fix(account): dedupe colliding identities on merge, journaling to the dossier
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
An account merge blanket-reassigned all of the secondary's identities to the primary, so merging two accounts that each had a confirmed email (or telegram/vk) left the survivor with two identities of one kind — which the profile and the retention dossier both treat as singular (the profile showed an arbitrary one; a later change-email journaled only one). The merge now keeps the primary's identity and journals the secondary's colliding one to retained_identities (reason=merge) before dropping it, so the survivor has one identity per kind and the absorbed credential still lands in the legal dossier. - migration 00008: widen retained_identities.reason CHECK to admit 'merge' (expand-contract — Up only widens the set, so an image rollback stays DB-safe). - accountmerge: dedupeIdentities + retainMergedIdentity before the identity reassign. - inttest TestAccountMergeDedupesEmail; docs ARCHITECTURE §4 + retention.go reason note.
This commit is contained in:
@@ -251,6 +251,44 @@ func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountMergeDedupesEmail keeps the primary's email when both accounts have one and
|
||||
// journals the secondary's to the dossier (reason=merge), so the survivor never ends up
|
||||
// with two email identities.
|
||||
func TestAccountMergeDedupesEmail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
merger := accountmerge.NewMerger(testDB)
|
||||
|
||||
primary := provisionAccount(t)
|
||||
secondary := provisionAccount(t)
|
||||
primaryEmail := "keep-" + uuid.NewString() + "@example.com"
|
||||
secondaryEmail := "absorb-" + uuid.NewString() + "@example.com"
|
||||
bindEmailIdentity(t, primary, primaryEmail)
|
||||
bindEmailIdentity(t, secondary, secondaryEmail)
|
||||
|
||||
if err := merger.Merge(ctx, primary, secondary); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
|
||||
// The primary keeps its own email; the secondary's is gone from the live identities.
|
||||
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, primaryEmail); !ok || owner != primary {
|
||||
t.Errorf("primary email owner = %s ok=%v, want primary %s", owner, ok, primary)
|
||||
}
|
||||
if _, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, secondaryEmail); ok {
|
||||
t.Error("the secondary's email must be removed (no duplicate email on the survivor)")
|
||||
}
|
||||
// The absorbed email stays in the legal dossier, tagged reason=merge.
|
||||
var reason string
|
||||
if err := testDB.QueryRowContext(ctx,
|
||||
`SELECT reason FROM backend.retained_identities WHERE account_id=$1 AND kind='email' AND external_id=$2`,
|
||||
secondary, secondaryEmail).Scan(&reason); err != nil {
|
||||
t.Fatalf("retained email row: %v", err)
|
||||
}
|
||||
if reason != "merge" {
|
||||
t.Errorf("retained reason = %q, want merge", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
|
||||
func TestAccountLinkFreeEmail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user