@cosmicdrift/kumiko-bundled-features 0.305.0 → 0.307.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 +9 -9
- package/src/__tests__/extension-points-typed.test.ts +135 -0
- package/src/__tests__/use-extension-options.test.ts +278 -0
- package/src/auth-email-password/changes.json +5 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
- package/src/auth-foundation/types.ts +14 -4
- package/src/auth-mfa/__tests__/session-scoped-sse-revoke.integration.test.ts +270 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -1
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
- package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +8 -1
- package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
- package/src/billing-foundation/types.ts +13 -1
- package/src/cap-counter/__tests__/enforce-cap.test.ts +7 -0
- package/src/cap-counter/changes.json +5 -0
- package/src/cap-counter/enforce-cap.ts +8 -1
- package/src/channel-email/feature.ts +2 -1
- package/src/channel-in-app/feature.ts +2 -1
- package/src/channel-push/feature.ts +2 -1
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
- package/src/config/__tests__/require-system-db.test.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +4 -8
- package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
- package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
- package/src/delivery/constants.ts +4 -0
- package/src/delivery/delivery-service.ts +45 -29
- package/src/delivery/feature.ts +2 -1
- package/src/delivery/index.ts +14 -11
- package/src/delivery/types.ts +24 -0
- package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
- package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
- package/src/document-ingest-foundation/changes.json +5 -0
- package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
- package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
- package/src/files/__tests__/files.integration.test.ts +2 -2
- package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
- package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/feature.ts +4 -0
- package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
- package/src/inbound-mail-foundation/types.ts +10 -3
- package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
- package/src/mail-foundation/feature.ts +15 -4
- package/src/mail-foundation/index.ts +1 -0
- package/src/mail-transport-inmemory/feature.ts +5 -4
- package/src/mail-transport-smtp/feature.ts +5 -4
- package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
- package/src/renderer-foundation/changes.json +8 -1
- package/src/renderer-foundation/constants.ts +4 -0
- package/src/renderer-foundation/feature.ts +10 -13
- package/src/renderer-foundation/index.ts +3 -0
- package/src/renderer-foundation/types.ts +23 -1
- package/src/renderer-simple/feature.ts +2 -1
- package/src/sessions/handlers/revoke-all-others.write.ts +1 -0
- package/src/sessions/session-callbacks.ts +1 -0
- package/src/sessions/session-revoked-event.ts +3 -0
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +2 -2
- package/src/tenant-lifecycle/__tests__/stages.test.ts +51 -0
- package/src/tenant-lifecycle/stages.ts +20 -18
- package/src/user/db/queries/__tests__/unsafe-read-retrying.integration.test.ts +54 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +3 -0
- package/src/user-data-rights/lib/default-mailers.ts +2 -1
- package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +6 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +6 -2
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +5 -0
- package/src/user/db/queries/__tests__/unsafe-read-retrying.test.ts +0 -52
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type
|
|
1
|
+
import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { declareEscapeHatch, type TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
4
|
import { resolveProfileForTenant } from "../compliance-profiles";
|
|
5
5
|
import { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id";
|
|
@@ -13,30 +13,33 @@ import { paymentsProjectionTable, subscriptionsProjectionTable } from "./project
|
|
|
13
13
|
// it redacts the projection row directly instead of relying on key erasure.
|
|
14
14
|
const REDACTED_PII_VALUE = "[erased]";
|
|
15
15
|
|
|
16
|
+
// Compliance-profile lookup and event-stream archive both sit outside TenantDb's own methods — need the escape hatch.
|
|
17
|
+
export const SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON =
|
|
18
|
+
"tenant-destroy looks up the compliance profile and archives the subscription event-stream before the projection row is redacted/deleted";
|
|
19
|
+
export const PAYMENT_TENANT_DESTROY_ARCHIVE_REASON =
|
|
20
|
+
"tenant-destroy looks up the compliance profile and archives the payment event-stream before the projection rows are redacted/deleted";
|
|
21
|
+
|
|
16
22
|
/** Tenant-destroy hook for billing PII (#800). HGB retains the accounting
|
|
17
23
|
* row but redacts the two provider-subject PII fields; other profiles
|
|
18
24
|
* hard-delete the row. Either way the subscription stream is archived so a
|
|
19
25
|
* future projection rebuild can't resurrect what was erased. */
|
|
20
|
-
export async function subscriptionTenantDestroyHook(ctx: {
|
|
21
|
-
|
|
22
|
-
readonly tenantId: TenantId;
|
|
23
|
-
}): Promise<void> {
|
|
26
|
+
export async function subscriptionTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
|
|
27
|
+
declareEscapeHatch({ reason: SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON });
|
|
24
28
|
const { profile } = await resolveProfileForTenant({
|
|
25
|
-
db: ctx.db,
|
|
29
|
+
db: ctx.db.unsafeRaw(SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON),
|
|
26
30
|
tenantId: ctx.tenantId,
|
|
27
31
|
});
|
|
28
32
|
const aggregateId = subscriptionAggregateId(ctx.tenantId);
|
|
29
33
|
if (profile.key === "de-hr-dsgvo-hgb") {
|
|
30
|
-
await updateMany(
|
|
31
|
-
ctx.db,
|
|
34
|
+
await ctx.db.updateMany(
|
|
32
35
|
subscriptionsProjectionTable as EntityTableMeta,
|
|
33
36
|
{ providerCustomerId: REDACTED_PII_VALUE, providerSubscriptionId: REDACTED_PII_VALUE },
|
|
34
37
|
{ id: aggregateId },
|
|
35
38
|
);
|
|
36
39
|
} else {
|
|
37
|
-
await
|
|
40
|
+
await ctx.db.deleteMany(subscriptionsProjectionTable as EntityTableMeta, { id: aggregateId });
|
|
38
41
|
}
|
|
39
|
-
await archiveStream(ctx.db, {
|
|
42
|
+
await archiveStream(ctx.db.unsafeRaw(SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON), {
|
|
40
43
|
tenantId: ctx.tenantId,
|
|
41
44
|
aggregateId,
|
|
42
45
|
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
@@ -50,28 +53,25 @@ export async function subscriptionTenantDestroyHook(ctx: {
|
|
|
50
53
|
* rows — the predicate here is `tenantId`, not `id`. The payment-stream
|
|
51
54
|
* itself is still one stream per tenant (paymentAggregateId), so
|
|
52
55
|
* archiveStream targets that single aggregateId as usual. */
|
|
53
|
-
export async function paymentTenantDestroyHook(ctx: {
|
|
54
|
-
|
|
55
|
-
readonly tenantId: TenantId;
|
|
56
|
-
}): Promise<void> {
|
|
56
|
+
export async function paymentTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
|
|
57
|
+
declareEscapeHatch({ reason: PAYMENT_TENANT_DESTROY_ARCHIVE_REASON });
|
|
57
58
|
const { profile } = await resolveProfileForTenant({
|
|
58
|
-
db: ctx.db,
|
|
59
|
+
db: ctx.db.unsafeRaw(PAYMENT_TENANT_DESTROY_ARCHIVE_REASON),
|
|
59
60
|
tenantId: ctx.tenantId,
|
|
60
61
|
});
|
|
61
62
|
const aggregateId = paymentAggregateId(ctx.tenantId);
|
|
62
63
|
if (profile.key === "de-hr-dsgvo-hgb") {
|
|
63
|
-
await updateMany(
|
|
64
|
-
ctx.db,
|
|
64
|
+
await ctx.db.updateMany(
|
|
65
65
|
paymentsProjectionTable as EntityTableMeta,
|
|
66
66
|
{ providerCustomerId: REDACTED_PII_VALUE },
|
|
67
67
|
{ tenantId: ctx.tenantId },
|
|
68
68
|
);
|
|
69
69
|
} else {
|
|
70
|
-
await
|
|
70
|
+
await ctx.db.deleteMany(paymentsProjectionTable as EntityTableMeta, {
|
|
71
71
|
tenantId: ctx.tenantId,
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
|
-
await archiveStream(ctx.db, {
|
|
74
|
+
await archiveStream(ctx.db.unsafeRaw(PAYMENT_TENANT_DESTROY_ARCHIVE_REASON), {
|
|
75
75
|
tenantId: ctx.tenantId,
|
|
76
76
|
aggregateId,
|
|
77
77
|
aggregateType: PAYMENT_AGGREGATE_TYPE,
|
|
@@ -26,7 +26,12 @@
|
|
|
26
26
|
|
|
27
27
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
28
28
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
29
|
-
import
|
|
29
|
+
import {
|
|
30
|
+
type BillingEventKinds,
|
|
31
|
+
SUBSCRIPTION_PROVIDER_EXTENSION,
|
|
32
|
+
type SubscriptionEventType,
|
|
33
|
+
type SubscriptionStatus,
|
|
34
|
+
} from "./constants";
|
|
30
35
|
|
|
31
36
|
// =============================================================================
|
|
32
37
|
// Normalisierter Webhook-Event
|
|
@@ -215,3 +220,10 @@ export type SubscriptionProviderPlugin = {
|
|
|
215
220
|
providerSubscriptionId: string,
|
|
216
221
|
) => Promise<void>;
|
|
217
222
|
};
|
|
223
|
+
|
|
224
|
+
// r.useExtension options-shape, co-located since the framework never imports upward.
|
|
225
|
+
declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
226
|
+
interface KumikoExtensionOptionsMap {
|
|
227
|
+
[SUBSCRIPTION_PROVIDER_EXTENSION]: SubscriptionProviderPlugin;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -114,6 +114,13 @@ describe("enforceCap — burstable profile (mails / tokens)", () => {
|
|
|
114
114
|
await expect(enforceCap(ctx, opts)).rejects.toThrow(CapExceededError);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
test("amount checks the last of the booked units: 1190 + 10 units passes, + 11 throws", async () => {
|
|
118
|
+
const ctx = stubCalendarCtx([{ value: 1190, lastSoftWarnedAt: null }]);
|
|
119
|
+
const passing = await enforceCap(ctx, { ...opts, amount: 10 });
|
|
120
|
+
expect(passing).toEqual({ state: "soft-hit", value: 1199, crossed: true });
|
|
121
|
+
await expect(enforceCap(ctx, { ...opts, amount: 11 })).rejects.toThrow(CapExceededError);
|
|
122
|
+
});
|
|
123
|
+
|
|
117
124
|
test("CapExceededError carries cap-name + limit + currentValue", async () => {
|
|
118
125
|
const ctx = stubCalendarCtx([{ value: 1500, lastSoftWarnedAt: null }]);
|
|
119
126
|
try {
|
|
@@ -93,6 +93,9 @@ export async function enforceCap(
|
|
|
93
93
|
readonly periodStartIso: string;
|
|
94
94
|
readonly limit: number;
|
|
95
95
|
readonly profile: CapToleranceProfileName;
|
|
96
|
+
// Units the caller is about to book in one go (default 1), checked with
|
|
97
|
+
// the same outcome as booking them one by one.
|
|
98
|
+
readonly amount?: number;
|
|
96
99
|
},
|
|
97
100
|
): Promise<EnforceCapResult> {
|
|
98
101
|
if (!ctx.db) {
|
|
@@ -110,7 +113,9 @@ export async function enforceCap(
|
|
|
110
113
|
);
|
|
111
114
|
|
|
112
115
|
const row = rows[0];
|
|
113
|
-
const
|
|
116
|
+
const storedValue = row ? (row["value"] as number) : 0; // @cast-boundary db-row
|
|
117
|
+
// The last of `amount` units sees this value before its own increment.
|
|
118
|
+
const value = storedValue + (options.amount ?? 1) - 1;
|
|
114
119
|
|
|
115
120
|
if (value >= hardThreshold) {
|
|
116
121
|
throw new CapExceededError(options.capName, options.limit, value, tolerance);
|
|
@@ -285,6 +290,7 @@ export async function enforceCapAndMaybeNotify(
|
|
|
285
290
|
readonly limit: number;
|
|
286
291
|
readonly profile: CapToleranceProfileName;
|
|
287
292
|
readonly notify: SoftHitNotifier;
|
|
293
|
+
readonly amount?: number;
|
|
288
294
|
},
|
|
289
295
|
): Promise<EnforceCapResult> {
|
|
290
296
|
const result = await enforceCap(ctx, {
|
|
@@ -292,6 +298,7 @@ export async function enforceCapAndMaybeNotify(
|
|
|
292
298
|
periodStartIso: options.periodStartIso,
|
|
293
299
|
limit: options.limit,
|
|
294
300
|
profile: options.profile,
|
|
301
|
+
...(options.amount !== undefined && { amount: options.amount }),
|
|
295
302
|
});
|
|
296
303
|
|
|
297
304
|
if (result.state === "soft-hit" && result.crossed) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
1
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { createEmailChannel, type EmailChannelOptions } from "./email-channel";
|
|
3
4
|
|
|
@@ -15,7 +16,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
|
|
|
15
16
|
});
|
|
16
17
|
r.requires("delivery");
|
|
17
18
|
|
|
18
|
-
r.useExtension(
|
|
19
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "email", {
|
|
19
20
|
mode: channel.mode,
|
|
20
21
|
resolve: channel.resolve,
|
|
21
22
|
render: channel.render,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
1
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { inboxQuery } from "./handlers/inbox.query";
|
|
3
4
|
import { markAllReadWrite } from "./handlers/mark-all-read.write";
|
|
@@ -23,7 +24,7 @@ export function createChannelInAppFeature(): FeatureDefinition {
|
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
// Register as delivery channel via extension system
|
|
26
|
-
r.useExtension(
|
|
27
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "inApp", {
|
|
27
28
|
mode: inAppChannel.mode,
|
|
28
29
|
resolve: inAppChannel.resolve,
|
|
29
30
|
send: inAppChannel.send,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
1
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { createPushChannel, type PushChannelOptions } from "./push-channel";
|
|
3
4
|
|
|
@@ -15,7 +16,7 @@ export function createChannelPushFeature(options: PushChannelOptions): FeatureDe
|
|
|
15
16
|
});
|
|
16
17
|
r.requires("delivery");
|
|
17
18
|
|
|
18
|
-
r.useExtension(
|
|
19
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
|
|
19
20
|
mode: channel.mode,
|
|
20
21
|
resolve: channel.resolve,
|
|
21
22
|
send: channel.send,
|
|
@@ -19,8 +19,8 @@ import { randomBytes } from "node:crypto";
|
|
|
19
19
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
20
|
import {
|
|
21
21
|
createEventStoreExecutor,
|
|
22
|
+
createSystemDbView,
|
|
22
23
|
createTenantDb,
|
|
23
|
-
createUncheckedSystemDb,
|
|
24
24
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
25
25
|
import {
|
|
26
26
|
access,
|
|
@@ -157,12 +157,23 @@ function racyJobCtx(
|
|
|
157
157
|
};
|
|
158
158
|
return {
|
|
159
159
|
db: createTenantDb(stack.db, SYSTEM_TENANT_ID),
|
|
160
|
-
systemDb:
|
|
160
|
+
systemDb: createSystemDbView(
|
|
161
|
+
createTenantDb(stack.db, SYSTEM_TENANT_ID, "system", undefined, undefined, undefined, {
|
|
162
|
+
unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
161
165
|
registry: stack.registry,
|
|
162
166
|
masterKeyProvider: mutableProvider,
|
|
163
167
|
configEncryption: racyCipher,
|
|
164
168
|
log,
|
|
165
|
-
|
|
169
|
+
// Without a sink, the job's escape-hatch reporter warn-logs through `log` and its
|
|
170
|
+
// report is deduped by the framework's process-wide window (escape-hatch-report.ts),
|
|
171
|
+
// so whether the unconditional per-run scan ack reaches `log.warn` here depends on
|
|
172
|
+
// unrelated sibling tests' order in the same process. A sink (mirrors production,
|
|
173
|
+
// where the audit feature wires one) routes that report away from `log` entirely,
|
|
174
|
+
// isolating this test from that shared window.
|
|
175
|
+
_escapeHatchAuditSink: async () => {},
|
|
176
|
+
} as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log/_escapeHatchAuditSink
|
|
166
177
|
}
|
|
167
178
|
|
|
168
179
|
beforeAll(async () => {
|
|
@@ -12,8 +12,8 @@ import { randomBytes } from "node:crypto";
|
|
|
12
12
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
13
|
import {
|
|
14
14
|
createEventStoreExecutor,
|
|
15
|
+
createSystemDbView,
|
|
15
16
|
createTenantDb,
|
|
16
|
-
createUncheckedSystemDb,
|
|
17
17
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
18
18
|
import {
|
|
19
19
|
access,
|
|
@@ -138,10 +138,18 @@ function capturingLog(captured: CapturedLog): TestJobLog {
|
|
|
138
138
|
|
|
139
139
|
function jobCtx(captured?: CapturedLog): Parameters<typeof reencryptJob>[1] {
|
|
140
140
|
const log = captured ? capturingLog(captured) : noopLog;
|
|
141
|
-
const systemModeDb = createTenantDb(
|
|
141
|
+
const systemModeDb = createTenantDb(
|
|
142
|
+
stack.db,
|
|
143
|
+
SYSTEM_TENANT_ID,
|
|
144
|
+
"system",
|
|
145
|
+
undefined,
|
|
146
|
+
undefined,
|
|
147
|
+
undefined,
|
|
148
|
+
{ unsafeRaw: { reason: "test: job context mirrors systemScope() grant" } },
|
|
149
|
+
);
|
|
142
150
|
return {
|
|
143
151
|
db: systemModeDb,
|
|
144
|
-
systemDb:
|
|
152
|
+
systemDb: createSystemDbView(systemModeDb),
|
|
145
153
|
registry: stack.registry,
|
|
146
154
|
masterKeyProvider: mutableProvider,
|
|
147
155
|
configEncryption: cipher,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Regression guard for kumiko-framework#2072: the KEK-rotation job's
|
|
2
|
-
// systemScope() fail-closed checks —
|
|
2
|
+
// systemScope() fail-closed checks — createSystemDbView().
|
|
3
3
|
// acknowledgeCrossTenant() gating the initial cross-tenant scan,
|
|
4
4
|
// assertRowsTenant() re-verifying each per-tenant slice before it's served
|
|
5
5
|
// to the write loop — must not block legitimate multi-tenant rotation. A
|
|
@@ -14,8 +14,8 @@ import { randomBytes } from "node:crypto";
|
|
|
14
14
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
15
|
import {
|
|
16
16
|
createEventStoreExecutor,
|
|
17
|
+
createSystemDbView,
|
|
17
18
|
createTenantDb,
|
|
18
|
-
createUncheckedSystemDb,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
20
20
|
import {
|
|
21
21
|
access,
|
|
@@ -117,7 +117,11 @@ function capturingLog(captured: CapturedLog): TestJobLog {
|
|
|
117
117
|
function jobCtx(captured: CapturedLog): Parameters<typeof reencryptJob>[1] {
|
|
118
118
|
return {
|
|
119
119
|
db: createTenantDb(stack.db, SYSTEM_TENANT_ID),
|
|
120
|
-
systemDb:
|
|
120
|
+
systemDb: createSystemDbView(
|
|
121
|
+
createTenantDb(stack.db, SYSTEM_TENANT_ID, "system", undefined, undefined, undefined, {
|
|
122
|
+
unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
121
125
|
registry: stack.registry,
|
|
122
126
|
masterKeyProvider: mutableProvider,
|
|
123
127
|
configEncryption: cipher,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import {
|
|
3
|
+
import { createSystemDbView } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
6
|
import { requireSystemDb } from "../feature";
|
|
7
7
|
|
|
8
8
|
// Minimal TenantDb stub — only .tenantId is read by assertTenantMatch /
|
|
9
|
-
//
|
|
9
|
+
// createSystemDbView; the real db-query methods are never called by
|
|
10
10
|
// requireSystemDb itself.
|
|
11
11
|
function fakeTenantDb(tenantId: TenantId): TenantDb {
|
|
12
12
|
return { tenantId, mode: "system" } as unknown as TenantDb;
|
|
@@ -16,7 +16,7 @@ function fakeTenantDb(tenantId: TenantId): TenantDb {
|
|
|
16
16
|
function contextStub(db: TenantDb, withSystemDb: boolean): HandlerContext {
|
|
17
17
|
return {
|
|
18
18
|
db,
|
|
19
|
-
systemDb: withSystemDb ?
|
|
19
|
+
systemDb: withSystemDb ? createSystemDbView(db) : undefined,
|
|
20
20
|
// biome-ignore lint/suspicious/noExplicitAny: rest of HandlerContext isn't touched by requireSystemDb.
|
|
21
21
|
} as any;
|
|
22
22
|
}
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
// UncheckedSystemDb.acknowledgeCrossTenant(). Rows are bucketed by tenant,
|
|
22
22
|
// and assertRowsTenant() re-verifies each row against its bucket's tenant
|
|
23
23
|
// right before the write — both checks are built locally from the exported
|
|
24
|
-
//
|
|
24
|
+
// createSystemDbView().
|
|
25
25
|
|
|
26
26
|
import {
|
|
27
27
|
createEventStoreExecutor,
|
|
28
|
+
createSystemDbView,
|
|
28
29
|
createTenantDb,
|
|
29
|
-
createUncheckedSystemDb,
|
|
30
30
|
type DbConnection,
|
|
31
31
|
type TenantDb,
|
|
32
32
|
type UncheckedSystemDb,
|
|
@@ -135,11 +135,7 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
135
135
|
function sdbFor(tenantId: TenantId): UncheckedSystemDb {
|
|
136
136
|
let existing = sdbCache.get(tenantId);
|
|
137
137
|
if (!existing) {
|
|
138
|
-
existing =
|
|
139
|
-
tdbFor(tenantId),
|
|
140
|
-
undefined,
|
|
141
|
-
reportEscapeHatchFor(tenantId),
|
|
142
|
-
);
|
|
138
|
+
existing = createSystemDbView(tdbFor(tenantId), undefined, reportEscapeHatchFor(tenantId));
|
|
143
139
|
sdbCache.set(tenantId, existing);
|
|
144
140
|
}
|
|
145
141
|
return existing;
|
|
@@ -149,7 +145,7 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
149
145
|
// to acknowledge the cross-tenant scan below, never bound to a real
|
|
150
146
|
// tenant's writes — sharing it with sdbFor(SYSTEM_TENANT_ID) would make a
|
|
151
147
|
// write-path cache entry double as the scan's ack gate.
|
|
152
|
-
const scanDb =
|
|
148
|
+
const scanDb = createSystemDbView(
|
|
153
149
|
createTenantDb(db, SYSTEM_TENANT_ID, "system"),
|
|
154
150
|
undefined,
|
|
155
151
|
reportEscapeHatchFor(SYSTEM_TENANT_ID),
|
|
@@ -1,32 +1,33 @@
|
|
|
1
|
-
//
|
|
2
|
-
// custom-field rows
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// unsafeReadRetrying instead — this test mirrors
|
|
6
|
-
// bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
|
|
7
|
-
// the retry now actually fires for each call site. applyRetentionRemovals
|
|
8
|
-
// (an UPDATE) is out of scope per #1358 — writes stay unretried.
|
|
1
|
+
// field-access.ts, quota.ts, user-data-rights.ts and retention.ts read
|
|
2
|
+
// custom-field rows through unsafeReadRetrying; this proves the retry fires
|
|
3
|
+
// per call site using a real captured driver error. applyRetentionRemovals
|
|
4
|
+
// (an UPDATE) stays out of scope — unretried by design.
|
|
9
5
|
|
|
10
|
-
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { captureClosedConnectionError } from "@cosmicdrift/kumiko-framework/testing";
|
|
11
8
|
import { selectSerializedFieldDefinition } from "../field-access";
|
|
12
9
|
import { countTenantFieldDefinitions } from "../quota";
|
|
13
10
|
import { selectHostRowsWithCustomFields } from "../retention";
|
|
14
11
|
import { selectCustomFieldsHostRows, selectFieldDefinitionsForEntity } from "../user-data-rights";
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
let closedConnectionError: unknown;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
closedConnectionError = await captureClosedConnectionError();
|
|
17
|
+
});
|
|
19
18
|
|
|
20
19
|
type FakeClient = {
|
|
21
20
|
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
22
21
|
begin: () => never;
|
|
22
|
+
options: { max: number };
|
|
23
23
|
calls: number;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
function fakeClient(failures:
|
|
26
|
+
function fakeClient(failures: unknown[], row: Record<string, unknown>): FakeClient {
|
|
27
27
|
const remaining = [...failures];
|
|
28
28
|
const client: FakeClient = {
|
|
29
29
|
calls: 0,
|
|
30
|
+
options: { max: 1 },
|
|
30
31
|
unsafe: async () => {
|
|
31
32
|
client.calls++;
|
|
32
33
|
const err = remaining.shift();
|
|
@@ -40,30 +41,30 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
|
|
|
40
41
|
return client;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
describe("custom-fields db/queries — closed-connection retry
|
|
44
|
+
describe("custom-fields db/queries — closed-connection retry", () => {
|
|
44
45
|
test("selectSerializedFieldDefinition retries once through db.unsafeRaw and returns the row", async () => {
|
|
45
|
-
const raw = fakeClient([closedConnectionError
|
|
46
|
+
const raw = fakeClient([closedConnectionError], { serialized_field: "sf1" });
|
|
46
47
|
const result = await selectSerializedFieldDefinition(raw, "t1", "entity", "field");
|
|
47
48
|
expect(result).toBe("sf1");
|
|
48
49
|
expect(raw.calls).toBe(2);
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
test("countTenantFieldDefinitions retries once through db.unsafeRaw and returns the count", async () => {
|
|
52
|
-
const raw = fakeClient([closedConnectionError
|
|
53
|
+
const raw = fakeClient([closedConnectionError], { n: 3 });
|
|
53
54
|
const result = await countTenantFieldDefinitions(raw, "t1");
|
|
54
55
|
expect(result).toBe(3);
|
|
55
56
|
expect(raw.calls).toBe(2);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
test("selectCustomFieldsHostRows retries once and returns rows", async () => {
|
|
59
|
-
const db = fakeClient([closedConnectionError
|
|
60
|
+
const db = fakeClient([closedConnectionError], { id: "1", custom_fields: {} });
|
|
60
61
|
const rows = await selectCustomFieldsHostRows(db as never, "host_table", "user_id", "u1", "t1");
|
|
61
62
|
expect(rows).toHaveLength(1);
|
|
62
63
|
expect(db.calls).toBe(2);
|
|
63
64
|
});
|
|
64
65
|
|
|
65
66
|
test("selectFieldDefinitionsForEntity retries once and returns rows", async () => {
|
|
66
|
-
const db = fakeClient([closedConnectionError
|
|
67
|
+
const db = fakeClient([closedConnectionError], { field_key: "k1", serialized_field: "sf" });
|
|
67
68
|
const rows = await selectFieldDefinitionsForEntity(db as never, "entity", "t1");
|
|
68
69
|
expect(rows).toHaveLength(1);
|
|
69
70
|
expect(rows[0]?.field_key).toBe("k1");
|
|
@@ -71,7 +72,7 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
|
|
|
71
72
|
});
|
|
72
73
|
|
|
73
74
|
test("selectHostRowsWithCustomFields retries once and returns rows", async () => {
|
|
74
|
-
const db = fakeClient([closedConnectionError
|
|
75
|
+
const db = fakeClient([closedConnectionError], {
|
|
75
76
|
id: "1",
|
|
76
77
|
modified_at: null,
|
|
77
78
|
custom_fields: {},
|
|
@@ -81,13 +82,13 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
|
|
|
81
82
|
expect(db.calls).toBe(2);
|
|
82
83
|
});
|
|
83
84
|
|
|
84
|
-
test("gives up after
|
|
85
|
-
const raw = fakeClient([closedConnectionError
|
|
85
|
+
test("gives up after exhausting pool-bounded retries (max: 1 → 3 total calls)", async () => {
|
|
86
|
+
const raw = fakeClient([closedConnectionError, closedConnectionError, closedConnectionError], {
|
|
86
87
|
serialized_field: "sf1",
|
|
87
88
|
});
|
|
88
|
-
await expect(selectSerializedFieldDefinition(raw, "t1", "entity", "field")).rejects.
|
|
89
|
-
|
|
89
|
+
await expect(selectSerializedFieldDefinition(raw, "t1", "entity", "field")).rejects.toBe(
|
|
90
|
+
closedConnectionError,
|
|
90
91
|
);
|
|
91
|
-
expect(raw.calls).toBe(
|
|
92
|
+
expect(raw.calls).toBe(3);
|
|
92
93
|
});
|
|
93
94
|
});
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
createRegistry,
|
|
5
|
+
defineFeature,
|
|
6
|
+
isOpenToAllGranted,
|
|
7
|
+
validateBoot,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
9
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
10
|
import { createConfigFeature } from "../../config/feature";
|
|
5
11
|
import { createTenantFeature } from "../../tenant/feature";
|
|
6
|
-
import { DELIVERY_LOG_SCREEN_ID } from "../constants";
|
|
12
|
+
import { DELIVERY_CHANNEL_EXTENSION, DELIVERY_LOG_SCREEN_ID } from "../constants";
|
|
13
|
+
import { collectChannels } from "../delivery-service";
|
|
7
14
|
import { createDeliveryFeature } from "../feature";
|
|
8
15
|
|
|
9
16
|
describe("delivery screens + handler access alignment", () => {
|
|
@@ -38,6 +45,17 @@ describe("delivery screens + handler access alignment", () => {
|
|
|
38
45
|
expect(preferencesAccess !== undefined && isOpenToAllGranted(preferencesAccess)).toBe(true);
|
|
39
46
|
});
|
|
40
47
|
|
|
48
|
+
test("collectChannels fails loud on a registration with invalid options", () => {
|
|
49
|
+
const broken = defineFeature("broken-channel", (r) => {
|
|
50
|
+
// @ts-expect-error resolve/send are required — proves the guard rejects a malformed options bag instead of silently dropping the channel
|
|
51
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "bad-entity", { anything: 1 });
|
|
52
|
+
});
|
|
53
|
+
const registry = createRegistry([...features, broken]);
|
|
54
|
+
expect(() => collectChannels(registry)).toThrow(
|
|
55
|
+
`${DELIVERY_CHANNEL_EXTENSION} registration for "bad-entity" has invalid options`,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
41
59
|
test("boot-validates with a narrowed access option", () => {
|
|
42
60
|
expect(() =>
|
|
43
61
|
validateBoot([
|