Files
CurrenciCombo/orchestrator/tests/unit/apiKeyAuth.test.ts
Devin 5a66cf87c8
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
API-key role binding: inject req.actorRole
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.
2026-04-22 18:17:05 +00:00

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();
});
});