@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.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 +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +23 -8
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
3
4
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
5
|
import {
|
|
5
6
|
configureBlindIndexKey,
|
|
@@ -13,6 +14,7 @@ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
13
14
|
import {
|
|
14
15
|
setupTestStack,
|
|
15
16
|
type TestStack,
|
|
17
|
+
TestUsers,
|
|
16
18
|
testTenantId,
|
|
17
19
|
unsafeCreateEntityTable,
|
|
18
20
|
unsafePushTables,
|
|
@@ -65,6 +67,7 @@ beforeAll(async () => {
|
|
|
65
67
|
createUserFeature(),
|
|
66
68
|
createTenantFeature(),
|
|
67
69
|
createAuthEmailPasswordFeature(),
|
|
70
|
+
authFoundationFeature,
|
|
68
71
|
createSessionsFeature(),
|
|
69
72
|
],
|
|
70
73
|
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
@@ -75,7 +78,7 @@ beforeAll(async () => {
|
|
|
75
78
|
},
|
|
76
79
|
});
|
|
77
80
|
callbacks.set(createSessionCallbacks({ db: stack.db }));
|
|
78
|
-
h = makeSessionHelpers(stack, TENANT);
|
|
81
|
+
h = makeSessionHelpers(stack, TENANT, bound.asAuthConfig().sessionCreator);
|
|
79
82
|
|
|
80
83
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
81
84
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
@@ -97,9 +100,10 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
97
100
|
const { sid } = await h.login("persist@example.com", "pw-long-enough");
|
|
98
101
|
|
|
99
102
|
const rows = await selectMany(stack.db, userSessionTable);
|
|
100
|
-
|
|
101
|
-
expect(
|
|
102
|
-
expect(
|
|
103
|
+
const userRows = rows.filter((r) => r["userId"] !== TestUsers.systemAdmin.id);
|
|
104
|
+
expect(userRows).toHaveLength(1);
|
|
105
|
+
expect(userRows[0]?.["id"]).toBe(sid);
|
|
106
|
+
expect(userRows[0]?.["revokedAt"]).toBeNull();
|
|
103
107
|
});
|
|
104
108
|
|
|
105
109
|
test("authenticated request passes while session is live, 401s once revoked", async () => {
|
|
@@ -138,8 +142,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
138
142
|
expect(logoutRes.status).toBe(200);
|
|
139
143
|
|
|
140
144
|
const rows = await selectMany(stack.db, userSessionTable);
|
|
141
|
-
|
|
142
|
-
expect(
|
|
145
|
+
const row = rows.find((r) => r["id"] === sid);
|
|
146
|
+
expect(row?.["id"]).toBe(sid);
|
|
147
|
+
expect(row?.["revokedAt"]).not.toBeNull();
|
|
143
148
|
|
|
144
149
|
const next = await h.authedPost("/api/query", token, {
|
|
145
150
|
type: "user:query:user:me",
|
|
@@ -294,12 +299,13 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
294
299
|
expect(body.error?.details?.reason).toBe("ownership_denied");
|
|
295
300
|
});
|
|
296
301
|
|
|
297
|
-
test("revoke-all-others on a sidless JWT
|
|
302
|
+
test("revoke-all-others on a sidless JWT is rejected by the middleware (no_sid)", async () => {
|
|
298
303
|
const { userId } = await h.seedUser("sidless@example.com", "pw-long-enough");
|
|
299
304
|
|
|
300
|
-
// Hand-sign a JWT without jti
|
|
301
|
-
//
|
|
302
|
-
//
|
|
305
|
+
// Hand-sign a JWT without jti. Once a sessionChecker is wired there is
|
|
306
|
+
// no relaxed mode (see session-strict-mode.integration.test.ts) — the
|
|
307
|
+
// middleware rejects it with no_sid before the handler's own
|
|
308
|
+
// session_required guard is ever reached.
|
|
303
309
|
const tokenNoSid = await stack.jwt.sign({ id: userId, tenantId: TENANT, roles: ["User"] });
|
|
304
310
|
|
|
305
311
|
const res = await stack.http.raw(
|
|
@@ -308,9 +314,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
308
314
|
{ type: SessionHandlers.revokeAllOthers, payload: {} },
|
|
309
315
|
{ Authorization: `Bearer ${tokenNoSid}` },
|
|
310
316
|
);
|
|
311
|
-
expect(res.status).toBe(
|
|
317
|
+
expect(res.status).toBe(401);
|
|
312
318
|
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
313
|
-
expect(body.error?.details?.reason).toBe("
|
|
319
|
+
expect(body.error?.details?.reason).toBe("no_sid");
|
|
314
320
|
});
|
|
315
321
|
|
|
316
322
|
test("N concurrent revoke+logout requests on the same sid converge safely", async () => {
|
|
@@ -491,10 +497,13 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
491
497
|
revokedAt: string | null;
|
|
492
498
|
}>;
|
|
493
499
|
};
|
|
494
|
-
// Three rows total: Alice's pre-promotion session,
|
|
495
|
-
// session, Bob's session.
|
|
496
|
-
|
|
497
|
-
|
|
500
|
+
// Three rows total for the two test users: Alice's pre-promotion session,
|
|
501
|
+
// Alice's post-promotion session, Bob's session. seedUser's bootstrap
|
|
502
|
+
// actor (systemAdmin) also holds live sessions in this tenant — exclude
|
|
503
|
+
// them, they aren't part of what this test pins.
|
|
504
|
+
const nonSystemRows = body.data.filter((r) => r.userId !== TestUsers.systemAdmin.id);
|
|
505
|
+
expect(nonSystemRows).toHaveLength(3);
|
|
506
|
+
const userIds = new Set(nonSystemRows.map((r) => r.userId));
|
|
498
507
|
expect(userIds.size).toBe(2);
|
|
499
508
|
|
|
500
509
|
// Order: most-recently-created first. aliceAsAdmin's session was the
|
|
@@ -552,7 +561,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
552
561
|
// different tenant must not be able to read another tenant's session rows.
|
|
553
562
|
test("session:detail and session:list never leak sessions across tenants", async () => {
|
|
554
563
|
const TENANT_2 = testTenantId(2);
|
|
555
|
-
const h2 = makeSessionHelpers(stack, TENANT_2)
|
|
564
|
+
const h2 = makeSessionHelpers(stack, TENANT_2, (user, meta) =>
|
|
565
|
+
callbacks.get().sessionCreator(user, meta),
|
|
566
|
+
);
|
|
556
567
|
|
|
557
568
|
const { userId: carolId } = await h.seedUser("carol4@example.com", "pw-long-enough");
|
|
558
569
|
await updateRows(
|
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
// const res = await h.authedPost("/api/query", token, { type, payload });
|
|
10
10
|
|
|
11
11
|
import { expect } from "bun:test";
|
|
12
|
+
import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
|
|
12
13
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
14
|
import { type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
14
15
|
import * as jose from "jose";
|
|
15
16
|
import { hashPassword } from "../../shared";
|
|
16
17
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
17
18
|
import { UserHandlers } from "../../user";
|
|
19
|
+
import { withMintedSession } from "../testing";
|
|
18
20
|
|
|
19
21
|
export type LoginResult = {
|
|
20
22
|
readonly token: string;
|
|
@@ -25,7 +27,15 @@ export type LoginResult = {
|
|
|
25
27
|
// `ReturnType<typeof makeSessionHelpers>` — no separate type export to
|
|
26
28
|
// keep in sync with the implementation. Params are typed inline on each
|
|
27
29
|
// method so the inference is sharp.
|
|
28
|
-
|
|
30
|
+
// `sessionCreator` is only needed when the suite wires a real sessionChecker
|
|
31
|
+
// (auth-middleware then rejects the sidless systemAdmin bootstrap actor
|
|
32
|
+
// used by seedUser). Suites without a sessionChecker (e.g. PAT tests) can
|
|
33
|
+
// omit it.
|
|
34
|
+
export function makeSessionHelpers(
|
|
35
|
+
stack: TestStack,
|
|
36
|
+
tenantId: TenantId,
|
|
37
|
+
sessionCreator?: SessionCreator,
|
|
38
|
+
) {
|
|
29
39
|
return {
|
|
30
40
|
async seedUser(
|
|
31
41
|
email: string,
|
|
@@ -33,10 +43,13 @@ export function makeSessionHelpers(stack: TestStack, tenantId: TenantId) {
|
|
|
33
43
|
opts?: { roles?: readonly string[] },
|
|
34
44
|
): Promise<{ userId: string }> {
|
|
35
45
|
const hash = await hashPassword(password);
|
|
46
|
+
const actor = sessionCreator
|
|
47
|
+
? await withMintedSession(sessionCreator, TestUsers.systemAdmin)
|
|
48
|
+
: TestUsers.systemAdmin;
|
|
36
49
|
const created = await stack.http.writeOk<{ id: string }>(
|
|
37
50
|
UserHandlers.create,
|
|
38
51
|
{ email, passwordHash: hash, displayName: email.split("@")[0] ?? "u" },
|
|
39
|
-
|
|
52
|
+
actor,
|
|
40
53
|
);
|
|
41
54
|
await seedTenantMembership(stack.db, {
|
|
42
55
|
userId: created.id,
|
package/src/sessions/feature.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXT_SESSION_STORE,
|
|
3
|
+
type SessionStore,
|
|
4
|
+
type SessionStoreProvider,
|
|
5
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
1
6
|
import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
7
|
import {
|
|
3
8
|
access,
|
|
@@ -14,7 +19,11 @@ import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
|
|
|
14
19
|
import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
|
|
15
20
|
import { SESSIONS_I18N } from "./i18n";
|
|
16
21
|
import { userSessionEntity } from "./schema/user-session";
|
|
17
|
-
import
|
|
22
|
+
import {
|
|
23
|
+
createSessionCallbacks,
|
|
24
|
+
type SessionAllOthersRevoker,
|
|
25
|
+
type SessionMassRevoker,
|
|
26
|
+
} from "./session-callbacks";
|
|
18
27
|
|
|
19
28
|
export type SessionsFeatureOptions = {
|
|
20
29
|
// A successful update on the `user` entity that changes the `passwordHash`
|
|
@@ -76,7 +85,7 @@ export function bindAutoRevokeFromFeature(
|
|
|
76
85
|
export function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition {
|
|
77
86
|
return defineFeature("sessions", (r) => {
|
|
78
87
|
r.describe(
|
|
79
|
-
"Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
|
|
88
|
+
"Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. The feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
|
|
80
89
|
);
|
|
81
90
|
r.uiHints({
|
|
82
91
|
displayLabel: "Sessions · Server-side Logout",
|
|
@@ -86,7 +95,9 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
86
95
|
// sessionChecker reads read_users on every authenticated request (status
|
|
87
96
|
// gate for locked accounts) — make that a boot-time dependency so a
|
|
88
97
|
// sessions-without-user wiring fails validateBoot instead of 500ing live.
|
|
89
|
-
|
|
98
|
+
// auth-foundation owns EXT_SESSION_STORE, which the useExtension below
|
|
99
|
+
// registers against.
|
|
100
|
+
r.requires("user", "auth-foundation");
|
|
90
101
|
// store_user_sessions is a hot-path direct-write store: sessionCreator
|
|
91
102
|
// inserts and the revoke handlers update rows WITHOUT emitting lifecycle
|
|
92
103
|
// events (the row columns ARE the audit trail). Registering it as
|
|
@@ -102,6 +113,23 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
102
113
|
piiEncryptedOnWrite: true,
|
|
103
114
|
});
|
|
104
115
|
|
|
116
|
+
// Self-registers as auth-foundation's sessionStore provider (#1371) —
|
|
117
|
+
// wraps the same createSessionCallbacks() used by the manual
|
|
118
|
+
// buildServer({ auth: { ... } }) wiring above; a future issue (#1372)
|
|
119
|
+
// removes that manual wiring once the middleware resolves generically
|
|
120
|
+
// via resolveSessionStore().
|
|
121
|
+
r.useExtension(EXT_SESSION_STORE, "default", {
|
|
122
|
+
build: (deps): SessionStore => {
|
|
123
|
+
const callbacks = createSessionCallbacks({ db: deps.db });
|
|
124
|
+
return {
|
|
125
|
+
creator: callbacks.sessionCreator,
|
|
126
|
+
revoker: callbacks.sessionRevoker,
|
|
127
|
+
checker: callbacks.sessionChecker,
|
|
128
|
+
massRevoker: callbacks.sessionMassRevoker,
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
} satisfies SessionStoreProvider);
|
|
132
|
+
|
|
105
133
|
const handlers = {
|
|
106
134
|
revoke: r.writeHandler(revokeWrite),
|
|
107
135
|
revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
|
|
@@ -27,9 +27,9 @@ export const detailQuery = defineQueryHandler({
|
|
|
27
27
|
createdAt: row.createdAt,
|
|
28
28
|
expiresAt: row.expiresAt,
|
|
29
29
|
revokedAt: row.revokedAt,
|
|
30
|
-
ip: row.ip ? await decryptStoredPii(row.ip, "sessions:detail") : row.ip,
|
|
30
|
+
ip: row.ip ? await decryptStoredPii(row.ip, "ip", "sessions:detail") : row.ip,
|
|
31
31
|
userAgent: row.userAgent
|
|
32
|
-
? await decryptStoredPii(row.userAgent, "sessions:detail")
|
|
32
|
+
? await decryptStoredPii(row.userAgent, "userAgent", "sessions:detail")
|
|
33
33
|
: row.userAgent,
|
|
34
34
|
};
|
|
35
35
|
},
|
|
@@ -30,8 +30,10 @@ export const listQuery = defineQueryHandler({
|
|
|
30
30
|
createdAt: r.createdAt,
|
|
31
31
|
expiresAt: r.expiresAt,
|
|
32
32
|
revokedAt: r.revokedAt,
|
|
33
|
-
ip: r.ip ? await decryptStoredPii(r.ip, "sessions:list") : r.ip,
|
|
34
|
-
userAgent: r.userAgent
|
|
33
|
+
ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
|
|
34
|
+
userAgent: r.userAgent
|
|
35
|
+
? await decryptStoredPii(r.userAgent, "userAgent", "sessions:list")
|
|
36
|
+
: r.userAgent,
|
|
35
37
|
})),
|
|
36
38
|
);
|
|
37
39
|
},
|
|
@@ -32,8 +32,10 @@ export const mineQuery = defineQueryHandler({
|
|
|
32
32
|
id: r.id,
|
|
33
33
|
createdAt: r.createdAt,
|
|
34
34
|
expiresAt: r.expiresAt,
|
|
35
|
-
ip: r.ip ? await decryptStoredPii(r.ip, "sessions:mine") : r.ip,
|
|
36
|
-
userAgent: r.userAgent
|
|
35
|
+
ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:mine") : r.ip,
|
|
36
|
+
userAgent: r.userAgent
|
|
37
|
+
? await decryptStoredPii(r.userAgent, "userAgent", "sessions:mine")
|
|
38
|
+
: r.userAgent,
|
|
37
39
|
current: currentSid === r.id,
|
|
38
40
|
})),
|
|
39
41
|
);
|
package/src/sessions/testing.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
// sessions feature owns. framework/testing only provides the generic
|
|
18
18
|
// `createLateBoundHolder<T>` — shape-independent.
|
|
19
19
|
|
|
20
|
-
import type { AuthRoutesConfig } from "@cosmicdrift/kumiko-framework/api";
|
|
20
|
+
import type { AuthRoutesConfig, SessionCreator } from "@cosmicdrift/kumiko-framework/api";
|
|
21
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
22
|
import type { LateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
23
|
import type { SessionCallbacks, SessionMassRevoker } from "./session-callbacks";
|
|
23
24
|
|
|
@@ -40,3 +41,15 @@ export function sessionCallbacksFromLateBound(
|
|
|
40
41
|
asMassRevoker: () => (userId) => holder.get().sessionMassRevoker(userId),
|
|
41
42
|
};
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
// Bootstrap actors (system-admin seed writes) need a real sid once a
|
|
46
|
+
// sessionChecker is wired — auth-middleware rejects sidless JWTs as
|
|
47
|
+
// forged/pre-session-tracking. Mints a live session row and returns the
|
|
48
|
+
// actor with `sid` attached so `stack.http.writeOk(..., actor)` passes.
|
|
49
|
+
export async function withMintedSession(
|
|
50
|
+
sessionCreator: SessionCreator,
|
|
51
|
+
user: SessionUser,
|
|
52
|
+
): Promise<SessionUser> {
|
|
53
|
+
const sid = await sessionCreator(user, { ip: "127.0.0.1", userAgent: "test" });
|
|
54
|
+
return { ...user, sid };
|
|
55
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { runChunkedMigration } from "../chunked-entity-migration";
|
|
3
|
+
|
|
4
|
+
describe("runChunkedMigration", () => {
|
|
5
|
+
test("processes all batches and stops with 'done' when nextBatch returns empty", async () => {
|
|
6
|
+
const batches: readonly number[][] = [[1, 2], [3], []];
|
|
7
|
+
let call = 0;
|
|
8
|
+
const migrated: number[] = [];
|
|
9
|
+
|
|
10
|
+
const result = await runChunkedMigration<number>({
|
|
11
|
+
nextBatch: async () => batches[call++] ?? [],
|
|
12
|
+
migrateRow: async (row) => {
|
|
13
|
+
migrated.push(row);
|
|
14
|
+
return "migrated";
|
|
15
|
+
},
|
|
16
|
+
maxFailures: 10,
|
|
17
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(migrated).toEqual([1, 2, 3]);
|
|
21
|
+
expect(result).toEqual({
|
|
22
|
+
migrated: 3,
|
|
23
|
+
skipped: 0,
|
|
24
|
+
failed: 0,
|
|
25
|
+
batchesProcessed: 2,
|
|
26
|
+
stoppedReason: "done",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("counts skipped and failed outcomes separately from migrated", async () => {
|
|
31
|
+
let call = 0;
|
|
32
|
+
const rows = [
|
|
33
|
+
{ id: 1, outcome: "migrated" as const },
|
|
34
|
+
{ id: 2, outcome: "skipped" as const },
|
|
35
|
+
{ id: 3, outcome: "failed" as const },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const result = await runChunkedMigration({
|
|
39
|
+
nextBatch: async () => (call++ === 0 ? rows : []),
|
|
40
|
+
migrateRow: async (row) => row.outcome,
|
|
41
|
+
maxFailures: 10,
|
|
42
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result.migrated).toBe(1);
|
|
46
|
+
expect(result.skipped).toBe(1);
|
|
47
|
+
expect(result.failed).toBe(1);
|
|
48
|
+
expect(result.stoppedReason).toBe("done");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a thrown migrateRow counts as failed and invokes onRowError", async () => {
|
|
52
|
+
let call = 0;
|
|
53
|
+
const errors: unknown[] = [];
|
|
54
|
+
const boom = new Error("boom");
|
|
55
|
+
|
|
56
|
+
const result = await runChunkedMigration<number>({
|
|
57
|
+
nextBatch: async () => (call++ === 0 ? [1] : []),
|
|
58
|
+
migrateRow: async () => {
|
|
59
|
+
throw boom;
|
|
60
|
+
},
|
|
61
|
+
maxFailures: 10,
|
|
62
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
63
|
+
onRowError: (row, err) => errors.push([row, err]),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result.failed).toBe(1);
|
|
67
|
+
expect(errors).toEqual([[1, boom]]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("stops with 'too_many_failures' once failed >= maxFailures, mid-batch", async () => {
|
|
71
|
+
let call = 0;
|
|
72
|
+
const seen: number[] = [];
|
|
73
|
+
|
|
74
|
+
const result = await runChunkedMigration<number>({
|
|
75
|
+
nextBatch: async () => (call++ === 0 ? [1, 2, 3] : []),
|
|
76
|
+
migrateRow: async (row) => {
|
|
77
|
+
seen.push(row);
|
|
78
|
+
return "failed";
|
|
79
|
+
},
|
|
80
|
+
maxFailures: 2,
|
|
81
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// The circuit-breaker checks before each row — row 3 is never attempted.
|
|
85
|
+
expect(seen).toEqual([1, 2]);
|
|
86
|
+
expect(result.failed).toBe(2);
|
|
87
|
+
expect(result.stoppedReason).toBe("too_many_failures");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("stops with 'too_many_failures' checked between batches too", async () => {
|
|
91
|
+
let call = 0;
|
|
92
|
+
const batches = [["a"], ["b"], ["c"]];
|
|
93
|
+
|
|
94
|
+
const result = await runChunkedMigration<string>({
|
|
95
|
+
nextBatch: async () => batches[call++] ?? [],
|
|
96
|
+
migrateRow: async () => "failed",
|
|
97
|
+
maxFailures: 1,
|
|
98
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Batch 1 processes "a" and pushes failed to 1; the breaker is only
|
|
102
|
+
// checked at the TOP of the loop, so batch 2 is fetched (batchesProcessed
|
|
103
|
+
// becomes 2) before the breaker trips and its row is never touched.
|
|
104
|
+
expect(result.batchesProcessed).toBe(2);
|
|
105
|
+
expect(result.stoppedReason).toBe("too_many_failures");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("stops with 'timeout' once the deadline has passed, without calling nextBatch again", async () => {
|
|
109
|
+
let nextBatchCalls = 0;
|
|
110
|
+
|
|
111
|
+
const result = await runChunkedMigration<number>({
|
|
112
|
+
nextBatch: async () => {
|
|
113
|
+
nextBatchCalls++;
|
|
114
|
+
return [];
|
|
115
|
+
},
|
|
116
|
+
migrateRow: async () => "migrated",
|
|
117
|
+
maxFailures: 10,
|
|
118
|
+
deadlineAt: Date.now() - 1,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(nextBatchCalls).toBe(0);
|
|
122
|
+
expect(result.batchesProcessed).toBe(0);
|
|
123
|
+
expect(result.stoppedReason).toBe("timeout");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("stops with 'signal' when the AbortSignal is already aborted", async () => {
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
controller.abort();
|
|
129
|
+
let nextBatchCalls = 0;
|
|
130
|
+
|
|
131
|
+
const result = await runChunkedMigration<number>({
|
|
132
|
+
nextBatch: async () => {
|
|
133
|
+
nextBatchCalls++;
|
|
134
|
+
return [];
|
|
135
|
+
},
|
|
136
|
+
migrateRow: async () => "migrated",
|
|
137
|
+
maxFailures: 10,
|
|
138
|
+
deadlineAt: Number.POSITIVE_INFINITY,
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(nextBatchCalls).toBe(0);
|
|
143
|
+
expect(result.stoppedReason).toBe("signal");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -6,14 +6,21 @@ import {
|
|
|
6
6
|
|
|
7
7
|
// Raw reads (fetchOne/selectMany) return the stored column value — with an
|
|
8
8
|
// active KMS that is ciphertext (self-describing: it names its own subject,
|
|
9
|
-
// so no entity/field context is needed
|
|
10
|
-
//
|
|
11
|
-
|
|
9
|
+
// so no entity/field context is needed for key selection). `field` must
|
|
10
|
+
// still match the field name used at encrypt time — it is part of the
|
|
11
|
+
// AAD (#1263), so a mismatch fails decrypt loud rather than silently
|
|
12
|
+
// letting ciphertext cut-and-pasted from another field decrypt. An erased
|
|
13
|
+
// subject yields the sentinel, which matches no real value.
|
|
14
|
+
export async function decryptStoredPii(
|
|
15
|
+
value: string,
|
|
16
|
+
field: string,
|
|
17
|
+
fallbackRequestId: string,
|
|
18
|
+
): Promise<string> {
|
|
12
19
|
const kms = configuredPiiSubjectKms();
|
|
13
20
|
if (!kms) return value;
|
|
14
|
-
const out = await decryptPiiFieldValues({ value }, [
|
|
21
|
+
const out = await decryptPiiFieldValues({ [field]: value }, [field], kms, {
|
|
15
22
|
requestId: requestContext.get()?.requestId ?? fallbackRequestId,
|
|
16
23
|
});
|
|
17
|
-
const decrypted = out[
|
|
24
|
+
const decrypted = out[field];
|
|
18
25
|
return typeof decrypted === "string" ? decrypted : value;
|
|
19
26
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// feature.ts contract tests for subscription-mollie.
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import type { MollieClient } from "@mollie/api-client";
|
|
6
|
+
import { describeSubscriptionProviderContract } from "../../billing-foundation/__tests__/subscription-provider-contract";
|
|
4
7
|
import { MOLLIE_PROVIDER_NAME, SUBSCRIPTION_MOLLIE_FEATURE } from "../constants";
|
|
5
8
|
import { createSubscriptionMollieFeature } from "../feature";
|
|
9
|
+
import { createMollieCheckoutSession } from "../plugin-methods";
|
|
10
|
+
import { type MollieClientShape, verifyAndParseMollieWebhook } from "../verify-webhook";
|
|
6
11
|
|
|
7
12
|
const VALID_OPTIONS = {
|
|
8
13
|
apiKey: "test_dummy_apikey",
|
|
@@ -98,9 +103,91 @@ describe("subscription-mollie — plugin-registration", () => {
|
|
|
98
103
|
createPortalSession?: unknown;
|
|
99
104
|
cancelSubscription?: unknown;
|
|
100
105
|
};
|
|
106
|
+
// Checks the mounted feature (the contract below builds its plugin
|
|
107
|
+
// by hand-wiring and would not catch a wiring gap here).
|
|
101
108
|
expect(typeof plugin?.verifyAndParseWebhook).toBe("function");
|
|
102
109
|
expect(typeof plugin?.createCheckoutSession).toBe("function");
|
|
103
110
|
expect(plugin?.createPortalSession).toBeUndefined();
|
|
104
111
|
expect(plugin?.cancelSubscription).toBeUndefined();
|
|
105
112
|
});
|
|
106
113
|
});
|
|
114
|
+
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// SubscriptionProviderPlugin contract
|
|
117
|
+
// =============================================================================
|
|
118
|
+
//
|
|
119
|
+
// Built directly from the plugin-method constructors (not from
|
|
120
|
+
// createSubscriptionMollieFeature's mounted plugin) — same reasoning as
|
|
121
|
+
// subscription-stripe's contract fixture: keeps SDK-call mocking local to
|
|
122
|
+
// the fixture instead of routing through the full feature-mount.
|
|
123
|
+
|
|
124
|
+
const CONTRACT_TENANT_ID = "tenant-contract";
|
|
125
|
+
const CONTRACT_PRICE_ID = "plan_pro";
|
|
126
|
+
|
|
127
|
+
function buildMollieContractFixture() {
|
|
128
|
+
const subscription = {
|
|
129
|
+
id: "sub_contract_001",
|
|
130
|
+
customerId: "cst_contract_001",
|
|
131
|
+
status: "active",
|
|
132
|
+
nextPaymentDate: "2026-06-15",
|
|
133
|
+
startDate: "2026-05-15",
|
|
134
|
+
metadata: { tenantId: CONTRACT_TENANT_ID, priceId: CONTRACT_PRICE_ID },
|
|
135
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Mollie-SDK-shape mock
|
|
136
|
+
} as any;
|
|
137
|
+
const payment = {
|
|
138
|
+
id: "tr_contract_001",
|
|
139
|
+
customerId: "cst_contract_001",
|
|
140
|
+
subscriptionId: "sub_contract_001",
|
|
141
|
+
sequenceType: "first",
|
|
142
|
+
status: "paid",
|
|
143
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Mollie-SDK-shape mock
|
|
144
|
+
} as any;
|
|
145
|
+
|
|
146
|
+
const webhookClient: MollieClientShape = {
|
|
147
|
+
payments: { get: async () => payment },
|
|
148
|
+
customerSubscriptions: {
|
|
149
|
+
get: async () => subscription,
|
|
150
|
+
list: async () => [],
|
|
151
|
+
create: async () => subscription,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const checkoutClient = {
|
|
156
|
+
customers: { create: async () => ({ id: "cus_contract" }) },
|
|
157
|
+
payments: {
|
|
158
|
+
create: async () => ({
|
|
159
|
+
getCheckoutUrl: () => "https://www.mollie.com/checkout/contract-test",
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
} as unknown as MollieClient;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
plugin: {
|
|
166
|
+
verifyAndParseWebhook: verifyAndParseMollieWebhook(webhookClient, {
|
|
167
|
+
priceToTier: VALID_OPTIONS.priceToTier,
|
|
168
|
+
priceToConfig: VALID_OPTIONS.priceToConfig,
|
|
169
|
+
}),
|
|
170
|
+
createCheckoutSession: createMollieCheckoutSession(
|
|
171
|
+
checkoutClient,
|
|
172
|
+
VALID_OPTIONS.priceToConfig,
|
|
173
|
+
VALID_OPTIONS.webhookUrl,
|
|
174
|
+
),
|
|
175
|
+
// createPortalSession + cancelSubscription deliberately omitted — Mollie limitation.
|
|
176
|
+
},
|
|
177
|
+
ctx: {} as HandlerContext,
|
|
178
|
+
checkout: {
|
|
179
|
+
priceId: CONTRACT_PRICE_ID,
|
|
180
|
+
tenantId: CONTRACT_TENANT_ID,
|
|
181
|
+
successUrl: "https://example.com/success",
|
|
182
|
+
cancelUrl: "https://example.com/cancel",
|
|
183
|
+
},
|
|
184
|
+
webhook: {
|
|
185
|
+
rawBody: JSON.stringify({ id: "tr_contract_001" }),
|
|
186
|
+
headers: { "content-type": "application/json" },
|
|
187
|
+
expectedTenantId: CONTRACT_TENANT_ID,
|
|
188
|
+
expectedTier: "pro",
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
describeSubscriptionProviderContract("subscription-mollie", buildMollieContractFixture);
|