Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,40 @@ describe("redactToken", () => {
);
});
});

describe("AdoRest.addBuildTags", () => {
// Regression: tags were sent one-per-request as PUT .../tags/<tag>, 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();
});
});
18 changes: 12 additions & 6 deletions scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` / `smoke-candidate:<buildId>`, 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<void> {
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] });
}

/**
Expand Down
Loading