textmachine/platform/internal/pgstore/identity_test.go

320 lines
11 KiB
Go

package pgstore
import (
"errors"
"fmt"
"reflect"
"sync"
"testing"
"time"
"textmachine/platform/internal/auth"
"textmachine/platform/internal/login"
"textmachine/platform/internal/money"
)
const signupGrant = 5 * money.PerUSD
// THE account-model test. Identity is (provider, subject); an address is a hint. A second identity
// arriving with an address that already belongs to someone must NOT land in that account — that is
// the takeover path, and no amount of email_verified makes it safe.
// Mutation caught: resolving the account by email, or adding a unique index on users.email.
func TestIdentityNeverJoinsAccountsByEmail(t *testing.T) {
s, ctx := testDB(t)
now := time.Now().UTC()
first, err := s.UpsertIdentity(ctx, login.Identity{
Provider: "google", Subject: "sub-A", Email: "reader@example.org", EmailVerified: true,
}, now, signupGrant)
if err != nil {
t.Fatal(err)
}
// Same address, different subject — a different person, or the same address handed on.
second, err := s.UpsertIdentity(ctx, login.Identity{
Provider: "google", Subject: "sub-B", Email: "reader@example.org", EmailVerified: true,
}, now, signupGrant)
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatal("two subjects with the same address were merged into one account: that is a takeover")
}
// And another provider carrying the same address is a third account, not an implicit link.
third, err := s.UpsertIdentity(ctx, login.Identity{
Provider: "github", Subject: "sub-A", Email: "reader@example.org", EmailVerified: true,
}, now, signupGrant)
if err != nil {
t.Fatal(err)
}
if third == first || third == second {
t.Fatal("an identity from another provider was linked by address alone")
}
}
// Signing in again is not signing up: the account, and its grant, are created exactly once.
func TestReturningIdentityKeepsItsAccountAndIsGrantedOnce(t *testing.T) {
s, ctx := testDB(t)
now := time.Now().UTC()
id := login.Identity{Provider: "google", Subject: "sub-A", Email: "reader@example.org", EmailVerified: true}
userID, err := s.UpsertIdentity(ctx, id, now, signupGrant)
if err != nil {
t.Fatal(err)
}
balance, err := s.Balance(ctx, userID)
if err != nil {
t.Fatal(err)
}
if balance != signupGrant {
t.Fatalf("a new account starts with %s, want %s", balance.USD(), money.MicroUSD(signupGrant).USD())
}
for range 3 {
again, err := s.UpsertIdentity(ctx, id, now.Add(time.Hour), signupGrant)
if err != nil {
t.Fatal(err)
}
if again != userID {
t.Fatalf("a returning identity got a new account: %s then %s", userID, again)
}
}
balance, err = s.Balance(ctx, userID)
if err != nil {
t.Fatal(err)
}
if balance != signupGrant {
t.Fatalf("signing in again granted more credit: %s", balance.USD())
}
}
// An unverified address is kept on the identity and never promoted to the account: the account's
// address is what a person is shown and what an operator searches by.
func TestUnverifiedAddressStaysOffTheAccount(t *testing.T) {
s, ctx := testDB(t)
now := time.Now().UTC()
userID, err := s.UpsertIdentity(ctx, login.Identity{
Provider: "google", Subject: "sub-A", Email: "unverified@example.org", EmailVerified: false,
}, now, 0)
if err != nil {
t.Fatal(err)
}
var accountEmail *string
if err := s.pool.QueryRow(ctx, `select email from users where id = $1`, userID).Scan(&accountEmail); err != nil {
t.Fatal(err)
}
if accountEmail != nil {
t.Fatalf("an unverified address reached the account: %q", *accountEmail)
}
var identityEmail *string
if err := s.pool.QueryRow(ctx,
`select email from identities where provider='google' and subject='sub-A'`).Scan(&identityEmail); err != nil {
t.Fatal(err)
}
if identityEmail == nil || *identityEmail != "unverified@example.org" {
t.Fatal("the identity should still remember the address it arrived with")
}
// Once the provider verifies it, the account picks it up.
if _, err := s.UpsertIdentity(ctx, login.Identity{
Provider: "google", Subject: "sub-A", Email: "unverified@example.org", EmailVerified: true,
}, now, 0); err != nil {
t.Fatal(err)
}
if err := s.pool.QueryRow(ctx, `select email from users where id = $1`, userID).Scan(&accountEmail); err != nil {
t.Fatal(err)
}
if accountEmail == nil || *accountEmail != "unverified@example.org" {
t.Fatal("a verified address should reach the account")
}
// PD-85: the RETURNING branch, which the two steps above never reach with an unverified address.
// Dropping the in.EmailVerified condition there used to pass the whole battery — and that is the
// branch every login after the first goes through, so a provider that later reports the address
// unverified, or reports a new one it has not checked, would overwrite the account's.
if _, err := s.UpsertIdentity(ctx, login.Identity{
Provider: "google", Subject: "sub-A", Email: "attacker@example.org", EmailVerified: false,
}, now, 0); err != nil {
t.Fatal(err)
}
if err := s.pool.QueryRow(ctx, `select email from users where id = $1`, userID).Scan(&accountEmail); err != nil {
t.Fatal(err)
}
if accountEmail == nil || *accountEmail != "unverified@example.org" {
t.Fatalf("an unverified address replaced the account's on a returning login: %v", accountEmail)
}
// The identity still records what actually arrived — the fact is kept, it is just not promoted.
if err := s.pool.QueryRow(ctx,
`select email from identities where provider='google' and subject='sub-A'`).Scan(&identityEmail); err != nil {
t.Fatal(err)
}
if identityEmail == nil || *identityEmail != "attacker@example.org" {
t.Fatalf("the identity did not record the address it was handed: %v", identityEmail)
}
}
// The state is single-use and it expires. Both are clauses of one statement, so a second callback
// racing the first cannot win: it deletes nothing.
// Mutation caught: splitting the DELETE ... RETURNING into a SELECT and a later DELETE.
func TestLoginStateIsSingleUseAndExpires(t *testing.T) {
s, ctx := testDB(t)
// Truncated to what timestamptz stores, so the round trip below can be compared whole.
now := time.Now().UTC().Truncate(time.Microsecond)
st := login.State{
Hash: auth.Digest("state-1"), Provider: "google", Issuer: "https://accounts.example.org",
Nonce: "n", Verifier: "v", ReturnTo: "/library", StartID: "REQ-ABC123",
CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute),
}
if err := s.PutLoginState(ctx, st); err != nil {
t.Fatal(err)
}
got, err := s.TakeLoginState(ctx, st.Hash, now)
if err != nil {
t.Fatal(err)
}
// The WHOLE struct, not the three fields someone remembered: StartID had been carried by
// login.State since P1 with no column behind it, so both `login_start_id` log lines were empty
// in production while the in-memory store used by the login tests showed them filled (PD-62).
// Comparing everything is what makes the next field added without a column fail here.
got.CreatedAt, got.ExpiresAt = got.CreatedAt.UTC(), got.ExpiresAt.UTC()
if !reflect.DeepEqual(got, st) {
t.Fatalf("the state did not survive the store whole:\n got %+v\n want %+v", got, st)
}
if _, err := s.TakeLoginState(ctx, st.Hash, now); !errors.Is(err, login.ErrNoState) {
t.Fatalf("a state must be usable once, got %v", err)
}
expired := login.State{Hash: auth.Digest("state-2"), Provider: "google", Nonce: "n", Verifier: "v",
CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
if err := s.PutLoginState(ctx, expired); err != nil {
t.Fatal(err)
}
if _, err := s.TakeLoginState(ctx, expired.Hash, now.Add(2*time.Minute)); !errors.Is(err, login.ErrNoState) {
t.Fatalf("an expired state must be refused, got %v", err)
}
n, err := s.DeleteExpiredLoginStates(ctx, now.Add(2*time.Minute))
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("sweep removed %d abandoned logins, want 1", n)
}
}
// "Sign out everywhere" has to reach sessions this request never saw — the property a self-verifying
// token cannot have, and the reason a session is a row.
func TestRevokeUserSessionsEndsAllOfThem(t *testing.T) {
s, ctx := testDB(t)
seedUser(t, s, ctx, "u1")
seedUser(t, s, ctx, "u2")
now := time.Now().UTC()
var mine [][]byte
for range 3 {
d := auth.Digest(auth.NewToken())
if err := s.CreateSession(ctx, d, "u1", now, time.Hour, 24*time.Hour); err != nil {
t.Fatal(err)
}
mine = append(mine, d)
}
other := auth.Digest(auth.NewToken())
if err := s.CreateSession(ctx, other, "u2", now, time.Hour, 24*time.Hour); err != nil {
t.Fatal(err)
}
n, err := s.RevokeUserSessions(ctx, "u1", now)
if err != nil {
t.Fatal(err)
}
if n != 3 {
t.Fatalf("revoked %d sessions, want 3", n)
}
for _, d := range mine {
if _, err := s.Lookup(ctx, d, now); !errors.Is(err, auth.ErrNoSession) {
t.Fatalf("a revoked session still resolves: %v", err)
}
}
if _, err := s.Lookup(ctx, other, now); err != nil {
t.Fatalf("another user's session was revoked too: %v", err)
}
}
// The journal survives the session sweep and stays coarse: a prefix and a class, never an address
// or a user agent.
func TestLoginJournalRecordsAttempts(t *testing.T) {
s, ctx := testDB(t)
seedUser(t, s, ctx, "u1")
now := time.Now().UTC()
if err := s.RecordLogin(ctx, login.LoginEvent{
UserID: "u1", Provider: "google", Outcome: "success", IPPrefix: "203.0.113.0/24", Client: "browser", At: now,
}); err != nil {
t.Fatal(err)
}
// A refused attempt has no account to belong to and still leaves a line.
if err := s.RecordLogin(ctx, login.LoginEvent{
Provider: "google", Outcome: "denied", Reason: "state_mismatch", IPPrefix: "203.0.113.0/24", At: now,
}); err != nil {
t.Fatal(err)
}
entries, err := s.RecentLogins(ctx, "u1", 10)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Outcome != "success" || entries[0].Client != "browser" {
t.Fatalf("journal = %+v", entries)
}
var denied int
if err := s.pool.QueryRow(ctx, `select count(*) from login_events where user_id is null`).Scan(&denied); err != nil {
t.Fatal(err)
}
if denied != 1 {
t.Fatalf("a denied attempt without an account left %d rows", denied)
}
}
// The single-use property is CONCURRENT, and only a concurrent test can see it: splitting the
// DELETE ... RETURNING into a SELECT and a later DELETE leaves sequential behaviour identical while
// two callbacks racing on one state both succeed — which is a login handed to whoever replayed it.
// The pin table claimed the sequential test above caught that split; it does not (found by review).
// Mutation caught: SELECT-then-DELETE in TakeLoginState.
func TestOnlyOneRacingCallbackCanConsumeAState(t *testing.T) {
s, ctx := testDB(t)
now := time.Now().UTC().Truncate(time.Microsecond)
const rounds = 40
for i := range rounds {
hash := auth.Digest(fmt.Sprintf("state-%d", i))
if err := s.PutLoginState(ctx, login.State{
Hash: hash, Provider: "google", Issuer: "https://accounts.example.org",
Nonce: "n", Verifier: "v", CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute),
}); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var won int
var wg sync.WaitGroup
for range 4 {
wg.Go(func() {
_, err := s.TakeLoginState(ctx, hash, now)
switch {
case err == nil:
mu.Lock()
won++
mu.Unlock()
case errors.Is(err, login.ErrNoState):
default:
mu.Lock()
t.Errorf("round %d: %v", i, err)
mu.Unlock()
}
})
}
wg.Wait()
if won != 1 {
t.Fatalf("round %d: %d of four racing callbacks consumed the same state", i, won)
}
}
}