@mandujs/core 0.20.10 → 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/README.md +2 -1
- package/package.json +28 -3
- 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 +495 -37
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1671 -80
- 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 +19 -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/config/validate.ts +1 -0
- 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/devtools/ai/mcp-connector.ts +18 -16
- package/src/devtools/client/components/mandu-character.tsx +4 -1
- package/src/devtools/client/components/panel/panel-container.tsx +20 -5
- 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 +679 -23
- 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 +247 -189
package/README.md
CHANGED
|
@@ -162,7 +162,7 @@ watcher.start();
|
|
|
162
162
|
listPresets().forEach(p => console.log(p.name, p.description));
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
-
### Presets
|
|
165
|
+
### Presets (6)
|
|
166
166
|
|
|
167
167
|
| Preset | Layers | Use Case |
|
|
168
168
|
|--------|--------|----------|
|
|
@@ -171,6 +171,7 @@ listPresets().forEach(p => console.log(p.name, p.description));
|
|
|
171
171
|
| `clean` | api, application, domain, infra, shared | Backend |
|
|
172
172
|
| `hexagonal` | adapters, ports, application, domain | DDD |
|
|
173
173
|
| `atomic` | pages, templates, organisms, molecules, atoms | UI |
|
|
174
|
+
| `cqrs` | commands, queries, events, dto, application, domain | Event-sourced apps |
|
|
174
175
|
|
|
175
176
|
### AST-based Analysis
|
|
176
177
|
|
package/package.json
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"types": "./src/index.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
|
+
"./auth": "./src/auth/index.ts",
|
|
11
|
+
"./auth/login": "./src/auth/login.ts",
|
|
12
|
+
"./auth/password": "./src/auth/password.ts",
|
|
13
|
+
"./auth/reset": "./src/auth/reset.ts",
|
|
14
|
+
"./auth/verification": "./src/auth/verification.ts",
|
|
10
15
|
"./client": "./src/client/index.ts",
|
|
16
|
+
"./db": "./src/db/index.ts",
|
|
17
|
+
"./desktop": "./src/desktop/index.ts",
|
|
18
|
+
"./desktop/worker": "./src/desktop/worker.ts",
|
|
19
|
+
"./email": "./src/email/index.ts",
|
|
20
|
+
"./filling/session-sqlite": "./src/filling/session-sqlite.ts",
|
|
11
21
|
"./middleware": "./src/middleware/index.ts",
|
|
22
|
+
"./middleware/oauth": "./src/middleware/oauth/index.ts",
|
|
23
|
+
"./middleware/secure": "./src/middleware/secure/index.ts",
|
|
24
|
+
"./middleware/rate-limit": "./src/middleware/rate-limit/index.ts",
|
|
12
25
|
"./testing": "./src/testing/index.ts",
|
|
13
26
|
"./plugins": "./src/plugins/index.ts",
|
|
14
27
|
"./error": "./src/error/index.ts",
|
|
28
|
+
"./id": "./src/id/index.ts",
|
|
15
29
|
"./observability": "./src/observability/index.ts",
|
|
30
|
+
"./perf": "./src/perf/index.ts",
|
|
31
|
+
"./scheduler": "./src/scheduler/index.ts",
|
|
32
|
+
"./storage/s3": "./src/storage/s3/index.ts",
|
|
16
33
|
"./bundler/prerender": "./src/bundler/prerender.ts",
|
|
17
34
|
"./*": "./src/*"
|
|
18
35
|
},
|
|
@@ -46,16 +63,24 @@
|
|
|
46
63
|
"access": "public"
|
|
47
64
|
},
|
|
48
65
|
"engines": {
|
|
49
|
-
"bun": ">=1.
|
|
66
|
+
"bun": ">=1.3.12"
|
|
50
67
|
},
|
|
51
68
|
"peerDependencies": {
|
|
52
69
|
"react": "^19.0.0",
|
|
53
70
|
"react-dom": "^19.0.0",
|
|
54
|
-
"
|
|
71
|
+
"react-refresh": ">=0.18.0",
|
|
72
|
+
"@tailwindcss/cli": ">=4.0.0",
|
|
73
|
+
"webview-bun": "^2.4.0"
|
|
55
74
|
},
|
|
56
75
|
"peerDependenciesMeta": {
|
|
57
76
|
"@tailwindcss/cli": {
|
|
58
77
|
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"react-refresh": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"webview-bun": {
|
|
83
|
+
"optional": true
|
|
59
84
|
}
|
|
60
85
|
},
|
|
61
86
|
"dependencies": {
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/auth/login tests
|
|
3
|
+
*
|
|
4
|
+
* Covers ergonomic login helpers that wrap Phase 2.3's session middleware.
|
|
5
|
+
* Fixture style mirrors `tests/middleware/session.test.ts` — real Request /
|
|
6
|
+
* Response objects, real `createCookieSessionStorage`, no mocks.
|
|
7
|
+
*
|
|
8
|
+
* What we verify:
|
|
9
|
+
* - `loginUser` writes userId + loggedAt + extras and commits in one shot
|
|
10
|
+
* - Custom keys are honored
|
|
11
|
+
* - Missing session middleware throws `AuthenticationError`
|
|
12
|
+
* - `logoutUser` emits expiring Set-Cookie and is idempotent
|
|
13
|
+
* - `currentUserId` / `loggedAt` are non-throwing read paths
|
|
14
|
+
* - End-to-end roundtrip through `ManduFilling`
|
|
15
|
+
* - Composition with `requireUser` via a `loadUser` beforeHandle bridge
|
|
16
|
+
* (executable documentation for the common real-world pattern)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from "bun:test";
|
|
20
|
+
import {
|
|
21
|
+
loginUser,
|
|
22
|
+
logoutUser,
|
|
23
|
+
currentUserId,
|
|
24
|
+
loggedAt,
|
|
25
|
+
} from "../login";
|
|
26
|
+
import { session } from "../../middleware/session";
|
|
27
|
+
import {
|
|
28
|
+
Session,
|
|
29
|
+
createCookieSessionStorage,
|
|
30
|
+
type SessionStorage,
|
|
31
|
+
} from "../../filling/session";
|
|
32
|
+
import { ManduContext } from "../../filling/context";
|
|
33
|
+
import { ManduFilling } from "../../filling/filling";
|
|
34
|
+
import {
|
|
35
|
+
AuthenticationError,
|
|
36
|
+
requireUser,
|
|
37
|
+
type BaseUser,
|
|
38
|
+
} from "../../filling/auth";
|
|
39
|
+
|
|
40
|
+
// ========== Helpers ==========
|
|
41
|
+
|
|
42
|
+
const SECRET = "login-helper-test-secret-32bytes!";
|
|
43
|
+
|
|
44
|
+
function makeReq(url: string, init: RequestInit & { cookie?: string } = {}): Request {
|
|
45
|
+
const { cookie, headers: rawHeaders, ...rest } = init;
|
|
46
|
+
const headers = new Headers(rawHeaders as HeadersInit | undefined);
|
|
47
|
+
if (cookie) headers.set("cookie", cookie);
|
|
48
|
+
return new Request(url, { ...rest, headers });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeCtx(req: Request): ManduContext {
|
|
52
|
+
return new ManduContext(req);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeStorage(): SessionStorage {
|
|
56
|
+
return createCookieSessionStorage({
|
|
57
|
+
cookie: { secrets: [SECRET] },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Extract the first Set-Cookie line matching `name=`. */
|
|
62
|
+
function readSetCookieLine(res: Response, name: string): string | null {
|
|
63
|
+
const headers = res.headers.getSetCookie?.() ?? [];
|
|
64
|
+
const needle = `${name}=`;
|
|
65
|
+
for (const line of headers) {
|
|
66
|
+
if (line.startsWith(needle)) return line;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Extract just the raw (encoded) cookie value from a Set-Cookie line. */
|
|
72
|
+
function readSetCookieRawValue(res: Response, name: string): string | null {
|
|
73
|
+
const line = readSetCookieLine(res, name);
|
|
74
|
+
if (!line) return null;
|
|
75
|
+
const [nv] = line.split(";");
|
|
76
|
+
const eq = nv.indexOf("=");
|
|
77
|
+
if (eq <= 0) return null;
|
|
78
|
+
return nv.slice(eq + 1).trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ========== loginUser ==========
|
|
82
|
+
|
|
83
|
+
describe("loginUser — stores userId + loggedAt and commits", () => {
|
|
84
|
+
it("writes userId + loggedAt and emits Set-Cookie via saveSession", async () => {
|
|
85
|
+
const storage = makeStorage();
|
|
86
|
+
const mw = session({ storage });
|
|
87
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
88
|
+
await mw(ctx);
|
|
89
|
+
|
|
90
|
+
await loginUser(ctx, "user-123");
|
|
91
|
+
|
|
92
|
+
// In-memory session state is visible immediately.
|
|
93
|
+
const s = ctx.get<Session>("session")!;
|
|
94
|
+
expect(s.get<string>("userId")).toBe("user-123");
|
|
95
|
+
expect(typeof s.get<number>("loginAt")).toBe("number");
|
|
96
|
+
|
|
97
|
+
// Set-Cookie lands on the next response built from this ctx.
|
|
98
|
+
const res = ctx.ok({ ok: true });
|
|
99
|
+
const line = readSetCookieLine(res, "__session");
|
|
100
|
+
expect(line).toBeTruthy();
|
|
101
|
+
expect(line!).toContain("HttpOnly");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("honors a custom userIdKey", async () => {
|
|
105
|
+
const storage = makeStorage();
|
|
106
|
+
const mw = session({ storage });
|
|
107
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
108
|
+
await mw(ctx);
|
|
109
|
+
|
|
110
|
+
await loginUser(ctx, "u-42", { userIdKey: "uid" });
|
|
111
|
+
|
|
112
|
+
const s = ctx.get<Session>("session")!;
|
|
113
|
+
expect(s.get<string>("uid")).toBe("u-42");
|
|
114
|
+
// Default key is NOT populated when custom key is supplied.
|
|
115
|
+
expect(s.get<string>("userId")).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("writes extras atomically with userId + loggedAt", async () => {
|
|
119
|
+
const storage = makeStorage();
|
|
120
|
+
const mw = session({ storage });
|
|
121
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
122
|
+
await mw(ctx);
|
|
123
|
+
|
|
124
|
+
await loginUser(ctx, "u-1", {
|
|
125
|
+
extras: {
|
|
126
|
+
role: "admin",
|
|
127
|
+
tenantId: "acme",
|
|
128
|
+
remember: true,
|
|
129
|
+
seat: 7,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const s = ctx.get<Session>("session")!;
|
|
134
|
+
expect(s.get<string>("userId")).toBe("u-1");
|
|
135
|
+
expect(s.get<string>("role")).toBe("admin");
|
|
136
|
+
expect(s.get<string>("tenantId")).toBe("acme");
|
|
137
|
+
expect(s.get<boolean>("remember")).toBe(true);
|
|
138
|
+
expect(s.get<number>("seat")).toBe(7);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("throws AuthenticationError when session middleware is not installed", async () => {
|
|
142
|
+
// No `session()` middleware — ctx has no attached session.
|
|
143
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
144
|
+
|
|
145
|
+
let thrown: unknown = null;
|
|
146
|
+
try {
|
|
147
|
+
await loginUser(ctx, "u-1");
|
|
148
|
+
} catch (err) {
|
|
149
|
+
thrown = err;
|
|
150
|
+
}
|
|
151
|
+
expect(thrown).toBeInstanceOf(AuthenticationError);
|
|
152
|
+
expect((thrown as AuthenticationError).statusCode).toBe(401);
|
|
153
|
+
expect(String(thrown)).toContain("Session middleware not installed");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("rejects empty userId with AuthenticationError (never silently stores '')", async () => {
|
|
157
|
+
const storage = makeStorage();
|
|
158
|
+
const mw = session({ storage });
|
|
159
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
160
|
+
await mw(ctx);
|
|
161
|
+
|
|
162
|
+
await expect(loginUser(ctx, "")).rejects.toBeInstanceOf(AuthenticationError);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ========== logoutUser ==========
|
|
167
|
+
|
|
168
|
+
describe("logoutUser — expiring cookie, idempotent", () => {
|
|
169
|
+
it("emits an expiring Set-Cookie (Max-Age=0)", async () => {
|
|
170
|
+
const storage = makeStorage();
|
|
171
|
+
const mw = session({ storage });
|
|
172
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
173
|
+
await mw(ctx);
|
|
174
|
+
await loginUser(ctx, "u-1");
|
|
175
|
+
|
|
176
|
+
// Now log out on a fresh ctx with the just-issued cookie — but for a unit
|
|
177
|
+
// test we can call logoutUser on the same ctx; destroySession will emit a
|
|
178
|
+
// new Set-Cookie line. There will be multiple Set-Cookie lines for
|
|
179
|
+
// __session because the first loginUser already queued one; we're checking
|
|
180
|
+
// that at least one Max-Age=0 line is present.
|
|
181
|
+
await logoutUser(ctx);
|
|
182
|
+
const res = ctx.ok({ ok: true });
|
|
183
|
+
|
|
184
|
+
const headers = res.headers.getSetCookie?.() ?? [];
|
|
185
|
+
const sessionLines = headers.filter((l) => l.startsWith("__session="));
|
|
186
|
+
expect(sessionLines.length).toBeGreaterThan(0);
|
|
187
|
+
const hasExpiring = sessionLines.some((l) => l.includes("Max-Age=0"));
|
|
188
|
+
expect(hasExpiring).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("is idempotent — two successive calls do not throw", async () => {
|
|
192
|
+
const storage = makeStorage();
|
|
193
|
+
const mw = session({ storage });
|
|
194
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
195
|
+
await mw(ctx);
|
|
196
|
+
|
|
197
|
+
// No prior login — session is empty. Two back-to-back logout calls must
|
|
198
|
+
// not throw. This exercises the "already logged out" code path.
|
|
199
|
+
await logoutUser(ctx);
|
|
200
|
+
await logoutUser(ctx);
|
|
201
|
+
|
|
202
|
+
const res = ctx.ok({ ok: true });
|
|
203
|
+
const line = readSetCookieLine(res, "__session");
|
|
204
|
+
expect(line).toBeTruthy();
|
|
205
|
+
expect(line!).toContain("Max-Age=0");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("throws AuthenticationError when session middleware is not installed", async () => {
|
|
209
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
210
|
+
await expect(logoutUser(ctx)).rejects.toBeInstanceOf(AuthenticationError);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ========== currentUserId ==========
|
|
215
|
+
|
|
216
|
+
describe("currentUserId — non-throwing reader", () => {
|
|
217
|
+
it("returns the userId after loginUser", async () => {
|
|
218
|
+
const storage = makeStorage();
|
|
219
|
+
const mw = session({ storage });
|
|
220
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
221
|
+
await mw(ctx);
|
|
222
|
+
|
|
223
|
+
expect(currentUserId(ctx)).toBeNull();
|
|
224
|
+
await loginUser(ctx, "abc-999");
|
|
225
|
+
expect(currentUserId(ctx)).toBe("abc-999");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("returns null WITHOUT throwing when session middleware is absent", () => {
|
|
229
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
230
|
+
expect(() => currentUserId(ctx)).not.toThrow();
|
|
231
|
+
expect(currentUserId(ctx)).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("returns null after logoutUser (in-memory wipe)", async () => {
|
|
235
|
+
const storage = makeStorage();
|
|
236
|
+
const mw = session({ storage });
|
|
237
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
238
|
+
await mw(ctx);
|
|
239
|
+
|
|
240
|
+
await loginUser(ctx, "u-1");
|
|
241
|
+
expect(currentUserId(ctx)).toBe("u-1");
|
|
242
|
+
|
|
243
|
+
await logoutUser(ctx);
|
|
244
|
+
// destroySession wipes in-memory state → currentUserId sees no user.
|
|
245
|
+
expect(currentUserId(ctx)).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("honors a custom userIdKey", async () => {
|
|
249
|
+
const storage = makeStorage();
|
|
250
|
+
const mw = session({ storage });
|
|
251
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
252
|
+
await mw(ctx);
|
|
253
|
+
|
|
254
|
+
await loginUser(ctx, "u-custom", { userIdKey: "uid" });
|
|
255
|
+
expect(currentUserId(ctx)).toBeNull(); // default key empty
|
|
256
|
+
expect(currentUserId(ctx, { userIdKey: "uid" })).toBe("u-custom");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ========== loggedAt ==========
|
|
261
|
+
|
|
262
|
+
describe("loggedAt — numeric timestamp reader", () => {
|
|
263
|
+
it("returns a timestamp close to Date.now() after login (within 1s)", async () => {
|
|
264
|
+
const storage = makeStorage();
|
|
265
|
+
const mw = session({ storage });
|
|
266
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
267
|
+
await mw(ctx);
|
|
268
|
+
|
|
269
|
+
const before = Date.now();
|
|
270
|
+
await loginUser(ctx, "u-1");
|
|
271
|
+
const after = Date.now();
|
|
272
|
+
|
|
273
|
+
const ts = loggedAt(ctx);
|
|
274
|
+
expect(ts).not.toBeNull();
|
|
275
|
+
expect(typeof ts).toBe("number");
|
|
276
|
+
expect(ts!).toBeGreaterThanOrEqual(before);
|
|
277
|
+
expect(ts!).toBeLessThanOrEqual(after);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("returns null when session middleware is absent", () => {
|
|
281
|
+
const ctx = makeCtx(makeReq("http://localhost/"));
|
|
282
|
+
expect(loggedAt(ctx)).toBeNull();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ========== End-to-end roundtrip through ManduFilling ==========
|
|
287
|
+
|
|
288
|
+
describe("roundtrip — login → cookie → read → logout", () => {
|
|
289
|
+
it("completes a full login/read/logout flow across three requests", async () => {
|
|
290
|
+
const storage = makeStorage();
|
|
291
|
+
|
|
292
|
+
// Single filling pipeline handles all three paths via ?action=.
|
|
293
|
+
const filling = new ManduFilling()
|
|
294
|
+
.use(session({ storage }))
|
|
295
|
+
.get(async (ctx) => {
|
|
296
|
+
const action = new URL(ctx.request.url).searchParams.get("action");
|
|
297
|
+
if (action === "login") {
|
|
298
|
+
await loginUser(ctx, "roundtrip-user");
|
|
299
|
+
return ctx.ok({ stage: "login" });
|
|
300
|
+
}
|
|
301
|
+
if (action === "logout") {
|
|
302
|
+
await logoutUser(ctx);
|
|
303
|
+
return ctx.ok({ stage: "logout" });
|
|
304
|
+
}
|
|
305
|
+
// Default: read side.
|
|
306
|
+
return ctx.ok({ uid: currentUserId(ctx) });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// 1. Login — capture the Set-Cookie the browser would keep.
|
|
310
|
+
const loginRes = await filling.handle(makeReq("http://localhost/?action=login"));
|
|
311
|
+
expect(loginRes.status).toBe(200);
|
|
312
|
+
const cookieLine = readSetCookieLine(loginRes, "__session");
|
|
313
|
+
expect(cookieLine).toBeTruthy();
|
|
314
|
+
const cookieValue = readSetCookieRawValue(loginRes, "__session");
|
|
315
|
+
expect(cookieValue).toBeTruthy();
|
|
316
|
+
|
|
317
|
+
// 2. Read — re-attach the issued cookie and verify currentUserId finds
|
|
318
|
+
// the id we stored.
|
|
319
|
+
const readRes = await filling.handle(
|
|
320
|
+
makeReq("http://localhost/", { cookie: `__session=${cookieValue}` }),
|
|
321
|
+
);
|
|
322
|
+
expect(readRes.status).toBe(200);
|
|
323
|
+
const readBody = (await readRes.json()) as { uid: string | null };
|
|
324
|
+
expect(readBody.uid).toBe("roundtrip-user");
|
|
325
|
+
|
|
326
|
+
// 3. Logout — issues expiring cookie; subsequent read with *that* cookie
|
|
327
|
+
// returns null userId because the client would drop the cookie on
|
|
328
|
+
// Max-Age=0. We simulate this by not passing any cookie on the fourth
|
|
329
|
+
// request (emulating what the browser does after receiving the
|
|
330
|
+
// expiring Set-Cookie).
|
|
331
|
+
const logoutRes = await filling.handle(
|
|
332
|
+
makeReq("http://localhost/?action=logout", { cookie: `__session=${cookieValue}` }),
|
|
333
|
+
);
|
|
334
|
+
expect(logoutRes.status).toBe(200);
|
|
335
|
+
const logoutLine = readSetCookieLine(logoutRes, "__session");
|
|
336
|
+
expect(logoutLine).toBeTruthy();
|
|
337
|
+
expect(logoutLine!).toContain("Max-Age=0");
|
|
338
|
+
|
|
339
|
+
// 4. Post-logout read with NO cookie — currentUserId is null.
|
|
340
|
+
const postLogoutRes = await filling.handle(makeReq("http://localhost/"));
|
|
341
|
+
const postLogoutBody = (await postLogoutRes.json()) as { uid: string | null };
|
|
342
|
+
expect(postLogoutBody.uid).toBeNull();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ========== Composition with requireUser from filling/auth ==========
|
|
347
|
+
|
|
348
|
+
describe("composition with requireUser — the loadUser bridge pattern", () => {
|
|
349
|
+
it("a loadUser beforeHandle bridges session.userId → ctx.set('user', ...) for requireUser", async () => {
|
|
350
|
+
// This is the common real-world pattern worth documenting as a test:
|
|
351
|
+
//
|
|
352
|
+
// - `loginUser` writes `userId` to the SESSION (persisted across
|
|
353
|
+
// requests via cookie).
|
|
354
|
+
// - `requireUser` reads the USER OBJECT from ctx.store at key "user"
|
|
355
|
+
// (a request-scoped store, wiped every request).
|
|
356
|
+
// - A tiny middleware placed AFTER `session()` reads the session
|
|
357
|
+
// userId, fetches the user record from your data layer, and calls
|
|
358
|
+
// `ctx.set("user", user)`. That's the bridge.
|
|
359
|
+
|
|
360
|
+
interface User extends BaseUser {
|
|
361
|
+
id: string;
|
|
362
|
+
name: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Fake user store.
|
|
366
|
+
const users = new Map<string, User>([
|
|
367
|
+
["u-1", { id: "u-1", name: "Alice" }],
|
|
368
|
+
["u-2", { id: "u-2", name: "Bob" }],
|
|
369
|
+
]);
|
|
370
|
+
|
|
371
|
+
const storage = makeStorage();
|
|
372
|
+
|
|
373
|
+
const filling = new ManduFilling()
|
|
374
|
+
.use(session({ storage }))
|
|
375
|
+
// ⭐️ The bridge: after session() installs the Session, hydrate the
|
|
376
|
+
// request-scoped `user` from the session's userId. Placed in
|
|
377
|
+
// beforeHandle so ALL subsequent handler code can rely on
|
|
378
|
+
// `requireUser(ctx)`.
|
|
379
|
+
.beforeHandle(async (ctx) => {
|
|
380
|
+
const uid = currentUserId(ctx);
|
|
381
|
+
if (uid) {
|
|
382
|
+
const user = users.get(uid);
|
|
383
|
+
if (user) ctx.set("user", user);
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
.get(async (ctx) => {
|
|
387
|
+
const action = new URL(ctx.request.url).searchParams.get("action");
|
|
388
|
+
if (action === "login") {
|
|
389
|
+
await loginUser(ctx, "u-1");
|
|
390
|
+
return ctx.ok({ stage: "login" });
|
|
391
|
+
}
|
|
392
|
+
// requireUser reads ctx.get("user") — the bridge populated it above
|
|
393
|
+
// IFF the session had a valid userId.
|
|
394
|
+
const user = requireUser<User>(ctx);
|
|
395
|
+
return ctx.ok({ id: user.id, name: user.name });
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// 1. Login issues the cookie.
|
|
399
|
+
const loginRes = await filling.handle(makeReq("http://localhost/?action=login"));
|
|
400
|
+
expect(loginRes.status).toBe(200);
|
|
401
|
+
const cookieValue = readSetCookieRawValue(loginRes, "__session");
|
|
402
|
+
expect(cookieValue).toBeTruthy();
|
|
403
|
+
|
|
404
|
+
// 2. Second request re-attaches cookie → bridge hydrates → requireUser
|
|
405
|
+
// returns the full user record.
|
|
406
|
+
const res = await filling.handle(
|
|
407
|
+
makeReq("http://localhost/", { cookie: `__session=${cookieValue}` }),
|
|
408
|
+
);
|
|
409
|
+
expect(res.status).toBe(200);
|
|
410
|
+
const body = (await res.json()) as { id: string; name: string };
|
|
411
|
+
expect(body.id).toBe("u-1");
|
|
412
|
+
expect(body.name).toBe("Alice");
|
|
413
|
+
|
|
414
|
+
// 3. Third request with NO cookie → no session userId → bridge does
|
|
415
|
+
// nothing → requireUser throws → filling maps to 401.
|
|
416
|
+
const unauthRes = await filling.handle(makeReq("http://localhost/"));
|
|
417
|
+
expect(unauthRes.status).toBe(401);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/auth/password tests
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise real argon2id and bcrypt hashing through `Bun.password`.
|
|
5
|
+
* They are CPU-bound (~50-200ms per hash op on laptop hardware); the suite
|
|
6
|
+
* uses the minimum cost parameters Bun accepts whenever the test does not
|
|
7
|
+
* care about actual KDF strength. Total runtime ~2-5s is expected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from "bun:test";
|
|
11
|
+
import { hashPassword, verifyPassword } from "../password";
|
|
12
|
+
|
|
13
|
+
// Argon2 hashes start with "$argon2id$", "$argon2d$", or "$argon2i$".
|
|
14
|
+
const ARGON2ID_PREFIX = "$argon2id$";
|
|
15
|
+
// bcrypt hashes use $2a$/$2b$/$2y$ prefixes; Bun emits $2b$.
|
|
16
|
+
const BCRYPT_PREFIX_RE = /^\$2[aby]\$/;
|
|
17
|
+
|
|
18
|
+
// Minimum argon2 cost params — keep tests fast without changing behavior.
|
|
19
|
+
const FAST_ARGON2 = {
|
|
20
|
+
algorithm: "argon2id",
|
|
21
|
+
memoryCost: 4,
|
|
22
|
+
timeCost: 2,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
describe("@mandujs/core/auth/password — hashPassword", () => {
|
|
26
|
+
it("produces an argon2id hash by default", async () => {
|
|
27
|
+
const h = await hashPassword("hunter2", FAST_ARGON2);
|
|
28
|
+
expect(h.startsWith(ARGON2ID_PREFIX)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("produces a bcrypt hash when algorithm=bcrypt is requested", async () => {
|
|
32
|
+
const h = await hashPassword("hunter2", { algorithm: "bcrypt", cost: 4 });
|
|
33
|
+
expect(h).toMatch(BCRYPT_PREFIX_RE);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("throws on empty plaintext", async () => {
|
|
37
|
+
await expect(hashPassword("", FAST_ARGON2)).rejects.toThrow(
|
|
38
|
+
/non-empty string/,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("throws when bcrypt input exceeds 72 bytes", async () => {
|
|
43
|
+
// 73 ASCII bytes (each char = 1 UTF-8 byte).
|
|
44
|
+
const tooLong = "a".repeat(73);
|
|
45
|
+
await expect(
|
|
46
|
+
hashPassword(tooLong, { algorithm: "bcrypt", cost: 4 }),
|
|
47
|
+
).rejects.toThrow(/72-byte limit/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("accepts >72-byte input when using argon2id (bcrypt-only limit)", async () => {
|
|
51
|
+
const longButFine = "a".repeat(100);
|
|
52
|
+
const h = await hashPassword(longButFine, FAST_ARGON2);
|
|
53
|
+
expect(h.startsWith(ARGON2ID_PREFIX)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("counts UTF-8 bytes (not code points) when enforcing the bcrypt limit", async () => {
|
|
57
|
+
// "é" is 2 bytes in UTF-8 — 40 of them = 80 bytes, over the 72 limit.
|
|
58
|
+
const multibyte = "é".repeat(40);
|
|
59
|
+
await expect(
|
|
60
|
+
hashPassword(multibyte, { algorithm: "bcrypt", cost: 4 }),
|
|
61
|
+
).rejects.toThrow(/72-byte limit/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("honors non-default argon2 timeCost via option pass-through", async () => {
|
|
65
|
+
// Bun embeds the cost parameters in the hash string:
|
|
66
|
+
// $argon2id$v=19$m=<memoryCost>,t=<timeCost>,p=1$<salt>$<hash>
|
|
67
|
+
// We assert the timeCost is preserved end-to-end.
|
|
68
|
+
const h = await hashPassword("same-password", {
|
|
69
|
+
algorithm: "argon2id",
|
|
70
|
+
memoryCost: 4,
|
|
71
|
+
timeCost: 3,
|
|
72
|
+
});
|
|
73
|
+
expect(h).toMatch(/\$argon2id\$v=\d+\$m=4,t=3,p=\d+\$/);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("@mandujs/core/auth/password — verifyPassword", () => {
|
|
78
|
+
it("returns true for a matching argon2id roundtrip", async () => {
|
|
79
|
+
const h = await hashPassword("correct-horse-battery-staple", FAST_ARGON2);
|
|
80
|
+
const ok = await verifyPassword("correct-horse-battery-staple", h);
|
|
81
|
+
expect(ok).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns true for a matching bcrypt roundtrip", async () => {
|
|
85
|
+
const h = await hashPassword("correct-horse-battery-staple", {
|
|
86
|
+
algorithm: "bcrypt",
|
|
87
|
+
cost: 4,
|
|
88
|
+
});
|
|
89
|
+
const ok = await verifyPassword("correct-horse-battery-staple", h);
|
|
90
|
+
expect(ok).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns false for wrong plaintext", async () => {
|
|
94
|
+
const h = await hashPassword("correct", FAST_ARGON2);
|
|
95
|
+
expect(await verifyPassword("incorrect", h)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns false for a tampered hash (one char mutated)", async () => {
|
|
99
|
+
const h = await hashPassword("correct", FAST_ARGON2);
|
|
100
|
+
// Flip the last character of the base64 hash segment.
|
|
101
|
+
const last = h.charAt(h.length - 1);
|
|
102
|
+
const replacement = last === "A" ? "B" : "A";
|
|
103
|
+
const tampered = h.slice(0, -1) + replacement;
|
|
104
|
+
expect(tampered).not.toBe(h);
|
|
105
|
+
expect(await verifyPassword("correct", tampered)).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns false (never throws) for a malformed hash string", async () => {
|
|
109
|
+
// Bun.password.verify throws on unparseable hashes; our wrapper collapses
|
|
110
|
+
// every failure mode to `false` so login handlers stay branchless.
|
|
111
|
+
expect(await verifyPassword("anything", "not-a-hash")).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns false for empty plaintext", async () => {
|
|
115
|
+
const h = await hashPassword("correct", FAST_ARGON2);
|
|
116
|
+
expect(await verifyPassword("", h)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns false for empty hash", async () => {
|
|
120
|
+
expect(await verifyPassword("correct", "")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|