diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs index 3b60eff9..360d219e 100644 --- a/internal/dev-entrypoint/http.test.mjs +++ b/internal/dev-entrypoint/http.test.mjs @@ -42,6 +42,18 @@ test("cloud web route policy proxies public Code Agent chat without gating other publicRoute: true, routeKey: "GET /v1/agent/chat/result/trc_live" }); + assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "GET /v1" + }); + assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/m3/status"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "GET /v1/m3/status" + }); assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/app.mjs"), { proxy: false, authRequired: false, diff --git a/web/hwlab-cloud-web/auth.mjs b/web/hwlab-cloud-web/auth.mjs index ce64315f..c51d67e1 100644 --- a/web/hwlab-cloud-web/auth.mjs +++ b/web/hwlab-cloud-web/auth.mjs @@ -73,7 +73,11 @@ async function readActiveSession(config) { if (serverSession.available) { if (!serverSession.authenticated && config.mode !== "server") { const localSession = readLocalSession(config); - if (localSession.authenticated) return localSession; + if (localSession.authenticated) { + const restoredServerSession = await restoreServerSessionFromLocal(config); + if (restoredServerSession.authenticated) return restoredServerSession; + return localSession; + } } return { authenticated: serverSession.authenticated, @@ -90,6 +94,18 @@ async function readActiveSession(config) { return readLocalSession(config); } +async function restoreServerSessionFromLocal(config) { + const serverResult = await attemptServerLogin(config.username, config.password); + if (!serverResult.available || !serverResult.authenticated) return { authenticated: false }; + writeLocalSession(config); + return { + authenticated: true, + mode: "server", + user: serverResult.user, + expiresAt: serverResult.expiresAt + }; +} + async function fetchServerSession() { try { const response = await fetch(AUTH_ROUTE.session, { diff --git a/web/hwlab-cloud-web/auth.test.mjs b/web/hwlab-cloud-web/auth.test.mjs new file mode 100644 index 00000000..64dd7ceb --- /dev/null +++ b/web/hwlab-cloud-web/auth.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ensureWorkbenchAuth } from "./auth.mjs"; + +const AUTH_STORAGE_KEY = "hwlab.cloudWorkbench.auth.v1"; + +test("local auth restore refreshes the server cookie before showing the workbench", async () => { + const harness = installAuthHarness({ + localSession: validLocalSession(), + responses: { + "/auth/session": { authenticated: false }, + "/auth/login": { + authenticated: true, + user: { username: "admin" }, + expiresAt: "2026-05-27T00:00:00.000Z" + } + } + }); + + try { + const session = await ensureWorkbenchAuth({ loginShell: harness.loginShell, appShell: harness.appShell }); + + assert.equal(session.authenticated, true); + assert.equal(session.mode, "server"); + assert.deepEqual(harness.fetchCalls.map((call) => call.path), ["/auth/session", "/auth/login"]); + assert.deepEqual(harness.fetchCalls.map((call) => call.method), ["GET", "POST"]); + assert.deepEqual(JSON.parse(harness.fetchCalls[1].body), { username: "admin", password: "hwlab2026" }); + assert.equal(harness.loginShell.hidden, true); + assert.equal(harness.appShell.hidden, false); + assert.equal(globalThis.document.body.dataset.authState, "authenticated"); + } finally { + harness.restore(); + } +}); + +test("local auth restore remains usable when server cookie refresh is unavailable", async () => { + const harness = installAuthHarness({ + localSession: validLocalSession(), + responses: { + "/auth/session": { authenticated: false }, + "/auth/login": { authenticated: false, error: "invalid_credentials", status: 401 } + } + }); + + try { + const session = await ensureWorkbenchAuth({ loginShell: harness.loginShell, appShell: harness.appShell }); + + assert.equal(session.authenticated, true); + assert.equal(session.mode, "local"); + assert.deepEqual(harness.fetchCalls.map((call) => call.path), ["/auth/session", "/auth/login"]); + assert.equal(harness.loginShell.hidden, true); + assert.equal(harness.appShell.hidden, false); + assert.equal(globalThis.document.body.dataset.authState, "authenticated"); + } finally { + harness.restore(); + } +}); + +function installAuthHarness({ localSession, responses }) { + const previous = { + window: globalThis.window, + document: globalThis.document, + fetch: globalThis.fetch, + config: globalThis.HWLAB_CLOUD_WEB_CONFIG + }; + const storage = new Map(); + if (localSession) storage.set(AUTH_STORAGE_KEY, JSON.stringify(localSession)); + const fetchCalls = []; + const loginShell = elementStub(); + const appShell = elementStub(); + + globalThis.HWLAB_CLOUD_WEB_CONFIG = { auth: { mode: "auto", username: "admin", password: "hwlab2026" } }; + globalThis.document = { body: { dataset: {} } }; + globalThis.window = { + localStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key) + }, + setTimeout: () => 0, + clearTimeout: () => {} + }; + globalThis.fetch = async (path, options = {}) => { + const response = responses[path] ?? { status: 404, authenticated: false }; + fetchCalls.push({ + path, + method: options.method ?? "GET", + body: options.body ?? null, + credentials: options.credentials ?? null + }); + const status = Number(response.status ?? (response.authenticated === false && response.error ? 401 : 200)); + return { + status, + ok: status >= 200 && status < 300, + json: async () => ({ ...response, status: undefined }) + }; + }; + + return { + loginShell, + appShell, + fetchCalls, + restore() { + globalThis.window = previous.window; + globalThis.document = previous.document; + globalThis.fetch = previous.fetch; + globalThis.HWLAB_CLOUD_WEB_CONFIG = previous.config; + } + }; +} + +function validLocalSession() { + return { + version: 1, + username: "admin", + issuedAt: Date.now() - 60_000, + expiresAt: Date.now() + 60 * 60 * 1000 + }; +} + +function elementStub() { + return { + hidden: false, + attributes: new Map(), + setAttribute(name, value) { + this.attributes.set(name, String(value)); + }, + removeAttribute(name) { + this.attributes.delete(name); + } + }; +} diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index bf12a621..7a3309e7 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "m3-readonly": "node scripts/m3-readonly-contract.mjs", - "check": "node --test code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs scripts/sidebar-resize.test.mjs scripts/trace-scroll.test.mjs && node scripts/check.mjs", + "check": "node --test auth.test.mjs code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs scripts/sidebar-resize.test.mjs scripts/trace-scroll.test.mjs && node scripts/check.mjs", "build": "node scripts/build.mjs", "layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json", "layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json",