@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
package/package.json
CHANGED
|
@@ -1,69 +1,94 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./src/index.ts",
|
|
7
|
-
"types": "./src/index.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": "./src/index.ts",
|
|
10
|
-
"./
|
|
11
|
-
"./
|
|
12
|
-
"./
|
|
13
|
-
"./
|
|
14
|
-
"./
|
|
15
|
-
"./
|
|
16
|
-
"./
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"src
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
},
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@mandujs/core",
|
|
3
|
+
"version": "0.22.0",
|
|
4
|
+
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
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",
|
|
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",
|
|
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",
|
|
25
|
+
"./testing": "./src/testing/index.ts",
|
|
26
|
+
"./plugins": "./src/plugins/index.ts",
|
|
27
|
+
"./error": "./src/error/index.ts",
|
|
28
|
+
"./id": "./src/id/index.ts",
|
|
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",
|
|
33
|
+
"./bundler/prerender": "./src/bundler/prerender.ts",
|
|
34
|
+
"./*": "./src/*"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src/**/*"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
|
|
41
|
+
"test:hydration": "bun test tests/hydration",
|
|
42
|
+
"test:streaming": "bun test tests/streaming-ssr",
|
|
43
|
+
"test:watch": "bun test --watch"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@happy-dom/global-registrator": "^15.0.0"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"mandu",
|
|
50
|
+
"framework",
|
|
51
|
+
"agent",
|
|
52
|
+
"ai",
|
|
53
|
+
"code-generation"
|
|
54
|
+
],
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://github.com/konamgil/mandu.git",
|
|
58
|
+
"directory": "packages/core"
|
|
59
|
+
},
|
|
60
|
+
"author": "konamgil",
|
|
61
|
+
"license": "MPL-2.0",
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"bun": ">=1.3.12"
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"react": "^19.0.0",
|
|
70
|
+
"react-dom": "^19.0.0",
|
|
71
|
+
"react-refresh": ">=0.18.0",
|
|
72
|
+
"@tailwindcss/cli": ">=4.0.0",
|
|
73
|
+
"webview-bun": "^2.4.0"
|
|
74
|
+
},
|
|
75
|
+
"peerDependenciesMeta": {
|
|
76
|
+
"@tailwindcss/cli": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"react-refresh": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"webview-bun": {
|
|
83
|
+
"optional": true
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"dependencies": {
|
|
87
|
+
"chokidar": "^5.0.0",
|
|
88
|
+
"fast-glob": "^3.3.2",
|
|
89
|
+
"glob": "^13.0.0",
|
|
90
|
+
"minimatch": "^10.1.1",
|
|
91
|
+
"ollama": "^0.6.3",
|
|
92
|
+
"zod": "^3.23.8"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -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
|
+
});
|