feat(panel): local-password login and forced password change
Add the op.console login and forced first-login password-change flow to the panel. RequireAuth bounces an unauthenticated visitor to /login; both /login and /change-password render outside the app shell with their own centered chrome. - TierProvider now derives auth state (deriveAuth) and exposes refresh() so a successful login re-fetches identity without a full reload; only a genuine 401 marks the session unauthenticated, so a transient /me failure keeps a healthy Zero-Trust principal in the app. - api.login/logout/changePassword send Content-Type: application/json on bodied requests to satisfy the backend guard; humanizeError maps the auth error codes to stable copy. Covered by vitest unit tests for deriveAuth branch coverage and the login/change-password wire-shape contracts.
This commit is contained in:
12 files changed
+688
-58
No files matched your search
@@ -62,4 +62,92 @@ describe("api.me wire shape", () => {
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
expect((opts as RequestInit).credentials).toBe("include");
|
||||
});
|
||||
|
||||
it("surfaces must_change_password from GET /me verbatim", async () => {
|
||||
// handleMe always emits must_change_password; the forced-change gate routes on
|
||||
// it, so the snake_case key must survive the untyped boundary unchanged.
|
||||
const body = {
|
||||
user_id: "u4",
|
||||
email: "[email protected]",
|
||||
role: "admin",
|
||||
is_admin: true,
|
||||
must_change_password: true,
|
||||
};
|
||||
vi.stubGlobal("fetch", fakeFetch(body));
|
||||
const id = await api.me();
|
||||
expect(id.must_change_password).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-password auth wire shapes", () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("login POSTs {username, password} and returns must_change_password", async () => {
|
||||
// EXACTLY handlers_auth.go handleLogin's request body and response.
|
||||
const fetchSpy = fakeFetch({
|
||||
user_id: "u1",
|
||||
role: "admin",
|
||||
must_change_password: true,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.login("owner", "s3cret");
|
||||
expect(res.must_change_password).toBe(true);
|
||||
expect(res.user_id).toBe("u1");
|
||||
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(String(url)).toBe("/auth/login");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
expect((opts as RequestInit).credentials).toBe("include");
|
||||
// The Go login route now REQUIRES Content-Type: application/json (it 415s any
|
||||
// other type to kill the cross-site form-POST forgery vector). This pins the
|
||||
// panel half of that contract: a refactor that drops the header silently breaks
|
||||
// login, and only this assertion would catch it.
|
||||
expect((opts as RequestInit).headers).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
||||
username: "owner",
|
||||
password: "s3cret",
|
||||
});
|
||||
});
|
||||
|
||||
it("logout POSTs to /auth/logout (idempotent {ok:true})", async () => {
|
||||
const fetchSpy = fakeFetch({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.logout();
|
||||
expect(res.ok).toBe(true);
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(String(url)).toBe("/auth/logout");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
});
|
||||
|
||||
it("changePassword POSTs {current_password, new_password}", async () => {
|
||||
const fetchSpy = fakeFetch({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
await api.changePassword("old-pw", "brand-new-pw");
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(String(url)).toBe("/auth/change-password");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
// Same JSON content-type contract as login — the change-password route guards on
|
||||
// it too (defense-in-depth), so the panel must keep sending it.
|
||||
expect((opts as RequestInit).headers).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
||||
current_password: "old-pw",
|
||||
new_password: "brand-new-pw",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the auth error codes to stable human copy", async () => {
|
||||
const { humanizeError } = await import("./api");
|
||||
expect(humanizeError({ code: "invalid_credentials" })).toMatch(/incorrect/i);
|
||||
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
|
||||
expect(humanizeError({ code: "weak_password" })).toMatch(/8 and 72/);
|
||||
expect(humanizeError({ code: "password_unchanged" })).toMatch(/differ/i);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user