f3193ae628
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
181 lines
7.7 KiB
TypeScript
181 lines
7.7 KiB
TypeScript
/**
|
|
* Auth flow integration tests (via app.request() injection): admin seeding / login /
|
|
* logout / password change / session / initial Project.
|
|
*/
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import type { MeResponse, ProjectsResponse } from "../src/api/types.js";
|
|
import { apiClient, createTestApp, loginAdmin, loginUser, provisionUser } from "./helpers.js";
|
|
import type { TestApp } from "./helpers.js";
|
|
|
|
describe("auth", () => {
|
|
let t: TestApp;
|
|
|
|
beforeEach(async () => {
|
|
t = await createTestApp();
|
|
});
|
|
afterEach(async () => {
|
|
await t.cleanup();
|
|
});
|
|
|
|
it("accessing a protected API while not logged in returns 401", async () => {
|
|
const res = await t.app.request("/api/projects");
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe("unauthorized");
|
|
});
|
|
|
|
it("registration is closed: no register endpoint", async () => {
|
|
// Not logged in: no such route under /api/auth, falls into the protected-section 401.
|
|
const anon = await t.app.request("/api/auth/register", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ userId: "alice", password: "password-123" }),
|
|
});
|
|
expect(anon.status).toBe(401);
|
|
// Logged in: falls through to notFound -> 404, proving the route has indeed been removed.
|
|
const admin = await loginAdmin(t.app);
|
|
const res = await apiClient(t.app, admin.cookie).post("/api/auth/register", {
|
|
userId: "alice",
|
|
password: "password-123",
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("seeded admin manages default_project; initial password carries the flag", async () => {
|
|
const admin = await loginAdmin(t.app);
|
|
expect(admin.user.isAdmin).toBe(true);
|
|
expect(admin.user.passwordIsInitial).toBe(true);
|
|
const api = apiClient(t.app, admin.cookie);
|
|
const projects = (await (await api.get("/api/projects")).json()) as ProjectsResponse;
|
|
expect(projects.projects.map((p) => p.projectId)).toContain("default_project");
|
|
expect(projects.projects[0]!.role).toBe("owner");
|
|
// default_agent has been initialized (directory exists).
|
|
await expect(
|
|
fs.access(path.join(t.root, "default_project", "agents", "default_agent", "agent_state")),
|
|
).resolves.toBeUndefined();
|
|
// Seeding is idempotent: re-seeding does not create a duplicate account.
|
|
await t.deps.authService.seedAdmin();
|
|
expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(1);
|
|
});
|
|
|
|
it("admin-created: default Project is <userId>-default_project, name defaults", async () => {
|
|
const bob = await provisionUser(t.app, "bob");
|
|
expect(bob.user.isAdmin).toBe(false);
|
|
expect(bob.user.passwordIsInitial).toBe(true);
|
|
const api = apiClient(t.app, bob.cookie);
|
|
const projects = (await (await api.get("/api/projects")).json()) as ProjectsResponse;
|
|
expect(projects.projects).toHaveLength(1);
|
|
const p = projects.projects[0]!;
|
|
expect(p.projectId).toBe("bob-default_project");
|
|
expect(p.name).toBe("bob");
|
|
expect(p.role).toBe("owner");
|
|
expect(p.ownerUserId).toBe("bob");
|
|
// The initial Project's .project_config.toml carries the display name and preset model config (the default model is written along with it).
|
|
const toml = await fs.readFile(
|
|
path.join(t.root, "bob-default_project", ".project_config.toml"),
|
|
"utf8",
|
|
);
|
|
expect(toml).toContain('name = "bob"');
|
|
expect(toml).toContain(
|
|
'default_model = { provider = "deepseek", model_id = "deepseek-v4-flash" }',
|
|
);
|
|
});
|
|
|
|
it("login / me / logout round trip; wrong password 401", async () => {
|
|
await provisionUser(t.app, "carol");
|
|
const wrong = await t.app.request("/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ userId: "carol", password: "wrong-password" }),
|
|
});
|
|
expect(wrong.status).toBe(401);
|
|
|
|
const { cookie } = await loginUser(t.app, "carol", "password-123");
|
|
expect(cookie.startsWith("penguin_session=")).toBe(true);
|
|
|
|
const api = apiClient(t.app, cookie);
|
|
const me = (await (await api.get("/api/me")).json()) as MeResponse;
|
|
expect(me.user.userId).toBe("carol");
|
|
|
|
const logout = await api.post("/api/auth/logout");
|
|
expect(logout.status).toBe(204);
|
|
const after = await api.get("/api/me");
|
|
expect(after.status).toBe(401);
|
|
});
|
|
|
|
it("self password change: old checked, new takes effect, initial flag cleared", async () => {
|
|
const { cookie } = await provisionUser(t.app, "dave");
|
|
const api = apiClient(t.app, cookie);
|
|
|
|
const wrongOld = await api.put("/api/me/password", {
|
|
oldPassword: "not-the-password",
|
|
newPassword: "new-password-1",
|
|
});
|
|
expect(wrongOld.status).toBe(400);
|
|
const tooShort = await api.put("/api/me/password", {
|
|
oldPassword: "password-123",
|
|
newPassword: "short",
|
|
});
|
|
expect(tooShort.status).toBe(400);
|
|
|
|
const ok = await api.put("/api/me/password", {
|
|
oldPassword: "password-123",
|
|
newPassword: "new-password-1",
|
|
});
|
|
expect(ok.status).toBe(204);
|
|
// After the password change, the current session remains valid and the initial-password flag is cleared.
|
|
const me = (await (await api.get("/api/me")).json()) as MeResponse;
|
|
expect(me.user.passwordIsInitial).toBe(false);
|
|
// The old password is invalidated, and the new password can log in.
|
|
const oldLogin = await t.app.request("/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ userId: "dave", password: "password-123" }),
|
|
});
|
|
expect(oldLogin.status).toBe(401);
|
|
await loginUser(t.app, "dave", "new-password-1");
|
|
});
|
|
|
|
it("write requests reject non-JSON Content-Type (CSRF defense)", async () => {
|
|
const res = await t.app.request("/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: "userId=a&password=b",
|
|
});
|
|
expect(res.status).toBe(415);
|
|
});
|
|
|
|
it("ui prefs read/write", async () => {
|
|
const { cookie } = await provisionUser(t.app, "erin");
|
|
const api = apiClient(t.app, cookie);
|
|
const empty = (await (await api.get("/api/me/prefs")).json()) as { prefs: unknown };
|
|
expect(empty.prefs).toEqual({});
|
|
await api.put("/api/me/prefs", { theme: "dark", lastProjectId: "default_project" });
|
|
const got = (await (await api.get("/api/me/prefs")).json()) as {
|
|
prefs: { theme: string };
|
|
};
|
|
expect(got.prefs.theme).toBe("dark");
|
|
});
|
|
|
|
it("PUT prefs shallow-merges without clobbering other writers' fields", async () => {
|
|
const { cookie } = await provisionUser(t.app, "fred");
|
|
const api = apiClient(t.app, cookie);
|
|
// Simulate two independent writers: switching Project writes lastProjectId, and onboarding writes credentialGuideSeen.
|
|
await api.put("/api/me/prefs", { lastProjectId: "p-1" });
|
|
await api.put("/api/me/prefs", { credentialGuideSeen: true });
|
|
const one = (await (await api.get("/api/me/prefs")).json()) as {
|
|
prefs: { lastProjectId?: string; credentialGuideSeen?: boolean };
|
|
};
|
|
// The second write must not erase the fields from the first (a prior full replace would drop lastProjectId, causing onboarding to reappear repeatedly).
|
|
expect(one.prefs).toEqual({ lastProjectId: "p-1", credentialGuideSeen: true });
|
|
// Switch Project again: credentialGuideSeen is still present.
|
|
await api.put("/api/me/prefs", { lastProjectId: "p-2" });
|
|
const two = (await (await api.get("/api/me/prefs")).json()) as {
|
|
prefs: { lastProjectId?: string; credentialGuideSeen?: boolean };
|
|
};
|
|
expect(two.prefs).toEqual({ lastProjectId: "p-2", credentialGuideSeen: true });
|
|
});
|
|
});
|