Files
geniusrun/backend/internal/api/isolation_test.go

227 lines
8.7 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
// doJSONAs is doJSON but for an explicit session Sub, for tests that need
// two distinct logged-in users against the same server (doJSON itself
// always mints a cookie for Sub: "test-user", the identity newTestServer
// pre-provisions).
func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body: %v", err)
}
reader = bytes.NewReader(b)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json")
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil {
t.Fatalf("mint test session cookie: %v", err)
}
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
userA, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found {
t.Fatalf("GetUserBySub(test-user): found=%v err=%v", found, err)
}
activityID, err := db.UpsertActivity(newCtx(), userA.ID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
_ = userB
router := s.Router()
// userA (the default doJSON identity) can see it.
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil)
if rec.Code != http.StatusOK {
t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String())
}
// userB, given the exact same activity id, gets 404 -- not another
// user's data, and not a 500 that would leak existence either way.
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String())
}
}
func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
userA, _, _ := db.GetUserBySub(newCtx(), "test-user")
kindsA, err := db.ListWorkoutKinds(newCtx(), userA.ID, false)
if err != nil || len(kindsA) == 0 {
t.Fatalf("ListWorkoutKinds(a): len=%d err=%v", len(kindsA), err)
}
targetID := kindsA[0].ID
originalName := kindsA[0].Name
router := s.Router()
rec := doJSONAs(t, router, "user-b", http.MethodPut, "/api/workout-kinds/"+itoa(targetID), map[string]any{
"name": "Hijacked",
"rule": json.RawMessage(`{"match":"all","conditions":[]}`),
})
if rec.Code != http.StatusNotFound {
t.Fatalf("userB updating userA's kind status = %d, want 404, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(targetID), nil)
var got workoutKindResponse
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Name != originalName {
t.Fatalf("userA's kind name changed to %q despite userB's update being rejected", got.Name)
}
}
func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if _, err := db.UpsertActivity(newCtx(), userB, store.Activity{GarminActivityID: 42, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity(b): %v", err)
}
if _, err := db.InsertKindAssignment(newCtx(), userB, store.KindAssignment{
ActivityID: func() int64 {
acts, _ := db.ListActivities(newCtx(), userB, store.ActivityFilter{})
return acts[0].ID
}(),
AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment(b): %v", err)
}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var page struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
}
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Total != 0 || len(page.Items) != 0 {
t.Fatalf("expected userA's review queue to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
}
}
func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.T) {
db, err := store.Open(t.TempDir() + "/isolation_test.db")
if err != nil {
t.Fatalf("store.Open: %v", err)
}
defer db.Close()
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String())
}
}
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
// deleting their own profile never touches another user's account, even
// though DeleteUser is keyed purely by the session-resolved userID.
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.ID != userA {
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
}
}
// TestIsolation_GarminConnectedNeverLeaksAcrossUsers confirms one user's
// successful Garmin auth never flips garmin_connected for another user.
func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/session/me", nil)
var meB sessionMeResponse
unmarshalBody(t, rec, &meB)
if meB.GarminConnected {
t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag")
}
}
// TestIsolation_SetupSessionsNeverLeakAcrossSubjects confirms one OIDC
// subject's pending ephemeral Garmin session is invisible to another
// subject -- e.g. subject B completing MFA must not accidentally continue
// subject A's in-progress attempt.
func TestIsolation_SetupSessionsNeverLeakAcrossSubjects(t *testing.T) {
s, _, _ := newUnprovisionedServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "a@example.com", "garmin_password": "pw-a",
})
if rec.Code != http.StatusOK {
t.Fatalf("login(a) status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSONAs(t, router, "user-b", http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "000000"})
if rec.Code != http.StatusConflict {
t.Fatalf("user-b mfa (no login attempt of their own) status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}