Harden task state and terminal rendering
This commit is contained in:
@@ -172,6 +172,50 @@ test("restores timers and starts legacy in-progress snapshots at load time", ()
|
||||
assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now);
|
||||
});
|
||||
|
||||
test("rejects unsafe restored identifiers and oversized timing collections", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
const before = state.snapshot();
|
||||
const task = { id: 1, name: "Task", status: "pending" as const, parentId: null, childrenIds: [] };
|
||||
|
||||
assert.throws(
|
||||
() => state.restore({ tasks: [{ ...task, id: Number.MAX_SAFE_INTEGER + 1 }], nextId: Number.MAX_SAFE_INTEGER }),
|
||||
/id must be a positive safe integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => state.restore({ tasks: [task], nextId: Number.MAX_SAFE_INTEGER + 1 }),
|
||||
/nextId must be a positive safe integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [task],
|
||||
nextId: 2,
|
||||
timings: Array.from({ length: 201 }, () => ({ taskId: 1, startedAt: 0, accumulatedMs: 0 })),
|
||||
}),
|
||||
/timings are limited to 200 entries/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [task],
|
||||
nextId: 2,
|
||||
timings: [{ taskId: 1, startedAt: 0, stoppedAt: 0, accumulatedMs: Number.MAX_SAFE_INTEGER }],
|
||||
}),
|
||||
/accumulatedMs must be an integer between/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("rejects additions when no safe generated task ID remains", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.restore({ tasks: [], nextId: Number.MAX_SAFE_INTEGER });
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.add([{ name: "Unsafe ID" }]), /no safe task IDs remain/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("rejects out-of-range persisted timestamps without replacing state", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
@@ -318,6 +362,76 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
|
||||
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
|
||||
});
|
||||
|
||||
test("rejects terminal controls and bidirectional overrides without changing state", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Safe", description: "Line one\nLine two" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
for (const name of ["Escape\u001b[2J", "Tab\tname", "Arabic mark\u061cname", "Override\u202eevil"]) {
|
||||
assert.throws(() => state.add([{ name }]), /unsafe control/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
}
|
||||
|
||||
assert.throws(() => state.update([{ id: 1, description: "Clipboard\u009d52;c;payload" }]), /unsafe control/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("enforces task name and description length limits by Unicode character", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "😀".repeat(200), description: "d".repeat(4_000) }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.add([{ name: "n".repeat(201) }]), /name must be at most 200 characters/);
|
||||
assert.throws(() => state.update([{ id: 1, description: "d".repeat(4_001) }]), /description must be at most 4000 characters/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("enforces per-mutation and per-session task limits atomically", () => {
|
||||
const state = new TaskState();
|
||||
const batch = Array.from({ length: 50 }, (_, index) => ({ name: `Task ${index + 1}` }));
|
||||
|
||||
assert.throws(() => state.add([...batch, { name: "Too many" }]), /at most 50 tasks/);
|
||||
assert.equal(state.list().length, 0);
|
||||
|
||||
for (let index = 0; index < 4; index += 1) state.add(batch);
|
||||
assert.equal(state.list().length, 200);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.add([{ name: "Session overflow" }]), /session is limited to 200 tasks/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
new TaskState().restore({
|
||||
tasks: Array.from({ length: 201 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `Restored ${index + 1}`,
|
||||
status: "pending" as const,
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
})),
|
||||
nextId: 202,
|
||||
}),
|
||||
/session is limited to 200 tasks/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts eight task levels and rejects deeper trees atomically", () => {
|
||||
const nested = (depth: number) => {
|
||||
let task: { name: string; children?: Array<{ name: string; children?: unknown[] }> } = { name: `Level ${depth}` };
|
||||
for (let level = depth - 1; level >= 1; level -= 1) {
|
||||
task = { name: `Level ${level}`, children: [task] };
|
||||
}
|
||||
return task;
|
||||
};
|
||||
|
||||
const state = new TaskState();
|
||||
state.add([nested(8) as never]);
|
||||
assert.equal(state.list().length, 8);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.add([nested(9) as never]), /depth must not exceed 8 levels/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("supports nullable descriptions and reports invalid children as validation errors", () => {
|
||||
const state = new TaskState();
|
||||
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
|
||||
|
||||
Reference in New Issue
Block a user