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()) } }