// SPDX-License-Identifier: MPL-2.0 package authsqlite import ( "crypto/sha256" "errors" "path/filepath" "sync" "testing" "time" "gamertan.com/web/auth" ) type profileFixture struct { store *Store path string now time.Time user auth.User session, other auth.Session } func newProfileFixture(t *testing.T) profileFixture { t.Helper() path := filepath.Join(t.TempDir(), "identity.sqlite") store, err := Open(path) if err != nil { t.Fatal(err) } t.Cleanup(func() { store.Close() }) now := time.Now().UTC().Truncate(time.Second) user := auth.User{ID: "profile-user", Username: "profile.reader", Email: "profile@example.test", DisplayName: "Profile Reader", Status: "active", CreatedAt: now, UpdatedAt: now} if err = store.CreateUser(t.Context(), user, "fixture-hash"); err != nil { t.Fatal(err) } session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-session")), CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour)} other := session other.Digest = sha256.Sum256([]byte("other-session")) for _, s := range []auth.Session{session, other} { if err = store.CreateSession(t.Context(), s); err != nil { t.Fatal(err) } } return profileFixture{store, path, now, user, session, other} } func (f profileFixture) change(field, value string, revision int64) (auth.ProfileEdit, auth.AuditEvent) { return auth.ProfileEdit{UserID: f.user.ID, SessionDigest: f.session.Digest, ExpectedRevision: revision, Field: field, Value: value}, auth.AuditEvent{ID: "profile-audit-" + field, ActorUserID: f.user.ID, Action: "auth.profile." + field, ResourceType: "user", ResourceID: f.user.ID, Summary: "Own profile field changed", CreatedAt: f.now} } func TestOwnProfileStableIdentityAndSessionPolicy(t *testing.T) { f := newProfileFixture(t) initial, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now) if err != nil || initial.Revision != 1 { t.Fatalf("initial revision: %d %v", initial.Revision, err) } change, audit := f.change("display_name", " Émilie ★ ", 1) updated, err := f.store.UpdateOwnProfile(t.Context(), change, audit) if err != nil || updated.DisplayName != "Émilie ★" || updated.Revision != 2 || updated.UserID != initial.UserID || updated.Username != initial.Username || updated.Email != initial.Email { t.Fatalf("display update: %+v %v", updated, err) } if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil { t.Fatal("display edit revoked session", err) } if _, err = f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) { t.Fatalf("stale: %v", err) } change, audit = f.change("username", "new.reader", 2) change.ExpectedPasswordHash = "fixture-hash" updated, err = f.store.UpdateOwnProfile(t.Context(), change, audit) if err != nil || updated.Username != "new.reader" || updated.Revision != 3 || updated.UserID != initial.UserID || updated.Email != initial.Email { t.Fatalf("username update: %+v %v", updated, err) } if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); !errors.Is(err, auth.ErrProfileAccess) { t.Fatalf("other session survived: %v", err) } user, hash, err := f.store.CredentialByIdentifier(t.Context(), "NEW.READER") if err != nil || user.ID != initial.UserID || hash != "fixture-hash" { t.Fatal("credential identity changed", err) } var count int if err = f.store.db.QueryRow(`SELECT count(*) FROM gwf_audit_events WHERE actor_user_id=? AND resource_id=?`, f.user.ID, f.user.ID).Scan(&count); err != nil || count != 2 { t.Fatalf("audits: %d %v", count, err) } reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false}) if err != nil { t.Fatal(err) } defer reopened.Close() if recovered, err := reopened.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || recovered != updated { t.Fatalf("restart: %+v %v", recovered, err) } } func TestOwnProfileAuthorizationAndRollback(t *testing.T) { for _, test := range []struct{ name, sql string }{ {"revoked-session", `DELETE FROM gwf_auth_sessions`}, {"expired-session", `UPDATE gwf_auth_sessions SET expires_at=1`}, {"suspended", `UPDATE gwf_users SET status='suspended'`}, {"disabled", `UPDATE gwf_users SET status='disabled'`}, {"registration-pending", `UPDATE gwf_users SET registration_pending=1`}, {"password-change", `UPDATE gwf_users SET password_change_required=1`}, } { t.Run(test.name, func(t *testing.T) { f := newProfileFixture(t) if _, err := f.store.db.Exec(test.sql); err != nil { t.Fatal(err) } change, audit := f.change("display_name", "not allowed", 1) if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) { t.Fatalf("access: %v", err) } }) } f := newProfileFixture(t) change, audit := f.change("username", "new.reader", 1) change.UserID = "another-user" audit.ActorUserID = change.UserID audit.ResourceID = change.UserID if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) { t.Fatalf("foreign user: %v", err) } change, audit = f.change("username", "new.reader", 1) change.ExpectedPasswordHash = "old-verified-hash" if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) { t.Fatalf("changed password: %v", err) } change.ExpectedPasswordHash = "fixture-hash" if err := f.store.AppendAudit(t.Context(), audit); err != nil { t.Fatal(err) } if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); err == nil { t.Fatal("duplicate audit accepted") } if profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || profile.Revision != 1 || profile.Username != f.user.Username { t.Fatalf("rollback: %+v %v", profile, err) } if _, err := f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil { t.Fatal("audit failure revoked session", err) } } func TestOwnProfileUniquenessConcurrencyAndMigration(t *testing.T) { f := newProfileFixture(t) otherUser := f.user otherUser.ID = "another-user" otherUser.Username = "another.reader" otherUser.Email = "another@example.test" if err := f.store.CreateUser(t.Context(), otherUser, "fixture-hash"); err != nil { t.Fatal(err) } change, audit := f.change("username", "ANOTHER.READER", 1) if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrUsernameUnavailable) { t.Fatalf("unique name: %v", err) } second, err := OpenWithOptions(f.path, OpenOptions{Migrate: false}) if err != nil { t.Fatal(err) } defer second.Close() var wg sync.WaitGroup results := make(chan error, 2) for _, store := range []*Store{f.store, second} { wg.Add(1) go func(store *Store) { defer wg.Done() change, audit := f.change("display_name", "New Name", 1) _, err := store.UpdateOwnProfile(t.Context(), change, audit) results <- err }(store) } wg.Wait() close(results) success, conflict := 0, 0 for err := range results { if err == nil { success++ } else if errors.Is(err, auth.ErrProfileConflict) { conflict++ } else { t.Fatal(err) } } if success != 1 || conflict != 1 { t.Fatalf("concurrent writes: %d successes, %d conflicts", success, conflict) } // Recreate the actual previous schema without rewriting its identity rows. if _, err = f.store.db.Exec(`ALTER TABLE gwf_users DROP COLUMN profile_revision`); err != nil { t.Fatal(err) } if _, err = f.store.db.Exec(`DELETE FROM gamertan_web_migrations WHERE version=11`); err != nil { t.Fatal(err) } if version, err := f.store.CurrentSchema(t.Context()); err != nil || version != 10 { t.Fatalf("prior schema: %d %v", version, err) } if err = f.store.RequireCurrentSchema(t.Context()); err == nil { t.Fatal("startup accepted old schema") } if err = f.store.Migrate(t.Context()); err != nil { t.Fatal(err) } profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now) if err != nil || profile.UserID != f.user.ID || profile.Email != f.user.Email || profile.DisplayName != "New Name" || profile.Revision != 1 { t.Fatalf("migration: %+v %v", profile, err) } if err = f.store.Migrate(t.Context()); err != nil { t.Fatal("idempotent migration", err) } }