@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -40,8 +40,16 @@ const testRegistry = createRegistry([
|
|
|
40
40
|
}),
|
|
41
41
|
]);
|
|
42
42
|
|
|
43
|
+
// Mirrors tier-engine's real resolver contract (tier-engine/feature.ts's
|
|
44
|
+
// mergeAlwaysOn calls): SYSTEM_TENANT_ID always includes at least the
|
|
45
|
+
// always-on features merged in, even when zero tiers grant anything —
|
|
46
|
+
// tests exercising "no tier grants this feature" must still see a non-empty
|
|
47
|
+
// SYSTEM_TENANT_ID union, exactly like a real app always has.
|
|
43
48
|
function tierResolverGranting(...features: readonly string[]): EffectiveFeaturesResolver {
|
|
44
|
-
return (
|
|
49
|
+
return (tenantId: TenantId) =>
|
|
50
|
+
tenantId === SYSTEM_TENANT_ID
|
|
51
|
+
? new Set([...features, "always-on-stand-in"])
|
|
52
|
+
: new Set(features);
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
describe("composeTierResolverWithGlobalToggles", () => {
|
|
@@ -77,10 +85,10 @@ describe("composeTierResolverWithGlobalToggles", () => {
|
|
|
77
85
|
// nothing (e.g. Free). An operator flipping the global row to true
|
|
78
86
|
// must not leak it in — that decision belongs to the tier, not the
|
|
79
87
|
// toggle.
|
|
80
|
-
const tierResolver = (
|
|
88
|
+
const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
|
|
81
89
|
tenantId === SYSTEM_TENANT_ID
|
|
82
90
|
? new Set(["personal-access-tokens"]) // union of all tiers' features
|
|
83
|
-
: new Set<string>()
|
|
91
|
+
: new Set<string>(); // this tenant's (Free) tier grants nothing
|
|
84
92
|
const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
|
|
85
93
|
runtime.apply("personal-access-tokens", true);
|
|
86
94
|
const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, testRegistry);
|
|
@@ -141,11 +149,46 @@ describe("composeTierResolverWithGlobalToggles", () => {
|
|
|
141
149
|
expect([...composed("t1" as TenantId)]).toEqual(["auth-self-registration"]);
|
|
142
150
|
});
|
|
143
151
|
|
|
152
|
+
test("kumiko-framework#1479/2: an empty SYSTEM_TENANT_ID union throws instead of silently disabling all tier-gating", () => {
|
|
153
|
+
// A tierResolver that doesn't implement the SYSTEM_TENANT_ID convention
|
|
154
|
+
// at all (e.g. always returns per-tenant grants, never the union) —
|
|
155
|
+
// the old code would treat this as "zero tier-managed features", so
|
|
156
|
+
// EVERY toggleable feature falls through to the tier-unaware cascade
|
|
157
|
+
// and tier-gating is silently defeated.
|
|
158
|
+
const brokenTierResolver: EffectiveFeaturesResolver = (_tenantId: TenantId) =>
|
|
159
|
+
new Set<string>();
|
|
160
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
|
|
161
|
+
const composed = composeTierResolverWithGlobalToggles(
|
|
162
|
+
brokenTierResolver,
|
|
163
|
+
runtime,
|
|
164
|
+
testRegistry,
|
|
165
|
+
);
|
|
166
|
+
expect(() => composed("t1" as TenantId)).toThrow(/SYSTEM_TENANT_ID/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("kumiko-framework#1479/2: tierManaged is computed lazily — a resolver not ready at compose-time is fine as long as it's ready by first call", () => {
|
|
170
|
+
let ready = false;
|
|
171
|
+
const lateBoundResolver: EffectiveFeaturesResolver = (tenantId: TenantId) => {
|
|
172
|
+
if (!ready) throw new Error("tierResolver not wired yet");
|
|
173
|
+
return tenantId === SYSTEM_TENANT_ID
|
|
174
|
+
? new Set(["personal-access-tokens"])
|
|
175
|
+
: new Set<string>();
|
|
176
|
+
};
|
|
177
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
|
|
178
|
+
// Composing must NOT eagerly call tierResolver(SYSTEM_TENANT_ID) — the
|
|
179
|
+
// late-bound holder pattern (run-dev-app.ts) builds the real resolver
|
|
180
|
+
// only after composeTierResolverWithGlobalToggles has already run.
|
|
181
|
+
const composed = composeTierResolverWithGlobalToggles(lateBoundResolver, runtime, testRegistry);
|
|
182
|
+
ready = true;
|
|
183
|
+
expect(() => composed("t1" as TenantId)).not.toThrow();
|
|
184
|
+
});
|
|
185
|
+
|
|
144
186
|
test("preserves the tier resolver's trialGate", async () => {
|
|
145
187
|
const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
|
|
146
|
-
const tierResolver = tierResolverGranting();
|
|
147
188
|
const trialGate = async (_tenantId: TenantId, _featureName: string) => true;
|
|
148
|
-
Object.assign(
|
|
189
|
+
const tierResolver: EffectiveFeaturesResolver = Object.assign(tierResolverGranting(), {
|
|
190
|
+
trialGate,
|
|
191
|
+
});
|
|
149
192
|
|
|
150
193
|
const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, testRegistry);
|
|
151
194
|
expect(composed.trialGate).toBe(trialGate);
|
|
@@ -177,10 +220,10 @@ describe("composeTierResolverWithGlobalToggles — non-toggleable + requires() i
|
|
|
177
220
|
// otherwise composeTierResolverWithGlobalToggles' `!tierManaged.has()`
|
|
178
221
|
// filter would incorrectly drop it (tier-managed but absent from this
|
|
179
222
|
// tenant's own tier subset).
|
|
180
|
-
const tierResolver = (
|
|
223
|
+
const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
|
|
181
224
|
tenantId === SYSTEM_TENANT_ID
|
|
182
225
|
? new Set(["tenant", "personal-access-tokens"])
|
|
183
|
-
: new Set(["tenant"])
|
|
226
|
+
: new Set(["tenant"]);
|
|
184
227
|
const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
|
|
185
228
|
const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
|
|
186
229
|
expect([...composed("t1" as TenantId)]).toContain("tenant");
|
|
@@ -190,12 +233,72 @@ describe("composeTierResolverWithGlobalToggles — non-toggleable + requires() i
|
|
|
190
233
|
// "pat-companion" is tier-unaware (no tier ever lists it) but requires
|
|
191
234
|
// "personal-access-tokens", which IS tier-managed and this tenant's own
|
|
192
235
|
// tier grants nothing — the dependency must not silently resolve true.
|
|
193
|
-
const tierResolver = (
|
|
194
|
-
tenantId === SYSTEM_TENANT_ID
|
|
195
|
-
? new Set(["personal-access-tokens"])
|
|
196
|
-
: new Set<string>()) as EffectiveFeaturesResolver;
|
|
236
|
+
const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
|
|
237
|
+
tenantId === SYSTEM_TENANT_ID ? new Set(["personal-access-tokens"]) : new Set<string>();
|
|
197
238
|
const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
|
|
198
239
|
const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
|
|
199
240
|
expect([...composed("t1" as TenantId)]).not.toContain("pat-companion");
|
|
200
241
|
});
|
|
242
|
+
|
|
243
|
+
test("kumiko-framework#1471/1: a tier-granted feature whose requires() dependency is globally disabled is excluded too", () => {
|
|
244
|
+
// "pat-companion" IS in this tenant's tier grant this time (unlike the
|
|
245
|
+
// test above) — the tenant's tier says yes, but its requires()
|
|
246
|
+
// dependency "personal-access-tokens" is killed globally via override.
|
|
247
|
+
// The old code only checked `readOverride(name) !== false` on
|
|
248
|
+
// "pat-companion" itself (which has no override, so it stayed) without
|
|
249
|
+
// ever re-running the requires() cascade over the tier-granted set —
|
|
250
|
+
// exactly the class of bug #1471/1 describes (channel-email staying up
|
|
251
|
+
// after its `delivery` dependency is killed).
|
|
252
|
+
const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
|
|
253
|
+
tenantId === SYSTEM_TENANT_ID
|
|
254
|
+
? new Set(["personal-access-tokens", "pat-companion"])
|
|
255
|
+
: new Set(["personal-access-tokens", "pat-companion"]);
|
|
256
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
|
|
257
|
+
runtime.apply("personal-access-tokens", false);
|
|
258
|
+
const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
|
|
259
|
+
const result = [...composed("t1" as TenantId)];
|
|
260
|
+
expect(result).not.toContain("personal-access-tokens");
|
|
261
|
+
expect(result).not.toContain("pat-companion");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("kumiko-framework#1471/2: a global override cannot disable a non-toggleable (always-on) feature", () => {
|
|
265
|
+
// "tenant" has no r.toggleable() call at all — computeEffectiveFeatures'
|
|
266
|
+
// Rule 1 says non-toggleable features ignore overrides entirely. The old
|
|
267
|
+
// code applied `readOverride(name) !== false` to every name in tierSet
|
|
268
|
+
// with no toggleability check, so a stray override row (seed script,
|
|
269
|
+
// ops SQL, or a feature that used to be toggleable and had `r.toggleable()`
|
|
270
|
+
// removed) could disable an always-on feature outright.
|
|
271
|
+
const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
|
|
272
|
+
tenantId === SYSTEM_TENANT_ID ? new Set(["tenant"]) : new Set(["tenant"]);
|
|
273
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
|
|
274
|
+
runtime.apply("tenant", false);
|
|
275
|
+
const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
|
|
276
|
+
expect([...composed("t1" as TenantId)]).toContain("tenant");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("framework#1528: a resolver satisfying the emptiness guard but not merging always-on per-tenant still surfaces the always-on feature", () => {
|
|
280
|
+
// Custom composeApp resolver: implements the SYSTEM_TENANT_ID convention
|
|
281
|
+
// (union includes "tenant") but, unlike tier-engine's own mergeAlwaysOn,
|
|
282
|
+
// returns only the tier-only set for real tenants. Before the Rule-1
|
|
283
|
+
// structural fix, "tenant" landed in the raw (unfiltered) tierManaged set
|
|
284
|
+
// and got skipped by the globalCascade loop too, since it's absent from
|
|
285
|
+
// this tenant's own tierSet — a silent 403 on every always-on handler.
|
|
286
|
+
const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
|
|
287
|
+
tenantId === SYSTEM_TENANT_ID
|
|
288
|
+
? new Set(["tenant", "personal-access-tokens"])
|
|
289
|
+
: new Set<string>();
|
|
290
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
|
|
291
|
+
const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
|
|
292
|
+
expect([...composed("t1" as TenantId)]).toContain("tenant");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("a tier-granted name with no registry entry is dropped by the requires() cascade fixpoint", () => {
|
|
296
|
+
const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
|
|
297
|
+
tenantId === SYSTEM_TENANT_ID
|
|
298
|
+
? new Set(["tenant", "ghost-feature"])
|
|
299
|
+
: new Set(["tenant", "ghost-feature"]);
|
|
300
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
|
|
301
|
+
const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
|
|
302
|
+
expect([...composed("t1" as TenantId)]).not.toContain("ghost-feature");
|
|
303
|
+
});
|
|
201
304
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
computeEffectiveFeatures,
|
|
3
2
|
type EffectiveFeaturesResolver,
|
|
3
|
+
isToggleableFeature,
|
|
4
4
|
type Registry,
|
|
5
5
|
SYSTEM_TENANT_ID,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -39,24 +39,93 @@ import type { GlobalFeatureToggleRuntime } from "./toggle-runtime";
|
|
|
39
39
|
export function composeTierResolverWithGlobalToggles(
|
|
40
40
|
tierResolver: EffectiveFeaturesResolver,
|
|
41
41
|
toggleRuntime: GlobalFeatureToggleRuntime,
|
|
42
|
+
// registry needed for toggleability lookups; toggleRuntime keeps its own registry reference private.
|
|
42
43
|
registry: Registry,
|
|
43
44
|
): EffectiveFeaturesResolver {
|
|
44
|
-
|
|
45
|
+
// Lazy + memoized: computed on first composed(tenantId) call, not at
|
|
46
|
+
// compose-time. Late-bound holder wiring (e.g. run-dev-app.ts's tier
|
|
47
|
+
// resolver plugin) may not have its real tierResolver built yet when
|
|
48
|
+
// composeTierResolverWithGlobalToggles itself runs.
|
|
49
|
+
let tierManaged: ReadonlySet<string> | undefined;
|
|
50
|
+
|
|
51
|
+
const composed: EffectiveFeaturesResolver = (tenantId) => {
|
|
52
|
+
if (tierManaged === undefined) {
|
|
53
|
+
const managed = tierResolver(SYSTEM_TENANT_ID);
|
|
54
|
+
// fail-loud: an empty union means the resolver doesn't implement the SYSTEM_TENANT_ID convention at all.
|
|
55
|
+
if (managed.size === 0) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
"composeTierResolverWithGlobalToggles: tierResolver(SYSTEM_TENANT_ID) " +
|
|
58
|
+
"returned an empty set. tier-engine's SYSTEM_TENANT_ID convention " +
|
|
59
|
+
"(union of every tier's features, plus always-on features) must " +
|
|
60
|
+
"never be empty — an empty result means the tierResolver passed in " +
|
|
61
|
+
"doesn't implement that convention, which would silently disable " +
|
|
62
|
+
"all tier-gating (every feature would be treated as tier-unaware).",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
// Structural Rule 1: only the TOGGLEABLE subset of the union is tier-managed.
|
|
66
|
+
// Non-toggleable (always-on) names must keep reaching `result` through the
|
|
67
|
+
// globalCascade loop below even if this resolver's SYSTEM_TENANT_ID answer
|
|
68
|
+
// satisfies the emptiness guard above without also merging always-on into
|
|
69
|
+
// every per-tenant tierSet (framework#1528).
|
|
70
|
+
tierManaged = new Set(
|
|
71
|
+
[...managed].filter((name) => {
|
|
72
|
+
const feature = registry.getFeature(name);
|
|
73
|
+
return feature !== undefined && isToggleableFeature(feature);
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
45
77
|
|
|
46
|
-
const composed = ((tenantId) => {
|
|
47
78
|
const tierSet = tierResolver(tenantId);
|
|
48
79
|
const result = new Set<string>();
|
|
49
80
|
for (const name of tierSet) {
|
|
50
|
-
|
|
81
|
+
// Only a toggleable feature's override can narrow the tier's grant —
|
|
82
|
+
// computeEffectiveFeatures' Rule 1 ignores overrides for features
|
|
83
|
+
// without a toggleableDefault, and an override row can otherwise
|
|
84
|
+
// reach a non-toggleable feature via seed-scripts/ops-SQL/an
|
|
85
|
+
// r.toggleable() removal after the fact (readOverride/refresh() have
|
|
86
|
+
// no toggleability gate of their own).
|
|
87
|
+
const feature = registry.getFeature(name);
|
|
88
|
+
const isToggleable = feature !== undefined && isToggleableFeature(feature);
|
|
89
|
+
if (!isToggleable || toggleRuntime.readOverride(name) !== false) {
|
|
90
|
+
result.add(name);
|
|
91
|
+
}
|
|
51
92
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
93
|
+
// toggleRuntime.effectiveFeatures() already applies computeEffectiveFeatures'
|
|
94
|
+
// full rule set (toggleableDefault ?? override, THEN the requires() cascade)
|
|
95
|
+
// — reusing it here instead of calling computeEffectiveFeatures directly
|
|
96
|
+
// keeps this in lockstep with the runtime's own dispatcher-facing callback.
|
|
97
|
+
const globalCascade = toggleRuntime.effectiveFeatures();
|
|
55
98
|
for (const name of globalCascade) {
|
|
56
99
|
if (!tierManaged.has(name)) result.add(name);
|
|
57
100
|
}
|
|
101
|
+
|
|
102
|
+
// requires() cascade over the combined result: a feature narrowed off by
|
|
103
|
+
// either loop above (tier-managed override, or absent from both sets)
|
|
104
|
+
// must also drop anything that itself requires it — computeEffectiveFeatures
|
|
105
|
+
// enforces this for its own single reader, but tierSet's membership isn't
|
|
106
|
+
// reader-driven, so the cascade has to be re-applied over the union.
|
|
107
|
+
let changed = true;
|
|
108
|
+
while (changed) {
|
|
109
|
+
changed = false;
|
|
110
|
+
for (const name of [...result]) {
|
|
111
|
+
const feature = registry.getFeature(name);
|
|
112
|
+
if (!feature) {
|
|
113
|
+
result.delete(name);
|
|
114
|
+
changed = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const dep of feature.requires) {
|
|
118
|
+
if (!result.has(dep)) {
|
|
119
|
+
result.delete(name);
|
|
120
|
+
changed = true;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
58
127
|
return result;
|
|
59
|
-
}
|
|
128
|
+
};
|
|
60
129
|
|
|
61
130
|
if (tierResolver.trialGate) {
|
|
62
131
|
return Object.assign(composed, { trialGate: tierResolver.trialGate });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { asEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
2
|
import {
|
|
2
3
|
boolean,
|
|
3
|
-
defineUnmanagedTable,
|
|
4
4
|
type EntityTableMeta,
|
|
5
5
|
instant,
|
|
6
6
|
integer,
|
|
@@ -31,14 +31,17 @@ export const globalFeatureStateTable = pgTable("store_global_feature_state", {
|
|
|
31
31
|
|
|
32
32
|
// r.storeTable meta — without this, collectTableMetas(FEATURES) never
|
|
33
33
|
// sees the table, so `kumiko schema generate` reports no changes and no
|
|
34
|
-
// app ever gets a migration for it (framework gap, not app-local).
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
// app ever gets a migration for it (framework gap, not app-local). Derived
|
|
35
|
+
// from the pgTable above instead of hand-written a second time — the two
|
|
36
|
+
// used to drift independently (framework#1529): a column added only to
|
|
37
|
+
// the pgTable made queries type-check while the migration generator stayed
|
|
38
|
+
// blind to it, failing in prod with "column ... does not exist" while every
|
|
39
|
+
// mocked-stack test stayed green.
|
|
40
|
+
const derivedMeta = asEntityTableMeta(globalFeatureStateTable);
|
|
41
|
+
if (!derivedMeta) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
"global-feature-state-table: asEntityTableMeta(globalFeatureStateTable) returned undefined " +
|
|
44
|
+
"— the pgTable definition no longer round-trips through the unmanaged-table meta builder.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
export const globalFeatureStateTableMeta: EntityTableMeta = derivedMeta;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
assertSafeStorageKey,
|
|
3
|
+
type FileStorageProvider,
|
|
4
|
+
type SignedUrlOptions,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
2
6
|
|
|
3
7
|
// =============================================================================
|
|
4
8
|
// Operator-Pflicht-Setup (Multipart-Upload-Cleanup)
|
|
@@ -72,10 +76,12 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
72
76
|
|
|
73
77
|
return {
|
|
74
78
|
async write(key, data, mimeType): Promise<void> {
|
|
79
|
+
assertSafeStorageKey(key);
|
|
75
80
|
await client.write(key, data, mimeType !== undefined ? { type: mimeType } : undefined);
|
|
76
81
|
},
|
|
77
82
|
|
|
78
83
|
async writeStream(key, source, options): Promise<void> {
|
|
84
|
+
assertSafeStorageKey(key);
|
|
79
85
|
// Echtes multipart-streaming via Bun's S3-Writer — partSize steuert die
|
|
80
86
|
// Part-Boundary intern (AWS/R2 verlangen non-final Parts >= 5 MiB,
|
|
81
87
|
// sonst EntityTooSmall beim CompleteMultipartUpload). Manuelles flush()
|
|
@@ -96,10 +102,12 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
96
102
|
},
|
|
97
103
|
|
|
98
104
|
async read(key): Promise<Uint8Array> {
|
|
105
|
+
assertSafeStorageKey(key);
|
|
99
106
|
return new Uint8Array(await client.file(key).arrayBuffer());
|
|
100
107
|
},
|
|
101
108
|
|
|
102
109
|
readStream(key): AsyncIterable<Uint8Array> {
|
|
110
|
+
assertSafeStorageKey(key);
|
|
103
111
|
// Lazy: erst beim ersten chunk-pull wird der GET-Request abgesetzt.
|
|
104
112
|
// Existiert der Key nicht, faellt der Error genau dort (nicht beim
|
|
105
113
|
// readStream-Aufruf) — gleiches Lazy-Verhalten wie inmemory + local.
|
|
@@ -113,10 +121,12 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
113
121
|
},
|
|
114
122
|
|
|
115
123
|
async delete(key): Promise<void> {
|
|
124
|
+
assertSafeStorageKey(key);
|
|
116
125
|
await client.delete(key);
|
|
117
126
|
},
|
|
118
127
|
|
|
119
128
|
async exists(key): Promise<boolean> {
|
|
129
|
+
assertSafeStorageKey(key);
|
|
120
130
|
return client.exists(key);
|
|
121
131
|
},
|
|
122
132
|
|
|
@@ -125,6 +135,7 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
125
135
|
expiresInSeconds: number,
|
|
126
136
|
options?: SignedUrlOptions,
|
|
127
137
|
): Promise<string> {
|
|
138
|
+
assertSafeStorageKey(key);
|
|
128
139
|
// contentDisposition wird von Bun als response-content-disposition
|
|
129
140
|
// Query-Param signiert (Response-Override fuer den GET-Download) —
|
|
130
141
|
// der Browser sieht den Original-Dateinamen statt des UUID-Keys.
|
|
@@ -22,12 +22,14 @@ import {
|
|
|
22
22
|
PII_ERASED_SENTINEL,
|
|
23
23
|
resetPiiSubjectKmsForTests,
|
|
24
24
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
25
|
-
import type
|
|
25
|
+
import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
27
|
import {
|
|
27
28
|
append,
|
|
28
29
|
createEventsTable,
|
|
29
30
|
getStreamVersion,
|
|
30
31
|
loadAggregate,
|
|
32
|
+
VersionConflictError,
|
|
31
33
|
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
32
34
|
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
33
35
|
import {
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
testTenantId,
|
|
38
40
|
unsafeCreateEntityTable,
|
|
39
41
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
42
|
+
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
|
|
40
43
|
import {
|
|
41
44
|
createComplianceProfilesFeature,
|
|
42
45
|
tenantComplianceProfileEntity,
|
|
@@ -54,6 +57,7 @@ import {
|
|
|
54
57
|
type MailThreadEventPayload,
|
|
55
58
|
} from "../events";
|
|
56
59
|
import { inboundMailFoundationFeature } from "../feature";
|
|
60
|
+
import { ingestMessageHandler } from "../handlers/ingest-message.write";
|
|
57
61
|
import {
|
|
58
62
|
inboundMessagesProjectionTable,
|
|
59
63
|
mailAccountsProjectionTable,
|
|
@@ -370,6 +374,79 @@ describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #
|
|
|
370
374
|
});
|
|
371
375
|
});
|
|
372
376
|
|
|
377
|
+
describe("scenario 2d: ingest-message — thread-rollup retry exhaustion (#1229)", () => {
|
|
378
|
+
// The version-conflict retry loop (THREAD_ROLLUP_MAX_ATTEMPTS = 5) is
|
|
379
|
+
// exercised by scenario 2c above only on its happy path (some attempt
|
|
380
|
+
// within the bound eventually wins). New behavior added alongside it —
|
|
381
|
+
// exhausting all 5 attempts — is untested: does it actually surface as an
|
|
382
|
+
// InternalError instead of silently persisting a stale messageCount?
|
|
383
|
+
// Forces exhaustion deterministically by calling the handler directly
|
|
384
|
+
// with a ctx whose tryAppendEvent always loses the version-conflict race
|
|
385
|
+
// for the thread aggregate specifically (the message-append itself still
|
|
386
|
+
// goes through a real, conflict-free append — there's no concurrent
|
|
387
|
+
// writer for a brand-new message aggregateId).
|
|
388
|
+
//
|
|
389
|
+
// Honest scope: this drives the handler directly (no dispatcher, no
|
|
390
|
+
// enclosing TX), same as reindex-entity-job's direct-call tests — it
|
|
391
|
+
// does NOT exercise the dispatcher's own TX-rollback-on-throw behavior,
|
|
392
|
+
// only that the handler itself throws instead of returning success after
|
|
393
|
+
// exhausting the attempts.
|
|
394
|
+
test("5 lost version-conflict races → InternalError, not a silent success", async () => {
|
|
395
|
+
const admin = adminFor(4023);
|
|
396
|
+
const accountId = await connectSharedAccount(admin);
|
|
397
|
+
const messageIdHeader = "exhaustion-root@example.com";
|
|
398
|
+
|
|
399
|
+
const tenantDb = createTenantDb(db, admin.tenantId, "system");
|
|
400
|
+
let threadAppendAttempts = 0;
|
|
401
|
+
const ctx = {
|
|
402
|
+
db: tenantDb,
|
|
403
|
+
registry: stack.registry,
|
|
404
|
+
...bridgeStub({ user: admin }),
|
|
405
|
+
loadAggregate: (aggregateId: string) => loadAggregate(db, aggregateId, admin.tenantId),
|
|
406
|
+
tryAppendEvent: async (args: {
|
|
407
|
+
aggregateId: string;
|
|
408
|
+
aggregateType: string;
|
|
409
|
+
type: string;
|
|
410
|
+
payload: unknown;
|
|
411
|
+
headers?: unknown;
|
|
412
|
+
}) => {
|
|
413
|
+
if (args.aggregateType === MAIL_THREAD_AGGREGATE_TYPE) {
|
|
414
|
+
threadAppendAttempts++;
|
|
415
|
+
return { ok: false as const, conflict: new VersionConflictError(args.aggregateId, 0) };
|
|
416
|
+
}
|
|
417
|
+
// Step 4 (message append): fresh aggregateId, no concurrent writer
|
|
418
|
+
// in this test — a real, conflict-free append via expectedVersion 0.
|
|
419
|
+
const event = await append(db, {
|
|
420
|
+
aggregateId: args.aggregateId,
|
|
421
|
+
aggregateType: args.aggregateType,
|
|
422
|
+
tenantId: admin.tenantId,
|
|
423
|
+
expectedVersion: 0,
|
|
424
|
+
type: args.type,
|
|
425
|
+
payload: args.payload as Record<string, unknown>,
|
|
426
|
+
metadata: { userId: String(admin.id) },
|
|
427
|
+
});
|
|
428
|
+
return { ok: true as const, event };
|
|
429
|
+
},
|
|
430
|
+
// @cast-boundary test/framework — hand-built ctx narrower than HandlerContext
|
|
431
|
+
} as unknown as HandlerContext;
|
|
432
|
+
|
|
433
|
+
await expect(
|
|
434
|
+
ingestMessageHandler.handler(
|
|
435
|
+
{
|
|
436
|
+
type: InboundMailFoundationHandlers.ingestMessage,
|
|
437
|
+
user: admin,
|
|
438
|
+
payload: ingestPayload(accountId, {
|
|
439
|
+
providerMessageId: "exhaustion-1",
|
|
440
|
+
messageIdHeader,
|
|
441
|
+
}),
|
|
442
|
+
},
|
|
443
|
+
ctx,
|
|
444
|
+
),
|
|
445
|
+
).rejects.toThrow(/lost the version-conflict race 5 times in a row/);
|
|
446
|
+
expect(threadAppendAttempts).toBe(5);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
373
450
|
describe("scenario 3: Scope-Sichtbarkeit (persönliches Postfach)", () => {
|
|
374
451
|
test("Messages eines user-scoped Accounts sieht nur der Owner (+ TenantAdmin)", async () => {
|
|
375
452
|
const tenantNumber = 4003;
|
|
@@ -20,21 +20,34 @@ export type InboundMailProviderContractFixture = {
|
|
|
20
20
|
export function describeInboundMailProviderContract(
|
|
21
21
|
name: string,
|
|
22
22
|
factory: () => InboundMailProviderContractFixture | Promise<InboundMailProviderContractFixture>,
|
|
23
|
-
opts?: {
|
|
23
|
+
opts?: {
|
|
24
|
+
readonly skip?: boolean;
|
|
25
|
+
// Whether the provider implements `watch` — read from the plugin's
|
|
26
|
+
// module-level export (e.g. `Boolean(imapInboundMailPlugin.watch)`),
|
|
27
|
+
// not by calling `factory()`. Calling the factory eagerly to probe
|
|
28
|
+
// this (#1337) ran the imap caller's fixture construction even when
|
|
29
|
+
// `skip` was true (the exact case the skip exists to avoid), and left
|
|
30
|
+
// an eager Promise unawaited/uncaught on an async factory. Defaults to
|
|
31
|
+
// true (run the watch test) when omitted.
|
|
32
|
+
readonly hasWatch?: boolean;
|
|
33
|
+
// bunfig's global test timeout (15s) is too tight for a live-IMAP
|
|
34
|
+
// round-trip under CI load — imap-live.integration.test.ts's own
|
|
35
|
+
// tests already use 20s for the same greenmail server.
|
|
36
|
+
readonly timeout?: number;
|
|
37
|
+
},
|
|
24
38
|
): void {
|
|
25
|
-
|
|
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);
|
|
39
|
+
const timeout = opts?.timeout;
|
|
31
40
|
|
|
32
41
|
describe(`${name} — InboundMailProviderPlugin contract`, () => {
|
|
33
42
|
const t = opts?.skip ? test.skip : test;
|
|
34
|
-
t(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
t(
|
|
44
|
+
"verify resolves for a valid account",
|
|
45
|
+
async () => {
|
|
46
|
+
const { plugin, ctx, account } = await factory();
|
|
47
|
+
await expect(plugin.verify(ctx, account)).resolves.toBeUndefined();
|
|
48
|
+
},
|
|
49
|
+
timeout,
|
|
50
|
+
);
|
|
38
51
|
|
|
39
52
|
t(
|
|
40
53
|
"fetch: backfill picks up a seeded message, incremental cursor finds nothing new",
|
|
@@ -55,39 +68,44 @@ export function describeInboundMailProviderContract(
|
|
|
55
68
|
});
|
|
56
69
|
expect(second.messages.some((m) => m.subject === subject)).toBe(false);
|
|
57
70
|
},
|
|
71
|
+
timeout,
|
|
58
72
|
);
|
|
59
73
|
|
|
60
|
-
const watchTest =
|
|
61
|
-
watchTest(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
const watchTest = opts?.hasWatch === false ? test.skip : t;
|
|
75
|
+
watchTest(
|
|
76
|
+
"watch: pushes a seeded message via onMessages",
|
|
77
|
+
async () => {
|
|
78
|
+
const { plugin, ctx, account, seed } = await factory();
|
|
79
|
+
if (!plugin.watch) return; // safety net if opts.hasWatch drifted from the plugin
|
|
80
|
+
const subject = `contract-watch-${crypto.randomUUID()}`;
|
|
65
81
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
const pushed = new Promise<void>((resolve, reject) => {
|
|
83
|
+
const timer = setTimeout(
|
|
84
|
+
() => reject(new Error("watch push not received within 5s")),
|
|
85
|
+
5_000,
|
|
86
|
+
);
|
|
87
|
+
void plugin
|
|
88
|
+
.watch?.(ctx, account, {
|
|
89
|
+
onMessages: async (msgs) => {
|
|
90
|
+
if (msgs.some((m) => m.subject === subject)) {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
resolve();
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
onError: (err) => {
|
|
75
96
|
clearTimeout(timer);
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
},
|
|
83
|
-
|
|
84
|
-
.then((stop) => {
|
|
85
|
-
void seed(subject).catch(reject);
|
|
86
|
-
void pushed.finally(() => void stop().catch(() => {}));
|
|
87
|
-
}, reject);
|
|
88
|
-
});
|
|
97
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
.then((stop) => {
|
|
101
|
+
void seed(subject).catch(reject);
|
|
102
|
+
void pushed.finally(() => void stop().catch(() => {}));
|
|
103
|
+
}, reject);
|
|
104
|
+
});
|
|
89
105
|
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
await pushed;
|
|
107
|
+
},
|
|
108
|
+
timeout,
|
|
109
|
+
);
|
|
92
110
|
});
|
|
93
111
|
}
|
|
@@ -295,6 +295,14 @@ describe("watch-supervisor — watch restart + ingest resilience", () => {
|
|
|
295
295
|
});
|
|
296
296
|
|
|
297
297
|
emitWatchError(accountId, new Error("socket dropped"));
|
|
298
|
+
// No assertion here: isWatching(accountId) flips to false as a side
|
|
299
|
+
// effect of emitWatchError itself (inbound-provider-inmemory's
|
|
300
|
+
// feature.ts sets state.watch = null before handlers.onError runs),
|
|
301
|
+
// and the restart re-arms fast enough that the persisted `backoff:`
|
|
302
|
+
// projection state isn't reliably observable between the two either
|
|
303
|
+
// — asserting either one here pins test-helper bookkeeping, not
|
|
304
|
+
// production behavior. The real assertions are below: the watch
|
|
305
|
+
// recovers, and the account stays active with a valid watchState.
|
|
298
306
|
|
|
299
307
|
await waitFor(() => {
|
|
300
308
|
expect(isWatching(accountId)).toBe(true);
|
|
@@ -302,7 +310,7 @@ describe("watch-supervisor — watch restart + ingest resilience", () => {
|
|
|
302
310
|
|
|
303
311
|
const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
304
312
|
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
|
|
305
|
-
expect(String(accounts[0]?.["watchState"])).toMatch(/^watching|backoff
|
|
313
|
+
expect(String(accounts[0]?.["watchState"])).toMatch(/^(watching|backoff:)/);
|
|
306
314
|
} finally {
|
|
307
315
|
await supervisor.stop();
|
|
308
316
|
}
|