From a4a1053ba3ccd8ef90e69ec0e598799e14850b78 Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Tue, 21 Jul 2026 14:23:26 -0700 Subject: [PATCH] feat: add organization policy rule methods Adds the /v1/org/policy surface to the org client (10 methods): listPolicyRules, getPolicyRule, createPolicyRule, updatePolicyRule, deletePolicyRule, reorderPolicyRules, getPolicyDefault, setPolicyDefault, publishPolicy, and getPolicyLastPublish. Writes publish automatically; pass { skipPublish: true } to stage and review first (a publish snapshots the whole org draft, including changes staged by other users). If the publish step fails after a successful write, the write stays staged and the publish request's error is rethrown unchanged. Types are honest about both directions: PolicyRuleTarget models responses (arrays always present, unset scalars explicit null) while PolicyRuleTargetInput models writes (optionals only; the API rejects nulls on write); org rule inputs exclude agent identities at the type level (the org API rejects them); PolicyWriteResult discriminates on `published`. The legacy createRule/updateRule/deleteRule methods are marked @deprecated (cloud deployments reject them with 410 Gone). Co-Authored-By: Claude Opus 4.8 --- README.md | 67 +++++++--- src/index.ts | 14 ++ src/org/index.ts | 179 ++++++++++++++++++++++++++ src/org/types.ts | 197 ++++++++++++++++++++++++++++- test/org/client.test.ts | 274 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 712 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6f1938e..a0c16ee 100644 --- a/README.md +++ b/README.md @@ -402,17 +402,23 @@ const connections = await onecli.org.listConnections(); await onecli.org.renameConnection(connections[0].id, "prod"); await onecli.org.deleteConnection(connections[0].id); -// Org-wide rules (apply to every agent in the org) -await onecli.org.createRule({ +// Org-wide policy rules (the policy engine — staged draft → publish). +// Writes publish automatically; pass { skipPublish: true } to stage and +// review first (a publish snapshots the WHOLE org draft, including changes +// staged by other users). +const { result: created } = await onecli.org.createPolicyRule({ name: "Block Gmail sends", - hostPattern: "gmail.googleapis.com", - pathPattern: "/gmail/v1/users/me/messages/send", action: "block", - enabled: true, + targets: [{ kind: "app", provider: "gmail", tools: ["send_email"] }], }); -const rules = await onecli.org.listRules(); -await onecli.org.updateRule(rules[0].id, { enabled: false }); -await onecli.org.deleteRule(rules[0].id); +const draft = await onecli.org.listPolicyRules(); // "published" = enforced +await onecli.org.updatePolicyRule(created.id, { enabled: false }); +await onecli.org.deletePolicyRule(created.id); +await onecli.org.publishPolicy(); // publish anything still staged + +// Legacy org rules (deprecated): cloud deployments reject these writes with +// 410 Gone; pre-cutover self-hosted servers still accept them. +const legacyRules = await onecli.org.listRules(); // Watch manual-approval requests across every project in the org. Each request // carries its own `projectId`, and the decision is routed back to that project. @@ -426,10 +432,14 @@ const handle = onecli.org.configureManualApproval( // handle.stop() when shutting down ``` -Rule listings mix two kinds of rules. Custom rules (like the one created -above) carry your `hostPattern`/`pathPattern`/`method`. App-permission rules -(managed through the app permissions surface) omit those endpoint fields and -are identified by `metadata.provider` + `metadata.toolId` instead. +Policy rules carry structured `targets` (app / connection / secret / +network) and `identities` (org rules take `agentGroup`/`user`/`group`). +Rule responses use `PolicyRuleTarget` (arrays always present, unset scalars +`null`); inputs use `PolicyRuleTargetInput` (omit unused fields — the API +rejects `null`s on write). `updatePolicyRule` requires at least one field +(an empty input is rejected with 422). Legacy rule listings still mix +custom rows (with `hostPattern`/etc.) and read-only app-permission rows +identified by `metadata.provider` + `metadata.toolId`. | Method | Endpoint | Returns | |--------|----------|---------| @@ -438,11 +448,20 @@ are identified by `metadata.provider` + `metadata.toolId` instead. | `listConnections(provider?)` | `GET /v1/org/connections` | `OrgConnection[]` | | `renameConnection(id, label)` | `PATCH /v1/org/connections/{id}` | `OrgConnection` | | `deleteConnection(id)` | `DELETE /v1/org/connections/{id}` | `void` | -| `listRules()` | `GET /v1/org/rules` | `OrgRule[]` | -| `getRule(id)` | `GET /v1/org/rules/{id}` | `OrgRule` | -| `createRule(input)` | `POST /v1/org/rules` | `OrgRule` | -| `updateRule(id, input)` | `PATCH /v1/org/rules/{id}` | `{ success: boolean }` | -| `deleteRule(id)` | `DELETE /v1/org/rules/{id}` | `void` | +| `listPolicyRules(status?)` | `GET /v1/org/policy/rules` | `OrgPolicyRule[]` | +| `getPolicyRule(id)` | `GET /v1/org/policy/rules/{id}` | `OrgPolicyRule` | +| `createPolicyRule(input, opts?)` | `POST /v1/org/policy/rules` (+publish) | `PolicyWriteResult` | +| `updatePolicyRule(id, input, opts?)` | `PATCH /v1/org/policy/rules/{id}` (+publish) | `PolicyWriteResult` | +| `deletePolicyRule(id, opts?)` | `DELETE /v1/org/policy/rules/{id}` (+publish) | `PolicyWriteResult` | +| `reorderPolicyRules(ids, opts?)` | `PUT /v1/org/policy/rules/order` (+publish) | `PolicyWriteResult` | +| `getPolicyDefault(status?)` / `setPolicyDefault(action, opts?)` | `GET`/`PATCH /v1/org/policy/default` | `OrgPolicyRule` / `PolicyWriteResult` | +| `publishPolicy()` | `POST /v1/org/policy/publish` | `PolicyPublishResult` | +| `getPolicyLastPublish()` | `GET /v1/org/policy/last-publish` | `PolicyLastPublish \| null` | +| `listRules()` *(legacy)* | `GET /v1/org/rules` | `OrgRule[]` | +| `getRule(id)` *(legacy)* | `GET /v1/org/rules/{id}` | `OrgRule` | +| `createRule(input)` *(deprecated — 410 on cloud)* | `POST /v1/org/rules` | `OrgRule` | +| `updateRule(id, input)` *(deprecated — 410 on cloud)* | `PATCH /v1/org/rules/{id}` | `{ success: boolean }` | +| `deleteRule(id)` *(deprecated — 410 on cloud)* | `DELETE /v1/org/rules/{id}` | `void` | | `configureManualApproval(cb, options?)` | `GET /v1/org/approvals/pending` (long-poll) | `ManualApprovalHandle` | --- @@ -476,6 +495,20 @@ import type { OrgRuleCondition, CreateOrgRuleInput, UpdateOrgRuleInput, + OrgPolicyRule, + PolicyRuleAction, + PolicyRuleStatus, + PolicyRuleTarget, + PolicyRuleTargetInput, + PolicyRuleIdentity, + OrgPolicyRuleIdentityInput, + PolicyRuleConditionsInput, + CreateOrgPolicyRuleInput, + UpdateOrgPolicyRuleInput, + PolicyWriteOptions, + PolicyWriteResult, + PolicyPublishResult, + PolicyLastPublish, } from "@onecli-sh/sdk"; ``` diff --git a/src/index.ts b/src/index.ts index 3feb546..494b68d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,4 +46,18 @@ export type { OrgRuleCondition, CreateOrgRuleInput, UpdateOrgRuleInput, + OrgPolicyRule, + PolicyRuleAction, + PolicyRuleStatus, + PolicyRuleIdentity, + OrgPolicyRuleIdentityInput, + PolicyRuleTarget, + PolicyRuleTargetInput, + PolicyRuleConditionsInput, + CreateOrgPolicyRuleInput, + UpdateOrgPolicyRuleInput, + PolicyWriteOptions, + PolicyWriteResult, + PolicyPublishResult, + PolicyLastPublish, } from "./org/types.js"; diff --git a/src/org/index.ts b/src/org/index.ts index 3452a3e..064dcaa 100644 --- a/src/org/index.ts +++ b/src/org/index.ts @@ -11,10 +11,19 @@ import type { } from "../approvals/types.js"; import type { ConnectOrgAppInput, + CreateOrgPolicyRuleInput, CreateOrgRuleInput, GetOrgAuthorizeUrlOptions, OrgConnection, + OrgPolicyRule, OrgRule, + PolicyLastPublish, + PolicyPublishResult, + PolicyRuleAction, + PolicyRuleStatus, + PolicyWriteOptions, + PolicyWriteResult, + UpdateOrgPolicyRuleInput, UpdateOrgRuleInput, } from "./types.js"; @@ -258,6 +267,9 @@ export class OrgClient { /** * Create an organization rule. + * + * @deprecated Cloud deployments reject this write with 410 Gone — use + * {@link createPolicyRule}. Pre-cutover self-hosted servers still accept it. */ createRule = async (input: CreateOrgRuleInput): Promise => { return this.request("POST", "/v1/org/rules", input); @@ -266,6 +278,9 @@ export class OrgClient { /** * Update an organization rule. Nullable fields accept an explicit `null` * to clear the stored value. + * + * @deprecated Cloud deployments reject this write with 410 Gone — use + * {@link updatePolicyRule}. Pre-cutover self-hosted servers still accept it. */ updateRule = async ( ruleId: string, @@ -280,6 +295,9 @@ export class OrgClient { /** * Delete an organization rule. + * + * @deprecated Cloud deployments reject this write with 410 Gone — use + * {@link deletePolicyRule}. Pre-cutover self-hosted servers still accept it. */ deleteRule = async (ruleId: string): Promise => { await this.request( @@ -288,6 +306,167 @@ export class OrgClient { ); }; + // ── The policy engine (staged draft → publish) ──────────────────────────── + // + // Writes land in the org's DRAFT and enforce only after a publish. By + // default each write method publishes immediately; note a publish + // snapshots the WHOLE org draft — including changes staged by other users + // — so pass `{ skipPublish: true }` and call `publishPolicy()` explicitly + // when several people edit policy. If the publish step fails after a + // successful write, the write IS staged and the PUBLISH request's error is + // rethrown unchanged (its `url` ends with `/publish`) — do NOT retry the + // write (that would stage a duplicate); inspect via + // `listPolicyRules("draft")` and retry `publishPolicy()` instead. + + /** + * List the organization's policy-engine rules — the DRAFT working copy by + * default, or the enforced set with `"published"`. + */ + listPolicyRules = async ( + status: PolicyRuleStatus = "draft", + ): Promise => { + return this.request( + "GET", + `/v1/org/policy/rules?status=${status}`, + ); + }; + + /** + * Get one DRAFT policy rule. Published row ids regenerate on every + * publish — list with `"published"` and match by `logicalId` instead. + */ + getPolicyRule = async (ruleId: string): Promise => { + return this.request( + "GET", + `/v1/org/policy/rules/${encodeURIComponent(ruleId)}`, + ); + }; + + /** + * Create a policy rule (and publish, unless `skipPublish`). + */ + createPolicyRule = async ( + input: CreateOrgPolicyRuleInput, + options?: PolicyWriteOptions, + ): Promise> => { + const rule = await this.request( + "POST", + "/v1/org/policy/rules", + input, + ); + return this.finishPolicyWrite(rule, options); + }; + + /** + * Update a DRAFT policy rule (and publish, unless `skipPublish`). + */ + updatePolicyRule = async ( + ruleId: string, + input: UpdateOrgPolicyRuleInput, + options?: PolicyWriteOptions, + ): Promise> => { + const rule = await this.request( + "PATCH", + `/v1/org/policy/rules/${encodeURIComponent(ruleId)}`, + input, + ); + return this.finishPolicyWrite(rule, options); + }; + + /** + * Delete a DRAFT policy rule (and publish, unless `skipPublish`). + */ + deletePolicyRule = async ( + ruleId: string, + options?: PolicyWriteOptions, + ): Promise> => { + await this.request( + "DELETE", + `/v1/org/policy/rules/${encodeURIComponent(ruleId)}`, + ); + return this.finishPolicyWrite(null, options); + }; + + /** + * Reorder the DRAFT (and publish, unless `skipPublish`). `orderedIds` + * must name EVERY non-default draft rule exactly once — take the full + * list from `listPolicyRules("draft")`; the server rejects a partial + * permutation with 409. + */ + reorderPolicyRules = async ( + orderedIds: string[], + options?: PolicyWriteOptions, + ): Promise> => { + const rules = await this.request( + "PUT", + "/v1/org/policy/rules/order", + { orderedIds }, + ); + return this.finishPolicyWrite(rules, options); + }; + + /** + * Get the organization's terminal Default Rule (a virtual default is + * returned when none is persisted yet). + */ + getPolicyDefault = async ( + status: PolicyRuleStatus = "draft", + ): Promise => { + return this.request( + "GET", + `/v1/org/policy/default?status=${status}`, + ); + }; + + /** + * Set the Default Rule's action (and publish, unless `skipPublish`). + */ + setPolicyDefault = async ( + action: PolicyRuleAction, + options?: PolicyWriteOptions, + ): Promise> => { + const rule = await this.request( + "PATCH", + "/v1/org/policy/default", + { action }, + ); + return this.finishPolicyWrite(rule, options); + }; + + /** + * Publish the WHOLE org draft — every staged change, including other + * users' — into a new enforced generation. + */ + publishPolicy = async (): Promise => { + return this.request( + "POST", + "/v1/org/policy/publish", + {}, + ); + }; + + /** + * The most recent publish, or `null` when the org was never published. + */ + getPolicyLastPublish = async (): Promise => { + return this.request( + "GET", + "/v1/org/policy/last-publish", + ); + }; + + /** Complete a policy write per the publish option (see the section note). */ + private finishPolicyWrite = async ( + result: T, + options?: PolicyWriteOptions, + ): Promise> => { + if (options?.skipPublish) { + return { result, published: false, generation: null }; + } + const publish = await this.publishPolicy(); + return { result, published: true, generation: publish.generation }; + }; + /** * Register a callback for manual approval requests across **every** project * in the organization. Starts background long-polling to the gateway; the diff --git a/src/org/types.ts b/src/org/types.ts index d05e336..7e81c1f 100644 --- a/src/org/types.ts +++ b/src/org/types.ts @@ -55,8 +55,9 @@ export interface OrgRuleCondition { * The endpoint fields (`hostPattern`/`pathPattern`/`method`) are present on * custom (user-authored) rules only. App-permission rules — rows whose * `metadata.source` is `"app_permission"` — omit them; those rules are - * identified by `metadata.provider` + `metadata.toolId` and managed via - * `PUT /v1/org/rules/permissions/{provider}`. + * identified by `metadata.provider` + `metadata.toolId`. On cloud + * deployments app permissions are managed as policy-engine rules (see + * `createPolicyRule`); the legacy permissions endpoint is retired there. */ export interface OrgRule { id: string; @@ -103,3 +104,195 @@ export interface UpdateOrgRuleInput { rateLimitWindow?: OrgRuleRateLimitWindow | null; conditions?: OrgRuleCondition[] | null; } + +/** A policy-engine rule action. */ +export type PolicyRuleAction = "allow" | "block"; + +/** A policy-engine rule's lifecycle status. */ +export type PolicyRuleStatus = "draft" | "published"; + +/** + * A principal a policy rule applies to, as the API returns it. Org rules + * carry `agentGroup`, `user`, or `group` identities (`agent` exists at + * project scope only); an empty identity list means "everyone". + */ +export interface PolicyRuleIdentity { + type: "agent" | "agentGroup" | "user" | "group"; + id: string; +} + +/** + * An identity on an org policy rule INPUT. The org API rejects `agent` + * identities (those exist at project scope only), so the input type + * excludes that kind entirely. + */ +export interface OrgPolicyRuleIdentityInput { + type: Exclude; + id: string; +} + +/** + * One destination a policy rule matches — a discriminated union on `kind`, + * as the API RETURNS it: array fields are always present (`[]` when unset) + * and unset scalars are explicit `null`s. Rule inputs use + * {@link PolicyRuleTargetInput} — the same union with optional fields + * instead (the API rejects `null`s on write). + */ +export type PolicyRuleTarget = + | { + kind: "app"; + provider: string; + tools: string[]; + connectionScope: "organization" | "project" | null; + } + | { kind: "connection"; connectionId: string; tools: string[] } + | { + kind: "secret"; + secretId: string | null; + secretScope: "organization" | "project" | null; + } + | { + kind: "network"; + hostPattern: string; + pathPattern: string | null; + // The response reflects storage: rows backfilled from the legacy model + // may carry method strings outside the 5-verb authoring enum. + method: string | null; + }; + +/** + * A target on a policy rule INPUT — omit optional fields when unused (the + * API rejects explicit `null`s on write). An `app` target names a provider + * (optionally narrowed by `tools`); a `connection` target names one + * connection by `connectionId`; a `secret` target names a specific + * `secretId` XOR a `secretScope` level; a `network` target matches raw + * host/path/method patterns. + */ +export type PolicyRuleTargetInput = + | { + kind: "app"; + provider: string; + tools?: string[]; + connectionScope?: "organization" | "project"; + } + | { kind: "connection"; connectionId: string; tools?: string[] } + | { + kind: "secret"; + secretId?: string; + secretScope?: "organization" | "project"; + } + | { + kind: "network"; + hostPattern: string; + pathPattern?: string; + method?: OrgRuleMethod; + }; + +/** + * A policy rule's `conditions` on input: EITHER behavioral (body-contains) + * conditions OR a connection target's resource scoping (`repositories` / + * `folders`) — never both on one rule. + */ +export type PolicyRuleConditionsInput = + | OrgRuleCondition[] + | { repositories: string[] } + | { folders: string[] }; + +/** + * An organization policy-engine rule (the staged draft→publish model). + * Writes land in the DRAFT and enforce only after a publish; published row + * `id`s regenerate on every publish — `logicalId` is the identity that is + * stable across statuses and generations. + */ +export interface OrgPolicyRule { + id: string; + scope: string; + status: PolicyRuleStatus; + generation: number; + priority: number; + enabled: boolean; + isDefault: boolean; + logicalId: string; + source: string; + name: string; + description: string | null; + action: PolicyRuleAction; + rateLimit: number | null; + rateLimitWindow: OrgRuleRateLimitWindow | null; + requireApproval: boolean; + conditions: unknown; + identities: PolicyRuleIdentity[]; + targets: PolicyRuleTarget[]; + createdAt: string; +} + +/** + * Input for creating a policy-engine rule. `targets` must name at least one + * destination; `rateLimit`/`rateLimitWindow` are paired and — like + * `requireApproval` — valid only on `action: "allow"`. + */ +export interface CreateOrgPolicyRuleInput { + name: string; + description?: string; + enabled?: boolean; + action: PolicyRuleAction; + rateLimit?: number; + rateLimitWindow?: OrgRuleRateLimitWindow; + requireApproval?: boolean; + conditions?: PolicyRuleConditionsInput; + identities?: OrgPolicyRuleIdentityInput[]; + targets: PolicyRuleTargetInput[]; +} + +/** + * Input for updating a DRAFT policy-engine rule — every field optional; + * nullable fields accept an explicit `null` to clear. The server rejects + * an empty body with 422 ("At least one field must be provided"). + */ +export interface UpdateOrgPolicyRuleInput { + name?: string; + description?: string | null; + enabled?: boolean; + action?: PolicyRuleAction; + rateLimit?: number | null; + rateLimitWindow?: OrgRuleRateLimitWindow | null; + requireApproval?: boolean; + conditions?: PolicyRuleConditionsInput | null; + identities?: OrgPolicyRuleIdentityInput[]; + targets?: PolicyRuleTargetInput[]; +} + +/** Per-write publish control (the staged model). */ +export interface PolicyWriteOptions { + /** + * Skip the automatic publish after the write, leaving it staged in the + * draft. Publish later with `publishPolicy()`. Note a publish snapshots + * the WHOLE org draft — including changes staged by other users — so + * `skipPublish` + an explicit reviewed `publishPolicy()` is the safe path + * when several people edit policy. + */ + skipPublish?: boolean; +} + +/** + * The result of a policy write plus its publish outcome — discriminated on + * `published`, so narrowing on it types `generation` accordingly. `result` + * is the written entity (the rule, or the reordered rule list). + */ +export type PolicyWriteResult = + | { result: T; published: true; generation: number } + | { result: T; published: false; generation: null }; + +/** The result of a policy publish. */ +export interface PolicyPublishResult { + generation: number; + ruleCount: number; +} + +/** The most recent publish (null when the org was never published). */ +export interface PolicyLastPublish { + generation: number; + ruleCount: number; + appliedAt: string; + appliedBy: { name: string | null; email: string } | null; +} diff --git a/test/org/client.test.ts b/test/org/client.test.ts index fac1397..5364977 100644 --- a/test/org/client.test.ts +++ b/test/org/client.test.ts @@ -311,3 +311,277 @@ describe("OneCLI facade", () => { expect(onecli.org).toBeInstanceOf(OrgClient); }); }); + +describe("OrgClient policy engine", () => { + const policyRule = { + id: "r1", + scope: "organization", + status: "draft", + generation: 0, + priority: 0, + enabled: true, + isDefault: false, + logicalId: "l1", + source: "custom", + name: "Block evil", + description: null, + action: "block", + rateLimit: null, + rateLimitWindow: null, + requireApproval: false, + conditions: null, + identities: [], + targets: [{ kind: "network", hostPattern: "evil.com" }], + createdAt: "2026-01-01T00:00:00Z", + }; + + it("createPolicyRule POSTs the rule then publishes (auto-publish default)", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify(policyRule), { status: 201 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ generation: 4, ruleCount: 3 }), { + status: 200, + }), + ); + + const outcome = await client().createPolicyRule({ + name: "Block evil", + action: "block", + targets: [{ kind: "network", hostPattern: "evil.com" }], + }); + + expect(outcome.published).toBe(true); + expect(outcome.generation).toBe(4); + expect(outcome.result.logicalId).toBe("l1"); + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + "http://localhost:3000/v1/org/policy/rules", + expect.objectContaining({ method: "POST" }), + ); + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + "http://localhost:3000/v1/org/policy/publish", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("skipPublish stages without publishing", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(JSON.stringify(policyRule), { status: 201 }), + ); + + const outcome = await client().createPolicyRule( + { + name: "Block evil", + action: "block", + targets: [{ kind: "network", hostPattern: "evil.com" }], + }, + { skipPublish: true }, + ); + + expect(outcome).toEqual({ + result: policyRule, + published: false, + generation: null, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("a publish failure after a successful write rethrows the publish error (the write stays staged)", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify(policyRule), { status: 201 }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + message: "A staged rule needs a paid plan", + type: "validation_error", + }, + }), + { status: 422 }, + ), + ); + + await expect( + client().createPolicyRule({ + name: "Block evil", + action: "block", + targets: [{ kind: "network", hostPattern: "evil.com" }], + }), + ).rejects.toMatchObject({ statusCode: 422 }); + }); + + it("listPolicyRules carries the status query; reorder PUTs the full permutation", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify([policyRule]), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([policyRule]), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ generation: 5, ruleCount: 1 }), { + status: 200, + }), + ); + + await client().listPolicyRules("published"); + await client().reorderPolicyRules(["r1"]); + + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + "http://localhost:3000/v1/org/policy/rules?status=published", + expect.objectContaining({ method: "GET" }), + ); + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + "http://localhost:3000/v1/org/policy/rules/order", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ orderedIds: ["r1"] }), + }), + ); + }); + + it("getPolicyLastPublish returns null when never published", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("null", { status: 200 }), + ); + await expect(client().getPolicyLastPublish()).resolves.toBeNull(); + }); + + it("updatePolicyRule PATCHes the encoded id with the input body then publishes", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify(policyRule), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ generation: 6, ruleCount: 3 }), { + status: 200, + }), + ); + + const outcome = await client().updatePolicyRule("r 1", { + enabled: false, + rateLimit: null, + }); + + expect(outcome.published).toBe(true); + expect(outcome.generation).toBe(6); + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + "http://localhost:3000/v1/org/policy/rules/r%201", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ enabled: false, rateLimit: null }), + }), + ); + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + "http://localhost:3000/v1/org/policy/publish", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("deletePolicyRule DELETEs (204) then publishes, with a null result", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ generation: 7, ruleCount: 2 }), { + status: 200, + }), + ); + + const outcome = await client().deletePolicyRule("r1"); + + expect(outcome).toEqual({ result: null, published: true, generation: 7 }); + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + "http://localhost:3000/v1/org/policy/rules/r1", + expect.objectContaining({ method: "DELETE" }), + ); + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + "http://localhost:3000/v1/org/policy/publish", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("getPolicyDefault carries the status query; setPolicyDefault PATCHes the action then publishes", async () => { + const defaultRule = { ...policyRule, isDefault: true, source: "default" }; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify(defaultRule), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(defaultRule), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ generation: 8, ruleCount: 2 }), { + status: 200, + }), + ); + + await client().getPolicyDefault("published"); + const outcome = await client().setPolicyDefault("block"); + + expect(outcome.published).toBe(true); + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + "http://localhost:3000/v1/org/policy/default?status=published", + expect.objectContaining({ method: "GET" }), + ); + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + "http://localhost:3000/v1/org/policy/default", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ action: "block" }), + }), + ); + }); + + it("getPolicyRule GETs the encoded draft id", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(JSON.stringify(policyRule), { status: 200 }), + ); + + const rule = await client().getPolicyRule("r/1"); + + expect(rule.logicalId).toBe("l1"); + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:3000/v1/org/policy/rules/r%2F1", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("policy routes get the route-miss Cloud/Enterprise hint on community servers", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + error: { + message: "Unrecognized request URL (GET: /v1/org/policy/rules).", + type: "invalid_request_error", + }, + }), + { status: 404 }, + ), + ); + await expect(client().listPolicyRules()).rejects.toBeInstanceOf( + OneCLIError, + ); + }); +});