Files
Hankan/test/server/http.test.ts
MechaCat02 b48a5f8fb1 fix(server): CORS, without which the phone could never reach the Pi
The Android build always talks to the Pi cross-origin -- a Capacitor
webview serves the app from http://localhost, not from your domain -- so
the browser sends a preflight OPTIONS first. It is not permitted to attach
an Authorization header to that. Auth ran before anything else, so the
preflight came back 401 and the real request was never attempted.

There were no Access-Control-Allow-* headers either, so even a successful
preflight would not have helped. Verified from an actual page before the
fix: GET /api/sync and POST /api/tutor both "Failed to fetch" -- an opaque
network error that points at the network rather than at middleware order.

CORS now runs first and answers OPTIONS itself. Any origin is allowed by
default, which is not a hole: the gate is a bearer token rather than a
cookie, so a hostile page gains nothing from being allowed to send a
request it cannot authenticate. HANKAN_ALLOWED_ORIGINS narrows it.

backends/echo.ts is a keyless backend that reflects the request back in
chunks. Deploying involves a container, a reverse proxy, a token, CORS and
an SSE stream that has to survive compression -- five things that break
independently, none of which involve Anthropic. HANKAN_TUTOR_BACKEND=echo
proves all five from the phone before a key exists and before anything is
billed. CI now runs the server that way, so the tutor endpoint is
exercised over real HTTP rather than only against an injected mock.

test/server/http.test.ts covers the preflight, the allow-origin header on
real responses, Vary: Origin, that a bad token is still refused, and that
the SSE stream parses and terminates with a done event.

Verified end to end in a browser: the Pi configured through the settings
panel, sync pushing 2 rows and a second sync moving 0 (the pushedAt
watermark holding), the header switching from "local stand-in" to
"connected", and a turn streaming back over SSE with the 8,859-character
system prompt intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:47:18 +02:00

101 lines
4.1 KiB
TypeScript

/* 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",
},
});
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);
});
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}` },
});
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");
const text = events.filter((e) => e.type === "delta").map((e) => e.text).join("");
expect(text).toContain("hello"); // the echo backend reflects the message
});
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);
});
});