Skip to content

Commit 85840eb

Browse files
committed
fix(dev): align gateway smoke auth contract
1 parent f7f2532 commit 85840eb

2 files changed

Lines changed: 85 additions & 59 deletions

File tree

scripts/dev/gateway-smoke.ts

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,40 @@ function hasHealthSummaryPayload(response: unknown): boolean {
5151
);
5252
}
5353

54-
function hasChatHistoryMessages(
55-
response: unknown,
56-
): response is { payload: { messages: unknown[] } } {
57-
if (response === null || typeof response !== "object") {
54+
function hasStringArray(value: unknown): value is string[] {
55+
return Array.isArray(value) && value.every((item) => typeof item === "string");
56+
}
57+
58+
function connectHelloScopes(response: unknown): string[] | null {
59+
if (!isRecord(response) || !isRecord(response.payload)) {
60+
return null;
61+
}
62+
const { payload } = response;
63+
if (
64+
payload.type !== "hello-ok" ||
65+
typeof payload.protocol !== "number" ||
66+
!isRecord(payload.features) ||
67+
!hasStringArray(payload.features.methods) ||
68+
!payload.features.methods.includes("health") ||
69+
!isRecord(payload.auth) ||
70+
payload.auth.role !== "operator" ||
71+
!hasStringArray(payload.auth.scopes)
72+
) {
73+
return null;
74+
}
75+
return payload.auth.scopes;
76+
}
77+
78+
function hasConnectHelloPayload(response: unknown): boolean {
79+
return connectHelloScopes(response) !== null;
80+
}
81+
82+
function hasUnpairedOperatorScopes(response: unknown): boolean {
83+
const scopes = connectHelloScopes(response);
84+
if (!scopes) {
5885
return false;
5986
}
60-
const payload = (response as { payload?: unknown }).payload;
61-
return (
62-
payload !== null &&
63-
typeof payload === "object" &&
64-
Array.isArray((payload as { messages?: unknown }).messages)
65-
);
87+
return scopes.length > 0;
6688
}
6789

6890
export async function runGatewaySmoke(
@@ -109,6 +131,14 @@ export async function runGatewaySmoke(
109131
stderr(`connect failed: ${String(connectRes.error)}`);
110132
return 2;
111133
}
134+
if (!hasConnectHelloPayload(connectRes)) {
135+
stderr("connect failed: missing hello-ok payload");
136+
return 2;
137+
}
138+
if (hasUnpairedOperatorScopes(connectRes)) {
139+
stderr("connect failed: unpaired iOS smoke unexpectedly received operator scopes");
140+
return 2;
141+
}
112142

113143
const healthRes = await request("health");
114144
if (!healthRes.ok) {
@@ -120,17 +150,7 @@ export async function runGatewaySmoke(
120150
return 3;
121151
}
122152

123-
const historyRes = await request("chat.history", { sessionKey: "main" }, 15000);
124-
if (!historyRes.ok) {
125-
stderr(`chat.history failed: ${String(historyRes.error)}`);
126-
return 4;
127-
}
128-
if (!hasChatHistoryMessages(historyRes)) {
129-
stderr("chat.history failed: missing messages array");
130-
return 4;
131-
}
132-
133-
stdout("ok: connected + health + chat.history");
153+
stdout("ok: connected + health");
134154
return 0;
135155
} finally {
136156
close();

test/scripts/gateway-smoke.test.ts

Lines changed: 44 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,25 @@ describe("gateway-smoke", () => {
4848
};
4949
}
5050

51+
function connectHelloResponse(scopes: string[] = []) {
52+
return {
53+
ok: true,
54+
payload: {
55+
auth: { role: "operator", scopes },
56+
features: { events: [], methods: ["health"] },
57+
policy: {
58+
maxBufferedBytes: 1024 * 1024,
59+
maxPayload: 256 * 1024,
60+
tickIntervalMs: 1000,
61+
},
62+
protocol: 1,
63+
server: { connId: "test-conn", version: "dev" },
64+
snapshot: {},
65+
type: "hello-ok",
66+
},
67+
};
68+
}
69+
5170
async function listenGatewaySmokeServer() {
5271
const requests: Array<{ method: string; params?: unknown; timeout?: number }> = [];
5372
server = createServer();
@@ -62,7 +81,7 @@ describe("gateway-smoke", () => {
6281
};
6382
requests.push({ method: frame.method, params: frame.params });
6483
if (frame.method === "connect") {
65-
ws.send(JSON.stringify({ id: frame.id, ok: true, payload: {}, type: "res" }));
84+
ws.send(JSON.stringify({ id: frame.id, type: "res", ...connectHelloResponse() }));
6685
return;
6786
}
6887
if (frame.method === "health") {
@@ -72,9 +91,9 @@ describe("gateway-smoke", () => {
7291
if (frame.method === "chat.history") {
7392
ws.send(
7493
JSON.stringify({
94+
error: "missing scope: operator.read",
7595
id: frame.id,
76-
ok: true,
77-
payload: { messages: [] },
96+
ok: false,
7897
type: "res",
7998
}),
8099
);
@@ -162,19 +181,14 @@ describe("gateway-smoke", () => {
162181
);
163182

164183
expect(code).toBe(0);
165-
expect(loopback.requests.map((request) => request.method)).toEqual([
166-
"connect",
167-
"health",
168-
"chat.history",
169-
]);
184+
expect(loopback.requests.map((request) => request.method)).toEqual(["connect", "health"]);
170185
expect(loopback.requests[0]?.params).toMatchObject({
171186
auth: { token: "secret-token" },
172187
client: { id: "openclaw-ios" },
173188
role: "operator",
174189
scopes: ["operator.read", "operator.write", "operator.admin"],
175190
});
176-
expect(loopback.requests[2]?.params).toEqual({ sessionKey: "main" });
177-
expect(stdout).toEqual(["ok: connected + health + chat.history"]);
191+
expect(stdout).toEqual(["ok: connected + health"]);
178192
expect(stderr).toEqual([]);
179193
});
180194

@@ -210,11 +224,10 @@ describe("gateway-smoke", () => {
210224
expect(stderr).toEqual(["connect failed: bad token"]);
211225
});
212226

213-
it("requires connect, health, and chat history in order", async () => {
227+
it("requires connect and health in order", async () => {
214228
const fake = createSmokeDeps({
215-
connect: { ok: true },
229+
connect: connectHelloResponse(),
216230
health: healthResponse(),
217-
"chat.history": { ok: true, payload: { messages: [] } },
218231
});
219232

220233
const code = await runGatewaySmoke(
@@ -227,55 +240,49 @@ describe("gateway-smoke", () => {
227240
expect(fake.calls).toEqual([
228241
{ method: "connect", timeout: undefined },
229242
{ method: "health", timeout: undefined },
230-
{ method: "chat.history", timeout: 15000 },
231243
]);
232-
expect(fake.stdout).toEqual(["ok: connected + health + chat.history"]);
244+
expect(fake.stdout).toEqual(["ok: connected + health"]);
233245
expect(fake.stderr).toEqual([]);
234246
});
235247

236-
it("fails when chat history success is missing message evidence", async () => {
248+
it("fails when connect success is missing hello evidence", async () => {
237249
const fake = createSmokeDeps({
238250
connect: { ok: true },
239-
health: healthResponse(),
240-
"chat.history": { ok: true },
241251
});
242252

243253
const code = await runGatewaySmoke(
244254
{ token: "secret-token", urlRaw: "ws://127.0.0.1:12345" },
245255
fake.deps,
246256
);
247257

248-
expect(code).toBe(4);
258+
expect(code).toBe(2);
249259
expect(fake.closed).toBe(1);
250-
expect(fake.calls).toEqual([
251-
{ method: "connect", timeout: undefined },
252-
{ method: "health", timeout: undefined },
253-
{ method: "chat.history", timeout: 15000 },
254-
]);
260+
expect(fake.calls).toEqual([{ method: "connect", timeout: undefined }]);
255261
expect(fake.stdout).toEqual([]);
256-
expect(fake.stderr).toEqual(["chat.history failed: missing messages array"]);
262+
expect(fake.stderr).toEqual(["connect failed: missing hello-ok payload"]);
257263
});
258264

259-
it("fails when chat history messages are not an array", async () => {
265+
it("fails when the unpaired iOS-shaped connect keeps operator scopes", async () => {
260266
const fake = createSmokeDeps({
261-
connect: { ok: true },
262-
health: healthResponse(),
263-
"chat.history": { ok: true, payload: { messages: {} } },
267+
connect: connectHelloResponse(["operator.read"]),
264268
});
265269

266270
const code = await runGatewaySmoke(
267271
{ token: "secret-token", urlRaw: "ws://127.0.0.1:12345" },
268272
fake.deps,
269273
);
270274

271-
expect(code).toBe(4);
275+
expect(code).toBe(2);
272276
expect(fake.closed).toBe(1);
273-
expect(fake.stderr).toEqual(["chat.history failed: missing messages array"]);
277+
expect(fake.calls).toEqual([{ method: "connect", timeout: undefined }]);
278+
expect(fake.stderr).toEqual([
279+
"connect failed: unpaired iOS smoke unexpectedly received operator scopes",
280+
]);
274281
});
275282

276283
it("fails after connect when health is unavailable", async () => {
277284
const fake = createSmokeDeps({
278-
connect: { ok: true },
285+
connect: connectHelloResponse(),
279286
health: { ok: false, error: "not healthy" },
280287
});
281288

@@ -292,7 +299,7 @@ describe("gateway-smoke", () => {
292299

293300
it("fails when health success is missing summary evidence", async () => {
294301
const fake = createSmokeDeps({
295-
connect: { ok: true },
302+
connect: connectHelloResponse(),
296303
health: { ok: true },
297304
});
298305

@@ -307,9 +314,9 @@ describe("gateway-smoke", () => {
307314
expect(fake.stderr).toEqual(["health failed: missing health summary payload"]);
308315
});
309316

310-
it("fails after health when chat history is unavailable", async () => {
317+
it("does not call scoped chat history for an unpaired iOS-shaped client", async () => {
311318
const fake = createSmokeDeps({
312-
connect: { ok: true },
319+
connect: connectHelloResponse(),
313320
health: healthResponse(),
314321
"chat.history": { ok: false, error: "session store unavailable" },
315322
});
@@ -319,13 +326,12 @@ describe("gateway-smoke", () => {
319326
fake.deps,
320327
);
321328

322-
expect(code).toBe(4);
329+
expect(code).toBe(0);
323330
expect(fake.closed).toBe(1);
324331
expect(fake.calls).toEqual([
325332
{ method: "connect", timeout: undefined },
326333
{ method: "health", timeout: undefined },
327-
{ method: "chat.history", timeout: 15000 },
328334
]);
329-
expect(fake.stderr).toEqual(["chat.history failed: session store unavailable"]);
335+
expect(fake.stderr).toEqual([]);
330336
});
331337
});

0 commit comments

Comments
 (0)