@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
|
@@ -3,7 +3,6 @@ import type {
|
|
|
3
3
|
APIRequestContext,
|
|
4
4
|
BrowserContext,
|
|
5
5
|
Page,
|
|
6
|
-
Request as PlaywrightRequest,
|
|
7
6
|
PlaywrightTestArgs,
|
|
8
7
|
PlaywrightTestOptions,
|
|
9
8
|
PlaywrightWorkerArgs,
|
|
@@ -19,6 +18,11 @@ import {
|
|
|
19
18
|
type SeedTenantOptions,
|
|
20
19
|
withSession,
|
|
21
20
|
} from "../seed-types";
|
|
21
|
+
import {
|
|
22
|
+
appBootBudgetViolation,
|
|
23
|
+
DEFAULT_MAX_APP_BOOTS_PER_TEST,
|
|
24
|
+
observeAppBoots,
|
|
25
|
+
} from "./app-boot-budget";
|
|
22
26
|
import {
|
|
23
27
|
CLIENT_IP_HEADER,
|
|
24
28
|
clearSession,
|
|
@@ -69,6 +73,7 @@ export type E2eSeededTenant = Omit<SeededTenant, "addUser"> & {
|
|
|
69
73
|
export type SeedTenantFixture = (opts?: E2eSeedTenantOptions) => Promise<E2eSeededTenant>;
|
|
70
74
|
|
|
71
75
|
type E2eFixtures = {
|
|
76
|
+
maxAppBootsPerTest: number;
|
|
72
77
|
seedTenant: SeedTenantFixture;
|
|
73
78
|
};
|
|
74
79
|
|
|
@@ -230,52 +235,35 @@ export function perTestClientIpKey(
|
|
|
230
235
|
return `${runToken ?? ""}:${testInfo.testId}:${testInfo.repeatEachIndex}:${testInfo.retry}`;
|
|
231
236
|
}
|
|
232
237
|
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// here). An explicit X-Forwarded-For set by the test wins.
|
|
236
|
-
export function headersWithClientIp(
|
|
237
|
-
request: { readonly url: string; readonly frameUrl: string | undefined },
|
|
238
|
-
headers: Readonly<Record<string, string>>,
|
|
239
|
-
clientIp: string,
|
|
240
|
-
): Record<string, string> | undefined {
|
|
241
|
-
if (request.frameUrl === undefined) return undefined;
|
|
242
|
-
let sameOrigin: boolean;
|
|
243
|
-
try {
|
|
244
|
-
sameOrigin = new URL(request.frameUrl).origin === new URL(request.url).origin;
|
|
245
|
-
} catch {
|
|
246
|
-
return undefined;
|
|
247
|
-
}
|
|
248
|
-
return sameOrigin ? { [CLIENT_IP_HEADER]: clientIp, ...headers } : undefined;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function frameUrlOf(request: PlaywrightRequest): string | undefined {
|
|
252
|
-
try {
|
|
253
|
-
return request.frame().url();
|
|
254
|
-
} catch {
|
|
255
|
-
// Service-worker requests have no frame.
|
|
256
|
-
return undefined;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
238
|
+
// Chromium turns its HTTP cache off for a context as soon as it has a route,
|
|
239
|
+
// so the header goes through setExtraHTTPHeaders and the cache stays on.
|
|
260
240
|
export async function providePerTestClientIpContext(
|
|
261
|
-
{
|
|
241
|
+
{
|
|
242
|
+
context,
|
|
243
|
+
maxAppBootsPerTest,
|
|
244
|
+
}: Pick<PlaywrightTestArgs, "context"> & Pick<E2eFixtures, "maxAppBootsPerTest">,
|
|
262
245
|
use: (context: BrowserContext) => Promise<void>,
|
|
263
|
-
testInfo: Pick<TestInfo, "testId" | "repeatEachIndex" | "retry">,
|
|
246
|
+
testInfo: Pick<TestInfo, "testId" | "repeatEachIndex" | "retry" | "status" | "expectedStatus">,
|
|
264
247
|
): Promise<void> {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const request = route.request();
|
|
268
|
-
const headers = headersWithClientIp(
|
|
269
|
-
{ url: request.url(), frameUrl: frameUrlOf(request) },
|
|
270
|
-
request.headers(),
|
|
271
|
-
clientIp,
|
|
272
|
-
);
|
|
273
|
-
return headers === undefined ? route.fallback() : route.fallback({ headers });
|
|
248
|
+
await context.setExtraHTTPHeaders({
|
|
249
|
+
[CLIENT_IP_HEADER]: syntheticClientIpFor(perTestClientIpKey(testInfo)),
|
|
274
250
|
});
|
|
251
|
+
const appBoots = observeAppBoots(context);
|
|
275
252
|
await use(context);
|
|
253
|
+
|
|
254
|
+
// A window.open tab left open stalls the context close by seconds.
|
|
255
|
+
await Promise.all(context.pages().map((page) => (page.isClosed() ? undefined : page.close())));
|
|
256
|
+
|
|
257
|
+
// Only after a passing body, so the budget never hides the original failure.
|
|
258
|
+
const violation =
|
|
259
|
+
testInfo.status === testInfo.expectedStatus
|
|
260
|
+
? appBootBudgetViolation(appBoots.count(), maxAppBootsPerTest)
|
|
261
|
+
: undefined;
|
|
262
|
+
if (violation !== undefined) throw new Error(violation);
|
|
276
263
|
}
|
|
277
264
|
|
|
278
265
|
export const test = base.extend<E2eFixtures>({
|
|
266
|
+
maxAppBootsPerTest: [DEFAULT_MAX_APP_BOOTS_PER_TEST, { option: true }],
|
|
279
267
|
context: providePerTestClientIpContext,
|
|
280
268
|
seedTenant: provideSeedTenant,
|
|
281
269
|
});
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// the production matcher checks driver-specific codes a fake can't reproduce.
|
|
3
3
|
|
|
4
4
|
import { isClosedConnectionError } from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
5
|
-
import postgres from "postgres";
|
|
6
5
|
import { requireEnv } from "../stack/db.ts";
|
|
7
6
|
|
|
8
7
|
export function testDatabaseUrl(): string {
|
|
@@ -14,8 +13,6 @@ const READS_PER_ROUND = 5;
|
|
|
14
13
|
|
|
15
14
|
type AdminClient = { unsafe(sql: string, params?: readonly unknown[]): Promise<unknown> };
|
|
16
15
|
|
|
17
|
-
// A per-round admin client hits a postgres-js reconnect timing bug (the first
|
|
18
|
-
// retry after terminate hangs); a single long-lived admin client avoids it.
|
|
19
16
|
export async function terminateBackendsByApplicationName(
|
|
20
17
|
admin: AdminClient,
|
|
21
18
|
applicationName: string,
|
|
@@ -31,11 +28,11 @@ export async function terminateBackendsByApplicationName(
|
|
|
31
28
|
export async function captureClosedConnectionError(
|
|
32
29
|
url: string = testDatabaseUrl(),
|
|
33
30
|
): Promise<unknown> {
|
|
34
|
-
const admin =
|
|
31
|
+
const admin = new Bun.SQL(url, { max: 1 });
|
|
35
32
|
try {
|
|
36
33
|
for (let round = 0; round < MAX_WARMUP_ROUNDS; round++) {
|
|
37
34
|
const applicationName = `kumiko-closed-conn-test-${crypto.randomUUID()}`;
|
|
38
|
-
const pool =
|
|
35
|
+
const pool = new Bun.SQL(url, { max: 1, connection: { application_name: applicationName } });
|
|
39
36
|
try {
|
|
40
37
|
await pool.unsafe("select 1");
|
|
41
38
|
await terminateBackendsByApplicationName(admin, applicationName);
|
|
@@ -47,13 +44,13 @@ export async function captureClosedConnectionError(
|
|
|
47
44
|
}
|
|
48
45
|
}
|
|
49
46
|
} finally {
|
|
50
|
-
await pool.
|
|
47
|
+
await pool.close({ timeout: 0 });
|
|
51
48
|
}
|
|
52
49
|
}
|
|
53
50
|
throw new Error(
|
|
54
51
|
`captureClosedConnectionError: no closed-connection error observed after ${MAX_WARMUP_ROUNDS} rounds.`,
|
|
55
52
|
);
|
|
56
53
|
} finally {
|
|
57
|
-
await admin.
|
|
54
|
+
await admin.close({ timeout: 0 });
|
|
58
55
|
}
|
|
59
56
|
}
|
|
@@ -10,3 +10,12 @@ export function consoleCallsMentioning(
|
|
|
10
10
|
): ReadonlyArray<ReadonlyArray<unknown>> {
|
|
11
11
|
return spy.mock.calls.filter((args) => args.some((arg) => String(arg).includes(fragment)));
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
// For "stays silent" assertions: counts every call from the given logger namespace instead of one
|
|
15
|
+
// message text, so a new or reworded warning from the module under test still fails the test.
|
|
16
|
+
export function consoleCallsFromNamespace(
|
|
17
|
+
spy: ConsoleSpyLike,
|
|
18
|
+
namespace: string,
|
|
19
|
+
): ReadonlyArray<ReadonlyArray<unknown>> {
|
|
20
|
+
return spy.mock.calls.filter((args) => String(args[0]).startsWith(`[${namespace}]`));
|
|
21
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Test-side writes on event-sourced tables that go through the event store, so
|
|
2
|
+
// the event log and the projection stay consistent. Unlike seed.ts these survive
|
|
3
|
+
// a projection rebuild and fire the same events a handler would.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createEventStoreExecutor,
|
|
7
|
+
createTenantDb,
|
|
8
|
+
type DbConnection,
|
|
9
|
+
fetchOne,
|
|
10
|
+
selectMany,
|
|
11
|
+
type WhereObject,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import {
|
|
14
|
+
createSystemUser,
|
|
15
|
+
SYSTEM_TENANT_ID,
|
|
16
|
+
type TenantId,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { executorManagedEntitySourceOf } from "@cosmicdrift/kumiko-framework/internal/testing";
|
|
19
|
+
|
|
20
|
+
type ExecutorArgs = Parameters<typeof createEventStoreExecutor>;
|
|
21
|
+
|
|
22
|
+
export type EventStoreWriteTarget = {
|
|
23
|
+
readonly table: ExecutorArgs[0];
|
|
24
|
+
readonly entity: ExecutorArgs[1];
|
|
25
|
+
readonly entityName: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type EventStoreWriteScope = {
|
|
29
|
+
/** Tenant of the acting system user and of the tenant-bound db; defaults to the system tenant. */
|
|
30
|
+
readonly tenantId?: TenantId;
|
|
31
|
+
/** Address a stream that lives on another tenant than the actor's. */
|
|
32
|
+
readonly streamTenantId?: TenantId;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type EntityId = string | number;
|
|
36
|
+
|
|
37
|
+
function isEntityId(value: unknown): value is EntityId {
|
|
38
|
+
return typeof value === "string" || typeof value === "number";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function loadTarget(
|
|
42
|
+
db: DbConnection,
|
|
43
|
+
target: EventStoreWriteTarget,
|
|
44
|
+
where: WhereObject,
|
|
45
|
+
verb: string,
|
|
46
|
+
): Promise<{ id: EntityId; version: number }> {
|
|
47
|
+
const row = await fetchOne<Record<string, unknown>>(db, target.table, where);
|
|
48
|
+
if (!row) {
|
|
49
|
+
throw new Error(`${verb} ${target.entityName}: no row matches ${JSON.stringify(where)}`);
|
|
50
|
+
}
|
|
51
|
+
const { id, version } = row;
|
|
52
|
+
if (!isEntityId(id)) throw new Error(`${verb} ${target.entityName}: row has no id`);
|
|
53
|
+
if (typeof version !== "number") {
|
|
54
|
+
throw new Error(`${verb} ${target.entityName}: row has no numeric version`);
|
|
55
|
+
}
|
|
56
|
+
return { id, version };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeContext(scope: EventStoreWriteScope | undefined, db: DbConnection) {
|
|
60
|
+
const tenantId = scope?.tenantId ?? SYSTEM_TENANT_ID;
|
|
61
|
+
return {
|
|
62
|
+
user: createSystemUser(tenantId),
|
|
63
|
+
tenantDb: createTenantDb({
|
|
64
|
+
db,
|
|
65
|
+
tenantId,
|
|
66
|
+
mode: "system",
|
|
67
|
+
reason: "test helper seeds event-store rows for any tenant",
|
|
68
|
+
}),
|
|
69
|
+
options: scope?.streamTenantId ? { streamTenantId: scope.streamTenantId } : undefined,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function failedWrite(
|
|
74
|
+
verb: string,
|
|
75
|
+
entityName: string,
|
|
76
|
+
error: { readonly code: string; readonly details?: unknown },
|
|
77
|
+
): Error {
|
|
78
|
+
return new Error(
|
|
79
|
+
`${verb} ${entityName} failed: ${error.code} ${JSON.stringify(error.details ?? {})}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Same arguments as seedRow, so a seed on an entity table switches over by rename. The row's
|
|
84
|
+
// tenantId picks the acting tenant (and so the stream tenant); seed global entities without one,
|
|
85
|
+
// the executor rejects a tenantId on them.
|
|
86
|
+
export async function seedRowViaEventStore(
|
|
87
|
+
db: DbConnection,
|
|
88
|
+
table: EventStoreWriteTarget["table"],
|
|
89
|
+
values: Readonly<Record<string, unknown>>,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
const source = executorManagedEntitySourceOf(table);
|
|
92
|
+
if (!source) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"seedRowViaEventStore: table is not an event-sourced entity table (buildEntityTable or r.entity); use seedRow",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const { entity, entityName } = source;
|
|
98
|
+
const { tenantId, ...payload } = values;
|
|
99
|
+
if (payload["id"] !== undefined && typeof payload["id"] !== "string") {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`seed ${entityName}: id must be a string, the event store would mint a new one`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (tenantId !== undefined && typeof tenantId !== "string") {
|
|
105
|
+
throw new Error(`seed ${entityName}: tenantId must be a string`);
|
|
106
|
+
}
|
|
107
|
+
const { user, tenantDb } = writeContext({ tenantId }, db);
|
|
108
|
+
const executor = createEventStoreExecutor(table, entity, { entityName });
|
|
109
|
+
const result = await executor.create(payload, user, tenantDb);
|
|
110
|
+
if (!result.isSuccess) throw failedWrite("seed", entityName, result.error);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function updateRowViaEventStore(
|
|
114
|
+
db: DbConnection,
|
|
115
|
+
target: EventStoreWriteTarget,
|
|
116
|
+
where: WhereObject,
|
|
117
|
+
changes: Readonly<Record<string, unknown>>,
|
|
118
|
+
scope?: EventStoreWriteScope,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const { id, version } = await loadTarget(db, target, where, "update");
|
|
121
|
+
const { user, tenantDb, options } = writeContext(scope, db);
|
|
122
|
+
const executor = createEventStoreExecutor(target.table, target.entity, {
|
|
123
|
+
entityName: target.entityName,
|
|
124
|
+
});
|
|
125
|
+
const result = await executor.update(
|
|
126
|
+
{ id, version, changes: { ...changes } },
|
|
127
|
+
user,
|
|
128
|
+
tenantDb,
|
|
129
|
+
options,
|
|
130
|
+
);
|
|
131
|
+
if (!result.isSuccess) throw failedWrite("update", target.entityName, result.error);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function deleteRowViaEventStore(
|
|
135
|
+
db: DbConnection,
|
|
136
|
+
target: EventStoreWriteTarget,
|
|
137
|
+
where: WhereObject,
|
|
138
|
+
scope?: EventStoreWriteScope,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
const { id } = await loadTarget(db, target, where, "delete");
|
|
141
|
+
const { user, tenantDb, options } = writeContext(scope, db);
|
|
142
|
+
const executor = createEventStoreExecutor(target.table, target.entity, {
|
|
143
|
+
entityName: target.entityName,
|
|
144
|
+
});
|
|
145
|
+
const result = await executor.delete({ id }, user, tenantDb, options);
|
|
146
|
+
if (!result.isSuccess) throw failedWrite("delete", target.entityName, result.error);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Deletes every row matching `where`, each through its own event-store delete. */
|
|
150
|
+
export async function deleteRowsViaEventStore(
|
|
151
|
+
db: DbConnection,
|
|
152
|
+
target: EventStoreWriteTarget,
|
|
153
|
+
where: WhereObject,
|
|
154
|
+
scope?: EventStoreWriteScope,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const rows = await selectMany<{ id: EntityId }>(db, target.table, where);
|
|
157
|
+
for (const { id } of rows) {
|
|
158
|
+
await deleteRowViaEventStore(db, target, { id }, scope);
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/helpers/index.ts
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
// must import nothing from this subpath — the stack builders live in
|
|
3
3
|
// `@cosmicdrift/kumiko-testing/stack`.
|
|
4
4
|
|
|
5
|
+
// Scope setters for tests that call cross-tenant or tenant functions directly, outside dispatch.
|
|
6
|
+
export {
|
|
7
|
+
runInRowLevelSecurityScope,
|
|
8
|
+
runInSystemRowLevelSecurityScope,
|
|
9
|
+
tenantRowLevelSecurityScope,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/internal/row-level-security";
|
|
5
11
|
// The four cache/injection resets stay in their own modules (they close over
|
|
6
12
|
// module-private state) and are only re-exported here — they are out of /crypto
|
|
7
13
|
// and /db. A production call to resetPiiSubjectKmsForTests() silently switches
|
|
@@ -23,7 +29,11 @@ export {
|
|
|
23
29
|
terminateBackendsByApplicationName,
|
|
24
30
|
testDatabaseUrl,
|
|
25
31
|
} from "./closed-connection-error.ts";
|
|
26
|
-
export {
|
|
32
|
+
export {
|
|
33
|
+
type ConsoleSpyLike,
|
|
34
|
+
consoleCallsFromNamespace,
|
|
35
|
+
consoleCallsMentioning,
|
|
36
|
+
} from "./console-calls.ts";
|
|
27
37
|
export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup.ts";
|
|
28
38
|
export {
|
|
29
39
|
type E2EGeneratorOptions,
|
|
@@ -32,6 +42,14 @@ export {
|
|
|
32
42
|
generateE2ESpec,
|
|
33
43
|
generateZodFixture,
|
|
34
44
|
} from "./e2e-generator.ts";
|
|
45
|
+
export {
|
|
46
|
+
deleteRowsViaEventStore,
|
|
47
|
+
deleteRowViaEventStore,
|
|
48
|
+
type EventStoreWriteScope,
|
|
49
|
+
type EventStoreWriteTarget,
|
|
50
|
+
seedRowViaEventStore,
|
|
51
|
+
updateRowViaEventStore,
|
|
52
|
+
} from "./event-store-writes.ts";
|
|
35
53
|
export { expectErrorIncludes } from "./expect-error.ts";
|
|
36
54
|
export { describeFileProviderContract } from "./file-provider-contract.ts";
|
|
37
55
|
export {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { afterAll, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mocks `specifier` with the real exports plus `overrides` and restores the real module in
|
|
5
|
+
* `afterAll`: bun's mock.module is process-global, so a leaked mock breaks later files of a
|
|
6
|
+
* shared DOM run. Pass the namespace imported before mocking; it is copied here because
|
|
7
|
+
* mock.module patches an already imported namespace in place. A relative specifier must be
|
|
8
|
+
* absolute (resolve it against the test file), since mock.module resolves it from this file.
|
|
9
|
+
*
|
|
10
|
+
* `restoreOverrides` fixes up modules that re-export a mocked binding: their copy was taken
|
|
11
|
+
* under the mock and holds the spy, so it must be pointed back at the real export.
|
|
12
|
+
*/
|
|
13
|
+
export function mockModuleForFile(
|
|
14
|
+
specifier: string,
|
|
15
|
+
realModule: object,
|
|
16
|
+
overrides: Readonly<Record<string, unknown>>,
|
|
17
|
+
options: { readonly restoreOverrides?: Readonly<Record<string, unknown>> } = {},
|
|
18
|
+
): void {
|
|
19
|
+
const real = { ...realModule };
|
|
20
|
+
mock.module(specifier, () => ({ ...real, ...overrides }));
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
mock.module(specifier, () => ({ ...real, ...options.restoreOverrides }));
|
|
23
|
+
});
|
|
24
|
+
}
|
package/src/scaffold.ts
CHANGED
package/src/stack/db.ts
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
import { DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import {
|
|
6
6
|
acquireSessionAdvisoryLock,
|
|
7
|
+
createApplicationRole,
|
|
7
8
|
createDatabase,
|
|
8
9
|
createDbConnection,
|
|
9
10
|
databaseExists,
|
|
10
11
|
dropDatabaseIfExists,
|
|
11
12
|
listDatabaseNamesWithPrefix,
|
|
12
13
|
renameDatabase,
|
|
14
|
+
transferDatabaseOwnership,
|
|
15
|
+
transferPublicSchemaOwnership,
|
|
13
16
|
} from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
14
17
|
import { ensureTemporalPolyfill } from "@cosmicdrift/kumiko-framework/time";
|
|
15
18
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
@@ -27,6 +30,9 @@ function requireEnv(name: string): string {
|
|
|
27
30
|
|
|
28
31
|
export const DEFAULT_TEST_DB_POOL_MAX = 10;
|
|
29
32
|
|
|
33
|
+
// Non-superuser without BYPASSRLS: the only login that makes row-level-security policies bite in tests.
|
|
34
|
+
export const TEST_APPLICATION_ROLE = "kumiko_app";
|
|
35
|
+
|
|
30
36
|
export type TestDb = {
|
|
31
37
|
db: DbConnection;
|
|
32
38
|
poolMax: number;
|
|
@@ -44,6 +50,8 @@ export type CreateTestDbOptions = {
|
|
|
44
50
|
readonly outsideTransactionPoolMax?: number;
|
|
45
51
|
/** Clone this already-migrated database instead of starting empty. */
|
|
46
52
|
readonly templateDbName?: string;
|
|
53
|
+
/** Own the database as the non-bypass application role and enforce row-level security on the pools. */
|
|
54
|
+
readonly applicationRole?: boolean;
|
|
47
55
|
};
|
|
48
56
|
|
|
49
57
|
// Set by the integration runner for one run; templates named after it are dropped when the run ends.
|
|
@@ -62,6 +70,67 @@ function databaseUrlOf(url: string, dbName: string): string {
|
|
|
62
70
|
return url.replace(/\/[^/]+$/, `/${dbName}`);
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
/**
|
|
74
|
+
* URL of database `dbName` on the host of `url`, logged in as TEST_APPLICATION_ROLE with the password
|
|
75
|
+
* of `url` (the caller's TEST_DATABASE_URL). Pair it with `createTestDb({ applicationRole: true })` or
|
|
76
|
+
* a database owned by that role, so boots with row level security on connect without BYPASSRLS.
|
|
77
|
+
*/
|
|
78
|
+
// DDL takes no bind params, so the password is read from the configured URL instead of a literal.
|
|
79
|
+
export function applicationRoleUrlOf(url: string, dbName: string): string {
|
|
80
|
+
const parsed = new URL(url);
|
|
81
|
+
parsed.pathname = `/${dbName}`;
|
|
82
|
+
parsed.username = TEST_APPLICATION_ROLE;
|
|
83
|
+
parsed.password = new URL(url).password;
|
|
84
|
+
return parsed.toString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function ensureApplicationRole(admin: DbConnection, url: string): Promise<void> {
|
|
88
|
+
await createApplicationRole(
|
|
89
|
+
admin,
|
|
90
|
+
TEST_APPLICATION_ROLE,
|
|
91
|
+
decodeURIComponent(new URL(url).password),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Admin connections below run as superuser for CREATE/DROP DATABASE and never open a tenant scope: "off" is explicit.
|
|
96
|
+
async function adoptPersistentDatabase(
|
|
97
|
+
admin: DbConnection,
|
|
98
|
+
url: string,
|
|
99
|
+
dbName: string,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
await transferDatabaseOwnership(admin, dbName, TEST_APPLICATION_ROLE);
|
|
102
|
+
const owner = createDbConnection({
|
|
103
|
+
url: databaseUrlOf(url, dbName),
|
|
104
|
+
maxConnections: 1,
|
|
105
|
+
rowLevelSecurity: "off", // superuser ownership transfer, no tenant scope
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
await transferPublicSchemaOwnership(owner.db, TEST_APPLICATION_ROLE);
|
|
109
|
+
} finally {
|
|
110
|
+
await owner.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type ProvisionArgs = {
|
|
115
|
+
readonly url: string;
|
|
116
|
+
readonly dbName: string;
|
|
117
|
+
readonly opts: CreateTestDbOptions;
|
|
118
|
+
readonly applicationRole: boolean;
|
|
119
|
+
readonly owner: string | undefined;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
async function provisionDatabase(
|
|
123
|
+
admin: DbConnection,
|
|
124
|
+
{ url, dbName, opts, applicationRole, owner }: ProvisionArgs,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const existing = opts.persistent === true && (await databaseExists(admin, dbName));
|
|
127
|
+
if (existing && applicationRole) await adoptPersistentDatabase(admin, url, dbName);
|
|
128
|
+
if (!existing) {
|
|
129
|
+
const template = opts.persistent === true ? undefined : opts.templateDbName;
|
|
130
|
+
await createDatabase(admin, dbName, template, owner);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
65
134
|
export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<TestDb> {
|
|
66
135
|
await ensureTemporalPolyfill();
|
|
67
136
|
const opts: CreateTestDbOptions = typeof arg === "string" ? { baseUrl: arg } : (arg ?? {});
|
|
@@ -69,24 +138,23 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
|
|
|
69
138
|
const dbName = opts.dbName ?? `kumiko_test_${generateId().slice(-8)}`;
|
|
70
139
|
const adminUrl = adminUrlOf(url);
|
|
71
140
|
|
|
72
|
-
const
|
|
141
|
+
const applicationRole = opts.applicationRole === true;
|
|
142
|
+
const owner = applicationRole ? TEST_APPLICATION_ROLE : undefined;
|
|
143
|
+
const admin = createDbConnection({ url: adminUrl, maxConnections: 1, rowLevelSecurity: "off" });
|
|
73
144
|
try {
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
await createDatabase(admin.db, dbName);
|
|
77
|
-
}
|
|
78
|
-
} else {
|
|
79
|
-
await createDatabase(admin.db, dbName, opts.templateDbName);
|
|
80
|
-
}
|
|
145
|
+
if (applicationRole) await ensureApplicationRole(admin.db, url);
|
|
146
|
+
await provisionDatabase(admin.db, { url, dbName, opts, applicationRole, owner });
|
|
81
147
|
} finally {
|
|
82
148
|
await admin.close();
|
|
83
149
|
}
|
|
84
150
|
|
|
85
|
-
const testUrl = databaseUrlOf(url, dbName);
|
|
151
|
+
const testUrl = applicationRole ? applicationRoleUrlOf(url, dbName) : databaseUrlOf(url, dbName);
|
|
152
|
+
const rowLevelSecurity = applicationRole ? "on" : "off";
|
|
86
153
|
const poolMax = opts.poolMax ?? DEFAULT_TEST_DB_POOL_MAX;
|
|
87
|
-
const conn = createDbConnection({ url: testUrl, maxConnections: poolMax });
|
|
154
|
+
const conn = createDbConnection({ url: testUrl, maxConnections: poolMax, rowLevelSecurity });
|
|
88
155
|
const outsideConn = createDbConnection({
|
|
89
156
|
url: testUrl,
|
|
157
|
+
rowLevelSecurity,
|
|
90
158
|
maxConnections: opts.outsideTransactionPoolMax ?? DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX,
|
|
91
159
|
});
|
|
92
160
|
|
|
@@ -99,13 +167,17 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
|
|
|
99
167
|
db: conn.db,
|
|
100
168
|
poolMax,
|
|
101
169
|
outsideTransactionDb: outsideConn.db,
|
|
102
|
-
client: conn.
|
|
170
|
+
client: conn.listenClient,
|
|
103
171
|
dbName,
|
|
104
172
|
cleanup: async () => {
|
|
105
173
|
await conn.close();
|
|
106
174
|
await outsideConn.close();
|
|
107
175
|
if (!opts.persistent) {
|
|
108
|
-
const admin2 = createDbConnection({
|
|
176
|
+
const admin2 = createDbConnection({
|
|
177
|
+
url: adminUrl,
|
|
178
|
+
maxConnections: 1,
|
|
179
|
+
rowLevelSecurity: "off",
|
|
180
|
+
});
|
|
109
181
|
try {
|
|
110
182
|
await dropDatabaseIfExists(admin2.db, dbName);
|
|
111
183
|
} finally {
|
|
@@ -123,10 +195,11 @@ const testDbTemplates = new Map<string, Promise<void>>();
|
|
|
123
195
|
export function ensureTestDbTemplate(
|
|
124
196
|
templateDbName: string,
|
|
125
197
|
buildSchema: (db: DbConnection) => Promise<void>,
|
|
198
|
+
options: { readonly applicationRole?: boolean } = {},
|
|
126
199
|
): Promise<void> {
|
|
127
200
|
const known = testDbTemplates.get(templateDbName);
|
|
128
201
|
if (known) return known;
|
|
129
|
-
const pending = buildTestDbTemplate(templateDbName, buildSchema);
|
|
202
|
+
const pending = buildTestDbTemplate(templateDbName, buildSchema, options);
|
|
130
203
|
testDbTemplates.set(templateDbName, pending);
|
|
131
204
|
pending.catch(() => testDbTemplates.delete(templateDbName));
|
|
132
205
|
return pending;
|
|
@@ -135,10 +208,16 @@ export function ensureTestDbTemplate(
|
|
|
135
208
|
export async function buildTestDbTemplate(
|
|
136
209
|
templateDbName: string,
|
|
137
210
|
buildSchema: (db: DbConnection) => Promise<void>,
|
|
211
|
+
options: { readonly applicationRole?: boolean } = {},
|
|
138
212
|
): Promise<void> {
|
|
213
|
+
const applicationRole = options.applicationRole === true;
|
|
139
214
|
const url = requireEnv("TEST_DATABASE_URL");
|
|
140
215
|
// One connection, so the session that takes the advisory lock is the one closed below.
|
|
141
|
-
const admin = createDbConnection({
|
|
216
|
+
const admin = createDbConnection({
|
|
217
|
+
url: adminUrlOf(url),
|
|
218
|
+
maxConnections: 1,
|
|
219
|
+
rowLevelSecurity: "off",
|
|
220
|
+
});
|
|
142
221
|
try {
|
|
143
222
|
await acquireSessionAdvisoryLock(admin.db, templateDbName);
|
|
144
223
|
// skip: another worker of this run already built it while we waited on the lock
|
|
@@ -146,8 +225,18 @@ export async function buildTestDbTemplate(
|
|
|
146
225
|
// Built under a side name and renamed once complete, so a clone never sees a half-built schema.
|
|
147
226
|
const buildDbName = `${templateDbName}_build`;
|
|
148
227
|
await dropDatabaseIfExists(admin.db, buildDbName);
|
|
149
|
-
await
|
|
150
|
-
|
|
228
|
+
if (applicationRole) await ensureApplicationRole(admin.db, url);
|
|
229
|
+
await createDatabase(
|
|
230
|
+
admin.db,
|
|
231
|
+
buildDbName,
|
|
232
|
+
undefined,
|
|
233
|
+
applicationRole ? TEST_APPLICATION_ROLE : undefined,
|
|
234
|
+
);
|
|
235
|
+
const conn = createDbConnection(
|
|
236
|
+
applicationRole
|
|
237
|
+
? { url: applicationRoleUrlOf(url, buildDbName), maxConnections: 1, rowLevelSecurity: "on" }
|
|
238
|
+
: { url: databaseUrlOf(url, buildDbName), maxConnections: 1, rowLevelSecurity: "off" },
|
|
239
|
+
);
|
|
151
240
|
try {
|
|
152
241
|
await buildSchema(conn.db);
|
|
153
242
|
} finally {
|
|
@@ -160,7 +249,11 @@ export async function buildTestDbTemplate(
|
|
|
160
249
|
}
|
|
161
250
|
|
|
162
251
|
export async function dropTestDbTemplates(baseUrl: string, runId: string): Promise<void> {
|
|
163
|
-
const admin = createDbConnection({
|
|
252
|
+
const admin = createDbConnection({
|
|
253
|
+
url: adminUrlOf(baseUrl),
|
|
254
|
+
maxConnections: 1,
|
|
255
|
+
rowLevelSecurity: "off",
|
|
256
|
+
});
|
|
164
257
|
try {
|
|
165
258
|
for (const name of await listDatabaseNamesWithPrefix(admin.db, testDbTemplatePrefix(runId))) {
|
|
166
259
|
await dropDatabaseIfExists(admin.db, name);
|
package/src/stack/index.ts
CHANGED
|
@@ -15,9 +15,11 @@ export {
|
|
|
15
15
|
unsafePushTables,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
17
17
|
export {
|
|
18
|
+
applicationRoleUrlOf,
|
|
18
19
|
type CreateTestDbOptions,
|
|
19
20
|
createTestDb,
|
|
20
21
|
DEFAULT_TEST_DB_POOL_MAX,
|
|
22
|
+
TEST_APPLICATION_ROLE,
|
|
21
23
|
type TestDb,
|
|
22
24
|
} from "./db.ts";
|
|
23
25
|
export { drainEventConsumers } from "./drain-event-consumers.ts";
|
|
@@ -11,7 +11,12 @@ import {
|
|
|
11
11
|
unsafePushTables,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// DDL must not run through the system-scoped `db` of a stack: each scoped statement is its own transaction, which CREATE INDEX CONCURRENTLY rejects.
|
|
15
|
+
type DbHolder = { readonly db: DbConnection; readonly unscopedDb?: DbConnection };
|
|
16
|
+
|
|
17
|
+
function ddlDbOf(stack: DbHolder): DbConnection {
|
|
18
|
+
return stack.unscopedDb ?? stack.db;
|
|
19
|
+
}
|
|
15
20
|
|
|
16
21
|
// biome-ignore lint/suspicious/noConsole: stack-internal status logging
|
|
17
22
|
const logInfo = (msg: string): void => console.log(msg);
|
|
@@ -45,7 +50,7 @@ export async function pushEntityProjectionTables(
|
|
|
45
50
|
if (seen.has(proj.table)) continue;
|
|
46
51
|
seen.add(proj.table);
|
|
47
52
|
const physical = schemaTableName(proj.table) ?? tableToMeta(proj.table).tableName;
|
|
48
|
-
if (!(await tableExists(stack
|
|
53
|
+
if (!(await tableExists(ddlDbOf(stack), `public.${physical}`))) {
|
|
49
54
|
missing[projName] = proj.table;
|
|
50
55
|
continue;
|
|
51
56
|
}
|
|
@@ -53,7 +58,7 @@ export async function pushEntityProjectionTables(
|
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
if (Object.keys(missing).length > 0) {
|
|
56
|
-
await unsafePushTables(stack
|
|
61
|
+
await unsafePushTables(ddlDbOf(stack), missing);
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
|
|
@@ -70,11 +75,11 @@ async function syncExistingProjectionTable(
|
|
|
70
75
|
table: unknown,
|
|
71
76
|
): Promise<void> {
|
|
72
77
|
const meta = tableToMeta(table);
|
|
73
|
-
const liveColumns = await columnNamesOf(stack
|
|
78
|
+
const liveColumns = await columnNamesOf(ddlDbOf(stack), physical);
|
|
74
79
|
const missingColumns = meta.columns.filter((c) => !liveColumns.has(c.name));
|
|
75
80
|
if (missingColumns.length === 0) {
|
|
76
81
|
logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
|
|
77
|
-
await addMissingIndexes(stack
|
|
82
|
+
await addMissingIndexes(ddlDbOf(stack), physical, meta.indexes);
|
|
78
83
|
// skip: table is already in sync, only indexes needed backfilling
|
|
79
84
|
return;
|
|
80
85
|
}
|
|
@@ -97,6 +102,6 @@ async function syncExistingProjectionTable(
|
|
|
97
102
|
`[kumiko-stack] table ${physical} is missing column(s) ` +
|
|
98
103
|
`${missingColumns.map((c) => `"${c.name}"`).join(", ")} — adding`,
|
|
99
104
|
);
|
|
100
|
-
await addMissingColumns(stack
|
|
101
|
-
await addMissingIndexes(stack
|
|
105
|
+
await addMissingColumns(ddlDbOf(stack), physical, missingColumns);
|
|
106
|
+
await addMissingIndexes(ddlDbOf(stack), physical, meta.indexes);
|
|
102
107
|
}
|