diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-20 07:39:55 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-20 07:39:55 +0300 |
| commit | 26d3dc4e031cf195638d2483bcfcdf45fd216502 (patch) | |
| tree | 7d605c37280a664dbdc018bba655993a27b1ae21 /player-server/internal/api/handlers_share_test.go | |
| parent | 99736c4dbd196bd5a665e084c3251baa7dc444a0 (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.go | 151 |
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) + } +} |
