diff --git a/tests/prompts/feature-flag-rollout.test.ts b/tests/prompts/feature-flag-rollout.test.ts new file mode 100644 index 000000000..56c40251f --- /dev/null +++ b/tests/prompts/feature-flag-rollout.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { registerFeatureFlagRolloutPrompt } from "../../src/prompts/feature-flag-rollout.js"; + +async function createTestClient(): Promise { + const server = new McpServer( + { name: "test-server", version: "0.0.1" }, + { capabilities: { prompts: {} } }, + ); + registerFeatureFlagRolloutPrompt(server); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "0.0.1" }); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + return client; +} + +async function getPromptText(args: Record): Promise { + const client = await createTestClient(); + const result = await client.getPrompt({ + name: "feature-flag-rollout", + arguments: args, + }); + return (result.messages[0].content as { type: string; text: string }).text; +} + +describe("feature-flag-rollout prompt", () => { + it("appears in the prompt list with rollout workflow description", async () => { + const client = await createTestClient(); + const { prompts } = await client.listPrompts(); + + const prompt = prompts.find((p) => p.name === "feature-flag-rollout"); + expect(prompt).toBeDefined(); + expect(prompt!.description).toContain("progressive FME feature flag rollout"); + }); + + it("documents workspaceId, orgId, and projectId as optional dual-mode scope args", async () => { + const client = await createTestClient(); + const { prompts } = await client.listPrompts(); + const prompt = prompts.find((p) => p.name === "feature-flag-rollout")!; + + const argNames = prompt.arguments!.map((a) => a.name); + expect(argNames).toEqual( + expect.arrayContaining(["featureFlagName", "workspaceId", "orgId", "projectId"]), + ); + + const workspaceId = prompt.arguments!.find((a) => a.name === "workspaceId")!; + const orgId = prompt.arguments!.find((a) => a.name === "orgId")!; + const projectId = prompt.arguments!.find((a) => a.name === "projectId")!; + + expect(workspaceId.required).toBeFalsy(); + expect(orgId.required).toBeFalsy(); + expect(projectId.required).toBeFalsy(); + }); + + it("requires featureFlagName", async () => { + const client = await createTestClient(); + const { prompts } = await client.listPrompts(); + const prompt = prompts.find((p) => p.name === "feature-flag-rollout")!; + + const featureFlagName = prompt.arguments!.find((a) => a.name === "featureFlagName")!; + expect(featureFlagName.required).toBe(true); + }); + + it("rejects when neither workspaceId nor orgId+projectId are provided", async () => { + const client = await createTestClient(); + + await expect( + client.getPrompt({ + name: "feature-flag-rollout", + arguments: { featureFlagName: "my_flag" }, + }), + ).rejects.toThrow(/Provide either workspaceId.*or orgId \+ projectId/); + }); + + it("rejects partial Harness-native scope (orgId only)", async () => { + const client = await createTestClient(); + + await expect( + client.getPrompt({ + name: "feature-flag-rollout", + arguments: { featureFlagName: "my_flag", orgId: "default" }, + }), + ).rejects.toThrow(/Provide either workspaceId.*or orgId \+ projectId/); + }); + + it("rejects partial Harness-native scope (projectId only)", async () => { + const client = await createTestClient(); + + await expect( + client.getPrompt({ + name: "feature-flag-rollout", + arguments: { featureFlagName: "my_flag", projectId: "payments" }, + }), + ).rejects.toThrow(/Provide either workspaceId.*or orgId \+ projectId/); + }); + + it("legacy mode: interpolates workspace_id scope args and omits Harness-native caveat", async () => { + const text = await getPromptText({ + featureFlagName: "checkout_v2", + workspaceId: "ws-legacy-1", + }); + + expect(text).toContain('feature flag "checkout_v2"'); + expect(text).toContain('workspace_id="ws-legacy-1"'); + expect(text).not.toContain("org_id="); + expect(text).not.toContain("Harness-native mode"); + expect(text).toContain('resource_type="fme_feature_flag"'); + expect(text).toContain('action="kill"'); + expect(text).toContain('action="restore"'); + }); + + it("Harness-native mode: interpolates org_id/project_id and surfaces NYI caveat", async () => { + const text = await getPromptText({ + featureFlagName: "checkout_v2", + orgId: "default", + projectId: "payments", + }); + + expect(text).toContain('org_id="default", project_id="payments"'); + expect(text).not.toContain("workspace_id="); + expect(text).toContain("Harness-native mode (org_id/project_id)"); + expect(text).toContain("fme_feature_flag_definition"); + expect(text).toContain("fme_rollout_status"); + expect(text).toContain("steps 3, 4, and 7"); + }); +}); diff --git a/tests/registry/governance.test.ts b/tests/registry/governance.test.ts index 792e561cb..f7cecfd99 100644 --- a/tests/registry/governance.test.ts +++ b/tests/registry/governance.test.ts @@ -496,4 +496,155 @@ describe("policy and policy_set multi-scope support", () => { expect(mockRequest).not.toHaveBeenCalled(); }, ); + + it.each(["policy", "policy_set"] as const)( + "%s get omits org/project for resource_scope=account", + async (resourceType) => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" })); + const mockRequest = vi.fn().mockResolvedValue({ identifier: "acct-policy" }); + const client = makeClient(mockRequest); + const idField = resourceType === "policy" ? "policy_id" : "policy_set_id"; + + await registry.dispatch(client, resourceType, "get", { + resource_scope: "account", + [idField]: "acct-policy", + }); + + const call = mockRequest.mock.calls[0][0] as { params: Record; path: string }; + expect(call.params.orgIdentifier).toBeUndefined(); + expect(call.params.projectIdentifier).toBeUndefined(); + expect(call.path).toContain("acct-policy"); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s create omits org/project for resource_scope=account", + async (resourceType) => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" })); + const mockRequest = vi.fn().mockResolvedValue({ identifier: "new-policy" }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, resourceType, "create", { + resource_scope: "account", + body: { + identifier: "new-policy", + name: "New Policy", + ...(resourceType === "policy" + ? { rego: "package harness\n\ndefault allow = true" } + : { + action: "onstep", + type: "sbom", + enabled: true, + }), + }, + }); + + const call = mockRequest.mock.calls[0][0] as { params: Record; method: string }; + expect(call.method).toBe("POST"); + expect(call.params.orgIdentifier).toBeUndefined(); + expect(call.params.projectIdentifier).toBeUndefined(); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s update injects only org for resource_scope=org", + async (resourceType) => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" })); + const mockRequest = vi.fn().mockResolvedValue({ identifier: "org-policy" }); + const client = makeClient(mockRequest); + const idField = resourceType === "policy" ? "policy_id" : "policy_set_id"; + + await registry.dispatch(client, resourceType, "update", { + resource_scope: "org", + org_id: "platform", + [idField]: "org-policy", + body: { name: "Updated" }, + }); + + const call = mockRequest.mock.calls[0][0] as { params: Record; method: string }; + expect(call.method).toBe("PATCH"); + expect(call.params.orgIdentifier).toBe("platform"); + expect(call.params.projectIdentifier).toBeUndefined(); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s get injects only org for resource_scope=org", + async (resourceType) => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" })); + const mockRequest = vi.fn().mockResolvedValue({ identifier: "org-policy" }); + const client = makeClient(mockRequest); + const idField = resourceType === "policy" ? "policy_id" : "policy_set_id"; + + await registry.dispatch(client, resourceType, "get", { + resource_scope: "org", + org_id: "platform", + [idField]: "org-policy", + }); + + const call = mockRequest.mock.calls[0][0] as { params: Record }; + expect(call.params.orgIdentifier).toBe("platform"); + expect(call.params.projectIdentifier).toBeUndefined(); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s delete omits org/project for resource_scope=account", + async (resourceType) => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" })); + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + const idField = resourceType === "policy" ? "policy_id" : "policy_set_id"; + + await registry.dispatch(client, resourceType, "delete", { + resource_scope: "account", + [idField]: "acct-policy", + }); + + const call = mockRequest.mock.calls[0][0] as { params: Record; method: string }; + expect(call.method).toBe("DELETE"); + expect(call.params.orgIdentifier).toBeUndefined(); + expect(call.params.projectIdentifier).toBeUndefined(); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s delete injects only org for resource_scope=org", + async (resourceType) => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" })); + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + const idField = resourceType === "policy" ? "policy_id" : "policy_set_id"; + + await registry.dispatch(client, resourceType, "delete", { + resource_scope: "org", + org_id: "platform", + [idField]: "org-policy", + }); + + const call = mockRequest.mock.calls[0][0] as { params: Record; method: string }; + expect(call.method).toBe("DELETE"); + expect(call.params.orgIdentifier).toBe("platform"); + expect(call.params.projectIdentifier).toBeUndefined(); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s delete keeps project default when resource_scope is omitted", + async (resourceType) => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" })); + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + const idField = resourceType === "policy" ? "policy_id" : "policy_set_id"; + + await registry.dispatch(client, resourceType, "delete", { + [idField]: "proj-policy", + }); + + const call = mockRequest.mock.calls[0][0] as { params: Record; method: string }; + expect(call.method).toBe("DELETE"); + expect(call.params.orgIdentifier).toBe("default"); + expect(call.params.projectIdentifier).toBe("test-project"); + }, + ); }); diff --git a/tests/registry/infrastructure.test.ts b/tests/registry/infrastructure.test.ts index 8c9d85834..f406470d4 100644 --- a/tests/registry/infrastructure.test.ts +++ b/tests/registry/infrastructure.test.ts @@ -203,4 +203,76 @@ describe("infrastructure deep links", () => { expect(result.openInHarness).toBe(INFRA_DEEP_LINK); }); + + it("get: openInHarness falls back to environment_id param when response omits environmentRef", async () => { + const client = makeClient( + vi.fn().mockResolvedValue({ + data: { + identifier: "k8s", + name: "k8s", + orgIdentifier: "default", + projectIdentifier: "avi", + }, + }), + ); + const result = (await registry.dispatch(client, "infrastructure", "get", { + infrastructure_id: "k8s", + org_id: "default", + project_id: "avi", + environment_id: "preprod", + })) as Record; + + expect(result.openInHarness).toBe(INFRA_DEEP_LINK); + }); + + it("list: openInHarness aliases nested infrastructure.environmentRef onto environmentIdentifier", async () => { + const client = makeClient( + vi.fn().mockResolvedValue({ + data: { + content: [ + { + identifier: "k8s", + name: "k8s", + infrastructure: { environmentRef: "preprod" }, + orgIdentifier: "default", + projectIdentifier: "avi", + }, + ], + totalElements: 1, + }, + }), + ); + const result = (await registry.dispatch(client, "infrastructure", "list", { + org_id: "default", + project_id: "avi", + })) as { items: Array> }; + + expect(result.items[0]!.openInHarness).toBe(INFRA_DEEP_LINK); + }); + + it("update: openInHarness aliases nested infrastructure.environmentRef onto environmentIdentifier", async () => { + const client = makeClient( + vi.fn().mockResolvedValue({ + data: { + identifier: "k8s", + name: "k8s", + infrastructure: { environmentRef: "preprod" }, + orgIdentifier: "default", + projectIdentifier: "avi", + }, + }), + ); + const result = (await registry.dispatch(client, "infrastructure", "update", { + infrastructure_id: "k8s", + org_id: "default", + project_id: "avi", + body: { + name: "k8s", + type: "KubernetesDirect", + environmentRef: "preprod", + }, + })) as Record; + + expect(result.openInHarness).toBe(INFRA_DEEP_LINK); + }); });