@bractjs/bractjs 0.1.5 → 0.1.7

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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/action-handler.test.ts +47 -0
  3. package/src/__tests__/action-registry.test.ts +73 -0
  4. package/src/__tests__/codegen.test.ts +50 -0
  5. package/src/__tests__/deferred.test.ts +96 -0
  6. package/src/__tests__/directives.test.ts +52 -0
  7. package/src/__tests__/env.test.ts +73 -0
  8. package/src/__tests__/errors.test.ts +113 -0
  9. package/src/__tests__/hash.test.ts +19 -0
  10. package/src/__tests__/integration.test.ts +1 -1
  11. package/src/__tests__/loader.test.ts +5 -2
  12. package/src/__tests__/manifest.test.ts +60 -0
  13. package/src/__tests__/middleware.test.ts +216 -0
  14. package/src/__tests__/response.test.ts +106 -0
  15. package/src/__tests__/security.test.ts +348 -0
  16. package/src/__tests__/session.test.ts +3 -3
  17. package/src/adapters/cloudflare.ts +65 -0
  18. package/src/build/bundler.ts +17 -6
  19. package/src/build/directives.ts +30 -3
  20. package/src/build/env-plugin.ts +8 -0
  21. package/src/build/hash.ts +0 -20
  22. package/src/build/plugins/css-modules.ts +110 -0
  23. package/src/client/ClientRouter.tsx +121 -13
  24. package/src/client/cache.ts +69 -0
  25. package/src/client/components/Link.tsx +16 -2
  26. package/src/client/components/LiveReload.tsx +4 -0
  27. package/src/client/hooks/useBlocker.ts +44 -0
  28. package/src/client/hooks/useFetcher.ts +66 -6
  29. package/src/client/hooks/useLocale.ts +12 -0
  30. package/src/client/hooks/useLocalizedLink.ts +18 -0
  31. package/src/client/hooks/useSearchParams.ts +74 -0
  32. package/src/client/rpc.ts +70 -0
  33. package/src/codegen/route-codegen.ts +96 -10
  34. package/src/dev/devtools.ts +144 -0
  35. package/src/dev/hmr-client.ts +14 -0
  36. package/src/dev/hmr-module-handler.ts +31 -5
  37. package/src/dev/hmr-server.ts +16 -0
  38. package/src/image/cache.ts +28 -8
  39. package/src/image/handler.ts +31 -13
  40. package/src/image/optimizer.ts +51 -14
  41. package/src/image/types.ts +1 -0
  42. package/src/index.ts +27 -0
  43. package/src/middleware/cors.ts +28 -8
  44. package/src/middleware/requestLogger.ts +4 -0
  45. package/src/server/action-handler.ts +45 -2
  46. package/src/server/action-registry.ts +14 -1
  47. package/src/server/adapter.ts +57 -0
  48. package/src/server/api-route.ts +127 -0
  49. package/src/server/context.ts +22 -0
  50. package/src/server/csrf.ts +17 -0
  51. package/src/server/env.ts +26 -4
  52. package/src/server/i18n.ts +63 -0
  53. package/src/server/loader.ts +61 -1
  54. package/src/server/middleware.ts +11 -7
  55. package/src/server/render.ts +14 -5
  56. package/src/server/request-handler.ts +77 -18
  57. package/src/server/response.ts +29 -5
  58. package/src/server/scanner.ts +6 -2
  59. package/src/server/serve.ts +102 -55
  60. package/src/server/session.ts +17 -5
  61. package/src/server/static.ts +31 -8
  62. package/src/server/stream-handler.ts +111 -0
  63. package/src/server/validate.ts +89 -0
  64. package/src/shared/route-types.ts +11 -0
  65. package/types/index.d.ts +94 -1
  66. package/types/route.d.ts +11 -0
@@ -0,0 +1,216 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { MiddlewarePipeline } from "../server/middleware.ts";
3
+ import type { MiddlewareContext } from "../server/middleware.ts";
4
+ import { cors } from "../middleware/cors.ts";
5
+ import { authGuard } from "../middleware/authGuard.ts";
6
+ import { requestLogger } from "../middleware/requestLogger.ts";
7
+
8
+ // ── Helpers ────────────────────────────────────────────────────────────────
9
+
10
+ function makeCtx(override: Partial<MiddlewareContext> = {}): MiddlewareContext {
11
+ return {
12
+ request: new Request("http://localhost/"),
13
+ params: {},
14
+ context: {},
15
+ ...override,
16
+ };
17
+ }
18
+
19
+ const ok200 = async () => new Response("ok", { status: 200 });
20
+
21
+ // ── MiddlewarePipeline ─────────────────────────────────────────────────────
22
+
23
+ describe("MiddlewarePipeline", () => {
24
+ test("calls handler when no middleware registered", async () => {
25
+ const pipeline = new MiddlewarePipeline();
26
+ const res = await pipeline.run(makeCtx(), ok200);
27
+ expect(res.status).toBe(200);
28
+ });
29
+
30
+ test("runs middleware in registration order", async () => {
31
+ const order: number[] = [];
32
+ const pipeline = new MiddlewarePipeline();
33
+ pipeline
34
+ .use(async (ctx, next) => { order.push(1); const r = await next(); order.push(4); return r; })
35
+ .use(async (ctx, next) => { order.push(2); const r = await next(); order.push(3); return r; });
36
+
37
+ await pipeline.run(makeCtx(), ok200);
38
+ expect(order).toEqual([1, 2, 3, 4]);
39
+ });
40
+
41
+ test("middleware can short-circuit without calling next()", async () => {
42
+ const pipeline = new MiddlewarePipeline();
43
+ pipeline.use(async () => new Response("blocked", { status: 403 }));
44
+ const res = await pipeline.run(makeCtx(), ok200);
45
+ expect(res.status).toBe(403);
46
+ expect(await res.text()).toBe("blocked");
47
+ });
48
+
49
+ test("middleware can modify the response", async () => {
50
+ const pipeline = new MiddlewarePipeline();
51
+ pipeline.use(async (ctx, next) => {
52
+ const res = await next();
53
+ const patched = new Response(res.body, res);
54
+ patched.headers.set("X-Test", "injected");
55
+ return patched;
56
+ });
57
+ const res = await pipeline.run(makeCtx(), ok200);
58
+ expect(res.headers.get("X-Test")).toBe("injected");
59
+ });
60
+
61
+ test("use() is chainable", () => {
62
+ const pipeline = new MiddlewarePipeline();
63
+ const ret = pipeline.use(async (_, next) => next());
64
+ expect(ret).toBe(pipeline);
65
+ });
66
+
67
+ test("multiple pipelines are independent", async () => {
68
+ const p1 = new MiddlewarePipeline();
69
+ const p2 = new MiddlewarePipeline();
70
+ p1.use(async () => new Response("p1", { status: 201 }));
71
+ const r1 = await p1.run(makeCtx(), ok200);
72
+ const r2 = await p2.run(makeCtx(), ok200);
73
+ expect(r1.status).toBe(201);
74
+ expect(r2.status).toBe(200);
75
+ });
76
+ });
77
+
78
+ // ── cors() ─────────────────────────────────────────────────────────────────
79
+
80
+ describe("cors()", () => {
81
+ test("adds CORS headers to regular requests for allowed origin", async () => {
82
+ const mw = cors({ origin: "https://example.com" });
83
+ const ctx = makeCtx({ request: new Request("http://localhost/", { headers: { Origin: "https://example.com" } }) });
84
+ const pipeline = new MiddlewarePipeline();
85
+ pipeline.use(mw);
86
+ const res = await pipeline.run(ctx, ok200);
87
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://example.com");
88
+ });
89
+
90
+ test("wildcard origin allows any origin", async () => {
91
+ const mw = cors({ origin: "*" });
92
+ const ctx = makeCtx({ request: new Request("http://localhost/", { headers: { Origin: "https://any.com" } }) });
93
+ const pipeline = new MiddlewarePipeline();
94
+ pipeline.use(mw);
95
+ const res = await pipeline.run(ctx, ok200);
96
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBeTruthy();
97
+ });
98
+
99
+ test("responds 204 to OPTIONS preflight", async () => {
100
+ const mw = cors({ origin: "*" });
101
+ const ctx = makeCtx({ request: new Request("http://localhost/", { method: "OPTIONS" }) });
102
+ const pipeline = new MiddlewarePipeline();
103
+ pipeline.use(mw);
104
+ const res = await pipeline.run(ctx, ok200);
105
+ expect(res.status).toBe(204);
106
+ });
107
+
108
+ test("does not set CORS header for disallowed origin", async () => {
109
+ const mw = cors({ origin: "https://allowed.com" });
110
+ const ctx = makeCtx({ request: new Request("http://localhost/", { headers: { Origin: "https://evil.com" } }) });
111
+ const pipeline = new MiddlewarePipeline();
112
+ pipeline.use(mw);
113
+ const res = await pipeline.run(ctx, ok200);
114
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
115
+ });
116
+
117
+ test("accepts array of origins", async () => {
118
+ const mw = cors({ origin: ["https://a.com", "https://b.com"] });
119
+ const ctx = makeCtx({ request: new Request("http://localhost/", { headers: { Origin: "https://b.com" } }) });
120
+ const pipeline = new MiddlewarePipeline();
121
+ pipeline.use(mw);
122
+ const res = await pipeline.run(ctx, ok200);
123
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://b.com");
124
+ });
125
+
126
+ test("sets Access-Control-Allow-Methods header", async () => {
127
+ const mw = cors({ origin: "*", methods: ["GET", "POST"] });
128
+ const ctx = makeCtx({ request: new Request("http://localhost/", { method: "OPTIONS" }) });
129
+ const pipeline = new MiddlewarePipeline();
130
+ pipeline.use(mw);
131
+ const res = await pipeline.run(ctx, ok200);
132
+ expect(res.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST");
133
+ });
134
+ });
135
+
136
+ // ── authGuard() ───────────────────────────────────────────────────────────
137
+
138
+ describe("authGuard()", () => {
139
+ function makeSession(user: unknown) {
140
+ return {
141
+ getSession: async () => ({ get: (key: string) => (key === "user" ? user : undefined) }),
142
+ };
143
+ }
144
+
145
+ test("sets ctx.context.user from session", async () => {
146
+ const mw = authGuard({ session: makeSession({ id: 1 }) });
147
+ const ctx = makeCtx();
148
+ const pipeline = new MiddlewarePipeline();
149
+ pipeline.use(mw);
150
+ await pipeline.run(ctx, ok200);
151
+ expect(ctx.context.user).toEqual({ id: 1 });
152
+ });
153
+
154
+ test("sets ctx.context.user to null when no session user", async () => {
155
+ const mw = authGuard({ session: makeSession(undefined) });
156
+ const ctx = makeCtx();
157
+ const pipeline = new MiddlewarePipeline();
158
+ pipeline.use(mw);
159
+ await pipeline.run(ctx, ok200);
160
+ expect(ctx.context.user).toBeNull();
161
+ });
162
+
163
+ test("required=true returns 401 when no user", async () => {
164
+ const mw = authGuard({ session: makeSession(undefined), required: true });
165
+ const pipeline = new MiddlewarePipeline();
166
+ pipeline.use(mw);
167
+ const res = await pipeline.run(makeCtx(), ok200);
168
+ expect(res.status).toBe(401);
169
+ const body = await res.json() as { error: string };
170
+ expect(body.error).toBe("Unauthorized");
171
+ });
172
+
173
+ test("required=true allows request when user is present", async () => {
174
+ const mw = authGuard({ session: makeSession({ id: 99 }), required: true });
175
+ const pipeline = new MiddlewarePipeline();
176
+ pipeline.use(mw);
177
+ const res = await pipeline.run(makeCtx(), ok200);
178
+ expect(res.status).toBe(200);
179
+ });
180
+
181
+ test("required=false (default) allows unauthenticated request", async () => {
182
+ const mw = authGuard({ session: makeSession(undefined), required: false });
183
+ const pipeline = new MiddlewarePipeline();
184
+ pipeline.use(mw);
185
+ const res = await pipeline.run(makeCtx(), ok200);
186
+ expect(res.status).toBe(200);
187
+ });
188
+ });
189
+
190
+ // ── requestLogger() ───────────────────────────────────────────────────────
191
+
192
+ describe("requestLogger()", () => {
193
+ test("passes the response through unchanged", async () => {
194
+ const mw = requestLogger();
195
+ const pipeline = new MiddlewarePipeline();
196
+ pipeline.use(mw);
197
+ const res = await pipeline.run(makeCtx(), ok200);
198
+ expect(res.status).toBe(200);
199
+ expect(await res.text()).toBe("ok");
200
+ });
201
+
202
+ test("logs path and status to console (smoke test)", async () => {
203
+ const logs: string[] = [];
204
+ const original = console.log;
205
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
206
+ const mw = requestLogger();
207
+ const ctx = makeCtx({ request: new Request("http://localhost/hello") });
208
+ const pipeline = new MiddlewarePipeline();
209
+ pipeline.use(mw);
210
+ await pipeline.run(ctx, ok200);
211
+ console.log = original;
212
+ expect(logs.length).toBeGreaterThan(0);
213
+ expect(logs[0]).toContain("/hello");
214
+ expect(logs[0]).toContain("200");
215
+ });
216
+ });
@@ -0,0 +1,106 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { redirect, json, error } from "../server/response.ts";
3
+
4
+ describe("redirect", () => {
5
+ test("returns 302 by default with Location header", () => {
6
+ const res = redirect("/dashboard");
7
+ expect(res.status).toBe(302);
8
+ expect(res.headers.get("Location")).toBe("/dashboard");
9
+ });
10
+
11
+ test("accepts custom status codes (301, 307, 308)", () => {
12
+ expect(redirect("/old", 301).status).toBe(301);
13
+ expect(redirect("/tmp", 307).status).toBe(307);
14
+ expect(redirect("/perm", 308).status).toBe(308);
15
+ });
16
+
17
+ test("body is null", async () => {
18
+ const res = redirect("/");
19
+ expect(await res.text()).toBe("");
20
+ });
21
+
22
+ describe("open-redirect guard", () => {
23
+ test("rejects absolute http(s) URLs", () => {
24
+ expect(() => redirect("https://evil.com/")).toThrow();
25
+ expect(() => redirect("http://evil.com")).toThrow();
26
+ });
27
+
28
+ test("rejects protocol-relative URLs (//evil.com)", () => {
29
+ expect(() => redirect("//evil.com/path")).toThrow();
30
+ });
31
+
32
+ test("rejects backslash protocol-relative variant", () => {
33
+ expect(() => redirect("/\\evil.com")).toThrow();
34
+ });
35
+
36
+ test("rejects javascript: and data: schemes", () => {
37
+ expect(() => redirect("javascript:alert(1)")).toThrow();
38
+ expect(() => redirect("data:text/html,x")).toThrow();
39
+ });
40
+
41
+ test("allows same-path redirects", () => {
42
+ expect(redirect("/foo").headers.get("Location")).toBe("/foo");
43
+ expect(redirect("/foo?q=1#x").headers.get("Location")).toBe("/foo?q=1#x");
44
+ });
45
+
46
+ test("opt-in: allowExternal lets through absolute URL", () => {
47
+ const res = redirect("https://allowed.example/path", 302, undefined, { allowExternal: true });
48
+ expect(res.headers.get("Location")).toBe("https://allowed.example/path");
49
+ });
50
+ });
51
+ });
52
+
53
+ describe("json", () => {
54
+ test("sets Content-Type to application/json", () => {
55
+ const res = json({ ok: true });
56
+ expect(res.headers.get("Content-Type")).toContain("application/json");
57
+ });
58
+
59
+ test("serializes data to JSON body", async () => {
60
+ const res = json({ name: "bract", version: 1 });
61
+ const body = await res.json() as { name: string; version: number };
62
+ expect(body.name).toBe("bract");
63
+ expect(body.version).toBe(1);
64
+ });
65
+
66
+ test("defaults to 200 status", () => {
67
+ expect(json({}).status).toBe(200);
68
+ });
69
+
70
+ test("respects custom status in init", () => {
71
+ expect(json({}, { status: 201 }).status).toBe(201);
72
+ });
73
+
74
+ test("merges custom headers while preserving Content-Type", () => {
75
+ const res = json({}, { headers: { "X-Custom": "yes" } });
76
+ expect(res.headers.get("X-Custom")).toBe("yes");
77
+ expect(res.headers.get("Content-Type")).toContain("application/json");
78
+ });
79
+
80
+ test("serializes arrays", async () => {
81
+ const res = json([1, 2, 3]);
82
+ const body = await res.json() as number[];
83
+ expect(body).toEqual([1, 2, 3]);
84
+ });
85
+
86
+ test("serializes null", async () => {
87
+ const res = json(null);
88
+ expect(await res.text()).toBe("null");
89
+ });
90
+ });
91
+
92
+ describe("error", () => {
93
+ test("returns 500 by default", () => {
94
+ expect(error("internal").status).toBe(500);
95
+ });
96
+
97
+ test("body is JSON with error field", async () => {
98
+ const res = error("something failed");
99
+ const body = await res.json() as { error: string };
100
+ expect(body.error).toBe("something failed");
101
+ });
102
+
103
+ test("accepts custom status", () => {
104
+ expect(error("not allowed", 403).status).toBe(403);
105
+ });
106
+ });
@@ -0,0 +1,348 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { mkdir, rm, writeFile, symlink } from "node:fs/promises";
3
+ import { resolve, join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { createServer } from "../server/serve.ts";
6
+ import { handleActionRequest } from "../server/action-handler.ts";
7
+ import { loadServerActions } from "../server/action-registry.ts";
8
+ import { safeStringify } from "../server/env.ts";
9
+ import { cors } from "../middleware/cors.ts";
10
+ import { createCookieSession } from "../server/session.ts";
11
+ import { MiddlewarePipeline, type MiddlewareContext } from "../server/middleware.ts";
12
+ import { serveStatic } from "../server/static.ts";
13
+ import { handleImageRequest } from "../image/handler.ts";
14
+
15
+ const ACTION_TMP = resolve(import.meta.dir, ".tmp-security-action");
16
+ let registeredActionId = "";
17
+
18
+ async function computeId(filePath: string, name: string): Promise<string> {
19
+ const raw = new TextEncoder().encode(filePath + "#" + name);
20
+ const buf = await crypto.subtle.digest("SHA-256", raw);
21
+ return Array.from(new Uint8Array(buf))
22
+ .map((b) => b.toString(16).padStart(2, "0"))
23
+ .join("")
24
+ .slice(0, 16);
25
+ }
26
+
27
+ const PORT = 3998;
28
+ const BASE = `http://localhost:${PORT}`;
29
+ const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
30
+
31
+ let handle: ReturnType<typeof createServer>;
32
+
33
+ beforeAll(async () => {
34
+ handle = createServer({
35
+ port: PORT,
36
+ appDir: FIXTURE_APP,
37
+ manifest: { clientEntry: "/build/client/client.js", routes: {} },
38
+ });
39
+
40
+ await rm(ACTION_TMP, { recursive: true, force: true });
41
+ await mkdir(join(ACTION_TMP, "routes"), { recursive: true });
42
+ const actionFile = join(ACTION_TMP, "routes", "_index.tsx");
43
+ await writeFile(actionFile, `"use server";\nexport async function ping(...args) { return args; }\n`);
44
+ await loadServerActions(ACTION_TMP);
45
+ registeredActionId = await computeId(actionFile, "ping");
46
+ });
47
+
48
+ afterAll(async () => {
49
+ handle.stop();
50
+ await rm(ACTION_TMP, { recursive: true, force: true });
51
+ });
52
+
53
+ // ── Item 1 — path traversal ───────────────────────────────────────────────
54
+
55
+ describe("path traversal", () => {
56
+ test("GET /public/../package.json returns 404, not file contents", async () => {
57
+ const res = await fetch(`${BASE}/public/../package.json`);
58
+ expect(res.status).toBe(404);
59
+ const body = await res.text();
60
+ expect(body).not.toContain("@bractjs/bractjs");
61
+ });
62
+ });
63
+
64
+ // ── Item 2 — action arg validation ────────────────────────────────────────
65
+
66
+ describe("action-handler — arg validation", () => {
67
+ test("non-array JSON body → 400", async () => {
68
+ const req = new Request(`http://x/_action?id=${registeredActionId}`, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
71
+ body: JSON.stringify({ foo: "bar" }),
72
+ });
73
+ const res = await handleActionRequest(req);
74
+ expect(res?.status).toBe(400);
75
+ });
76
+
77
+ test("array with __proto__ key → 400", async () => {
78
+ const req = new Request(`http://x/_action?id=${registeredActionId}`, {
79
+ method: "POST",
80
+ headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
81
+ // Use JSON.parse to actually inject __proto__ as an own key
82
+ body: '[{"__proto__":{"polluted":true}}]',
83
+ });
84
+ const res = await handleActionRequest(req);
85
+ expect(res?.status).toBe(400);
86
+ });
87
+
88
+ test("array with constructor key → 400", async () => {
89
+ const req = new Request(`http://x/_action?id=${registeredActionId}`, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
92
+ body: '[{"constructor":1}]',
93
+ });
94
+ const res = await handleActionRequest(req);
95
+ expect(res?.status).toBe(400);
96
+ });
97
+
98
+ test("JSON body > 1 MiB rejected with 413 (advertised via Content-Length)", async () => {
99
+ const huge = "a".repeat(2 * 1024 * 1024);
100
+ const req = new Request(`http://x/_action?id=${registeredActionId}`, {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/json",
104
+ "X-BractJS-Action": "1",
105
+ "Content-Length": String(2 * 1024 * 1024 + 2),
106
+ },
107
+ body: `["${huge}"]`,
108
+ });
109
+ const res = await handleActionRequest(req);
110
+ expect(res?.status).toBe(413);
111
+ });
112
+
113
+ test("JSON body > 1 MiB rejected with 413 even when Content-Length lies", async () => {
114
+ const huge = "a".repeat(2 * 1024 * 1024);
115
+ const req = new Request(`http://x/_action?id=${registeredActionId}`, {
116
+ method: "POST",
117
+ headers: {
118
+ "Content-Type": "application/json",
119
+ "X-BractJS-Action": "1",
120
+ "Content-Length": "10",
121
+ },
122
+ body: `["${huge}"]`,
123
+ });
124
+ const res = await handleActionRequest(req);
125
+ expect(res?.status).toBe(413);
126
+ });
127
+ });
128
+
129
+ // ── Item 3 — CSRF ─────────────────────────────────────────────────────────
130
+
131
+ describe("CSRF — cross-origin mutation", () => {
132
+ test("/_action without X-BractJS-Action and with cross-origin Origin → 403", async () => {
133
+ const req = new Request("http://localhost/_action?id=abc", {
134
+ method: "POST",
135
+ headers: { Origin: "https://evil.example" },
136
+ body: "[]",
137
+ });
138
+ const res = await handleActionRequest(req);
139
+ expect(res?.status).toBe(403);
140
+ });
141
+
142
+ test("/_action with same-origin Origin → not 403 (404 unknown id)", async () => {
143
+ const req = new Request("http://localhost/_action?id=abc", {
144
+ method: "POST",
145
+ headers: { Origin: "http://localhost", "Content-Type": "application/json" },
146
+ body: "[]",
147
+ });
148
+ const res = await handleActionRequest(req);
149
+ expect(res?.status).not.toBe(403);
150
+ });
151
+
152
+ test("route POST with mismatched Origin → 403", async () => {
153
+ const res = await fetch(`${BASE}/`, {
154
+ method: "POST",
155
+ body: new FormData(),
156
+ headers: { Origin: "https://evil.example" },
157
+ });
158
+ expect(res.status).toBe(403);
159
+ });
160
+ });
161
+
162
+ // ── Item 5 — safeStringify ───────────────────────────────────────────────
163
+
164
+ describe("safeStringify", () => {
165
+ test("escapes U+2028 / U+2029", () => {
166
+ const ls = String.fromCharCode(0x2028);
167
+ const ps = String.fromCharCode(0x2029);
168
+ const out = safeStringify({ a: `x${ls}y${ps}z` });
169
+ expect(out).not.toContain(ls);
170
+ expect(out).not.toContain(ps);
171
+ expect(out).toContain("\\u2028");
172
+ expect(out).toContain("\\u2029");
173
+ });
174
+
175
+ test("escapes < > &", () => {
176
+ const out = safeStringify({ x: "<script>&" });
177
+ expect(out).toContain("\\u003c");
178
+ expect(out).toContain("\\u003e");
179
+ expect(out).toContain("\\u0026");
180
+ });
181
+ });
182
+
183
+ // ── Item 6 — session ─────────────────────────────────────────────────────
184
+
185
+ describe("session — secret validation", () => {
186
+ test("empty secrets throws", () => {
187
+ expect(() => createCookieSession({ name: "s", secrets: [] })).toThrow();
188
+ });
189
+
190
+ test("short secret throws", () => {
191
+ expect(() => createCookieSession({ name: "s", secrets: ["short"] })).toThrow();
192
+ });
193
+
194
+ test("valid secret roundtrips", async () => {
195
+ const s = createCookieSession({ name: "s", secrets: ["a-secret-that-is-long-enough-1"] });
196
+ const sess = await s.getSession(null);
197
+ sess.set("k", "v");
198
+ const cookie = await s.commitSession(sess);
199
+ const rt = await s.getSession(cookie.split(";")[0]);
200
+ expect(rt.get("k")).toBe("v");
201
+ });
202
+
203
+ test("tampered signature rejected", async () => {
204
+ const s = createCookieSession({ name: "s", secrets: ["a-secret-that-is-long-enough-1"] });
205
+ const sess = await s.getSession(null);
206
+ sess.set("k", "v");
207
+ const cookie = await s.commitSession(sess);
208
+ const tampered = cookie.replace(/=([^;]+)/, (_, val) => `=${val.slice(0, -1)}X`);
209
+ const rt = await s.getSession(tampered.split(";")[0]);
210
+ expect(rt.has("k")).toBe(false);
211
+ });
212
+ });
213
+
214
+ // ── Item 7 — CORS ────────────────────────────────────────────────────────
215
+
216
+ describe("cors middleware", () => {
217
+ async function runOnce(mw: ReturnType<typeof cors>, req: Request): Promise<Response> {
218
+ const ctx: MiddlewareContext = { request: req, params: {}, context: {} };
219
+ const pipeline = new MiddlewarePipeline();
220
+ pipeline.use(mw);
221
+ return pipeline.run(ctx, () => Promise.resolve(new Response("ok")));
222
+ }
223
+
224
+ test("wildcard never reflects Origin", async () => {
225
+ const mw = cors({ origin: "*" });
226
+ const res = await runOnce(mw, new Request("http://x/", { headers: { Origin: "https://evil.example" } }));
227
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
228
+ });
229
+
230
+ test("always emits Vary: Origin", async () => {
231
+ const mw = cors({ origin: "https://ok.example" });
232
+ const res = await runOnce(mw, new Request("http://x/", { headers: { Origin: "https://ok.example" } }));
233
+ expect(res.headers.get("Vary")).toContain("Origin");
234
+ });
235
+
236
+ test("credentials + wildcard throws at setup", () => {
237
+ expect(() => cors({ origin: "*", credentials: true })).toThrow();
238
+ });
239
+ });
240
+
241
+ // ── Item 8 — image dim validation ────────────────────────────────────────
242
+
243
+ describe("image handler — dim allowlist", () => {
244
+ test("w=999 (not in allowlist) → 400", async () => {
245
+ const res = await fetch(`${BASE}/_image?src=/public/a.jpg&w=999`);
246
+ expect(res.status).toBe(400);
247
+ });
248
+
249
+ test("w=320 (in allowlist) → not 400 (404 because file missing)", async () => {
250
+ const res = await fetch(`${BASE}/_image?src=/public/missing.jpg&w=320`);
251
+ expect(res.status).toBe(404);
252
+ });
253
+
254
+ test("w=3840&h=3840 (area too large) → 400", async () => {
255
+ const res = await fetch(`${BASE}/_image?src=/public/a.jpg&w=3840&h=3840`);
256
+ expect(res.status).toBe(400);
257
+ });
258
+ });
259
+
260
+ // ── Item 11 — HttpError → response ───────────────────────────────────────
261
+ // (Implicitly covered: integration.test.ts hits a route; this test exercises
262
+ // the conversion via a direct loader throw is awkward without fixtures. Skip
263
+ // here — the request-handler change is type-checked + reachable via redirect.)
264
+
265
+ // ── Item 12 — Content-Type branching for action ──────────────────────────
266
+
267
+ describe("action Content-Type branching", () => {
268
+ test("JSON content-type does not require multipart formData", async () => {
269
+ const res = await fetch(`${BASE}/`, {
270
+ method: "POST",
271
+ headers: { "Content-Type": "application/json", Origin: BASE },
272
+ body: JSON.stringify({ name: "bract" }),
273
+ });
274
+ // The fixture's action accepts FormData; with JSON CT, request-handler
275
+ // passes an empty FormData and the action returns. Should not 500 from
276
+ // formData() throwing.
277
+ expect([200, 302, 400, 404]).toContain(res.status);
278
+ });
279
+ });
280
+
281
+ // ── Item 14 — meta is array in __BRACTJS_DATA__ ─────────────────────────
282
+
283
+ describe("render meta shape", () => {
284
+ test("__BRACTJS_DATA__.meta is an array", async () => {
285
+ const res = await fetch(`${BASE}/`);
286
+ const html = await res.text();
287
+ const match = html.match(/window\.__BRACTJS_DATA__=({[\s\S]*?});/);
288
+ expect(match).not.toBeNull();
289
+ const data = JSON.parse(match![1]) as { meta: unknown };
290
+ expect(Array.isArray(data.meta)).toBe(true);
291
+ });
292
+ });
293
+
294
+ // ── Item 20 — middleware double-next ─────────────────────────────────────
295
+
296
+ describe("middleware — double next()", () => {
297
+ test("calling next() twice rejects", async () => {
298
+ const pipeline = new MiddlewarePipeline();
299
+ pipeline.use(async (_ctx, next) => {
300
+ await next();
301
+ return next(); // illegal
302
+ });
303
+ const ctx: MiddlewareContext = { request: new Request("http://x/"), params: {}, context: {} };
304
+ await expect(pipeline.run(ctx, () => Promise.resolve(new Response("ok")))).rejects.toThrow(/more than once/);
305
+ });
306
+ });
307
+
308
+ // ── Symlink escape (static + image) ─────────────────────────────────────
309
+
310
+ describe("symlink escape — static", () => {
311
+ let pub: string;
312
+ let outside: string;
313
+ let buildDir: string;
314
+
315
+ beforeAll(async () => {
316
+ const root = await Bun.file(tmpdir()).exists() ? tmpdir() : ".";
317
+ pub = join(root, `bract-sym-pub-${Date.now()}`);
318
+ outside = join(root, `bract-sym-out-${Date.now()}`);
319
+ buildDir = join(root, `bract-sym-build-${Date.now()}`);
320
+ await mkdir(pub, { recursive: true });
321
+ await mkdir(outside, { recursive: true });
322
+ await mkdir(join(buildDir, "client"), { recursive: true });
323
+ await writeFile(join(outside, "secret.txt"), "PWNED");
324
+ // Symlink inside /public/ that points outside the root.
325
+ await symlink(join(outside, "secret.txt"), join(pub, "escape.txt"));
326
+ });
327
+
328
+ afterAll(async () => {
329
+ await rm(pub, { recursive: true, force: true });
330
+ await rm(outside, { recursive: true, force: true });
331
+ await rm(buildDir, { recursive: true, force: true });
332
+ });
333
+
334
+ test("static refuses to serve a symlink whose target is outside publicDir", async () => {
335
+ const res = await serveStatic("/public/escape.txt", buildDir, pub);
336
+ expect(res).toBeNull();
337
+ });
338
+
339
+ test("image /_image refuses src that symlinks outside publicDir", async () => {
340
+ const cacheDir = join(tmpdir(), `bract-sym-cache-${Date.now()}`);
341
+ await mkdir(cacheDir, { recursive: true });
342
+ const req = new Request(`http://x/_image?src=/public/escape.txt&w=320`);
343
+ const res = await handleImageRequest(req, pub, cacheDir);
344
+ expect(res?.status === 400 || res?.status === 404).toBe(true);
345
+ await rm(cacheDir, { recursive: true, force: true });
346
+ });
347
+ });
348
+