296 lines
10 KiB
Go
296 lines
10 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")
|
|
}
|
|
}
|
|
|
|
// 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, ErrNoLoginState) {
|
|
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, ErrNoLoginState) {
|
|
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, ErrNoLoginState):
|
|
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)
|
|
}
|
|
}
|
|
}
|