diff --git a/server/src/modules/computers/application/service.test.ts b/server/src/modules/computers/application/service.test.ts index d7211d9a7b..a576befd68 100644 --- a/server/src/modules/computers/application/service.test.ts +++ b/server/src/modules/computers/application/service.test.ts @@ -298,6 +298,30 @@ describe("computer ownership", () => { await f.service.reconcile(); expect(f.backend.stop).toHaveBeenCalledOnce(); }); + it("disconnects only the exact company-scoped viewer owned by the current user without provider access", async () => { + const f = fixture(); + await f.attach(); + const viewer = await f.service.connect({ ...f.scope, userId: "alice", idleTimeoutMs: 60_000 }); + const other = await f.service.connect({ ...f.scope, userId: "bob", idleTimeoutMs: 60_000 }); + const runner = await f.admit(); + vi.clearAllMocks(); + const input = { ...f.scope, owner: viewer.owner, userId: "alice" }; + await expect(f.service.disconnectViewer({ ...input, companyId: "foreign-company" })).rejects.toMatchObject({ code: "not_found" }); + await expect(f.service.disconnectViewer({ ...input, environmentId: "foreign-environment" })).rejects.toMatchObject({ code: "not_found" }); + await expect(f.service.disconnectViewer({ ...input, userId: "bob" })).rejects.toMatchObject({ code: "forbidden" }); + await expect(f.service.disconnectViewer({ ...input, owner: { ...viewer.owner, generation: viewer.owner.generation + 1 } })).rejects.toMatchObject({ code: "conflict" }); + await expect(f.service.disconnectViewer({ ...input, owner: { ...viewer.owner, computerId: "another-computer" } })).rejects.toMatchObject({ code: "conflict" }); + await expect(f.service.disconnectViewer({ ...input, owner: runner.owner })).rejects.toMatchObject({ code: "forbidden" }); + expect((await f.repository.get(f.scope)).ledger.owners.every(owner => owner.phase === "active")).toBe(true); + await f.service.disconnectViewer(input); + const owners = (await f.repository.get(f.scope)).ledger.owners; + expect(owners.find(owner => owner.id === viewer.owner.ownerId)?.phase).toBe("retired"); + expect(owners.find(owner => owner.id === other.owner.ownerId)?.phase).toBe("active"); + expect(owners.find(owner => owner.id === runner.owner.ownerId)?.phase).toBe("active"); + for (const method of [f.backend.inspect, f.backend.ready, f.backend.retire, f.backend.stop, f.backend.renew, f.backend.desktop]) { + expect(method).not.toHaveBeenCalled(); + } + }); it("prunes retired viewers while preserving active viewers and runner stop proof", async () => { const f = fixture(); await f.attach(); diff --git a/server/src/routes/computers.ts b/server/src/routes/computers.ts index 591db369f0..e00115136e 100644 --- a/server/src/routes/computers.ts +++ b/server/src/routes/computers.ts @@ -28,6 +28,13 @@ export function computerRoutes(db: Db, computers = computerService(db)) { if (!issue) return; res.setHeader("Cache-Control", "no-store"); res.setHeader("Referrer-Policy", "no-referrer"); + if (req.method === "POST" && req.path === "/disconnect") { + // A task may have moved since this viewer connected. Cleanup is scoped + // by the module to the posted environment, exact owner, and current user. + res.locals.computer = { issue }; + next(); + return; + } if (!(await settings.getExperimental()).enableBoatEnvironments && !req.path.endsWith("/disconnect")) { if (req.method === "GET") { res.json(null); return; } throw unprocessable("Boat environments are disabled."); @@ -76,13 +83,12 @@ export function computerRoutes(db: Db, computers = computerService(db)) { router.post("/issues/:issueId/computer/disconnect", async (req, res) => { const input = presenceSchema.parse(req.body); - const { environment, issue } = res.locals.computer; - if (input.environmentId !== environment.id) throw notFound("Computer not available for this task"); + const { issue } = res.locals.computer; const actor = getActorInfo(req); - await computers.disconnectViewer({ companyId: issue.companyId, environmentId: environment.id, + await computers.disconnectViewer({ companyId: issue.companyId, environmentId: input.environmentId, owner: input.owner, userId: actor.actorId }); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, - action: "computer.disconnected", entityType: "environment", entityId: environment.id, details: { issueId: issue.id } }); + action: "computer.disconnected", entityType: "environment", entityId: input.environmentId, details: { issueId: issue.id } }); res.status(204).end(); });