fix(dev): align gateway smoke auth contract

This commit is contained in:
Vincent Koc
2026-06-07 12:06:56 +02:00
parent f7f2532cac
commit 85840eb10e
2 changed files with 85 additions and 59 deletions
+41 -21
View File
@@ -51,18 +51,40 @@ function hasHealthSummaryPayload(response: unknown): boolean {
);
}
function hasChatHistoryMessages(
response: unknown,
): response is { payload: { messages: unknown[] } } {
if (response === null || typeof response !== "object") {
function hasStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function connectHelloScopes(response: unknown): string[] | null {
if (!isRecord(response) || !isRecord(response.payload)) {
return null;
}
const { payload } = response;
if (
payload.type !== "hello-ok" ||
typeof payload.protocol !== "number" ||
!isRecord(payload.features) ||
!hasStringArray(payload.features.methods) ||
!payload.features.methods.includes("health") ||
!isRecord(payload.auth) ||
payload.auth.role !== "operator" ||
!hasStringArray(payload.auth.scopes)
) {
return null;
}
return payload.auth.scopes;
}
function hasConnectHelloPayload(response: unknown): boolean {
return connectHelloScopes(response) !== null;
}
function hasUnpairedOperatorScopes(response: unknown): boolean {
const scopes = connectHelloScopes(response);
if (!scopes) {
return false;
}
const payload = (response as { payload?: unknown }).payload;
return (
payload !== null &&
typeof payload === "object" &&
Array.isArray((payload as { messages?: unknown }).messages)
);
return scopes.length > 0;
}
export async function runGatewaySmoke(
@@ -109,6 +131,14 @@ export async function runGatewaySmoke(
stderr(`connect failed: ${String(connectRes.error)}`);
return 2;
}
if (!hasConnectHelloPayload(connectRes)) {
stderr("connect failed: missing hello-ok payload");
return 2;
}
if (hasUnpairedOperatorScopes(connectRes)) {
stderr("connect failed: unpaired iOS smoke unexpectedly received operator scopes");
return 2;
}
const healthRes = await request("health");
if (!healthRes.ok) {
@@ -120,17 +150,7 @@ export async function runGatewaySmoke(
return 3;
}
const historyRes = await request("chat.history", { sessionKey: "main" }, 15000);
if (!historyRes.ok) {
stderr(`chat.history failed: ${String(historyRes.error)}`);
return 4;
}
if (!hasChatHistoryMessages(historyRes)) {
stderr("chat.history failed: missing messages array");
return 4;
}
stdout("ok: connected + health + chat.history");
stdout("ok: connected + health");
return 0;
} finally {
close();
+44 -38
View File
@@ -48,6 +48,25 @@ describe("gateway-smoke", () => {
};
}
function connectHelloResponse(scopes: string[] = []) {
return {
ok: true,
payload: {
auth: { role: "operator", scopes },
features: { events: [], methods: ["health"] },
policy: {
maxBufferedBytes: 1024 * 1024,
maxPayload: 256 * 1024,
tickIntervalMs: 1000,
},
protocol: 1,
server: { connId: "test-conn", version: "dev" },
snapshot: {},
type: "hello-ok",
},
};
}
async function listenGatewaySmokeServer() {
const requests: Array<{ method: string; params?: unknown; timeout?: number }> = [];
server = createServer();
@@ -62,7 +81,7 @@ describe("gateway-smoke", () => {
};
requests.push({ method: frame.method, params: frame.params });
if (frame.method === "connect") {
ws.send(JSON.stringify({ id: frame.id, ok: true, payload: {}, type: "res" }));
ws.send(JSON.stringify({ id: frame.id, type: "res", ...connectHelloResponse() }));
return;
}
if (frame.method === "health") {
@@ -72,9 +91,9 @@ describe("gateway-smoke", () => {
if (frame.method === "chat.history") {
ws.send(
JSON.stringify({
error: "missing scope: operator.read",
id: frame.id,
ok: true,
payload: { messages: [] },
ok: false,
type: "res",
}),
);
@@ -162,19 +181,14 @@ describe("gateway-smoke", () => {
);
expect(code).toBe(0);
expect(loopback.requests.map((request) => request.method)).toEqual([
"connect",
"health",
"chat.history",
]);
expect(loopback.requests.map((request) => request.method)).toEqual(["connect", "health"]);
expect(loopback.requests[0]?.params).toMatchObject({
auth: { token: "secret-token" },
client: { id: "openclaw-ios" },
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
});
expect(loopback.requests[2]?.params).toEqual({ sessionKey: "main" });
expect(stdout).toEqual(["ok: connected + health + chat.history"]);
expect(stdout).toEqual(["ok: connected + health"]);
expect(stderr).toEqual([]);
});
@@ -210,11 +224,10 @@ describe("gateway-smoke", () => {
expect(stderr).toEqual(["connect failed: bad token"]);
});
it("requires connect, health, and chat history in order", async () => {
it("requires connect and health in order", async () => {
const fake = createSmokeDeps({
connect: { ok: true },
connect: connectHelloResponse(),
health: healthResponse(),
"chat.history": { ok: true, payload: { messages: [] } },
});
const code = await runGatewaySmoke(
@@ -227,17 +240,14 @@ describe("gateway-smoke", () => {
expect(fake.calls).toEqual([
{ method: "connect", timeout: undefined },
{ method: "health", timeout: undefined },
{ method: "chat.history", timeout: 15000 },
]);
expect(fake.stdout).toEqual(["ok: connected + health + chat.history"]);
expect(fake.stdout).toEqual(["ok: connected + health"]);
expect(fake.stderr).toEqual([]);
});
it("fails when chat history success is missing message evidence", async () => {
it("fails when connect success is missing hello evidence", async () => {
const fake = createSmokeDeps({
connect: { ok: true },
health: healthResponse(),
"chat.history": { ok: true },
});
const code = await runGatewaySmoke(
@@ -245,22 +255,16 @@ describe("gateway-smoke", () => {
fake.deps,
);
expect(code).toBe(4);
expect(code).toBe(2);
expect(fake.closed).toBe(1);
expect(fake.calls).toEqual([
{ method: "connect", timeout: undefined },
{ method: "health", timeout: undefined },
{ method: "chat.history", timeout: 15000 },
]);
expect(fake.calls).toEqual([{ method: "connect", timeout: undefined }]);
expect(fake.stdout).toEqual([]);
expect(fake.stderr).toEqual(["chat.history failed: missing messages array"]);
expect(fake.stderr).toEqual(["connect failed: missing hello-ok payload"]);
});
it("fails when chat history messages are not an array", async () => {
it("fails when the unpaired iOS-shaped connect keeps operator scopes", async () => {
const fake = createSmokeDeps({
connect: { ok: true },
health: healthResponse(),
"chat.history": { ok: true, payload: { messages: {} } },
connect: connectHelloResponse(["operator.read"]),
});
const code = await runGatewaySmoke(
@@ -268,14 +272,17 @@ describe("gateway-smoke", () => {
fake.deps,
);
expect(code).toBe(4);
expect(code).toBe(2);
expect(fake.closed).toBe(1);
expect(fake.stderr).toEqual(["chat.history failed: missing messages array"]);
expect(fake.calls).toEqual([{ method: "connect", timeout: undefined }]);
expect(fake.stderr).toEqual([
"connect failed: unpaired iOS smoke unexpectedly received operator scopes",
]);
});
it("fails after connect when health is unavailable", async () => {
const fake = createSmokeDeps({
connect: { ok: true },
connect: connectHelloResponse(),
health: { ok: false, error: "not healthy" },
});
@@ -292,7 +299,7 @@ describe("gateway-smoke", () => {
it("fails when health success is missing summary evidence", async () => {
const fake = createSmokeDeps({
connect: { ok: true },
connect: connectHelloResponse(),
health: { ok: true },
});
@@ -307,9 +314,9 @@ describe("gateway-smoke", () => {
expect(fake.stderr).toEqual(["health failed: missing health summary payload"]);
});
it("fails after health when chat history is unavailable", async () => {
it("does not call scoped chat history for an unpaired iOS-shaped client", async () => {
const fake = createSmokeDeps({
connect: { ok: true },
connect: connectHelloResponse(),
health: healthResponse(),
"chat.history": { ok: false, error: "session store unavailable" },
});
@@ -319,13 +326,12 @@ describe("gateway-smoke", () => {
fake.deps,
);
expect(code).toBe(4);
expect(code).toBe(0);
expect(fake.closed).toBe(1);
expect(fake.calls).toEqual([
{ method: "connect", timeout: undefined },
{ method: "health", timeout: undefined },
{ method: "chat.history", timeout: 15000 },
]);
expect(fake.stderr).toEqual(["chat.history failed: session store unavailable"]);
expect(fake.stderr).toEqual([]);
});
});