From ab8cdea2141beb6a86f9d8983274173a5e527826 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 26 Jul 2026 10:19:00 +0200 Subject: [PATCH] feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown --- backend/internal/api/api_test.go | 80 ++++++++++++++++++++++++++ backend/internal/api/isolation_test.go | 28 +++++++++ backend/internal/api/profile.go | 26 +++++++++ backend/internal/api/server.go | 32 +++++++++++ 4 files changed, 166 insertions(+) diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index cabf6ca..e8b70b6 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "path/filepath" "strconv" "testing" @@ -764,6 +765,85 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) { } } +func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) { + s, db, userID := newTestServer(t) + router := s.Router() + + // Force the per-user Garmin client to be built and cached. + rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) + if rec.Code != http.StatusOK { + t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) + } + client, err := s.garminFor(context.Background(), userID) + if err != nil { + t.Fatalf("garminFor: %v", err) + } + mockClient, ok := client.(*mock.Client) + if !ok { + t.Fatalf("expected *mock.Client, got %T", client) + } + + rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String()) + } + + if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found { + t.Fatalf("expected user gone after delete, found=%v err=%v", found, err) + } + if !mockClient.ClosedCalled { + t.Error("expected the cached garmin client to be Close()d on profile deletion") + } +} + +func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) { + s, db, userID := newTestServer(t) + s.mu.Lock() + s.userSyncRunning[userID] = true + s.mu.Unlock() + + rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } + if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found { + t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err) + } +} + +func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) { + db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + tokenStoreRoot := t.TempDir() + userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10)) + if err := os.MkdirAll(userTokenDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + m := &mock.Client{} + garminFactory := func(garmin.Config) garmin.Client { return m } + s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + + rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) { + t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err) + } +} + func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) { t.Helper() s, db, _ := newTestServer(t) diff --git a/backend/internal/api/isolation_test.go b/backend/internal/api/isolation_test.go index 25704eb..f25e4e6 100644 --- a/backend/internal/api/isolation_test.go +++ b/backend/internal/api/isolation_test.go @@ -153,3 +153,31 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing. 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()) + } +} diff --git a/backend/internal/api/profile.go b/backend/internal/api/profile.go index 6b477ba..819a306 100644 --- a/backend/internal/api/profile.go +++ b/backend/internal/api/profile.go @@ -75,3 +75,29 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, updated) } + +// handleDeleteProfile permanently deletes the signed-in user's entire +// geniusrun account (profile, workout kinds/paces, activities and +// everything under them, sync state/runs -- see schema.sql's ON DELETE +// CASCADE from users(id)) and tears down their cached Garmin client and +// token-store directory. It does not touch the session cookie itself -- +// the frontend follows a successful call with a real logout navigation +// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md). +func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + + s.mu.Lock() + inProgress := s.userSyncRunning[userID] + s.mu.Unlock() + if inProgress { + writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile") + return + } + + if err := s.DB.DeleteUser(r.Context(), userID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.removeGarminClient(userID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 9798bf4..4bd723d 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "net/http" + "os" "path/filepath" "strconv" "sync" @@ -116,6 +117,36 @@ func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, e return svc, nil } +// removeGarminClient drops userID's cached garmin.Client/sync.Service (if +// any) and every other per-user in-memory entry for userID, terminating the +// client's subprocess and best-effort removing its on-disk token-store +// directory. Called when a user's account has just been deleted from the +// DB, so nothing in memory keeps referencing a userID that no longer +// exists. +func (s *Server) removeGarminClient(userID int64) { + s.mu.Lock() + client, ok := s.userGarmin[userID] + delete(s.userGarmin, userID) + delete(s.userSync, userID) + delete(s.userAuthStatus, userID) + delete(s.userAuthMessage, userID) + delete(s.userSyncRunning, userID) + s.mu.Unlock() + + if ok { + if err := client.Close(); err != nil { + log.Printf("api: close garmin client for deleted user %d: %v", userID, err) + } + } + if s.GarminBase.TokenStorePath == "" { + return + } + tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10)) + if err := os.RemoveAll(tokenStoreDir); err != nil { + log.Printf("api: remove token store dir for deleted user %d: %v", userID, err) + } +} + // RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync // every provisioned user in turn, replacing the old single-global-Service // background loop. @@ -167,6 +198,7 @@ func (s *Server) Router() http.Handler { r.Route("/profile", func(r chi.Router) { r.Get("/", s.handleGetProfile) r.Put("/", s.handleUpdateProfile) + r.Delete("/", s.handleDeleteProfile) }) r.Route("/auth", func(r chi.Router) {