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)) } // The rule #47 assertion. 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) } }