Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions tests/registry/access-control.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, EndpointSpec>)[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> = {}): 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<string, unknown>;
};
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<string, unknown>;

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<string, unknown> };
expect(call.path).toBe("/ng/api/user/users");
expect(call.body).toEqual({
emails: ["new@example.com"],
userGroups: ["developers"],
roleBindings: [],
});
});
});
79 changes: 79 additions & 0 deletions tests/schemas/schema-bundle-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, unknown>>;
const httpStepInfo = (defs.pipeline.steps.custom as Record<string, unknown>).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<string, Record<string, unknown>>;
const unified = defs[key].stages.unified as Record<string, unknown>;
const stage = unified.UnifiedStageNodeV1 as {
properties: Record<string, { description?: string; additionalProperties?: boolean }>;
};

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<string, Record<string, unknown>>;
const cdSteps = defs.pipeline.steps.cd as Record<string, unknown>;

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<string, unknown> }>;
};
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");
}
});
});
Loading