Ctrl K
ComunidadGuías…

internal/store/postgres/account_lifecycle_integration_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"fmt"
	"strings"
	"testing"
	"time"
	"unicode/utf8"

	"github.com/jackc/pgx/v5/pgxpool"

	"telesrv/internal/domain"
	"telesrv/internal/store"
)

func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	nonce := time.Now().UnixNano()
	users := NewUserStore(pool)
	deleted := createTestUser(t, ctx, users, fmt.Sprintf("15571%d", nonce), "Delete", "Me")
	peer := createTestUser(t, ctx, users, fmt.Sprintf("15572%d", nonce), "Keep", "Peer")
	var channelID int64
	var collectiblePhoneID int64
	t.Cleanup(func() {
		if channelID != 0 {
			_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id = $1`, channelID)
		}
		if collectiblePhoneID != 0 {
			_, _ = pool.Exec(ctx, `DELETE FROM collectible_phones WHERE id = $1`, collectiblePhoneID)
		}
		_, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
		_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
		_, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
		_, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
		_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
		_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
		_, _ = pool.Exec(ctx, `DELETE FROM private_messages WHERE sender_user_id = ANY($1) OR recipient_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
		_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, []int64{deleted.ID, peer.ID})
	})
	deletedUsername := fmt.Sprintf("deleteme%d", nonce)
	deleted, err := users.UpdateUsername(ctx, deleted.ID, deletedUsername)
	if err != nil {
		t.Fatalf("set deleted user username: %v", err)
	}

	authOne := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 1)
	authTwo := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 2)
	if _, err := pool.Exec(ctx, `INSERT INTO contacts
(user_id, contact_user_id, contact_phone, contact_first_name, contact_last_name)
VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err != nil {
		t.Fatalf("insert reverse contact: %v", err)
	}
	if _, err := NewMessageStore(pool).SendPrivateText(ctx, domain.SendPrivateTextRequest{
		SenderUserID: deleted.ID, RecipientUserID: peer.ID, RandomID: nonce, Message: "keep shared history",
	}); err != nil {
		t.Fatalf("send shared message: %v", err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)`, deleted.ID); err != nil {
		t.Fatalf("insert account settings: %v", err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO stars_balances (user_id, balance) VALUES ($1, 50)`, deleted.ID); err != nil {
		t.Fatalf("insert stars balance: %v", err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, 100)`, deleted.ID); err != nil {
		t.Fatalf("insert TON balance: %v", err)
	}
	collectiblePhone := fmt.Sprintf("888%010d", nonce%10_000_000_000)
	if err := pool.QueryRow(ctx, `INSERT INTO collectible_phones
(phone, tier, status, owner_user_id, purchase_date, currency, amount, created_at, updated_at)
VALUES ($1, 'standard', 'owned', $2, $3, 'XTR', 100, $3, $3)
RETURNING id`, collectiblePhone, deleted.ID, time.Now().UTC()).Scan(&collectiblePhoneID); err != nil {
		t.Fatalf("insert collectible phone: %v", err)
	}
	createdChannel, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
		CreatorUserID: deleted.ID,
		Title:         "Retained deletion membership",
		Megagroup:     true,
		Date:          int(time.Now().Unix()),
	})
	if err != nil {
		t.Fatalf("create retained channel membership: %v", err)
	}
	channelID = createdChannel.Channel.ID
	var contactVersionBefore, channelParticipantsVersionBefore int64
	if err := pool.QueryRow(ctx, `
SELECT COALESCE((SELECT version FROM read_model_versions
                 WHERE model = 'contact_account' AND owner_user_id = $1
                   AND peer_type = 'user' AND peer_id = $1), 0)`, peer.ID).Scan(&contactVersionBefore); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `
SELECT COALESCE((SELECT version FROM read_model_versions
                 WHERE model = 'channel_participants' AND owner_user_id = 0
                   AND peer_type = 'channel' AND peer_id = $1), 0)`, channelID).Scan(&channelParticipantsVersionBefore); err != nil {
		t.Fatal(err)
	}

	lifecycle := NewAccountLifecycleStore(pool)
	now := time.Now().UTC().Truncate(time.Second)
	digestOne := sha256.Sum256([]byte("confirm-one"))
	pending, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
		UserID: deleted.ID, RequesterAuthKeyID: authOne, Reason: "Forgot password",
		ConfirmHashDigest: digestOne, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-one",
		RequestedAt: now, ExecuteAt: now.Add(7 * 24 * time.Hour),
	})
	if err != nil || !created || pending.UserID != deleted.ID {
		t.Fatalf("schedule deletion = %+v created=%v err=%v", pending, created, err)
	}
	if got, found, err := lifecycle.PendingAccountDeletionByHash(ctx, deleted.ID, digestOne); err != nil || !found || got.ID != pending.ID {
		t.Fatalf("pending deletion by hash = %+v found=%v err=%v", got, found, err)
	}
	revoked, err := lifecycle.CancelAccountDeletion(ctx, deleted.ID, digestOne, now.Add(time.Minute))
	if err != nil || len(revoked) != 1 || revoked[0].AuthKeyID != authOne {
		t.Fatalf("cancel deletion revoked=%+v err=%v", revoked, err)
	}
	if _, found, err := NewAuthKeyStore(pool).Get(ctx, authOne); err != nil || found {
		t.Fatalf("requester auth key after cancel found=%v err=%v, want revoked", found, err)
	}
	if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
		t.Fatalf("other auth key after cancel found=%v err=%v, want retained", found, err)
	}
	digestTwo := sha256.Sum256([]byte("confirm-two"))
	pendingBeforeDelete, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
		UserID: deleted.ID, RequesterAuthKeyID: authTwo, Reason: "Delete account",
		ConfirmHashDigest: digestTwo, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-two",
		RequestedAt: now.Add(time.Minute), ExecuteAt: now.Add(7 * 24 * time.Hour),
	})
	if err != nil || !created {
		t.Fatalf("schedule deletion before tombstone = %+v created=%v err=%v", pendingBeforeDelete, created, err)
	}

	result, err := lifecycle.ExecuteAccountDeletion(ctx, deleted.ID, domain.AccountDeletionManual, "manual", now.Add(2*time.Minute))
	if err != nil {
		t.Fatalf("execute account deletion: %v", err)
	}
	if !result.Changed || !result.User.Deleted || result.User.Phone != "" || result.User.FirstName != "" || len(result.RevokedAuthorizations) != 1 {
		t.Fatalf("deletion result = %+v", result)
	}
	if _, found, err := users.ByPhone(ctx, deleted.Phone); err != nil || found {
		t.Fatalf("released phone found=%v err=%v", found, err)
	}
	if _, found, err := users.ByUsername(ctx, deletedUsername); err != nil || found {
		t.Fatalf("released username found=%v err=%v", found, err)
	}
	if tombstone, found, err := users.ByID(ctx, deleted.ID); err != nil || !found || !tombstone.Deleted || tombstone.FirstName != "" {
		t.Fatalf("tombstone = %+v found=%v err=%v", tombstone, found, err)
	}
	if _, found, err := NewAuthorizationStore(pool).ByAuthKey(ctx, authTwo); err != nil || found {
		t.Fatalf("authorization after tombstone found=%v err=%v, want revoked", found, err)
	}
	if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
		t.Fatalf("permanent protocol auth key after tombstone found=%v err=%v, want retained", found, err)
	}
	var requestState string
	if err := pool.QueryRow(ctx, `SELECT state FROM account_deletion_requests WHERE id = $1`, pendingBeforeDelete.ID).Scan(&requestState); err != nil {
		t.Fatal(err)
	}
	if requestState != "executed" {
		t.Fatalf("pending request state after tombstone = %q, want executed", requestState)
	}
	var deletedVersion, contactVersionAfter, channelParticipantsVersionAfter int64
	if err := pool.QueryRow(ctx, `
SELECT version FROM read_model_versions
WHERE model = 'user_deleted' AND owner_user_id = $1
  AND peer_type = 'user' AND peer_id = $1`, deleted.ID).Scan(&deletedVersion); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `
SELECT COALESCE((SELECT version FROM read_model_versions
                 WHERE model = 'contact_account' AND owner_user_id = $1
                   AND peer_type = 'user' AND peer_id = $1), 0)`, peer.ID).Scan(&contactVersionAfter); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `
SELECT COALESCE((SELECT version FROM read_model_versions
                 WHERE model = 'channel_participants' AND owner_user_id = 0
                   AND peer_type = 'channel' AND peer_id = $1), 0)`, channelID).Scan(&channelParticipantsVersionAfter); err != nil {
		t.Fatal(err)
	}
	if deletedVersion < 1 || contactVersionAfter != contactVersionBefore || channelParticipantsVersionAfter != channelParticipantsVersionBefore {
		t.Fatalf("logical-delete read-model fanout deleted=%d contact=%d->%d channel=%d->%d",
			deletedVersion, contactVersionBefore, contactVersionAfter, channelParticipantsVersionBefore, channelParticipantsVersionAfter)
	}
	if _, err := users.UpdateProfile(ctx, deleted.ID, "Resurrected", "", ""); err == nil {
		t.Fatal("deleted account profile mutation unexpectedly succeeded")
	}
	history, err := NewMessageStore(pool).ListByUser(ctx, peer.ID, domain.MessageFilter{
		HasPeer: true,
		Peer:    domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
		Limit:   10,
	})
	if err != nil || len(history.Messages) != 1 || history.Messages[0].Body != "keep shared history" || history.Messages[0].From.ID != deleted.ID {
		t.Fatalf("peer history after deletion = %+v err=%v", history, err)
	}
	var ownerBoxes, peerBoxes, settings, contacts, notifications int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1`, deleted.ID).Scan(&ownerBoxes); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND from_user_id = $2`, peer.ID, deleted.ID).Scan(&peerBoxes); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_settings WHERE user_id = $1`, deleted.ID).Scan(&settings); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM contacts WHERE user_id = $1 OR contact_user_id = $1`, deleted.ID).Scan(&contacts); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_deletion_notifications WHERE target_user_id = $1 AND deleted_user_id = $2`, peer.ID, deleted.ID).Scan(&notifications); err != nil {
		t.Fatal(err)
	}
	var memberStatus string
	if err := pool.QueryRow(ctx, `SELECT status FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, deleted.ID).Scan(&memberStatus); err != nil {
		t.Fatal(err)
	}
	if ownerBoxes == 0 || peerBoxes != 1 || settings != 1 || contacts != 1 || notifications != 1 || memberStatus != "active" {
		// The peer kept both a reverse contact and a dialog row with the deleted
		// account, so it owns exactly one pending tombstone notification. That row
		// is the only thing that tells an offline viewer the peer is gone.
		t.Fatalf("logical-delete retained state ownerBoxes=%d peerBoxes=%d settings=%d contacts=%d notifications=%d memberStatus=%q",
			ownerBoxes, peerBoxes, settings, contacts, notifications, memberStatus)
	}
	var stars, ton, starClear, tonClear int64
	if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1`, deleted.ID).Scan(&stars); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1`, deleted.ID).Scan(&ton); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount), 0) FROM stars_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&starClear); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_nanoton), 0) FROM ton_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&tonClear); err != nil {
		t.Fatal(err)
	}
	if stars != 50 || ton != 100 || starClear != 0 || tonClear != 0 {
		t.Fatalf("logical-delete retained finances stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear)
	}
	var collectibleStatus string
	var collectibleOwner int64
	if err := pool.QueryRow(ctx, `SELECT status, owner_user_id FROM collectible_phones WHERE id = $1`, collectiblePhoneID).Scan(&collectibleStatus, &collectibleOwner); err != nil {
		t.Fatal(err)
	}
	if collectibleStatus != "owned" || collectibleOwner != deleted.ID {
		t.Fatalf("logical-delete collectible phone status=%q owner=%d, want owned by tombstone %d", collectibleStatus, collectibleOwner, deleted.ID)
	}
}

func TestAccountLifecycleDueSourcesAndTTLWatermarkPostgres(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	nonce := time.Now().UnixNano()
	users := NewUserStore(pool)
	ttlUser := createTestUser(t, ctx, users, fmt.Sprintf("15671%d", nonce), "TTL", "User")
	freezeUser := createTestUser(t, ctx, users, fmt.Sprintf("15672%d", nonce), "Frozen", "User")
	pendingUser := createTestUser(t, ctx, users, fmt.Sprintf("15673%d", nonce), "Pending", "User")
	ids := []int64{ttlUser.ID, freezeUser.ID, pendingUser.ID}
	t.Cleanup(func() {
		_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, ids)
		_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, ids)
		_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, ids)
	})
	now := time.Now().UTC().Truncate(time.Second)
	if _, err := pool.Exec(ctx, `UPDATE users SET account_delete_at = $2 WHERE id = $1`, ttlUser.ID, now.Add(-time.Minute)); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO account_restrictions
(user_id, frozen, reason, actor, command_id, frozen_since, frozen_until, appeal_url)
VALUES ($1, true, 'abuse', 'test', 'freeze-test', $2, $3, 'https://example.test/appeal')`, freezeUser.ID, now.Add(-time.Hour), now.Add(-time.Minute)); err != nil {
		t.Fatal(err)
	}
	digest := sha256.Sum256([]byte("due-pending"))
	if _, err := pool.Exec(ctx, `INSERT INTO account_deletion_requests
(user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at)
VALUES ($1, 123, 'forgot', $2, $3, $4)`, pendingUser.ID, digest[:], now.Add(-8*24*time.Hour), now.Add(-time.Minute)); err != nil {
		t.Fatal(err)
	}
	candidates, err := NewAccountLifecycleStore(pool).DueAccountDeletions(ctx, now, 10)
	if err != nil {
		t.Fatal(err)
	}
	sources := make(map[int64]domain.AccountDeletionSource, len(candidates))
	for _, candidate := range candidates {
		sources[candidate.UserID] = candidate.Source
	}
	if sources[ttlUser.ID] != domain.AccountDeletionAccountTTL || sources[freezeUser.ID] != domain.AccountDeletionFreezeExpiry || sources[pendingUser.ID] != domain.AccountDeletionPasswordResetExpiry {
		t.Fatalf("due sources = %+v", sources)
	}
	seen := now.Add(time.Hour)
	if err := users.UpdateLastSeen(ctx, ttlUser.ID, int(seen.Unix())); err != nil {
		t.Fatal(err)
	}
	lifecycle := NewAccountLifecycleStore(pool)
	if stale, err := lifecycle.ExecuteAccountDeletion(ctx, ttlUser.ID, domain.AccountDeletionAccountTTL, "", now); err != nil || stale.Changed {
		t.Fatalf("stale TTL candidate changed=%v err=%v", stale.Changed, err)
	}
	if _, err := pool.Exec(ctx, `UPDATE account_restrictions SET frozen_until = $2, updated_at = $3 WHERE user_id = $1`, freezeUser.ID, now.Add(24*time.Hour), now); err != nil {
		t.Fatal(err)
	}
	if stale, err := lifecycle.ExecuteAccountDeletion(ctx, freezeUser.ID, domain.AccountDeletionFreezeExpiry, "", now); err != nil || stale.Changed {
		t.Fatalf("extended freeze candidate changed=%v err=%v", stale.Changed, err)
	}
	if _, err := pool.Exec(ctx, `UPDATE account_deletion_requests SET state = 'cancelled', completed_at = $2, updated_at = $2 WHERE user_id = $1`, pendingUser.ID, now); err != nil {
		t.Fatal(err)
	}
	if stale, err := lifecycle.ExecuteAccountDeletion(ctx, pendingUser.ID, domain.AccountDeletionPasswordResetExpiry, "", now); err != nil || stale.Changed {
		t.Fatalf("cancelled pending candidate changed=%v err=%v", stale.Changed, err)
	}
	var deadline time.Time
	if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
		t.Fatal(err)
	}
	if want := seen.Add(365 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
		t.Fatalf("TTL watermark deadline=%v want=%v", deadline, want)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)
ON CONFLICT (user_id) DO UPDATE SET account_ttl_days = EXCLUDED.account_ttl_days`, ttlUser.ID); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
		t.Fatal(err)
	}
	if want := seen.Add(30 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
		t.Fatalf("custom TTL deadline=%v want=%v", deadline, want)
	}
}

func TestAccountPasswordChangedAtIgnoresSRPChallengeRotationPostgres(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	user := createTestUser(t, ctx, NewUserStore(pool), fmt.Sprintf("15771%d", time.Now().UnixNano()), "Password", "Clock")
	t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, user.ID) })
	if _, err := pool.Exec(ctx, `INSERT INTO account_passwords
(user_id, has_password, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_verifier, srp_id, srp_b)
VALUES ($1, true, '\x01', '\x02', 3, '\x03', '\x04', 10, '\x05')`, user.ID); err != nil {
		t.Fatal(err)
	}
	var initial, afterChallenge, afterPassword time.Time
	if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&initial); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_id = 11, srp_b = '\x06', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterChallenge); err != nil {
		t.Fatal(err)
	}
	if !afterChallenge.Equal(initial) {
		t.Fatalf("SRP challenge rotation changed password clock: initial=%v after=%v", initial, afterChallenge)
	}
	if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_verifier = '\x07', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterPassword); err != nil {
		t.Fatal(err)
	}
	if !afterPassword.After(afterChallenge) {
		t.Fatalf("password verifier change did not advance clock: before=%v after=%v", afterChallenge, afterPassword)
	}
}

func TestTruncateAccountDeletionReasonUTF8(t *testing.T) {
	got := truncateUTF8Bytes(strings.Repeat("界", 400), 1024)
	if !utf8.ValidString(got) || len(got) > 1024 {
		t.Fatalf("truncateUTF8Bytes returned invalid result: valid=%v bytes=%d", utf8.ValidString(got), len(got))
	}
	if got == "" {
		t.Fatal("truncateUTF8Bytes unexpectedly removed the whole reason")
	}
}

func saveLifecycleTestAuthorization(t *testing.T, ctx context.Context, db *pgxpool.Pool, userID int64, marker byte) [8]byte {
	t.Helper()
	var id [8]byte
	var value [256]byte
	if _, err := rand.Read(id[:]); err != nil {
		t.Fatal(err)
	}
	id[0] = marker
	if _, err := rand.Read(value[:]); err != nil {
		t.Fatal(err)
	}
	if err := NewAuthKeyStore(db).Save(ctx, store.AuthKeyData{ID: id, Value: value}); err != nil {
		t.Fatalf("save lifecycle auth key: %v", err)
	}
	if err := NewAuthorizationStore(db).Bind(ctx, domain.Authorization{AuthKeyID: id, UserID: userID, Hash: int64(marker)}); err != nil {
		t.Fatalf("bind lifecycle authorization: %v", err)
	}
	return id
}