@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.159.1
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-gate.test.tsx +65 -3
- 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 +20 -0
- 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__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -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/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -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 +22 -7
- 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/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 +4 -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-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/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createStaticLocaleResolver,
|
|
4
|
+
LocaleProvider,
|
|
5
|
+
PrimitivesProvider,
|
|
6
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
|
+
import { fireEvent, type RenderResult, render, screen, waitFor } from "@testing-library/react";
|
|
9
|
+
import type { ReactElement } from "react";
|
|
10
|
+
import { makeSessionApi } from "../../../auth-email-password/web/__tests__/test-utils";
|
|
11
|
+
import { SessionContext } from "../../../auth-email-password/web/session";
|
|
12
|
+
import { defaultTranslations } from "../i18n";
|
|
13
|
+
import { MfaVerifyScreen } from "../mfa-verify-screen";
|
|
14
|
+
|
|
15
|
+
function renderScreen(ui: ReactElement, session = makeSessionApi()): RenderResult {
|
|
16
|
+
return render(
|
|
17
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
18
|
+
<LocaleProvider
|
|
19
|
+
resolver={createStaticLocaleResolver({ locale: "de" })}
|
|
20
|
+
fallbackBundles={[defaultTranslations]}
|
|
21
|
+
>
|
|
22
|
+
<SessionContext.Provider value={session}>{ui}</SessionContext.Provider>
|
|
23
|
+
</LocaleProvider>
|
|
24
|
+
</PrimitivesProvider>,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
globalThis.fetch = mock(
|
|
30
|
+
async () => new Response(null, { status: 200 }),
|
|
31
|
+
) as unknown as typeof fetch;
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {});
|
|
34
|
+
|
|
35
|
+
describe("MfaVerifyScreen", () => {
|
|
36
|
+
test("rendert Titel + Code-Feld + Submit-Button (de)", () => {
|
|
37
|
+
void renderScreen(<MfaVerifyScreen challengeToken="challenge-1" />);
|
|
38
|
+
expect(screen.getByText("Zwei-Faktor-Bestätigung")).toBeTruthy();
|
|
39
|
+
expect(screen.getByLabelText(/^Code/)).toBeTruthy();
|
|
40
|
+
expect(screen.getByRole("button", { name: "Bestätigen" })).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("richtiger Code → ruft session.refresh() + onSuccess()", async () => {
|
|
44
|
+
globalThis.fetch = mock(
|
|
45
|
+
async () =>
|
|
46
|
+
new Response(
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
isSuccess: true,
|
|
49
|
+
token: "t",
|
|
50
|
+
user: { id: "u1", tenantId: "t1", roles: [] },
|
|
51
|
+
}),
|
|
52
|
+
{ status: 200 },
|
|
53
|
+
),
|
|
54
|
+
) as unknown as typeof fetch;
|
|
55
|
+
const session = makeSessionApi();
|
|
56
|
+
const onSuccess = mock(() => {});
|
|
57
|
+
void renderScreen(
|
|
58
|
+
<MfaVerifyScreen challengeToken="challenge-1" onSuccess={onSuccess} />,
|
|
59
|
+
session,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
fireEvent.change(screen.getByLabelText(/^Code/), { target: { value: "123456" } });
|
|
63
|
+
fireEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
|
|
64
|
+
|
|
65
|
+
await waitFor(() => {
|
|
66
|
+
expect(session.refresh).toHaveBeenCalled();
|
|
67
|
+
expect(onSuccess).toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("falscher Code (invalid_totp_code) → Banner mit übersetztem Text", async () => {
|
|
72
|
+
globalThis.fetch = mock(
|
|
73
|
+
async () =>
|
|
74
|
+
new Response(JSON.stringify({ isSuccess: false, error: "invalid_totp_code" }), {
|
|
75
|
+
status: 200,
|
|
76
|
+
}),
|
|
77
|
+
) as unknown as typeof fetch;
|
|
78
|
+
void renderScreen(<MfaVerifyScreen challengeToken="challenge-1" />);
|
|
79
|
+
|
|
80
|
+
fireEvent.change(screen.getByLabelText(/^Code/), { target: { value: "000000" } });
|
|
81
|
+
fireEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
|
|
82
|
+
|
|
83
|
+
await waitFor(() => {
|
|
84
|
+
expect(screen.getByText("Ungültiger Code. Bitte erneut versuchen.")).toBeTruthy();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("onCancel gesetzt → 'Zurück zum Login'-Button vorhanden, klick ruft onCancel", () => {
|
|
89
|
+
const onCancel = mock(() => {});
|
|
90
|
+
void renderScreen(<MfaVerifyScreen challengeToken="challenge-1" onCancel={onCancel} />);
|
|
91
|
+
|
|
92
|
+
fireEvent.click(screen.getByRole("button", { name: "Zurück zum Login" }));
|
|
93
|
+
expect(onCancel).toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("ohne onCancel → kein 'Zurück zum Login'-Button", () => {
|
|
97
|
+
void renderScreen(<MfaVerifyScreen challengeToken="challenge-1" />);
|
|
98
|
+
expect(screen.queryByRole("button", { name: "Zurück zum Login" })).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { SubscriptionProviderPlugin } from "../types";
|
|
4
|
+
|
|
5
|
+
export type SubscriptionProviderContractFixture = {
|
|
6
|
+
readonly plugin: SubscriptionProviderPlugin;
|
|
7
|
+
readonly ctx: HandlerContext;
|
|
8
|
+
readonly checkout: Parameters<
|
|
9
|
+
NonNullable<SubscriptionProviderPlugin["createCheckoutSession"]>
|
|
10
|
+
>[1];
|
|
11
|
+
// Providers without a customer-portal (e.g. Mollie) omit this — the
|
|
12
|
+
// portal-test then skips, mirroring `createPortalSession` being optional.
|
|
13
|
+
readonly portal?: Parameters<NonNullable<SubscriptionProviderPlugin["createPortalSession"]>>[1];
|
|
14
|
+
readonly cancelSubscriptionId?: string;
|
|
15
|
+
// A real, provider-signed/verifiable webhook payload for the happy path.
|
|
16
|
+
// Providers without a fixture skip the webhook-parsing test — building one
|
|
17
|
+
// needs provider-specific signing (Stripe HMAC) or a mocked API client
|
|
18
|
+
// (Mollie lazy-fetch), which the provider's own verify-webhook.test.ts
|
|
19
|
+
// already assembles.
|
|
20
|
+
readonly webhook?: {
|
|
21
|
+
readonly rawBody: string;
|
|
22
|
+
readonly headers: Record<string, string>;
|
|
23
|
+
readonly expectedTenantId: string;
|
|
24
|
+
readonly expectedTier: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function describeSubscriptionProviderContract(
|
|
29
|
+
name: string,
|
|
30
|
+
factory: () => SubscriptionProviderContractFixture | Promise<SubscriptionProviderContractFixture>,
|
|
31
|
+
): void {
|
|
32
|
+
describe(`${name} — SubscriptionProviderPlugin contract`, () => {
|
|
33
|
+
test("implements verifyAndParseWebhook", async () => {
|
|
34
|
+
const { plugin } = await factory();
|
|
35
|
+
expect(typeof plugin.verifyAndParseWebhook).toBe("function");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("verifyAndParseWebhook parses a valid provider event into a SubscriptionEvent", async () => {
|
|
39
|
+
const { plugin, webhook } = await factory();
|
|
40
|
+
if (!webhook) return;
|
|
41
|
+
const event = await plugin.verifyAndParseWebhook(webhook.rawBody, webhook.headers);
|
|
42
|
+
expect(event).not.toBeNull();
|
|
43
|
+
expect(event?.tenantId).toBe(webhook.expectedTenantId);
|
|
44
|
+
expect(event?.tier).toBe(webhook.expectedTier);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("createCheckoutSession returns a hosted-page url", async () => {
|
|
48
|
+
const { plugin, ctx, checkout } = await factory();
|
|
49
|
+
if (!plugin.createCheckoutSession) return;
|
|
50
|
+
const result = await plugin.createCheckoutSession(ctx, checkout);
|
|
51
|
+
expect(typeof result.url).toBe("string");
|
|
52
|
+
expect(result.url.length).toBeGreaterThan(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("createPortalSession returns a hosted-page url — providers without a portal skip", async () => {
|
|
56
|
+
const { plugin, ctx, portal } = await factory();
|
|
57
|
+
if (!plugin.createPortalSession || !portal) return;
|
|
58
|
+
const result = await plugin.createPortalSession(ctx, portal);
|
|
59
|
+
expect(typeof result.url).toBe("string");
|
|
60
|
+
expect(result.url.length).toBeGreaterThan(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("cancelSubscription resolves without throwing — providers without cancel-support skip", async () => {
|
|
64
|
+
const { plugin, ctx, cancelSubscriptionId } = await factory();
|
|
65
|
+
if (!plugin.cancelSubscription || !cancelSubscriptionId) return;
|
|
66
|
+
await expect(plugin.cancelSubscription(ctx, cancelSubscriptionId)).resolves.toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -2,21 +2,24 @@ import { PII_CIPHERTEXT_PREFIX } from "@cosmicdrift/kumiko-framework/crypto";
|
|
|
2
2
|
import type { EmailMessage, EmailTransport } from "./types";
|
|
3
3
|
|
|
4
4
|
const isProductionEnv = () => process.env["NODE_ENV"] === "production";
|
|
5
|
-
|
|
5
|
+
// Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
|
|
6
|
+
// older/decrypt-only format version still present in unmigrated rows.
|
|
7
|
+
const CIPHERTEXT_MARKER = "kumiko-pii:v";
|
|
8
|
+
const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
|
|
6
9
|
|
|
7
10
|
// A PII ciphertext never belongs in an outgoing mail. A ciphertext RECIPIENT
|
|
8
11
|
// is always refused (the address is garbage — better no mail than a leaked
|
|
9
12
|
// blob); ciphertext in subject/body fails loud in dev and is redacted+logged
|
|
10
13
|
// in prod.
|
|
11
14
|
export function guardEmailMessage(message: EmailMessage): EmailMessage {
|
|
12
|
-
if (message.to.includes(
|
|
15
|
+
if (message.to.includes(CIPHERTEXT_MARKER)) {
|
|
13
16
|
throw new Error(
|
|
14
17
|
"[channel-email] refusing to send: recipient address is a PII ciphertext " +
|
|
15
18
|
`("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
|
|
16
19
|
);
|
|
17
20
|
}
|
|
18
21
|
const leaking =
|
|
19
|
-
message.subject.includes(
|
|
22
|
+
message.subject.includes(CIPHERTEXT_MARKER) || message.html.includes(CIPHERTEXT_MARKER);
|
|
20
23
|
if (!leaking) return message;
|
|
21
24
|
const detail =
|
|
22
25
|
"[channel-email] mail subject/body contains a PII ciphertext " +
|
|
@@ -1086,6 +1086,20 @@ describe("encrypted config", () => {
|
|
|
1086
1086
|
const value = await configFn("integration:config:api-secret");
|
|
1087
1087
|
expect(value).toBe("sk-super-secret-key-12345");
|
|
1088
1088
|
});
|
|
1089
|
+
|
|
1090
|
+
test("reading an encrypted value with no cipher wired throws instead of leaking ciphertext", async () => {
|
|
1091
|
+
const noCipherResolver = createConfigResolver({});
|
|
1092
|
+
const configFn = createConfigAccessor(
|
|
1093
|
+
stack.registry,
|
|
1094
|
+
noCipherResolver,
|
|
1095
|
+
"00000000-0000-4000-8000-000000000001",
|
|
1096
|
+
systemAdmin.id,
|
|
1097
|
+
db,
|
|
1098
|
+
);
|
|
1099
|
+
await expect(configFn("integration:config:api-secret")).rejects.toThrow(
|
|
1100
|
+
/encrypted but no cipher/,
|
|
1101
|
+
);
|
|
1102
|
+
});
|
|
1089
1103
|
});
|
|
1090
1104
|
|
|
1091
1105
|
// --- Config lifecycle events ---
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
configurePiiSubjectKms,
|
|
12
12
|
InMemoryKmsAdapter,
|
|
13
13
|
isPiiCiphertext,
|
|
14
|
+
PII_CIPHERTEXT_PREFIX,
|
|
14
15
|
resetPiiSubjectKmsForTests,
|
|
15
16
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
16
17
|
import {
|
|
@@ -96,7 +97,7 @@ describe("piiEncrypted config keys", () => {
|
|
|
96
97
|
});
|
|
97
98
|
expect(row).toBeDefined();
|
|
98
99
|
expect(isPiiCiphertext(row?.value)).toBe(true);
|
|
99
|
-
expect(row?.value).toStartWith(
|
|
100
|
+
expect(row?.value).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${tenantAdmin.tenantId}:`);
|
|
100
101
|
|
|
101
102
|
const values = await stack.http.queryOk<
|
|
102
103
|
Record<string, { value: unknown; scope: string; source: string }>
|
|
@@ -118,7 +119,7 @@ describe("piiEncrypted config keys", () => {
|
|
|
118
119
|
});
|
|
119
120
|
expect(row).toBeDefined();
|
|
120
121
|
expect(isPiiCiphertext(row?.value)).toBe(true);
|
|
121
|
-
expect(row?.value).toStartWith(
|
|
122
|
+
expect(row?.value).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${tenantAdmin.id}:`);
|
|
122
123
|
|
|
123
124
|
const values = await stack.http.queryOk<
|
|
124
125
|
Record<string, { value: unknown; scope: string; source: string }>
|
|
@@ -136,7 +136,10 @@ const noopLog = {
|
|
|
136
136
|
// that's the point in migrateRow right after decrypt, right before its own
|
|
137
137
|
// executor.update, so the job's captured row.version is guaranteed stale by
|
|
138
138
|
// the time it issues that update.
|
|
139
|
-
function racyJobCtx(
|
|
139
|
+
function racyJobCtx(
|
|
140
|
+
rowVersionAtBatchRead: number,
|
|
141
|
+
log: typeof noopLog = noopLog,
|
|
142
|
+
): Parameters<typeof reencryptJob>[1] {
|
|
140
143
|
let fired = false;
|
|
141
144
|
const racyCipher: EnvelopeCipher = {
|
|
142
145
|
decrypt: (stored, scope) => cipher.decrypt(stored, scope),
|
|
@@ -153,7 +156,7 @@ function racyJobCtx(rowVersionAtBatchRead: number): Parameters<typeof reencryptJ
|
|
|
153
156
|
registry: stack.registry,
|
|
154
157
|
masterKeyProvider: mutableProvider,
|
|
155
158
|
configEncryption: racyCipher,
|
|
156
|
-
log
|
|
159
|
+
log,
|
|
157
160
|
} as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
|
|
158
161
|
}
|
|
159
162
|
|
|
@@ -187,7 +190,21 @@ describe("config KEK-rotation job — concurrent write race (kumiko-framework#11
|
|
|
187
190
|
}),
|
|
188
191
|
);
|
|
189
192
|
|
|
190
|
-
|
|
193
|
+
// Distinguishes "skipped" (version_conflict, row already fine) from
|
|
194
|
+
// "failed" (any other rejection) — both leave the row's end-state
|
|
195
|
+
// identical (reencrypt.job.ts:159-166), so a regression that silently
|
|
196
|
+
// turns this branch into "failed" would pass every assertion below it.
|
|
197
|
+
// The warn log at reencrypt.job.ts:171 is the only observable signature
|
|
198
|
+
// that fires on "failed" but not on "skipped" — assert it stayed silent.
|
|
199
|
+
const warnCalls: unknown[][] = [];
|
|
200
|
+
const spyLog = {
|
|
201
|
+
...noopLog,
|
|
202
|
+
warn: (...args: unknown[]) => {
|
|
203
|
+
warnCalls.push(args);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
await reencryptJob({}, racyJobCtx(seeded.version, spyLog));
|
|
207
|
+
expect(warnCalls).toEqual([]);
|
|
191
208
|
|
|
192
209
|
// The concurrent write already lands under the current key — its
|
|
193
210
|
// envelope must survive untouched; the rotation job's own update lost
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Regression guard for kumiko-framework#1187: proves the config
|
|
2
|
-
// KEK-rotation job handles a real version-N → version-N+1 rotation
|
|
3
|
-
//
|
|
4
|
-
// .test.ts for that story). Modeled directly on auth-mfa's
|
|
2
|
+
// KEK-rotation job handles a real version-N → version-N+1 rotation. Modeled
|
|
3
|
+
// directly on auth-mfa's
|
|
5
4
|
// reencrypt-job.integration.test.ts, which is the reference regression
|
|
6
5
|
// guard for this exact bug class (kumiko-framework#266 Step 8): rotation
|
|
7
6
|
// must go through executor.update() (a real event), not a raw UPDATE on
|
package/src/config/feature.ts
CHANGED
|
@@ -58,10 +58,8 @@ export function createConfigFeature(): FeatureDefinition {
|
|
|
58
58
|
readiness: r.queryHandler(readinessQuery),
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// once after adding a new master key version or before retiring the
|
|
64
|
-
// legacy CONFIG_ENCRYPTION_KEY.
|
|
61
|
+
// KEK-rotation for encrypted values. Manual trigger — ops runs it once
|
|
62
|
+
// after adding a new master key version.
|
|
65
63
|
r.job("reencrypt", { trigger: { manual: true } }, reencryptJob);
|
|
66
64
|
|
|
67
65
|
r.translations({ keys: CONFIG_FEATURE_I18N });
|
|
@@ -135,7 +135,13 @@ export const setWrite = defineWriteHandler({
|
|
|
135
135
|
tenantId,
|
|
136
136
|
userId: String(event.user.id),
|
|
137
137
|
};
|
|
138
|
-
serialized = await encryptPiiValueForSubject(
|
|
138
|
+
serialized = await encryptPiiValueForSubject(
|
|
139
|
+
kms,
|
|
140
|
+
subject,
|
|
141
|
+
serialized,
|
|
142
|
+
kmsCtx,
|
|
143
|
+
event.payload.key,
|
|
144
|
+
);
|
|
139
145
|
}
|
|
140
146
|
|
|
141
147
|
const existing = await findConfigRow(db, event.payload.key, tenantId, userId);
|
package/src/config/resolver.ts
CHANGED
|
@@ -117,7 +117,7 @@ async function decryptPiiEncrypted(raw: string, qualifiedKey: string): Promise<s
|
|
|
117
117
|
});
|
|
118
118
|
}
|
|
119
119
|
const kmsCtx: KmsContext = { requestId: requestContext.get()?.requestId ?? "config:resolver" };
|
|
120
|
-
return decryptPiiValueForSubject(kms, raw, kmsCtx);
|
|
120
|
+
return decryptPiiValueForSubject(kms, raw, kmsCtx, qualifiedKey);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
// backing="secrets" keys store their value in the secrets store (flat per
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// rows on strategy "delete".
|
|
15
15
|
|
|
16
16
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
17
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
17
18
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
18
19
|
import {
|
|
19
20
|
buildEntityTable,
|
|
@@ -137,6 +138,7 @@ beforeAll(async () => {
|
|
|
137
138
|
stack = await setupTestStack({
|
|
138
139
|
features: [
|
|
139
140
|
createUserFeature(),
|
|
141
|
+
authFoundationFeature,
|
|
140
142
|
createSessionsFeature(),
|
|
141
143
|
createDataRetentionFeature(),
|
|
142
144
|
createComplianceProfilesFeature(),
|
|
@@ -31,7 +31,7 @@ export const logQuery = defineQueryHandler({
|
|
|
31
31
|
...row,
|
|
32
32
|
recipientAddress:
|
|
33
33
|
typeof row["recipientAddress"] === "string"
|
|
34
|
-
? await decryptStoredPii(row["recipientAddress"], "delivery-log")
|
|
34
|
+
? await decryptStoredPii(row["recipientAddress"], "recipientAddress", "delivery-log")
|
|
35
35
|
: row["recipientAddress"],
|
|
36
36
|
})),
|
|
37
37
|
),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
3
|
+
import { describeFileProviderContract } from "@cosmicdrift/kumiko-framework/testing";
|
|
3
4
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
5
|
import { createS3ProviderFromEnv } from "../env-helper";
|
|
5
6
|
import { createS3Provider } from "../s3-provider";
|
|
@@ -219,3 +220,9 @@ describe("createS3ProviderFromEnv", () => {
|
|
|
219
220
|
expect(() => createS3ProviderFromEnv("NON_EXISTENT_PREFIX_")).toThrow(/missing_env/);
|
|
220
221
|
});
|
|
221
222
|
});
|
|
223
|
+
|
|
224
|
+
describeFileProviderContract("S3Provider (Minio)", () => provider);
|
|
225
|
+
|
|
226
|
+
describeFileProviderContract("createS3ProviderFromEnv (Minio)", () =>
|
|
227
|
+
createS3ProviderFromEnv("MINIO_"),
|
|
228
|
+
);
|
|
@@ -165,11 +165,12 @@ describe("FolderManager filing mode", () => {
|
|
|
165
165
|
await waitFor(() => expect(onReassigned).toHaveBeenCalled());
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
test("the in-tree new-folder row opens a draft; submitting (Enter) creates a root folder", async () => {
|
|
168
|
+
test("the in-tree new-folder row opens a draft; submitting (Enter) creates a root folder and reassigns", async () => {
|
|
169
169
|
folderRows = [];
|
|
170
|
+
const onReassigned = mock(() => {});
|
|
170
171
|
render(
|
|
171
172
|
<Wrapper>
|
|
172
|
-
<FolderManager />
|
|
173
|
+
<FolderManager filing={filingWith(onReassigned)} />
|
|
173
174
|
</Wrapper>,
|
|
174
175
|
);
|
|
175
176
|
fireEvent.click(screen.getByTestId("folder-manager-new-root"));
|
|
@@ -180,6 +181,30 @@ describe("FolderManager filing mode", () => {
|
|
|
180
181
|
await waitFor(() =>
|
|
181
182
|
expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.createFolder, { name: "Inbox" }),
|
|
182
183
|
);
|
|
184
|
+
await waitFor(() => expect(onReassigned).toHaveBeenCalled());
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("renaming a folder dispatches update-folder and reassigns", async () => {
|
|
188
|
+
folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
|
|
189
|
+
const onReassigned = mock(() => {});
|
|
190
|
+
render(
|
|
191
|
+
<Wrapper>
|
|
192
|
+
<FolderManager filing={filingWith(onReassigned)} />
|
|
193
|
+
</Wrapper>,
|
|
194
|
+
);
|
|
195
|
+
fireEvent.click(screen.getByTestId("folder-rename-f1"));
|
|
196
|
+
fireEvent.change(document.getElementById("folder-manager-draft") as HTMLInputElement, {
|
|
197
|
+
target: { value: "Renamed" },
|
|
198
|
+
});
|
|
199
|
+
fireEvent.submit(screen.getByTestId("folder-manager-draft"));
|
|
200
|
+
await waitFor(() =>
|
|
201
|
+
expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.updateFolder, {
|
|
202
|
+
id: "f1",
|
|
203
|
+
version: 1,
|
|
204
|
+
changes: { name: "Renamed" },
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
await waitFor(() => expect(onReassigned).toHaveBeenCalled());
|
|
183
208
|
});
|
|
184
209
|
|
|
185
210
|
test("a mixed-type tree files each leaf under its own entityType (per-leaf override + fallback)", async () => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { MailAccountEventPayload } from "../events";
|
|
4
|
+
import { loadCurrentMailAccountPayload } from "../handlers/account-state";
|
|
5
|
+
|
|
6
|
+
function ctxWithEvents(
|
|
7
|
+
payloads: readonly MailAccountEventPayload[],
|
|
8
|
+
): Pick<HandlerContext, "loadAggregate"> {
|
|
9
|
+
return {
|
|
10
|
+
loadAggregate: async () =>
|
|
11
|
+
payloads.map((payload) => ({
|
|
12
|
+
// @cast-boundary test fixture — only `payload` is read by the function under test
|
|
13
|
+
payload,
|
|
14
|
+
})) as unknown as Awaited<ReturnType<HandlerContext["loadAggregate"]>>,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("loadCurrentMailAccountPayload", () => {
|
|
19
|
+
test("returns undefined when the aggregate has no events", async () => {
|
|
20
|
+
const ctx = ctxWithEvents([]);
|
|
21
|
+
expect(await loadCurrentMailAccountPayload(ctx, "acc-1")).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("returns the payload of the LAST event — full-snapshot semantics", async () => {
|
|
25
|
+
const first = { status: "active" } as unknown as MailAccountEventPayload;
|
|
26
|
+
const last = { status: "degraded" } as unknown as MailAccountEventPayload;
|
|
27
|
+
const ctx = ctxWithEvents([first, last]);
|
|
28
|
+
expect(await loadCurrentMailAccountPayload(ctx, "acc-1")).toBe(last);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -274,12 +274,13 @@ describe("scenario 2b: ingest-message — Thread-Rollup selbstkorrigierend statt
|
|
|
274
274
|
);
|
|
275
275
|
const threadAggId = mailThreadAggregateId(admin.tenantId, "mid:self-heal-root@example.com");
|
|
276
276
|
|
|
277
|
-
// Simuliert
|
|
278
|
-
// Handler-Kommentar Schritt 5)
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
277
|
+
// Simuliert den Snapshot-Drift, den ein verlorener Version-Conflict-
|
|
278
|
+
// Retry (siehe Handler-Kommentar Schritt 5) theoretisch trotz der
|
|
279
|
+
// Retry-Schleife hinterlassen könnte: 2 Message-Rows liegen vor, aber
|
|
280
|
+
// der ZULETZT geschriebene Thread-Event trägt einen zu niedrigen
|
|
281
|
+
// Snapshot. Der neue ingest liest den Live-COUNT gegen
|
|
282
|
+
// read_inbound_messages neu statt dem Event-Snapshot zu vertrauen —
|
|
283
|
+
// der Fehler ist selbstkorrigierend, nicht kumulativ.
|
|
283
284
|
const expectedVersion = await getStreamVersion(db, threadAggId, admin.tenantId);
|
|
284
285
|
const corruptedPayload: MailThreadEventPayload = {
|
|
285
286
|
threadKey: "mid:self-heal-root@example.com",
|
|
@@ -319,6 +320,56 @@ describe("scenario 2b: ingest-message — Thread-Rollup selbstkorrigierend statt
|
|
|
319
320
|
});
|
|
320
321
|
});
|
|
321
322
|
|
|
323
|
+
describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #1229)", () => {
|
|
324
|
+
// Probabilistic — looped per house convention (mehrere Durchläufe, z.B.
|
|
325
|
+
// 20x): the version-conflict race between two step-5 thread-rollup
|
|
326
|
+
// appends only fires on genuine transaction-timing interleaving, which
|
|
327
|
+
// a single run can't force deterministically.
|
|
328
|
+
const ITERATIONS = 20;
|
|
329
|
+
const CONCURRENCY = 3;
|
|
330
|
+
|
|
331
|
+
test(`${CONCURRENCY} concurrent ingests of different messages in the same thread never 500 and converge on the correct messageCount, ${ITERATIONS} rounds`, async () => {
|
|
332
|
+
const admin = adminFor(4022);
|
|
333
|
+
const accountId = await connectSharedAccount(admin);
|
|
334
|
+
|
|
335
|
+
for (let round = 0; round < ITERATIONS; round++) {
|
|
336
|
+
const rootMessageIdHeader = `race-root-${round}@example.com`;
|
|
337
|
+
|
|
338
|
+
const responses = await Promise.all(
|
|
339
|
+
Array.from({ length: CONCURRENCY }, (_, n) =>
|
|
340
|
+
stack.http.write(
|
|
341
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
342
|
+
ingestPayload(accountId, {
|
|
343
|
+
providerMessageId: `race-${round}-${n}`,
|
|
344
|
+
messageIdHeader: `race-${round}-${n}@example.com`,
|
|
345
|
+
references: [rootMessageIdHeader],
|
|
346
|
+
}),
|
|
347
|
+
admin,
|
|
348
|
+
),
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// No writer's transaction fails with a 500 from the thread-rollup
|
|
353
|
+
// version conflict — each of the CONCURRENCY distinct messages is
|
|
354
|
+
// its own aggregate, so step 4 never races; only step 5 (shared
|
|
355
|
+
// threadAggId) does, and the bounded retry loop must absorb it.
|
|
356
|
+
for (const res of responses) {
|
|
357
|
+
expect(res.status).toBe(200);
|
|
358
|
+
const body = (await res.json()) as { isSuccess: boolean; data: { duplicate: boolean } };
|
|
359
|
+
expect(body.isSuccess).toBe(true);
|
|
360
|
+
expect(body.data.duplicate).toBe(false);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const threadAggId = mailThreadAggregateId(admin.tenantId, `mid:${rootMessageIdHeader}`);
|
|
364
|
+
const threadRows = await selectMany(db, mailThreadsProjectionTable, { id: threadAggId });
|
|
365
|
+
expect(threadRows).toHaveLength(1);
|
|
366
|
+
// Every message's contribution landed — no drift from a silently
|
|
367
|
+
// stale-read-and-succeed append (the bug the retry loop replaces).
|
|
368
|
+
expect(threadRows[0]?.["messageCount"]).toBe(CONCURRENCY);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
322
373
|
describe("scenario 3: Scope-Sichtbarkeit (persönliches Postfach)", () => {
|
|
323
374
|
test("Messages eines user-scoped Accounts sieht nur der Owner (+ TenantAdmin)", async () => {
|
|
324
375
|
const tenantNumber = 4003;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Shared contract for inbound-mail-foundation provider plugins — analog
|
|
2
|
+
// to describeKmsAdapterContract (packages/framework/src/crypto/__tests__).
|
|
3
|
+
// Cursor payloads are provider-opaque here (imap: {uidValidity,lastUid},
|
|
4
|
+
// inmemory: {offset}) — the contract only round-trips nextCursor, never
|
|
5
|
+
// inspects its shape. Error-path behaviour stays provider-specific
|
|
6
|
+
// (verify/wrong-credentials semantics don't line up across providers).
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { InboundMailContext, InboundMailProviderPlugin, MailAccountRecord } from "../types";
|
|
10
|
+
|
|
11
|
+
export type InboundMailProviderContractFixture = {
|
|
12
|
+
readonly plugin: InboundMailProviderPlugin;
|
|
13
|
+
readonly ctx: InboundMailContext;
|
|
14
|
+
readonly account: MailAccountRecord;
|
|
15
|
+
/** Injects a message with the given subject into the provider's
|
|
16
|
+
* backing store (imap: sends real mail; inmemory: seedInboundMessage). */
|
|
17
|
+
readonly seed: (subject: string) => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function describeInboundMailProviderContract(
|
|
21
|
+
name: string,
|
|
22
|
+
factory: () => InboundMailProviderContractFixture | Promise<InboundMailProviderContractFixture>,
|
|
23
|
+
opts?: { readonly skip?: boolean },
|
|
24
|
+
): void {
|
|
25
|
+
// Eager sync probe so a provider without `watch` produces a visible
|
|
26
|
+
// test.skip instead of a silent 0-assertion pass (#1337). Both current
|
|
27
|
+
// factories (imap, inmemory) are sync object literals; an async factory
|
|
28
|
+
// falls back to the old runtime `if (!plugin.watch) return` check below.
|
|
29
|
+
const probe = factory();
|
|
30
|
+
const watchProbe = probe instanceof Promise ? undefined : Boolean(probe.plugin.watch);
|
|
31
|
+
|
|
32
|
+
describe(`${name} — InboundMailProviderPlugin contract`, () => {
|
|
33
|
+
const t = opts?.skip ? test.skip : test;
|
|
34
|
+
t("verify resolves for a valid account", async () => {
|
|
35
|
+
const { plugin, ctx, account } = await factory();
|
|
36
|
+
await expect(plugin.verify(ctx, account)).resolves.toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
t(
|
|
40
|
+
"fetch: backfill picks up a seeded message, incremental cursor finds nothing new",
|
|
41
|
+
async () => {
|
|
42
|
+
const { plugin, ctx, account, seed } = await factory();
|
|
43
|
+
const subject = `contract-${crypto.randomUUID()}`;
|
|
44
|
+
await seed(subject);
|
|
45
|
+
|
|
46
|
+
const first = await plugin.fetch(ctx, account, null, {
|
|
47
|
+
backfillWindowDays: 1,
|
|
48
|
+
maxMessages: 50,
|
|
49
|
+
});
|
|
50
|
+
expect(first.messages.some((m) => m.subject === subject)).toBe(true);
|
|
51
|
+
|
|
52
|
+
const second = await plugin.fetch(ctx, account, first.nextCursor, {
|
|
53
|
+
backfillWindowDays: 1,
|
|
54
|
+
maxMessages: 50,
|
|
55
|
+
});
|
|
56
|
+
expect(second.messages.some((m) => m.subject === subject)).toBe(false);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const watchTest = watchProbe === false ? test.skip : t;
|
|
61
|
+
watchTest("watch: pushes a seeded message via onMessages", async () => {
|
|
62
|
+
const { plugin, ctx, account, seed } = await factory();
|
|
63
|
+
if (!plugin.watch) return; // fallback for an async factory (watchProbe undefined)
|
|
64
|
+
const subject = `contract-watch-${crypto.randomUUID()}`;
|
|
65
|
+
|
|
66
|
+
const pushed = new Promise<void>((resolve, reject) => {
|
|
67
|
+
const timer = setTimeout(
|
|
68
|
+
() => reject(new Error("watch push not received within 5s")),
|
|
69
|
+
5_000,
|
|
70
|
+
);
|
|
71
|
+
void plugin
|
|
72
|
+
.watch?.(ctx, account, {
|
|
73
|
+
onMessages: async (msgs) => {
|
|
74
|
+
if (msgs.some((m) => m.subject === subject)) {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
resolve();
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
onError: (err) => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
.then((stop) => {
|
|
85
|
+
void seed(subject).catch(reject);
|
|
86
|
+
void pushed.finally(() => void stop().catch(() => {}));
|
|
87
|
+
}, reject);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await pushed;
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|