@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
|
@@ -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 }>
|
|
@@ -155,4 +156,24 @@ describe("piiEncrypted config keys", () => {
|
|
|
155
156
|
configurePiiSubjectKms(kms);
|
|
156
157
|
}
|
|
157
158
|
});
|
|
159
|
+
|
|
160
|
+
test("read of stored piiEncrypted ciphertext without KMS fails loud", async () => {
|
|
161
|
+
// Plant ciphertext while KMS is wired, then strip it so the READ path
|
|
162
|
+
// (decryptPiiEncrypted) — not just the write path — fails loud.
|
|
163
|
+
await stack.http.writeOk(
|
|
164
|
+
ConfigHandlers.set,
|
|
165
|
+
{ key: BILLING_ADDRESS_KEY, value: "planted-for-read" },
|
|
166
|
+
tenantAdmin,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
resetPiiSubjectKmsForTests();
|
|
170
|
+
expect(configuredPiiSubjectKms()).toBeUndefined();
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const res = await stack.http.query(ConfigQueries.values, {}, tenantAdmin);
|
|
174
|
+
expect(res.status).toBe(500);
|
|
175
|
+
} finally {
|
|
176
|
+
configurePiiSubjectKms(kms);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
158
179
|
});
|
|
@@ -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
|
+
}
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
InboundCursorInvalidError,
|
|
44
44
|
InboundMailAccountStatuses,
|
|
45
45
|
InboundMailFoundationHandlers,
|
|
46
|
+
InboundRateLimitError,
|
|
46
47
|
inboundMailFoundationFeature,
|
|
47
48
|
inboundMessagesProjectionTable,
|
|
48
49
|
mailAccountsProjectionTable,
|
|
@@ -268,4 +269,157 @@ describe("watch-supervisor — error semantics", () => {
|
|
|
268
269
|
"cursor-ok-2@example.com",
|
|
269
270
|
]);
|
|
270
271
|
});
|
|
272
|
+
|
|
273
|
+
test("InboundRateLimitError on fetch keeps the account active for the next tick", async () => {
|
|
274
|
+
const admin = adminFor(4107);
|
|
275
|
+
const accountId = await connectSharedAccount(admin);
|
|
276
|
+
failNextFetchWith(accountId, new InboundRateLimitError("slow down", 30_000));
|
|
277
|
+
|
|
278
|
+
const supervisor = createSupervisor();
|
|
279
|
+
await supervisor.pollOnce();
|
|
280
|
+
|
|
281
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
282
|
+
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("watch-supervisor — watch restart + ingest resilience", () => {
|
|
287
|
+
test("transient watch error schedules backoff restart and re-arms watch", async () => {
|
|
288
|
+
const admin = adminFor(4108);
|
|
289
|
+
const accountId = await connectSharedAccount(admin);
|
|
290
|
+
const supervisor = createSupervisor();
|
|
291
|
+
try {
|
|
292
|
+
await supervisor.start();
|
|
293
|
+
await waitFor(() => {
|
|
294
|
+
expect(isWatching(accountId)).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
emitWatchError(accountId, new Error("socket dropped"));
|
|
298
|
+
|
|
299
|
+
await waitFor(() => {
|
|
300
|
+
expect(isWatching(accountId)).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
304
|
+
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
|
|
305
|
+
expect(String(accounts[0]?.["watchState"])).toMatch(/^watching|backoff:/);
|
|
306
|
+
} finally {
|
|
307
|
+
await supervisor.stop();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("watch-ingest failure is logged; poll reconciliation still ingests", async () => {
|
|
312
|
+
const admin = adminFor(4109);
|
|
313
|
+
const accountId = await connectSharedAccount(admin);
|
|
314
|
+
let ingestCalls = 0;
|
|
315
|
+
const supervisor = createInboundMailSupervisor({
|
|
316
|
+
providerCtx: { registry: stack.registry },
|
|
317
|
+
db,
|
|
318
|
+
dispatchWrite: ({ handlerQn, payload, tenantId }) => {
|
|
319
|
+
if (handlerQn === InboundMailFoundationHandlers.ingestMessage) {
|
|
320
|
+
ingestCalls += 1;
|
|
321
|
+
if (ingestCalls === 1) {
|
|
322
|
+
return Promise.resolve({ isSuccess: false, error: { code: "simulated" } });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return stack.dispatcher.write(
|
|
326
|
+
handlerQn,
|
|
327
|
+
payload,
|
|
328
|
+
createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
|
|
329
|
+
);
|
|
330
|
+
},
|
|
331
|
+
pollIntervalMs: 60_000,
|
|
332
|
+
watchBackoffInitialMs: 50,
|
|
333
|
+
watchBackoffMaxMs: 200,
|
|
334
|
+
});
|
|
335
|
+
try {
|
|
336
|
+
await supervisor.start();
|
|
337
|
+
await waitFor(() => {
|
|
338
|
+
expect(isWatching(accountId)).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await seedInboundMessage(
|
|
342
|
+
accountId,
|
|
343
|
+
rawMsg({ providerMessageId: "watch-ingest-fail-1", subject: "via watch" }),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
await supervisor.pollOnce();
|
|
347
|
+
|
|
348
|
+
const rows = await selectMany(db, inboundMessagesProjectionTable, { accountId });
|
|
349
|
+
expect(rows.some((r) => r["messageIdHeader"] === "watch-ingest-fail-1@example.com")).toBe(
|
|
350
|
+
true,
|
|
351
|
+
);
|
|
352
|
+
} finally {
|
|
353
|
+
await supervisor.stop();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("storeBody hook persists raw MIME references on poll ingest", async () => {
|
|
358
|
+
const admin = adminFor(4110);
|
|
359
|
+
const accountId = await connectSharedAccount(admin);
|
|
360
|
+
const stored: string[] = [];
|
|
361
|
+
const supervisor = createInboundMailSupervisor({
|
|
362
|
+
providerCtx: { registry: stack.registry },
|
|
363
|
+
db,
|
|
364
|
+
dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
365
|
+
stack.dispatcher.write(
|
|
366
|
+
handlerQn,
|
|
367
|
+
payload,
|
|
368
|
+
createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
|
|
369
|
+
),
|
|
370
|
+
storeBody: async (_account, msg) => {
|
|
371
|
+
stored.push(msg.providerMessageId);
|
|
372
|
+
return `body-ref:${msg.providerMessageId}`;
|
|
373
|
+
},
|
|
374
|
+
pollIntervalMs: 60_000,
|
|
375
|
+
});
|
|
376
|
+
await seedInboundMessage(
|
|
377
|
+
accountId,
|
|
378
|
+
rawMsg({
|
|
379
|
+
providerMessageId: "body-store-1",
|
|
380
|
+
rawMime: new TextEncoder().encode("raw-mime-bytes"),
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
await supervisor.pollOnce();
|
|
384
|
+
expect(stored).toEqual(["body-store-1"]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("disconnecting an account tears down its watcher on the next pollOnce", async () => {
|
|
388
|
+
const admin = adminFor(4111);
|
|
389
|
+
const accountId = await connectSharedAccount(admin);
|
|
390
|
+
const supervisor = createSupervisor();
|
|
391
|
+
try {
|
|
392
|
+
await supervisor.start();
|
|
393
|
+
await waitFor(() => {
|
|
394
|
+
expect(isWatching(accountId)).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await stack.http.writeOk(
|
|
398
|
+
InboundMailFoundationHandlers.disconnectAccount,
|
|
399
|
+
{ accountId, reason: "test" },
|
|
400
|
+
admin,
|
|
401
|
+
);
|
|
402
|
+
await supervisor.pollOnce();
|
|
403
|
+
|
|
404
|
+
expect(isWatching(accountId)).toBe(false);
|
|
405
|
+
} finally {
|
|
406
|
+
await supervisor.stop();
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("start() is idempotent — second start keeps the watcher armed", async () => {
|
|
411
|
+
const admin = adminFor(4112);
|
|
412
|
+
const accountId = await connectSharedAccount(admin);
|
|
413
|
+
const supervisor = createSupervisor();
|
|
414
|
+
try {
|
|
415
|
+
await supervisor.start();
|
|
416
|
+
await waitFor(() => {
|
|
417
|
+
expect(isWatching(accountId)).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
await supervisor.start();
|
|
420
|
+
expect(isWatching(accountId)).toBe(true);
|
|
421
|
+
} finally {
|
|
422
|
+
await supervisor.stop();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
271
425
|
});
|