diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 3b65cbb..1b1420f 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -546,6 +546,25 @@ func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accoun if _, err := upd.ExecContext(ctx, tx); err != nil { return fmt.Errorf("consume confirmation: %w", err) } + // Journal the outgoing email before replacing it, so the legal dossier keeps the + // address the account used to hold (see retention.go). + var old model.Identities + sel := postgres.SELECT( + table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt, + ).FROM(table.Identities).WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), + ).LIMIT(1) + switch err := sel.QueryContext(ctx, tx, &old); { + case err == nil: + if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil { + return err + } + case errors.Is(err, qrm.ErrNoRows): + // No prior email (this doubles as an attach); nothing to retain. + default: + return fmt.Errorf("load outgoing email identity: %w", err) + } del := table.Identities.DELETE().WHERE( table.Identities.AccountID.EQ(postgres.UUID(accountID)). AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index fe21dc7..fc9db93 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -31,21 +31,29 @@ func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind st if err != nil { return err } - hasKind, others := false, 0 + var toRetain []Identity + others := 0 for _, id := range ids { if id.Kind == kind { - hasKind = true + toRetain = append(toRetain, id) } else { others++ } } - if !hasKind { + if len(toRetain) == 0 { return ErrNotFound } if others == 0 { return ErrLastIdentity } return withTx(ctx, s.db, func(tx *sql.Tx) error { + // Journal the detached credential before removing it, so the legal dossier + // survives while the identity frees for reuse (see retention.go). + for _, id := range toRetain { + if err := retainIdentityTx(ctx, tx, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainUnlink); err != nil { + return err + } + } delID := table.Identities.DELETE().WHERE( table.Identities.AccountID.EQ(postgres.UUID(accountID)). AND(table.Identities.Kind.EQ(postgres.String(kind))), diff --git a/backend/internal/account/retention.go b/backend/internal/account/retention.go new file mode 100644 index 0000000..8e60945 --- /dev/null +++ b/backend/internal/account/retention.go @@ -0,0 +1,43 @@ +package account + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// Reasons recorded on a retained_identities row: what detached the credential from its +// account. The row is written just before the live identities row is removed, preserving +// the legal dossier (which email/vk/tg was linked, and when) even as the identity frees +// for reuse. See docs/ARCHITECTURE.md ยง9.1. +const ( + retainUnlink = "unlink" + retainChange = "change" + retainDelete = "delete" +) + +// retainIdentityTx appends a retention-journal row for one identity being detached, inside +// tx. linkedAt is the identity's original creation time; detached_at defaults to now(). It +// must run in the same transaction as the identity removal, so the dossier and the live +// state can never diverge. +func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind, externalID string, confirmed bool, linkedAt time.Time, reason string) error { + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("account: new retained id: %w", err) + } + ins := table.RetainedIdentities.INSERT( + table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID, + table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID, + table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt, + table.RetainedIdentities.Reason, + ).VALUES(id, accountID, kind, externalID, confirmed, linkedAt, reason) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("account: retain identity (%s, %s): %w", kind, externalID, err) + } + return nil +} diff --git a/backend/internal/inttest/retention_test.go b/backend/internal/inttest/retention_test.go new file mode 100644 index 0000000..95fbba6 --- /dev/null +++ b/backend/internal/inttest/retention_test.go @@ -0,0 +1,92 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// 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) + } +}