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/store" ) // 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_AssignCannotTargetOtherUsersActivity(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) } kindsB, err := db.ListWorkoutKinds(newCtx(), userB, false) if err != nil || len(kindsB) == 0 { t.Fatalf("ListWorkoutKinds(b): len=%d err=%v", len(kindsB), err) } kindB := kindsB[0] if kindB.Name == "Race" { // Race is rejected before the ownership check kindB = kindsB[1] } // userB, given userA's real activity id (and a kind userB legitimately // owns), must not be able to write an assignment onto it. rec := doJSONAs(t, s.Router(), "user-b", http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindB.ID}) if rec.Code == http.StatusOK { t.Fatalf("userB assigning userA's activity succeeded (status %d), body = %s", rec.Code, rec.Body.String()) } if _, ok, err := db.CurrentAssignment(newCtx(), userA.ID, activityID); err != nil { t.Fatalf("CurrentAssignment: %v", err) } else if ok { t.Fatalf("userB's rejected assign still created an assignment on userA's activity") } } 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_ActivitiesListOnlyShowsOwnActivities(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/activities/", 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 activities list 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 := &garmin.MockClient{} s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &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/garmin/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()) } }