Skip to content

Commit 2f7d065

Browse files
MajorTalclaude
andcommitted
fix(sdk): validate deploy query options
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c7bafa8 commit 2f7d065

2 files changed

Lines changed: 206 additions & 5 deletions

File tree

sdk/src/namespaces/deploy.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3555,6 +3555,29 @@ describe("Deploy.list", () => {
35553555
assert.equal(w.requests[0].path, "/deploy/v2/operations?limit=5");
35563556
});
35573557

3558+
it("rejects invalid limit values with a LocalError before issuing a request", async () => {
3559+
const invalidLimits = [
3560+
{ label: "NaN", value: Number.NaN },
3561+
{ label: "zero", value: 0 },
3562+
{ label: "negative", value: -1 },
3563+
{ label: "fractional", value: 1.5 },
3564+
{ label: "infinite", value: Number.POSITIVE_INFINITY },
3565+
{ label: "unsafe", value: Number.MAX_SAFE_INTEGER + 1 },
3566+
];
3567+
3568+
for (const { label, value } of invalidLimits) {
3569+
const w = makeWiring();
3570+
const deploy = new Deploy(w.client);
3571+
await assert.rejects(
3572+
() => deploy.list({ project: "prj_test", limit: value }),
3573+
(err: unknown) =>
3574+
err instanceof LocalError && /limit/i.test((err as LocalError).message),
3575+
`${label} limit should be rejected locally`,
3576+
);
3577+
assert.equal(w.requests.length, 0, `${label}: no gateway request`);
3578+
}
3579+
});
3580+
35583581
it("accepts a bare projectId string and issues the same request as the options form", async () => {
35593582
const projectLookups: string[] = [];
35603583
const wiringFor = (): FakeWiring => {
@@ -3759,6 +3782,34 @@ describe("Deploy release observability", () => {
37593782
assert.equal(w.requests[0].headers?.apikey, "ak");
37603783
});
37613784

3785+
it("rejects invalid getRelease siteLimit values before issuing a request", async () => {
3786+
const invalidLimits = [
3787+
{ label: "NaN", value: Number.NaN },
3788+
{ label: "zero", value: 0 },
3789+
{ label: "negative", value: -1 },
3790+
{ label: "fractional", value: 1.5 },
3791+
{ label: "infinite", value: Number.POSITIVE_INFINITY },
3792+
{ label: "unsafe", value: Number.MAX_SAFE_INTEGER + 1 },
3793+
];
3794+
3795+
for (const { label, value } of invalidLimits) {
3796+
const w = makeWiring();
3797+
const deploy = new Deploy(w.client);
3798+
await assert.rejects(
3799+
() =>
3800+
deploy.getRelease({
3801+
project: "prj_test",
3802+
releaseId: "rel_1",
3803+
siteLimit: value,
3804+
}),
3805+
(err: unknown) =>
3806+
err instanceof LocalError && /siteLimit/i.test((err as LocalError).message),
3807+
`${label} siteLimit should be rejected locally`,
3808+
);
3809+
assert.equal(w.requests.length, 0, `${label}: no gateway request`);
3810+
}
3811+
});
3812+
37623813
it("fetches the active release inventory with site_limit", async () => {
37633814
const w = makeWiring();
37643815
const activeInventory = { ...inventory, release_id: "rel_active", state_kind: "current_live" };
@@ -3775,6 +3826,29 @@ describe("Deploy release observability", () => {
37753826
assert.equal(w.requests[0].headers?.apikey, "ak");
37763827
});
37773828

3829+
it("rejects invalid getActiveRelease siteLimit values before issuing a request", async () => {
3830+
const invalidLimits = [
3831+
{ label: "NaN", value: Number.NaN },
3832+
{ label: "zero", value: 0 },
3833+
{ label: "negative", value: -1 },
3834+
{ label: "fractional", value: 1.5 },
3835+
{ label: "infinite", value: Number.POSITIVE_INFINITY },
3836+
{ label: "unsafe", value: Number.MAX_SAFE_INTEGER + 1 },
3837+
];
3838+
3839+
for (const { label, value } of invalidLimits) {
3840+
const w = makeWiring();
3841+
const deploy = new Deploy(w.client);
3842+
await assert.rejects(
3843+
() => deploy.getActiveRelease({ project: "prj_test", siteLimit: value }),
3844+
(err: unknown) =>
3845+
err instanceof LocalError && /siteLimit/i.test((err as LocalError).message),
3846+
`${label} siteLimit should be rejected locally`,
3847+
);
3848+
assert.equal(w.requests.length, 0, `${label}: no gateway request`);
3849+
}
3850+
});
3851+
37783852
it("diffs release targets with encoded selectors, limit, and project apikey auth", async () => {
37793853
const w = makeWiring();
37803854
const diff = {
@@ -3810,4 +3884,78 @@ describe("Deploy release observability", () => {
38103884
assert.equal(w.requests[0].headers?.apikey, "ak");
38113885
});
38123886

3887+
it("rejects invalid diff limit values before issuing a request", async () => {
3888+
const invalidLimits = [
3889+
{ label: "NaN", value: Number.NaN },
3890+
{ label: "zero", value: 0 },
3891+
{ label: "negative", value: -1 },
3892+
{ label: "fractional", value: 1.5 },
3893+
{ label: "infinite", value: Number.POSITIVE_INFINITY },
3894+
{ label: "unsafe", value: Number.MAX_SAFE_INTEGER + 1 },
3895+
];
3896+
3897+
for (const { label, value } of invalidLimits) {
3898+
const w = makeWiring();
3899+
const deploy = new Deploy(w.client);
3900+
await assert.rejects(
3901+
() =>
3902+
deploy.diff({
3903+
project: "prj_test",
3904+
from: "empty",
3905+
to: "rel_2",
3906+
limit: value,
3907+
}),
3908+
(err: unknown) =>
3909+
err instanceof LocalError && /limit/i.test((err as LocalError).message),
3910+
`${label} limit should be rejected locally`,
3911+
);
3912+
assert.equal(w.requests.length, 0, `${label}: no gateway request`);
3913+
}
3914+
});
3915+
3916+
it("rejects missing or empty diff selectors before issuing a request", async () => {
3917+
const cases: Array<{
3918+
label: string;
3919+
opts: Parameters<Deploy["diff"]>[0];
3920+
expectedMessage: RegExp;
3921+
}> = [
3922+
{
3923+
label: "missing from",
3924+
opts: { project: "prj_test", to: "rel_2" } as unknown as Parameters<
3925+
Deploy["diff"]
3926+
>[0],
3927+
expectedMessage: /from/i,
3928+
},
3929+
{
3930+
label: "missing to",
3931+
opts: { project: "prj_test", from: "rel_1" } as unknown as Parameters<
3932+
Deploy["diff"]
3933+
>[0],
3934+
expectedMessage: /to/i,
3935+
},
3936+
{
3937+
label: "empty from",
3938+
opts: { project: "prj_test", from: "", to: "rel_2" },
3939+
expectedMessage: /from/i,
3940+
},
3941+
{
3942+
label: "empty to",
3943+
opts: { project: "prj_test", from: "rel_1", to: "" },
3944+
expectedMessage: /to/i,
3945+
},
3946+
];
3947+
3948+
for (const { label, opts, expectedMessage } of cases) {
3949+
const w = makeWiring();
3950+
const deploy = new Deploy(w.client);
3951+
await assert.rejects(
3952+
() => deploy.diff(opts),
3953+
(err: unknown) =>
3954+
err instanceof LocalError && expectedMessage.test((err as LocalError).message),
3955+
`${label} should be rejected locally`,
3956+
);
3957+
assert.equal(w.requests.length, 0, `${label}: no gateway request`);
3958+
}
3959+
});
3960+
38133961
});

sdk/src/namespaces/deploy.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,14 @@ export class Deploy {
288288
"listing deploy operations",
289289
);
290290
}
291+
const normalizedLimit = normalizePositiveSafeIntegerQueryOption(
292+
limit,
293+
"r.deploy.list limit",
294+
"listing deploy operations",
295+
);
291296
const headers = await apikeyHeaders(this.client, project);
292297
const qs = new URLSearchParams();
293-
if (limit !== undefined) qs.set("limit", String(limit));
298+
if (normalizedLimit !== undefined) qs.set("limit", String(normalizedLimit));
294299
const path =
295300
qs.toString().length > 0
296301
? `/deploy/v2/operations?${qs.toString()}`
@@ -350,10 +355,15 @@ export class Deploy {
350355
"fetching release inventory",
351356
);
352357
}
358+
const siteLimit = normalizePositiveSafeIntegerQueryOption(
359+
opts.siteLimit,
360+
"r.deploy.getRelease siteLimit",
361+
"fetching release inventory",
362+
);
353363
const headers = await apikeyHeaders(this.client, opts.project);
354364
return this.client.request<ReleaseInventory>(
355365
appendQuery(`/deploy/v2/releases/${encodeURIComponent(opts.releaseId)}`, {
356-
site_limit: opts.siteLimit,
366+
site_limit: siteLimit,
357367
}),
358368
{ headers, context: "fetching release inventory" },
359369
);
@@ -373,10 +383,15 @@ export class Deploy {
373383
"fetching active release inventory",
374384
);
375385
}
386+
const siteLimit = normalizePositiveSafeIntegerQueryOption(
387+
opts.siteLimit,
388+
"r.deploy.getActiveRelease siteLimit",
389+
"fetching active release inventory",
390+
);
376391
const headers = await apikeyHeaders(this.client, opts.project);
377392
return this.client.request<ActiveReleaseInventory>(
378393
appendQuery("/deploy/v2/releases/active", {
379-
site_limit: opts.siteLimit,
394+
site_limit: siteLimit,
380395
}),
381396
{ headers, context: "fetching active release inventory" },
382397
);
@@ -394,9 +409,24 @@ export class Deploy {
394409
"diffing releases",
395410
);
396411
}
412+
const from = requireNonEmptyStringQueryOption(
413+
opts.from,
414+
"r.deploy.diff from",
415+
"diffing releases",
416+
);
417+
const to = requireNonEmptyStringQueryOption(
418+
opts.to,
419+
"r.deploy.diff to",
420+
"diffing releases",
421+
);
422+
const limit = normalizePositiveSafeIntegerQueryOption(
423+
opts.limit,
424+
"r.deploy.diff limit",
425+
"diffing releases",
426+
);
397427
const headers = await apikeyHeaders(this.client, opts.project);
398-
const qs = new URLSearchParams({ from: opts.from, to: opts.to });
399-
if (opts.limit !== undefined) qs.set("limit", String(opts.limit));
428+
const qs = new URLSearchParams({ from, to });
429+
if (limit !== undefined) qs.set("limit", String(limit));
400430
return this.client.request<ReleaseToReleaseDiff>(
401431
`/deploy/v2/releases/diff?${qs.toString()}`,
402432
{ headers, context: "diffing releases" },
@@ -433,6 +463,29 @@ function appendQuery(
433463
return query.length > 0 ? `${path}?${query}` : path;
434464
}
435465

466+
function normalizePositiveSafeIntegerQueryOption(
467+
value: number | undefined,
468+
label: string,
469+
context: string,
470+
): number | undefined {
471+
if (value === undefined) return undefined;
472+
if (!Number.isSafeInteger(value) || value <= 0) {
473+
throw new LocalError(`${label} must be a positive safe integer`, context);
474+
}
475+
return value;
476+
}
477+
478+
function requireNonEmptyStringQueryOption(
479+
value: unknown,
480+
label: string,
481+
context: string,
482+
): string {
483+
if (typeof value !== "string" || value.length === 0) {
484+
throw new LocalError(`${label} must be a non-empty string`, context);
485+
}
486+
return value;
487+
}
488+
436489
// ─── Internal pipeline ───────────────────────────────────────────────────────
437490

438491
async function applyOnce(

0 commit comments

Comments
 (0)