/* The server as a browser actually meets it: over HTTP, cross-origin. These need a running server (HANKAN_TEST_SERVER) and are skipped otherwise. The tutor half needs HANKAN_TUTOR_BACKEND=echo, which is keyless — the point is the transport, not the model. The bug this file exists for: CORS was absent and the auth middleware ran first, so a preflight OPTIONS — which a browser is not allowed to send credentials on — came back 401 and every cross-origin request failed before reaching any handler. The Android build always talks to the Pi cross-origin, so that was launch-blocking for the phone, and it presents as an opaque "Failed to fetch" rather than as an auth error. */ import { describe, it, expect } from "vitest"; const BASE = process.env.HANKAN_TEST_SERVER; const TOKEN = process.env.HANKAN_TEST_TOKEN ?? "test-token"; const run = BASE ? describe : describe.skip; /** What a Capacitor webview sends as its Origin. */ const PHONE_ORIGIN = "http://localhost"; run("CORS", () => { it("answers the preflight instead of rejecting it as unauthorised", async () => { const res = await fetch(`${BASE}/api/sync`, { method: "OPTIONS", headers: { origin: PHONE_ORIGIN, "access-control-request-method": "POST", "access-control-request-headers": "authorization,content-type,x-hankan-protocol", }, }); expect(res.status).not.toBe(401); expect(res.status).toBeLessThan(300); expect(res.headers.get("access-control-allow-origin")).toBe(PHONE_ORIGIN); expect(res.headers.get("access-control-allow-headers")).toMatch(/authorization/i); // Without this the phone's browser refuses to send the protocol header, // and every sync from it is answered 426. expect(res.headers.get("access-control-allow-headers")).toMatch(/x-hankan-protocol/i); }); it("allows the Authorization header, without which the token cannot be sent", async () => { const res = await fetch(`${BASE}/api/tutor`, { method: "OPTIONS", headers: { origin: PHONE_ORIGIN, "access-control-request-method": "POST" }, }); expect(res.headers.get("access-control-allow-headers")).toMatch(/authorization/i); }); it("puts the allow-origin header on the real response too", async () => { const res = await fetch(`${BASE}/api/sync?cursor=0`, { headers: { origin: PHONE_ORIGIN, authorization: `Bearer ${TOKEN}`, "x-hankan-protocol": "2" }, }); expect(res.status).toBe(200); expect(res.headers.get("access-control-allow-origin")).toBe(PHONE_ORIGIN); // Caches must not serve one origin's response to another. expect(res.headers.get("vary")).toMatch(/origin/i); }); it("still refuses a bad token", async () => { const res = await fetch(`${BASE}/api/sync?cursor=0`, { headers: { origin: PHONE_ORIGIN, authorization: "Bearer wrong" }, }); expect(res.status).toBe(401); }); }); run("tutor over HTTP", () => { it("streams SSE events that parse, and ends with done", async () => { const res = await fetch(`${BASE}/api/tutor`, { method: "POST", headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ system: "system prompt here", history: [], message: "hello" }), }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toMatch(/text\/event-stream/); // Compression is the usual reason SSE appears to hang behind a proxy. expect(res.headers.get("cache-control")).toMatch(/no-transform/); const body = await res.text(); const events = body .split("\n\n") .map((b) => b.split("\n").find((l) => l.startsWith("data:"))) .filter((l): l is string => Boolean(l)) .map((l) => JSON.parse(l.slice(5).trim()) as { type: string; text?: string }); expect(events.length).toBeGreaterThan(1); expect(events.some((e) => e.type === "delta")).toBe(true); expect(events[events.length - 1]!.type).toBe("done"); /* Deliberately not asserting the content. This file tests the transport, and the server may be configured with any backend — anthropic, openai or echo. Asserting the echo backend's wording made the test fail the moment the server was pointed at a real model, which is exactly the case it should have kept working through. */ const text = events.filter((e) => e.type === "delta").map((e) => e.text).join(""); expect(text.length).toBeGreaterThan(0); }); it("rejects a request with no system prompt", async () => { const res = await fetch(`${BASE}/api/tutor`, { method: "POST", headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ message: "hello" }), }); expect(res.status).toBe(400); }); });