diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/ado-rest.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/ado-rest.test.ts index 644529d8..bce3a951 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/ado-rest.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/ado-rest.test.ts @@ -248,3 +248,40 @@ describe("redactToken", () => { ); }); }); + +describe("AdoRest.addBuildTags", () => { + // Regression: tags were sent one-per-request as PUT .../tags/, putting + // the tag in the URL PATH. ADO's ASP.NET front end validates the *decoded* + // path, so `smoke-case:canary` was rejected with HTTP 400 "A potentially + // dangerous Request.Path value was detected from the client (:)" even + // correctly encoded as %3A — which is every tag this harness writes. + // Observed live on build 629522's children. + it("sends tags in the request body, never in the URL path", async () => { + const calls: { url: string; method?: string; body?: unknown }[] = []; + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ url, method: init?.method, body: init?.body }); + return jsonResponse(200, { count: 2, value: ["smoke-case:canary"] }); + }); + const rest = makeRest(fetchImpl as unknown as typeof fetch); + + await rest.addBuildTags(4242, ["smoke-case:canary", "smoke-candidate:99"]); + + expect(calls).toHaveLength(1); + expect(calls[0]!.method).toBe("POST"); + expect(calls[0]!.url).toContain("/build/builds/4242/tags?api-version="); + // The colon-bearing values must not reach the path in any encoding. + expect(calls[0]!.url).not.toContain("smoke-case"); + expect(calls[0]!.url).not.toContain("%3A"); + expect(JSON.parse(String(calls[0]!.body))).toEqual([ + "smoke-case:canary", + "smoke-candidate:99", + ]); + }); + + it("makes no request when there are no tags", async () => { + const fetchImpl = vi.fn(async () => jsonResponse(200, {})); + const rest = makeRest(fetchImpl as unknown as typeof fetch); + await rest.addBuildTags(1, []); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts b/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts index 041eee4d..3e953b32 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts @@ -225,14 +225,20 @@ export class AdoRest { * Every case in a lane shares one definition, so tags (alongside the * per-case `sourceBranch`) are how a run is identified in the lane's * history. Callers treat failures here as non-fatal. + * + * Uses the **body** form (`POST .../tags`) rather than the per-tag path form + * (`PUT .../tags/{tag}`). ADO's ASP.NET front end validates the *decoded* + * request path, so a tag containing `:` is rejected with HTTP 400 "A + * potentially dangerous Request.Path value was detected from the client (:)" + * even when correctly percent-encoded as `%3A`. Our tags are + * `smoke-case:` / `smoke-candidate:`, so every one of them hit + * that. Sending them in the body sidesteps path validation entirely, and + * tags all of them in a single request. */ async addBuildTags(buildId: number, tags: readonly string[]): Promise { - for (const tag of tags) { - const path = this.projPath( - `_apis/build/builds/${buildId}/tags/${AdoRest.seg(tag)}?api-version=7.1`, - ); - await this.request(path, { method: "PUT" }); - } + if (tags.length === 0) return; + const path = this.projPath(`_apis/build/builds/${buildId}/tags?api-version=7.1`); + await this.request(path, { method: "POST", body: [...tags] }); } /**