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:
155
backend/internal/api/isolation_test.go
Normal file
155
backend/internal/api/isolation_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user