feat(account): deletion = legal retention, not erasure (PR3) #164
@@ -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))),
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user