- Formalized Ghost Cockpit Protocol in docs/GHOST_COCKPIT_SPEC.md. - Added `GhostCockpitClient` reference implementation. - Implemented `createWebSocketGuard` in `sdk/hono.ts` to seamlessly terminate invalidated user sessions with code 1008. - Added robust lifecycle cleanups and error checking for WebSocket frame deliveries on socket close. - Added comprehensive integration tests in `sdk/hono.test.ts`. - Cleaned unused imports and fixed all linting warnings. - Moved task definition to complete. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
217 lines
6.2 KiB
TypeScript
217 lines
6.2 KiB
TypeScript
import { Hono } from "jsr:@hono/hono@4";
|
|
import { assertEquals } from "jsr:@std/assert";
|
|
import type { WSContext } from "jsr:@hono/hono@4/ws";
|
|
import {
|
|
createAuthMiddleware,
|
|
createWebSocketGuard,
|
|
requireScope,
|
|
} from "./hono.ts";
|
|
import { stub } from "jsr:@std/testing/mock";
|
|
import { AuthSdk } from "./mod.ts";
|
|
|
|
type Variables = {
|
|
userId: string;
|
|
scopes: string[];
|
|
};
|
|
|
|
Deno.test("AuthMiddleware - Authorized request sets userId and scopes", async () => {
|
|
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
|
const validateSessionStub = stub(
|
|
sdk,
|
|
"validateSession",
|
|
() =>
|
|
Promise.resolve({
|
|
valid: true,
|
|
uuid: "test-user-id",
|
|
scopes: ["commander", "viewer"],
|
|
}),
|
|
);
|
|
|
|
const app = new Hono<{ Variables: Variables }>();
|
|
app.use("*", createAuthMiddleware(sdk));
|
|
app.get(
|
|
"/",
|
|
(c) => c.json({ userId: c.get("userId"), scopes: c.get("scopes") }),
|
|
);
|
|
|
|
const req = new Request("http://localhost/", {
|
|
headers: {
|
|
"Authorization": "Bearer valid-token",
|
|
},
|
|
});
|
|
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 200);
|
|
const json = await res.json();
|
|
assertEquals(json, {
|
|
userId: "test-user-id",
|
|
scopes: ["commander", "viewer"],
|
|
});
|
|
|
|
validateSessionStub.restore();
|
|
});
|
|
|
|
Deno.test("AuthMiddleware - Unauthorized request (missing token)", async () => {
|
|
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
|
|
|
const app = new Hono<{ Variables: Variables }>();
|
|
app.use("*", createAuthMiddleware(sdk));
|
|
app.get("/", (c) => c.text(`User: ${c.get("userId")}`));
|
|
|
|
const req = new Request("http://localhost/");
|
|
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 401);
|
|
const json = await res.json();
|
|
assertEquals(json, { error: "Unauthorized: Missing session token" });
|
|
});
|
|
|
|
Deno.test("AuthMiddleware - Unauthorized request (invalid token)", async () => {
|
|
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
|
const validateSessionStub = stub(
|
|
sdk,
|
|
"validateSession",
|
|
() => Promise.resolve({ valid: false, error: "Invalid token" }),
|
|
);
|
|
|
|
const app = new Hono<{ Variables: Variables }>();
|
|
app.use("*", createAuthMiddleware(sdk));
|
|
app.get("/", (c) => c.text(`User: ${c.get("userId")}`));
|
|
|
|
const req = new Request("http://localhost/", {
|
|
headers: {
|
|
"Authorization": "Bearer invalid-token",
|
|
},
|
|
});
|
|
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 401);
|
|
const json = await res.json();
|
|
assertEquals(json, { error: "Invalid token" });
|
|
|
|
validateSessionStub.restore();
|
|
});
|
|
|
|
Deno.test("requireScope - allows user when scope is present", async () => {
|
|
const app = new Hono<{ Variables: Variables }>();
|
|
app.use("*", (c, next) => {
|
|
c.set("userId", "test-user");
|
|
c.set("scopes", ["commander", "viewer"]);
|
|
return next();
|
|
});
|
|
app.get("/command", requireScope("commander"), (c) => c.text("Command OK"));
|
|
|
|
const req = new Request("http://localhost/command");
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 200);
|
|
assertEquals(await res.text(), "Command OK");
|
|
});
|
|
|
|
Deno.test("requireScope - allows user when global admin scope is present", async () => {
|
|
const app = new Hono<{ Variables: Variables }>();
|
|
app.use("*", (c, next) => {
|
|
c.set("userId", "admin-user");
|
|
c.set("scopes", ["admin"]);
|
|
return next();
|
|
});
|
|
app.get(
|
|
"/restricted",
|
|
requireScope("special-scope"),
|
|
(c) => c.text("Admin Override OK"),
|
|
);
|
|
|
|
const req = new Request("http://localhost/restricted");
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 200);
|
|
assertEquals(await res.text(), "Admin Override OK");
|
|
});
|
|
|
|
Deno.test("requireScope - rejects user when scope is missing", async () => {
|
|
const app = new Hono<{ Variables: Variables }>();
|
|
app.use("*", (c, next) => {
|
|
c.set("userId", "test-user");
|
|
c.set("scopes", ["viewer"]);
|
|
return next();
|
|
});
|
|
app.get("/command", requireScope("commander"), (c) => c.text("Command OK"));
|
|
|
|
const req = new Request("http://localhost/command");
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 403);
|
|
const json = await res.json();
|
|
assertEquals(json, {
|
|
error: "Forbidden: Required scope 'commander' missing.",
|
|
});
|
|
});
|
|
|
|
Deno.test("createWebSocketGuard - closes immediately if no token is provided", () => {
|
|
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
|
const guard = createWebSocketGuard(sdk);
|
|
|
|
let sentData: string | undefined;
|
|
let closeCode: number | undefined;
|
|
let closeReason: string | undefined;
|
|
|
|
const mockWs = {
|
|
send: (data: string) => {
|
|
sentData = data;
|
|
},
|
|
close: (code: number, reason: string) => {
|
|
closeCode = code;
|
|
closeReason = reason;
|
|
},
|
|
} as unknown as WSContext;
|
|
|
|
guard.onOpen!(new Event("open"), mockWs);
|
|
|
|
assertEquals(
|
|
sentData,
|
|
JSON.stringify({ type: "AUTH_REVOKED", reason: "SESSION_EXPIRED" }),
|
|
);
|
|
assertEquals(closeCode, 1008);
|
|
assertEquals(closeReason, "Session Expired");
|
|
});
|
|
|
|
Deno.test("createWebSocketGuard - handles invalidate event and cleans up on close", () => {
|
|
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
|
const token = "test-token-123";
|
|
const guard = createWebSocketGuard(sdk, token);
|
|
|
|
let sentData: string | undefined;
|
|
let closeCode: number | undefined;
|
|
let closeReason: string | undefined;
|
|
|
|
const mockWs = {
|
|
send: (data: string) => {
|
|
sentData = data;
|
|
},
|
|
close: (code: number, reason: string) => {
|
|
closeCode = code;
|
|
closeReason = reason;
|
|
},
|
|
} as unknown as WSContext;
|
|
|
|
guard.onOpen!(new Event("open"), mockWs);
|
|
|
|
// Trigger invalidation for a different token (should do nothing)
|
|
sdk["emit"]("invalidate", "other-token");
|
|
assertEquals(sentData, undefined);
|
|
assertEquals(closeCode, undefined);
|
|
|
|
// Trigger invalidation for the matching token
|
|
sdk["emit"]("invalidate", token);
|
|
assertEquals(
|
|
sentData,
|
|
JSON.stringify({ type: "AUTH_REVOKED", reason: "SESSION_EXPIRED" }),
|
|
);
|
|
assertEquals(closeCode, 1008);
|
|
assertEquals(closeReason, "Session Expired");
|
|
|
|
// Verify listener is registered
|
|
assertEquals(sdk["listeners"].get("invalidate")?.size, 1);
|
|
|
|
// Verify listener cleanup on close
|
|
guard.onClose!(new CloseEvent("close"), mockWs);
|
|
assertEquals(sdk["listeners"].has("invalidate"), false);
|
|
});
|