From 3a1333017dd7a8e797497a7a61c7c2601ed9ab5b Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sat, 25 Jul 2026 17:37:40 +0200 Subject: [PATCH] store: add cross-user isolation tests Dedicated adversarial coverage for the core security property: no store method can read or mutate another user's profile, workout kinds/paces, or sync state/runs, even when handed that other user's real row id. --- backend/internal/store/isolation_test.go | 163 +++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 backend/internal/store/isolation_test.go diff --git a/backend/internal/store/isolation_test.go b/backend/internal/store/isolation_test.go new file mode 100644 index 0000000..e714717 --- /dev/null +++ b/backend/internal/store/isolation_test.go @@ -0,0 +1,163 @@ +package store + +import ( + "context" + "testing" +) + +// TestIsolation_ProfileNeverLeaksAcrossUsers confirms GetProfile only ever +// returns the row matching the given userID, and that two users' profiles +// can diverge independently. +func TestIsolation_ProfileNeverLeaksAcrossUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + profileA, err := db.GetProfile(ctx, userA) + if err != nil { + t.Fatalf("GetProfile(a): %v", err) + } + profileA.GarminEmail = "a@example.com" + if err := db.UpdateProfile(ctx, userA, profileA); err != nil { + t.Fatalf("UpdateProfile(a): %v", err) + } + + profileB, err := db.GetProfile(ctx, userB) + if err != nil { + t.Fatalf("GetProfile(b): %v", err) + } + if profileB.GarminEmail == "a@example.com" { + t.Fatal("userB's profile picked up userA's GarminEmail update") + } + + // Attempting to update B's profile "as A" (i.e. calling UpdateProfile + // with userA but a struct that happens to describe B's desired state) + // only ever touches the row WHERE user_id = userA -- confirm B is + // unaffected by any such call. + if err := db.UpdateProfile(ctx, userA, Profile{GarminEmail: "still-a-only@example.com"}); err != nil { + t.Fatalf("UpdateProfile(a) second call: %v", err) + } + profileB2, err := db.GetProfile(ctx, userB) + if err != nil { + t.Fatalf("GetProfile(b) after A's update: %v", err) + } + if profileB2.GarminEmail == "still-a-only@example.com" { + t.Fatal("userA's UpdateProfile call leaked into userB's row") + } +} + +// TestIsolation_WorkoutKindsAndPacesNeverLeakAcrossUsers confirms +// GetWorkoutKind/GetWorkoutTypePace return not-found for a kind id that +// exists but belongs to a different user, and that UpdateWorkoutKind / +// UpdateWorkoutTypePace can never mutate another user's row even if handed +// that row's real id. +func TestIsolation_WorkoutKindsAndPacesNeverLeakAcrossUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + kindsA, err := db.ListWorkoutKinds(ctx, userA, false) + if err != nil || len(kindsA) == 0 { + t.Fatalf("ListWorkoutKinds(a): len=%d err=%v", len(kindsA), err) + } + targetKindID := kindsA[0].ID + + if _, found, err := db.GetWorkoutKind(ctx, userB, targetKindID); err != nil || found { + t.Fatalf("expected userB not to see userA's kind %d, found=%v err=%v", targetKindID, found, err) + } + + // Attempt to update A's kind "as B" -- must silently affect zero rows, + // not A's real row. + if err := db.UpdateWorkoutKind(ctx, userB, WorkoutKind{ID: targetKindID, Name: "Hijacked", RuleJSON: "{}"}); err != nil { + t.Fatalf("UpdateWorkoutKind(as b): %v", err) + } + stillA, found, err := db.GetWorkoutKind(ctx, userA, targetKindID) + if err != nil || !found { + t.Fatalf("GetWorkoutKind(a) after B's attempted update: found=%v err=%v", found, err) + } + if stillA.Name == "Hijacked" { + t.Fatal("userB's UpdateWorkoutKind call was able to mutate userA's kind") + } + + if _, err := db.GetWorkoutTypePace(ctx, userB, targetKindID); err != nil { + t.Fatalf("GetWorkoutTypePace(as b) should return a zero-value pace, not an error, got: %v", err) + } + minPace := 300.0 + if err := db.UpdateWorkoutTypePace(ctx, userB, WorkoutTypePace{WorkoutKindID: targetKindID, PaceMinSecPerKm: &minPace}); err != nil { + t.Fatalf("UpdateWorkoutTypePace(as b): %v", err) + } + paceA, err := db.GetWorkoutTypePace(ctx, userA, targetKindID) + if err != nil { + t.Fatalf("GetWorkoutTypePace(a): %v", err) + } + if paceA.PaceMinSecPerKm != nil { + t.Fatal("userB's UpdateWorkoutTypePace call was able to mutate userA's pace row") + } +} + +// TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers confirms each user's +// backfill watermark and sync run history are independent. +func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + if err := db.UpdateSyncState(ctx, userA, "2020-01-01", true); err != nil { + t.Fatalf("UpdateSyncState(a): %v", err) + } + stateB, err := db.GetSyncState(ctx, userB) + if err != nil { + t.Fatalf("GetSyncState(b): %v", err) + } + if stateB.BackfillComplete || stateB.EarliestSyncedDate != nil { + t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB) + } + + runID, err := db.StartSyncRun(ctx, userA, SyncKindBackfill) + if err != nil { + t.Fatalf("StartSyncRun(a): %v", err) + } + runsB, err := db.ListSyncRuns(ctx, userB, 10) + if err != nil { + t.Fatalf("ListSyncRuns(b): %v", err) + } + if len(runsB) != 0 { + t.Fatalf("expected userB to have 0 sync runs, got %d (userA's run id=%d)", len(runsB), runID) + } + + // Finishing A's run "as B" must not succeed against A's row. + if err := db.FinishSyncRun(ctx, userB, runID, 5, nil); err != nil { + t.Fatalf("FinishSyncRun(as b): %v", err) + } + latestA, found, err := db.LatestSyncRun(ctx, userA) + if err != nil || !found { + t.Fatalf("LatestSyncRun(a): found=%v err=%v", found, err) + } + if latestA.Status == SyncStatusSuccess { + t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run") + } +}