store: add ClaimLegacyOwner one-time upgrade bootstrap
Binds pre-multi-tenancy singleton rows to one named OIDC subject, given at startup via an env var (wired in Task 11). No-ops once any user exists or on a genuinely fresh install.
This commit is contained in:
59
backend/internal/store/legacy_claim.go
Normal file
59
backend/internal/store/legacy_claim.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClaimLegacyOwner is a one-time upgrade step, not a normal runtime
|
||||||
|
// operation: it binds every pre-multi-tenancy row (user_id IS NULL, left
|
||||||
|
// that way by migrations that can't take runtime parameters -- see
|
||||||
|
// docs/superpowers/specs/2026-07-25-per-user-profile-design.md) to a single
|
||||||
|
// new user identified by oidcSub. Safe to call on every startup: once the
|
||||||
|
// users table is non-empty, it's a no-op, so leaving
|
||||||
|
// GENIUSRUN_LEGACY_OWNER_OIDC_SUB set after the first successful run causes
|
||||||
|
// no harm. Also a no-op on a genuinely fresh install (no legacy profile row
|
||||||
|
// to claim at all).
|
||||||
|
func (db *DB) ClaimLegacyOwner(ctx context.Context, oidcSub string) error {
|
||||||
|
var userCount int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||||||
|
return fmt.Errorf("count users: %w", err)
|
||||||
|
}
|
||||||
|
if userCount > 0 {
|
||||||
|
return nil // already bootstrapped (either claimed already, or real signups exist)
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName string
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT name FROM profile WHERE user_id IS NULL LIMIT 1`).Scan(&displayName)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil // fresh install, no pre-existing singleton profile to claim
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find legacy profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin claim legacy owner tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create legacy owner user: %w", err)
|
||||||
|
}
|
||||||
|
userID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// table is always one of the fixed literals below, never user input.
|
||||||
|
for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state", "sync_runs"} {
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET user_id = ? WHERE user_id IS NULL`, userID); err != nil {
|
||||||
|
return fmt.Errorf("claim legacy %s rows: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
109
backend/internal/store/legacy_claim_test.go
Normal file
109
backend/internal/store/legacy_claim_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClaimLegacyOwner_BindsExistingSingletonRowsToOneNewUser(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Simulate the pre-migration state: a fresh DB already has one
|
||||||
|
// migration-seeded profile row (id=1, user_id=NULL) and 8 workout_kinds
|
||||||
|
// rows (user_id=NULL) -- exactly what a real upgraded deployment looks
|
||||||
|
// like right after Task 1's migrations run, before any user exists.
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE profile SET name = 'Kriss', garmin_email = 'kriss@example.com' WHERE user_id IS NULL`); err != nil {
|
||||||
|
t.Fatalf("seed legacy profile: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `INSERT INTO activities (user_id, garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) VALUES (NULL, 1, '2026-01-01 00:00:00', 1800, 5000, '{}')`); err != nil {
|
||||||
|
t.Fatalf("seed legacy activity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
|
||||||
|
t.Fatalf("ClaimLegacyOwner: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, found, err := db.GetUserBySub(ctx, "kriss-sub")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if u.DisplayName != "Kriss" {
|
||||||
|
t.Errorf("DisplayName = %q, want %q (from the legacy profile's name column)", u.DisplayName, "Kriss")
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := db.GetProfile(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile(claimed user): %v", err)
|
||||||
|
}
|
||||||
|
if profile.GarminEmail != "kriss@example.com" {
|
||||||
|
t.Errorf("claimed profile GarminEmail = %q, want kriss@example.com", profile.GarminEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
kinds, err := db.ListWorkoutKinds(ctx, u.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWorkoutKinds(claimed user): %v", err)
|
||||||
|
}
|
||||||
|
if len(kinds) != 8 {
|
||||||
|
t.Fatalf("expected the 8 legacy workout_kinds rows to be claimed, got %d", len(kinds))
|
||||||
|
}
|
||||||
|
|
||||||
|
activities, err := db.ListActivities(ctx, u.ID, ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities(claimed user): %v", err)
|
||||||
|
}
|
||||||
|
if len(activities) != 1 {
|
||||||
|
t.Fatalf("expected the 1 legacy activity to be claimed, got %d", len(activities))
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingNullUserIDRows int
|
||||||
|
for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state"} {
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE user_id IS NULL`).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count NULL user_id in %s: %v", table, err)
|
||||||
|
}
|
||||||
|
remainingNullUserIDRows += n
|
||||||
|
}
|
||||||
|
if remainingNullUserIDRows != 0 {
|
||||||
|
t.Errorf("expected every legacy row to be claimed, %d rows still have NULL user_id", remainingNullUserIDRows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimLegacyOwner_NoOpOnceAUserAlreadyExists(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
firstUserID, err := db.ProvisionUser(ctx, "already-here", "Someone")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second call (simulating a later restart with the env var still set)
|
||||||
|
// must not create a second user or touch anything.
|
||||||
|
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
|
||||||
|
t.Fatalf("ClaimLegacyOwner (should be a no-op): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found {
|
||||||
|
t.Fatalf("expected no user created for kriss-sub, found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
users, err := db.ListUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUsers: %v", err)
|
||||||
|
}
|
||||||
|
if len(users) != 1 || users[0].ID != firstUserID {
|
||||||
|
t.Fatalf("expected exactly the 1 pre-existing user to remain, got %+v", users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimLegacyOwner_NoOpOnGenuinelyFreshInstall(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
|
||||||
|
t.Fatalf("ClaimLegacyOwner on fresh install: %v", err)
|
||||||
|
}
|
||||||
|
if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found {
|
||||||
|
t.Fatalf("expected no user created on a fresh install with no legacy profile, found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user