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>
227 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|