diff --git a/packages/work-sdk/src/azure-devops.ts b/packages/work-sdk/src/azure-devops.ts index c6efadf..29f02cb 100644 --- a/packages/work-sdk/src/azure-devops.ts +++ b/packages/work-sdk/src/azure-devops.ts @@ -199,7 +199,13 @@ function mapWorkItem( const typeName = field(item, "System.WorkItemType") ?? "Other"; const assignedTo = field(item, "System.AssignedTo"); const priorityValue = Number(field(item, "Microsoft.VSTS.Common.Priority")); - const teamProject = field(item, "System.TeamProject") ?? project; + const teamProject = field(item, "System.TeamProject"); + if (!teamProject || teamProject.toLowerCase() !== project.toLowerCase()) { + throw new WorkAuthorizationError("Azure DevOps work item is outside the configured project", { + provider: "azure-devops", + details: { configured: project, received: teamProject }, + }); + } const description = field(item, "System.Description"); const parent = parentId(item); const htmlUrl = item._links?.html?.href ?? `https://dev.azure.com/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_workitems/edit/${item.id}`; @@ -468,23 +474,23 @@ export function azureDevOpsWorkAdapter(options: AzureDevOpsWorkAdapterOptions): async update(id, input, callOptions) { throwIfAborted(callOptions?.signal); - const needsCurrent = input.parentId !== undefined || input.description === null || input.priority === "none" || input.assigneeIds?.length === 0; - const current = needsCurrent ? await getRaw(id, callOptions?.signal) : undefined; - const patch: JsonPatchOperation[] = []; - if (callOptions?.expectedRevision !== undefined) { - const revision = Number(callOptions.expectedRevision); - if (!Number.isInteger(revision) || revision < 1) throw new WorkValidationError("expectedRevision must be an Azure DevOps revision number", { provider: "azure-devops", details: { expectedRevision: callOptions.expectedRevision } }); - patch.push({ op: "test", path: "/rev", value: revision }); - } - patch.push(...patchFor(input, current)); + const expectedRevision = callOptions?.expectedRevision === undefined ? undefined : Number(callOptions.expectedRevision); + if (expectedRevision !== undefined && (!Number.isInteger(expectedRevision) || expectedRevision < 1)) throw new WorkValidationError("expectedRevision must be an Azure DevOps revision number", { provider: "azure-devops", details: { expectedRevision: callOptions?.expectedRevision } }); + if (input.assigneeIds && input.assigneeIds.length > 1) throw new WorkValidationError("Azure DevOps supports one assignee per work item", { provider: "azure-devops", details: { field: "assigneeIds" } }); + const current = await getRaw(id, callOptions?.signal); + const currentItem = mapWorkItem(current, organization, project, states, kinds); + const patch: JsonPatchOperation[] = [{ op: "test", path: "/fields/System.TeamProject", value: field(current, "System.TeamProject") }]; + if (expectedRevision !== undefined) patch.push({ op: "test", path: "/rev", value: expectedRevision }); + const changes = patchFor(input, current); + patch.push(...changes); if (patch.every((operation) => operation.op === "test")) { - if (!current) throw new WorkValidationError("Update must contain at least one changed field", { provider: "azure-devops" }); + const canBeNoOp = input.parentId !== undefined || input.description === null || input.priority === "none" || input.assigneeIds?.length === 0; + if (!canBeNoOp) throw new WorkValidationError("Update must contain at least one changed field", { provider: "azure-devops" }); if (callOptions?.expectedRevision !== undefined && String(current.rev) !== callOptions.expectedRevision) { throw new WorkConflictError("Azure DevOps work item changed before the no-op update", { provider: "azure-devops", details: { expected: callOptions.expectedRevision, actual: String(current.rev) } }); } - const item = mapWorkItem(current, organization, project, states, kinds); - verifyApplied(item, input); - return item; + verifyApplied(currentItem, input); + return currentItem; } const raw = await request(`/_apis/wit/workitems/${encodeURIComponent(id)}?$expand=relations&api-version=7.1`, { method: "PATCH", @@ -500,6 +506,7 @@ export function azureDevOpsWorkAdapter(options: AzureDevOpsWorkAdapterOptions): async addComment(id, input: AddCommentInput, callOptions) { throwIfAborted(callOptions?.signal); if (!input.body.trim()) throw new WorkValidationError("body must not be empty", { provider: "azure-devops" }); + mapWorkItem(await getRaw(id, callOptions?.signal), organization, project, states, kinds); const comment = await request(`/_apis/wit/workItems/${encodeURIComponent(id)}/comments?format=markdown&api-version=7.1-preview.4`, { method: "POST", body: JSON.stringify({ text: input.body }), ...(callOptions?.signal ? { signal: callOptions.signal } : {}), }); diff --git a/packages/work-sdk/test/azure-devops.test.ts b/packages/work-sdk/test/azure-devops.test.ts index 278f93b..16e2592 100644 --- a/packages/work-sdk/test/azure-devops.test.ts +++ b/packages/work-sdk/test/azure-devops.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { WorkAuthenticationError, + WorkAuthorizationError, WorkConflictError, WorkRateLimitError, WorkUnsupportedError, @@ -141,6 +142,7 @@ describe("Azure DevOps adapter", () => { const updated = await adapter.update("42", { title: "New title", parentId: "11" }, { expectedRevision: "7" }); expect(updated).toMatchObject({ title: "New title", parentId: "11", revision: "8" }); expect(calls[1]).toMatchObject({ method: "PATCH", body: [ + { op: "test", path: "/fields/System.TeamProject", value: "Platform" }, { op: "test", path: "/rev", value: 7 }, { op: "add", path: "/fields/System.Title", value: "New title" }, { op: "remove", path: "/relations/0" }, @@ -165,6 +167,7 @@ describe("Azure DevOps adapter", () => { it("adds Markdown comments and normalizes authors", async () => { const fetcher = vi.fn(async (url, init) => { + if (!init?.method) return json(item()); expect(String(url)).toContain("format=markdown&api-version=7.1-preview.4"); expect(JSON.parse(String(init?.body))).toEqual({ text: "Deployed" }); return json({ id: 9, text: "Deployed", createdDate: "2026-01-03T00:00:00Z", createdBy: { id: "user-1", displayName: "Ada" } }); @@ -173,6 +176,16 @@ describe("Azure DevOps adapter", () => { expect(comment).toMatchObject({ id: "9", body: "Deployed", author: { id: "user-1", displayName: "Ada" } }); }); + it("rejects cross-project reads and mutations", async () => { + const fetcher = vi.fn(async () => json(item({ fields: { "System.TeamProject": "Secret" } }))); + const adapter = azureDevOpsWorkAdapter({ ...baseOptions, fetch: fetcher }); + + await expect(adapter.get("42")).rejects.toBeInstanceOf(WorkAuthorizationError); + await expect(adapter.update("42", { title: "Cross-project edit" })).rejects.toBeInstanceOf(WorkAuthorizationError); + await expect(adapter.addComment("42", { body: "Cross-project comment" })).rejects.toBeInstanceOf(WorkAuthorizationError); + expect(fetcher.mock.calls.every(([, init]) => !init?.method)).toBe(true); + }); + it("fails closed when Azure rules modify a requested field", async () => { const fetcher = vi.fn(async () => json(item({ fields: { "System.Title": "Rule changed this" } }))); const adapter = azureDevOpsWorkAdapter({ ...baseOptions, fetch: fetcher });