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.
This commit is contained in:
2026-07-25 18:33:44 +02:00
parent e37173ea08
commit ad906a10f7

View File

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