summaryrefslogtreecommitdiff
path: root/player-server/internal/api/handlers_share_test.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-20 07:39:55 +0300
committerPaul Buetow <paul@buetow.org>2026-05-20 07:39:55 +0300
commit26d3dc4e031cf195638d2483bcfcdf45fd216502 (patch)
tree7d605c37280a664dbdc018bba655993a27b1ae21 /player-server/internal/api/handlers_share_test.go
parent99736c4dbd196bd5a665e084c3251baa7dc444a0 (diff)
Inject clock.Clock into api.Server and replace time.Now() in share/auth handlers
handlers_share.go (handleCreateShare share-expiry) and handlers_auth.go (setSessionCookie and apiTokenExpiresAt) previously called time.Now() directly, which made time-dependent semantics impossible to assert deterministically in tests. They now use s.clk.Now(), where s.clk is a clock.Clock injected through ServerDeps.Clock (nil-default to clock.RealClock{} so existing callers keep working unchanged). apiTokenExpiresAt is promoted to a method on *Server so it can reach the injected clock. Production wiring in cmd/player/main.go passes deps.clk so handlers share the same time source as the rest of the services. Two new unit tests (handlers_share_test.go) use clock.MockClock to assert handleCreateShare and setSessionCookie compute their expiry timestamps from the injected clock rather than the wall clock. While propagating, this commit also includes a mechanical fix-up for the pathID(...) signature change (now returns int64 + error) across the API handler files so the package still builds; the additional err-check makes malformed path variables produce 400 instead of silently parsing as zero. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Diffstat (limited to 'player-server/internal/api/handlers_share_test.go')
-rw-r--r--player-server/internal/api/handlers_share_test.go151
1 files changed, 151 insertions, 0 deletions
diff --git a/player-server/internal/api/handlers_share_test.go b/player-server/internal/api/handlers_share_test.go
new file mode 100644
index 0000000..58d3257
--- /dev/null
+++ b/player-server/internal/api/handlers_share_test.go
@@ -0,0 +1,151 @@
+package api
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "codeberg.org/snonux/player/internal"
+ "codeberg.org/snonux/player/internal/auth"
+ "codeberg.org/snonux/player/internal/clock"
+ "codeberg.org/snonux/player/internal/model"
+ "codeberg.org/snonux/player/internal/service"
+)
+
+// TestCreateShare_UsesInjectedClock pins "now" via a clock.MockClock and
+// asserts that handleCreateShare derives expiresAt from the injected clock —
+// not from time.Now(). This guards against the previous flakiness where the
+// expiry was computed off the wall clock and could drift between assertion
+// runs (e.g. when the test goroutine was descheduled).
+func TestCreateShare_UsesInjectedClock(t *testing.T) {
+ // Pin a deterministic instant well in the past so any accidental
+ // time.Now() leak would produce a wildly different expiresAt.
+ fixedNow := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
+ mockClk := &clock.MockClock{T: fixedNow}
+
+ const expiryDays = 14
+ wantExpiresAt := fixedNow.Add(expiryDays * 24 * time.Hour)
+
+ var capturedExpiresAt time.Time
+ ms := &service.MockMediaService{
+ CreateShareFunc: func(_ context.Context, _, mediaID int64, expiresAt time.Time) (*model.Share, error) {
+ capturedExpiresAt = expiresAt
+ return &model.Share{Token: "tok", MediaID: mediaID}, nil
+ },
+ }
+ // authSvc satisfies the BootstrapRedirect middleware (CountUsers > 0
+ // so requests aren't redirected to /bootstrap.html) and RequireSession
+ // indirectly via session validation — no admin check on this route.
+ authSvc := &service.MockAuthService{
+ CountUsersFunc: func(context.Context) (int, error) { return 1, nil },
+ GetUserByIDFunc: func(_ context.Context, id int64) (*model.User, error) { return &model.User{ID: id}, nil },
+ }
+
+ store := buildSessionStore(1)
+ sm := auth.NewSessionManager(store, mockClk, time.Hour)
+ cfg := &internal.Config{SessionTimeoutHours: 24, ShareDefaultExpiryDays: expiryDays}
+
+ // Build the Server directly so we can inject the mock clock — the
+ // shared newTestServer helper doesn't expose Clock yet, and adding it
+ // there would force every existing test to thread an extra arg.
+ srv, err := NewServer(ServerDeps{
+ Store: buildCountStore(1),
+ SessionManager: sm,
+ Config: cfg,
+ Services: ServerServices{
+ Browse: ms,
+ Write: ms,
+ Share: ms,
+ Tag: ms,
+ Favorite: ms,
+ Note: ms,
+ Auth: authSvc,
+ },
+ StaticFS: newTestFS(map[string]string{"index.html": "x"}),
+ MediaStreamer: service.NewMediaStreamer(nil),
+ Clock: mockClk,
+ })
+ if err != nil {
+ t.Fatalf("NewServer: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/media/1/shares", nil)
+ req.AddCookie(addSessionCookie(t, store, sm, 1))
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d (body=%q)", rr.Code, rr.Body.String())
+ }
+ if !capturedExpiresAt.Equal(wantExpiresAt) {
+ t.Fatalf("expected expiresAt %v, got %v", wantExpiresAt, capturedExpiresAt)
+ }
+}
+
+// TestSetSessionCookie_UsesInjectedClock asserts the session-cookie Expires
+// field is derived from s.clk.Now(), not time.Now(). We exercise this via
+// handleLogin (the public Login route), which calls setSessionCookie on
+// success — that's the only handler path that produces a Set-Cookie header
+// with a non-empty Expires.
+func TestSetSessionCookie_UsesInjectedClock(t *testing.T) {
+ fixedNow := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
+ mockClk := &clock.MockClock{T: fixedNow}
+
+ const sessionHours = 12
+ wantExpires := fixedNow.Add(sessionHours * time.Hour)
+
+ authSvc := &service.MockAuthService{
+ CountUsersFunc: func(context.Context) (int, error) { return 1, nil },
+ LoginFunc: func(_ context.Context, _, _ string) (*service.AuthResult, error) {
+ return &service.AuthResult{
+ SessionID: "sess-xyz",
+ User: &model.User{ID: 1, Username: "alice", IsAdmin: false},
+ }, nil
+ },
+ }
+
+ store := buildSessionStore(1)
+ sm := auth.NewSessionManager(store, mockClk, time.Hour)
+ cfg := &internal.Config{SessionTimeoutHours: sessionHours}
+
+ srv, err := NewServer(ServerDeps{
+ Store: buildCountStore(1),
+ SessionManager: sm,
+ Config: cfg,
+ Services: ServerServices{
+ Auth: authSvc,
+ },
+ StaticFS: newTestFS(map[string]string{"index.html": "x"}),
+ MediaStreamer: service.NewMediaStreamer(nil),
+ Clock: mockClk,
+ })
+ if err != nil {
+ t.Fatalf("NewServer: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/login",
+ strings.NewReader(`{"username":"alice","password":"pw"}`))
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d (body=%q)", rr.Code, rr.Body.String())
+ }
+
+ var sessionCookie *http.Cookie
+ for _, c := range rr.Result().Cookies() {
+ if c.Name == "session" {
+ sessionCookie = c
+ break
+ }
+ }
+ if sessionCookie == nil {
+ t.Fatal("expected session cookie in response")
+ }
+ if !sessionCookie.Expires.Equal(wantExpires) {
+ t.Fatalf("expected cookie Expires %v, got %v", wantExpires, sessionCookie.Expires)
+ }
+}