@mandujs/core 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +94 -69
- package/src/auth/__tests__/login.test.ts +419 -0
- package/src/auth/__tests__/password.test.ts +122 -0
- package/src/auth/__tests__/reset.test.ts +296 -0
- package/src/auth/__tests__/tokens.test.ts +274 -0
- package/src/auth/__tests__/verification.test.ts +274 -0
- package/src/auth/index.ts +76 -0
- package/src/auth/login.ts +225 -0
- package/src/auth/password.ts +120 -0
- package/src/auth/reset.ts +243 -0
- package/src/auth/tokens.ts +612 -0
- package/src/auth/verification.ts +253 -0
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
- package/src/bundler/__tests__/cold-start.test.ts +504 -0
- package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
- package/src/bundler/__tests__/extended-watch.test.ts +710 -0
- package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
- package/src/bundler/__tests__/hdr.test.ts +353 -0
- package/src/bundler/__tests__/hmr-client.test.ts +532 -0
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
- package/src/bundler/build.test.ts +8 -1
- package/src/bundler/build.ts +310 -18
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1611 -59
- package/src/bundler/fast-refresh-plugin.ts +307 -0
- package/src/bundler/hmr-types.ts +252 -0
- package/src/bundler/manifest-schema.ts +301 -0
- package/src/bundler/safe-build.test.ts +128 -0
- package/src/bundler/safe-build.ts +77 -0
- package/src/bundler/scenario-matrix.ts +229 -0
- package/src/bundler/types.ts +11 -0
- package/src/bundler/vendor-cache-types.ts +130 -0
- package/src/bundler/vendor-cache.ts +526 -0
- package/src/client/router.ts +214 -56
- package/src/db/__tests__/db.test.ts +485 -0
- package/src/db/index.ts +513 -0
- package/src/db/migrations/__tests__/runner.test.ts +661 -0
- package/src/db/migrations/history-table.ts +345 -0
- package/src/db/migrations/lock.ts +269 -0
- package/src/db/migrations/runner.ts +633 -0
- package/src/desktop/__tests__/smoke.test.ts +100 -0
- package/src/desktop/__tests__/window.test.ts +172 -0
- package/src/desktop/__tests__/worker.test.ts +266 -0
- package/src/desktop/index.ts +43 -0
- package/src/desktop/types.ts +158 -0
- package/src/desktop/window.ts +492 -0
- package/src/desktop/worker.ts +180 -0
- package/src/email/__tests__/email.test.ts +355 -0
- package/src/email/index.ts +282 -0
- package/src/email/resend.ts +163 -0
- package/src/email/smtp.ts +64 -0
- package/src/filling/__tests__/session-sqlite.test.ts +454 -0
- package/src/filling/context.ts +72 -78
- package/src/filling/cookie-codec.ts +299 -0
- package/src/filling/deps.ts +25 -1
- package/src/filling/filling.ts +28 -3
- package/src/filling/session-sqlite.ts +617 -0
- package/src/filling/session.ts +265 -216
- package/src/guard/decision-memory.test.ts +52 -22
- package/src/id/__tests__/id.test.ts +120 -0
- package/src/id/index.ts +105 -0
- package/src/kitchen/index.ts +2 -2
- package/src/kitchen/kitchen-handler.ts +86 -0
- package/src/kitchen/stream/activity-sse.ts +2 -1
- package/src/middleware/csrf.ts +328 -0
- package/src/middleware/index.ts +40 -0
- package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
- package/src/middleware/oauth/index.ts +505 -0
- package/src/middleware/oauth/providers.ts +115 -0
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
- package/src/middleware/rate-limit/index.ts +522 -0
- package/src/middleware/rate-limit/sqlite-store.ts +382 -0
- package/src/middleware/secure/__tests__/secure.test.ts +360 -0
- package/src/middleware/secure/csp.ts +193 -0
- package/src/middleware/secure/index.ts +417 -0
- package/src/middleware/session.ts +174 -0
- package/src/observability/event-bus.ts +81 -79
- package/src/paths.ts +37 -0
- package/src/perf/hmr-markers.ts +215 -0
- package/src/perf/index.ts +104 -0
- package/src/resource/__tests__/generator.test.ts +603 -2
- package/src/resource/ddl/__tests__/diff.test.ts +639 -0
- package/src/resource/ddl/__tests__/emit.test.ts +799 -0
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
- package/src/resource/ddl/diff.ts +392 -0
- package/src/resource/ddl/emit.ts +548 -0
- package/src/resource/ddl/persistence-types.ts +218 -0
- package/src/resource/ddl/snapshot.ts +447 -0
- package/src/resource/ddl/type-map.ts +223 -0
- package/src/resource/ddl/types.ts +232 -0
- package/src/resource/generator-repo.ts +610 -0
- package/src/resource/generator-schema.ts +476 -0
- package/src/resource/generator.ts +117 -1
- package/src/resource/index.ts +17 -1
- package/src/resource/schema.ts +30 -0
- package/src/router/fs-scanner.ts +3 -0
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
- package/src/runtime/__tests__/hdr-client.test.ts +223 -0
- package/src/runtime/__tests__/http-errors.test.ts +117 -0
- package/src/runtime/__tests__/not-found.test.ts +152 -0
- package/src/runtime/boundary.tsx +21 -1
- package/src/runtime/fast-refresh-runtime.ts +322 -0
- package/src/runtime/fast-refresh-types.ts +128 -0
- package/src/runtime/hmr-client.ts +409 -0
- package/src/runtime/http-errors.ts +113 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/logger.ts +678 -677
- package/src/runtime/not-found.ts +93 -0
- package/src/runtime/redirect.ts +133 -0
- package/src/runtime/server.ts +518 -20
- package/src/runtime/ssr.ts +340 -10
- package/src/runtime/streaming-ssr.ts +222 -19
- package/src/scheduler/__tests__/scheduler.test.ts +514 -0
- package/src/scheduler/index.ts +343 -0
- package/src/storage/s3/__tests__/s3.test.ts +479 -0
- package/src/storage/s3/index.ts +412 -0
- package/src/testing/index.ts +58 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 7.2 R1 Agent C (H2) — BundleManifest schema validation tests.
|
|
3
|
+
*
|
|
4
|
+
* Covers the primary Medium-severity finding from the Phase 7.1 audit
|
|
5
|
+
* (`docs/security/phase-7-1-audit.md` §M-02): tampered manifest injection
|
|
6
|
+
* via `shared.fastRefresh.{glue,runtime}` URL replacement. The schema
|
|
7
|
+
* now rejects such tampers at load time; the contract asserted below
|
|
8
|
+
* pins the precise shape so regressions fail fast.
|
|
9
|
+
*
|
|
10
|
+
* References:
|
|
11
|
+
* docs/security/phase-7-1-audit.md §M-02
|
|
12
|
+
* docs/bun/phase-7-2-team-plan.md §3 Agent C H2
|
|
13
|
+
* packages/core/src/bundler/manifest-schema.ts
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
import {
|
|
18
|
+
BundleManifestSchema,
|
|
19
|
+
MAX_MANIFEST_URL_LEN,
|
|
20
|
+
ManifestValidationError,
|
|
21
|
+
SAFE_MANDU_URL_REGEX,
|
|
22
|
+
isSafeManduUrl,
|
|
23
|
+
safeValidateBundleManifest,
|
|
24
|
+
validateBundleManifest,
|
|
25
|
+
} from "../manifest-schema";
|
|
26
|
+
|
|
27
|
+
/** Known-good manifest, used as the basis for mutation tests. */
|
|
28
|
+
const VALID_MANIFEST = {
|
|
29
|
+
version: 1,
|
|
30
|
+
buildTime: "2026-04-19T00:00:00.000Z",
|
|
31
|
+
env: "development" as const,
|
|
32
|
+
bundles: {
|
|
33
|
+
page: {
|
|
34
|
+
js: "/.mandu/client/page.js",
|
|
35
|
+
dependencies: [],
|
|
36
|
+
priority: "visible" as const,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
shared: {
|
|
40
|
+
runtime: "/.mandu/client/_runtime.js",
|
|
41
|
+
vendor: "/.mandu/client/_vendor-react.js",
|
|
42
|
+
fastRefresh: {
|
|
43
|
+
runtime: "/.mandu/client/_fast-refresh-runtime.js",
|
|
44
|
+
glue: "/.mandu/client/_vendor-react-refresh.js",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
50
|
+
// Section A — primitive URL predicate
|
|
51
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
52
|
+
|
|
53
|
+
describe("manifest-schema — isSafeManduUrl", () => {
|
|
54
|
+
test("[A1] accepts bundler-shape URLs", () => {
|
|
55
|
+
expect(isSafeManduUrl("/.mandu/client/page.js")).toBe(true);
|
|
56
|
+
expect(isSafeManduUrl("/.mandu/client/_vendor-react.js")).toBe(true);
|
|
57
|
+
expect(isSafeManduUrl("/.mandu/client/nested/island.js")).toBe(true);
|
|
58
|
+
expect(isSafeManduUrl("/.mandu/client/styles.css")).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("[A2] rejects absolute URLs with protocol", () => {
|
|
62
|
+
expect(isSafeManduUrl("https://evil.example.com/steal.js")).toBe(false);
|
|
63
|
+
expect(isSafeManduUrl("http://evil.example.com/x.js")).toBe(false);
|
|
64
|
+
expect(isSafeManduUrl("data:application/javascript,alert(1)")).toBe(false);
|
|
65
|
+
expect(isSafeManduUrl("javascript:alert(1)")).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("[A3] rejects path traversal", () => {
|
|
69
|
+
expect(isSafeManduUrl("/.mandu/client/../secret.js")).toBe(false);
|
|
70
|
+
expect(isSafeManduUrl("/.mandu/client/a/../b.js")).toBe(false);
|
|
71
|
+
expect(isSafeManduUrl("/.mandu/..//client/page.js")).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("[A4] rejects empty / overlong / wrong-shape", () => {
|
|
75
|
+
expect(isSafeManduUrl("")).toBe(false);
|
|
76
|
+
expect(isSafeManduUrl("x".repeat(MAX_MANIFEST_URL_LEN + 1))).toBe(false);
|
|
77
|
+
expect(isSafeManduUrl("/not-mandu/client/page.js")).toBe(false);
|
|
78
|
+
expect(isSafeManduUrl("/.mandu/client/page.txt")).toBe(false);
|
|
79
|
+
expect(isSafeManduUrl("page.js")).toBe(false); // relative
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("[A5] rejects control / special characters", () => {
|
|
83
|
+
expect(isSafeManduUrl("/.mandu/client/page\n.js")).toBe(false);
|
|
84
|
+
expect(isSafeManduUrl("/.mandu/client/page<script>.js")).toBe(false);
|
|
85
|
+
expect(isSafeManduUrl('/.mandu/client/page".js')).toBe(false);
|
|
86
|
+
expect(isSafeManduUrl("/.mandu/client/page\\.js")).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("[A6] non-string input returns false (Zod pre-check)", () => {
|
|
90
|
+
expect(isSafeManduUrl(123)).toBe(false);
|
|
91
|
+
expect(isSafeManduUrl(null)).toBe(false);
|
|
92
|
+
expect(isSafeManduUrl(undefined)).toBe(false);
|
|
93
|
+
expect(isSafeManduUrl({})).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("[A7] SAFE_MANDU_URL_REGEX anchors correctly", () => {
|
|
97
|
+
// Must match start AND end — no embedded matches permitted
|
|
98
|
+
expect(SAFE_MANDU_URL_REGEX.test("/.mandu/client/page.js")).toBe(true);
|
|
99
|
+
expect(SAFE_MANDU_URL_REGEX.test("/prefix/.mandu/client/page.js")).toBe(false);
|
|
100
|
+
expect(SAFE_MANDU_URL_REGEX.test("/.mandu/client/page.js?t=1")).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
105
|
+
// Section B — full manifest validation
|
|
106
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
107
|
+
|
|
108
|
+
describe("manifest-schema — validateBundleManifest", () => {
|
|
109
|
+
test("[B1] accepts a valid dev manifest", () => {
|
|
110
|
+
const ok = validateBundleManifest(VALID_MANIFEST);
|
|
111
|
+
expect(ok.version).toBe(1);
|
|
112
|
+
expect(ok.shared.fastRefresh?.glue).toBe("/.mandu/client/_vendor-react-refresh.js");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("[B2] rejects tampered shared.fastRefresh.glue URL (https://evil.example.com)", () => {
|
|
116
|
+
const tampered = {
|
|
117
|
+
...VALID_MANIFEST,
|
|
118
|
+
shared: {
|
|
119
|
+
...VALID_MANIFEST.shared,
|
|
120
|
+
fastRefresh: {
|
|
121
|
+
runtime: "/.mandu/client/_fast-refresh-runtime.js",
|
|
122
|
+
glue: "https://evil.example.com/steal.js",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
expect(() => validateBundleManifest(tampered)).toThrow(ManifestValidationError);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("[B3] rejects tampered shared.fastRefresh.runtime URL (data: scheme)", () => {
|
|
130
|
+
const tampered = {
|
|
131
|
+
...VALID_MANIFEST,
|
|
132
|
+
shared: {
|
|
133
|
+
...VALID_MANIFEST.shared,
|
|
134
|
+
fastRefresh: {
|
|
135
|
+
runtime: "data:application/javascript;base64,YWxlcnQoMSk=",
|
|
136
|
+
glue: "/.mandu/client/_vendor-react-refresh.js",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
expect(() => validateBundleManifest(tampered)).toThrow(ManifestValidationError);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("[B4] rejects missing version", () => {
|
|
144
|
+
const invalid = { ...VALID_MANIFEST };
|
|
145
|
+
delete (invalid as Record<string, unknown>).version;
|
|
146
|
+
expect(() => validateBundleManifest(invalid)).toThrow(ManifestValidationError);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("[B5] rejects missing shared block", () => {
|
|
150
|
+
const invalid = { ...VALID_MANIFEST };
|
|
151
|
+
delete (invalid as Record<string, unknown>).shared;
|
|
152
|
+
expect(() => validateBundleManifest(invalid)).toThrow(ManifestValidationError);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("[B6] rejects unknown top-level keys (strict mode catches schema drift)", () => {
|
|
156
|
+
const withExtra = {
|
|
157
|
+
...VALID_MANIFEST,
|
|
158
|
+
evilField: { steal: "everything" },
|
|
159
|
+
};
|
|
160
|
+
expect(() => validateBundleManifest(withExtra)).toThrow(ManifestValidationError);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("[B7] rejects negative / zero / non-integer version", () => {
|
|
164
|
+
expect(() => validateBundleManifest({ ...VALID_MANIFEST, version: 0 })).toThrow();
|
|
165
|
+
expect(() => validateBundleManifest({ ...VALID_MANIFEST, version: -1 })).toThrow();
|
|
166
|
+
expect(() => validateBundleManifest({ ...VALID_MANIFEST, version: 1.5 })).toThrow();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("[B8] rejects traversal in bundles[].js", () => {
|
|
170
|
+
const bad = {
|
|
171
|
+
...VALID_MANIFEST,
|
|
172
|
+
bundles: {
|
|
173
|
+
page: {
|
|
174
|
+
js: "/.mandu/client/../evil.js",
|
|
175
|
+
dependencies: [],
|
|
176
|
+
priority: "visible" as const,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
expect(() => validateBundleManifest(bad)).toThrow(ManifestValidationError);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("[B9] rejects overlong URL in shared.runtime", () => {
|
|
184
|
+
const bad = {
|
|
185
|
+
...VALID_MANIFEST,
|
|
186
|
+
shared: {
|
|
187
|
+
...VALID_MANIFEST.shared,
|
|
188
|
+
runtime: "/.mandu/client/" + "a".repeat(MAX_MANIFEST_URL_LEN) + ".js",
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
expect(() => validateBundleManifest(bad)).toThrow(ManifestValidationError);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("[B10] accepts manifest without optional fastRefresh (prod shape)", () => {
|
|
195
|
+
const prodish = {
|
|
196
|
+
...VALID_MANIFEST,
|
|
197
|
+
env: "production" as const,
|
|
198
|
+
shared: {
|
|
199
|
+
runtime: VALID_MANIFEST.shared.runtime,
|
|
200
|
+
vendor: VALID_MANIFEST.shared.vendor,
|
|
201
|
+
// no fastRefresh
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
const ok = validateBundleManifest(prodish);
|
|
205
|
+
expect(ok.shared.fastRefresh).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("[B11] ManifestValidationError carries structured issue list", () => {
|
|
209
|
+
const tampered = {
|
|
210
|
+
...VALID_MANIFEST,
|
|
211
|
+
shared: {
|
|
212
|
+
...VALID_MANIFEST.shared,
|
|
213
|
+
fastRefresh: {
|
|
214
|
+
runtime: "https://evil.example.com/r.js",
|
|
215
|
+
glue: "https://evil.example.com/g.js",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
try {
|
|
220
|
+
validateBundleManifest(tampered);
|
|
221
|
+
throw new Error("should have thrown");
|
|
222
|
+
} catch (err) {
|
|
223
|
+
expect(err).toBeInstanceOf(ManifestValidationError);
|
|
224
|
+
const verr = err as ManifestValidationError;
|
|
225
|
+
expect(verr.issues.length).toBeGreaterThanOrEqual(2);
|
|
226
|
+
// Both tampered URLs must be reported
|
|
227
|
+
const paths = verr.issues.map((i) => i.path).join(" ");
|
|
228
|
+
expect(paths).toContain("shared.fastRefresh.runtime");
|
|
229
|
+
expect(paths).toContain("shared.fastRefresh.glue");
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
235
|
+
// Section C — safeValidateBundleManifest (non-throwing)
|
|
236
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
237
|
+
|
|
238
|
+
describe("manifest-schema — safeValidateBundleManifest", () => {
|
|
239
|
+
test("[C1] ok:true with a valid manifest", () => {
|
|
240
|
+
const r = safeValidateBundleManifest(VALID_MANIFEST);
|
|
241
|
+
expect(r.ok).toBe(true);
|
|
242
|
+
if (r.ok) {
|
|
243
|
+
expect(r.manifest.shared.fastRefresh?.glue).toBe(
|
|
244
|
+
"/.mandu/client/_vendor-react-refresh.js",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("[C2] ok:false with issues list on invalid manifest", () => {
|
|
250
|
+
const bad = { ...VALID_MANIFEST, version: "not a number" as unknown };
|
|
251
|
+
const r = safeValidateBundleManifest(bad);
|
|
252
|
+
expect(r.ok).toBe(false);
|
|
253
|
+
if (!r.ok) {
|
|
254
|
+
expect(r.issues.length).toBeGreaterThan(0);
|
|
255
|
+
expect(r.issues[0].path).toBe("version");
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("[C3] BundleManifestSchema.safeParse matches the public validator", () => {
|
|
260
|
+
// Sanity: both paths agree on accept/reject for the same input
|
|
261
|
+
const rp = BundleManifestSchema.safeParse(VALID_MANIFEST);
|
|
262
|
+
expect(rp.success).toBe(true);
|
|
263
|
+
const rv = safeValidateBundleManifest(VALID_MANIFEST);
|
|
264
|
+
expect(rv.ok).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 7.2 R1 Agent C (H4) — production bundle smoke tests.
|
|
3
|
+
*
|
|
4
|
+
* Confirms the Phase 7.1 audit finding L-02 regression is closed: a
|
|
5
|
+
* production build (`minify: true`, no Fast Refresh plugin) must NEVER
|
|
6
|
+
* leak the dev-only symbols that would otherwise install Fast Refresh
|
|
7
|
+
* stubs on `window` (`$RefreshReg$` / `$RefreshSig$` / `__MANDU_HMR__`)
|
|
8
|
+
* or pull in the vendor runtime (`_vendor-react-refresh`).
|
|
9
|
+
*
|
|
10
|
+
* We assert at two layers:
|
|
11
|
+
* (A) the generated output JS bundle is free of the dev symbols
|
|
12
|
+
* (B) the returned manifest has `shared.fastRefresh === undefined`
|
|
13
|
+
*
|
|
14
|
+
* Both layers matter because (A) can drift independently of (B) if a
|
|
15
|
+
* future refactor inlines a symbol name into a comment or data literal.
|
|
16
|
+
*
|
|
17
|
+
* References:
|
|
18
|
+
* docs/security/phase-7-1-audit.md §3 L-02
|
|
19
|
+
* docs/bun/phase-7-2-team-plan.md §3 Agent C H4
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
23
|
+
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
|
|
24
|
+
import fs from "fs/promises";
|
|
25
|
+
import { tmpdir } from "os";
|
|
26
|
+
import path from "path";
|
|
27
|
+
import { fastRefreshPlugin } from "../fast-refresh-plugin";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Strings we treat as dev-only Fast Refresh fingerprints. Any of them
|
|
31
|
+
* appearing in a prod bundle is a leak. We keep the list small and
|
|
32
|
+
* precise so we don't false-flag legitimate unrelated code.
|
|
33
|
+
*/
|
|
34
|
+
const DEV_LEAK_FINGERPRINTS = [
|
|
35
|
+
"$RefreshReg$",
|
|
36
|
+
"$RefreshSig$",
|
|
37
|
+
"__MANDU_HMR__",
|
|
38
|
+
"_vendor-react-refresh",
|
|
39
|
+
"_fast-refresh-runtime",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
// Integration tests that spin up Bun.build are gated on CI flaky-matrix
|
|
43
|
+
// environments. See `fast-refresh.test.ts` for the same pattern.
|
|
44
|
+
describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
|
|
45
|
+
"prod-smoke — no Fast Refresh leak in production bundles",
|
|
46
|
+
() => {
|
|
47
|
+
let rootDir = "";
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
rootDir = await mkdtemp(path.join(tmpdir(), "mandu-prod-smoke-"));
|
|
51
|
+
// Minimal island source — no JSX runtime, no imports, small AST
|
|
52
|
+
await writeFile(
|
|
53
|
+
path.join(rootDir, "counter.client.tsx"),
|
|
54
|
+
`import { useState } from 'react';
|
|
55
|
+
export default function Counter() {
|
|
56
|
+
const [count, setCount] = useState(0);
|
|
57
|
+
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
|
|
58
|
+
}
|
|
59
|
+
`,
|
|
60
|
+
"utf-8",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(async () => {
|
|
65
|
+
if (rootDir) await rm(rootDir, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("[S1] production build output contains no dev-only Fast Refresh symbols", async () => {
|
|
69
|
+
// Prod path: `reactFastRefresh` off, plugin `disabled: true`,
|
|
70
|
+
// minify on. Matches the config `buildClientBundles` uses when
|
|
71
|
+
// `options.minify === true`.
|
|
72
|
+
const result = await Bun.build({
|
|
73
|
+
entrypoints: [path.join(rootDir, "counter.client.tsx")],
|
|
74
|
+
target: "browser",
|
|
75
|
+
minify: true,
|
|
76
|
+
plugins: [fastRefreshPlugin({ disabled: true })],
|
|
77
|
+
external: ["react", "react-dom", "react/jsx-dev-runtime", "react/jsx-runtime"],
|
|
78
|
+
});
|
|
79
|
+
expect(result.success).toBe(true);
|
|
80
|
+
|
|
81
|
+
const src = await result.outputs[0]!.text();
|
|
82
|
+
for (const fingerprint of DEV_LEAK_FINGERPRINTS) {
|
|
83
|
+
expect(src.includes(fingerprint)).toBe(false);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("[S2] prod build with reactFastRefresh=true explicitly disabled does not emit stubs", async () => {
|
|
88
|
+
// Defense-in-depth: even if a future config accident leaves
|
|
89
|
+
// `reactFastRefresh: false` off-by-default, the disabled plugin
|
|
90
|
+
// MUST still prevent injection. We pass reactFastRefresh: false
|
|
91
|
+
// here to represent the guaranteed-prod config.
|
|
92
|
+
const result = await Bun.build({
|
|
93
|
+
entrypoints: [path.join(rootDir, "counter.client.tsx")],
|
|
94
|
+
target: "browser",
|
|
95
|
+
minify: true,
|
|
96
|
+
reactFastRefresh: false,
|
|
97
|
+
plugins: [fastRefreshPlugin({ disabled: true })],
|
|
98
|
+
external: ["react", "react-dom", "react/jsx-dev-runtime", "react/jsx-runtime"],
|
|
99
|
+
});
|
|
100
|
+
expect(result.success).toBe(true);
|
|
101
|
+
|
|
102
|
+
const src = await result.outputs[0]!.text();
|
|
103
|
+
expect(src).not.toContain("$RefreshReg$");
|
|
104
|
+
expect(src).not.toContain("$RefreshSig$");
|
|
105
|
+
expect(src).not.toContain("__MANDU_HMR__");
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Pure manifest-shape tests run regardless of the bundler skip flag —
|
|
111
|
+
// they don't invoke Bun.build and are safe everywhere.
|
|
112
|
+
describe("prod-smoke — manifest shape invariants (no Bun.build)", () => {
|
|
113
|
+
test("[M1] buildClientBundles prod path would omit shared.fastRefresh", async () => {
|
|
114
|
+
// We cannot easily invoke `buildClientBundles` here without a
|
|
115
|
+
// full Mandu project scaffold, so we assert the contract
|
|
116
|
+
// semantically via the static build.ts source: the `fastRefresh`
|
|
117
|
+
// field is populated only when the vendor shim result has both
|
|
118
|
+
// `reactRefreshRuntime` and `fastRefreshRuntime`. In prod mode
|
|
119
|
+
// `buildVendorShims` is called with `isDev: false` which returns
|
|
120
|
+
// empty strings for those — unwinding to `undefined` in the
|
|
121
|
+
// ternary at `build.ts:1548-1554`.
|
|
122
|
+
//
|
|
123
|
+
// This test documents the invariant textually; the bundler-level
|
|
124
|
+
// integration tests (S1/S2 above) prove the output side.
|
|
125
|
+
const buildSource = await fs.readFile(
|
|
126
|
+
path.join(
|
|
127
|
+
import.meta.dir,
|
|
128
|
+
"..",
|
|
129
|
+
"build.ts",
|
|
130
|
+
),
|
|
131
|
+
"utf-8",
|
|
132
|
+
);
|
|
133
|
+
// The precise construction MUST be guarded by runtime presence.
|
|
134
|
+
expect(buildSource).toContain("vendorResult.reactRefreshRuntime && vendorResult.fastRefreshRuntime");
|
|
135
|
+
// And MUST spread-conditionally include the field.
|
|
136
|
+
expect(buildSource).toContain("...(fastRefresh ? { fastRefresh } : {})");
|
|
137
|
+
});
|
|
138
|
+
});
|