Files
scrabble-game/backend/internal/inttest/retention_test.go
T
Ilia Denisov 12af378b18 feat(account): stamp last-login time + IP on cold app-load
The profile GET (fetched once per cold app-load by the SPA) stamps accounts
.last_login_at/.last_login_ip, throttled to at most once per hour per account
(best-effort, never blocks the read). IP from the gateway-forwarded X-Forwarded-For.
Feeds the account-deletion dossier. Integration test covers the throttle.
2026-07-03 11:54:37 +02:00

129 lines
4.3 KiB
Go

//go:build integration
package inttest
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// lastLoginIP reads an account's stamped last-login IP ("" when unset).
func lastLoginIP(t *testing.T, accountID uuid.UUID) string {
t.Helper()
var ip sql.NullString
err := testDB.QueryRowContext(context.Background(),
"SELECT last_login_ip FROM accounts WHERE account_id = $1", accountID).Scan(&ip)
if err != nil {
t.Fatalf("read last_login_ip %s: %v", accountID, err)
}
return ip.String
}
// retainedRow is one row of the retention journal, read directly for assertions.
type retainedRow struct {
kind, externalID, reason string
}
// retainedRows reads the retention journal for an account, oldest detach first.
func retainedRows(t *testing.T, accountID uuid.UUID) []retainedRow {
t.Helper()
rows, err := testDB.QueryContext(context.Background(),
"SELECT kind, external_id, reason FROM retained_identities WHERE account_id = $1 ORDER BY detached_at",
accountID)
if err != nil {
t.Fatalf("query retained_identities %s: %v", accountID, err)
}
defer rows.Close()
var out []retainedRow
for rows.Next() {
var r retainedRow
if err := rows.Scan(&r.kind, &r.externalID, &r.reason); err != nil {
t.Fatalf("scan retained row: %v", err)
}
out = append(out, r)
}
return out
}
// TestUnlinkJournalsRetainedIdentity: detaching a provider records it in the retention
// journal (reason=unlink) before the live identity is removed.
func TestUnlinkJournalsRetainedIdentity(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
tgExt := "tg-" + uuid.NewString()
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
if err != nil {
t.Fatalf("provision: %v", err)
}
email := "keep-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
t.Fatalf("attach email: %v", err)
}
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
t.Fatalf("unlink telegram: %v", err)
}
got := retainedRows(t, acc.ID)
if len(got) != 1 || got[0].kind != account.KindTelegram || got[0].externalID != tgExt || got[0].reason != "unlink" {
t.Fatalf("retained rows = %+v, want one unlink telegram %q", got, tgExt)
}
}
// TestChangeEmailJournalsOldAddress: an email change records the outgoing address in the
// retention journal (reason=change).
func TestChangeEmailJournalsOldAddress(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
oldAddr := "old-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil {
t.Fatalf("attach old email: %v", err)
}
newAddr := "new-" + uuid.NewString() + "@example.com"
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
t.Fatalf("request change: %v", err)
}
if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("confirm change: %v", err)
}
got := retainedRows(t, acc.ID)
if len(got) != 1 || got[0].kind != account.KindEmail || got[0].externalID != oldAddr || got[0].reason != "change" {
t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr)
}
}
// TestStampLastLoginThrottles: the first cold-load stamp writes the IP; a second within
// the hour is a no-op (throttled), so it costs at most one write per account per hour.
func TestStampLastLoginThrottles(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.StampLastLogin(ctx, acc.ID, "1.2.3.4"); err != nil {
t.Fatalf("first stamp: %v", err)
}
if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" {
t.Fatalf("first stamp ip = %q, want 1.2.3.4", got)
}
if err := store.StampLastLogin(ctx, acc.ID, "9.9.9.9"); err != nil {
t.Fatalf("second stamp: %v", err)
}
if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" {
t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got)
}
}