Files
openclaw/src/gateway/server/plugins-http.runtime-scopes.test.ts
Pavan Kumar Gondhi 6517c700de fix(nostr): require operator.admin scope for profile mutation routes [AI] (#63553)
* fix: address issue

* fix: address review feedback

* fix: address review feedback

* fix: finalize issue changes

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* docs: add changelog entry for PR merge
2026-04-10 16:38:41 +05:30

325 lines
10 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SubsystemLogger } from "../../logging/subsystem.js";
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
import {
releasePinnedPluginHttpRouteRegistry,
setActivePluginRegistry,
} from "../../plugins/runtime.js";
import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
import type { AuthorizedGatewayHttpRequest } from "../http-utils.js";
import { authorizeOperatorScopesForMethod } from "../method-scopes.js";
import { makeMockHttpResponse } from "../test-http-response.js";
import { createTestRegistry } from "./__tests__/test-utils.js";
import { createGatewayPluginRequestHandler } from "./plugins-http.js";
function createRoute(params: {
path: string;
auth: "gateway" | "plugin";
match?: "exact" | "prefix";
gatewayRuntimeScopeSurface?: "write-default" | "trusted-operator";
handler?: (req: IncomingMessage, res: ServerResponse) => boolean | Promise<boolean>;
}) {
return {
pluginId: "route",
path: params.path,
auth: params.auth,
gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface,
match: params.match ?? "exact",
handler: params.handler ?? (() => true),
source: "route",
};
}
function createMockLogger(): SubsystemLogger {
const child = vi.fn<(name: string) => SubsystemLogger>();
const logger = {
subsystem: "test/plugins-http-runtime-scopes",
isEnabled: () => true,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
raw: vi.fn(),
child,
} satisfies SubsystemLogger;
child.mockImplementation(() => logger);
return logger as SubsystemLogger;
}
function assertWriteHelperAllowed() {
const scopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes ?? [];
const auth = authorizeOperatorScopesForMethod("agent", scopes);
if (!auth.allowed) {
throw new Error(`missing scope: ${auth.missingScope}`);
}
}
function assertAdminHelperAllowed() {
const scopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes ?? [];
const auth = authorizeOperatorScopesForMethod("set-heartbeats", scopes);
if (!auth.allowed) {
throw new Error(`missing scope: ${auth.missingScope}`);
}
}
describe("plugin HTTP route runtime scopes", () => {
afterEach(() => {
releasePinnedPluginHttpRouteRegistry();
setActivePluginRegistry(createEmptyPluginRegistry());
});
async function invokeRoute(params: {
path: string;
auth: "gateway" | "plugin";
gatewayRuntimeScopeSurface?: "write-default" | "trusted-operator";
gatewayAuthSatisfied: boolean;
gatewayRequestAuth?: AuthorizedGatewayHttpRequest;
gatewayRequestOperatorScopes?: readonly string[];
}) {
const log = createMockLogger();
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
createRoute({
path: params.path,
auth: params.auth,
gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface,
handler: async () => {
assertWriteHelperAllowed();
return true;
},
}),
],
}),
log,
});
const response = makeMockHttpResponse();
const handled = await handler(
{ url: params.path } as IncomingMessage,
response.res,
undefined,
{
gatewayAuthSatisfied: params.gatewayAuthSatisfied,
gatewayRequestAuth: params.gatewayRequestAuth,
gatewayRequestOperatorScopes: params.gatewayRequestOperatorScopes,
},
);
return { handled, log, ...response };
}
it("keeps plugin-auth routes off write-capable runtime helpers", async () => {
const { handled, res, setHeader, end, log } = await invokeRoute({
path: "/hook",
auth: "plugin",
gatewayAuthSatisfied: false,
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(500);
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
expect(end).toHaveBeenCalledWith("Internal Server Error");
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.write"));
});
it("preserves write-capable runtime helpers on gateway-auth routes", async () => {
const { handled, res, log } = await invokeRoute({
path: "/secure-hook",
auth: "gateway",
gatewayAuthSatisfied: true,
gatewayRequestOperatorScopes: ["operator.write"],
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(log.warn).not.toHaveBeenCalled();
});
it("fails closed when gateway-auth route runtime scopes are missing", async () => {
const { handled, res, log } = await invokeRoute({
path: "/secure-hook",
auth: "gateway",
gatewayAuthSatisfied: true,
});
expect(handled).toBe(false);
expect(res.statusCode).toBe(200);
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining("blocked without caller scope context"),
);
});
it("does not allow write helpers for read-scoped gateway-auth requests", async () => {
const { handled, res, setHeader, end, log } = await invokeRoute({
path: "/secure-hook",
auth: "gateway",
gatewayAuthSatisfied: true,
gatewayRequestOperatorScopes: ["operator.read"],
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(500);
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
expect(end).toHaveBeenCalledWith("Internal Server Error");
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.write"));
});
it("restores trusted-operator defaults for routes opting into trusted surface", async () => {
let observedScopes: string[] | undefined;
const log = createMockLogger();
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
createRoute({
path: "/secure-admin-hook",
auth: "gateway",
gatewayRuntimeScopeSurface: "trusted-operator",
handler: async () => {
observedScopes =
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
assertAdminHelperAllowed();
return true;
},
}),
],
}),
log,
});
const response = makeMockHttpResponse();
const handled = await handler(
{ url: "/secure-admin-hook" } as IncomingMessage,
response.res,
undefined,
{
gatewayAuthSatisfied: true,
gatewayRequestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false },
gatewayRequestOperatorScopes: ["operator.write"],
},
);
expect(handled).toBe(true);
expect(response.res.statusCode).toBe(200);
expect(log.warn).not.toHaveBeenCalled();
expect(observedScopes).toEqual(
expect.arrayContaining(["operator.admin", "operator.read", "operator.write"]),
);
});
it("scopes runtime privileges per matched route for exact/prefix overlap", async () => {
const observed: Array<{ route: "exact" | "prefix"; scopes: string[] }> = [];
const log = createMockLogger();
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
createRoute({
path: "/secure/admin-hook",
auth: "gateway",
match: "exact",
handler: async () => {
observed.push({
route: "exact",
scopes:
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [],
});
return false;
},
}),
createRoute({
path: "/secure",
auth: "gateway",
match: "prefix",
gatewayRuntimeScopeSurface: "trusted-operator",
handler: async () => {
observed.push({
route: "prefix",
scopes:
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [],
});
assertAdminHelperAllowed();
return true;
},
}),
],
}),
log,
});
const response = makeMockHttpResponse();
const handled = await handler(
{ url: "/secure/admin-hook" } as IncomingMessage,
response.res,
undefined,
{
gatewayAuthSatisfied: true,
gatewayRequestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false },
gatewayRequestOperatorScopes: ["operator.write"],
},
);
expect(handled).toBe(true);
expect(response.res.statusCode).toBe(200);
expect(log.warn).not.toHaveBeenCalled();
expect(observed).toHaveLength(2);
expect(observed[0]).toEqual({
route: "exact",
scopes: ["operator.write"],
});
expect(observed[1]?.route).toBe("prefix");
expect(observed[1]?.scopes).toEqual(
expect.arrayContaining(["operator.admin", "operator.read", "operator.write"]),
);
});
it.each([
{
auth: "plugin" as const,
gatewayAuthSatisfied: false,
path: "/hook",
gatewayRequestOperatorScopes: undefined,
expectedScopes: [],
},
{
auth: "gateway" as const,
gatewayAuthSatisfied: true,
path: "/secure-hook",
gatewayRequestOperatorScopes: ["operator.read"],
expectedScopes: ["operator.read"],
},
])(
"maps $auth routes to $expectedScopes",
async ({ auth, gatewayAuthSatisfied, gatewayRequestOperatorScopes, path, expectedScopes }) => {
let observedScopes: string[] | undefined;
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
createRoute({
path,
auth,
handler: vi.fn(async () => {
observedScopes =
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
return true;
}),
}),
],
}),
log: createMockLogger(),
});
const { res } = makeMockHttpResponse();
const handled = await handler({ url: path } as IncomingMessage, res, undefined, {
gatewayAuthSatisfied,
gatewayRequestOperatorScopes,
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(observedScopes).toEqual(expectedScopes);
},
);
});