@remit/account-worker 0.0.33 → 0.0.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/cascade.ts +24 -1
- package/src/compose-relational.auth.test.ts +150 -0
- package/src/compose-relational.ts +30 -49
- package/src/deletion-port.ts +5 -23
- package/src/handlers/account-delete.test.ts +137 -0
- package/src/handlers/account-delete.ts +64 -0
- package/src/handlers/account-export.sqlite.test.ts +305 -0
- package/src/handlers/account-export.ts +37 -8
- package/src/handlers/account-fanout.test.ts +2 -1
- package/src/handlers/account-fanout.ts +13 -31
- package/src/handlers/account-finalize.ts +3 -84
- package/src/handlers/account-purge-finalize.test.ts +3 -0
- package/src/handlers/organize-job.test.ts +2 -0
- package/src/process.ts +48 -2
- package/src/handlers/account-export.test.ts +0 -140
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/account-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.35",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"dev": "node --import tsx src/poller.ts"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
+
"@remit/auth-service": "*",
|
|
40
41
|
"@remit/logger": "*",
|
|
41
42
|
"@remit/data-ports": "*",
|
|
42
43
|
"@remit/logger-lambda": "*",
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"@remit/electrodb-entities": "*",
|
|
48
49
|
"@aws-sdk/client-sqs": "^3.1090.0",
|
|
49
50
|
"archiver": "^8.0.0",
|
|
51
|
+
"drizzle-orm": "^0.45.2",
|
|
50
52
|
"electrodb": "^3.9.1",
|
|
51
53
|
"expect-env": "^1.0.1",
|
|
52
54
|
"@types/archiver": "^8.0.0",
|
|
@@ -54,6 +56,7 @@
|
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
56
58
|
"@aws-sdk/core": "*",
|
|
59
|
+
"better-sqlite3": "^12.11.1",
|
|
57
60
|
"esbuild": "^0.28.1"
|
|
58
61
|
},
|
|
59
62
|
"license": "MIT",
|
package/src/cascade.ts
CHANGED
|
@@ -46,7 +46,30 @@ export interface CascadeServices {
|
|
|
46
46
|
messageLabelService: IMessageLabelRepository;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export
|
|
49
|
+
export type CascadeRepositories = Pick<
|
|
50
|
+
RemitClient,
|
|
51
|
+
| "accountConfig"
|
|
52
|
+
| "account"
|
|
53
|
+
| "address"
|
|
54
|
+
| "mailbox"
|
|
55
|
+
| "message"
|
|
56
|
+
| "messageFlag"
|
|
57
|
+
| "envelope"
|
|
58
|
+
| "outboxMessage"
|
|
59
|
+
| "threadMessage"
|
|
60
|
+
| "mailboxLock"
|
|
61
|
+
| "placementMove"
|
|
62
|
+
| "flagPush"
|
|
63
|
+
| "accountExportRequest"
|
|
64
|
+
| "accountSetting"
|
|
65
|
+
| "filter"
|
|
66
|
+
| "label"
|
|
67
|
+
| "messageLabel"
|
|
68
|
+
>;
|
|
69
|
+
|
|
70
|
+
export const cascadeServicesOf = (
|
|
71
|
+
client: CascadeRepositories,
|
|
72
|
+
): CascadeServices => ({
|
|
50
73
|
accountConfigService: client.accountConfig,
|
|
51
74
|
accountService: client.account,
|
|
52
75
|
addressService: client.address,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { before, describe, it } from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { type Auth, createAuth } from "@remit/auth-service";
|
|
8
|
+
import { auth_session, auth_user } from "@remit/auth-service/schema";
|
|
9
|
+
import { noopLogger } from "@remit/logger";
|
|
10
|
+
import Database from "better-sqlite3";
|
|
11
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
12
|
+
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
13
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
14
|
+
import { buildRelationalDeletionCapabilities } from "./compose-relational.js";
|
|
15
|
+
|
|
16
|
+
const ORIGIN = "https://reader.remit.test";
|
|
17
|
+
const PASSWORD = "correct horse battery staple";
|
|
18
|
+
|
|
19
|
+
const MIGRATIONS = new URL(
|
|
20
|
+
"../../../deploy/vps/migrations-sqlite/",
|
|
21
|
+
import.meta.url,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const mcpDeviceCode = sqliteTable("mcp_device_code", {
|
|
25
|
+
id: text("id").primaryKey(),
|
|
26
|
+
deviceCode: text("device_code").notNull(),
|
|
27
|
+
userCode: text("user_code").notNull(),
|
|
28
|
+
userId: text("user_id"),
|
|
29
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
30
|
+
status: text("status").notNull(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
interface SignedUp {
|
|
34
|
+
userId: string;
|
|
35
|
+
cookie: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const signUp = async (auth: Auth, email: string): Promise<SignedUp> => {
|
|
39
|
+
const response = await auth.api.signUpEmail({
|
|
40
|
+
body: { email, password: PASSWORD, name: email },
|
|
41
|
+
asResponse: true,
|
|
42
|
+
});
|
|
43
|
+
assert.equal(response.status, 200);
|
|
44
|
+
const { user } = (await response.json()) as { user: { id: string } };
|
|
45
|
+
const cookie = response.headers
|
|
46
|
+
.getSetCookie()
|
|
47
|
+
.map((value) => value.split(";")[0])
|
|
48
|
+
.join("; ");
|
|
49
|
+
return { userId: user.id, cookie };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const tokenStatus = async (auth: Auth, cookie: string): Promise<number> =>
|
|
53
|
+
(
|
|
54
|
+
await auth.handler(
|
|
55
|
+
new Request(`${ORIGIN}/api/auth/token`, { headers: { cookie } }),
|
|
56
|
+
)
|
|
57
|
+
).status;
|
|
58
|
+
|
|
59
|
+
describe("relational deletion capabilities — login user erase", () => {
|
|
60
|
+
let filename: string;
|
|
61
|
+
let auth: Auth;
|
|
62
|
+
let erased: SignedUp;
|
|
63
|
+
let kept: SignedUp;
|
|
64
|
+
|
|
65
|
+
const rowsOf = (): {
|
|
66
|
+
users: string[];
|
|
67
|
+
sessionUsers: string[];
|
|
68
|
+
deviceCodeUsers: (string | null)[];
|
|
69
|
+
} => {
|
|
70
|
+
const sqlite = new Database(filename, { readonly: true });
|
|
71
|
+
const db = drizzle(sqlite);
|
|
72
|
+
const users = db
|
|
73
|
+
.select({ id: auth_user.id })
|
|
74
|
+
.from(auth_user)
|
|
75
|
+
.all()
|
|
76
|
+
.map((row) => row.id);
|
|
77
|
+
const sessionUsers = db
|
|
78
|
+
.select({ userId: auth_session.userId })
|
|
79
|
+
.from(auth_session)
|
|
80
|
+
.all()
|
|
81
|
+
.map((row) => row.userId);
|
|
82
|
+
const deviceCodeUsers = db
|
|
83
|
+
.select({ userId: mcpDeviceCode.userId })
|
|
84
|
+
.from(mcpDeviceCode)
|
|
85
|
+
.all()
|
|
86
|
+
.map((row) => row.userId);
|
|
87
|
+
sqlite.close();
|
|
88
|
+
return { users, sessionUsers, deviceCodeUsers };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
before(async () => {
|
|
92
|
+
const dir = await mkdtemp(join(tmpdir(), "remit-relational-auth-"));
|
|
93
|
+
filename = join(dir, "app.db");
|
|
94
|
+
process.env.SQLITE_APP_DB_PATH = filename;
|
|
95
|
+
process.env.SQLITE_MAIL_DB_PATH = join(dir, "mail.db");
|
|
96
|
+
process.env.SQLITE_SEARCH_DB_PATH = join(dir, "search.db");
|
|
97
|
+
|
|
98
|
+
const migrating = new Database(filename);
|
|
99
|
+
migrating.pragma("journal_mode = WAL");
|
|
100
|
+
migrating.pragma("foreign_keys = ON");
|
|
101
|
+
for (const set of ["app", "mcp"]) {
|
|
102
|
+
migrate(drizzle(migrating), {
|
|
103
|
+
migrationsFolder: fileURLToPath(new URL(set, MIGRATIONS)),
|
|
104
|
+
migrationsTable: `__drizzle_migrations_${set}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
migrating.close();
|
|
108
|
+
|
|
109
|
+
auth = await createAuth({
|
|
110
|
+
connectionString: filename,
|
|
111
|
+
secret: "relational-auth-secret-0123456789abcdef",
|
|
112
|
+
baseURL: ORIGIN,
|
|
113
|
+
selfSignUpEnabled: true,
|
|
114
|
+
});
|
|
115
|
+
erased = await signUp(auth, "erased@remit.test");
|
|
116
|
+
kept = await signUp(auth, "kept@remit.test");
|
|
117
|
+
|
|
118
|
+
const seeding = new Database(filename);
|
|
119
|
+
drizzle(seeding)
|
|
120
|
+
.insert(mcpDeviceCode)
|
|
121
|
+
.values(
|
|
122
|
+
[erased, kept].map(({ userId }) => ({
|
|
123
|
+
id: userId,
|
|
124
|
+
deviceCode: `device-${userId}`,
|
|
125
|
+
userCode: `user-${userId}`,
|
|
126
|
+
userId,
|
|
127
|
+
expiresAt: new Date(Date.now() + 600_000),
|
|
128
|
+
status: "approved",
|
|
129
|
+
})),
|
|
130
|
+
)
|
|
131
|
+
.run();
|
|
132
|
+
seeding.close();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("deletes the user, its sessions and device codes, and refuses the old session cookie", async () => {
|
|
136
|
+
assert.equal(await tokenStatus(auth, erased.cookie), 200);
|
|
137
|
+
|
|
138
|
+
await buildRelationalDeletionCapabilities().signOut(
|
|
139
|
+
erased.userId,
|
|
140
|
+
noopLogger,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const { users, sessionUsers, deviceCodeUsers } = rowsOf();
|
|
144
|
+
assert.deepEqual(users, [kept.userId]);
|
|
145
|
+
assert.deepEqual(sessionUsers, [kept.userId]);
|
|
146
|
+
assert.deepEqual(deviceCodeUsers, [kept.userId]);
|
|
147
|
+
assert.equal(await tokenStatus(auth, erased.cookie), 401);
|
|
148
|
+
assert.equal(await tokenStatus(auth, kept.cookie), 200);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -1,67 +1,48 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { auth_user } from "@remit/auth-service/schema";
|
|
3
2
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
BETTER_SQLITE3_LIMITS,
|
|
4
|
+
openSqliteDatabases,
|
|
5
|
+
runDrizzleAccountConfigErase,
|
|
6
|
+
runDrizzleCascadeDelete,
|
|
7
|
+
type SqliteStores,
|
|
6
8
|
sqliteDatabasePathsFromEnv,
|
|
9
|
+
syncStores,
|
|
7
10
|
} from "@remit/drizzle-service";
|
|
8
11
|
import type { Logger } from "@remit/logger";
|
|
12
|
+
import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
|
|
13
|
+
import { eq } from "drizzle-orm";
|
|
9
14
|
import type { DeletionCapabilities } from "./deletion-capabilities.js";
|
|
10
15
|
|
|
11
|
-
// Mirrors `@remit/storage-service`'s filesystem-backend default: bodies
|
|
12
|
-
// live under `STORAGE_LOCAL_PATH` (`.remit/storage` when unset). The cascade
|
|
13
|
-
// deletes the same `accounts/{accountConfigId}/…` key prefixes the S3 backend
|
|
14
|
-
// would, translated to a recursive directory removal.
|
|
15
|
-
const storageBasePath = (): string =>
|
|
16
|
-
process.env.STORAGE_LOCAL_PATH ?? ".remit/storage";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Recursively remove every stored object under a key prefix. Replay-safe:
|
|
20
|
-
* `force: true` swallows a missing directory (ENOENT), matching the S3 backend's
|
|
21
|
-
* "delete on missing keys is a 200" idempotency.
|
|
22
|
-
*/
|
|
23
16
|
const deleteStoragePrefix = async (
|
|
24
17
|
keyPrefix: string,
|
|
25
18
|
log: Logger,
|
|
26
19
|
): Promise<void> => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
await createFilesystemStorageService(
|
|
21
|
+
process.env.STORAGE_LOCAL_PATH ?? ".remit/storage",
|
|
22
|
+
).deletePrefix(keyPrefix);
|
|
23
|
+
log.info({ keyPrefix }, "Storage prefix cleanup complete");
|
|
30
24
|
};
|
|
31
25
|
|
|
32
|
-
// The
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
let deleterPromise: Promise<CascadeDeleter> | undefined;
|
|
26
|
+
// The app, mail and search databases are opened once, on first use, and
|
|
27
|
+
// shared by every delete (the native binding loads there, hence async). Built
|
|
28
|
+
// lazily so a worker that never deletes does not open a database handle.
|
|
29
|
+
let storesPromise: Promise<SqliteStores> | undefined;
|
|
37
30
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
const getStores = (): Promise<SqliteStores> => {
|
|
32
|
+
storesPromise ??= openSqliteDatabases(sqliteDatabasePathsFromEnv()).then(
|
|
33
|
+
(databases) => syncStores(databases, BETTER_SQLITE3_LIMITS),
|
|
34
|
+
);
|
|
35
|
+
return storesPromise;
|
|
42
36
|
};
|
|
43
37
|
|
|
44
|
-
/**
|
|
45
|
-
* The self-host stack's deletion capabilities (RFC 035/036).
|
|
46
|
-
*
|
|
47
|
-
* - `signOut`: no-op. Deleting the AccountConfig row severs the session's
|
|
48
|
-
* account→data resolution, and there is no federated session store to sign
|
|
49
|
-
* out of; better-auth session rows are owned by the auth service, outside this
|
|
50
|
-
* worker's data seam.
|
|
51
|
-
* - `invalidateContent`: no-op. Caddy proxies `/content/*` straight to the
|
|
52
|
-
* backend with no response cache (`deploy/vps/caddy/routes.caddy`), so there
|
|
53
|
-
* is nothing to invalidate.
|
|
54
|
-
* - `deleteStoragePrefix`: recursive filesystem removal.
|
|
55
|
-
* - `cascadeDelete`: the Drizzle cascade over SQLite.
|
|
56
|
-
*/
|
|
57
38
|
export const buildRelationalDeletionCapabilities =
|
|
58
39
|
(): DeletionCapabilities => ({
|
|
59
40
|
assertReady: async () => {},
|
|
60
41
|
signOut: async (userId, log) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
);
|
|
42
|
+
await (await getStores()).app.db
|
|
43
|
+
.delete(auth_user)
|
|
44
|
+
.where(eq(auth_user.id, userId));
|
|
45
|
+
log.info({ userId }, "Auth user erased");
|
|
65
46
|
},
|
|
66
47
|
invalidateContent: async (contentPrefix, log) => {
|
|
67
48
|
log.info(
|
|
@@ -71,8 +52,8 @@ export const buildRelationalDeletionCapabilities =
|
|
|
71
52
|
},
|
|
72
53
|
deleteStoragePrefix: (keyPrefix, log) =>
|
|
73
54
|
deleteStoragePrefix(keyPrefix, log),
|
|
74
|
-
cascadeDelete: async (entities, log) =>
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
55
|
+
cascadeDelete: async (entities, log) =>
|
|
56
|
+
runDrizzleCascadeDelete(await getStores(), entities, log),
|
|
57
|
+
eraseAccountConfig: async (accountConfigId, log) =>
|
|
58
|
+
runDrizzleAccountConfigErase(await getStores(), accountConfigId, log),
|
|
78
59
|
});
|
package/src/deletion-port.ts
CHANGED
|
@@ -1,25 +1,6 @@
|
|
|
1
1
|
import type { Logger } from "@remit/logger";
|
|
2
2
|
import type { CascadeEntity } from "./cascade.js";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* The four steps of the deletion cascade that vary by deployment. Everything
|
|
6
|
-
* else — enumerating the AccountConfig's rows, the FIFO purge orchestration, the
|
|
7
|
-
* queue sends — is backend-neutral and lives in the handlers unchanged.
|
|
8
|
-
*
|
|
9
|
-
* Two composition roots build this:
|
|
10
|
-
*
|
|
11
|
-
* - The DynamoDB composition (AWS custody stack): Cognito global sign-out,
|
|
12
|
-
* CloudFront invalidation, raw-S3 prefix delete, DynamoDB `BatchWriteItem`
|
|
13
|
-
* cascade. Byte-for-byte the pre-split behavior. It imports the AWS SDK /
|
|
14
|
-
* ElectroDB graph and lives outside this shared, open-core module; it is
|
|
15
|
-
* injected here through `setDeletionCapabilities` so that graph never reaches
|
|
16
|
-
* the relational tree.
|
|
17
|
-
* - `compose-relational.ts` (self-host stack, RFC 035/036): no-op sign-out
|
|
18
|
-
* (deleting the AccountConfig severs the session's data resolution; there is
|
|
19
|
-
* no CDN to sign out of), no-op invalidation (Caddy proxies `/content/*`
|
|
20
|
-
* straight to the backend with no cache), filesystem prefix delete, and the
|
|
21
|
-
* Drizzle cascade over SQLite.
|
|
22
|
-
*/
|
|
23
4
|
export interface DeletionCapabilities {
|
|
24
5
|
/**
|
|
25
6
|
* Validate every precondition the destructive steps depend on, BEFORE any row
|
|
@@ -29,10 +10,6 @@ export interface DeletionCapabilities {
|
|
|
29
10
|
* Relational is a no-op — its steps carry no such deploy-time configuration.
|
|
30
11
|
*/
|
|
31
12
|
assertReady(log: Logger): Promise<void>;
|
|
32
|
-
/**
|
|
33
|
-
* Invalidate the deleted user's active auth sessions. AWS: Cognito
|
|
34
|
-
* `AdminUserGlobalSignOut`. Relational: no-op.
|
|
35
|
-
*/
|
|
36
13
|
signOut(userId: string, log: Logger): Promise<void>;
|
|
37
14
|
/**
|
|
38
15
|
* Purge any CDN/edge cache under a tenant/account content prefix
|
|
@@ -52,4 +29,9 @@ export interface DeletionCapabilities {
|
|
|
52
29
|
* through its repository as the cascade-in-progress marker.
|
|
53
30
|
*/
|
|
54
31
|
cascadeDelete(entities: CascadeEntity[], log: Logger): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Remove every row an account config owns except the AccountConfig row,
|
|
34
|
+
* which the caller deletes last. Relational: one keyed delete per table.
|
|
35
|
+
*/
|
|
36
|
+
eraseAccountConfig(accountConfigId: string, log: Logger): Promise<void>;
|
|
55
37
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { after, before, describe, it } from "node:test";
|
|
6
|
+
import { recordingQueue } from "@remit/data-ports/recording-queue";
|
|
7
|
+
import {
|
|
8
|
+
BETTER_SQLITE3_LIMITS,
|
|
9
|
+
composeSqliteRepositories,
|
|
10
|
+
messageObjectsInMemory,
|
|
11
|
+
openSqliteDatabases,
|
|
12
|
+
syncStores,
|
|
13
|
+
} from "@remit/drizzle-service";
|
|
14
|
+
import { createShippedSqliteFiles } from "@remit/drizzle-service/test-sqlite";
|
|
15
|
+
import { noopLogger } from "@remit/logger";
|
|
16
|
+
import { cascadeServicesOf } from "../cascade.js";
|
|
17
|
+
import { buildRelationalDeletionCapabilities } from "../compose-relational.js";
|
|
18
|
+
import type { AccountDeleteFinalizeEvent } from "../events.js";
|
|
19
|
+
import {
|
|
20
|
+
finalizeAccountDelete,
|
|
21
|
+
processAccountDelete,
|
|
22
|
+
} from "./account-delete.js";
|
|
23
|
+
|
|
24
|
+
const exists = (path: string): Promise<boolean> =>
|
|
25
|
+
stat(path).then(
|
|
26
|
+
() => true,
|
|
27
|
+
() => false,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const writeUnder = async (base: string, key: string): Promise<string> => {
|
|
31
|
+
const full = join(base, key);
|
|
32
|
+
await mkdir(dirname(full), { recursive: true });
|
|
33
|
+
await writeFile(full, "x");
|
|
34
|
+
return full;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("account delete on a host that runs the finalize step from its own queue", () => {
|
|
38
|
+
const previousStoragePath = process.env.STORAGE_LOCAL_PATH;
|
|
39
|
+
let directory: string;
|
|
40
|
+
let close: () => Promise<void>;
|
|
41
|
+
let repositories: ReturnType<typeof composeSqliteRepositories>;
|
|
42
|
+
|
|
43
|
+
before(async () => {
|
|
44
|
+
directory = await mkdtemp(join(tmpdir(), "remit-account-delete-"));
|
|
45
|
+
process.env.STORAGE_LOCAL_PATH = join(directory, "storage");
|
|
46
|
+
const databases = await openSqliteDatabases(
|
|
47
|
+
createShippedSqliteFiles(directory),
|
|
48
|
+
);
|
|
49
|
+
close = databases.close;
|
|
50
|
+
repositories = composeSqliteRepositories(
|
|
51
|
+
syncStores(databases, BETTER_SQLITE3_LIMITS),
|
|
52
|
+
messageObjectsInMemory(),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
after(async () => {
|
|
57
|
+
await close();
|
|
58
|
+
if (previousStoragePath === undefined)
|
|
59
|
+
delete process.env.STORAGE_LOCAL_PATH;
|
|
60
|
+
else process.env.STORAGE_LOCAL_PATH = previousStoragePath;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("signs out, queues the finalize step, and the finalize step erases the tenant", async () => {
|
|
64
|
+
const accountConfig = await repositories.accountConfig.create({
|
|
65
|
+
userId: "user000000000000000000001",
|
|
66
|
+
});
|
|
67
|
+
const { accountConfigId } = accountConfig;
|
|
68
|
+
await repositories.accountExportRequest.create({
|
|
69
|
+
accountConfigId,
|
|
70
|
+
userId: accountConfig.userId,
|
|
71
|
+
state: "Ready",
|
|
72
|
+
});
|
|
73
|
+
await repositories.label.create({ accountConfigId, name: "Receipts" });
|
|
74
|
+
const storage = process.env.STORAGE_LOCAL_PATH ?? "";
|
|
75
|
+
const stored = [
|
|
76
|
+
await writeUnder(storage, `accounts/${accountConfigId}/a/body.eml`),
|
|
77
|
+
await writeUnder(storage, `exports/${accountConfigId}/e/export.zip`),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const services = cascadeServicesOf(repositories);
|
|
81
|
+
const capabilities = buildRelationalDeletionCapabilities();
|
|
82
|
+
const queue = recordingQueue();
|
|
83
|
+
const signedOut: string[] = [];
|
|
84
|
+
|
|
85
|
+
await processAccountDelete(
|
|
86
|
+
{ type: "AccountDelete", accountConfigId, userId: "auth-user-1" },
|
|
87
|
+
noopLogger,
|
|
88
|
+
{
|
|
89
|
+
services,
|
|
90
|
+
signOut: async (userId) => {
|
|
91
|
+
signedOut.push(userId);
|
|
92
|
+
},
|
|
93
|
+
enqueueFinalize: (event) => queue.send({ body: event }),
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
assert.deepEqual(signedOut, ["auth-user-1"]);
|
|
97
|
+
const [queued] = queue.sent;
|
|
98
|
+
assert.deepEqual(queued?.body, {
|
|
99
|
+
type: "FinalizeAccountDelete",
|
|
100
|
+
accountConfigId,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const finalize: AccountDeleteFinalizeEvent = {
|
|
104
|
+
type: "FinalizeAccountDelete",
|
|
105
|
+
accountConfigId,
|
|
106
|
+
};
|
|
107
|
+
await finalizeAccountDelete(finalize, noopLogger, {
|
|
108
|
+
services,
|
|
109
|
+
capabilities,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
assert.deepEqual(
|
|
113
|
+
await repositories.accountConfig.get([accountConfigId]),
|
|
114
|
+
[],
|
|
115
|
+
);
|
|
116
|
+
assert.deepEqual(
|
|
117
|
+
(
|
|
118
|
+
await repositories.accountExportRequest.listByAccountConfig(
|
|
119
|
+
accountConfigId,
|
|
120
|
+
)
|
|
121
|
+
).items,
|
|
122
|
+
[],
|
|
123
|
+
);
|
|
124
|
+
assert.deepEqual(
|
|
125
|
+
await repositories.label.listByAccountConfig(accountConfigId),
|
|
126
|
+
[],
|
|
127
|
+
);
|
|
128
|
+
for (const path of stored) {
|
|
129
|
+
assert.equal(await exists(path), false, `${path} must be deleted`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await finalizeAccountDelete(finalize, noopLogger, {
|
|
133
|
+
services,
|
|
134
|
+
capabilities,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Logger } from "@remit/logger";
|
|
2
|
+
import type { CascadeServices } from "../cascade.js";
|
|
3
|
+
import type { DeletionCapabilities } from "../deletion-port.js";
|
|
4
|
+
import type {
|
|
5
|
+
AccountDeleteEvent,
|
|
6
|
+
AccountDeleteFinalizeEvent,
|
|
7
|
+
} from "../events.js";
|
|
8
|
+
|
|
9
|
+
export interface AccountDeleteDeps {
|
|
10
|
+
services: CascadeServices;
|
|
11
|
+
signOut: DeletionCapabilities["signOut"];
|
|
12
|
+
enqueueFinalize: (event: AccountDeleteFinalizeEvent) => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const processAccountDelete = async (
|
|
16
|
+
event: AccountDeleteEvent,
|
|
17
|
+
log: Logger,
|
|
18
|
+
{ services, signOut, enqueueFinalize }: AccountDeleteDeps,
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
const { accountConfigId } = event;
|
|
21
|
+
const [accountConfig] = await services.accountConfigService.get([
|
|
22
|
+
accountConfigId,
|
|
23
|
+
]);
|
|
24
|
+
if (accountConfig === undefined) {
|
|
25
|
+
log.info({ accountConfigId }, "AccountConfig already erased — no-op");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await signOut(event.userId, log);
|
|
30
|
+
await enqueueFinalize({ type: "FinalizeAccountDelete", accountConfigId });
|
|
31
|
+
log.info({ accountConfigId }, "Enqueued finalize event");
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface AccountDeleteFinalizeDeps {
|
|
35
|
+
services: CascadeServices;
|
|
36
|
+
capabilities: DeletionCapabilities;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The AccountConfig row is deleted last: while it exists, a replay finds it
|
|
41
|
+
* and runs the whole erase again.
|
|
42
|
+
*/
|
|
43
|
+
export const finalizeAccountDelete = async (
|
|
44
|
+
event: AccountDeleteFinalizeEvent,
|
|
45
|
+
log: Logger,
|
|
46
|
+
{ services, capabilities }: AccountDeleteFinalizeDeps,
|
|
47
|
+
): Promise<void> => {
|
|
48
|
+
const { accountConfigId } = event;
|
|
49
|
+
const [accountConfig] = await services.accountConfigService.get([
|
|
50
|
+
accountConfigId,
|
|
51
|
+
]);
|
|
52
|
+
if (accountConfig === undefined) {
|
|
53
|
+
log.info({ accountConfigId }, "Account erase already complete — no-op");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await capabilities.assertReady(log);
|
|
58
|
+
await capabilities.invalidateContent(accountConfigId, log);
|
|
59
|
+
await capabilities.eraseAccountConfig(accountConfigId, log);
|
|
60
|
+
await capabilities.deleteStoragePrefix(`accounts/${accountConfigId}/`, log);
|
|
61
|
+
await capabilities.deleteStoragePrefix(`exports/${accountConfigId}/`, log);
|
|
62
|
+
await services.accountConfigService.delete(accountConfigId);
|
|
63
|
+
log.info({ accountConfigId }, "AccountConfig deleted; erase complete");
|
|
64
|
+
};
|