From ad906a10f744e4b44f8076651a8837fb3ac8779e Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sat, 25 Jul 2026 18:33:44 +0200 Subject: [PATCH] api: add end-to-end cross-user isolation tests HTTP-level counterpart to the store-layer isolation tests: proves the full middleware+handler chain rejects/hides another user's activities, workout kinds, and review queue even when given that user's real row ids, and that an unprovisioned session is blocked from every data route. --- backend/internal/api/isolation_test.go | 155 +++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 backend/internal/api/isolation_test.go diff --git a/backend/internal/api/isolation_test.go b/backend/internal/api/isolation_test.go new file mode 100644 index 0000000..25704eb --- /dev/null +++ b/backend/internal/api/isolation_test.go @@ -0,0 +1,155 @@ +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()) + } +}