@cosmicdrift/kumiko-bundled-features 0.2.1 → 0.2.3
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/CHANGELOG.md +108 -0
- package/package.json +12 -6
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/login.write.ts +31 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +33 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/file-foundation/feature.ts +43 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +8 -10
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +89 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +16 -6
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +309 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +334 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// User-Data-Export Integration-Test (S2.U3).
|
|
2
|
+
//
|
|
3
|
+
// User-Explicit-Anforderung "alle daten enthalten, ES + files; PII
|
|
4
|
+
// check in daten; alle wichtigen cross data matrix checks".
|
|
5
|
+
//
|
|
6
|
+
// Pinst:
|
|
7
|
+
// - Bundle enthaelt user-Profil + fileRefs aller Tenant-Memberships
|
|
8
|
+
// (Cross-Tenant zusammengefuehrt in einem Output).
|
|
9
|
+
// - PII-Surface: passwordHash + roles + status sind NICHT im Bundle
|
|
10
|
+
// (User-Hook-Selektion).
|
|
11
|
+
// - File-Binaries kommen NICHT inline — nur Stueckliste mit
|
|
12
|
+
// storageKey + fileName (ZIP-Bau ist optional Async-Wrap).
|
|
13
|
+
// - Other-User-Isolation: Bobs Files nicht in Alices Bundle.
|
|
14
|
+
// - Orphan-User (0 Memberships): user-Profil-Hook laeuft trotzdem
|
|
15
|
+
// ueber Pseudo-Tenant.
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
23
|
+
import { sql } from "drizzle-orm";
|
|
24
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
25
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
26
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
27
|
+
import { createFilesFeature } from "../../files";
|
|
28
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
29
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
30
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
31
|
+
import { runUserExport } from "../run-user-export";
|
|
32
|
+
|
|
33
|
+
let stack: TestStack;
|
|
34
|
+
|
|
35
|
+
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
36
|
+
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
37
|
+
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
38
|
+
|
|
39
|
+
function uuid(suffix: number): string {
|
|
40
|
+
return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ALICE_ID = uuid(1);
|
|
44
|
+
const BOB_ID = uuid(2);
|
|
45
|
+
const ORPHAN_ID = uuid(3);
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
stack = await setupTestStack({
|
|
49
|
+
features: [
|
|
50
|
+
createUserFeature(),
|
|
51
|
+
createFilesFeature(),
|
|
52
|
+
createDataRetentionFeature(),
|
|
53
|
+
createComplianceProfilesFeature(),
|
|
54
|
+
createUserDataRightsFeature(),
|
|
55
|
+
createUserDataRightsDefaultsFeature(),
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
60
|
+
await stack.db.execute(sql`
|
|
61
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
62
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
63
|
+
tenant_id UUID NOT NULL,
|
|
64
|
+
user_id TEXT NOT NULL,
|
|
65
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
67
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
68
|
+
inserted_by_id TEXT,
|
|
69
|
+
modified_by_id TEXT,
|
|
70
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
71
|
+
deleted_at TIMESTAMPTZ,
|
|
72
|
+
deleted_by_id TEXT,
|
|
73
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
74
|
+
UNIQUE(user_id, tenant_id)
|
|
75
|
+
)
|
|
76
|
+
`);
|
|
77
|
+
await stack.db.execute(sql`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS file_refs (
|
|
79
|
+
id UUID PRIMARY KEY,
|
|
80
|
+
tenant_id UUID NOT NULL,
|
|
81
|
+
storage_key TEXT NOT NULL,
|
|
82
|
+
file_name TEXT NOT NULL,
|
|
83
|
+
mime_type TEXT NOT NULL,
|
|
84
|
+
size INTEGER NOT NULL,
|
|
85
|
+
entity_type TEXT,
|
|
86
|
+
entity_id TEXT,
|
|
87
|
+
field_name TEXT,
|
|
88
|
+
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
89
|
+
inserted_by_id TEXT
|
|
90
|
+
)
|
|
91
|
+
`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterAll(async () => {
|
|
95
|
+
await stack.cleanup();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
beforeEach(async () => {
|
|
99
|
+
await stack.db.delete(userTable);
|
|
100
|
+
await stack.db.execute(sql`DELETE FROM read_tenant_memberships`);
|
|
101
|
+
await stack.db.execute(sql`DELETE FROM file_refs`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const NOW = () => getTemporal().Now.instant();
|
|
105
|
+
|
|
106
|
+
async function seedUser(
|
|
107
|
+
id: string,
|
|
108
|
+
overrides: { email?: string; displayName?: string; roles?: string } = {},
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
await stack.db.insert(userTable).values({
|
|
111
|
+
id,
|
|
112
|
+
tenantId: TENANT_SYSTEM,
|
|
113
|
+
email: overrides.email ?? `user-${id}@example.com`,
|
|
114
|
+
passwordHash: "secret-hash-must-not-leak",
|
|
115
|
+
displayName: overrides.displayName ?? `User ${id}`,
|
|
116
|
+
locale: "de",
|
|
117
|
+
emailVerified: true,
|
|
118
|
+
roles: overrides.roles ?? '["Member","SecretRole"]',
|
|
119
|
+
status: USER_STATUS.Active,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function seedMembership(userId: string, tenantId: string): Promise<void> {
|
|
124
|
+
await stack.db.execute(sql`
|
|
125
|
+
INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
126
|
+
VALUES (${tenantId}, ${userId}, '["Member"]')
|
|
127
|
+
ON CONFLICT (user_id, tenant_id) DO NOTHING
|
|
128
|
+
`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function seedFileRef(
|
|
132
|
+
id: string,
|
|
133
|
+
tenantId: string,
|
|
134
|
+
insertedById: string | null,
|
|
135
|
+
fileName: string,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
await stack.db.execute(sql`
|
|
138
|
+
INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
139
|
+
VALUES (${id}, ${tenantId}, ${`storage/${id}`}, ${fileName}, 'application/pdf', 1024, ${insertedById})
|
|
140
|
+
ON CONFLICT (id) DO NOTHING
|
|
141
|
+
`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("runUserExport :: alle Daten enthalten + Cross-Tenant", () => {
|
|
145
|
+
test("Alice in Tenant A + B → Bundle hat user-Profil + fileRefs aus beiden Tenants", async () => {
|
|
146
|
+
await seedUser(ALICE_ID, {
|
|
147
|
+
email: "alice@example.com",
|
|
148
|
+
displayName: "Alice Test",
|
|
149
|
+
});
|
|
150
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
151
|
+
await seedMembership(ALICE_ID, TENANT_B);
|
|
152
|
+
await seedFileRef(uuid(101), TENANT_A, ALICE_ID, "alice-a-1.pdf");
|
|
153
|
+
await seedFileRef(uuid(102), TENANT_A, ALICE_ID, "alice-a-2.pdf");
|
|
154
|
+
await seedFileRef(uuid(103), TENANT_B, ALICE_ID, "alice-b-1.pdf");
|
|
155
|
+
|
|
156
|
+
const bundle = await runUserExport({
|
|
157
|
+
db: stack.db,
|
|
158
|
+
registry: stack.registry,
|
|
159
|
+
userId: ALICE_ID,
|
|
160
|
+
now: NOW(),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(bundle.userId).toBe(ALICE_ID);
|
|
164
|
+
expect(bundle.tenants).toHaveLength(2);
|
|
165
|
+
|
|
166
|
+
// user-Entity in beiden Tenant-Sections (Hook ist tenant-agnostisch
|
|
167
|
+
// und liefert dasselbe Profil — das ist OK fuer den export-pfad,
|
|
168
|
+
// App-Author kann Bundle pro Tenant trennen wenn gewuenscht).
|
|
169
|
+
const tenantA = bundle.tenants.find((t) => t.tenantId === TENANT_A);
|
|
170
|
+
expect(tenantA).toBeDefined();
|
|
171
|
+
const userSnippet = tenantA?.entities.find((e) => e.entity === "user");
|
|
172
|
+
expect(userSnippet).toBeDefined();
|
|
173
|
+
expect(userSnippet?.rows).toHaveLength(1);
|
|
174
|
+
expect(String(userSnippet?.rows[0]?.["email"])).toBe("alice@example.com");
|
|
175
|
+
expect(String(userSnippet?.rows[0]?.["displayName"])).toBe("Alice Test");
|
|
176
|
+
|
|
177
|
+
// fileRefs cross-tenant: 2 in A, 1 in B → 4 Snippet-Eintraege
|
|
178
|
+
// (2× user + 2× fileRef-Entries) und Flat-fileRefs hat 4 Eintraege
|
|
179
|
+
// total (2 in A + 1 in B = 3 ist falsch — die fileRef-Hook listet
|
|
180
|
+
// pro Tenant → also 2 + 1 = 3 fileRefs flat).
|
|
181
|
+
expect(bundle.fileRefs).toHaveLength(3);
|
|
182
|
+
const fileNamesA = bundle.fileRefs
|
|
183
|
+
.filter((f) => f.tenantId === TENANT_A)
|
|
184
|
+
.map((f) => f.fileName);
|
|
185
|
+
expect(fileNamesA).toContain("alice-a-1.pdf");
|
|
186
|
+
expect(fileNamesA).toContain("alice-a-2.pdf");
|
|
187
|
+
const fileNamesB = bundle.fileRefs
|
|
188
|
+
.filter((f) => f.tenantId === TENANT_B)
|
|
189
|
+
.map((f) => f.fileName);
|
|
190
|
+
expect(fileNamesB).toContain("alice-b-1.pdf");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("runUserExport :: PII-Surface (Datenschutz-Audit)", () => {
|
|
195
|
+
test("Bundle enthaelt KEIN passwordHash + KEIN roles + KEIN status", async () => {
|
|
196
|
+
await seedUser(ALICE_ID, {
|
|
197
|
+
roles: '["Member","SecretAdmin","HiddenRole"]',
|
|
198
|
+
});
|
|
199
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
200
|
+
|
|
201
|
+
const bundle = await runUserExport({
|
|
202
|
+
db: stack.db,
|
|
203
|
+
registry: stack.registry,
|
|
204
|
+
userId: ALICE_ID,
|
|
205
|
+
now: NOW(),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Roundtrip durch JSON.stringify — auch wenn ein Hook das Feld
|
|
209
|
+
// versehentlich exposed, faellt es hier auf.
|
|
210
|
+
const serialized = JSON.stringify(bundle);
|
|
211
|
+
expect(serialized).not.toContain("secret-hash-must-not-leak");
|
|
212
|
+
expect(serialized).not.toContain("SecretAdmin");
|
|
213
|
+
expect(serialized).not.toContain("HiddenRole");
|
|
214
|
+
|
|
215
|
+
// Strukturell pruefen: user-Profil-Row hat keine privileged-Felder.
|
|
216
|
+
const userSnippet = bundle.tenants[0]?.entities.find((e) => e.entity === "user");
|
|
217
|
+
const profile = userSnippet?.rows[0];
|
|
218
|
+
expect(profile?.["passwordHash"]).toBeUndefined();
|
|
219
|
+
expect(profile?.["roles"]).toBeUndefined();
|
|
220
|
+
expect(profile?.["status"]).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("runUserExport :: Cross-User-Isolation", () => {
|
|
225
|
+
test("Alice's Bundle enthaelt KEINE Daten von Bob (gleicher Tenant, andere insertedById)", async () => {
|
|
226
|
+
await seedUser(ALICE_ID, { email: "alice@example.com" });
|
|
227
|
+
await seedUser(BOB_ID, { email: "bob.distinct@example.com" });
|
|
228
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
229
|
+
await seedMembership(BOB_ID, TENANT_A);
|
|
230
|
+
// Beide haben Files im selben Tenant.
|
|
231
|
+
await seedFileRef(uuid(201), TENANT_A, ALICE_ID, "alice-private.pdf");
|
|
232
|
+
await seedFileRef(uuid(202), TENANT_A, BOB_ID, "bob-private.pdf");
|
|
233
|
+
|
|
234
|
+
const aliceBundle = await runUserExport({
|
|
235
|
+
db: stack.db,
|
|
236
|
+
registry: stack.registry,
|
|
237
|
+
userId: ALICE_ID,
|
|
238
|
+
now: NOW(),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const aliceFileNames = aliceBundle.fileRefs.map((f) => f.fileName);
|
|
242
|
+
expect(aliceFileNames).toContain("alice-private.pdf");
|
|
243
|
+
expect(aliceFileNames).not.toContain("bob-private.pdf");
|
|
244
|
+
|
|
245
|
+
// user-Profil enthaelt ALICE's email, nicht Bobs.
|
|
246
|
+
const userRow = aliceBundle.tenants[0]?.entities.find((e) => e.entity === "user")?.rows[0];
|
|
247
|
+
expect(String(userRow?.["email"])).toBe("alice@example.com");
|
|
248
|
+
expect(JSON.stringify(aliceBundle)).not.toContain("bob.distinct@example.com");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("runUserExport :: Orphan-User (0 Memberships)", () => {
|
|
253
|
+
test("User ohne Memberships → user-Profil trotzdem im Bundle (Pseudo-Tenant)", async () => {
|
|
254
|
+
await seedUser(ORPHAN_ID, { email: "orphan@example.com" });
|
|
255
|
+
// KEINE seedMembership.
|
|
256
|
+
|
|
257
|
+
const bundle = await runUserExport({
|
|
258
|
+
db: stack.db,
|
|
259
|
+
registry: stack.registry,
|
|
260
|
+
userId: ORPHAN_ID,
|
|
261
|
+
now: NOW(),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(bundle.tenants).toHaveLength(1);
|
|
265
|
+
const orphanSection = bundle.tenants[0];
|
|
266
|
+
const userSnippet = orphanSection?.entities.find((e) => e.entity === "user");
|
|
267
|
+
expect(userSnippet?.rows).toHaveLength(1);
|
|
268
|
+
expect(String(userSnippet?.rows[0]?.["email"])).toBe("orphan@example.com");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("runUserExport :: Empty-State", () => {
|
|
273
|
+
test("User existiert nicht → leeres Bundle ohne Error", async () => {
|
|
274
|
+
// KEIN seedUser — der user-Hook returnt null wenn nichts da ist.
|
|
275
|
+
const bundle = await runUserExport({
|
|
276
|
+
db: stack.db,
|
|
277
|
+
registry: stack.registry,
|
|
278
|
+
userId: ORPHAN_ID,
|
|
279
|
+
now: NOW(),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Orphan-Path laeuft trotzdem (Hook returnt null → kein Snippet).
|
|
283
|
+
expect(bundle.userId).toBe(ORPHAN_ID);
|
|
284
|
+
expect(bundle.fileRefs).toEqual([]);
|
|
285
|
+
// Der user-Hook gibt `null` zurueck → keine entities-Section.
|
|
286
|
+
const allUserSnippets = bundle.tenants.flatMap((t) =>
|
|
287
|
+
t.entities.filter((e) => e.entity === "user"),
|
|
288
|
+
);
|
|
289
|
+
expect(allUserSnippets).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Token-Helper Unit-Tests (S2.U3 Atom 4a).
|
|
2
|
+
//
|
|
3
|
+
// Pinst:
|
|
4
|
+
// - generateDownloadToken: 32-byte random → base64url plain + SHA256 hash
|
|
5
|
+
// - hashDownloadToken: deterministisch (verify-Pfad fuer Atom 4b)
|
|
6
|
+
// - Web-Crypto-Universal: laeuft in vitest (= bun-runtime via vitest)
|
|
7
|
+
// ohne node:crypto-Import. Memory `feedback_universal_deps`.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { generateDownloadToken, hashDownloadToken } from "../token-helpers";
|
|
11
|
+
|
|
12
|
+
describe("generateDownloadToken", () => {
|
|
13
|
+
test("returns plain (base64url) + matching hash (hex)", async () => {
|
|
14
|
+
const { plain, hash } = await generateDownloadToken();
|
|
15
|
+
|
|
16
|
+
// base64url-Format: 32 byte random → ceil(32/3*4) = 43 chars,
|
|
17
|
+
// padding stripped. Erlaubt: A-Z a-z 0-9 - _
|
|
18
|
+
expect(plain).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
19
|
+
|
|
20
|
+
// SHA256 hex = 64 chars
|
|
21
|
+
expect(hash).toMatch(/^[a-f0-9]{64}$/);
|
|
22
|
+
|
|
23
|
+
// Hash matched plain
|
|
24
|
+
const verifiedHash = await hashDownloadToken(plain);
|
|
25
|
+
expect(verifiedHash).toBe(hash);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("zwei aufeinanderfolgende calls liefern unterschiedliche tokens (kryptographisch random)", async () => {
|
|
29
|
+
const t1 = await generateDownloadToken();
|
|
30
|
+
const t2 = await generateDownloadToken();
|
|
31
|
+
expect(t1.plain).not.toBe(t2.plain);
|
|
32
|
+
expect(t1.hash).not.toBe(t2.hash);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("Token URL-safe (kein +/= im base64url)", async () => {
|
|
36
|
+
// Probabilistisch: 20× generieren + verifizieren dass keiner unsafe-chars hat.
|
|
37
|
+
// Pin gegen reine btoa-Verwendung (die liefert + / =).
|
|
38
|
+
for (let i = 0; i < 20; i++) {
|
|
39
|
+
const { plain } = await generateDownloadToken();
|
|
40
|
+
expect(plain).not.toMatch(/[+/=]/);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("hashDownloadToken", () => {
|
|
46
|
+
test("deterministic — selbe input → selbe hash", async () => {
|
|
47
|
+
const plain = "abc-123_test_token";
|
|
48
|
+
const h1 = await hashDownloadToken(plain);
|
|
49
|
+
const h2 = await hashDownloadToken(plain);
|
|
50
|
+
expect(h1).toBe(h2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("verschiedene inputs → verschiedene hashes", async () => {
|
|
54
|
+
const h1 = await hashDownloadToken("token-A");
|
|
55
|
+
const h2 = await hashDownloadToken("token-B");
|
|
56
|
+
expect(h1).not.toBe(h2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("SHA256-shape: 64 hex chars", async () => {
|
|
60
|
+
const h = await hashDownloadToken("anything");
|
|
61
|
+
expect(h).toMatch(/^[a-f0-9]{64}$/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Boot-Smoke-Test fuer user-data-rights (S2.U2).
|
|
2
|
+
//
|
|
3
|
+
// Pre-Commit-Checkliste-Item: Neues Feature → 5-Zeilen-Boot-Smoke-Test.
|
|
4
|
+
// Faengt Drift an Schema-Definition oder Boot-Validation frueh.
|
|
5
|
+
//
|
|
6
|
+
// Tieferer Cross-Feature-Test (mit useExtension(EXT_USER_DATA, ...) +
|
|
7
|
+
// Sprint-2-H1/H2-Hooks) kommt in S2.T1 (Cross-Data-Matrix).
|
|
8
|
+
|
|
9
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
11
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
12
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
13
|
+
import { createUserFeature } from "../../user";
|
|
14
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
15
|
+
|
|
16
|
+
let stack: TestStack;
|
|
17
|
+
|
|
18
|
+
const userFeature = createUserFeature();
|
|
19
|
+
const dataRetention = createDataRetentionFeature();
|
|
20
|
+
const complianceProfiles = createComplianceProfilesFeature();
|
|
21
|
+
const userDataRights = createUserDataRightsFeature();
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
stack = await setupTestStack({
|
|
25
|
+
features: [userFeature, dataRetention, complianceProfiles, userDataRights],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await stack.cleanup();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("user-data-rights :: feature-definition smoke", () => {
|
|
34
|
+
test("Feature laedt clean (requires user + data-retention + compliance-profiles)", () => {
|
|
35
|
+
expect(stack).toBeDefined();
|
|
36
|
+
expect(userDataRights.name).toBe("user-data-rights");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("EXT_USER_DATA-Extension ist registriert (andere Features koennen useExtension dranhaengen)", () => {
|
|
40
|
+
expect(userDataRights.registrarExtensions["userData"]).toBeDefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("requires user + data-retention + compliance-profiles", () => {
|
|
44
|
+
const requires = userDataRights.requires;
|
|
45
|
+
expect(requires).toContain("user");
|
|
46
|
+
expect(requires).toContain("data-retention");
|
|
47
|
+
expect(requires).toContain("compliance-profiles");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("usesApi compliance.forTenant fuer Grace-Period-Resolution", () => {
|
|
51
|
+
expect(userDataRights.usedApis.has("compliance.forTenant")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("usesApi retention.policyFor fuer blockDelete-Konsultation (S2.D3 wired)", () => {
|
|
55
|
+
expect(userDataRights.usedApis.has("retention.policyFor")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// zip-path Unit-Tests (S2.U3 Atom 3c).
|
|
2
|
+
//
|
|
3
|
+
// Pinst die ZIP-Pfad-Sanitization gegen Path-Traversal + edge-cases.
|
|
4
|
+
// Diese Logik ist load-bearing — wenn sie failt, kann ein User-uploaded
|
|
5
|
+
// Filename mit "../" einen ZIP-Reader dazu bringen, ausserhalb des
|
|
6
|
+
// Extract-Roots zu schreiben.
|
|
7
|
+
|
|
8
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { buildFileRefZipPath, sanitizeZipFilename } from "../zip-path";
|
|
11
|
+
|
|
12
|
+
const TENANT = "00000000-0000-0000-0000-000000000001" as TenantId;
|
|
13
|
+
|
|
14
|
+
describe("sanitizeZipFilename", () => {
|
|
15
|
+
test("preserves alphanumeric + dot + dash + underscore", () => {
|
|
16
|
+
expect(sanitizeZipFilename("report-2024.pdf")).toBe("report-2024.pdf");
|
|
17
|
+
expect(sanitizeZipFilename("my_file.txt")).toBe("my_file.txt");
|
|
18
|
+
expect(sanitizeZipFilename("CamelCase-File_v2.docx")).toBe("CamelCase-File_v2.docx");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("Path-Traversal: '../' → kein '..' im Output", () => {
|
|
22
|
+
// Klassisch: User uploaded "../../etc/passwd" als fileName. Reader
|
|
23
|
+
// wuerde sonst beim Extract aus dem ZIP-Root rausschreiben. Sanitize
|
|
24
|
+
// collaps `..`-Sequenzen + strippt leading dots/dashes/underscores.
|
|
25
|
+
// Resultat ist NICHT visually identical mit dem Original (informativ),
|
|
26
|
+
// aber garantiert kein '..'-Segment im final-Path.
|
|
27
|
+
const result = sanitizeZipFilename("../../etc/passwd");
|
|
28
|
+
expect(result).not.toContain("..");
|
|
29
|
+
expect(result).toBe("file.etc_passwd"); // Pfade kollabieren auf fallback-base + safe-ext
|
|
30
|
+
|
|
31
|
+
// Pure ".." faellt komplett auf fallback "file" zurueck.
|
|
32
|
+
expect(sanitizeZipFilename("..")).toBe("file");
|
|
33
|
+
expect(sanitizeZipFilename("...")).toBe("file");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("Null-Byte: 'report\\x00.pdf' → 'report.pdf'", () => {
|
|
37
|
+
// Null-byte injection ist eine alte aber realistische Falle —
|
|
38
|
+
// Some C-based tools truncieren bei \x00, ZIP-Readern unklar.
|
|
39
|
+
expect(sanitizeZipFilename("report\x00.pdf")).toBe("report_.pdf");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("Path-Separator: 'sub/dir/file.txt' → 'sub_dir_file.txt'", () => {
|
|
43
|
+
expect(sanitizeZipFilename("sub/dir/file.txt")).toBe("sub_dir_file.txt");
|
|
44
|
+
expect(sanitizeZipFilename("c:\\Users\\file")).toBe("c__Users_file");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("Empty / null / undefined → 'unnamed'", () => {
|
|
48
|
+
expect(sanitizeZipFilename("")).toBe("unnamed");
|
|
49
|
+
// @ts-expect-error: null/undefined explicit defended
|
|
50
|
+
expect(sanitizeZipFilename(null)).toBe("unnamed");
|
|
51
|
+
// @ts-expect-error: null/undefined explicit defended
|
|
52
|
+
expect(sanitizeZipFilename(undefined)).toBe("unnamed");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("All-special-chars (no remainder) → 'file' fallback", () => {
|
|
56
|
+
expect(sanitizeZipFilename("///")).toBe("file");
|
|
57
|
+
expect(sanitizeZipFilename("\\")).toBe("file");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("Long basename gekappt (Extension preserved)", () => {
|
|
61
|
+
const longName = "a".repeat(200);
|
|
62
|
+
const result = sanitizeZipFilename(`${longName}.pdf`);
|
|
63
|
+
expect(result.length).toBeLessThanOrEqual(120);
|
|
64
|
+
expect(result.endsWith(".pdf")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("Long extension gekappt", () => {
|
|
68
|
+
const longExt = "x".repeat(50);
|
|
69
|
+
const result = sanitizeZipFilename(`file.${longExt}`);
|
|
70
|
+
expect(result.startsWith("file.")).toBe(true);
|
|
71
|
+
// Extension wird auf 20 chars max gekappt
|
|
72
|
+
const ext = result.split(".").pop() ?? "";
|
|
73
|
+
expect(ext.length).toBeLessThanOrEqual(20);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("Hidden-File (leading dot) verliert leading-dot durch strip", () => {
|
|
77
|
+
// ".bashrc" — kein basename (lastDot=0 nicht "hasExt"-Bedingung).
|
|
78
|
+
// Wird also als raw-baseName behandelt + dann leading-dot gestrippt.
|
|
79
|
+
expect(sanitizeZipFilename(".bashrc")).toBe("bashrc");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("Unicode chars (non-ASCII) werden zu underscore + ggf. fallback", () => {
|
|
83
|
+
// "résumé" basename: "r_sum_" → keine leading-strip, bleibt
|
|
84
|
+
// — aber: leading underscore wird gestrippt? Nein, nur leading "_._-"
|
|
85
|
+
// werden gestrippt vor strict basename. "r_sum_" startet mit "r".
|
|
86
|
+
expect(sanitizeZipFilename("résumé.pdf")).toBe("r_sum_.pdf");
|
|
87
|
+
// "文件" basename: "__" → all-underscore — kein strip aktiv weil
|
|
88
|
+
// erstes Zeichen ist "_" das in [._-] ist, also wird gestrippt
|
|
89
|
+
// → empty → fallback "file".
|
|
90
|
+
expect(sanitizeZipFilename("文件.txt")).toBe("file.txt");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("buildFileRefZipPath", () => {
|
|
95
|
+
test("Standard-Layout: files/<tenantId>/<fileRefId>-<name>", () => {
|
|
96
|
+
const path = buildFileRefZipPath({
|
|
97
|
+
tenantId: TENANT,
|
|
98
|
+
fileRefId: "abc-123",
|
|
99
|
+
fileName: "report.pdf",
|
|
100
|
+
});
|
|
101
|
+
expect(path).toBe(`files/${TENANT}/abc-123-report.pdf`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("Path-Traversal im fileName wird sanitized (kein '..' im Final-Path)", () => {
|
|
105
|
+
const path = buildFileRefZipPath({
|
|
106
|
+
tenantId: TENANT,
|
|
107
|
+
fileRefId: "abc",
|
|
108
|
+
fileName: "../../etc/passwd",
|
|
109
|
+
});
|
|
110
|
+
expect(path).toBe(`files/${TENANT}/abc-file.etc_passwd`);
|
|
111
|
+
expect(path).not.toContain("..");
|
|
112
|
+
expect(path.split("/").length).toBe(3); // exakt 3 segments: files / tenantId / sanitized-basename
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("Path ist deterministic — gleicher Input → gleicher Output", () => {
|
|
116
|
+
const args = { tenantId: TENANT, fileRefId: "x", fileName: "y.txt" };
|
|
117
|
+
expect(buildFileRefZipPath(args)).toBe(buildFileRefZipPath(args));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Audit-Helper fuer Download-Endpoint (S2.U3 Atom 4b).
|
|
2
|
+
//
|
|
3
|
+
// Beim Download (Token-Pfad oder Job-Pfad) werden Audit-Felder am
|
|
4
|
+
// Token-Row aktualisiert:
|
|
5
|
+
// - useCount + 1
|
|
6
|
+
// - lastUsedAt = now
|
|
7
|
+
// - lastUsedFromIp = caller-IP (X-Forwarded-For oder Connection-IP)
|
|
8
|
+
// - lastUsedUserAgent = UA-Header
|
|
9
|
+
//
|
|
10
|
+
// **Best-Effort-Update:** version-conflicts (zwei parallel-Downloads
|
|
11
|
+
// raceen um den update) werden silent geswallowt — Audit ist "letzter
|
|
12
|
+
// Use", nicht "alle Uses". Der zweite Download succeeded trotzdem
|
|
13
|
+
// (kein download-block bei race).
|
|
14
|
+
//
|
|
15
|
+
// **ES via tokenCrud.update:** kein direct-UPDATE. Memory
|
|
16
|
+
// `feedback_no_fake_dispatcher` + `feedback_event_store_tenant_consistency`.
|
|
17
|
+
|
|
18
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
19
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
20
|
+
import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
22
|
+
import { tokenCrud } from "./run-export-jobs";
|
|
23
|
+
import { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt";
|
|
24
|
+
|
|
25
|
+
const attemptCrud = createEventStoreExecutor(downloadAttemptsTable, downloadAttemptEntity, {
|
|
26
|
+
entityName: "download-attempt",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
30
|
+
|
|
31
|
+
export interface RecordDownloadUseArgs {
|
|
32
|
+
readonly db: DbConnection;
|
|
33
|
+
readonly tokenId: string;
|
|
34
|
+
readonly tokenVersion: number;
|
|
35
|
+
readonly tokenUseCount: number;
|
|
36
|
+
/**
|
|
37
|
+
* Tenant fuer die system-mode-TenantDb. ExportDownloadToken ist
|
|
38
|
+
* tenant-agnostisch (1:1 zum tenant-agnostic Job), aber der event-
|
|
39
|
+
* store-Stream-Lookup braucht einen Tenant-Context. Wir nutzen
|
|
40
|
+
* job.requestedFromTenantId — dieselbe Identitaet wie beim
|
|
41
|
+
* Token-Create im Worker (Atom 4a).
|
|
42
|
+
*/
|
|
43
|
+
readonly tenantId: TenantId;
|
|
44
|
+
readonly now: Instant;
|
|
45
|
+
readonly ip: string | null;
|
|
46
|
+
readonly userAgent: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Audit-Update: useCount, lastUsedAt, IP, UA. Best-effort —
|
|
51
|
+
* version-conflicts (parallel-downloads) werden swallowed, Download
|
|
52
|
+
* succeeded trotzdem.
|
|
53
|
+
*/
|
|
54
|
+
export async function recordDownloadUse(args: RecordDownloadUseArgs): Promise<void> {
|
|
55
|
+
const { db, tokenId, tokenVersion, tokenUseCount, tenantId, now, ip, userAgent } = args;
|
|
56
|
+
const executor = createSystemUser(tenantId);
|
|
57
|
+
const tdb = createTenantDb(db, tenantId, "system");
|
|
58
|
+
|
|
59
|
+
await tokenCrud
|
|
60
|
+
.update(
|
|
61
|
+
{
|
|
62
|
+
id: tokenId,
|
|
63
|
+
version: tokenVersion,
|
|
64
|
+
changes: {
|
|
65
|
+
useCount: tokenUseCount + 1,
|
|
66
|
+
lastUsedAt: now,
|
|
67
|
+
lastUsedFromIp: ip,
|
|
68
|
+
lastUsedUserAgent: userAgent,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
executor,
|
|
72
|
+
tdb,
|
|
73
|
+
)
|
|
74
|
+
.catch(() => {
|
|
75
|
+
// version-conflict bei parallelen Downloads → der erste hat
|
|
76
|
+
// bereits useCount inkrementiert. Wir behalten den Audit-Eintrag
|
|
77
|
+
// des ersten Downloads (lastUsedAt etc.). Kein download-block.
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// extractCallerIp lebt in feature.ts (extractAuditMeta) — query-handler
|
|
82
|
+
// haben keinen direkten Header-Zugriff (transport-agnostic), httpRoute-
|
|
83
|
+
// Wrapper extrahiert + steckt in payload.auditMeta.
|
|
84
|
+
|
|
85
|
+
export type DownloadAttemptResult = "notFound" | "expired" | "failed" | "signedUrlNotSupported";
|
|
86
|
+
|
|
87
|
+
export interface RecordInvalidAttemptArgs {
|
|
88
|
+
readonly db: DbConnection;
|
|
89
|
+
readonly tenantId: TenantId;
|
|
90
|
+
readonly now: Instant;
|
|
91
|
+
readonly result: DownloadAttemptResult;
|
|
92
|
+
readonly via: "token" | "job";
|
|
93
|
+
readonly tokenHash: string | null;
|
|
94
|
+
readonly jobId: string | null;
|
|
95
|
+
readonly attemptedByUserId: string | null;
|
|
96
|
+
readonly ip: string | null;
|
|
97
|
+
readonly userAgent: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Best-effort INSERT in read_download_attempts. Throws im Audit-Pfad
|
|
101
|
+
// duerfen den Download-Endpoint nicht killen (User soll seinen 404
|
|
102
|
+
// bekommen, nicht 500); failures werden silent geswallowt.
|
|
103
|
+
export async function recordInvalidAttempt(args: RecordInvalidAttemptArgs): Promise<void> {
|
|
104
|
+
const { db, tenantId, now } = args;
|
|
105
|
+
const executor = createSystemUser(tenantId);
|
|
106
|
+
const tdb = createTenantDb(db, tenantId, "system");
|
|
107
|
+
await attemptCrud
|
|
108
|
+
.create(
|
|
109
|
+
{
|
|
110
|
+
result: args.result,
|
|
111
|
+
via: args.via,
|
|
112
|
+
tokenHash: args.tokenHash,
|
|
113
|
+
jobId: args.jobId,
|
|
114
|
+
attemptedByUserId: args.attemptedByUserId,
|
|
115
|
+
ip: args.ip,
|
|
116
|
+
userAgent: args.userAgent,
|
|
117
|
+
attemptedAt: now,
|
|
118
|
+
},
|
|
119
|
+
executor,
|
|
120
|
+
tdb,
|
|
121
|
+
)
|
|
122
|
+
.catch(() => {
|
|
123
|
+
// best-effort
|
|
124
|
+
});
|
|
125
|
+
}
|