Files
geniusrun/backend/internal/api/isolation_test.go
Christophe Vila 8c9285f33c fix: IDEAS.md quickfixes — idle-timeout app config, id_token out of Claims, FormEvent import
session.idle_timeout (minutes, default 15) joins the app-config registry
and drives the onboarding Garmin session eviction, distinct from
session.duration (the login cookie lifetime in hours). The raw Keycloak
ID token no longer rides in auth.Claims through every request context:
it's minted into the session cookie separately and read back only by the
logout handler via IDTokenFromSessionCookie. OnboardingWizard uses the
type-imported FormEvent<HTMLFormElement> instead of the React.FormEvent
namespace alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:44:37 +02:00

230 lines
9.0 KiB
Go

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/store"
)
// 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_AssignCannotTargetOtherUsersActivity(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)
}
kindsB, err := db.ListWorkoutKinds(newCtx(), userB, false)
if err != nil || len(kindsB) == 0 {
t.Fatalf("ListWorkoutKinds(b): len=%d err=%v", len(kindsB), err)
}
kindB := kindsB[0]
if kindB.Name == "Race" { // Race is rejected before the ownership check
kindB = kindsB[1]
}
// userB, given userA's real activity id (and a kind userB legitimately
// owns), must not be able to write an assignment onto it.
rec := doJSONAs(t, s.Router(), "user-b", http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindB.ID})
if rec.Code == http.StatusOK {
t.Fatalf("userB assigning userA's activity succeeded (status %d), body = %s", rec.Code, rec.Body.String())
}
if _, ok, err := db.CurrentAssignment(newCtx(), userA.ID, activityID); err != nil {
t.Fatalf("CurrentAssignment: %v", err)
} else if ok {
t.Fatalf("userB's rejected assign still created an assignment on userA's activity")
}
}
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_ActivitiesListOnlyShowsOwnActivities(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/activities/", 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 activities list 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 := &garmin.MockClient{}
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &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())
}
}
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
// deleting their own profile never touches another user's account, even
// though DeleteUser is keyed purely by the session-resolved userID.
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.ID != userA {
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
}
}
// TestIsolation_GarminConnectedNeverLeaksAcrossUsers confirms one user's
// successful Garmin auth never flips garmin_connected for another user.
func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(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)
}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/session/me", nil)
var meB sessionMeResponse
unmarshalBody(t, rec, &meB)
if meB.GarminConnected {
t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag")
}
}
// TestIsolation_SetupSessionsNeverLeakAcrossSubjects confirms one OIDC
// subject's pending ephemeral Garmin session is invisible to another
// subject -- e.g. subject B completing MFA must not accidentally continue
// subject A's in-progress attempt.
func TestIsolation_SetupSessionsNeverLeakAcrossSubjects(t *testing.T) {
s, _, _ := newUnprovisionedServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "a@example.com", "garmin_password": "pw-a",
})
if rec.Code != http.StatusOK {
t.Fatalf("login(a) status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSONAs(t, router, "user-b", http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "000000"})
if rec.Code != http.StatusConflict {
t.Fatalf("user-b mfa (no login attempt of their own) status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}