diff --git a/tests/registry/access-control.test.ts b/tests/registry/access-control.test.ts new file mode 100644 index 000000000..b55f9415a --- /dev/null +++ b/tests/registry/access-control.test.ts @@ -0,0 +1,171 @@ +/** + * Regression tests for access_control toolset — user aggregate API routing (#876) + * and invite body normalization. + * + * GET /ng/api/user/{userId} returns 405 on ng-manager; the UI uses + * GET /ng/api/user/aggregate/{userId}. List is POST /ng/api/user/aggregate. + */ +import { describe, it, expect, vi } from "vitest"; +import { accessControlToolset } from "../../src/registry/toolsets/access-control.js"; +import { Registry } from "../../src/registry/index.js"; +import type { Config } from "../../src/config.js"; +import type { HarnessClient } from "../../src/client/harness-client.js"; +import type { EndpointSpec, ResourceDefinition } from "../../src/registry/types.js"; + +function findResource(type: string): ResourceDefinition { + const res = accessControlToolset.resources.find((r) => r.resourceType === type); + if (!res) throw new Error(`Resource type "${type}" not found in accessControlToolset`); + return res; +} + +function getOp(type: string, op: string): EndpointSpec { + const res = findResource(type); + const spec = (res.operations as Record)[op]; + if (!spec) throw new Error(`Operation "${op}" not found on "${type}"`); + return spec; +} + +function getExecuteAction(type: string, action: string): EndpointSpec { + const res = findResource(type); + const spec = res.executeActions?.[action]; + if (!spec) throw new Error(`Execute action "${action}" not found on "${type}"`); + return spec; +} + +function makeConfig(overrides: Partial = {}): Config { + return { + HARNESS_API_KEY: "pat.test", + HARNESS_ACCOUNT_ID: "test-account", + HARNESS_BASE_URL: "https://app.harness.io", + HARNESS_ORG: "default-org", + HARNESS_PROJECT: "default-project", + HARNESS_API_TIMEOUT_MS: 30000, + HARNESS_MAX_RETRIES: 3, + LOG_LEVEL: "info", + HARNESS_MAX_BODY_SIZE_MB: 10, + HARNESS_RATE_LIMIT_RPS: 10, + HARNESS_READ_ONLY: false, + HARNESS_SKIP_ELICITATION: false, + HARNESS_ALLOW_HTTP: false, + HARNESS_FME_BASE_URL: "https://api.split.io", + ...overrides, + } as Config; +} + +function makeClient(requestFn: (...args: unknown[]) => unknown = vi.fn().mockResolvedValue({})): HarnessClient { + return { + request: requestFn, + account: "test-account", + } as unknown as HarnessClient; +} + +describe("user resource — aggregate API paths (#876)", () => { + it("list uses POST /ng/api/user/aggregate", () => { + const spec = getOp("user", "list"); + expect(spec.method).toBe("POST"); + expect(spec.path).toBe("/ng/api/user/aggregate"); + expect(spec.path).not.toContain("/ng/api/user/users"); + }); + + it("get uses GET /ng/api/user/aggregate/{userId}", () => { + const spec = getOp("user", "get"); + expect(spec.method).toBe("GET"); + expect(spec.path).toBe("/ng/api/user/aggregate/{userId}"); + expect(spec.path).not.toBe("/ng/api/user/{userId}"); + }); + + it("maps user_id path param to userId placeholder", () => { + const spec = getOp("user", "get"); + expect(spec.pathParams).toEqual({ user_id: "userId" }); + }); + + it("list bodyBuilder maps search_term to searchTerm", () => { + const spec = getOp("user", "list"); + expect(spec.bodyBuilder!({ search_term: "alice@example.com" })).toEqual({ + searchTerm: "alice@example.com", + }); + expect(spec.bodyBuilder!({})).toEqual({ searchTerm: "" }); + }); +}); + +describe("user dispatch — aggregate API integration", () => { + it("harness_get routes to /ng/api/user/aggregate/{userId}", async () => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "access_control" })); + const mockRequest = vi.fn().mockResolvedValue({ + status: "SUCCESS", + data: { user: { uuid: "usr-42", email: "alice@example.com" } }, + }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "user", "get", { user_id: "usr-42" }); + + const call = mockRequest.mock.calls[0]![0] as { method: string; path: string }; + expect(call.method).toBe("GET"); + expect(call.path).toBe("/ng/api/user/aggregate/usr-42"); + expect(call.path).not.toMatch(/\/ng\/api\/user\/usr-42$/); + }); + + it("harness_list posts searchTerm to /ng/api/user/aggregate", async () => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "access_control" })); + const mockRequest = vi.fn().mockResolvedValue({ + status: "SUCCESS", + data: { content: [], totalItems: 0 }, + }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "user", "list", { search_term: "bob" }); + + const call = mockRequest.mock.calls[0]![0] as { + method: string; + path: string; + body: Record; + }; + expect(call.method).toBe("POST"); + expect(call.path).toBe("/ng/api/user/aggregate"); + expect(call.body).toEqual({ searchTerm: "bob" }); + }); +}); + +describe("user invite — body normalization", () => { + it("invite action posts to /ng/api/user/users", () => { + const spec = getExecuteAction("user", "invite"); + expect(spec.method).toBe("POST"); + expect(spec.path).toBe("/ng/api/user/users"); + }); + + it("bodyBuilder normalizes comma-separated emails and snake_case aliases", () => { + const spec = getExecuteAction("user", "invite"); + const body = spec.bodyBuilder!({ + body: { + email_ids: "a@example.com, b@example.com", + user_group_ids: ["grp-1", "grp-2"], + role_bindings: [{ roleIdentifier: "_account_viewer" }], + }, + }) as Record; + + expect(body.emails).toEqual(["a@example.com", "b@example.com"]); + expect(body.userGroups).toEqual(["grp-1", "grp-2"]); + expect(body.roleBindings).toEqual([{ roleIdentifier: "_account_viewer" }]); + }); + + it("dispatchExecute sends normalized invite body", async () => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "access_control" })); + const mockRequest = vi.fn().mockResolvedValue({ status: "SUCCESS" }); + const client = makeClient(mockRequest); + + await registry.dispatchExecute(client, "user", "invite", { + body: { + emails: ["new@example.com"], + user_groups: ["developers"], + }, + }); + + const call = mockRequest.mock.calls[0]![0] as { path: string; body: Record }; + expect(call.path).toBe("/ng/api/user/users"); + expect(call.body).toEqual({ + emails: ["new@example.com"], + userGroups: ["developers"], + roleBindings: [], + }); + }); +}); diff --git a/tests/schemas/schema-bundle-contract.test.ts b/tests/schemas/schema-bundle-contract.test.ts index dc51f3f88..02616bd57 100644 --- a/tests/schemas/schema-bundle-contract.test.ts +++ b/tests/schemas/schema-bundle-contract.test.ts @@ -220,4 +220,83 @@ describe("schema bundle contract", () => { expect(dynamicStage.properties.dynamic.properties).toHaveProperty("source-config"); } }); + + it("includes HttpStepInfo authentication conditional required fields in v0 pipeline and template (#869)", () => { + for (const key of ["pipeline", "template"] as const) { + const defs = SCHEMAS[key].definitions as Record>; + const httpStepInfo = (defs.pipeline.steps.custom as Record).HttpStepInfo as { + allOf: Array<{ + properties?: { + authentication?: { + allOf: Array<{ + if: { properties: { type: { const: string } } }; + then: { properties: { spec: { required: string[] } } }; + }>; + }; + }; + }>; + }; + + const authBlock = httpStepInfo.allOf.find((part) => part.properties?.authentication)?.properties + ?.authentication; + expect(authBlock?.allOf).toBeDefined(); + + const requiredByType = new Map( + authBlock!.allOf.map((branch) => [ + branch.if.properties.type.const, + branch.then.properties.spec.required, + ]), + ); + + expect(requiredByType.get("Basic")).toEqual(["username", "password"]); + expect(requiredByType.get("BearerToken")).toEqual(["token"]); + expect(requiredByType.get("ApiKey")).toEqual(["keyName", "keyValue"]); + } + }); + + it("includes UnifiedStageNodeV1 permissions field in v1 pipeline and template (#864)", () => { + for (const key of ["pipeline_v1", "template_v1"] as const) { + const defs = SCHEMAS[key].definitions as Record>; + const unified = defs[key].stages.unified as Record; + const stage = unified.UnifiedStageNodeV1 as { + properties: Record; + }; + + expect(stage.properties).toHaveProperty("permissions"); + expect(stage.properties.permissions.description).toContain("scoped permissions"); + expect(stage.properties.permissions.additionalProperties).toBe(true); + } + }); + + it("includes Istio-only K8sTrafficRouting provider spec in v0 pipeline and template (#874)", () => { + for (const key of ["pipeline", "template"] as const) { + const defs = SCHEMAS[key].definitions as Record>; + const cdSteps = defs.pipeline.steps.cd as Record; + + const routingSpec = cdSteps.K8sTrafficRoutingSpec as { + properties: { provider: { enum: string[] } }; + allOf: Array<{ + if: { properties: { provider: { const: string } } }; + then: { properties: { spec: { $ref: string } } }; + }>; + }; + + expect(routingSpec.properties.provider.enum).toEqual(["istio"]); + expect(routingSpec.properties.provider.enum).not.toContain("smi"); + + const istioBranch = routingSpec.allOf.find( + (branch) => branch.if.properties.provider.const === "istio", + ); + expect(istioBranch?.then.properties.spec.$ref).toContain("IstioProviderSpec"); + + const istioSpec = cdSteps.IstioProviderSpec as { + allOf: Array<{ properties?: Record }>; + }; + const istioProps = istioSpec.allOf.find((part) => part.properties)?.properties ?? {}; + expect(istioProps).toHaveProperty("gateways"); + expect(istioProps).toHaveProperty("hosts"); + expect(istioProps).toHaveProperty("delegateService"); + expect(istioProps).not.toHaveProperty("rootService"); + } + }); });