@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.0.0
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 +6 -8
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
- package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
- package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
- package/src/auth-email-password/constants.ts +0 -4
- package/src/auth-email-password/feature.ts +4 -8
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
- package/src/auth-email-password/handlers/login.write.ts +1 -4
- package/src/auth-email-password/password-hashing.ts +0 -11
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
- package/src/auth-email-password/web/auth-gate.tsx +4 -3
- package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
- package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
- package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
- package/src/auth-email-password/web/user-menu.tsx +1 -3
- package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
- package/src/config/__tests__/config.integration.test.ts +8 -7
- package/src/config/__tests__/deserialize-value.test.ts +6 -3
- package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
- package/src/config/feature.ts +8 -14
- package/src/config/handlers/set.write.ts +2 -2
- package/src/config/index.ts +3 -4
- package/src/config/resolver.ts +10 -34
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
- package/src/custom-fields/constants.ts +0 -1
- package/src/custom-fields/events.ts +0 -6
- package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
- package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
- package/src/custom-fields/wire-for-entity.ts +77 -116
- package/src/data-retention/__tests__/parse-override.test.ts +13 -13
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
- package/src/folders/__tests__/folders.integration.test.ts +0 -3
- package/src/folders/web/__tests__/tree.test.ts +0 -8
- package/src/folders/web/folder-manager.tsx +3 -1
- package/src/folders/web/i18n.ts +2 -0
- package/src/folders/web/tree.ts +2 -13
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
- package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
- package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
- package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
- package/src/ledger/recurring.ts +8 -17
- package/src/ledger/web/index.ts +2 -3
- package/src/legal-pages/feature.ts +3 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
- package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
- package/src/managed-pages/feature.ts +3 -1
- package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
- package/src/secrets/handlers/rotate.job.ts +98 -77
- package/src/secrets/secrets-context.ts +38 -7
- package/src/secrets/table.ts +7 -6
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
- package/src/sessions/feature.ts +18 -51
- package/src/sessions/index.ts +2 -2
- package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
- package/src/tags/__tests__/tags.integration.test.ts +1 -12
- package/src/tags/entity.ts +1 -6
- package/src/tags/schemas.ts +2 -9
- package/src/tags/web/tag-manager.tsx +1 -1
- package/src/template-resolver/testing.ts +1 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
- package/src/user/__tests__/user.integration.test.ts +0 -26
- package/src/user/handlers/find-for-auth.query.ts +5 -7
- package/src/user/index.ts +0 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
- package/src/user-data-rights/feature.ts +3 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
- package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
- package/src/user-data-rights/run-forget-cleanup.ts +0 -1
- package/src/user-data-rights/run-user-export.ts +4 -43
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
- package/src/user-data-rights/web/client-plugin.tsx +1 -6
- package/src/user-data-rights/web/i18n.ts +10 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
- package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
- package/src/user-profile/handlers/change-email.write.ts +8 -11
- package/src/user-profile/web/profile-screen.tsx +1 -12
- package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
- package/src/auth-email-password/password-hashing.test.ts +0 -35
- package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
- package/src/config/handlers/reencrypt.job.ts +0 -197
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
- package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
- package/src/personal-access-tokens/constants.ts +0 -33
- package/src/personal-access-tokens/feature.ts +0 -78
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
- package/src/personal-access-tokens/handlers/create.write.ts +0 -41
- package/src/personal-access-tokens/handlers/list.query.ts +0 -47
- package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
- package/src/personal-access-tokens/hash.ts +0 -17
- package/src/personal-access-tokens/index.ts +0 -45
- package/src/personal-access-tokens/resolver.ts +0 -58
- package/src/personal-access-tokens/roles.ts +0 -29
- package/src/personal-access-tokens/schema/api-token.ts +0 -56
- package/src/personal-access-tokens/scopes.ts +0 -43
- package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
- package/src/personal-access-tokens/web/i18n.ts +0 -69
- package/src/personal-access-tokens/web/index.ts +0 -7
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
- package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
- package/src/shared/chunked-entity-migration.ts +0 -89
- package/src/shared/index.ts +0 -7
- package/src/tags/__tests__/schemas.test.ts +0 -34
- package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
- package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
|
@@ -74,6 +74,11 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
74
74
|
"userDataRights.privacyCenter.deletion.dialogTitle": "Konto wirklich löschen?",
|
|
75
75
|
"userDataRights.privacyCenter.deletion.dialogDescription":
|
|
76
76
|
"Mit dem Bestätigen startet die Lösch-Frist. Du kannst die Löschung bis zu ihrem Ablauf wieder abbrechen.",
|
|
77
|
+
|
|
78
|
+
"userDataRights.privacyCenter.audit.title": "Aktivitätsprotokoll (Art. 15)",
|
|
79
|
+
"userDataRights.privacyCenter.audit.intro":
|
|
80
|
+
"Die letzten Ereignisse zu deinem Konto über alle Tenants hinweg.",
|
|
81
|
+
"userDataRights.privacyCenter.audit.empty": "Noch keine Ereignisse.",
|
|
77
82
|
},
|
|
78
83
|
en: {
|
|
79
84
|
"userDataRights.deletion.request.title": "Request account deletion",
|
|
@@ -141,5 +146,10 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
141
146
|
"userDataRights.privacyCenter.deletion.dialogTitle": "Delete your account?",
|
|
142
147
|
"userDataRights.privacyCenter.deletion.dialogDescription":
|
|
143
148
|
"Confirming starts the deletion grace period. You can cancel the deletion until it ends.",
|
|
149
|
+
|
|
150
|
+
"userDataRights.privacyCenter.audit.title": "Activity log (Art. 15)",
|
|
151
|
+
"userDataRights.privacyCenter.audit.intro":
|
|
152
|
+
"The most recent events on your account across all tenants.",
|
|
153
|
+
"userDataRights.privacyCenter.audit.empty": "No events yet.",
|
|
144
154
|
},
|
|
145
155
|
};
|
package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts
CHANGED
|
@@ -162,7 +162,6 @@ describe("S2.H1 :: userExportHook", () => {
|
|
|
162
162
|
|
|
163
163
|
const result = await userExportHook({
|
|
164
164
|
db: stack.db,
|
|
165
|
-
registry: stack.registry,
|
|
166
165
|
tenantId: TENANT_A,
|
|
167
166
|
userId: uuid(1001),
|
|
168
167
|
});
|
|
@@ -183,7 +182,6 @@ describe("S2.H1 :: userExportHook", () => {
|
|
|
183
182
|
test("returns null wenn User nicht existiert", async () => {
|
|
184
183
|
const result = await userExportHook({
|
|
185
184
|
db: stack.db,
|
|
186
|
-
registry: stack.registry,
|
|
187
185
|
tenantId: TENANT_A,
|
|
188
186
|
userId: uuid(1002),
|
|
189
187
|
});
|
|
@@ -195,10 +193,7 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
195
193
|
test('strategy="delete" → softDelete + email/displayName anonymisiert + status=deleted', async () => {
|
|
196
194
|
await seedUser(uuid(1003));
|
|
197
195
|
|
|
198
|
-
await userDeleteHook(
|
|
199
|
-
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1003) },
|
|
200
|
-
"delete",
|
|
201
|
-
);
|
|
196
|
+
await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1003) }, "delete");
|
|
202
197
|
|
|
203
198
|
const row = await fetchUser(uuid(1003));
|
|
204
199
|
expect(row).not.toBeNull();
|
|
@@ -214,10 +209,7 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
214
209
|
test('strategy="anonymize" → email/displayName anonymisiert aber status bleibt active', async () => {
|
|
215
210
|
await seedUser(uuid(1004));
|
|
216
211
|
|
|
217
|
-
await userDeleteHook(
|
|
218
|
-
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1004) },
|
|
219
|
-
"anonymize",
|
|
220
|
-
);
|
|
212
|
+
await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1004) }, "anonymize");
|
|
221
213
|
|
|
222
214
|
const row = await fetchUser(uuid(1004));
|
|
223
215
|
if (!row) throw new Error("row should exist");
|
|
@@ -230,19 +222,13 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
230
222
|
test("idempotent: zweiter delete-Call crasht nicht UND State bleibt korrekt deleted", async () => {
|
|
231
223
|
await seedUser(uuid(1005));
|
|
232
224
|
|
|
233
|
-
await userDeleteHook(
|
|
234
|
-
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
|
|
235
|
-
"delete",
|
|
236
|
-
);
|
|
225
|
+
await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete");
|
|
237
226
|
const afterFirst = await fetchUser(uuid(1005));
|
|
238
227
|
if (!afterFirst) throw new Error("user should exist after first delete");
|
|
239
228
|
|
|
240
229
|
// Zweiter Call: kein Crash + State unverändert
|
|
241
230
|
await expect(
|
|
242
|
-
userDeleteHook(
|
|
243
|
-
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
|
|
244
|
-
"delete",
|
|
245
|
-
),
|
|
231
|
+
userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete"),
|
|
246
232
|
).resolves.toBeUndefined();
|
|
247
233
|
|
|
248
234
|
// State-Verifikation (S2.H1+H2-Audit N3): Row weiterhin deleted,
|
|
@@ -263,7 +249,6 @@ describe("S2.H2 :: fileRefExportHook", () => {
|
|
|
263
249
|
|
|
264
250
|
const result = await fileRefExportHook({
|
|
265
251
|
db: stack.db,
|
|
266
|
-
registry: stack.registry,
|
|
267
252
|
tenantId: TENANT_A,
|
|
268
253
|
userId: "user-files-1",
|
|
269
254
|
});
|
|
@@ -278,7 +263,6 @@ describe("S2.H2 :: fileRefExportHook", () => {
|
|
|
278
263
|
test("returns null wenn User keine Files hat", async () => {
|
|
279
264
|
const result = await fileRefExportHook({
|
|
280
265
|
db: stack.db,
|
|
281
|
-
registry: stack.registry,
|
|
282
266
|
tenantId: TENANT_A,
|
|
283
267
|
userId: "ghost-user-no-files",
|
|
284
268
|
});
|
|
@@ -292,7 +276,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
292
276
|
await seedFileRef(uuid(202), TENANT_A, "user-delete-files", "f2.pdf");
|
|
293
277
|
|
|
294
278
|
await fileRefDeleteHook(
|
|
295
|
-
{ db: stack.db,
|
|
279
|
+
{ db: stack.db, tenantId: TENANT_A, userId: "user-delete-files" },
|
|
296
280
|
"delete",
|
|
297
281
|
);
|
|
298
282
|
|
|
@@ -304,7 +288,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
304
288
|
await seedFileRef(uuid(203), TENANT_A, "user-anon-files", "shared.pdf");
|
|
305
289
|
|
|
306
290
|
await fileRefDeleteHook(
|
|
307
|
-
{ db: stack.db,
|
|
291
|
+
{ db: stack.db, tenantId: TENANT_A, userId: "user-anon-files" },
|
|
308
292
|
"anonymize",
|
|
309
293
|
);
|
|
310
294
|
|
|
@@ -321,10 +305,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
321
305
|
await seedFileRef(uuid(302), TENANT_B, "shared-user", "tenantB.pdf");
|
|
322
306
|
|
|
323
307
|
// Tenant A loescht alle Files von "shared-user"
|
|
324
|
-
await fileRefDeleteHook(
|
|
325
|
-
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "shared-user" },
|
|
326
|
-
"delete",
|
|
327
|
-
);
|
|
308
|
+
await fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "shared-user" }, "delete");
|
|
328
309
|
|
|
329
310
|
const aRemaining = await fetchFileRefs(TENANT_A, "shared-user");
|
|
330
311
|
const bRemaining = await fetchFileRefs(TENANT_B, "shared-user");
|
|
@@ -338,7 +319,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
338
319
|
await seedFileRef(uuid(401), TENANT_A, "user-idem-files", "f.pdf");
|
|
339
320
|
|
|
340
321
|
await fileRefDeleteHook(
|
|
341
|
-
{ db: stack.db,
|
|
322
|
+
{ db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" },
|
|
342
323
|
"delete",
|
|
343
324
|
);
|
|
344
325
|
const afterFirst = await fetchFileRefs(TENANT_A, "user-idem-files");
|
|
@@ -346,10 +327,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
346
327
|
|
|
347
328
|
// Zweiter Call: kein Crash + State weiter 0 Files
|
|
348
329
|
await expect(
|
|
349
|
-
fileRefDeleteHook(
|
|
350
|
-
{ db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
|
|
351
|
-
"delete",
|
|
352
|
-
),
|
|
330
|
+
fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" }, "delete"),
|
|
353
331
|
).resolves.toBeUndefined();
|
|
354
332
|
const afterSecond = await fetchFileRefs(TENANT_A, "user-idem-files");
|
|
355
333
|
expect(afterSecond).toHaveLength(0);
|
|
@@ -76,10 +76,6 @@ describe("ProfileScreen", () => {
|
|
|
76
76
|
expect(view.getByTestId("profile-email")).toBeTruthy();
|
|
77
77
|
expect(view.getByTestId("profile-password")).toBeTruthy();
|
|
78
78
|
expect(view.getByTestId("profile-danger")).toBeTruthy();
|
|
79
|
-
// 607/4: page heading is <h1> (Heading variant="page") — the danger-zone
|
|
80
|
-
// title must be <h2>, not Card's default <h3>, so the levels don't skip.
|
|
81
|
-
expect(view.getByTestId("profile-danger").querySelector("h2")).not.toBeNull();
|
|
82
|
-
expect(view.getByTestId("profile-danger").querySelector("h3")).toBeNull();
|
|
83
79
|
expect(view.getByTestId("profile-email-current").textContent).toContain("marc@example.com");
|
|
84
80
|
expect(view.getByTestId("profile-danger-delete")).toBeTruthy();
|
|
85
81
|
// Echte i18n: kein einziger roher Key im sichtbaren Text.
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
access,
|
|
3
|
-
createSystemUser,
|
|
4
|
-
defineWriteHandler,
|
|
5
|
-
SYSTEM_TENANT_ID,
|
|
6
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
4
|
import { z } from "zod";
|
|
9
5
|
import { AuthErrors, verifyPassword } from "../../auth-email-password";
|
|
10
|
-
import { UserErrors, UserHandlers, UserQueries } from "../../user";
|
|
6
|
+
import { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "../../user";
|
|
11
7
|
import { UserProfileErrors } from "../constants";
|
|
12
8
|
|
|
13
9
|
// Gleiche Failure-Shape wie auth-email-password (anti-enumeration):
|
|
@@ -70,10 +66,11 @@ export const changeEmailWrite = defineWriteHandler({
|
|
|
70
66
|
);
|
|
71
67
|
}
|
|
72
68
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
const
|
|
69
|
+
// Stream-Tenant-Auflösung wie in change-password: das user-Aggregate
|
|
70
|
+
// ist systemScope, sein Event-Stream kann in einem anderen Tenant
|
|
71
|
+
// liegen als die Session — optimistic locking braucht den echten.
|
|
72
|
+
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, event.user.id, USER_FEATURE);
|
|
73
|
+
const writer = createSystemUser(streamTenant ?? event.user.tenantId);
|
|
77
74
|
|
|
78
75
|
const writeRes = await ctx.writeAs(writer, UserHandlers.update, {
|
|
79
76
|
id: me.id,
|
|
@@ -246,17 +246,6 @@ function DangerZoneSection({
|
|
|
246
246
|
}): ReactNode {
|
|
247
247
|
const t = useTranslation();
|
|
248
248
|
const { Button, Banner, Dialog, Card } = usePrimitives();
|
|
249
|
-
// Card.slots.title always renders <h3> — with the page's <h1> (variant="page")
|
|
250
|
-
// as this screen's only other heading, that skips <h2> (607/4). An explicit
|
|
251
|
-
// header slot with a real <h2> keeps the levels contiguous; layout mirrors
|
|
252
|
-
// DefaultCard's own default-header markup.
|
|
253
|
-
const dangerZoneHeader = (
|
|
254
|
-
<div className="flex flex-wrap items-start justify-between gap-3 px-6 pt-6 pb-4">
|
|
255
|
-
<h2 className="text-base font-semibold leading-none tracking-tight">
|
|
256
|
-
{t("profile.danger.title")}
|
|
257
|
-
</h2>
|
|
258
|
-
</div>
|
|
259
|
-
);
|
|
260
249
|
const dispatcher = useDispatcher();
|
|
261
250
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
262
251
|
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
@@ -301,7 +290,7 @@ function DangerZoneSection({
|
|
|
301
290
|
<Card
|
|
302
291
|
testId="profile-danger"
|
|
303
292
|
className="border-destructive/40"
|
|
304
|
-
slots={{
|
|
293
|
+
slots={{ title: t("profile.danger.title"), footer }}
|
|
305
294
|
>
|
|
306
295
|
<div className="flex flex-col gap-4">
|
|
307
296
|
{deletionRequested ? (
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { MIN_HMAC_SECRET_LENGTH } from "../constants";
|
|
3
|
-
import { createAuthEmailPasswordFeature } from "../feature";
|
|
4
|
-
|
|
5
|
-
// A short HMAC secret makes reset/verify tokens forgeable (account takeover),
|
|
6
|
-
// so the factory must fail fast — same bar as the ≥32-char JWT_SECRET check.
|
|
7
|
-
|
|
8
|
-
const okSecret = "x".repeat(MIN_HMAC_SECRET_LENGTH);
|
|
9
|
-
const shortSecret = "x".repeat(MIN_HMAC_SECRET_LENGTH - 1);
|
|
10
|
-
const appUrl = "https://app.example.com/flow";
|
|
11
|
-
|
|
12
|
-
describe("createAuthEmailPasswordFeature hmacSecret validation", () => {
|
|
13
|
-
test("rejects a short passwordReset.hmacSecret", () => {
|
|
14
|
-
expect(() =>
|
|
15
|
-
createAuthEmailPasswordFeature({ passwordReset: { hmacSecret: shortSecret, appUrl } }),
|
|
16
|
-
).toThrow(/passwordReset\.hmacSecret must be/);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("rejects a short emailVerification.hmacSecret", () => {
|
|
20
|
-
expect(() =>
|
|
21
|
-
createAuthEmailPasswordFeature({ emailVerification: { hmacSecret: shortSecret, appUrl } }),
|
|
22
|
-
).toThrow(/emailVerification\.hmacSecret must be/);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("accepts secrets at the minimum length", () => {
|
|
26
|
-
expect(() =>
|
|
27
|
-
createAuthEmailPasswordFeature({
|
|
28
|
-
passwordReset: { hmacSecret: okSecret, appUrl },
|
|
29
|
-
emailVerification: { hmacSecret: okSecret, appUrl },
|
|
30
|
-
}),
|
|
31
|
-
).not.toThrow();
|
|
32
|
-
});
|
|
33
|
-
});
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
3
|
-
|
|
4
|
-
const medianMs = async (fn: () => Promise<unknown>, runs = 5): Promise<number> => {
|
|
5
|
-
const samples: number[] = [];
|
|
6
|
-
for (let i = 0; i < runs; i++) {
|
|
7
|
-
const start = performance.now();
|
|
8
|
-
await fn();
|
|
9
|
-
samples.push(performance.now() - start);
|
|
10
|
-
}
|
|
11
|
-
samples.sort((a, b) => a - b);
|
|
12
|
-
return samples[Math.floor(samples.length / 2)] ?? 0;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
// #774: the login no-user path must cost the same argon2 latency as a real
|
|
16
|
-
// verify, otherwise response timing leaks whether an email is registered.
|
|
17
|
-
describe("verifyDummyPassword (anti-enumeration timing)", () => {
|
|
18
|
-
test("resolves to void, never matches", async () => {
|
|
19
|
-
expect(await verifyDummyPassword("whatever")).toBeUndefined();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("burns real argon2 cost, comparable to a failed verify (not a no-op)", async () => {
|
|
23
|
-
const realHash = await hashPassword("correct-horse-battery");
|
|
24
|
-
// warm the lazily-cached dummy hash so we time the verify, not the one-off hash
|
|
25
|
-
await verifyDummyPassword("warmup");
|
|
26
|
-
|
|
27
|
-
const realMs = await medianMs(() => verifyPassword(realHash, "wrong-password"));
|
|
28
|
-
const dummyMs = await medianMs(() => verifyDummyPassword("wrong-password"));
|
|
29
|
-
|
|
30
|
-
// A skipped miss-path (the bug) is sub-millisecond; a real argon2 verify
|
|
31
|
-
// is ~20ms. A half-of-real floor is a wide, non-flaky bound that still
|
|
32
|
-
// fails hard if the dummy verify is ever removed.
|
|
33
|
-
expect(dummyMs).toBeGreaterThan(realMs * 0.5);
|
|
34
|
-
});
|
|
35
|
-
});
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { renderHook } from "@testing-library/react";
|
|
3
|
-
import { useUrlToken } from "../auth-form-primitives";
|
|
4
|
-
|
|
5
|
-
// #774: magic-link tokens must not linger in browser history / Referer.
|
|
6
|
-
// useUrlToken reads the token once, then scrubs the param via replaceState.
|
|
7
|
-
// (dom.preload resets window.location to http://localhost/ after each test.)
|
|
8
|
-
describe("useUrlToken (magic-link history hygiene)", () => {
|
|
9
|
-
test("reads ?token= and strips it from the URL, keeping other params", () => {
|
|
10
|
-
window.history.replaceState(null, "", "http://localhost/reset?token=secret-abc&keep=1");
|
|
11
|
-
const { result } = renderHook(() => useUrlToken());
|
|
12
|
-
expect(result.current).toBe("secret-abc");
|
|
13
|
-
expect(window.location.search).toBe("?keep=1");
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("no token param → URL untouched", () => {
|
|
17
|
-
window.history.replaceState(null, "", "http://localhost/reset?keep=1");
|
|
18
|
-
const { result } = renderHook(() => useUrlToken());
|
|
19
|
-
expect(result.current).toBe("");
|
|
20
|
-
expect(window.location.search).toBe("?keep=1");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("explicit override short-circuits both URL read and scrub", () => {
|
|
24
|
-
window.history.replaceState(null, "", "http://localhost/reset?token=url-token");
|
|
25
|
-
const { result } = renderHook(() => useUrlToken("prop-token"));
|
|
26
|
-
expect(result.current).toBe("prop-token");
|
|
27
|
-
expect(window.location.search).toBe("?token=url-token");
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
// Pins the legacy → envelope migration story for encrypted config values:
|
|
2
|
-
// 1. rows written under the old CONFIG_ENCRYPTION_KEY format stay readable
|
|
3
|
-
// as long as the legacy key is wired as the cipher's fallback,
|
|
4
|
-
// 2. reading an encrypted row WITHOUT any cipher throws (the pre-envelope
|
|
5
|
-
// code silently returned the ciphertext as the value),
|
|
6
|
-
// 3. the reencrypt job migrates legacy rows onto the envelope format
|
|
7
|
-
// through the event-store executor (event payload carries the new
|
|
8
|
-
// envelope, so a projection rebuild no longer needs the legacy key),
|
|
9
|
-
// and is idempotent on re-run.
|
|
10
|
-
|
|
11
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
-
import { randomBytes } from "node:crypto";
|
|
13
|
-
import { asRawClient, fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
-
import {
|
|
15
|
-
createEncryptionProvider,
|
|
16
|
-
createEventStoreExecutor,
|
|
17
|
-
createTenantDb,
|
|
18
|
-
} from "@cosmicdrift/kumiko-framework/db";
|
|
19
|
-
import {
|
|
20
|
-
type AppContext,
|
|
21
|
-
access,
|
|
22
|
-
createRegistry,
|
|
23
|
-
createSystemUser,
|
|
24
|
-
createTenantConfig,
|
|
25
|
-
defineFeature,
|
|
26
|
-
type TenantId,
|
|
27
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
-
import {
|
|
29
|
-
createEnvelopeCipher,
|
|
30
|
-
type EnvelopeCipher,
|
|
31
|
-
isStoredEnvelope,
|
|
32
|
-
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
33
|
-
import {
|
|
34
|
-
setupTestStack,
|
|
35
|
-
type TestStack,
|
|
36
|
-
testTenantId,
|
|
37
|
-
unsafePushTables,
|
|
38
|
-
} from "@cosmicdrift/kumiko-framework/stack";
|
|
39
|
-
import { createTestMasterKeyProvider } from "@cosmicdrift/kumiko-framework/testing";
|
|
40
|
-
import { createConfigFeature } from "../feature";
|
|
41
|
-
import { reencryptJob } from "../handlers/reencrypt.job";
|
|
42
|
-
import { createConfigResolver } from "../resolver";
|
|
43
|
-
import { configValueEntity, configValuesTable } from "../table";
|
|
44
|
-
|
|
45
|
-
const KEY = "enc-mig:config:secret-pass";
|
|
46
|
-
const TENANT: TenantId = testTenantId(1);
|
|
47
|
-
const PLAINTEXT = "legacy-smtp-password";
|
|
48
|
-
|
|
49
|
-
const keyDef = createTenantConfig("text", {
|
|
50
|
-
encrypted: true,
|
|
51
|
-
read: access.all,
|
|
52
|
-
write: access.all,
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
// legacy single-key provider — the pre-envelope CONFIG_ENCRYPTION_KEY world
|
|
56
|
-
const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
|
|
57
|
-
const masterKeyProvider = createTestMasterKeyProvider();
|
|
58
|
-
const cipherWithLegacy = createEnvelopeCipher(masterKeyProvider, { legacy });
|
|
59
|
-
const cipherWithoutLegacy = createEnvelopeCipher(masterKeyProvider);
|
|
60
|
-
|
|
61
|
-
const encMigFeature = defineFeature("enc-mig", (r) => {
|
|
62
|
-
r.config({ keys: { "secret-pass": keyDef } });
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
|
|
66
|
-
entityName: "config-value",
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
let stack: TestStack;
|
|
70
|
-
|
|
71
|
-
async function seedLegacyRow(): Promise<string> {
|
|
72
|
-
const legacyValue = legacy.encrypt(JSON.stringify(PLAINTEXT));
|
|
73
|
-
const systemUser = createSystemUser(TENANT);
|
|
74
|
-
const tdb = createTenantDb(stack.db, TENANT, "system");
|
|
75
|
-
const result = await executor.create(
|
|
76
|
-
{ key: KEY, value: legacyValue, tenantId: TENANT, userId: null },
|
|
77
|
-
systemUser,
|
|
78
|
-
tdb,
|
|
79
|
-
);
|
|
80
|
-
if (!result.isSuccess) throw new Error(`seed failed: ${result.error.code}`);
|
|
81
|
-
return (result.data as { id: string }).id;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function readStoredValue(): Promise<string> {
|
|
85
|
-
const row = await fetchOne<{ value: string }>(stack.db, configValuesTable, {
|
|
86
|
-
key: KEY,
|
|
87
|
-
tenantId: TENANT,
|
|
88
|
-
});
|
|
89
|
-
if (!row) throw new Error("row missing");
|
|
90
|
-
return row.value;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
type ReencryptCtx = Pick<AppContext, "db" | "masterKeyProvider" | "configEncryption" | "registry">;
|
|
94
|
-
function jobCtx(cipher: EnvelopeCipher): Parameters<typeof reencryptJob>[1] {
|
|
95
|
-
const ctx: ReencryptCtx = {
|
|
96
|
-
db: stack.db,
|
|
97
|
-
masterKeyProvider,
|
|
98
|
-
configEncryption: cipher,
|
|
99
|
-
registry: stack.registry,
|
|
100
|
-
};
|
|
101
|
-
return ctx as unknown as Parameters<typeof reencryptJob>[1];
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
beforeAll(async () => {
|
|
105
|
-
stack = await setupTestStack({ features: [createConfigFeature(), encMigFeature] });
|
|
106
|
-
await unsafePushTables(stack.db, { configValuesTable });
|
|
107
|
-
await seedLegacyRow();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
afterAll(async () => {
|
|
111
|
-
await stack.cleanup();
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("legacy encrypted config rows", () => {
|
|
115
|
-
test("stay readable through the cipher's legacy fallback (get + cascade path)", async () => {
|
|
116
|
-
const resolver = createConfigResolver({ cipher: cipherWithLegacy });
|
|
117
|
-
const value = await resolver.get(KEY, keyDef, TENANT, "u1", stack.db);
|
|
118
|
-
expect(value).toBe(PLAINTEXT);
|
|
119
|
-
|
|
120
|
-
const cascade = await resolver.getCascade(KEY, keyDef, TENANT, "u1", stack.db);
|
|
121
|
-
const active = cascade.levels.find((l) => l.hasValue);
|
|
122
|
-
expect(active?.value).toBe(PLAINTEXT);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("throw without any cipher instead of leaking ciphertext as the value", async () => {
|
|
126
|
-
const resolver = createConfigResolver({});
|
|
127
|
-
await expect(resolver.get(KEY, keyDef, TENANT, "u1", stack.db)).rejects.toThrow(
|
|
128
|
-
/encrypted but no cipher/,
|
|
129
|
-
);
|
|
130
|
-
await expect(resolver.getCascade(KEY, keyDef, TENANT, "u1", stack.db)).rejects.toThrow(
|
|
131
|
-
/encrypted but no cipher/,
|
|
132
|
-
);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("throw with a remediation hint when the legacy key is missing", async () => {
|
|
136
|
-
const resolver = createConfigResolver({ cipher: cipherWithoutLegacy });
|
|
137
|
-
await expect(resolver.get(KEY, keyDef, TENANT, "u1", stack.db)).rejects.toThrow(/legacy/);
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
describe("config:reencrypt job", () => {
|
|
142
|
-
test("migrates the legacy row to envelope format via the executor", async () => {
|
|
143
|
-
expect((await readStoredValue()).startsWith("{")).toBe(false);
|
|
144
|
-
|
|
145
|
-
await reencryptJob({}, jobCtx(cipherWithLegacy));
|
|
146
|
-
|
|
147
|
-
const stored = await readStoredValue();
|
|
148
|
-
expect(stored.startsWith("{")).toBe(true);
|
|
149
|
-
expect(isStoredEnvelope(JSON.parse(stored))).toBe(true);
|
|
150
|
-
|
|
151
|
-
// decryptable WITHOUT the legacy key now — retiring CONFIG_ENCRYPTION_KEY
|
|
152
|
-
// after a clean run is safe
|
|
153
|
-
const resolver = createConfigResolver({ cipher: cipherWithoutLegacy });
|
|
154
|
-
expect(await resolver.get(KEY, keyDef, TENANT, "u1", stack.db)).toBe(PLAINTEXT);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test("event payload carries the new envelope — rebuild-safe without legacy key", async () => {
|
|
158
|
-
// The migration wrote a configValue.updated event whose payload holds
|
|
159
|
-
// the envelope value; replaying events verbatim reproduces the
|
|
160
|
-
// migrated state, so the final projection never needs the legacy key.
|
|
161
|
-
const registryCheck = createRegistry([createConfigFeature(), encMigFeature]);
|
|
162
|
-
expect(registryCheck.getConfigKey(KEY)?.encrypted).toBe(true);
|
|
163
|
-
|
|
164
|
-
const events = await asRawClient(stack.db).unsafe<{
|
|
165
|
-
payload: { changes?: { value?: string } };
|
|
166
|
-
}>(
|
|
167
|
-
`SELECT payload FROM kumiko_events WHERE aggregate_type = 'config-value' AND type LIKE '%.updated' ORDER BY created_at DESC LIMIT 1`,
|
|
168
|
-
);
|
|
169
|
-
const lastValue = events[0]?.payload?.changes?.value;
|
|
170
|
-
expect(typeof lastValue).toBe("string");
|
|
171
|
-
expect((lastValue as string).startsWith("{")).toBe(true);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test("is idempotent — a second run migrates nothing", async () => {
|
|
175
|
-
const before = await readStoredValue();
|
|
176
|
-
await reencryptJob({}, jobCtx(cipherWithLegacy));
|
|
177
|
-
expect(await readStoredValue()).toBe(before);
|
|
178
|
-
});
|
|
179
|
-
});
|