The per-caller ownership enforcement introduced for issue #35447 was silently bypassed: all four mutation/list schemas used additionalProperties:false but did not declare callerSessionKey, causing AJV to strip the field before the handler could read it. As a result resolveCronCallerOptions always received an empty caller and fell back to allow-all behaviour. Fix: - Add optional callerSessionKey (NonEmptyString) to CronListParamsSchema, CronUpdateParamsSchema, CronRemoveParamsSchema and CronRunParamsSchema. - Update the four handlers in server-methods/cron.ts to read p.callerSessionKey instead of the previous p.sessionKey (which was never populated through these schemas). - Add validator tests covering acceptance of the new field and rejection of empty strings across all four operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
182 lines
5.6 KiB
TypeScript
182 lines
5.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
validateCronAddParams,
|
|
validateCronListParams,
|
|
validateCronRemoveParams,
|
|
validateCronRunParams,
|
|
validateCronRunsParams,
|
|
validateCronUpdateParams,
|
|
} from "./index.js";
|
|
|
|
const minimalAddParams = {
|
|
name: "daily-summary",
|
|
schedule: { kind: "every", everyMs: 60_000 },
|
|
sessionTarget: "main",
|
|
wakeMode: "next-heartbeat",
|
|
payload: { kind: "systemEvent", text: "tick" },
|
|
} as const;
|
|
|
|
describe("cron protocol validators", () => {
|
|
it("accepts minimal add params", () => {
|
|
expect(validateCronAddParams(minimalAddParams)).toBe(true);
|
|
});
|
|
|
|
it("accepts current and custom session targets", () => {
|
|
expect(
|
|
validateCronAddParams({
|
|
...minimalAddParams,
|
|
sessionTarget: "current",
|
|
payload: { kind: "agentTurn", message: "tick" },
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
validateCronAddParams({
|
|
...minimalAddParams,
|
|
sessionTarget: "session:project-alpha",
|
|
payload: { kind: "agentTurn", message: "tick" },
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
validateCronUpdateParams({
|
|
id: "job-1",
|
|
patch: { sessionTarget: "session:project-alpha" },
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects add params when required scheduling fields are missing", () => {
|
|
const { wakeMode: _wakeMode, ...withoutWakeMode } = minimalAddParams;
|
|
expect(validateCronAddParams(withoutWakeMode)).toBe(false);
|
|
});
|
|
|
|
it("accepts update params for id and jobId selectors", () => {
|
|
expect(validateCronUpdateParams({ id: "job-1", patch: { enabled: false } })).toBe(true);
|
|
expect(validateCronUpdateParams({ jobId: "job-2", patch: { enabled: true } })).toBe(true);
|
|
});
|
|
|
|
it("accepts remove params for id and jobId selectors", () => {
|
|
expect(validateCronRemoveParams({ id: "job-1" })).toBe(true);
|
|
expect(validateCronRemoveParams({ jobId: "job-2" })).toBe(true);
|
|
});
|
|
|
|
it("accepts run params mode for id and jobId selectors", () => {
|
|
expect(validateCronRunParams({ id: "job-1", mode: "force" })).toBe(true);
|
|
expect(validateCronRunParams({ jobId: "job-2", mode: "due" })).toBe(true);
|
|
});
|
|
|
|
it("accepts list paging/filter/sort params", () => {
|
|
expect(
|
|
validateCronListParams({
|
|
includeDisabled: true,
|
|
limit: 50,
|
|
offset: 0,
|
|
query: "daily",
|
|
enabled: "all",
|
|
sortBy: "nextRunAtMs",
|
|
sortDir: "asc",
|
|
}),
|
|
).toBe(true);
|
|
expect(validateCronListParams({ offset: -1 })).toBe(false);
|
|
});
|
|
|
|
it("accepts callerSessionKey on list params", () => {
|
|
expect(validateCronListParams({ callerSessionKey: "telegram:direct:111" })).toBe(true);
|
|
expect(validateCronListParams({ callerSessionKey: "" })).toBe(false);
|
|
});
|
|
|
|
it("accepts callerSessionKey on update params", () => {
|
|
expect(
|
|
validateCronUpdateParams({
|
|
id: "job-1",
|
|
patch: { enabled: false },
|
|
callerSessionKey: "telegram:direct:111",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
validateCronUpdateParams({
|
|
jobId: "job-2",
|
|
patch: { enabled: true },
|
|
callerSessionKey: "telegram:direct:222",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
validateCronUpdateParams({ id: "job-1", patch: { enabled: false }, callerSessionKey: "" }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("accepts callerSessionKey on remove params", () => {
|
|
expect(validateCronRemoveParams({ id: "job-1", callerSessionKey: "telegram:direct:111" })).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
validateCronRemoveParams({ jobId: "job-2", callerSessionKey: "telegram:direct:222" }),
|
|
).toBe(true);
|
|
expect(validateCronRemoveParams({ id: "job-1", callerSessionKey: "" })).toBe(false);
|
|
});
|
|
|
|
it("accepts callerSessionKey on run params", () => {
|
|
expect(
|
|
validateCronRunParams({
|
|
id: "job-1",
|
|
mode: "force",
|
|
callerSessionKey: "telegram:direct:111",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
validateCronRunParams({
|
|
jobId: "job-2",
|
|
mode: "due",
|
|
callerSessionKey: "telegram:direct:222",
|
|
}),
|
|
).toBe(true);
|
|
expect(validateCronRunParams({ id: "job-1", callerSessionKey: "" })).toBe(false);
|
|
});
|
|
|
|
it("enforces runs limit minimum for id and jobId selectors", () => {
|
|
expect(validateCronRunsParams({ id: "job-1", limit: 1 })).toBe(true);
|
|
expect(validateCronRunsParams({ jobId: "job-2", limit: 1 })).toBe(true);
|
|
expect(validateCronRunsParams({ id: "job-1", limit: 0 })).toBe(false);
|
|
expect(validateCronRunsParams({ jobId: "job-2", limit: 0 })).toBe(false);
|
|
});
|
|
|
|
it("rejects cron.runs path traversal ids", () => {
|
|
expect(validateCronRunsParams({ id: "../job-1" })).toBe(false);
|
|
expect(validateCronRunsParams({ id: "nested/job-1" })).toBe(false);
|
|
expect(validateCronRunsParams({ jobId: "..\\job-2" })).toBe(false);
|
|
expect(validateCronRunsParams({ jobId: "nested\\job-2" })).toBe(false);
|
|
});
|
|
|
|
it("accepts runs paging/filter/sort params", () => {
|
|
expect(
|
|
validateCronRunsParams({
|
|
id: "job-1",
|
|
limit: 50,
|
|
offset: 0,
|
|
status: "error",
|
|
query: "timeout",
|
|
sortDir: "desc",
|
|
}),
|
|
).toBe(true);
|
|
expect(validateCronRunsParams({ id: "job-1", offset: -1 })).toBe(false);
|
|
});
|
|
|
|
it("accepts all-scope runs with multi-select filters", () => {
|
|
expect(
|
|
validateCronRunsParams({
|
|
scope: "all",
|
|
limit: 25,
|
|
statuses: ["ok", "error"],
|
|
deliveryStatuses: ["delivered", "not-requested"],
|
|
query: "fail",
|
|
sortDir: "desc",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
validateCronRunsParams({
|
|
scope: "job",
|
|
statuses: [],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|