Files
ELY-Browser/cloudflare/tests/account_reset_gc_sqlite.test.ts

446 lines
16 KiB
TypeScript

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
import type { ElyR2Object, ElyR2PutOptions, Env } from "../src/bindings.js";
import { accountDeletionDocument } from "../src/account_deletion.js";
import { authSessionCacheKvKey } from "../src/auth.js";
import { purgeLegacySessionCache } from "../src/legacy_auth_kv_cleanup.js";
import {
recentDeviceActionProofBytes,
type SensitiveAction,
} from "../src/recent_device_action_proof.js";
import { collectSyncR2Garbage } from "../src/sync_r2_gc.js";
import { maintainSyncR2Storage } from "../src/sync_r2_maintenance.js";
import { syncResetDocument } from "../src/sync_reset.js";
import { PUBLIC_KEY, signDeviceMessage } from "./devices_test_support.js";
import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js";
const USER_ID = "user-01";
const DEVICE_ID = "device-01";
const KEY_ID = "1".repeat(64);
const TOKEN_HASH = "2".repeat(64);
const OWNER_HASH = createHash("sha256").update(USER_ID).digest("hex");
const NOW = 1_800_000_000;
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
describe("account deletion and reset GC drains", () => {
it("drains 101 reset candidates through bounded batches", async () => {
await withDatabase(async (databasePath, bucket, _kv, env) => {
const keys = seedReadyCandidates(databasePath, bucket, 101);
const document = await syncResetDocument(
await resetRequest("sync-reset-101-items", NOW),
env,
authContext(),
NOW,
);
const replay = await syncResetDocument(
await resetRequest("sync-reset-101-items", NOW),
env,
authContext(),
NOW + 1_000,
);
assert.equal(document.deleted.r2_objects, 101);
assert.equal(replay.reset_at, NOW);
assert.equal(replay.deleted.r2_objects, 0);
assert.equal(deletedCandidateCount(databasePath), 101);
assert.equal(bucket.size, 0);
assert.deepEqual(bucket.deletes.sort(), keys.sort());
});
});
it("releases rotation staging on reset and finalizes cleanup during maintenance", async () => {
await withDatabase(async (databasePath, bucket, _kv, env) => {
const [key] = seedReadyCandidates(databasePath, bucket, 1);
assert.ok(key !== undefined);
seedCompletedRotation(databasePath, key);
const document = await syncResetDocument(
await resetRequest("sync-reset-rotation", NOW),
env,
authContext(),
NOW,
);
assert.equal(document.deleted.r2_objects, 1);
assert.equal(candidateState(databasePath, key), "deleted");
assert.deepEqual(query(databasePath, `
SELECT cleanup_snapshot_id, storage_cleaned_at
FROM sync_vault_rotations
WHERE user_id = '${USER_ID}' AND idempotency_key = 'rotation-reset-0001'
`), [{ cleanup_snapshot_id: "sync-reset", storage_cleaned_at: null }]);
await maintainSyncR2Storage(env, NOW + 1);
assert.deepEqual(query(databasePath, `
SELECT storage_cleaned_at
FROM sync_vault_rotations
WHERE user_id = '${USER_ID}' AND idempotency_key = 'rotation-reset-0001'
`), [{ storage_cleaned_at: NOW + 1 }]);
});
});
it("drains 101 account candidates after anonymizing their owner", async () => {
await withDatabase(async (databasePath, bucket, _kv, env) => {
seedReadyCandidates(databasePath, bucket, 101);
const context = authContext();
const request = await accountDeleteRequest("account-delete-101-items", NOW);
const replayRequest = request.clone();
const document = await accountDeletionDocument(
request,
env,
context,
NOW,
);
const replay = await accountDeletionDocument(replayRequest, env, context, NOW + 1_000);
assert.equal(document.deleted.r2_objects, 101);
assert.equal(replay.account_hash, document.account_hash);
assert.equal(replay.deleted_at, NOW);
assert.equal(replay.deleted.users, 0);
assert.equal(deletedCandidateCount(databasePath), 101);
assert.equal(bucket.size, 0);
assert.equal(query(databasePath, `
SELECT COUNT(*) AS count FROM sync_r2_gc_candidates WHERE user_id IS NOT NULL
`)[0]?.count, 0);
assert.equal(query(databasePath, `
SELECT COUNT(*) AS count FROM better_auth_user WHERE id = '${USER_ID}'
`)[0]?.count, 0);
assert.deepEqual(query(databasePath, `
SELECT user_id, outcome FROM audit_events WHERE event_type = 'account.delete'
`), [{ user_id: null, outcome: "success" }]);
});
});
it("returns account deletion success while scheduled cleanup retries R2 and KV failures", async () => {
await withDatabase(async (databasePath, bucket, kv, env) => {
const [key] = seedReadyCandidates(databasePath, bucket, 1);
assert.ok(key !== undefined);
const legacyKey = authSessionCacheKvKey("local", TOKEN_HASH);
kv.values.set(legacyKey, "legacy-session");
bucket.failDeletes = true;
kv.failDeletes = true;
const document = await accountDeletionDocument(
await accountDeleteRequest("account-delete-cleanup-failure", NOW),
env,
authContext(),
NOW,
);
assert.equal(document.deleted.kv_session_cache, 0);
assert.equal(candidateState(databasePath, key), "deleting");
assert.equal(query(databasePath, `
SELECT COUNT(*) AS count FROM better_auth_user WHERE id = '${USER_ID}'
`)[0]?.count, 0);
bucket.failDeletes = false;
kv.failDeletes = false;
assert.equal(await collectSyncR2Garbage(env, NOW + 61, { ownerHash: OWNER_HASH }), 1);
assert.equal(await purgeLegacySessionCache(env), 1);
assert.equal(candidateState(databasePath, key), "deleted");
assert.equal(bucket.size, 0);
assert.equal(kv.values.size, 0);
});
});
it("rolls back account deletion when its authenticated authority changes before the batch", async () => {
for (const beforeBatchSql of [
"DELETE FROM better_auth_session WHERE id = 'session-01';",
`UPDATE user_device_keys SET signing_public_key = '${"9".repeat(64)}'
WHERE user_id = '${USER_ID}' AND device_id = '${DEVICE_ID}';`,
]) {
await withDatabase(async (databasePath, bucket, _kv, env) => {
const [key] = seedReadyCandidates(databasePath, bucket, 1);
assert.ok(key !== undefined);
const request = await accountDeleteRequest("account-delete-authority-race", NOW);
const racedEnv = {
...env,
ELY_DB: new SqliteD1Database(databasePath, beforeBatchSql),
} as Env;
await assert.rejects(
() => accountDeletionDocument(request, racedEnv, authContext(), NOW),
/device_action_gate_failed/,
);
assert.deepEqual(query(databasePath, `SELECT
(SELECT COUNT(*) FROM better_auth_user WHERE id = '${USER_ID}') AS users,
(SELECT COUNT(*) FROM user_devices WHERE user_id = '${USER_ID}') AS devices,
(SELECT COUNT(*) FROM user_device_keys WHERE user_id = '${USER_ID}') AS device_keys,
(SELECT COUNT(*) FROM sync_vault_accounts WHERE user_id = '${USER_ID}') AS vaults,
(SELECT COUNT(*) FROM audit_events WHERE event_type = 'account.delete') AS audits
`), [{ users: 1, devices: 1, device_keys: 1, vaults: 1, audits: 0 }]);
assert.equal(candidateState(databasePath, key), "ready");
assert.equal(bucket.size, 1);
});
}
});
it("continues R2 inventory and GC when legacy KV purge fails", async () => {
await withDatabase(async (databasePath, bucket, kv, env) => {
const hash = "f".repeat(64);
const key = `sync-payloads/us-east/${OWNER_HASH}/bookmarks/object-01/${hash}.bin`;
bucket.values.set(key, new Uint8Array([1]).buffer);
kv.failLists = true;
await assert.rejects(() => maintainSyncR2Storage(env, NOW), AggregateError);
assert.equal(bucket.size, 0);
assert.equal(candidateState(databasePath, key), "deleted");
});
});
});
async function withDatabase(
run: (databasePath: string, bucket: TestBucket, kv: TestKv, env: Env) => Promise<void>,
): Promise<void> {
const tempDir = mkdtempSync(join(tmpdir(), "ely-account-reset-gc-"));
try {
const databasePath = join(tempDir, "ely.db");
for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) {
execute(databasePath, readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"));
}
seedAuthority(databasePath);
const bucket = new TestBucket();
const kv = new TestKv();
const env = {
ELY_DB: new SqliteD1Database(databasePath),
ELY_STORAGE: bucket,
ELY_KV: kv,
ELY_ENVIRONMENT: "local",
} as unknown as Env;
await run(databasePath, bucket, kv, env);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
function seedAuthority(databasePath: string): void {
execute(databasePath, `
INSERT INTO better_auth_user (
id, name, email, emailVerified, createdAt, updatedAt
) VALUES (
'${USER_ID}', 'User', 'user@example.com', 1,
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'
);
INSERT INTO user_devices (
user_id, device_id, public_key, device_name, platform,
approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key
) VALUES (
'${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', 'Mac', 'macOS',
'approved', 1, 1, 1, NULL, 'device-register-0001'
);
INSERT INTO user_device_keys (
user_id, device_id, signing_public_key, wrapping_public_key,
key_protocol_version, created_at
) VALUES (
'${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', '${"4".repeat(64)}', 2, 1
);
INSERT INTO better_auth_session (
id, expiresAt, token, createdAt, updatedAt, userId
) VALUES (
'session-01', '2099-01-01T00:00:00Z', 'session-token-01',
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '${USER_ID}'
);
INSERT INTO better_auth_session_device_context (
session_id, user_id, device_id, updated_at
) VALUES ('session-01', '${USER_ID}', '${DEVICE_ID}', 1);
INSERT INTO sync_vault_accounts (
user_id, current_key_id, current_generation, created_at, updated_at
) VALUES ('${USER_ID}', '${KEY_ID}', 1, 1, 1);
`);
}
function seedReadyCandidates(databasePath: string, bucket: TestBucket, count: number): string[] {
const keys = Array.from({ length: count }, (_, index) => snapshotKey(index + 1));
execute(databasePath, keys.map((key, index) => `
INSERT INTO sync_r2_gc_candidates (
r2_key, user_id, owner_hash, object_kind, state, write_token,
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
ready_at, delete_started_at, deleted_at
) VALUES (
'${key}', '${USER_ID}', '${OWNER_HASH}', 'snapshot', 'ready', NULL,
0, NULL, 1, 1, NULL, 1, NULL, NULL
);
`).join("\n"));
for (const [index, key] of keys.entries()) {
bucket.values.set(key, new Uint8Array([index % 256]).buffer);
}
return keys;
}
function seedCompletedRotation(databasePath: string, r2Key: string): void {
execute(databasePath, `
INSERT INTO user_devices (
user_id, device_id, public_key, device_name, platform,
approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key
) VALUES (
'${USER_ID}', 'device-02', '${"5".repeat(64)}', 'Old Mac', 'macOS',
'revoked', 1, 1, 1, 2, 'device-register-0002'
);
INSERT INTO sync_vault_rotations (
user_id, idempotency_key, audit_event_id, target_device_id, approver_device_id,
previous_key_id, previous_generation, new_key_id, new_generation, request_hash,
envelope_count, r2_object_count, created_at, completed_at
) VALUES (
'${USER_ID}', 'rotation-reset-0001', 'rotation-reset-audit', 'device-02', '${DEVICE_ID}',
'${KEY_ID}', 1, '${"6".repeat(64)}', 2, '${"7".repeat(64)}', 1, 1, 1, 2
);
INSERT INTO sync_vault_rotation_r2_objects (
user_id, rotation_idempotency_key, r2_key
) VALUES ('${USER_ID}', 'rotation-reset-0001', '${r2Key}');
`);
}
function resetRequest(idempotencyKey: string, proofCreatedAt: number): Promise<Request> {
return actionRequest(
"sync.reset",
"delete-cloud-sync-data",
idempotencyKey,
proofCreatedAt,
"/api/sync/reset",
);
}
function accountDeleteRequest(idempotencyKey: string, proofCreatedAt: number): Promise<Request> {
return actionRequest(
"account.delete",
"delete-elydora-account",
idempotencyKey,
proofCreatedAt,
"/api/account/delete",
);
}
async function actionRequest(
action: SensitiveAction,
confirmation: string,
idempotencyKey: string,
proofCreatedAt: number,
path: string,
): Promise<Request> {
const actionProof = await signDeviceMessage(recentDeviceActionProofBytes({
action,
userId: USER_ID,
sessionId: authContext().sessionId,
deviceId: DEVICE_ID,
confirmation,
idempotencyKey,
proofCreatedAt,
}));
return new Request(`https://elydora.test${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
version: 2,
confirmation,
idempotency_key: idempotencyKey,
proof_created_at: proofCreatedAt,
action_proof: actionProof,
}),
});
}
function authContext() {
return {
userId: USER_ID,
deviceId: DEVICE_ID,
sessionId: "session-01",
tokenHash: TOKEN_HASH,
expiresAt: "2099-01-01T00:00:00Z",
createdAt: "2026-01-01T00:00:00Z",
} as const;
}
function snapshotKey(index: number): string {
const hash = index.toString(16).padStart(64, "0");
return `sync-snapshots/us-east/${OWNER_HASH}/snapshot-${index}/${hash}.bin`;
}
function deletedCandidateCount(databasePath: string): unknown {
return query(databasePath, `
SELECT COUNT(*) AS count FROM sync_r2_gc_candidates WHERE state = 'deleted'
`)[0]?.count;
}
function candidateState(databasePath: string, key: string): unknown {
return query(databasePath, `
SELECT state FROM sync_r2_gc_candidates WHERE r2_key = '${key}'
`)[0]?.state;
}
class TestBucket {
readonly deletes: string[] = [];
readonly values = new Map<string, ArrayBuffer>();
failDeletes = false;
get(key: string): Promise<ElyR2Object | null> {
const value = this.values.get(key);
return Promise.resolve(value === undefined ? null : object(value));
}
put(key: string, value: ArrayBuffer, _options?: ElyR2PutOptions): Promise<ElyR2Object> {
this.values.set(key, value);
return Promise.resolve(object(value));
}
delete(key: string): Promise<void> {
if (this.failDeletes) return Promise.reject(new Error("r2_delete_failed"));
this.deletes.push(key);
this.values.delete(key);
return Promise.resolve();
}
list(options: { prefix: string; cursor?: string; limit: number }) {
const objects = [...this.values.keys()]
.filter((key) => key.startsWith(options.prefix))
.slice(0, options.limit)
.map((key) => ({ key }));
return Promise.resolve({ objects, truncated: false as const });
}
get size(): number {
return this.values.size;
}
}
class TestKv {
readonly values = new Map<string, string>();
failDeletes = false;
failLists = false;
get(key: string): Promise<string | null> {
return Promise.resolve(this.values.get(key) ?? null);
}
put(key: string, value: string): Promise<void> {
this.values.set(key, value);
return Promise.resolve();
}
delete(key: string): Promise<void> {
if (this.failDeletes) return Promise.reject(new Error("kv_delete_failed"));
this.values.delete(key);
return Promise.resolve();
}
list(options: { prefix: string; cursor?: string; limit: number }) {
if (this.failLists) return Promise.reject(new Error("kv_list_failed"));
const keys = [...this.values.keys()]
.filter((key) => key.startsWith(options.prefix))
.slice(0, options.limit)
.map((name) => ({ name }));
return Promise.resolve({ keys, list_complete: true as const });
}
}
function object(value: ArrayBuffer): ElyR2Object {
return { arrayBuffer: () => Promise.resolve(value) };
}