213 lines
8.1 KiB
Go
213 lines
8.1 KiB
Go
// 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)
|
|
}
|
|
}
|