diff --git a/tests/runner-e2e/chat-catalog-domains/runtime-discard.test.ts b/tests/runner-e2e/chat-catalog-domains/runtime-discard.test.ts index d781394f83e..3541301e050 100644 --- a/tests/runner-e2e/chat-catalog-domains/runtime-discard.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/runtime-discard.test.ts @@ -25,3 +25,7 @@ it("rejects accepting a stale discard of consumed input", () => { const o = vali it("rejects a later answer borrowed from another native phase", () => { const o = valid(); o.state.latePhaseAttributed = false; expect(gradeDiscard(o).passed).toBe(false); }); it("rejects new direction never reaching the actual later worker", () => { const o = valid(); o.state.cancelDirectionRunBound = false; expect(gradeDiscard(o).passed).toBe(false); }); it("rejects overwriting the already-delivered historical request", () => { const o = valid(); o.state.consumedStillReadable = false; expect(gradeDiscard(o).passed).toBe(false); }); + +it.each([403, 500])("rejects late discard status %s even when the human fixture otherwise looks valid", status => { + const o = valid(); o.state.lateDiscardStatus = status; expect(gradeDiscard(o).passed).toBe(false); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/runtime-discard.ts b/tests/runner-e2e/chat-catalog-domains/runtime-discard.ts index 45bc39f5da4..71d0f3beab3 100644 --- a/tests/runner-e2e/chat-catalog-domains/runtime-discard.ts +++ b/tests/runner-e2e/chat-catalog-domains/runtime-discard.ts @@ -7,7 +7,7 @@ import { submitTaskReply } from "../user-actions.js"; import { checks, type Entity } from "./internal-support.js"; import { probeAgentPermission, cleanupIdentityProbes } from "./identity-support.js"; import { releaseRuntimeWorker } from "./runtime-busy-worker.js"; -import { humanQueuedRuntime } from "./runtime-queue-controls.js"; +import { humanQueuedRuntime, probeConsumedHumanQueue } from "./runtime-queue-controls.js"; import { runtimeQueueCases } from "./runtime-queue.js"; import { runtimeDefinition, runtimeObservation, observeRuntime } from "./runtime-support.js"; @@ -78,7 +78,7 @@ export const runtimeDiscardCases = [runtimeDefinition("steering.discard-queued-m state.consumedBeforeCancellation = detail.contextSnapshot?.wakeCommentIds?.includes(second.comment.id) === true && detail.runnerProfileJson?.nativeExecutionInput?.task?.prompt?.includes(secondText) === true; state.discardedNeverDelivered = consumed.runs.every(row => !((row.contextSnapshot?.wakeCommentIds as string[] | undefined) ?? []).includes(targetId)); - state.lateDiscardStatus = (await ctx.input.page.request.delete(`${queuePath}/${second.comment.id}`, { data: { queueId: secondQueue.queueId, revision: secondQueue.revision } })).status(); + state.lateDiscardStatus = await probeConsumedHumanQueue(ctx, String(state.humanId), `${queuePath}/${second.comment.id}`, "DELETE", { queueId: secondQueue.queueId, revision: secondQueue.revision }); await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); const message = `I later queued a different contact request on Website ${task.identifier ?? task.id}, comment ${second.comment.id}, and Morgan has already consumed it. Cancel that later contact requirement now by sending Morgan new direction to remove the contact section from the saved website plan while retaining the accessible-font requirement. Explain what happened to the already-delivered input; do not erase or rewrite it.`; const priorIds = consumed.runs.map(row => row.id); diff --git a/tests/runner-e2e/chat-catalog-domains/runtime-human-queue-probe.test.ts b/tests/runner-e2e/chat-catalog-domains/runtime-human-queue-probe.test.ts new file mode 100644 index 00000000000..bafd8a2eeb2 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/runtime-human-queue-probe.test.ts @@ -0,0 +1,31 @@ +import { expect, it, vi } from "vitest"; +import type { ChatCatalogContext } from "../chat-catalog-contract.js"; +import { probeConsumedHumanQueue } from "./runtime-queue-controls.js"; +function fixture(method: "PATCH" | "DELETE", status = 409) { + const origin = "http://127.0.0.1:4567", user = { user: { id: "human" } }; + const fetch = vi.fn(async (_url, options) => ({ status: () => options.headers.Origin === origin ? status : 403 })); + const browserGet = vi.fn(async () => ({ status: () => 200, json: async () => user })); + const ctx = { scenario: { id: method === "PATCH" ? "steering.edit-queued-message" : "steering.discard-queued-message" }, + input: { api: { baseURL: origin, get: vi.fn(async () => user) }, page: { url: () => `${origin}/RUN/issues/RUN-1`, request: { get: browserGet, fetch } } } } as unknown as ChatCatalogContext; + return { ctx, fetch, browserGet, origin }; +} +it.each(["PATCH", "DELETE"] as const)("preserves the same browser human, trusted Origin and exact stale %s response", async method => { + const f = fixture(method), data = { queueId: "queue", revision: 3 }; + expect(await probeConsumedHumanQueue(f.ctx, "human", "/api/issues/task/queued-comments/comment", method, data)).toBe(409); + expect(f.fetch).toHaveBeenCalledExactlyOnceWith(`${f.origin}/api/issues/task/queued-comments/comment`, { method, data, headers: { Origin: f.origin }, maxRedirects: 0, timeout: 15_000 }); + expect(f.browserGet).toHaveBeenCalledExactlyOnceWith(`${f.origin}/api/auth/get-session`, { maxRedirects: 0 }); +}); +it("never substitutes a changed human, other origin, other endpoint or undeclared scenario", async () => { + for (const change of [ (f: ReturnType) => { f.ctx.input.api.get = vi.fn(async () => ({ user: { id: "other" } })) as never; }, + (f: ReturnType) => { f.browserGet.mockResolvedValue({ status: () => 200, json: async () => ({ user: { id: "other" } }) }); }, + (f: ReturnType) => { f.ctx.scenario.id = "steering.queue-follow-up"; } ]) { + const f = fixture("PATCH"); change(f); + await expect(probeConsumedHumanQueue(f.ctx, "human", "/api/issues/task/queued-comments/comment", "PATCH", {})).rejects.toThrow(); expect(f.fetch).not.toHaveBeenCalled(); + } + for (const endpoint of ["https://foreign.test/api/issues/task/queued-comments/comment", "/api/instance/settings", "/api/issues/task/queued-comments/comment?redirect=1"]) { + const f = fixture("DELETE"); await expect(probeConsumedHumanQueue(f.ctx, "human", endpoint, "DELETE", {})).rejects.toThrow(); expect(f.fetch).not.toHaveBeenCalled(); + } +}); +it.each([200, 403, 500])("retains actual non-conflict %s instead of manufacturing 409", async status => { + const f = fixture("DELETE", status); expect(await probeConsumedHumanQueue(f.ctx, "human", "/api/issues/task/queued-comments/comment", "DELETE", {})).toBe(status); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/runtime-queue-controls.test.ts b/tests/runner-e2e/chat-catalog-domains/runtime-queue-controls.test.ts index d78619c7d8c..bc11f874eca 100644 --- a/tests/runner-e2e/chat-catalog-domains/runtime-queue-controls.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/runtime-queue-controls.test.ts @@ -26,3 +26,7 @@ it("rejects stale concurrent acceptance", () => { const o = valid(); o.state.con it("rejects silently overwriting an already-consumed message", () => { const o = valid(); o.state.dispatchedStatus = 200; expect(gradeQueueEdit(o).passed).toBe(false); }); it("rejects a plausible wrong consumer without exact saved input and order", () => { const o = valid(); o.state.nativeDeliveryBound = false; expect(gradeQueueEdit(o).passed).toBe(false); }); it("rejects losing the initial native work", () => { const o = valid(); o.state.initialRevisionRetained = false; expect(gradeQueueEdit(o).passed).toBe(false); }); + +it.each([200, 403, 500])("rejects late edit status %s rather than replacing the real 409 assertion", status => { + const o = valid(); o.state.dispatchedStatus = status; expect(gradeQueueEdit(o).passed).toBe(false); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/runtime-queue-controls.ts b/tests/runner-e2e/chat-catalog-domains/runtime-queue-controls.ts index 97861e0ab48..dae940f653e 100644 --- a/tests/runner-e2e/chat-catalog-domains/runtime-queue-controls.ts +++ b/tests/runner-e2e/chat-catalog-domains/runtime-queue-controls.ts @@ -28,6 +28,18 @@ export async function humanQueuedRuntime(ctx: ChatCatalogContext, targetText = r Object.assign(state, { queueBefore: before, targetId: before.entries[1]!.comment.id, humanId: session.user.id, concurrentMarker: `Preserve the human note ${ctx.input.nonce}.` }); await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); return state; } +export async function probeConsumedHumanQueue(ctx: ChatCatalogContext, humanId: string, endpoint: string, method: "PATCH" | "DELETE", data: unknown) { + const origin = ctx.input.api.baseURL, url = new URL(endpoint, origin); + if (ctx.scenario.id !== "steering.edit-queued-message" && ctx.scenario.id !== "steering.discard-queued-message") throw new Error("Consumed queue probe requires its declared human scenario"); + if (!humanId || new URL(ctx.input.page.url()).origin !== origin || url.origin !== origin || url.search || url.hash + || !/^\/api\/issues\/[^/]+\/queued-comments\/[^/]+$/.test(url.pathname)) throw new Error("Consumed queue probe escaped its human fixture"); + const apiSession = await ctx.input.api.get<{ user?: { id?: string } }>("/api/auth/get-session"); + const browserSession = await ctx.input.page.request.get(`${origin}/api/auth/get-session`, { maxRedirects: 0 }); + if (browserSession.status() !== 200 || apiSession.user?.id !== humanId || (await browserSession.json() as { user?: { id?: string } }).user?.id !== humanId) + throw new Error("Consumed queue probe requires the original authenticated human in both contexts"); + const response = await ctx.input.page.request.fetch(url.href, { method, data, headers: { Origin: origin }, maxRedirects: 0, timeout: 15_000 }); + return response.status(); +} async function edit(page: Page, id: string, body: string) { await page.getByTestId(`task-chat-queued-message-${id}`).getByRole("button", { name: /^Queued message actions:/ }).click(); await page.getByRole("menuitem", { name: "Edit message", exact: true }).click(); @@ -111,7 +123,7 @@ export const runtimeQueueControlCases = [runtimeDefinition("steering.edit-queued reject: v => v.runs.length > 3 || v.runs.some(row => ["failed", "cancelled", "timed_out"].includes(row.status)) ? "The declared edited-message delivery failed or duplicated work" : undefined }); await expect(ctx.input.page.getByText("Queued message changed", { exact: true })).toBeVisible(); await expect(ctx.input.page.getByRole("button", { name: "Queue as new message", exact: true })).toBeVisible(); state.dispatchedWarningVisible = true; - state.dispatchedStatus = (await ctx.input.page.request.patch(mutation, { data: { queueId: pending.queueId, revision: pending.revision, body: "Late stale edit must not replace history" } })).status(); + state.dispatchedStatus = await probeConsumedHumanQueue(ctx, String(state.humanId), mutation, "PATCH", { queueId: pending.queueId, revision: pending.revision, body: "Late stale edit must not replace history" }); const comments = await ctx.input.api.get(`/api/issues/${(state.task as Entity).id}/comments?order=asc`); state.originalCommentStillReadable = comments.some(row => row.id === targetId && row.body === `${blueText} ${state.concurrentMarker}` && row.authorUserId === state.humanId); const consumers = delivered.runs.filter(row => row.agentId === (state.worker as Entity).id && row.id !== state.workerRunId);