@cosmicdrift/kumiko-bundled-features 0.220.1 → 0.222.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 +10 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +85 -11
- package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
- package/src/audit/__tests__/audit.integration.test.ts +3 -1
- package/src/audit/__tests__/constants.test.ts +13 -0
- package/src/audit/constants.ts +10 -1
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +2 -0
- package/src/audit/web/audit-log-detail-screen.tsx +35 -15
- package/src/audit/web/audit-log-screen.tsx +45 -33
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +15 -10
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/seeding.ts +3 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/auth-mfa/hmac-token-codec.ts +75 -0
- package/src/auth-mfa/mfa-challenge-token.ts +10 -65
- package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/channel-email/__tests__/email-channel.test.ts +19 -0
- package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
- package/src/channel-email/email-channel.ts +14 -7
- package/src/channel-email/pii-guard.ts +8 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/crypto-shredding/constants.ts +1 -0
- package/src/crypto-shredding/feature.ts +6 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
- package/src/crypto-shredding/index.ts +2 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +8 -3
- package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
- package/src/files-provider-s3/s3-provider.ts +45 -0
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
- package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
- package/src/files-tenant-data/changes.json +1 -0
- package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
- package/src/files-tenant-data/hooks.ts +93 -0
- package/src/files-tenant-data/index.ts +58 -0
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/db/queries/cleanup.ts +17 -0
- package/src/form-draft/handlers/cleanup.job.ts +51 -30
- package/src/form-draft/handlers/discard.write.ts +33 -15
- package/src/form-draft/handlers/get.query.ts +4 -2
- package/src/form-draft/handlers/list.query.ts +4 -1
- package/src/form-draft/lookup.ts +12 -5
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +286 -23
- package/src/jobs/constants.ts +1 -0
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/handlers/retry.write.ts +26 -6
- package/src/jobs/i18n.ts +3 -0
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/sessions/session-callbacks.ts +1 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
- package/src/tags/feature.ts +7 -4
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
- package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
- package/src/tenant/changes.json +1 -1
- package/src/tenant/constants.ts +5 -1
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/remove-member.write.ts +12 -0
- package/src/tenant/handlers/team-list.query.ts +2 -2
- package/src/tenant/handlers/update-member-roles.write.ts +11 -38
- package/src/tenant/i18n.ts +2 -2
- package/src/tenant/last-tenant-admin.ts +39 -0
- package/src/tenant/screens.ts +2 -0
- package/src/tenant/seeding.ts +3 -1
- package/src/tenant-lifecycle/feature.ts +1 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
- package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/src/tenant-lifecycle/stages.ts +5 -0
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +1 -1
- package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/changes.json +12 -5
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +18 -5
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
- package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
- package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +33 -6
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
- package/src/user-profile/web/profile-screen.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.222.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"./file-derivatives": "./src/file-derivatives/index.ts",
|
|
72
72
|
"./derivatives-sharp": "./src/derivatives-sharp/index.ts",
|
|
73
73
|
"./files": "./src/files/index.ts",
|
|
74
|
+
"./files-tenant-data": "./src/files-tenant-data/index.ts",
|
|
74
75
|
"./user-data-rights": "./src/user-data-rights/index.ts",
|
|
75
76
|
"./user-data-rights/web": "./src/user-data-rights/web/index.ts",
|
|
76
77
|
"./user-data-rights-defaults": "./src/user-data-rights-defaults/index.ts",
|
|
@@ -126,12 +127,12 @@
|
|
|
126
127
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
128
|
},
|
|
128
129
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
130
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.222.0",
|
|
131
|
+
"@cosmicdrift/kumiko-framework": "0.222.0",
|
|
132
|
+
"@cosmicdrift/kumiko-headless": "0.222.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer": "0.222.0",
|
|
134
|
+
"@cosmicdrift/kumiko-renderer-web": "0.222.0",
|
|
135
|
+
"@cosmicdrift/kumiko-types": "0.222.0",
|
|
135
136
|
"@mollie/api-client": "^4.5.0",
|
|
136
137
|
"@node-rs/argon2": "^2.0.2",
|
|
137
138
|
"@types/mailparser": "^3.4.6",
|
|
@@ -160,7 +161,7 @@
|
|
|
160
161
|
"devDependencies": {
|
|
161
162
|
"@testing-library/user-event": "^14.6.1",
|
|
162
163
|
"@types/qrcode": "^1.5.5",
|
|
163
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
164
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
164
|
+
"@cosmicdrift/kumiko-locale-de": "0.222.0",
|
|
165
|
+
"@cosmicdrift/kumiko-locale-es": "0.222.0"
|
|
165
166
|
}
|
|
166
167
|
}
|
|
@@ -294,7 +294,7 @@ describe("access-invalidation event consumer", () => {
|
|
|
294
294
|
{
|
|
295
295
|
id: userId,
|
|
296
296
|
version: detail["version"],
|
|
297
|
-
changes: { roles:
|
|
297
|
+
changes: { roles: ["SystemAdmin"] },
|
|
298
298
|
},
|
|
299
299
|
admin,
|
|
300
300
|
);
|
|
@@ -307,6 +307,7 @@ describe("access-invalidation event consumer", () => {
|
|
|
307
307
|
const sessionRow = await fetchOne<{ revokedAt: unknown }>(stack.db, userSessionTable, {
|
|
308
308
|
id: sid,
|
|
309
309
|
});
|
|
310
|
+
expect(sessionRow).toBeTruthy();
|
|
310
311
|
expect(sessionRow?.revokedAt).not.toBeNull();
|
|
311
312
|
});
|
|
312
313
|
});
|
|
@@ -21,7 +21,8 @@ import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-w
|
|
|
21
21
|
import { render, waitFor } from "@testing-library/react";
|
|
22
22
|
import type { ReactNode } from "react";
|
|
23
23
|
import { TenantQueries } from "../../tenant/constants";
|
|
24
|
-
import { AuditQueries } from "../constants";
|
|
24
|
+
import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries } from "../constants";
|
|
25
|
+
import { AuditLogDetailScreen } from "../web/audit-log-detail-screen";
|
|
25
26
|
import { AuditLogScreen } from "../web/audit-log-screen";
|
|
26
27
|
import { defaultTranslations } from "../web/i18n";
|
|
27
28
|
|
|
@@ -124,14 +125,32 @@ const MEMBERS: readonly MemberRowFixture[] = [
|
|
|
124
125
|
{ userId: "user-2", email: "bob@example.com", displayName: null },
|
|
125
126
|
];
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
type AuditDetailFixture = AuditRowFixture & { readonly metadata: Record<string, unknown> };
|
|
129
|
+
|
|
130
|
+
function detailFixtureFor(row: AuditRowFixture): AuditDetailFixture {
|
|
131
|
+
return { ...row, metadata: {} };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type MembersResult =
|
|
135
|
+
| { readonly isSuccess: true; readonly data: readonly MemberRowFixture[] }
|
|
136
|
+
| { readonly isSuccess: false; readonly error: { readonly message: string } };
|
|
137
|
+
|
|
138
|
+
function makeDispatcher(
|
|
139
|
+
options: {
|
|
140
|
+
readonly detail?: AuditDetailFixture | null;
|
|
141
|
+
readonly membersResult?: MembersResult;
|
|
142
|
+
} = {},
|
|
143
|
+
): Dispatcher {
|
|
128
144
|
const statusStore = createStore<DispatcherStatus>("online");
|
|
129
145
|
const query = (async (type: string) => {
|
|
130
146
|
if (type === AuditQueries.list) {
|
|
131
147
|
return { isSuccess: true, data: { rows: AUDIT_ROWS, nextBefore: null } };
|
|
132
148
|
}
|
|
149
|
+
if (type === AuditQueries.details) {
|
|
150
|
+
return { isSuccess: true, data: options.detail ?? null };
|
|
151
|
+
}
|
|
133
152
|
if (type === TenantQueries.members) {
|
|
134
|
-
return { isSuccess: true, data: MEMBERS };
|
|
153
|
+
return options.membersResult ?? { isSuccess: true, data: MEMBERS };
|
|
135
154
|
}
|
|
136
155
|
return { isSuccess: true, data: null };
|
|
137
156
|
}) as unknown as Dispatcher["query"];
|
|
@@ -146,7 +165,12 @@ function makeDispatcher(): Dispatcher {
|
|
|
146
165
|
} as unknown as Dispatcher; // @cast-boundary test-stub
|
|
147
166
|
}
|
|
148
167
|
|
|
149
|
-
function
|
|
168
|
+
function renderWithProviders(
|
|
169
|
+
ui: ReactNode,
|
|
170
|
+
options: { readonly nav?: NavApi; readonly dispatcher?: Dispatcher } = {},
|
|
171
|
+
): ReturnType<typeof render> {
|
|
172
|
+
const nav = options.nav ?? stubNav;
|
|
173
|
+
const dispatcher = options.dispatcher ?? makeDispatcher();
|
|
150
174
|
const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
|
|
151
175
|
<TokensProvider value={stubTokens}>
|
|
152
176
|
<LocaleProvider
|
|
@@ -154,16 +178,25 @@ function renderScreen(): ReturnType<typeof render> {
|
|
|
154
178
|
fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
|
|
155
179
|
>
|
|
156
180
|
<PrimitivesProvider value={defaultPrimitives}>
|
|
157
|
-
<NavProvider value={
|
|
181
|
+
<NavProvider value={nav}>
|
|
158
182
|
<LiveEventsProvider value={stubLiveEvents}>
|
|
159
|
-
<DispatcherProvider dispatcher={
|
|
183
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
160
184
|
</LiveEventsProvider>
|
|
161
185
|
</NavProvider>
|
|
162
186
|
</PrimitivesProvider>
|
|
163
187
|
</LocaleProvider>
|
|
164
188
|
</TokensProvider>
|
|
165
189
|
);
|
|
166
|
-
return render(
|
|
190
|
+
return render(ui, { wrapper });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderScreen(dispatcher?: Dispatcher): ReturnType<typeof render> {
|
|
194
|
+
return renderWithProviders(<AuditLogScreen />, { dispatcher });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderDetailScreen(entityId: string, dispatcher: Dispatcher): ReturnType<typeof render> {
|
|
198
|
+
const nav: NavApi = { ...stubNav, route: { screenId: AUDIT_LOG_DETAIL_SCREEN_ID, entityId } };
|
|
199
|
+
return renderWithProviders(<AuditLogDetailScreen />, { nav, dispatcher });
|
|
167
200
|
}
|
|
168
201
|
|
|
169
202
|
describe("AuditLogScreen — actor name resolution", () => {
|
|
@@ -185,14 +218,14 @@ describe("AuditLogScreen — actor name resolution", () => {
|
|
|
185
218
|
expect(view.getByTestId("cell-2-actor").textContent).toBe("bob@example.com");
|
|
186
219
|
});
|
|
187
220
|
|
|
188
|
-
test("createdBy not in members list → cell
|
|
221
|
+
test("createdBy not in members list → cell shows Unknown actor, never the raw id", async () => {
|
|
189
222
|
const view = renderScreen();
|
|
190
223
|
await waitFor(() => {
|
|
191
224
|
const cell = view.queryByTestId("cell-3-actor");
|
|
192
225
|
if (cell === null) throw new Error("actor cell not rendered yet");
|
|
193
226
|
});
|
|
194
227
|
const cell = view.getByTestId("cell-3-actor");
|
|
195
|
-
expect(cell.textContent).toBe("");
|
|
228
|
+
expect(cell.textContent).toBe("Unknown actor");
|
|
196
229
|
expect(cell.textContent).not.toBe("user-missing");
|
|
197
230
|
});
|
|
198
231
|
|
|
@@ -215,14 +248,55 @@ describe("AuditLogScreen — actor name resolution", () => {
|
|
|
215
248
|
expect(view.getByTestId("cell-4-actor").textContent).toBe("System");
|
|
216
249
|
});
|
|
217
250
|
|
|
218
|
-
test("createdBy is an unresolvable UUID →
|
|
251
|
+
test("createdBy is an unresolvable UUID → Unknown actor label, raw UUID never rendered", async () => {
|
|
219
252
|
const view = renderScreen();
|
|
220
253
|
await waitFor(() => {
|
|
221
254
|
const cell = view.queryByTestId("cell-5-actor");
|
|
222
255
|
if (cell === null) throw new Error("actor cell not rendered yet");
|
|
223
256
|
});
|
|
224
257
|
const cell = view.getByTestId("cell-5-actor");
|
|
225
|
-
expect(cell.textContent).toBe("");
|
|
258
|
+
expect(cell.textContent).toBe("Unknown actor");
|
|
226
259
|
expect(view.container.textContent).not.toContain("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
|
|
227
260
|
});
|
|
228
261
|
});
|
|
262
|
+
|
|
263
|
+
describe("AuditLogScreen — members query failure", () => {
|
|
264
|
+
test("members query returns isSuccess:false → list still renders, no crash", async () => {
|
|
265
|
+
const dispatcher = makeDispatcher({
|
|
266
|
+
membersResult: { isSuccess: false, error: { message: "members unavailable" } },
|
|
267
|
+
});
|
|
268
|
+
const view = renderScreen(dispatcher);
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
const cell = view.queryByTestId("cell-1-type");
|
|
271
|
+
if (cell === null) throw new Error("table not rendered yet");
|
|
272
|
+
});
|
|
273
|
+
expect(view.getByTestId("cell-1-type").textContent).toBe("widget.created");
|
|
274
|
+
expect(view.getByTestId("cell-1-actor").textContent).toBe("Unknown actor");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("AuditLogDetailScreen — actor name resolution", () => {
|
|
279
|
+
test("known member with displayName → detail actor field shows the display name", async () => {
|
|
280
|
+
const row = AUDIT_ROWS[0];
|
|
281
|
+
if (row === undefined) throw new Error("fixture missing");
|
|
282
|
+
const dispatcher = makeDispatcher({ detail: detailFixtureFor(row) });
|
|
283
|
+
const view = renderDetailScreen(row.id, dispatcher);
|
|
284
|
+
await waitFor(() => {
|
|
285
|
+
const cell = view.queryByTestId("audit-detail-actor");
|
|
286
|
+
if (cell === null) throw new Error("actor field not rendered yet");
|
|
287
|
+
});
|
|
288
|
+
expect(view.getByTestId("audit-detail-actor").textContent).toBe("Alice Example");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("known member without displayName → detail actor field falls back to email", async () => {
|
|
292
|
+
const row = AUDIT_ROWS[1];
|
|
293
|
+
if (row === undefined) throw new Error("fixture missing");
|
|
294
|
+
const dispatcher = makeDispatcher({ detail: detailFixtureFor(row) });
|
|
295
|
+
const view = renderDetailScreen(row.id, dispatcher);
|
|
296
|
+
await waitFor(() => {
|
|
297
|
+
const cell = view.queryByTestId("audit-detail-actor");
|
|
298
|
+
if (cell === null) throw new Error("actor field not rendered yet");
|
|
299
|
+
});
|
|
300
|
+
expect(view.getByTestId("audit-detail-actor").textContent).toBe("bob@example.com");
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -2,11 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
4
|
import { createConfigFeature } from "../../config/feature";
|
|
5
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
5
6
|
import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
|
|
6
7
|
import { createAuditFeature } from "../feature";
|
|
7
8
|
|
|
8
9
|
describe("audit log screen + handler access alignment", () => {
|
|
9
|
-
const features = [createConfigFeature(), createAuditFeature()];
|
|
10
|
+
const features = [createConfigFeature(), createTenantFeature(), createAuditFeature()];
|
|
10
11
|
|
|
11
12
|
test("boot-validates with audit-log screen registered", () => {
|
|
12
13
|
expect(() => validateBoot(features)).not.toThrow();
|
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
unsafeCreateEntityTable,
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
23
|
import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
|
|
24
|
+
import { createConfigFeature } from "../../config";
|
|
25
|
+
import { createTenantFeature } from "../../tenant";
|
|
24
26
|
import { AuditQueries } from "../constants";
|
|
25
27
|
import { createAuditFeature } from "../feature";
|
|
26
28
|
|
|
@@ -59,7 +61,7 @@ const otherTenantAdmin: SessionUser = createTestUser({
|
|
|
59
61
|
|
|
60
62
|
beforeAll(async () => {
|
|
61
63
|
stack = await setupTestStack({
|
|
62
|
-
features: [widgetFeature, createAuditFeature()],
|
|
64
|
+
features: [widgetFeature, createConfigFeature(), createTenantFeature(), createAuditFeature()],
|
|
63
65
|
});
|
|
64
66
|
await unsafeCreateEntityTable(stack.db, widgetEntity);
|
|
65
67
|
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SYSTEM_ACTOR_ID, SYSTEM_ACTOR_IDS } from "../constants";
|
|
3
|
+
|
|
4
|
+
describe("audit constants", () => {
|
|
5
|
+
test("SYSTEM_ACTOR_ID matches the event-store system user id", () => {
|
|
6
|
+
expect(SYSTEM_ACTOR_ID).toBe("system");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("SYSTEM_ACTOR_IDS includes createSystemUser nil UUID", () => {
|
|
10
|
+
expect(SYSTEM_ACTOR_IDS.has(SYSTEM_ACTOR_ID)).toBe(true);
|
|
11
|
+
expect(SYSTEM_ACTOR_IDS.has("00000000-0000-0000-0000-000000000000")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
});
|
package/src/audit/constants.ts
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
// Qualified handler names + screen ids — shared by server feature and web client.
|
|
3
3
|
export const AUDIT_FEATURE = "audit" as const;
|
|
4
4
|
|
|
5
|
-
/** Literal `createdBy` written by
|
|
5
|
+
/** Literal `createdBy` written by hand-built system actors (secrets/jobs/…). */
|
|
6
6
|
export const SYSTEM_ACTOR_ID = "system" as const;
|
|
7
7
|
|
|
8
|
+
/** Literal `createdBy` for anonymous / unauthenticated actors. */
|
|
9
|
+
export const ANONYMOUS_USER_ID = "anonymous" as const;
|
|
10
|
+
|
|
11
|
+
/** All `createdBy` values that mean "system" in the audit UI (nil-UUID from createSystemUser + literal). */
|
|
12
|
+
export const SYSTEM_ACTOR_IDS: ReadonlySet<string> = new Set([
|
|
13
|
+
SYSTEM_ACTOR_ID,
|
|
14
|
+
"00000000-0000-0000-0000-000000000000",
|
|
15
|
+
]);
|
|
16
|
+
|
|
8
17
|
export const AuditQueries = {
|
|
9
18
|
list: "audit:query:list",
|
|
10
19
|
details: "audit:query:details",
|
package/src/audit/feature.ts
CHANGED
|
@@ -31,6 +31,8 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
31
31
|
recommended: false,
|
|
32
32
|
});
|
|
33
33
|
r.translations({ keys: AUDIT_I18N });
|
|
34
|
+
// Screens resolve actor names via tenant:query:members.
|
|
35
|
+
r.requires("tenant");
|
|
34
36
|
|
|
35
37
|
const queries = {
|
|
36
38
|
list: r.queryHandler(listQuery),
|
package/src/audit/i18n.ts
CHANGED
|
@@ -17,6 +17,8 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
17
17
|
"audit.log.col.aggregate": { en: "Aggregate" },
|
|
18
18
|
"audit.log.col.actor": { en: "Actor" },
|
|
19
19
|
"audit.log.actor.system": { en: "System" },
|
|
20
|
+
"audit.log.actor.anonymous": { en: "Anonymous" },
|
|
21
|
+
"audit.log.actor.unknown": { en: "Unknown actor" },
|
|
20
22
|
"audit.log.filter.eventType": { en: "Event type" },
|
|
21
23
|
"audit.log.filter.aggregateType": { en: "Aggregate type" },
|
|
22
24
|
"audit.log.filter.from": { en: "From" },
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
12
12
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
13
13
|
import { TenantQueries } from "../../tenant/constants";
|
|
14
|
-
import { AuditQueries,
|
|
14
|
+
import { ANONYMOUS_USER_ID, AuditQueries, SYSTEM_ACTOR_IDS } from "../constants";
|
|
15
15
|
|
|
16
16
|
type AuditDetail = {
|
|
17
17
|
readonly id: string;
|
|
@@ -34,7 +34,15 @@ type State =
|
|
|
34
34
|
| { readonly kind: "loading" }
|
|
35
35
|
| { readonly kind: "missing" }
|
|
36
36
|
| { readonly kind: "error"; readonly message: string }
|
|
37
|
-
| {
|
|
37
|
+
| {
|
|
38
|
+
readonly kind: "ready";
|
|
39
|
+
readonly event: AuditDetail;
|
|
40
|
+
/** Resolved member display string, or "" if unknown. System actor is
|
|
41
|
+
* flagged separately so the translated label is formed in render
|
|
42
|
+
* (avoids refetch on locale change — fw#2297). */
|
|
43
|
+
readonly memberLabel: string;
|
|
44
|
+
readonly isSystemActor: boolean;
|
|
45
|
+
};
|
|
38
46
|
|
|
39
47
|
export function AuditLogDetailScreen(): ReactNode {
|
|
40
48
|
const t = useTranslation();
|
|
@@ -60,21 +68,24 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
60
68
|
return;
|
|
61
69
|
}
|
|
62
70
|
const event = res.data;
|
|
63
|
-
|
|
71
|
+
if (SYSTEM_ACTOR_IDS.has(event.createdBy)) {
|
|
72
|
+
setState({ kind: "ready", event, memberLabel: "", isSystemActor: true });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Single-user filter — do not decrypt the whole tenant roster for one name.
|
|
76
|
+
const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {
|
|
77
|
+
userId: event.createdBy,
|
|
78
|
+
});
|
|
64
79
|
const member = membersRes.isSuccess
|
|
65
80
|
? membersRes.data.find((m) => m.userId === event.createdBy)
|
|
66
81
|
: undefined;
|
|
67
82
|
setState({
|
|
68
83
|
kind: "ready",
|
|
69
84
|
event,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
? t("audit.log.actor.system")
|
|
73
|
-
: member
|
|
74
|
-
? (member.displayName ?? member.email ?? "")
|
|
75
|
-
: "",
|
|
85
|
+
memberLabel: member ? (member.displayName ?? member.email ?? "") : "",
|
|
86
|
+
isSystemActor: false,
|
|
76
87
|
});
|
|
77
|
-
}, [dispatcher, eventId
|
|
88
|
+
}, [dispatcher, eventId]);
|
|
78
89
|
|
|
79
90
|
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
80
91
|
useEffect(() => {
|
|
@@ -83,7 +94,7 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
83
94
|
|
|
84
95
|
if (state.kind === "loading") {
|
|
85
96
|
return (
|
|
86
|
-
<FormScreenShell testId="audit-log-detail-screen">
|
|
97
|
+
<FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
|
|
87
98
|
<Text variant="small">{t("audit.log.detail.loading")}</Text>
|
|
88
99
|
</FormScreenShell>
|
|
89
100
|
);
|
|
@@ -91,7 +102,7 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
91
102
|
|
|
92
103
|
if (state.kind === "missing") {
|
|
93
104
|
return (
|
|
94
|
-
<FormScreenShell testId="audit-log-detail-screen">
|
|
105
|
+
<FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
|
|
95
106
|
<Banner variant="error">{t("audit.log.detail.missing")}</Banner>
|
|
96
107
|
</FormScreenShell>
|
|
97
108
|
);
|
|
@@ -99,16 +110,25 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
99
110
|
|
|
100
111
|
if (state.kind === "error") {
|
|
101
112
|
return (
|
|
102
|
-
<FormScreenShell testId="audit-log-detail-screen">
|
|
113
|
+
<FormScreenShell testId="audit-log-detail-screen" maxWidth="3xl">
|
|
103
114
|
<Banner variant="error">{state.message}</Banner>
|
|
104
115
|
</FormScreenShell>
|
|
105
116
|
);
|
|
106
117
|
}
|
|
107
118
|
|
|
108
|
-
const { event,
|
|
119
|
+
const { event, memberLabel, isSystemActor } = state;
|
|
120
|
+
const actorName = isSystemActor
|
|
121
|
+
? t("audit.log.actor.system")
|
|
122
|
+
: event.createdBy === ANONYMOUS_USER_ID
|
|
123
|
+
? t("audit.log.actor.anonymous")
|
|
124
|
+
: memberLabel || t("audit.log.actor.unknown");
|
|
109
125
|
|
|
110
126
|
return (
|
|
111
|
-
<FormScreenShell
|
|
127
|
+
<FormScreenShell
|
|
128
|
+
testId="audit-log-detail-screen"
|
|
129
|
+
maxWidth="3xl"
|
|
130
|
+
className="flex flex-col gap-6"
|
|
131
|
+
>
|
|
112
132
|
<Card slots={{ title: event.type }}>
|
|
113
133
|
<dl className="grid gap-3 text-sm">
|
|
114
134
|
<div>
|
|
@@ -14,7 +14,12 @@ import {
|
|
|
14
14
|
import { Filter, RotateCcw } from "lucide-react";
|
|
15
15
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
16
16
|
import { TenantQueries } from "../../tenant/constants";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
ANONYMOUS_USER_ID,
|
|
19
|
+
AUDIT_LOG_DETAIL_SCREEN_ID,
|
|
20
|
+
AuditQueries,
|
|
21
|
+
SYSTEM_ACTOR_IDS,
|
|
22
|
+
} from "../constants";
|
|
18
23
|
|
|
19
24
|
type AuditRow = {
|
|
20
25
|
readonly id: string;
|
|
@@ -36,7 +41,6 @@ type MemberRow = {
|
|
|
36
41
|
|
|
37
42
|
type Filters = {
|
|
38
43
|
readonly eventType: string;
|
|
39
|
-
readonly aggregateType: string;
|
|
40
44
|
readonly from: string;
|
|
41
45
|
readonly to: string;
|
|
42
46
|
};
|
|
@@ -51,7 +55,17 @@ type State =
|
|
|
51
55
|
readonly names: ReadonlyMap<string, string>;
|
|
52
56
|
};
|
|
53
57
|
|
|
54
|
-
const EMPTY_FILTERS: Filters = { eventType: "",
|
|
58
|
+
const EMPTY_FILTERS: Filters = { eventType: "", from: "", to: "" };
|
|
59
|
+
|
|
60
|
+
function actorLabel(
|
|
61
|
+
createdBy: string,
|
|
62
|
+
names: ReadonlyMap<string, string>,
|
|
63
|
+
t: (key: string) => string,
|
|
64
|
+
): string {
|
|
65
|
+
if (SYSTEM_ACTOR_IDS.has(createdBy)) return t("audit.log.actor.system");
|
|
66
|
+
if (createdBy === ANONYMOUS_USER_ID) return t("audit.log.actor.anonymous");
|
|
67
|
+
return names.get(createdBy) ?? t("audit.log.actor.unknown");
|
|
68
|
+
}
|
|
55
69
|
|
|
56
70
|
export function AuditLogScreen(): ReactNode {
|
|
57
71
|
const t = useTranslation();
|
|
@@ -70,25 +84,37 @@ export function AuditLogScreen(): ReactNode {
|
|
|
70
84
|
// mid-session and refetching on every "older" click is wasted work.
|
|
71
85
|
const membersRef = useRef<Promise<ReadonlyMap<string, string>> | null>(null);
|
|
72
86
|
|
|
87
|
+
// Tenant/dispatcher identity can change without remounting — drop the cache.
|
|
88
|
+
const dispatcherIdentityRef = useRef(dispatcher);
|
|
89
|
+
if (dispatcherIdentityRef.current !== dispatcher) {
|
|
90
|
+
dispatcherIdentityRef.current = dispatcher;
|
|
91
|
+
membersRef.current = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
const load = useCallback(
|
|
74
95
|
async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
|
|
75
96
|
setState({ kind: "loading" });
|
|
76
97
|
const f = overrideFilters ?? filtersRef.current;
|
|
77
|
-
membersRef.current
|
|
78
|
-
.query<readonly MemberRow[]>(TenantQueries.members, {})
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
98
|
+
if (!membersRef.current) {
|
|
99
|
+
const p = dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {}).then((res) => {
|
|
100
|
+
if (!res.isSuccess) {
|
|
101
|
+
// Soft failure: clear so a later filter/page retry can refetch
|
|
102
|
+
// instead of freezing an empty actor column for the session.
|
|
103
|
+
if (membersRef.current === p) membersRef.current = null;
|
|
104
|
+
return new Map<string, string>();
|
|
105
|
+
}
|
|
106
|
+
return new Map(res.data.map((m) => [m.userId, m.displayName ?? m.email ?? ""] as const));
|
|
107
|
+
});
|
|
108
|
+
membersRef.current = p;
|
|
109
|
+
void p.catch(() => {
|
|
110
|
+
if (membersRef.current === p) membersRef.current = null;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
84
113
|
const [res, names] = await Promise.all([
|
|
85
114
|
dispatcher.query<AuditResponse>(AuditQueries.list, {
|
|
86
115
|
limit: 50,
|
|
87
116
|
...(cursor !== undefined && { before: cursor }),
|
|
88
117
|
...(f.eventType.trim() !== "" && { eventType: f.eventType.trim() }),
|
|
89
|
-
...(f.aggregateType.trim() !== "" && {
|
|
90
|
-
aggregateType: f.aggregateType.trim(),
|
|
91
|
-
}),
|
|
92
118
|
...(f.from !== "" && { from: toIsoStart(f.from) }),
|
|
93
119
|
...(f.to !== "" && { to: toIsoEnd(f.to) }),
|
|
94
120
|
}),
|
|
@@ -130,7 +156,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
130
156
|
return (
|
|
131
157
|
<div className="w-full" data-testid="audit-log-screen">
|
|
132
158
|
<div className="flex flex-wrap items-end gap-4 px-6 pt-6">
|
|
133
|
-
<div className="min-w-40 flex-1">
|
|
159
|
+
<div className="min-w-40 max-w-64 flex-1">
|
|
134
160
|
<Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
|
|
135
161
|
<Input
|
|
136
162
|
kind="text"
|
|
@@ -141,18 +167,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
141
167
|
/>
|
|
142
168
|
</Field>
|
|
143
169
|
</div>
|
|
144
|
-
<div className="min-w-40 flex-1">
|
|
145
|
-
<Field id="audit-filter-aggregate" label={t("audit.log.filter.aggregateType")}>
|
|
146
|
-
<Input
|
|
147
|
-
kind="text"
|
|
148
|
-
id="audit-filter-aggregate"
|
|
149
|
-
name="audit-filter-aggregate"
|
|
150
|
-
value={filters.aggregateType}
|
|
151
|
-
onChange={(v) => setFilters((f) => ({ ...f, aggregateType: v }))}
|
|
152
|
-
/>
|
|
153
|
-
</Field>
|
|
154
|
-
</div>
|
|
155
|
-
<div className="min-w-40 flex-1">
|
|
170
|
+
<div className="min-w-40 max-w-64 flex-1">
|
|
156
171
|
<Field id="audit-filter-from" label={t("audit.log.filter.from")}>
|
|
157
172
|
<Input
|
|
158
173
|
kind="date"
|
|
@@ -163,7 +178,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
163
178
|
/>
|
|
164
179
|
</Field>
|
|
165
180
|
</div>
|
|
166
|
-
<div className="min-w-40 flex-1">
|
|
181
|
+
<div className="min-w-40 max-w-64 flex-1">
|
|
167
182
|
<Field id="audit-filter-to" label={t("audit.log.filter.to")}>
|
|
168
183
|
<Input
|
|
169
184
|
kind="date"
|
|
@@ -184,7 +199,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
184
199
|
}}
|
|
185
200
|
testId="audit-log-apply-filters"
|
|
186
201
|
>
|
|
187
|
-
<Filter className="h-4 w-4" />
|
|
202
|
+
<Filter className="h-4 w-4" aria-hidden="true" />
|
|
188
203
|
<span>{t("audit.log.filter.apply")}</span>
|
|
189
204
|
</Button>
|
|
190
205
|
<Button
|
|
@@ -197,7 +212,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
197
212
|
}}
|
|
198
213
|
testId="audit-log-reset-filters"
|
|
199
214
|
>
|
|
200
|
-
<RotateCcw className="h-4 w-4" />
|
|
215
|
+
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
|
201
216
|
<span>{t("audit.log.filter.reset")}</span>
|
|
202
217
|
</Button>
|
|
203
218
|
</div>
|
|
@@ -217,10 +232,7 @@ export function AuditLogScreen(): ReactNode {
|
|
|
217
232
|
values: {
|
|
218
233
|
when: formatWhen(row.createdAt),
|
|
219
234
|
type: row.type,
|
|
220
|
-
actor:
|
|
221
|
-
row.createdBy === SYSTEM_ACTOR_ID
|
|
222
|
-
? t("audit.log.actor.system")
|
|
223
|
-
: (state.names.get(row.createdBy) ?? ""),
|
|
235
|
+
actor: actorLabel(row.createdBy, state.names, t),
|
|
224
236
|
},
|
|
225
237
|
}))}
|
|
226
238
|
onRowClick={(row) => openDetail(row.id)}
|
|
@@ -78,6 +78,7 @@ async function seedLoginUser(opts: {
|
|
|
78
78
|
password: string;
|
|
79
79
|
tenantId?: TenantId;
|
|
80
80
|
roles?: string[];
|
|
81
|
+
locale?: string;
|
|
81
82
|
}): Promise<{ id: string; tenantId: TenantId }> {
|
|
82
83
|
const hash = await hashPassword(opts.password);
|
|
83
84
|
const created = await stack.http.writeOk<{ id: string }>(
|
|
@@ -86,6 +87,7 @@ async function seedLoginUser(opts: {
|
|
|
86
87
|
email: opts.email,
|
|
87
88
|
passwordHash: hash,
|
|
88
89
|
displayName: opts.email.split("@")[0] ?? "user",
|
|
90
|
+
locale: opts.locale,
|
|
89
91
|
},
|
|
90
92
|
systemAdmin,
|
|
91
93
|
);
|
|
@@ -134,6 +136,33 @@ describe("scenario 1: login success", () => {
|
|
|
134
136
|
},
|
|
135
137
|
);
|
|
136
138
|
expect(meRes.status).toBe(200);
|
|
139
|
+
|
|
140
|
+
// fw#2333 — a user without a stored locale must not carry a locale
|
|
141
|
+
// claim at all (not null, not "").
|
|
142
|
+
const payload = await stack.jwt.verify(body.token as string);
|
|
143
|
+
expect(payload.locale).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("correct credentials with a stored locale → JWT carries the locale claim", async () => {
|
|
147
|
+
const seed = await seedLoginUser({
|
|
148
|
+
email: "good-locale@example.com",
|
|
149
|
+
password: "correct-horse-battery",
|
|
150
|
+
roles: ["User"],
|
|
151
|
+
locale: "de-DE",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
155
|
+
email: "good-locale@example.com",
|
|
156
|
+
password: "correct-horse-battery",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(res.status).toBe(200);
|
|
160
|
+
const body = await res.json();
|
|
161
|
+
expect(body.token).toBeTypeOf("string");
|
|
162
|
+
|
|
163
|
+
const payload = await stack.jwt.verify(body.token as string);
|
|
164
|
+
expect(payload.locale).toBe("de-DE");
|
|
165
|
+
expect(payload.sub).toBe(seed.id);
|
|
137
166
|
});
|
|
138
167
|
});
|
|
139
168
|
|