Some checks failed
CI / Frontend Lint (pull_request) Failing after 6s
CI / Frontend Type Check (pull_request) Failing after 6s
CI / Frontend Build (pull_request) Failing after 8s
CI / Frontend E2E Tests (pull_request) Failing after 8s
CI / Orchestrator Build (pull_request) Failing after 7s
CI / Contracts Compile (pull_request) Failing after 5s
CI / Contracts Test (pull_request) Failing after 5s
Code Quality / SonarQube Analysis (pull_request) Failing after 18s
Code Quality / Code Quality Checks (pull_request) Failing after 4s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 5s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s
Closes gap-analysis v2 §7.7. - API_KEYS entries now accept the form key:role (back-compat: bare keys default to role=operator). Known roles come from ActorRole in transactionState.ts (coordinator / approver / releaser / validator / exception_manager / operator). - apiKeyAuth + optionalApiKeyAuth inject req.actorRole alongside req.apiKey so the SoD enforcement in the state machine can consult the authenticated role directly. - New requireRole(...roles) guard for per-route role gating. - Fail-closed: unknown roles are skipped during parsing, not silently promoted to operator. Cache auto-invalidates when API_KEYS changes. - 9 unit tests.
135 lines
4.9 KiB
TypeScript
135 lines
4.9 KiB
TypeScript
/**
|
|
* Tests for API-key role binding (gap v2 §7.7).
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
|
|
import type { Request, Response, NextFunction } from "express";
|
|
import {
|
|
apiKeyAuth,
|
|
optionalApiKeyAuth,
|
|
requireRole,
|
|
__resetApiKeyCacheForTests,
|
|
} from "../../src/middleware/apiKeyAuth";
|
|
|
|
function makeReqRes(headers: Record<string, string> = {}) {
|
|
const req = { headers } as unknown as Request;
|
|
const json = jest.fn();
|
|
const status = jest.fn().mockReturnValue({ json }) as unknown as Response["status"];
|
|
const res = { status, json } as unknown as Response;
|
|
const next = jest.fn() as unknown as NextFunction;
|
|
return { req, res, next, status, json };
|
|
}
|
|
|
|
describe("apiKeyAuth role binding", () => {
|
|
beforeEach(() => {
|
|
__resetApiKeyCacheForTests();
|
|
process.env.API_KEYS = "";
|
|
});
|
|
|
|
it("rejects when no key is supplied (401)", () => {
|
|
const { req, res, next, status } = makeReqRes();
|
|
apiKeyAuth(req, res, next);
|
|
expect(status).toHaveBeenCalledWith(401);
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects when the key is not registered (403)", () => {
|
|
process.env.API_KEYS = "good-key:approver";
|
|
const { req, res, next, status } = makeReqRes({ "x-api-key": "bad-key" });
|
|
apiKeyAuth(req, res, next);
|
|
expect(status).toHaveBeenCalledWith(403);
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("binds role=operator for bare keys (back-compat)", () => {
|
|
process.env.API_KEYS = "legacy-key";
|
|
const { req, res, next } = makeReqRes({ "x-api-key": "legacy-key" });
|
|
apiKeyAuth(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
expect((req as Request & { actorRole?: string }).actorRole).toBe("operator");
|
|
});
|
|
|
|
it("binds the declared role for key:role entries", () => {
|
|
process.env.API_KEYS = "k1:approver,k2:releaser,k3:validator";
|
|
const cases: Array<[string, string]> = [
|
|
["k1", "approver"],
|
|
["k2", "releaser"],
|
|
["k3", "validator"],
|
|
];
|
|
for (const [key, role] of cases) {
|
|
__resetApiKeyCacheForTests();
|
|
const { req, res, next } = makeReqRes({ "x-api-key": key });
|
|
apiKeyAuth(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
expect((req as Request & { actorRole?: string }).actorRole).toBe(role);
|
|
}
|
|
});
|
|
|
|
it("fails closed on unknown roles — entry is skipped", () => {
|
|
process.env.API_KEYS = "k1:root,k2:approver";
|
|
const reject = makeReqRes({ "x-api-key": "k1" });
|
|
apiKeyAuth(reject.req, reject.res, reject.next);
|
|
expect(reject.status).toHaveBeenCalledWith(403);
|
|
|
|
__resetApiKeyCacheForTests();
|
|
process.env.API_KEYS = "k1:root,k2:approver";
|
|
const accept = makeReqRes({ "x-api-key": "k2" });
|
|
apiKeyAuth(accept.req, accept.res, accept.next);
|
|
expect(accept.next).toHaveBeenCalled();
|
|
expect(
|
|
(accept.req as Request & { actorRole?: string }).actorRole,
|
|
).toBe("approver");
|
|
});
|
|
|
|
it("accepts Bearer authorization header", () => {
|
|
process.env.API_KEYS = "bearer-key:releaser";
|
|
const { req, res, next } = makeReqRes({
|
|
authorization: "Bearer bearer-key",
|
|
});
|
|
apiKeyAuth(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
expect((req as Request & { actorRole?: string }).actorRole).toBe("releaser");
|
|
});
|
|
|
|
it("re-parses the cache when API_KEYS changes", () => {
|
|
process.env.API_KEYS = "v1:approver";
|
|
const first = makeReqRes({ "x-api-key": "v1" });
|
|
apiKeyAuth(first.req, first.res, first.next);
|
|
expect(first.next).toHaveBeenCalled();
|
|
|
|
process.env.API_KEYS = "v2:releaser";
|
|
const second = makeReqRes({ "x-api-key": "v1" });
|
|
apiKeyAuth(second.req, second.res, second.next);
|
|
expect(second.status).toHaveBeenCalledWith(403);
|
|
});
|
|
|
|
it("optionalApiKeyAuth is a pass-through when no key is supplied", () => {
|
|
const { req, res, next } = makeReqRes();
|
|
optionalApiKeyAuth(req, res, next);
|
|
expect(next).toHaveBeenCalled();
|
|
expect((req as Request & { actorRole?: string }).actorRole).toBeUndefined();
|
|
});
|
|
|
|
it("requireRole lets permitted roles through and 403s others", () => {
|
|
process.env.API_KEYS = "a:approver,r:releaser";
|
|
const guard = requireRole("approver");
|
|
|
|
__resetApiKeyCacheForTests();
|
|
const ok = makeReqRes({ "x-api-key": "a" });
|
|
apiKeyAuth(ok.req, ok.res, ok.next);
|
|
const okNext = jest.fn() as unknown as NextFunction;
|
|
const okStatus = jest.fn().mockReturnValue({ json: jest.fn() }) as unknown as Response["status"];
|
|
guard(ok.req, { status: okStatus } as unknown as Response, okNext);
|
|
expect(okNext).toHaveBeenCalled();
|
|
|
|
__resetApiKeyCacheForTests();
|
|
const bad = makeReqRes({ "x-api-key": "r" });
|
|
apiKeyAuth(bad.req, bad.res, bad.next);
|
|
const badNext = jest.fn() as unknown as NextFunction;
|
|
const badStatus = jest.fn().mockReturnValue({ json: jest.fn() }) as unknown as Response["status"];
|
|
guard(bad.req, { status: badStatus } as unknown as Response, badNext);
|
|
expect(badStatus).toHaveBeenCalledWith(403);
|
|
expect(badNext).not.toHaveBeenCalled();
|
|
});
|
|
});
|