feat(cloud-console): add user authentication and administration

This commit is contained in:
2026-07-13 17:54:53 +08:00
parent 035b177128
commit cdef630e67
35 changed files with 4126 additions and 113 deletions
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTH_INVALID_EVENT,
clearStoredToken,
getStoredToken,
listDevices,
login,
registerPlugin,
storeToken,
} from "./api";
function response(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("Cloud Console API authentication", () => {
beforeEach(() => {
clearStoredToken();
document.cookie = "amcp_csrf=; Max-Age=0; path=/";
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
clearStoredToken();
});
it("uses credentialed account login without a bearer header", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
response({
id: "user-a",
username: "admin",
display_name: "Administrator",
role: "admin",
enabled: true,
must_change_password: false,
scopes: ["*"],
created_at: "2026-01-01T00:00:00+00:00",
updated_at: "2026-01-01T00:00:00+00:00",
last_login_at: null,
}),
);
await login("admin", "correct-horse-battery-staple");
expect(fetch).toHaveBeenCalledWith(
expect.stringMatching(/\/v1\/auth\/login$/),
expect.objectContaining({
credentials: "include",
headers: expect.not.objectContaining({ Authorization: expect.any(String) }),
}),
);
});
it("uses CSRF proof for session-authenticated writes", async () => {
document.cookie = "amcp_csrf=csrf-value; path=/";
vi.mocked(fetch).mockResolvedValueOnce(
response({ name: "demo", version: "1", entry_point_kind: "tool", target: "m:t", wired: false }),
);
await registerPlugin({
name: "demo",
version: "1",
entry_point_kind: "tool",
target: "m:t",
});
expect(fetch).toHaveBeenCalledWith(
expect.stringMatching(/\/v1\/plugins$/),
expect.objectContaining({
credentials: "include",
headers: expect.objectContaining({ "X-CSRF-Token": "csrf-value" }),
}),
);
});
it("keeps the explicit compatibility bearer-token path", async () => {
storeToken("compatibility-token");
vi.mocked(fetch).mockResolvedValueOnce(response([]));
await listDevices();
expect(fetch).toHaveBeenCalledWith(
expect.stringMatching(/\/v1\/devices$/),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer compatibility-token" }),
}),
);
});
it("clears authentication only on 401, not on 403", async () => {
storeToken("compatibility-token");
const invalidated = vi.fn();
window.addEventListener(AUTH_INVALID_EVENT, invalidated);
vi.mocked(fetch).mockResolvedValueOnce(response({ detail: "unauthorized" }, 401));
await expect(listDevices()).rejects.toMatchObject({ status: 401 });
expect(getStoredToken()).toBeNull();
expect(invalidated).toHaveBeenCalledTimes(1);
storeToken("compatibility-token");
vi.mocked(fetch).mockResolvedValueOnce(response({ detail: "forbidden" }, 403));
await expect(listDevices()).rejects.toMatchObject({ status: 403 });
expect(getStoredToken()).toBe("compatibility-token");
window.removeEventListener(AUTH_INVALID_EVENT, invalidated);
});
});