This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user