@cosmicdrift/kumiko-server-runtime 0.1.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/LICENSE +57 -0
- package/package.json +87 -0
- package/src/__tests__/boot-extra-context.test.ts +140 -0
- package/src/__tests__/build-prod-bundle.test.ts +278 -0
- package/src/__tests__/cache-headers.test.ts +83 -0
- package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +308 -0
- package/src/__tests__/compose-features-wiring.integration.test.ts +382 -0
- package/src/__tests__/compose-features.test.ts +128 -0
- package/src/__tests__/config-seed-boot.integration.test.ts +158 -0
- package/src/__tests__/inject-schema.test.ts +62 -0
- package/src/__tests__/pii-boot-gate.test.ts +68 -0
- package/src/__tests__/renderer-web-css-relocation.integration.test.ts +85 -0
- package/src/__tests__/renderer-web-shell-sentinel.test.ts +35 -0
- package/src/__tests__/require-env.test.ts +29 -0
- package/src/__tests__/resolve-auth-mail.test.ts +69 -0
- package/src/__tests__/resolve-tailwind-cli.test.ts +81 -0
- package/src/__tests__/run-prod-app-env-source.test.ts +157 -0
- package/src/__tests__/run-prod-app-spec.test.ts +57 -0
- package/src/__tests__/run-prod-app.integration.test.ts +915 -0
- package/src/__tests__/session-wiring.test.ts +51 -0
- package/src/__tests__/try-hono-first.test.ts +63 -0
- package/src/boot/__tests__/job-run-logger.test.ts +26 -0
- package/src/boot/apply-boot-seeds.ts +19 -0
- package/src/boot/boot-crypto.ts +82 -0
- package/src/boot/job-run-logger.ts +51 -0
- package/src/build-prod-bundle.ts +692 -0
- package/src/bun-serve-options.ts +22 -0
- package/src/compose-features.ts +164 -0
- package/src/extra-routes-deps.ts +47 -0
- package/src/index.ts +16 -0
- package/src/inject-schema.ts +30 -0
- package/src/pii-boot-gate.ts +62 -0
- package/src/resolve-tailwind-cli.ts +45 -0
- package/src/run-prod-app-boot-context.ts +241 -0
- package/src/run-prod-app-static-files.ts +273 -0
- package/src/run-prod-app.ts +1137 -0
- package/src/session-wiring.ts +29 -0
- package/src/try-hono-first.ts +46 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Unit-Tests für composeFeatures.authOptions — pinst dass die
|
|
2
|
+
// passwordReset / emailVerification options an
|
|
3
|
+
// createAuthEmailPasswordFeature durchgereicht werden, sodass die
|
|
4
|
+
// request- und confirm-Handler im resultierenden Feature registriert
|
|
5
|
+
// sind. Bug-Pattern: ohne diesen Wiring würde runProdApp.options.auth.
|
|
6
|
+
// passwordReset = {sendResetEmail, appResetUrl} die routes mounten,
|
|
7
|
+
// aber die Handler fehlen → POST /api/auth/request-password-reset
|
|
8
|
+
// dispatched ins Leere → 500.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
11
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { composeFeatures } from "../compose-features";
|
|
13
|
+
|
|
14
|
+
const noopFeature = defineFeature("noop-app", () => {});
|
|
15
|
+
|
|
16
|
+
// Mirrors what the create-kumiko-app picker hands back when the user
|
|
17
|
+
// ticks an auto-mounted feature: a stub with the same name as a bundled
|
|
18
|
+
// one. The dedupe path drops it and warns.
|
|
19
|
+
const pickerAuthDupe = defineFeature("auth-email-password", () => {});
|
|
20
|
+
|
|
21
|
+
const HMAC_SECRET = "test-secret-with-at-least-32-bytes-aaa";
|
|
22
|
+
|
|
23
|
+
describe("composeFeatures", () => {
|
|
24
|
+
test("includeBundled=false → nur App-Features", () => {
|
|
25
|
+
const features = composeFeatures([noopFeature], { includeBundled: false });
|
|
26
|
+
expect(features.map((f) => f.name)).toEqual(["noop-app"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("includeBundled=true → 4 bundled Features davor", () => {
|
|
30
|
+
const features = composeFeatures([noopFeature], { includeBundled: true });
|
|
31
|
+
expect(features.map((f) => f.name)).toEqual([
|
|
32
|
+
"config",
|
|
33
|
+
"user",
|
|
34
|
+
"tenant",
|
|
35
|
+
"auth-email-password",
|
|
36
|
+
"noop-app",
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("authOptions.passwordReset → request-password-reset + reset-password handlers registriert", () => {
|
|
41
|
+
const features = composeFeatures([noopFeature], {
|
|
42
|
+
includeBundled: true,
|
|
43
|
+
authOptions: { passwordReset: { hmacSecret: HMAC_SECRET, appUrl: "https://app/reset" } },
|
|
44
|
+
});
|
|
45
|
+
const auth = features.find((f) => f.name === "auth-email-password");
|
|
46
|
+
expect(auth).toBeDefined();
|
|
47
|
+
if (!auth) return;
|
|
48
|
+
const handlerNames = Array.from(Object.keys(auth.writeHandlers));
|
|
49
|
+
expect(handlerNames).toContain("request-password-reset");
|
|
50
|
+
expect(handlerNames).toContain("reset-password");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("authOptions.emailVerification → request-email-verification + verify-email handlers registriert", () => {
|
|
54
|
+
const features = composeFeatures([noopFeature], {
|
|
55
|
+
includeBundled: true,
|
|
56
|
+
authOptions: { emailVerification: { hmacSecret: HMAC_SECRET, appUrl: "https://app/verify" } },
|
|
57
|
+
});
|
|
58
|
+
const auth = features.find((f) => f.name === "auth-email-password");
|
|
59
|
+
expect(auth).toBeDefined();
|
|
60
|
+
if (!auth) return;
|
|
61
|
+
const handlerNames = Array.from(Object.keys(auth.writeHandlers));
|
|
62
|
+
expect(handlerNames).toContain("request-email-verification");
|
|
63
|
+
expect(handlerNames).toContain("verify-email");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("authOptions.signup → signup-request + signup-confirm handlers registriert", () => {
|
|
67
|
+
const features = composeFeatures([noopFeature], {
|
|
68
|
+
includeBundled: true,
|
|
69
|
+
authOptions: { signup: { appUrl: "https://app/signup/complete" } },
|
|
70
|
+
});
|
|
71
|
+
const auth = features.find((f) => f.name === "auth-email-password");
|
|
72
|
+
expect(auth).toBeDefined();
|
|
73
|
+
if (!auth) return;
|
|
74
|
+
const handlerNames = Array.from(Object.keys(auth.writeHandlers));
|
|
75
|
+
expect(handlerNames).toContain("signup-request");
|
|
76
|
+
expect(handlerNames).toContain("signup-confirm");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("OHNE authOptions → KEINE reset/verify-handlers (anti-default-deploy-bug)", () => {
|
|
80
|
+
// Genau der Bug der vom Review-Agent gefangen wurde: composeFeatures
|
|
81
|
+
// ohne authOptions registriert die handler nicht. Wenn jemand das
|
|
82
|
+
// versehentlich vergisst und nur die routes (auth-routes-config)
|
|
83
|
+
// wired, schlagen die requests auf prod fehl. Der Test pinst dass
|
|
84
|
+
// dieser default-deny-Pfad bewusst leer ist.
|
|
85
|
+
const features = composeFeatures([noopFeature], { includeBundled: true });
|
|
86
|
+
const auth = features.find((f) => f.name === "auth-email-password");
|
|
87
|
+
expect(auth).toBeDefined();
|
|
88
|
+
if (!auth) return;
|
|
89
|
+
const handlerNames = Array.from(Object.keys(auth.writeHandlers));
|
|
90
|
+
expect(handlerNames).not.toContain("request-password-reset");
|
|
91
|
+
expect(handlerNames).not.toContain("reset-password");
|
|
92
|
+
expect(handlerNames).not.toContain("request-email-verification");
|
|
93
|
+
expect(handlerNames).not.toContain("verify-email");
|
|
94
|
+
// Die regulären auth-handlers (login/logout/change-password) MÜSSEN
|
|
95
|
+
// aber immer da sein — das ist der core-flow.
|
|
96
|
+
expect(handlerNames).toContain("login");
|
|
97
|
+
expect(handlerNames).toContain("logout");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("app feature duplicating a bundled name is dropped (no createRegistry crash)", () => {
|
|
101
|
+
// create-kumiko-app's picker hands back createAuthEmailPasswordFeature()
|
|
102
|
+
// because the user ticked it in the recommended set; runDevApp then adds
|
|
103
|
+
// its OWN bundled copy via includeBundled:true, and createRegistry throws
|
|
104
|
+
// "Duplicate feature: auth-email-password". The dedupe path keeps the
|
|
105
|
+
// bundled instance (it carries authOptions wiring) and drops the app stub.
|
|
106
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
107
|
+
const features = composeFeatures([pickerAuthDupe, noopFeature], {
|
|
108
|
+
includeBundled: true,
|
|
109
|
+
});
|
|
110
|
+
// The warn is part of the fix contract (changeset: "warn so the user can
|
|
111
|
+
// remove the line") — without this assertion the warn is removable with no RED.
|
|
112
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("auth-email-password"));
|
|
113
|
+
warnSpy.mockRestore();
|
|
114
|
+
expect(features.map((f) => f.name)).toEqual([
|
|
115
|
+
"config",
|
|
116
|
+
"user",
|
|
117
|
+
"tenant",
|
|
118
|
+
"auth-email-password",
|
|
119
|
+
"noop-app",
|
|
120
|
+
]);
|
|
121
|
+
// The kept instance is the bundled one — confirmed by the presence of
|
|
122
|
+
// login/logout handlers (the picker stub has none).
|
|
123
|
+
const auth = features.find((f) => f.name === "auth-email-password");
|
|
124
|
+
expect(auth).toBeDefined();
|
|
125
|
+
if (!auth) return;
|
|
126
|
+
expect(Object.keys(auth.writeHandlers)).toContain("login");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Pins the boot-wiring contract for config-seeds. runDevApp's onAfterSetup
|
|
2
|
+
// and runProdApp's seed-block both call `applyBootSeeds(...)` — this test
|
|
3
|
+
// calls the SAME helper, so if someone removes the call site from
|
|
4
|
+
// runDevApp / runProdApp the helper still has at least one caller (this
|
|
5
|
+
// test). Code review then sees an orphaned helper, not a silently broken
|
|
6
|
+
// boot. For a stricter end-to-end pin you'd start an actual runDevApp;
|
|
7
|
+
// that's heavy and not done here.
|
|
8
|
+
//
|
|
9
|
+
// Tests:
|
|
10
|
+
// 1. seed rows land in the projection after applyBootSeeds runs,
|
|
11
|
+
// 2. a re-boot is a no-op (idempotent),
|
|
12
|
+
// 3. an admin set on top of a seed wins the resolver cascade; coexistence
|
|
13
|
+
// vs. override semantics depend on the admin user's tenantId.
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
configValuesTable,
|
|
18
|
+
createConfigAccessorFactory,
|
|
19
|
+
createConfigFeature,
|
|
20
|
+
createConfigResolver,
|
|
21
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
22
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
23
|
+
import {
|
|
24
|
+
access,
|
|
25
|
+
createSystemConfig,
|
|
26
|
+
createSystemSeed,
|
|
27
|
+
createTenantConfig,
|
|
28
|
+
createTenantSeed,
|
|
29
|
+
defineFeature,
|
|
30
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
31
|
+
import {
|
|
32
|
+
setupTestStack,
|
|
33
|
+
type TestStack,
|
|
34
|
+
TestUsers,
|
|
35
|
+
unsafePushTables,
|
|
36
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
37
|
+
import { applyBootSeeds } from "../boot/apply-boot-seeds";
|
|
38
|
+
|
|
39
|
+
const bootSeedsFeature = defineFeature("boot-seeds-test", (r) => {
|
|
40
|
+
r.requires("config");
|
|
41
|
+
r.config({
|
|
42
|
+
keys: {
|
|
43
|
+
siteName: createTenantConfig("text", {
|
|
44
|
+
default: "DEFAULT_SITE",
|
|
45
|
+
read: access.all,
|
|
46
|
+
write: access.all,
|
|
47
|
+
}),
|
|
48
|
+
maintenance: createSystemConfig("boolean", {
|
|
49
|
+
default: false,
|
|
50
|
+
read: access.all,
|
|
51
|
+
write: access.systemAdmin,
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
seeds: {
|
|
55
|
+
siteName: createTenantSeed({ value: "from-seed" }),
|
|
56
|
+
maintenance: createSystemSeed({ value: true }),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const SITE_KEY = "boot-seeds-test:config:site-name";
|
|
62
|
+
const MAINT_KEY = "boot-seeds-test:config:maintenance";
|
|
63
|
+
|
|
64
|
+
let stack: TestStack;
|
|
65
|
+
const resolver = createConfigResolver();
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
stack = await setupTestStack({
|
|
69
|
+
features: [createConfigFeature(), bootSeedsFeature],
|
|
70
|
+
extraContext: ({ registry }) => ({
|
|
71
|
+
configResolver: resolver,
|
|
72
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterAll(async () => {
|
|
79
|
+
await stack.cleanup();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("config-seed boot wiring", () => {
|
|
83
|
+
test("first boot: applyBootSeeds writes one row per seed", async () => {
|
|
84
|
+
await applyBootSeeds({ registry: stack.registry, db: stack.db });
|
|
85
|
+
|
|
86
|
+
const rows = await selectMany(stack.db, configValuesTable);
|
|
87
|
+
expect(rows.length).toBe(2);
|
|
88
|
+
|
|
89
|
+
const siteKeyDef = stack.registry.getConfigKey(SITE_KEY);
|
|
90
|
+
expect(siteKeyDef).toBeDefined();
|
|
91
|
+
const sitePeek = await resolver.get(
|
|
92
|
+
SITE_KEY,
|
|
93
|
+
siteKeyDef!,
|
|
94
|
+
TestUsers.systemAdmin.tenantId,
|
|
95
|
+
TestUsers.systemAdmin.id,
|
|
96
|
+
stack.db,
|
|
97
|
+
);
|
|
98
|
+
expect(sitePeek).toBe("from-seed");
|
|
99
|
+
|
|
100
|
+
const maintKeyDef = stack.registry.getConfigKey(MAINT_KEY);
|
|
101
|
+
expect(maintKeyDef).toBeDefined();
|
|
102
|
+
const maintPeek = await resolver.get(
|
|
103
|
+
MAINT_KEY,
|
|
104
|
+
maintKeyDef!,
|
|
105
|
+
TestUsers.systemAdmin.tenantId,
|
|
106
|
+
TestUsers.systemAdmin.id,
|
|
107
|
+
stack.db,
|
|
108
|
+
);
|
|
109
|
+
expect(maintPeek).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("re-boot: idempotent — every seed already on disk → no extra rows", async () => {
|
|
113
|
+
await applyBootSeeds({ registry: stack.registry, db: stack.db });
|
|
114
|
+
|
|
115
|
+
const rows = await selectMany(stack.db, configValuesTable);
|
|
116
|
+
expect(rows.length).toBe(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("admin set on top of seed wins resolver — Re-Boot preserves admin", async () => {
|
|
120
|
+
// siteName is a TENANT-scope key. The seed writes a row under
|
|
121
|
+
// SYSTEM_TENANT_ID (= "for all tenants"). An admin on a real tenant
|
|
122
|
+
// writes a row under THAT tenantId — higher specificity. Both rows
|
|
123
|
+
// coexist; the resolver returns the more specific one.
|
|
124
|
+
//
|
|
125
|
+
// If the admin happens to write as SYSTEM_TENANT_ID (e.g. test user
|
|
126
|
+
// is the system-admin on the system-tenant), the admin write hits
|
|
127
|
+
// the seed-row directly and updates the same aggregate stream. Both
|
|
128
|
+
// paths end up with the admin value winning — the row-count
|
|
129
|
+
// assertion makes the path explicit.
|
|
130
|
+
await stack.http.writeOk(
|
|
131
|
+
"config:write:set",
|
|
132
|
+
{ key: SITE_KEY, value: "admin-override", scope: "tenant" },
|
|
133
|
+
TestUsers.systemAdmin,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await applyBootSeeds({ registry: stack.registry, db: stack.db });
|
|
137
|
+
|
|
138
|
+
const siteKeyDef = stack.registry.getConfigKey(SITE_KEY);
|
|
139
|
+
expect(siteKeyDef).toBeDefined();
|
|
140
|
+
const peek = await resolver.get(
|
|
141
|
+
SITE_KEY,
|
|
142
|
+
siteKeyDef!,
|
|
143
|
+
TestUsers.systemAdmin.tenantId,
|
|
144
|
+
TestUsers.systemAdmin.id,
|
|
145
|
+
stack.db,
|
|
146
|
+
);
|
|
147
|
+
expect(peek).toBe("admin-override");
|
|
148
|
+
|
|
149
|
+
// Row-count tells us which path was hit:
|
|
150
|
+
// - 2 rows = override path (admin tenantId === SYSTEM_TENANT_ID,
|
|
151
|
+
// updated the seed-stream in place).
|
|
152
|
+
// - 3 rows = coexistence path (admin tenantId !== SYSTEM_TENANT_ID,
|
|
153
|
+
// new specific-tenant row sits next to the seed system-row).
|
|
154
|
+
// Either is correct as long as the resolver picks the admin value.
|
|
155
|
+
const rows = await selectMany(stack.db, configValuesTable);
|
|
156
|
+
expect([2, 3]).toContain(rows.length);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// injectSchema teilt sich dev-server (every HTML response) und prod-
|
|
2
|
+
// server (static-fallback index.html) Pfad. Bug hier wäre stiller
|
|
3
|
+
// Production-Fail: createKumikoApp findet `window.__KUMIKO_SCHEMA__`
|
|
4
|
+
// nicht und mountet leer. Tests pinnen die Idempotenz + die zwei
|
|
5
|
+
// Insertion-Punkte (vor /client.js-Tag oder vor </body>).
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { injectSchema } from "../inject-schema";
|
|
9
|
+
|
|
10
|
+
const SCHEMA = '{"features":[]}';
|
|
11
|
+
const TAG = `<script>window.__KUMIKO_SCHEMA__=${SCHEMA};</script>`;
|
|
12
|
+
|
|
13
|
+
describe("injectSchema", () => {
|
|
14
|
+
test("HTML mit /client.js-Tag: Schema-Tag wird DAVOR eingefügt", () => {
|
|
15
|
+
const html = '<html><body><script src="/client.js" defer></script></body></html>';
|
|
16
|
+
const out = injectSchema(html, SCHEMA);
|
|
17
|
+
expect(out).toContain(TAG);
|
|
18
|
+
// Schema MUSS vor dem Client-Bundle stehen — sonst läuft
|
|
19
|
+
// createKumikoApp() bevor window.__KUMIKO_SCHEMA__ gesetzt ist.
|
|
20
|
+
expect(out.indexOf(TAG)).toBeLessThan(out.indexOf('<script src="/client.js"'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("HTML ohne /client.js-Tag: Schema-Tag wird vor </body> eingefügt", () => {
|
|
24
|
+
const html = "<html><body><div id=root></div></body></html>";
|
|
25
|
+
const out = injectSchema(html, SCHEMA);
|
|
26
|
+
expect(out).toContain(TAG);
|
|
27
|
+
expect(out.indexOf(TAG)).toBeLessThan(out.indexOf("</body>"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("HTML ohne </body>: Schema-Tag wird angehängt (defensiver Fallback)", () => {
|
|
31
|
+
const html = "<div>fragment</div>";
|
|
32
|
+
const out = injectSchema(html, SCHEMA);
|
|
33
|
+
expect(out.endsWith(TAG)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("Idempotent: bei bereits injectem Schema kein zweiter Tag", () => {
|
|
37
|
+
const html = `<html><body>${TAG}</body></html>`;
|
|
38
|
+
const out = injectSchema(html, '{"features":[{"differentSchema":true}]}');
|
|
39
|
+
// Original-Tag bleibt, kein zweiter Tag hinzugefügt — der Marker-
|
|
40
|
+
// Check verhindert sonst stacking-Tags bei repeated reads.
|
|
41
|
+
expect(out).toBe(html);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("Schema mit Komplex-Daten (entities + screens) bleibt valides JS", () => {
|
|
45
|
+
const complex = JSON.stringify({
|
|
46
|
+
features: [
|
|
47
|
+
{
|
|
48
|
+
featureName: "items",
|
|
49
|
+
entities: { item: { fields: { title: { type: "text" } } } },
|
|
50
|
+
screens: [{ id: "list", type: "entityList", entity: "item", columns: ["title"] }],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
const html = '<html><body><script src="/client.js"></script></body></html>';
|
|
55
|
+
const out = injectSchema(html, complex);
|
|
56
|
+
// Sanity: das injected Skript muss valid JS sein (Object-Literal-
|
|
57
|
+
// Syntax, keine HTML-Reserved-Chars im JSON die den <script>-Block
|
|
58
|
+
// brechen würden — JSON.stringify entkommt /, < usw. nicht, aber
|
|
59
|
+
// die Standard-Chars die wir hier nutzen sind unkritisch).
|
|
60
|
+
expect(out).toContain(`window.__KUMIKO_SCHEMA__=${complex}`);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { assertPiiBootInvariants } from "../pii-boot-gate";
|
|
5
|
+
|
|
6
|
+
const piiFeature = defineFeature("gate-pii", (r) => {
|
|
7
|
+
r.entity(
|
|
8
|
+
"person",
|
|
9
|
+
createEntity({
|
|
10
|
+
table: "read_gate_persons",
|
|
11
|
+
fields: { email: createTextField({ required: true, pii: true, lookupable: true }) },
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
const plainFeature = defineFeature("gate-plain", (r) => {
|
|
16
|
+
r.entity(
|
|
17
|
+
"thing",
|
|
18
|
+
createEntity({ table: "read_gate_things", fields: { name: createTextField() } }),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const kms = new InMemoryKmsAdapter();
|
|
23
|
+
const KEY = Buffer.alloc(32, 7).toString("base64");
|
|
24
|
+
|
|
25
|
+
describe("assertPiiBootInvariants — prod", () => {
|
|
26
|
+
test("PII without a KMS aborts boot", () => {
|
|
27
|
+
expect(() => assertPiiBootInvariants([piiFeature], { mode: "prod" })).toThrow(
|
|
28
|
+
/BOOT ABORTED.*PLAINTEXT.*allowPlaintextPii/s,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("explicit allowPlaintextPii downgrades to a warning", () => {
|
|
33
|
+
expect(() =>
|
|
34
|
+
assertPiiBootInvariants([piiFeature], {
|
|
35
|
+
mode: "prod",
|
|
36
|
+
allowPlaintextPii: "kms rollout pending, infra#188",
|
|
37
|
+
}),
|
|
38
|
+
).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("KMS + blindIndexKey boots", () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
assertPiiBootInvariants([piiFeature], { mode: "prod", kms, blindIndexKey: KEY }),
|
|
44
|
+
).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("KMS without blindIndexKey aborts when lookupable fields exist", () => {
|
|
48
|
+
expect(() => assertPiiBootInvariants([piiFeature], { mode: "prod", kms })).toThrow(
|
|
49
|
+
/blindIndexKey/,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no PII entities → nothing to gate", () => {
|
|
54
|
+
expect(() => assertPiiBootInvariants([plainFeature], { mode: "prod" })).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("assertPiiBootInvariants — dev", () => {
|
|
59
|
+
test("PII without a KMS only warns in dev", () => {
|
|
60
|
+
expect(() => assertPiiBootInvariants([piiFeature], { mode: "dev" })).not.toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("KMS without blindIndexKey aborts in dev too (lookups broken in any mode)", () => {
|
|
64
|
+
expect(() => assertPiiBootInvariants([piiFeature], { mode: "dev", kms })).toThrow(
|
|
65
|
+
/blindIndexKey/,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Relocation-Test für den @source-Layout-Fix (#359).
|
|
2
|
+
//
|
|
3
|
+
// Der Bug: renderer-web/src/styles.css scannte seine eigenen Shell-Klassen
|
|
4
|
+
// über einen MONOREPO-relativen @source (`../../renderer-web/src`). Im
|
|
5
|
+
// Workspace löst der via Symlink auf (grün), in einem Standalone-Consumer
|
|
6
|
+
// (echtes node_modules ohne Monorepo-Geschwister) findet er nichts → unstyled
|
|
7
|
+
// prod (15KB statt 48KB). Der Fix macht die Zeile self-relativ (`./`), weil
|
|
8
|
+
// das Paket `src` shippt.
|
|
9
|
+
//
|
|
10
|
+
// Am REALEN Ort der styles.css sind `./` und `../../renderer-web/src`
|
|
11
|
+
// identisch — ein Test dort wäre grün mit Bug UND Fix. Der Diskriminator ist
|
|
12
|
+
// RELOCATION: wir kopieren die echte styles.css an einen Ort, an dem der alte
|
|
13
|
+
// Pfad tot ist und nur `./` greift. Der Ort liegt unter dem Repo-Root, damit
|
|
14
|
+
// `@import "tailwindcss"` / `react-day-picker` weiter über node_modules
|
|
15
|
+
// auflösen. Braucht Bun (Bun.spawn im Tailwind-One-Shot) — sonst silent skip.
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from "bun:test";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { runTailwindOnce } from "../build-prod-bundle";
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const REPO_ROOT = resolve(__dirname, "../../../..");
|
|
26
|
+
const RENDERER_WEB_STYLES = resolve(__dirname, "../../../renderer-web/src/styles.css");
|
|
27
|
+
const SELF_SOURCE = '@source "./**/*.{ts,tsx}";';
|
|
28
|
+
const MONOREPO_SOURCE = '@source "../../renderer-web/src/**/*.{ts,tsx}";';
|
|
29
|
+
|
|
30
|
+
function bunAvailable(): boolean {
|
|
31
|
+
try {
|
|
32
|
+
execFileSync("bun", ["--version"], { stdio: "ignore" });
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("renderer-web styles.css @source relocation (#359)", () => {
|
|
40
|
+
// skipIf statt `if (!bunAvailable()) return;` — sonst meldet der Runner den
|
|
41
|
+
// Test grün, obwohl er nie lief (silent-pass verschleiert fehlende Coverage).
|
|
42
|
+
test.skipIf(!bunAvailable())(
|
|
43
|
+
"self-relative @source scans the shell standalone; monorepo path would not",
|
|
44
|
+
async () => {
|
|
45
|
+
// Tailwind v4 auto-scannt nur das cwd-Verzeichnis (empirisch bestätigt:
|
|
46
|
+
// das Verzeichnis der Input-CSS wird NICHT automatisch gescannt). Im echten
|
|
47
|
+
// Standalone-Consumer ist renderer-web zudem in node_modules (gitignored) →
|
|
48
|
+
// ebenfalls nicht auto-gescannt. Die Shell-Klassen erreicht also NUR der
|
|
49
|
+
// explizite @source der styles.css. Hier mirrorn wir das robust, ohne
|
|
50
|
+
// gitignore-/node_modules-Semantik: das Paket-`src` (mit der Shell-Probe)
|
|
51
|
+
// liegt AUSSERHALB des Build-cwd — nur der self-relative `@source "./**"`
|
|
52
|
+
// der relozierten styles.css erreicht es, der monorepo-Pfad ist tot.
|
|
53
|
+
// Temp unter REPO_ROOT, damit @import "tailwindcss"/react-day-picker via
|
|
54
|
+
// node_modules auflösen.
|
|
55
|
+
const dir = await mkdtemp(join(REPO_ROOT, ".reloc-rendererweb-"));
|
|
56
|
+
const buildCwd = join(dir, "app");
|
|
57
|
+
const pkgSrc = join(dir, "pkg");
|
|
58
|
+
try {
|
|
59
|
+
const realCss = await readFile(RENDERER_WEB_STYLES, "utf8");
|
|
60
|
+
// Guard: der Fix MUSS in der Quelle stehen, sonst testet die Relocation nichts.
|
|
61
|
+
expect(realCss).toContain(SELF_SOURCE);
|
|
62
|
+
|
|
63
|
+
await mkdir(buildCwd, { recursive: true });
|
|
64
|
+
await mkdir(join(pkgSrc, "layout"), { recursive: true });
|
|
65
|
+
await writeFile(
|
|
66
|
+
join(pkgSrc, "layout/probe.tsx"),
|
|
67
|
+
`export const P = () => <div className="min-h-screen" />;\n`,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await writeFile(join(pkgSrc, "styles.css"), realCss);
|
|
71
|
+
const fixed = await runTailwindOnce(join(pkgSrc, "styles.css"), buildCwd);
|
|
72
|
+
expect(fixed).toContain("min-h-screen");
|
|
73
|
+
|
|
74
|
+
// Diskriminator: mit dem ALTEN monorepo-relativen @source ist die Probe
|
|
75
|
+
// an diesem relozierten Ort unerreichbar → Sentinel fehlt. Beweist, dass
|
|
76
|
+
// der Fix kein No-op ist (der Test würde bei einem Revert rot).
|
|
77
|
+
await writeFile(join(pkgSrc, "styles.css"), realCss.replace(SELF_SOURCE, MONOREPO_SOURCE));
|
|
78
|
+
const buggy = await runTailwindOnce(join(pkgSrc, "styles.css"), buildCwd);
|
|
79
|
+
expect(buggy).not.toContain("min-h-screen");
|
|
80
|
+
} finally {
|
|
81
|
+
await rm(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Unit-Test für den CSS-Completeness-Guard (#359): nach dem Tailwind-One-Shot
|
|
2
|
+
// prüft der Build, ob das Output-CSS die renderer-web-Shell-Sentinel-Klasse
|
|
3
|
+
// enthält — aber NUR wenn auf das gepackte renderer-web-styles.css
|
|
4
|
+
// zurückgefallen wurde. Fehlt sie dort, würde prod unstyled rendern → laut
|
|
5
|
+
// failen mit Hinweis auf src/styles.css.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { assertRendererWebShellPresent } from "../build-prod-bundle";
|
|
9
|
+
|
|
10
|
+
const fallback = {
|
|
11
|
+
path: "/app/node_modules/renderer-web/src/styles.css",
|
|
12
|
+
isRendererWebFallback: true,
|
|
13
|
+
};
|
|
14
|
+
const appOwned = { path: "/app/src/styles.css", isRendererWebFallback: false };
|
|
15
|
+
|
|
16
|
+
describe("assertRendererWebShellPresent (#359 CSS-completeness guard)", () => {
|
|
17
|
+
test("fallback + fehlende Shell-Sentinel-Klasse → wirft mit src/styles.css-Hinweis", () => {
|
|
18
|
+
expect(() => assertRendererWebShellPresent(".some-other{display:flex}", fallback)).toThrow(
|
|
19
|
+
/min-h-screen/,
|
|
20
|
+
);
|
|
21
|
+
expect(() => assertRendererWebShellPresent(".some-other{display:flex}", fallback)).toThrow(
|
|
22
|
+
/src\/styles\.css/,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("fallback + Shell-Sentinel vorhanden → kein Throw", () => {
|
|
27
|
+
expect(() =>
|
|
28
|
+
assertRendererWebShellPresent(".min-h-screen{min-height:100vh}", fallback),
|
|
29
|
+
).not.toThrow();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("app-eigenes styles.css (kein Fallback) → nie asserten, auch ohne Sentinel", () => {
|
|
33
|
+
expect(() => assertRendererWebShellPresent("", appOwned)).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// requireEnv — the missing-var error message must match the actual caller.
|
|
2
|
+
// Regression (728/2): the advice text was hardcoded to prod/container
|
|
3
|
+
// wording ("Coolify secrets") regardless of the `context` param, so a
|
|
4
|
+
// runDevApp caller got misleading production-deploy instructions for a
|
|
5
|
+
// missing local .env var.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { requireEnv } from "../run-prod-app";
|
|
9
|
+
|
|
10
|
+
describe("requireEnv", () => {
|
|
11
|
+
test("default context (runProdApp) gives container/production advice", () => {
|
|
12
|
+
expect(() => requireEnv("MISSING_VAR", {})).toThrow(/container env.*Coolify secrets/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("runDevApp context gives local .env advice, not production advice", () => {
|
|
16
|
+
expect(() => requireEnv("MISSING_VAR", {}, "runDevApp")).toThrow(
|
|
17
|
+
/\.env \/ shell before running the dev server/,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("runDevApp context error does NOT mention Coolify", () => {
|
|
22
|
+
try {
|
|
23
|
+
requireEnv("MISSING_VAR", {}, "runDevApp");
|
|
24
|
+
throw new Error("expected requireEnv to throw");
|
|
25
|
+
} catch (err) {
|
|
26
|
+
expect(String(err)).not.toContain("Coolify");
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type RunProdAppAuthOptions, resolveAuthMail } from "../run-prod-app";
|
|
3
|
+
|
|
4
|
+
// Pins the auth.mail convenience (resolveAuthMail): one mail block expands
|
|
5
|
+
// into the four explicit flow setups built from DEFAULT_AUTH_PATHS, the
|
|
6
|
+
// null-transport guard (no SMTP_HOST → flows stay unwired), and explicit
|
|
7
|
+
// per-flow setups winning over the mail default.
|
|
8
|
+
|
|
9
|
+
const admin: RunProdAppAuthOptions["admin"] = {
|
|
10
|
+
email: "admin@example.com",
|
|
11
|
+
password: "pw-long-enough",
|
|
12
|
+
displayName: "Admin",
|
|
13
|
+
memberships: [],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const withMail: RunProdAppAuthOptions = {
|
|
17
|
+
admin,
|
|
18
|
+
mail: { baseUrl: "https://app.example.com", appName: "Test" },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("resolveAuthMail", () => {
|
|
22
|
+
test("no mail block → auth returned unchanged", () => {
|
|
23
|
+
const noMail: RunProdAppAuthOptions = { admin };
|
|
24
|
+
const out = resolveAuthMail(noMail, "secret", { SMTP_HOST: "localhost" });
|
|
25
|
+
expect(out.passwordReset).toBeUndefined();
|
|
26
|
+
expect(out.signup).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("mail + SMTP_HOST → all four flows wired from DEFAULT_AUTH_PATHS", () => {
|
|
30
|
+
const out = resolveAuthMail(withMail, "secret", { SMTP_HOST: "localhost" });
|
|
31
|
+
// All four flows carry appUrl as feature options — each handler mails via
|
|
32
|
+
// delivery (ctx.notify); no callback wiring remains.
|
|
33
|
+
expect(out.passwordReset?.appUrl).toBe("https://app.example.com/reset-password");
|
|
34
|
+
expect(out.emailVerification?.appUrl).toBe("https://app.example.com/verify-email");
|
|
35
|
+
expect(out.signup?.appUrl).toBe("https://app.example.com/signup/complete");
|
|
36
|
+
expect(out.invite?.appUrl).toBe("https://app.example.com/invite/accept");
|
|
37
|
+
expect(out.passwordReset?.hmacSecret).toBe("secret");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("mail but NO SMTP_HOST → null-transport guard, flows stay unwired", () => {
|
|
41
|
+
const out = resolveAuthMail(withMail, "secret", {});
|
|
42
|
+
expect(out.passwordReset).toBeUndefined();
|
|
43
|
+
expect(out.signup).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("explicit per-flow setup wins over the mail default", () => {
|
|
47
|
+
const explicit: RunProdAppAuthOptions = {
|
|
48
|
+
...withMail,
|
|
49
|
+
passwordReset: {
|
|
50
|
+
hmacSecret: "h",
|
|
51
|
+
appUrl: "https://custom.example.com/pw",
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const out = resolveAuthMail(explicit, "secret", { SMTP_HOST: "localhost" });
|
|
55
|
+
expect(out.passwordReset?.appUrl).toBe("https://custom.example.com/pw");
|
|
56
|
+
// other flows still come from the mail default
|
|
57
|
+
expect(out.signup?.appUrl).toBe("https://app.example.com/signup/complete");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("paths override only affects the named path", () => {
|
|
61
|
+
const pathsOverride: RunProdAppAuthOptions = {
|
|
62
|
+
admin,
|
|
63
|
+
mail: { baseUrl: "https://app.example.com", paths: { resetPassword: "/pw" } },
|
|
64
|
+
};
|
|
65
|
+
const out = resolveAuthMail(pathsOverride, "secret", { SMTP_HOST: "localhost" });
|
|
66
|
+
expect(out.passwordReset?.appUrl).toBe("https://app.example.com/pw");
|
|
67
|
+
expect(out.emailVerification?.appUrl).toBe("https://app.example.com/verify-email");
|
|
68
|
+
});
|
|
69
|
+
});
|