Harden task state and terminal rendering

This commit is contained in:
jb
2026-08-26 15:13:19 -04:00
parent 6826854b66
commit ae0ca47236
10 changed files with 452 additions and 48 deletions
+114
View File
@@ -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/);