Files
minstrel/internal/api/me_sessions_test.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

227 lines
7.4 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// seedSession inserts a session for userID and returns its id.
func seedSession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, ip string) pgtype.UUID {
t.Helper()
token, err := auth.MintSessionToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
sess, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
UserID: userID,
TokenHash: auth.HashSessionToken(token),
UserAgent: "test-agent",
Ip: ip,
})
if err != nil {
t.Fatalf("insert session: %v", err)
}
return sess.ID
}
// withSession attaches the user and current-session id the handlers expect
// from RequireUser.
func withSession(r *http.Request, user dbq.User, sessionID pgtype.UUID) *http.Request {
ctx := context.WithValue(r.Context(), userCtxKeyForTest(), user)
ctx = context.WithValue(ctx, auth.SessionIDCtxKeyForTest(), sessionID)
return r.WithContext(ctx)
}
// withURLParam wires a chi route param, which handlers read via chi.URLParam.
func withURLParam(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// Household data stays per-user: a delete keyed only on session id would let
// any household member revoke any other member's session by id — this pins that
// the user scope is actually in the WHERE clause and not just intended.
func TestRevokeMySession_CannotRevokeAnotherUsersSession(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "alice", "hunter2", false)
bob := seedUser(t, pool, "bob", "hunter2", false)
bobSession := seedSession(t, pool, bob.ID, "203.0.113.9")
aliceSession := seedSession(t, pool, alice.ID, "203.0.113.1")
target := uuidToString(bobSession)
req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil)
req = withURLParam(req, "id", target)
req = withSession(req, alice, aliceSession)
w := httptest.NewRecorder()
h.handleRevokeMySession(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (not another user's to revoke)", w.Code)
}
// The 404 must mean "didn't happen", not merely "wasn't reported".
var stillThere bool
if err := pool.QueryRow(context.Background(),
`SELECT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, bobSession,
).Scan(&stillThere); err != nil {
t.Fatalf("exists check: %v", err)
}
if !stillThere {
t.Error("bob's session was deleted by alice's request")
}
}
func TestRevokeMySession_DeletesOwnSession(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "alice", "hunter2", false)
current := seedSession(t, pool, alice.ID, "203.0.113.1")
other := seedSession(t, pool, alice.ID, "198.51.100.7")
target := uuidToString(other)
req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil)
req = withURLParam(req, "id", target)
req = withSession(req, alice, current)
w := httptest.NewRecorder()
h.handleRevokeMySession(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", w.Code)
}
var gone bool
if err := pool.QueryRow(context.Background(),
`SELECT NOT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, other,
).Scan(&gone); err != nil {
t.Fatalf("exists check: %v", err)
}
if !gone {
t.Error("session survived its own owner's revoke")
}
}
// "Log out everywhere else" must spare the caller — otherwise the button
// signs you out of the page you pressed it on, which is indistinguishable
// from the compromise it's meant to remedy.
func TestRevokeMyOtherSessions_SparesCurrentAndOtherUsers(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "alice", "hunter2", false)
bob := seedUser(t, pool, "bob", "hunter2", false)
current := seedSession(t, pool, alice.ID, "203.0.113.1")
seedSession(t, pool, alice.ID, "198.51.100.7")
seedSession(t, pool, alice.ID, "198.51.100.8")
bobSession := seedSession(t, pool, bob.ID, "203.0.113.9")
req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil)
req = withSession(req, alice, current)
w := httptest.NewRecorder()
h.handleRevokeMyOtherSessions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var body revokedResp
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Revoked != 2 {
t.Errorf("revoked = %d, want 2 (alice's other two, not bob's)", body.Revoked)
}
var aliceRemaining, bobRemaining int
if err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID,
).Scan(&aliceRemaining); err != nil {
t.Fatalf("count alice: %v", err)
}
if aliceRemaining != 1 {
t.Errorf("alice sessions = %d, want 1 (the current one)", aliceRemaining)
}
if err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM sessions WHERE id = $1`, bobSession,
).Scan(&bobRemaining); err != nil {
t.Fatalf("count bob: %v", err)
}
if bobRemaining != 1 {
t.Error("bob's session was caught in alice's logout-others")
}
}
// Without a current-session id the exclusion has nothing to exclude, so the
// handler must refuse rather than delete everything.
func TestRevokeMyOtherSessions_RefusesWithoutCurrentSession(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "alice", "hunter2", false)
seedSession(t, pool, alice.ID, "203.0.113.1")
req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil)
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), alice))
w := httptest.NewRecorder()
h.handleRevokeMyOtherSessions(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", w.Code)
}
var remaining int
if err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID,
).Scan(&remaining); err != nil {
t.Fatalf("count: %v", err)
}
if remaining != 1 {
t.Errorf("sessions = %d, want 1 — refusing must not delete", remaining)
}
}
func TestListMySessions_FlagsCurrentAndScopesToUser(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "alice", "hunter2", false)
bob := seedUser(t, pool, "bob", "hunter2", false)
current := seedSession(t, pool, alice.ID, "203.0.113.1")
seedSession(t, pool, alice.ID, "198.51.100.7")
seedSession(t, pool, bob.ID, "203.0.113.9")
req := httptest.NewRequest(http.MethodGet, "/api/me/sessions", nil)
req = withSession(req, alice, current)
w := httptest.NewRecorder()
h.handleListMySessions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var got []sessionResp
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 2 {
t.Fatalf("sessions = %d, want 2 (bob's must not appear)", len(got))
}
currentCount := 0
for _, s := range got {
if s.Current {
currentCount++
if s.ID != uuidToString(current) {
t.Errorf("current flagged on %s, want %s", s.ID, uuidToString(current))
}
}
if s.CreatedIP == "" {
t.Error("created_ip empty — the whole point of the surface")
}
}
if currentCount != 1 {
t.Errorf("current-flagged rows = %d, want exactly 1", currentCount)
}
}