184 lines
7.0 KiB
Go
184 lines
7.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/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())
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|