@cosmicdrift/kumiko-testing 0.388.0 → 0.390.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/README.md +4 -1
- package/package.json +12 -8
- package/src/changes.json +101 -0
- package/src/e2e/app-boot-budget.ts +22 -0
- package/src/e2e/auth-kit.ts +8 -2
- package/src/e2e/define-app-e2e-config.ts +79 -12
- package/src/e2e/index.ts +7 -1
- package/src/e2e/present-identities.ts +90 -0
- package/src/e2e/screenshots.ts +6 -75
- package/src/e2e/seed-route.ts +11 -0
- package/src/e2e/seeded-tenant-fixture.ts +27 -39
- package/src/helpers/closed-connection-error.ts +4 -7
- package/src/helpers/console-calls.ts +9 -0
- package/src/helpers/event-store-writes.ts +160 -0
- package/src/helpers/index.ts +19 -1
- package/src/helpers/mock-module.ts +24 -0
- package/src/scaffold.ts +1 -1
- package/src/stack/db.ts +110 -17
- package/src/stack/index.ts +2 -0
- package/src/stack/push-entity-projection-tables.ts +12 -7
- package/src/stack/test-stack-setup.ts +95 -23
- package/src/stack/test-stack.ts +10 -5
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
// @runtime dev
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { buildServer, createSseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
assertRowLevelSecurityBootState,
|
|
6
|
+
type DbConnection,
|
|
7
|
+
type EntityTableMeta,
|
|
8
|
+
knownTablesOf,
|
|
9
|
+
type RowLevelSecurityMode,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
5
11
|
import { createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
12
|
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine/types";
|
|
7
13
|
import {
|
|
8
14
|
createArchivedStreamsTable,
|
|
9
15
|
createEventsTable,
|
|
10
16
|
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
appHookScopedRunner,
|
|
19
|
+
DEFAULT_ROW_LEVEL_SECURITY_MODE,
|
|
20
|
+
systemScopedRunner,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/internal/row-level-security";
|
|
11
22
|
import {
|
|
12
23
|
createRateLimitResolver,
|
|
13
24
|
unsafePushTables,
|
|
@@ -31,9 +42,11 @@ import {
|
|
|
31
42
|
import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
32
43
|
import { createInMemorySearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
33
44
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
45
|
+
import type { Clock } from "@cosmicdrift/kumiko-types/tz-context";
|
|
34
46
|
import {
|
|
35
47
|
createTestDb,
|
|
36
48
|
ensureTestDbTemplate,
|
|
49
|
+
TEST_APPLICATION_ROLE,
|
|
37
50
|
TEST_DB_TEMPLATE_RUN_ENV,
|
|
38
51
|
testDbTemplatePrefix,
|
|
39
52
|
} from "./db.ts";
|
|
@@ -94,7 +107,7 @@ async function pushStackSchema(
|
|
|
94
107
|
// setupTestStack call is ready for writes without needing a manual
|
|
95
108
|
// createEventsTable().
|
|
96
109
|
await createEventsTable(db);
|
|
97
|
-
// Archive-stream metadata — needed by ctx.
|
|
110
|
+
// Archive-stream metadata — needed by ctx.events.append's archive guard and
|
|
98
111
|
// loadAggregate's default-skip. Idempotent, so production boot running
|
|
99
112
|
// the same call is fine.
|
|
100
113
|
await createArchivedStreamsTable(db);
|
|
@@ -134,6 +147,7 @@ async function resolveTestDbTemplateName(
|
|
|
134
147
|
options: TestStackOptions,
|
|
135
148
|
metas: readonly EntityTableMeta[],
|
|
136
149
|
withFileTables: boolean,
|
|
150
|
+
rowLevelSecurity: RowLevelSecurityMode,
|
|
137
151
|
): Promise<string | undefined> {
|
|
138
152
|
const runId = process.env[TEST_DB_TEMPLATE_RUN_ENV];
|
|
139
153
|
if (runId === undefined || options.persistentDb === true || options.dbName !== undefined) {
|
|
@@ -141,7 +155,14 @@ async function resolveTestDbTemplateName(
|
|
|
141
155
|
}
|
|
142
156
|
const { renderTablesDdl } = await import("@cosmicdrift/kumiko-framework/db");
|
|
143
157
|
const fingerprint = createHash("sha256")
|
|
144
|
-
.update(
|
|
158
|
+
.update(
|
|
159
|
+
JSON.stringify({
|
|
160
|
+
withFileTables,
|
|
161
|
+
rowLevelSecurity,
|
|
162
|
+
role: rowLevelSecurity === "on" ? TEST_APPLICATION_ROLE : "superuser",
|
|
163
|
+
ddl: renderTablesDdl(metas, { rowLevelSecurity }),
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
145
166
|
.digest("hex")
|
|
146
167
|
.slice(0, 16);
|
|
147
168
|
return `${testDbTemplatePrefix(runId)}${fingerprint}`;
|
|
@@ -192,8 +213,17 @@ export async function provisionStackStorage(options: TestStackOptions): Promise<
|
|
|
192
213
|
const withFileTables = options.files !== undefined;
|
|
193
214
|
const buildStackSchema = (db: DbConnection): Promise<void> =>
|
|
194
215
|
pushStackSchema(db, stackTableMetas, withFileTables);
|
|
195
|
-
const
|
|
196
|
-
|
|
216
|
+
const rowLevelSecurity = options.rowLevelSecurity ?? "on";
|
|
217
|
+
const applicationRole = rowLevelSecurity === "on";
|
|
218
|
+
const templateDbName = await resolveTestDbTemplateName(
|
|
219
|
+
options,
|
|
220
|
+
stackTableMetas,
|
|
221
|
+
withFileTables,
|
|
222
|
+
rowLevelSecurity,
|
|
223
|
+
);
|
|
224
|
+
if (templateDbName !== undefined) {
|
|
225
|
+
await ensureTestDbTemplate(templateDbName, buildStackSchema, { applicationRole });
|
|
226
|
+
}
|
|
197
227
|
|
|
198
228
|
// Forward db-name/persistent-flag through to createTestDb. The
|
|
199
229
|
// defaults (undefined dbName, persistent:false) keep the legacy
|
|
@@ -208,6 +238,7 @@ export async function provisionStackStorage(options: TestStackOptions): Promise<
|
|
|
208
238
|
outsideTransactionPoolMax: options.outsideTransactionPoolMax,
|
|
209
239
|
}),
|
|
210
240
|
...(templateDbName !== undefined && { templateDbName }),
|
|
241
|
+
applicationRole,
|
|
211
242
|
}),
|
|
212
243
|
createTestRedis(
|
|
213
244
|
options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix },
|
|
@@ -218,13 +249,16 @@ export async function provisionStackStorage(options: TestStackOptions): Promise<
|
|
|
218
249
|
// DB/Redis — the caller never gets the stack object back to call
|
|
219
250
|
// cleanup() itself (same "leaked ephemeral DB" failure class as the later
|
|
220
251
|
// try/catch in setupTestStack, but for setup that runs before that try starts).
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
252
|
+
try {
|
|
253
|
+
if (templateDbName === undefined) await buildStackSchema(testDb.db);
|
|
254
|
+
await assertRowLevelSecurityBootState(
|
|
255
|
+
testDb.db,
|
|
256
|
+
rowLevelSecurity,
|
|
257
|
+
knownTablesOf(options.features),
|
|
258
|
+
);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
await runCleanupsIgnoringErrors([() => testDb.cleanup(), () => testRedis.cleanup()]);
|
|
261
|
+
throw error;
|
|
228
262
|
}
|
|
229
263
|
return { testDb, testRedis };
|
|
230
264
|
}
|
|
@@ -248,14 +282,26 @@ export function createStackCacheSync(
|
|
|
248
282
|
// A static `files.storageProvider` is wired as the per-tenant resolver — the
|
|
249
283
|
// framework test seam that doesn't require mounting config + file-foundation.
|
|
250
284
|
// (Bundled GDPR tests mount the real provider features instead.)
|
|
285
|
+
function isClock(value: unknown): value is Clock {
|
|
286
|
+
return (
|
|
287
|
+
typeof value === "object" && value !== null && "now" in value && typeof value.now === "function"
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
251
291
|
async function resolveStackFileProvider(
|
|
252
292
|
options: TestStackOptions,
|
|
253
293
|
testDb: StackStorage["testDb"],
|
|
294
|
+
readClock: () => unknown,
|
|
254
295
|
): Promise<import("@cosmicdrift/kumiko-framework/files").FileProviderResolver | undefined> {
|
|
255
296
|
if (!options.files) return undefined;
|
|
256
297
|
const provider = options.files.storageProvider;
|
|
257
298
|
const { withUploadLedger } = await import("@cosmicdrift/kumiko-framework/files");
|
|
258
|
-
|
|
299
|
+
// Read per call: the clock arrives via extraContext, which is merged after this resolver is built.
|
|
300
|
+
const now = (): Temporal.Instant => {
|
|
301
|
+
const clock = readClock();
|
|
302
|
+
return isClock(clock) ? clock.now() : Temporal.Now.instant();
|
|
303
|
+
};
|
|
304
|
+
return withUploadLedger(() => Promise.resolve(provider), testDb.db, now);
|
|
259
305
|
}
|
|
260
306
|
|
|
261
307
|
export async function buildStackServices(
|
|
@@ -277,7 +323,12 @@ export async function buildStackServices(
|
|
|
277
323
|
);
|
|
278
324
|
|
|
279
325
|
const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
|
|
280
|
-
const
|
|
326
|
+
const extraContextClock: { current: unknown } = { current: undefined };
|
|
327
|
+
const fileProviderResolver = await resolveStackFileProvider(
|
|
328
|
+
options,
|
|
329
|
+
testDb,
|
|
330
|
+
() => extraContextClock.current,
|
|
331
|
+
);
|
|
281
332
|
const observability = options.observability ?? createNoopProvider();
|
|
282
333
|
|
|
283
334
|
// Same AppContext buildServer() gets below (db/redis/searchAdapter/
|
|
@@ -290,8 +341,18 @@ export async function buildStackServices(
|
|
|
290
341
|
// it there either (only dispatcherOptions.effectiveFeatures, consumed by
|
|
291
342
|
// the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
|
|
292
343
|
// index.ts). Putting it here would reintroduce the exact test-vs-prod
|
|
293
|
-
// drift fixed: a job/handler reading `ctx.effectiveFeatures` would pass in
|
|
344
|
+
// drift fixed: a job/handler reading `ctx.services.effectiveFeatures` would pass in
|
|
294
345
|
// tests and break in prod.
|
|
346
|
+
const extraContext =
|
|
347
|
+
typeof options.extraContext === "function"
|
|
348
|
+
? options.extraContext({
|
|
349
|
+
registry,
|
|
350
|
+
// The raw pool, as in prod: request-time services keep the ambient request scope, so a tenant leak shows in tests.
|
|
351
|
+
db: testDb.db,
|
|
352
|
+
sseBroker,
|
|
353
|
+
redis: testRedis.redis,
|
|
354
|
+
})
|
|
355
|
+
: options.extraContext;
|
|
295
356
|
const appContext = {
|
|
296
357
|
db: testDb.db,
|
|
297
358
|
redis: testRedis.redis,
|
|
@@ -301,10 +362,9 @@ export async function buildStackServices(
|
|
|
301
362
|
...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
|
|
302
363
|
...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
|
|
303
364
|
...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
|
|
304
|
-
...
|
|
305
|
-
? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
|
|
306
|
-
: options.extraContext),
|
|
365
|
+
...extraContext,
|
|
307
366
|
};
|
|
367
|
+
extraContextClock.current = extraContext?.["clock"];
|
|
308
368
|
return { searchAdapter, events, registry, sseBroker, entityCache, observability, appContext };
|
|
309
369
|
}
|
|
310
370
|
|
|
@@ -313,7 +373,7 @@ export type StackServices = Awaited<ReturnType<typeof buildStackServices>>;
|
|
|
313
373
|
type JobFailureTracker = ReturnType<typeof createJobFailureTracker>;
|
|
314
374
|
|
|
315
375
|
// Built before buildServer() so it can be merged into dispatcherOptions
|
|
316
|
-
// (write handlers' `ctx.jobRunner.dispatch(...)` and the afterCommit
|
|
376
|
+
// (write handlers' `ctx.services.jobRunner.dispatch(...)` and the afterCommit
|
|
317
377
|
// event-trigger hook both need it present at dispatcher-construction
|
|
318
378
|
// time, same as the prod entrypoint's buildJobRunnerWithHook). Uses the
|
|
319
379
|
// test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
|
|
@@ -409,7 +469,7 @@ async function configureStackSearch(
|
|
|
409
469
|
|
|
410
470
|
// buildServer only auto-wires an L3 RateLimitResolver from
|
|
411
471
|
// `context.redis` — a degradation test that nulls `redis` via
|
|
412
|
-
// extraContext (to exercise a handler's own ctx.redis-down guard) also
|
|
472
|
+
// extraContext (to exercise a handler's own ctx.services.redis-down guard) also
|
|
413
473
|
// silently strips rate-limiting, even though every handler with a
|
|
414
474
|
// `rateLimit` declaration still requires a resolver at dispatch time.
|
|
415
475
|
// Since e7bf543ad, an IP-keyed bucket key is built unconditionally
|
|
@@ -436,6 +496,12 @@ function resolveStackRateLimitFallback(
|
|
|
436
496
|
});
|
|
437
497
|
}
|
|
438
498
|
|
|
499
|
+
function testCodeDb<T extends object>(options: TestStackOptions, db: T): T {
|
|
500
|
+
return (options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE) === "on"
|
|
501
|
+
? systemScopedRunner(db, "test stack seeds and reads rows across tenants")
|
|
502
|
+
: db;
|
|
503
|
+
}
|
|
504
|
+
|
|
439
505
|
async function resolveStackAnonymousAccess(
|
|
440
506
|
options: TestStackOptions,
|
|
441
507
|
testDb: StackStorage["testDb"],
|
|
@@ -443,12 +509,16 @@ async function resolveStackAnonymousAccess(
|
|
|
443
509
|
services: StackServices,
|
|
444
510
|
): Promise<Pick<ServerBuildOptions, "anonymousAccess">> {
|
|
445
511
|
const { registry, sseBroker } = services;
|
|
512
|
+
const hookDb = appHookScopedRunner(
|
|
513
|
+
testDb.db,
|
|
514
|
+
options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE,
|
|
515
|
+
);
|
|
446
516
|
const baseAnon =
|
|
447
517
|
typeof options.anonymousAccess === "function"
|
|
448
|
-
? options.anonymousAccess({ registry, db:
|
|
518
|
+
? options.anonymousAccess({ registry, db: hookDb, sseBroker, redis: testRedis.redis })
|
|
449
519
|
: options.anonymousAccess;
|
|
450
520
|
const resolvedAnon = options.enrichAnonymousAccess
|
|
451
|
-
? await options.enrichAnonymousAccess(baseAnon, { registry, db:
|
|
521
|
+
? await options.enrichAnonymousAccess(baseAnon, { registry, db: hookDb })
|
|
452
522
|
: baseAnon;
|
|
453
523
|
return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
|
|
454
524
|
}
|
|
@@ -557,7 +627,9 @@ function assembleTestStack(
|
|
|
557
627
|
app: server.app,
|
|
558
628
|
jwt: server.jwt,
|
|
559
629
|
registry: services.registry,
|
|
560
|
-
|
|
630
|
+
// Test code sees every tenant's rows; the services keep the enforced pool.
|
|
631
|
+
db: testCodeDb(options, testDb.db),
|
|
632
|
+
unscopedDb: testDb.db,
|
|
561
633
|
dbPoolMax: testDb.poolMax,
|
|
562
634
|
redis: testRedis,
|
|
563
635
|
cacheSync,
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -37,7 +37,10 @@ export type TestStack = {
|
|
|
37
37
|
app: Hono;
|
|
38
38
|
jwt: JwtHelper;
|
|
39
39
|
registry: Registry;
|
|
40
|
+
/** Sees every tenant's rows (system scope) at rowLevelSecurity "on". */
|
|
40
41
|
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
42
|
+
/** The enforced pool the services use: a statement without a scope sees no tenant row. Same as `db` at "off". */
|
|
43
|
+
unscopedDb: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
41
44
|
/** Resolved connection pool size of `db`. */
|
|
42
45
|
readonly dbPoolMax: number;
|
|
43
46
|
redis: TestRedis;
|
|
@@ -61,7 +64,7 @@ export type TestStack = {
|
|
|
61
64
|
// wires itself (_fileProviderResolver). A dev-server that starts its own
|
|
62
65
|
// lane job-runners beside this stack must hand them THIS, not a
|
|
63
66
|
// `{ db, registry }` literal — that's the drift, and it makes an
|
|
64
|
-
// event-triggered job reaching for ctx.files die where the request path works.
|
|
67
|
+
// event-triggered job reaching for ctx.services.files die where the request path works.
|
|
65
68
|
context: AppContext;
|
|
66
69
|
// Present whenever a system consumer (SSE, Search) or
|
|
67
70
|
// r.multiStreamProjection is wired. Tests drain it via runOnce() (or the
|
|
@@ -73,7 +76,7 @@ export type TestStack = {
|
|
|
73
76
|
lifecycle?: Lifecycle;
|
|
74
77
|
// Only set when `options.jobs` is truthy AND ≥1 `r.job(...)` is
|
|
75
78
|
// registered in the mounted features. Lets integration tests call
|
|
76
|
-
// `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.jobRunner`.
|
|
79
|
+
// `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.services.jobRunner`.
|
|
77
80
|
jobRunner?: JobRunner;
|
|
78
81
|
// Waits for every in-flight job and its follow-up event-consumer /
|
|
79
82
|
// job-trigger cascade to settle — instead of `waitFor` polling on a
|
|
@@ -116,7 +119,7 @@ export type TestStackOptions = {
|
|
|
116
119
|
/** Wire up auth routes (login, tenant-switch). Leave undefined to skip. */
|
|
117
120
|
authConfig?: AuthRoutesConfig;
|
|
118
121
|
/** Register a file storage provider so uploads via POST /api/files work and
|
|
119
|
-
* `ctx.files.ref(key)` is available to hooks/MSPs. Omit to skip — tests
|
|
122
|
+
* `ctx.services.files.ref(key)` is available to hooks/MSPs. Omit to skip — tests
|
|
120
123
|
* without file handling don't need it. */
|
|
121
124
|
files?: { storageProvider: import("@cosmicdrift/kumiko-framework/files").FileStorageProvider };
|
|
122
125
|
/** Observability provider — omit for NoopProvider (no spans/metrics).
|
|
@@ -160,7 +163,7 @@ export type TestStackOptions = {
|
|
|
160
163
|
persistentDb?: boolean;
|
|
161
164
|
/** Cap on the main app pool (default: driver default). Lets tests saturate it deliberately. */
|
|
162
165
|
dbPoolMax?: number;
|
|
163
|
-
/** Size of the dedicated pool behind ctx.
|
|
166
|
+
/** Size of the dedicated pool behind ctx.db.outsideTransaction / ctx.outsideTransaction. */
|
|
164
167
|
outsideTransactionPoolMax?: number;
|
|
165
168
|
/** Forwarded to buildServer — when set, requests without a JWT pass
|
|
166
169
|
* through as anonymous instead of 401. See AnonymousAccessConfig.
|
|
@@ -187,7 +190,7 @@ export type TestStackOptions = {
|
|
|
187
190
|
) => Promise<
|
|
188
191
|
import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"] | undefined
|
|
189
192
|
>;
|
|
190
|
-
/** Opt-in JobRunner wired into ctx.jobRunner and merged into
|
|
193
|
+
/** Opt-in JobRunner wired into ctx.services.jobRunner and merged into
|
|
191
194
|
* dispatcherOptions so event-triggered jobs enqueue on commit — mirrors
|
|
192
195
|
* the prod entrypoint's `buildJobRunnerWithHook`. Unlike prod (which
|
|
193
196
|
* always builds one), this only builds a runner when `registry.getAllJobs()`
|
|
@@ -254,6 +257,8 @@ export type TestStackOptions = {
|
|
|
254
257
|
* entities across features included). Use it with the app's complete feature
|
|
255
258
|
* composition; the default only checks what is safe on a feature subset. */
|
|
256
259
|
validateBoot?: "full";
|
|
260
|
+
/** Postgres tenant isolation, as in prod. "on" (default) runs the stack as the non-bypass role kumiko_app against policies; "off" uses the superuser without policies. */
|
|
261
|
+
rowLevelSecurity?: import("@cosmicdrift/kumiko-framework/db").RowLevelSecurityMode;
|
|
257
262
|
};
|
|
258
263
|
|
|
259
264
|
const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
|