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>
230 lines
9.0 KiB
Go
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())
|
|
}
|
|
}
|