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) } } } // The two tests below cover statements this pack MEASURED as executed by NOTHING — not by a unit // test, not through cmd/, not anywhere in the battery — while both are live production code: // UserByIdentity backs the seeding command and DeleteOldLoginEvents is the daemon's retention sweep, // which runs on a timer against a real deployment. Their `$n` arity and Scan targets were therefore // checked by nothing at all; two planted mutations (a broken arity and a transposed parameter pair) // survived the whole battery. sqlc now generates both, and these pin the behaviour underneath. func TestUserByIdentityResolvesAPairAndInventsNoAccount(t *testing.T) { s, ctx := testDB(t) in := login.Identity{Provider: "google", Subject: "sub-known", Email: "known@example.test", EmailVerified: true} userID, err := s.UpsertIdentity(ctx, in, time.Now().UTC(), signupGrant) if err != nil { t.Fatal(err) } got, err := s.UserByIdentity(ctx, "google", "sub-known") if err != nil { t.Fatalf("a known pair must resolve: %v", err) } if got != userID { t.Errorf("resolved %q, want %q", got, userID) } // ⚠ The two arguments are both strings and adjacent, so the swap has to be made VISIBLE by // asking the transposed question: with the pair reversed there is no such identity, and the // answer must be ErrNoAccount rather than this same user. Measured — with only the assertion // above, a planted transposition of the two parameters survived. if _, err := s.UserByIdentity(ctx, "sub-known", "google"); !errors.Is(err, ErrNoAccount) { t.Errorf("provider and subject are not interchangeable; reversed, want ErrNoAccount, got %v", err) } // And an unknown pair creates nothing: the CREATION of an account stays on the one path that // also writes its signup grant. if _, err := s.UserByIdentity(ctx, "google", "sub-never-seen"); !errors.Is(err, ErrNoAccount) { t.Errorf("an unknown pair must answer ErrNoAccount, got %v", err) } var n int if err := s.Pool().QueryRow(ctx, `select count(*) from users`).Scan(&n); err != nil { t.Fatal(err) } if n != 1 { t.Errorf("resolving an unknown pair minted an account: %d users exist, want 1", n) } } func TestTheLoginJournalRetentionDeletesOnlyWhatIsOlderThanTheCutoff(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u_ret") now := time.Now().UTC() write := func(at time.Time, reason string) { t.Helper() if err := s.RecordLogin(ctx, login.LoginEvent{ UserID: "u_ret", Provider: "google", Outcome: "denied", Reason: reason, IPPrefix: "203.0.113.0/24", Client: "browser", At: at, }); err != nil { t.Fatal(err) } } cutoff := now.Add(-24 * time.Hour) write(now.Add(-72*time.Hour), "old") write(now.Add(-1*time.Hour), "recent") // A row exactly AT the cutoff, because the predicate is strict (`at < before`) and a fixture with // nothing on the boundary cannot tell `<` from `<=`. Measured: without this row, relaxing the // comparison passed both this test and the generation gate. write(cutoff, "exactly-at-the-cutoff") deleted, err := s.DeleteOldLoginEvents(ctx, cutoff) if err != nil { t.Fatal(err) } // The COUNT is asserted, not just the absence of an error: this sweep's whole product is how many // rows it removed, and a statement that matched nothing returns zero without failing. if deleted != 1 { t.Errorf("deleted %d rows, want exactly 1 — only the row STRICTLY older than the cutoff", deleted) } left, err := s.RecentLogins(ctx, "u_ret", 10) if err != nil { t.Fatal(err) } kept := map[string]bool{} for _, e := range left { kept[e.Reason] = true } if len(left) != 2 || !kept["recent"] || !kept["exactly-at-the-cutoff"] { t.Errorf("retention kept the wrong rows: %+v", left) } }