auth.test.js
11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
// Integration test for T19 (register/login/logout/me), dev-plan-3.md.
// Spawns server.js on a throwaway port against an isolated tmpdir copy
// (own data.sqlite too — db.js defaults to __dirname/data.sqlite), same
// isolation approach as admin-auth.test.js / uploads-route.test.js.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { spawn } = require("node:child_process");
const PORT = 24934 + Math.floor(Math.random() * 1000);
const BASE = `http://localhost:${PORT}`;
const ADMIN_PASSWORD = "test-password-only-for-this-run";
function waitForServer(url, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
return (async function poll() {
while (Date.now() < deadline) {
try {
await fetch(url);
return;
} catch {
await new Promise((r) => setTimeout(r, 100));
}
}
throw new Error("server did not come up in time");
})();
}
function sessionCookie(res) {
const setCookie = res.headers.get("set-cookie") || "";
return setCookie.split(";")[0];
}
let child;
let cwd;
test.before(async () => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "auth-test-"));
for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) {
fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
}
child = spawn(process.execPath, ["server.js"], {
cwd,
env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD },
stdio: "pipe",
});
await waitForServer(BASE);
});
test.after(() => {
child.kill();
fs.rmSync(cwd, { recursive: true, force: true });
});
test("register: rejects a malformed email", async () => {
const res = await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "not-an-email", password: "longenoughpassword" }),
});
assert.equal(res.status, 400);
});
test("register: rejects a too-short password", async () => {
const res = await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "short-pw@example.com", password: "short1" }),
});
assert.equal(res.status, 400);
});
test("register -> auto-logged-in session -> GET /api/auth/me -> logout closes the loop", async () => {
const registerRes = await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "alice@example.com", password: "correct-horse-battery" }),
});
assert.equal(registerRes.status, 200);
const registered = await registerRes.json();
assert.equal(registered.email, "alice@example.com");
assert.equal(registered.emailVerified, false);
const cookie = sessionCookie(registerRes);
assert.ok(cookie.startsWith("user_session="), "register should auto-issue a user_session cookie, distinct from admin_session");
const meRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } });
assert.equal(meRes.status, 200);
const me = await meRes.json();
assert.equal(me.id, registered.id);
assert.equal(me.email, "alice@example.com");
const logoutRes = await fetch(`${BASE}/api/auth/logout`, { method: "POST", headers: { Cookie: cookie } });
assert.equal(logoutRes.status, 200);
const meAfterLogoutRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } });
assert.equal(meAfterLogoutRes.status, 401, "the old session token must be dead after logout");
});
test("GET /api/auth/me with no cookie at all is unauthorized", async () => {
const res = await fetch(`${BASE}/api/auth/me`);
assert.equal(res.status, 401);
});
test("register: duplicate email is a 409, not a silently-created second account", async () => {
await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "dup@example.com", password: "correct-horse-battery" }),
});
const secondRes = await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "dup@example.com", password: "a-totally-different-password" }),
});
assert.equal(secondRes.status, 409);
});
test("login: correct credentials issue a fresh session", async () => {
await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "bob@example.com", password: "correct-horse-battery" }),
});
const loginRes = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "bob@example.com", password: "correct-horse-battery" }),
});
assert.equal(loginRes.status, 200);
const cookie = sessionCookie(loginRes);
const meRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } });
assert.equal(meRes.status, 200);
const me = await meRes.json();
assert.equal(me.email, "bob@example.com");
});
test("login: wrong password and unknown email return the exact same error (no email enumeration)", async () => {
await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "carol@example.com", password: "correct-horse-battery" }),
});
const wrongPasswordRes = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "carol@example.com", password: "totally-wrong-password" }),
});
const unknownEmailRes = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "nobody-registered-this@example.com", password: "whatever" }),
});
assert.equal(wrongPasswordRes.status, 401);
assert.equal(unknownEmailRes.status, 401);
const [wrongBody, unknownBody] = await Promise.all([wrongPasswordRes.json(), unknownEmailRes.json()]);
assert.deepEqual(wrongBody, unknownBody, "the two failure modes must be indistinguishable to the caller");
});
test("a user_session cookie doesn't unlock /api/admin/* (the two session systems stay separate)", async () => {
const registerRes = await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "dana@example.com", password: "correct-horse-battery" }),
});
const cookie = sessionCookie(registerRes);
const res = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } });
assert.equal(res.status, 401);
});
// ---- T20: email verification ----
test("verify: no vendor configured yet, so register's dev response carries a usable verify link that actually flips emailVerified", async () => {
const registerRes = await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "erin@example.com", password: "correct-horse-battery" }),
});
const registered = await registerRes.json();
assert.ok(registered.devVerifyUrl, "dev/test environments must be able to complete verification without a real inbox");
const cookie = sessionCookie(registerRes);
const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
assert.equal(meBefore.emailVerified, false);
const verifyRes = await fetch(`${BASE}${registered.devVerifyUrl}`);
assert.equal(verifyRes.status, 200);
const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
assert.equal(meAfter.emailVerified, true);
});
test("verify: an unknown/garbage token is a clear error, not a silent success", async () => {
const res = await fetch(`${BASE}/api/auth/verify?token=not-a-real-token`);
assert.equal(res.status, 400);
});
test("verify: a token can't be reused once it's already verified the account", async () => {
const registerRes = await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "frank@example.com", password: "correct-horse-battery" }),
});
const { devVerifyUrl } = await registerRes.json();
const firstRes = await fetch(`${BASE}${devVerifyUrl}`);
assert.equal(firstRes.status, 200);
const secondRes = await fetch(`${BASE}${devVerifyUrl}`);
assert.equal(secondRes.status, 400, "a used verify token must not silently succeed again");
});
// ---- T21: binding an existing anonymous viewer_uid/viewer_nickname ----
test("register: an existing viewer_uid/viewer_nickname cookie gets bound to the new account as legacyUid/nickname", async () => {
const registerRes = await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: "viewer_uid=old-anon-uid-123; viewer_nickname=%E8%80%81%E8%A7%82%E4%BC%97", // "老观众"
},
body: JSON.stringify({ email: "grace@example.com", password: "correct-horse-battery" }),
});
const registered = await registerRes.json();
assert.equal(registered.nickname, "老观众", "nickname should migrate from the cookie, not require re-entering it");
const cookie = sessionCookie(registerRes);
const me = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
assert.equal(me.nickname, "老观众");
});
test("login: an account with a bound legacyUid pushes it back onto the browser, even with no/fresh viewer_uid cookie", async () => {
await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: "viewer_uid=henry-bound-uid; viewer_nickname=Henry" },
body: JSON.stringify({ email: "henry@example.com", password: "correct-horse-battery" }),
});
// Log in from what looks like a clean browser — no viewer_uid cookie at
// all this time (simulating cleared cookies / a different device).
const loginRes = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "henry@example.com", password: "correct-horse-battery" }),
});
assert.equal(loginRes.status, 200);
const setCookies = loginRes.headers.getSetCookie();
const viewerUidCookie = setCookies.find((c) => c.startsWith("viewer_uid="));
const viewerNicknameCookie = setCookies.find((c) => c.startsWith("viewer_nickname="));
assert.ok(viewerUidCookie, "login must re-issue the account's bound viewer_uid, not leave the browser without one");
assert.match(viewerUidCookie, /^viewer_uid=henry-bound-uid;/);
assert.match(viewerNicknameCookie, /^viewer_nickname=Henry;/);
// Not HttpOnly — live.html/dance.html's own client-side JS reads these
// via document.cookie, same as the identity cookies they already set.
assert.ok(!viewerUidCookie.includes("HttpOnly"));
});