codex-proxy / src /routes /__tests__ /dashboard-login.test.ts
icebear
feat: dashboard login gate for public deployments (#148)
91ee702 unverified
raw
history blame
10.2 kB
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
const mockConfig = {
server: { proxy_api_key: "secret-key" as string | null },
session: { ttl_minutes: 60, cleanup_interval_minutes: 5 },
auth: { rotation_strategy: "least_used" as string },
quota: {
refresh_interval_minutes: 5,
warning_thresholds: { primary: [80, 90], secondary: [80, 90] },
skip_exhausted: true,
},
};
vi.mock("../../config.js", () => ({
getConfig: vi.fn(() => mockConfig),
reloadAllConfigs: vi.fn(),
ROTATION_STRATEGIES: ["least_used", "round_robin", "sticky"],
}));
const mockGetConnInfo = vi.fn(() => ({ remote: { address: "192.168.1.100" } }));
vi.mock("@hono/node-server/conninfo", () => ({
getConnInfo: (...args: unknown[]) => mockGetConnInfo(...args),
}));
vi.mock("../../auth/dashboard-session.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../auth/dashboard-session.js")>();
return actual;
});
vi.mock("../../paths.js", () => ({
getConfigDir: vi.fn(() => "/tmp/test-config"),
getPublicDir: vi.fn(() => "/tmp/test-public"),
getDesktopPublicDir: vi.fn(() => "/tmp/test-desktop"),
getDataDir: vi.fn(() => "/tmp/test-data"),
getBinDir: vi.fn(() => "/tmp/test-bin"),
isEmbedded: vi.fn(() => false),
}));
vi.mock("../../utils/yaml-mutate.js", () => ({
mutateYaml: vi.fn(),
}));
vi.mock("../../tls/transport.js", () => ({
getTransport: vi.fn(),
getTransportInfo: vi.fn(() => ({})),
}));
vi.mock("../../tls/curl-binary.js", () => ({
getCurlDiagnostics: vi.fn(() => ({})),
}));
vi.mock("../../fingerprint/manager.js", () => ({
buildHeaders: vi.fn(() => ({})),
}));
vi.mock("../../update-checker.js", () => ({
getUpdateState: vi.fn(() => ({})),
checkForUpdate: vi.fn(),
isUpdateInProgress: vi.fn(() => false),
}));
vi.mock("../../self-update.js", () => ({
getProxyInfo: vi.fn(() => ({})),
canSelfUpdate: vi.fn(() => false),
checkProxySelfUpdate: vi.fn(),
applyProxySelfUpdate: vi.fn(),
isProxyUpdateInProgress: vi.fn(() => false),
getCachedProxyUpdateResult: vi.fn(() => null),
getDeployMode: vi.fn(() => "git"),
}));
vi.mock("@hono/node-server/serve-static", () => ({
serveStatic: vi.fn(() => vi.fn()),
}));
import {
createDashboardAuthRoutes,
_resetRateLimitForTest,
} from "../dashboard-login.js";
import { createSettingsRoutes } from "../admin/settings.js";
import { _resetForTest } from "../../auth/dashboard-session.js";
function createApp(): Hono {
const app = new Hono();
app.route("/", createDashboardAuthRoutes());
app.route("/", createSettingsRoutes());
return app;
}
describe("dashboard auth endpoints", () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfig.server.proxy_api_key = "secret-key";
mockGetConnInfo.mockReturnValue({ remote: { address: "192.168.1.100" } });
_resetForTest();
_resetRateLimitForTest();
});
describe("POST /auth/dashboard-login", () => {
it("returns 200 and sets cookie with correct password", async () => {
const app = createApp();
const res = await app.request("/auth/dashboard-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: "secret-key" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
const cookie = res.headers.get("set-cookie");
expect(cookie).toContain("_codex_session=");
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Strict");
expect(cookie).toContain("Path=/");
expect(cookie).toContain("Max-Age=");
});
it("returns 401 with wrong password", async () => {
const app = createApp();
const res = await app.request("/auth/dashboard-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: "wrong" }),
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBeTruthy();
});
it("returns 400 with missing body", async () => {
const app = createApp();
const res = await app.request("/auth/dashboard-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
it("sets Secure flag when behind HTTPS reverse proxy", async () => {
const app = createApp();
const res = await app.request("/auth/dashboard-login", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Forwarded-Proto": "https",
},
body: JSON.stringify({ password: "secret-key" }),
});
expect(res.status).toBe(200);
const cookie = res.headers.get("set-cookie");
expect(cookie).toContain("Secure");
});
it("omits Secure flag for HTTP", async () => {
const app = createApp();
const res = await app.request("/auth/dashboard-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: "secret-key" }),
});
expect(res.status).toBe(200);
const cookie = res.headers.get("set-cookie");
expect(cookie).not.toContain("Secure");
});
it("returns 429 after 5 failed attempts", async () => {
const app = createApp();
for (let i = 0; i < 5; i++) {
await app.request("/auth/dashboard-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: "wrong" }),
});
}
const res = await app.request("/auth/dashboard-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: "wrong" }),
});
expect(res.status).toBe(429);
});
});
describe("POST /auth/dashboard-logout", () => {
it("clears session and cookie", async () => {
const app = createApp();
// Login first
const loginRes = await app.request("/auth/dashboard-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: "secret-key" }),
});
const cookie = loginRes.headers.get("set-cookie")!;
const sessionId = cookie.match(/_codex_session=([^;]+)/)![1];
// Logout
const logoutRes = await app.request("/auth/dashboard-logout", {
method: "POST",
headers: { Cookie: `_codex_session=${sessionId}` },
});
expect(logoutRes.status).toBe(200);
const body = await logoutRes.json();
expect(body.success).toBe(true);
const clearCookie = logoutRes.headers.get("set-cookie");
expect(clearCookie).toContain("Max-Age=0");
});
});
describe("GET /auth/dashboard-status", () => {
it("returns required=false when no key configured", async () => {
mockConfig.server.proxy_api_key = null;
const app = createApp();
const res = await app.request("/auth/dashboard-status");
const body = await res.json();
expect(body.required).toBe(false);
expect(body.authenticated).toBe(true);
});
it("returns required=false for localhost", async () => {
mockGetConnInfo.mockReturnValue({ remote: { address: "127.0.0.1" } });
const app = createApp();
const res = await app.request("/auth/dashboard-status");
const body = await res.json();
expect(body.required).toBe(false);
expect(body.authenticated).toBe(true);
});
it("returns required=true, authenticated=false for remote without session", async () => {
const app = createApp();
const res = await app.request("/auth/dashboard-status");
const body = await res.json();
expect(body.required).toBe(true);
expect(body.authenticated).toBe(false);
});
it("returns required=true, authenticated=true for remote with valid session", async () => {
const app = createApp();
// Login first
const loginRes = await app.request("/auth/dashboard-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: "secret-key" }),
});
const cookie = loginRes.headers.get("set-cookie")!;
const sessionId = cookie.match(/_codex_session=([^;]+)/)![1];
const statusRes = await app.request("/auth/dashboard-status", {
headers: { Cookie: `_codex_session=${sessionId}` },
});
const body = await statusRes.json();
expect(body.required).toBe(true);
expect(body.authenticated).toBe(true);
});
});
describe("POST /admin/settings — remote clear protection", () => {
it("blocks remote session from clearing proxy_api_key", async () => {
const app = createApp();
const res = await app.request("/admin/settings", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer secret-key",
},
body: JSON.stringify({ proxy_api_key: null }),
});
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toContain("Cannot clear");
});
it("allows localhost to clear proxy_api_key", async () => {
mockGetConnInfo.mockReturnValue({ remote: { address: "127.0.0.1" } });
const app = createApp();
const res = await app.request("/admin/settings", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer secret-key",
},
body: JSON.stringify({ proxy_api_key: null }),
});
expect(res.status).toBe(200);
});
it("allows remote session to change (not clear) proxy_api_key", async () => {
const app = createApp();
const res = await app.request("/admin/settings", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer secret-key",
},
body: JSON.stringify({ proxy_api_key: "new-key" }),
});
expect(res.status).toBe(200);
});
});
});