@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.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>",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"./custom-fields": "./src/custom-fields/index.ts",
|
|
39
39
|
"./custom-fields/web": "./src/custom-fields/web/index.ts",
|
|
40
40
|
"./tags": "./src/tags/index.ts",
|
|
41
|
+
"./tenant-settings": "./src/tenant-settings/index.ts",
|
|
41
42
|
"./tags/web": "./src/tags/web/index.ts",
|
|
42
43
|
"./folders": "./src/folders/index.ts",
|
|
43
44
|
"./folders/web": "./src/folders/web/index.ts",
|
|
@@ -118,11 +119,11 @@
|
|
|
118
119
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
119
120
|
},
|
|
120
121
|
"dependencies": {
|
|
121
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
124
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
125
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
122
|
+
"@cosmicdrift/kumiko-dispatcher-live": "2.0.0",
|
|
123
|
+
"@cosmicdrift/kumiko-framework": "2.0.0",
|
|
124
|
+
"@cosmicdrift/kumiko-headless": "2.0.0",
|
|
125
|
+
"@cosmicdrift/kumiko-renderer": "2.0.0",
|
|
126
|
+
"@cosmicdrift/kumiko-renderer-web": "2.0.0",
|
|
126
127
|
"@mollie/api-client": "^4.5.0",
|
|
127
128
|
"imapflow": "^1.3.3",
|
|
128
129
|
"mailparser": "^3.9.8",
|
|
@@ -146,6 +147,9 @@
|
|
|
146
147
|
"README.md",
|
|
147
148
|
"LICENSE"
|
|
148
149
|
],
|
|
150
|
+
"peerDependencies": {
|
|
151
|
+
"@cosmicdrift/kumiko-types": "^2.0.0"
|
|
152
|
+
},
|
|
149
153
|
"devDependencies": {
|
|
150
154
|
"@testing-library/user-event": "^14.6.1",
|
|
151
155
|
"@types/mailparser": "^3.4.6",
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// createAccessInvalidationEventConsumer (kumiko-framework#1560) — the last
|
|
2
|
+
// leg of #1524's push-based mid-stream access revocation. Proves the
|
|
3
|
+
// consumer reacts to the two real event sources it's specced against:
|
|
4
|
+
//
|
|
5
|
+
// - sessions:event:session-revoked (#1559), both the self-service revoke
|
|
6
|
+
// path AND the privileged, cross-tenant revoke-all-for-user (DSGVO
|
|
7
|
+
// Art.18 account-freeze, appended anchored on SYSTEM_TENANT_ID).
|
|
8
|
+
// - tenant-membership.updated / .deleted (role change / member removal),
|
|
9
|
+
// where userId is read from the previous-snapshot rather than
|
|
10
|
+
// payload.changes.
|
|
11
|
+
//
|
|
12
|
+
// Asserted directly against stack.sseBroker.subscribeAccessInvalidation —
|
|
13
|
+
// no real SSE connection or HTTP-stream needed, mirrors how sse-broker.test.ts
|
|
14
|
+
// tests the broker itself. Mid-stream teardown (the actual AccessDeniedError
|
|
15
|
+
// thrown into an open dispatch-stream) is #1561's job, not this one.
|
|
16
|
+
//
|
|
17
|
+
// Every write below MUST go through the real write handlers (never a
|
|
18
|
+
// hand-crafted StoredEvent) — that's what pins the hardcoded event-type
|
|
19
|
+
// literals in system-hooks.ts to their actual bundled-features sources. A
|
|
20
|
+
// future "simplification" to synthetic events would silently destroy that
|
|
21
|
+
// guard.
|
|
22
|
+
|
|
23
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
25
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
26
|
+
import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
|
|
27
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
28
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
29
|
+
import {
|
|
30
|
+
setupTestStack,
|
|
31
|
+
type TestStack,
|
|
32
|
+
TestUsers,
|
|
33
|
+
testTenantId,
|
|
34
|
+
unsafeCreateEntityTable,
|
|
35
|
+
unsafePushTables,
|
|
36
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
37
|
+
import {
|
|
38
|
+
createLateBoundHolder,
|
|
39
|
+
createTestEnvelopeCipher,
|
|
40
|
+
resetTestTables,
|
|
41
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
42
|
+
import { AuthHandlers } from "../auth-email-password/constants";
|
|
43
|
+
import { createAuthEmailPasswordFeature } from "../auth-email-password/feature";
|
|
44
|
+
import { createConfigFeature } from "../config";
|
|
45
|
+
import { createConfigResolver } from "../config/resolver";
|
|
46
|
+
import { configValuesTable } from "../config/table";
|
|
47
|
+
import { makeSessionHelpers } from "../sessions/__tests__/test-helpers";
|
|
48
|
+
import { SessionHandlers } from "../sessions/constants";
|
|
49
|
+
import { createSessionsFeature } from "../sessions/feature";
|
|
50
|
+
import { userSessionEntity, userSessionTable } from "../sessions/schema/user-session";
|
|
51
|
+
import { createSessionCallbacks, type SessionCallbacks } from "../sessions/session-callbacks";
|
|
52
|
+
import { sessionCallbacksFromLateBound, withMintedSession } from "../sessions/testing";
|
|
53
|
+
import { createTenantFeature } from "../tenant";
|
|
54
|
+
import { TenantHandlers } from "../tenant/constants";
|
|
55
|
+
import { tenantMembershipsTable } from "../tenant/membership-table";
|
|
56
|
+
import { tenantEntity } from "../tenant/schema/tenant";
|
|
57
|
+
import { UserHandlers } from "../user";
|
|
58
|
+
import { createUserFeature } from "../user/feature";
|
|
59
|
+
import { userEntity, userTable } from "../user/schema/user";
|
|
60
|
+
|
|
61
|
+
let stack: TestStack;
|
|
62
|
+
let h: ReturnType<typeof makeSessionHelpers>;
|
|
63
|
+
let sessionCreator: SessionCreator;
|
|
64
|
+
const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
|
|
65
|
+
|
|
66
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
67
|
+
const TENANT = testTenantId(1);
|
|
68
|
+
|
|
69
|
+
// Collects every userId the consumer pushed an invalidation for during the
|
|
70
|
+
// running test, in push order. Reset in beforeEach.
|
|
71
|
+
let invalidated: string[];
|
|
72
|
+
let unsubscribe: (() => void) | undefined;
|
|
73
|
+
|
|
74
|
+
function trackInvalidation(userId: string): void {
|
|
75
|
+
unsubscribe = stack.sseBroker.subscribeAccessInvalidation(userId, () => {
|
|
76
|
+
invalidated.push(userId);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
82
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
83
|
+
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
84
|
+
|
|
85
|
+
stack = await setupTestStack({
|
|
86
|
+
features: [
|
|
87
|
+
createConfigFeature(),
|
|
88
|
+
createUserFeature(),
|
|
89
|
+
createTenantFeature(),
|
|
90
|
+
createAuthEmailPasswordFeature(),
|
|
91
|
+
authFoundationFeature,
|
|
92
|
+
createSessionsFeature({ autoRevokeOnPasswordChange: bound.asMassRevoker() }),
|
|
93
|
+
],
|
|
94
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
95
|
+
authConfig: {
|
|
96
|
+
...bound.asAuthConfig(),
|
|
97
|
+
membershipQuery: "tenant:query:memberships",
|
|
98
|
+
loginHandler: AuthHandlers.login,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
callbacks.set(createSessionCallbacks({ db: stack.db }));
|
|
102
|
+
h = makeSessionHelpers(stack, TENANT, bound.asAuthConfig().sessionCreator);
|
|
103
|
+
const boundSessionCreator = bound.asAuthConfig().sessionCreator;
|
|
104
|
+
if (!boundSessionCreator) throw new Error("sessionCreator not bound — check setup order");
|
|
105
|
+
sessionCreator = boundSessionCreator;
|
|
106
|
+
|
|
107
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
108
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
109
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
110
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
111
|
+
await createEventsTable(stack.db);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterAll(async () => {
|
|
115
|
+
await stack.cleanup();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
beforeEach(async () => {
|
|
119
|
+
await resetTestTables(stack.db, [
|
|
120
|
+
userTable,
|
|
121
|
+
tenantMembershipsTable,
|
|
122
|
+
userSessionTable,
|
|
123
|
+
eventsTable,
|
|
124
|
+
]);
|
|
125
|
+
invalidated = [];
|
|
126
|
+
unsubscribe?.();
|
|
127
|
+
unsubscribe = undefined;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("access-invalidation event consumer", () => {
|
|
131
|
+
test("session-revoked (self-service revoke) pushes an invalidation for that user", async () => {
|
|
132
|
+
const { userId } = await h.seedUser("revoke1@example.com", "pw-long-enough");
|
|
133
|
+
const { token, sid } = await h.login("revoke1@example.com", "pw-long-enough");
|
|
134
|
+
trackInvalidation(userId);
|
|
135
|
+
|
|
136
|
+
const res = await h.authedPost("/api/write", token, {
|
|
137
|
+
type: SessionHandlers.revoke,
|
|
138
|
+
payload: { id: sid },
|
|
139
|
+
});
|
|
140
|
+
expect(res.status).toBe(200);
|
|
141
|
+
|
|
142
|
+
await stack.eventDispatcher?.runOnce();
|
|
143
|
+
|
|
144
|
+
expect(invalidated).toEqual([userId]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("session-revoked via the privileged cross-tenant revoke-all-for-user (SYSTEM_TENANT_ID) still routes on payload.userId", async () => {
|
|
148
|
+
const { userId } = await h.seedUser("revoke2@example.com", "pw-long-enough");
|
|
149
|
+
await h.login("revoke2@example.com", "pw-long-enough");
|
|
150
|
+
trackInvalidation(userId);
|
|
151
|
+
|
|
152
|
+
// Fresh sid per test — withMintedSession mints synchronously instead of
|
|
153
|
+
// relying on stack.http's per-userId sid cache, which would otherwise
|
|
154
|
+
// hand back a sid truncated out from under it by the next beforeEach's
|
|
155
|
+
// resetTestTables(userSessionTable).
|
|
156
|
+
const systemActor = await withMintedSession(sessionCreator, {
|
|
157
|
+
id: TestUsers.systemAdmin.id,
|
|
158
|
+
tenantId: TENANT,
|
|
159
|
+
roles: ["system"],
|
|
160
|
+
});
|
|
161
|
+
const result = await stack.http.write(
|
|
162
|
+
SessionHandlers.revokeAllForUser,
|
|
163
|
+
{ userId },
|
|
164
|
+
systemActor,
|
|
165
|
+
);
|
|
166
|
+
expect(result.status).toBe(200);
|
|
167
|
+
|
|
168
|
+
// The write itself is anchored on SYSTEM_TENANT_ID (not TENANT) — this
|
|
169
|
+
// is exactly the asymmetry #1559's handoff comment warns about. If the
|
|
170
|
+
// consumer routed on event.tenantId instead of payload.userId, this
|
|
171
|
+
// event would never surface here.
|
|
172
|
+
const events = await selectMany(stack.db, eventsTable, {
|
|
173
|
+
type: "sessions:event:session-revoked",
|
|
174
|
+
});
|
|
175
|
+
expect(events.length).toBeGreaterThan(0);
|
|
176
|
+
|
|
177
|
+
await stack.eventDispatcher?.runOnce();
|
|
178
|
+
|
|
179
|
+
expect(invalidated).toEqual([userId]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("tenant-membership.updated (role change) reads userId from the previous snapshot and invalidates", async () => {
|
|
183
|
+
const { userId } = await h.seedUser("rolechange@example.com", "pw-long-enough");
|
|
184
|
+
trackInvalidation(userId);
|
|
185
|
+
|
|
186
|
+
const admin = await withMintedSession(sessionCreator, TestUsers.systemAdmin);
|
|
187
|
+
const result = await stack.http.writeOk(
|
|
188
|
+
TenantHandlers.updateMemberRoles,
|
|
189
|
+
{ userId, tenantId: TENANT, roles: ["Admin"] },
|
|
190
|
+
admin,
|
|
191
|
+
);
|
|
192
|
+
expect(result).toBeTruthy();
|
|
193
|
+
|
|
194
|
+
await stack.eventDispatcher?.runOnce();
|
|
195
|
+
|
|
196
|
+
expect(invalidated).toEqual([userId]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("tenant-membership.deleted (member removed) reads userId from the previous snapshot and invalidates", async () => {
|
|
200
|
+
const { userId } = await h.seedUser("removed@example.com", "pw-long-enough");
|
|
201
|
+
trackInvalidation(userId);
|
|
202
|
+
|
|
203
|
+
const admin = await withMintedSession(sessionCreator, TestUsers.systemAdmin);
|
|
204
|
+
const result = await stack.http.writeOk(
|
|
205
|
+
TenantHandlers.removeMember,
|
|
206
|
+
{ userId, tenantId: TENANT },
|
|
207
|
+
admin,
|
|
208
|
+
);
|
|
209
|
+
expect(result).toBeTruthy();
|
|
210
|
+
|
|
211
|
+
await stack.eventDispatcher?.runOnce();
|
|
212
|
+
|
|
213
|
+
expect(invalidated).toEqual([userId]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("tenant-membership.created (new member added) does not push an invalidation", async () => {
|
|
217
|
+
// A genuine tenant-membership.created event, not h.seedUser's direct-DB
|
|
218
|
+
// seedTenantMembership bypass — go through the real addMember write
|
|
219
|
+
// handler so this negative case exercises the actual event the
|
|
220
|
+
// consumer must ignore, not just "some other unrelated event type".
|
|
221
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
222
|
+
UserHandlers.create,
|
|
223
|
+
{ email: "newmember@example.com", displayName: "newmember" },
|
|
224
|
+
TestUsers.systemAdmin,
|
|
225
|
+
);
|
|
226
|
+
trackInvalidation(created.id);
|
|
227
|
+
|
|
228
|
+
const admin = await withMintedSession(sessionCreator, TestUsers.systemAdmin);
|
|
229
|
+
await stack.http.writeOk(
|
|
230
|
+
TenantHandlers.addMember,
|
|
231
|
+
{ userId: created.id, tenantId: TENANT, roles: ["User"] },
|
|
232
|
+
admin,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const events = await selectMany(stack.db, eventsTable, { type: "tenant-membership.created" });
|
|
236
|
+
expect(events.length).toBeGreaterThan(0);
|
|
237
|
+
|
|
238
|
+
await stack.eventDispatcher?.runOnce();
|
|
239
|
+
|
|
240
|
+
expect(invalidated).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("password change (sessionMassRevoker) pushes an invalidation for that user", async () => {
|
|
244
|
+
const { userId } = await h.seedUser("pwchange@example.com", "old-pw-long-enough");
|
|
245
|
+
const { token } = await h.login("pwchange@example.com", "old-pw-long-enough");
|
|
246
|
+
trackInvalidation(userId);
|
|
247
|
+
|
|
248
|
+
// The industry-standard "password change signs you out everywhere" flow
|
|
249
|
+
// — goes through the sessionMassRevoker callback (feature.ts's postSave
|
|
250
|
+
// hook on passwordHash changes), NOT one of the *.write.ts handlers.
|
|
251
|
+
// This is exactly the gap a security review of #1560 caught: that
|
|
252
|
+
// callback did a raw updateMany with no event, so an already-open SSE
|
|
253
|
+
// stream survived a password change.
|
|
254
|
+
const res = await h.authedPost("/api/write", token, {
|
|
255
|
+
type: AuthHandlers.changePassword,
|
|
256
|
+
payload: { oldPassword: "old-pw-long-enough", newPassword: "new-pw-long-enough" },
|
|
257
|
+
});
|
|
258
|
+
expect(res.status).toBe(200);
|
|
259
|
+
|
|
260
|
+
await stack.eventDispatcher?.runOnce();
|
|
261
|
+
|
|
262
|
+
expect(invalidated).toEqual([userId]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("sessionRevokeAllOthers callback (used by auth-mfa, distinct from the revoke-all-others.write.ts handler) pushes an invalidation", async () => {
|
|
266
|
+
const { userId } = await h.seedUser("mfarevoke@example.com", "pw-long-enough");
|
|
267
|
+
await h.login("mfarevoke@example.com", "pw-long-enough");
|
|
268
|
+
trackInvalidation(userId);
|
|
269
|
+
|
|
270
|
+
// currentSid undefined → revokes every live session, mirrors how
|
|
271
|
+
// auth-mfa calls this callback directly (no dispatcher ctx available).
|
|
272
|
+
const revoked = await callbacks.get().sessionRevokeAllOthers(userId, undefined);
|
|
273
|
+
expect(revoked).toBeGreaterThan(0);
|
|
274
|
+
|
|
275
|
+
await stack.eventDispatcher?.runOnce();
|
|
276
|
+
|
|
277
|
+
expect(invalidated).toEqual([userId]);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// #1561 — mid-stream access revocation tears down an open HTTP SSE stream.
|
|
2
|
+
// Complements access-invalidation-consumer.integration.test.ts (#1560), which
|
|
3
|
+
// only asserts broker.publishAccessInvalidation. Here we open a real
|
|
4
|
+
// POST /api/stream via createLiveDispatcher + setupTestStack, revoke access
|
|
5
|
+
// while the generator is gated between chunks, and assert the client sees
|
|
6
|
+
// access_denied instead of further yields.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
11
|
+
import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
12
|
+
import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
|
|
13
|
+
import { generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
14
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
|
+
import {
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
TestUsers,
|
|
20
|
+
testTenantId,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
unsafePushTables,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
+
import {
|
|
25
|
+
createLateBoundHolder,
|
|
26
|
+
createTestEnvelopeCipher,
|
|
27
|
+
resetTestTables,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import { AuthHandlers } from "../auth-email-password/constants";
|
|
31
|
+
import { createAuthEmailPasswordFeature } from "../auth-email-password/feature";
|
|
32
|
+
import { createConfigFeature } from "../config";
|
|
33
|
+
import { createConfigResolver } from "../config/resolver";
|
|
34
|
+
import { configValuesTable } from "../config/table";
|
|
35
|
+
import { makeSessionHelpers } from "../sessions/__tests__/test-helpers";
|
|
36
|
+
import { SessionHandlers } from "../sessions/constants";
|
|
37
|
+
import { createSessionsFeature } from "../sessions/feature";
|
|
38
|
+
import { userSessionEntity, userSessionTable } from "../sessions/schema/user-session";
|
|
39
|
+
import { createSessionCallbacks, type SessionCallbacks } from "../sessions/session-callbacks";
|
|
40
|
+
import { sessionCallbacksFromLateBound, withMintedSession } from "../sessions/testing";
|
|
41
|
+
import { createTenantFeature } from "../tenant";
|
|
42
|
+
import { TenantHandlers } from "../tenant/constants";
|
|
43
|
+
import { tenantMembershipsTable } from "../tenant/membership-table";
|
|
44
|
+
import { tenantEntity } from "../tenant/schema/tenant";
|
|
45
|
+
import { createUserFeature } from "../user/feature";
|
|
46
|
+
import { userEntity, userTable } from "../user/schema/user";
|
|
47
|
+
|
|
48
|
+
let stack: TestStack;
|
|
49
|
+
let h: ReturnType<typeof makeSessionHelpers>;
|
|
50
|
+
let sessionCreator: SessionCreator;
|
|
51
|
+
const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
|
|
52
|
+
|
|
53
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
54
|
+
const TENANT = testTenantId(1);
|
|
55
|
+
|
|
56
|
+
// Gate between chunk 0 and chunk 1 so the test can revoke + drain the event
|
|
57
|
+
// consumer before the stream's next pull — otherwise an eager pump would
|
|
58
|
+
// race past the invalidation window.
|
|
59
|
+
let releaseNextChunk: (() => void) | undefined;
|
|
60
|
+
let nextChunkGate: Promise<void>;
|
|
61
|
+
|
|
62
|
+
function resetGate(): void {
|
|
63
|
+
nextChunkGate = new Promise<void>((resolve) => {
|
|
64
|
+
releaseNextChunk = resolve;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const streamProbeFeature = defineFeature("stream-probe", (r) => {
|
|
69
|
+
r.streamHandler(
|
|
70
|
+
"probe:tail",
|
|
71
|
+
z.object({}).optional(),
|
|
72
|
+
async function* () {
|
|
73
|
+
yield { phase: "open" as const };
|
|
74
|
+
await nextChunkGate;
|
|
75
|
+
// Reached only if mid-stream access check failed to cut the generator.
|
|
76
|
+
yield { phase: "should-not-reach-client" as const };
|
|
77
|
+
},
|
|
78
|
+
{ access: { roles: ["User"] } },
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function buildLiveFetch(authJwt: string): {
|
|
83
|
+
readonly fetch: typeof fetch;
|
|
84
|
+
readonly csrfToken: string;
|
|
85
|
+
} {
|
|
86
|
+
const csrfToken = generateToken();
|
|
87
|
+
const cookieHeader = `kumiko_auth=${authJwt}; kumiko_csrf=${csrfToken}`;
|
|
88
|
+
const fetchImpl = (async (url: unknown, init: RequestInit | undefined) => {
|
|
89
|
+
const reqInit: RequestInit = {
|
|
90
|
+
...(init ?? {}),
|
|
91
|
+
headers: {
|
|
92
|
+
...(init?.headers ?? {}),
|
|
93
|
+
Cookie: cookieHeader,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
return stack.app.request(String(url), reqInit);
|
|
97
|
+
}) as unknown as typeof fetch;
|
|
98
|
+
return { fetch: fetchImpl, csrfToken };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
beforeAll(async () => {
|
|
102
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
103
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
104
|
+
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
105
|
+
|
|
106
|
+
stack = await setupTestStack({
|
|
107
|
+
features: [
|
|
108
|
+
createConfigFeature(),
|
|
109
|
+
createUserFeature(),
|
|
110
|
+
createTenantFeature(),
|
|
111
|
+
createAuthEmailPasswordFeature(),
|
|
112
|
+
authFoundationFeature,
|
|
113
|
+
createSessionsFeature({ autoRevokeOnPasswordChange: bound.asMassRevoker() }),
|
|
114
|
+
streamProbeFeature,
|
|
115
|
+
],
|
|
116
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
117
|
+
authConfig: {
|
|
118
|
+
...bound.asAuthConfig(),
|
|
119
|
+
membershipQuery: "tenant:query:memberships",
|
|
120
|
+
loginHandler: AuthHandlers.login,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
callbacks.set(createSessionCallbacks({ db: stack.db }));
|
|
124
|
+
h = makeSessionHelpers(stack, TENANT, bound.asAuthConfig().sessionCreator);
|
|
125
|
+
const boundSessionCreator = bound.asAuthConfig().sessionCreator;
|
|
126
|
+
if (!boundSessionCreator) throw new Error("sessionCreator not bound — check setup order");
|
|
127
|
+
sessionCreator = boundSessionCreator;
|
|
128
|
+
|
|
129
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
130
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
131
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
132
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
133
|
+
await createEventsTable(stack.db);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterAll(async () => {
|
|
137
|
+
await stack.cleanup();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
beforeEach(async () => {
|
|
141
|
+
await resetTestTables(stack.db, [
|
|
142
|
+
userTable,
|
|
143
|
+
tenantMembershipsTable,
|
|
144
|
+
userSessionTable,
|
|
145
|
+
eventsTable,
|
|
146
|
+
]);
|
|
147
|
+
resetGate();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("access-invalidation mid-stream SSE teardown (#1561)", () => {
|
|
151
|
+
test("session revoke mid-stream terminates the open HTTP SSE stream", async () => {
|
|
152
|
+
await h.seedUser("stream-revoke@example.com", "pw-long-enough");
|
|
153
|
+
const { token, sid } = await h.login("stream-revoke@example.com", "pw-long-enough");
|
|
154
|
+
|
|
155
|
+
const { fetch, csrfToken } = buildLiveFetch(token);
|
|
156
|
+
const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
|
|
157
|
+
|
|
158
|
+
const iter = dispatcher
|
|
159
|
+
.stream<{ phase: string }>("stream-probe:stream:probe:tail", {})
|
|
160
|
+
[Symbol.asyncIterator]();
|
|
161
|
+
|
|
162
|
+
const first = await iter.next();
|
|
163
|
+
expect(first.done).toBe(false);
|
|
164
|
+
expect(first.value).toEqual({ phase: "open" });
|
|
165
|
+
|
|
166
|
+
const revokeRes = await h.authedPost("/api/write", token, {
|
|
167
|
+
type: SessionHandlers.revoke,
|
|
168
|
+
payload: { id: sid },
|
|
169
|
+
});
|
|
170
|
+
expect(revokeRes.status).toBe(200);
|
|
171
|
+
await stack.eventDispatcher?.runOnce();
|
|
172
|
+
|
|
173
|
+
releaseNextChunk?.();
|
|
174
|
+
|
|
175
|
+
let thrown: unknown;
|
|
176
|
+
try {
|
|
177
|
+
await iter.next();
|
|
178
|
+
} catch (e) {
|
|
179
|
+
thrown = e;
|
|
180
|
+
}
|
|
181
|
+
expect(thrown).toMatchObject({ code: "access_denied" });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("tenant role strip mid-stream terminates the open HTTP SSE stream", async () => {
|
|
185
|
+
const { userId } = await h.seedUser("stream-roles@example.com", "pw-long-enough");
|
|
186
|
+
const { token } = await h.login("stream-roles@example.com", "pw-long-enough");
|
|
187
|
+
|
|
188
|
+
const { fetch, csrfToken } = buildLiveFetch(token);
|
|
189
|
+
const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
|
|
190
|
+
|
|
191
|
+
const iter = dispatcher
|
|
192
|
+
.stream<{ phase: string }>("stream-probe:stream:probe:tail", {})
|
|
193
|
+
[Symbol.asyncIterator]();
|
|
194
|
+
|
|
195
|
+
const first = await iter.next();
|
|
196
|
+
expect(first.done).toBe(false);
|
|
197
|
+
expect(first.value).toEqual({ phase: "open" });
|
|
198
|
+
|
|
199
|
+
const admin = await withMintedSession(sessionCreator, TestUsers.systemAdmin);
|
|
200
|
+
await stack.http.writeOk(
|
|
201
|
+
TenantHandlers.updateMemberRoles,
|
|
202
|
+
{ userId, tenantId: TENANT, roles: ["Admin"] },
|
|
203
|
+
admin,
|
|
204
|
+
);
|
|
205
|
+
await stack.eventDispatcher?.runOnce();
|
|
206
|
+
|
|
207
|
+
releaseNextChunk?.();
|
|
208
|
+
|
|
209
|
+
let thrown: unknown;
|
|
210
|
+
try {
|
|
211
|
+
await iter.next();
|
|
212
|
+
} catch (e) {
|
|
213
|
+
thrown = e;
|
|
214
|
+
}
|
|
215
|
+
expect(thrown).toMatchObject({ code: "access_denied" });
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
testTenantId,
|
|
21
21
|
unsafeCreateEntityTable,
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
24
|
import { AuditQueries } from "../constants";
|
|
24
25
|
import { createAuditFeature } from "../feature";
|
|
25
26
|
|
|
@@ -189,47 +190,61 @@ describe("audit: list query", () => {
|
|
|
189
190
|
});
|
|
190
191
|
|
|
191
192
|
test("filter by from/to date range (inclusive bounds, outside-range rows excluded)", async () => {
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// db side (ms) can't blur anchor vs event.
|
|
193
|
+
// Stamp created_at after writes — wall-clock setTimeout anchors race with
|
|
194
|
+
// ms-precision DB timestamps under load (#1548). Fixed Instant grid keeps
|
|
195
|
+
// from/to assertions deterministic.
|
|
196
196
|
await createWidget(admin, "before-window");
|
|
197
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
198
|
-
const t1 = Temporal.Now.instant();
|
|
199
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
200
197
|
await createWidget(admin, "in-window-1");
|
|
201
198
|
await createWidget(admin, "in-window-2");
|
|
202
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
203
|
-
const t2 = Temporal.Now.instant();
|
|
204
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
205
199
|
await createWidget(admin, "after-window");
|
|
206
200
|
|
|
207
|
-
|
|
201
|
+
const listed = await stack.http.queryOk<AuditResponse>(AuditQueries.list, {}, admin);
|
|
202
|
+
const byName = new Map(
|
|
203
|
+
listed.rows
|
|
204
|
+
.filter((r) => r.type === "widget.created")
|
|
205
|
+
.map((r) => [(r.payload as { name?: string }).name ?? "", r.id]),
|
|
206
|
+
);
|
|
207
|
+
const stamps: ReadonlyArray<readonly [string, string]> = [
|
|
208
|
+
["before-window", "2020-06-01T12:00:00Z"],
|
|
209
|
+
["in-window-1", "2020-06-01T12:00:10Z"],
|
|
210
|
+
["in-window-2", "2020-06-01T12:00:20Z"],
|
|
211
|
+
["after-window", "2020-06-01T12:00:30Z"],
|
|
212
|
+
];
|
|
213
|
+
for (const [name, createdAt] of stamps) {
|
|
214
|
+
const id = byName.get(name);
|
|
215
|
+
expect(id).toBeDefined();
|
|
216
|
+
await asRawClient(stack.db).unsafe(
|
|
217
|
+
`UPDATE kumiko_events SET created_at = $1::timestamptz WHERE id = $2::bigint`,
|
|
218
|
+
[createdAt, id],
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const tFrom = "2020-06-01T12:00:10Z";
|
|
223
|
+
const tTo = "2020-06-01T12:00:20Z";
|
|
224
|
+
|
|
208
225
|
const inWindow = await stack.http.queryOk<AuditResponse>(
|
|
209
226
|
AuditQueries.list,
|
|
210
|
-
{ from:
|
|
227
|
+
{ from: tFrom, to: tTo },
|
|
211
228
|
admin,
|
|
212
229
|
);
|
|
213
230
|
expect(inWindow.rows).toHaveLength(2);
|
|
214
231
|
const names = inWindow.rows.map((r) => (r.payload as { name?: string }).name).sort();
|
|
215
232
|
expect(names).toEqual(["in-window-1", "in-window-2"]);
|
|
216
233
|
|
|
217
|
-
|
|
218
|
-
const sinceT1 = await stack.http.queryOk<AuditResponse>(
|
|
234
|
+
const sinceFrom = await stack.http.queryOk<AuditResponse>(
|
|
219
235
|
AuditQueries.list,
|
|
220
|
-
{ from:
|
|
236
|
+
{ from: tFrom },
|
|
221
237
|
admin,
|
|
222
238
|
);
|
|
223
|
-
expect(
|
|
239
|
+
expect(sinceFrom.rows).toHaveLength(3);
|
|
224
240
|
|
|
225
|
-
|
|
226
|
-
const untilT1 = await stack.http.queryOk<AuditResponse>(
|
|
241
|
+
const untilBefore = await stack.http.queryOk<AuditResponse>(
|
|
227
242
|
AuditQueries.list,
|
|
228
|
-
{ to:
|
|
243
|
+
{ to: "2020-06-01T12:00:00Z" },
|
|
229
244
|
admin,
|
|
230
245
|
);
|
|
231
|
-
expect(
|
|
232
|
-
expect((
|
|
246
|
+
expect(untilBefore.rows).toHaveLength(1);
|
|
247
|
+
expect((untilBefore.rows[0]?.payload as { name?: string }).name).toBe("before-window");
|
|
233
248
|
});
|
|
234
249
|
|
|
235
250
|
test("rejects inverted from/to range with validation_error", async () => {
|
|
@@ -312,6 +327,20 @@ describe("audit: list query", () => {
|
|
|
312
327
|
expect(updated?.metadata).toMatchObject({ userId: admin.id });
|
|
313
328
|
});
|
|
314
329
|
|
|
330
|
+
test("kumiko-framework#1525: from/to filter works without relying on globalThis.Temporal", async () => {
|
|
331
|
+
await createWidget(admin, "in-range");
|
|
332
|
+
// createTzContext uses a static temporal-polyfill import, so full HTTP
|
|
333
|
+
// dispatch (buildHandlerContext → ctx.tz) is safe ambient-free.
|
|
334
|
+
const res = await withoutAmbientTemporal(() =>
|
|
335
|
+
stack.http.queryOk<AuditResponse>(
|
|
336
|
+
AuditQueries.list,
|
|
337
|
+
{ limit: 50, from: "2020-01-01T00:00:00Z", to: "2030-01-01T00:00:00Z" },
|
|
338
|
+
admin,
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
expect(res.rows.length).toBeGreaterThanOrEqual(1);
|
|
342
|
+
});
|
|
343
|
+
|
|
315
344
|
test("access denied for non-admin roles", async () => {
|
|
316
345
|
await createWidget(admin, "A");
|
|
317
346
|
const res = await stack.http.query(AuditQueries.list, {}, regularUser);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
15
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
16
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import { Temporal } from "temporal-polyfill";
|
|
17
18
|
import { z } from "zod";
|
|
18
19
|
|
|
19
20
|
const MAX_LIMIT = 100;
|
|
@@ -247,6 +247,12 @@ describe("scenario 5c: change-password common-password rejection (#1340)", () =>
|
|
|
247
247
|
signedIn,
|
|
248
248
|
);
|
|
249
249
|
expect(error.httpStatus).toBe(400);
|
|
250
|
+
// "errors.validation.custom" alone is generic — any .refine() failure
|
|
251
|
+
// on the schema would match. Pin the field too, so this only passes
|
|
252
|
+
// for a rejection on newPassword specifically (the common-password
|
|
253
|
+
// refine), not an unrelated validation error elsewhere in the schema.
|
|
254
|
+
expectErrorIncludes(error, "errors.validation.custom");
|
|
255
|
+
expectErrorIncludes(error, "newPassword");
|
|
250
256
|
|
|
251
257
|
// Old password still works — the rejected change never landed.
|
|
252
258
|
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|