@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.
Files changed (38) hide show
  1. package/LICENSE +57 -0
  2. package/package.json +87 -0
  3. package/src/__tests__/boot-extra-context.test.ts +140 -0
  4. package/src/__tests__/build-prod-bundle.test.ts +278 -0
  5. package/src/__tests__/cache-headers.test.ts +83 -0
  6. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +308 -0
  7. package/src/__tests__/compose-features-wiring.integration.test.ts +382 -0
  8. package/src/__tests__/compose-features.test.ts +128 -0
  9. package/src/__tests__/config-seed-boot.integration.test.ts +158 -0
  10. package/src/__tests__/inject-schema.test.ts +62 -0
  11. package/src/__tests__/pii-boot-gate.test.ts +68 -0
  12. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +85 -0
  13. package/src/__tests__/renderer-web-shell-sentinel.test.ts +35 -0
  14. package/src/__tests__/require-env.test.ts +29 -0
  15. package/src/__tests__/resolve-auth-mail.test.ts +69 -0
  16. package/src/__tests__/resolve-tailwind-cli.test.ts +81 -0
  17. package/src/__tests__/run-prod-app-env-source.test.ts +157 -0
  18. package/src/__tests__/run-prod-app-spec.test.ts +57 -0
  19. package/src/__tests__/run-prod-app.integration.test.ts +915 -0
  20. package/src/__tests__/session-wiring.test.ts +51 -0
  21. package/src/__tests__/try-hono-first.test.ts +63 -0
  22. package/src/boot/__tests__/job-run-logger.test.ts +26 -0
  23. package/src/boot/apply-boot-seeds.ts +19 -0
  24. package/src/boot/boot-crypto.ts +82 -0
  25. package/src/boot/job-run-logger.ts +51 -0
  26. package/src/build-prod-bundle.ts +692 -0
  27. package/src/bun-serve-options.ts +22 -0
  28. package/src/compose-features.ts +164 -0
  29. package/src/extra-routes-deps.ts +47 -0
  30. package/src/index.ts +16 -0
  31. package/src/inject-schema.ts +30 -0
  32. package/src/pii-boot-gate.ts +62 -0
  33. package/src/resolve-tailwind-cli.ts +45 -0
  34. package/src/run-prod-app-boot-context.ts +241 -0
  35. package/src/run-prod-app-static-files.ts +273 -0
  36. package/src/run-prod-app.ts +1137 -0
  37. package/src/session-wiring.ts +29 -0
  38. 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
+ });