Skip to content

Commit 984f7c4

Browse files
committed
test(dcg): increase integration coverage
1 parent 9b77aa7 commit 984f7c4

2 files changed

Lines changed: 174 additions & 1 deletion

File tree

src/services/destructive-command-guard/__tests__/manager.spec.ts

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { createHash } from "crypto"
22
import { EventEmitter } from "events"
3-
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises"
3+
import { access, chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "fs/promises"
44
import { tmpdir } from "os"
55
import path from "path"
66
import { PassThrough } from "stream"
77

88
import { spawn } from "child_process"
9+
import { get } from "https"
10+
import type { IncomingMessage, RequestOptions } from "http"
911

1012
import { DCG_ARCHIVES, DCG_MAX_ARCHIVE_BYTES, DCG_VERSION } from "../constants"
1113
import {
@@ -19,19 +21,23 @@ import {
1921
isTrustedDownloadUrl,
2022
promoteStagedInstallation,
2123
resolveTrustedRedirect,
24+
ensureDcgInstalled,
2225
verifyChecksum,
2326
} from "../manager"
2427

2528
vi.mock("child_process", () => ({ spawn: vi.fn() }))
29+
vi.mock("https", () => ({ get: vi.fn() }))
2630

2731
const mockSpawn = vi.mocked(spawn)
32+
const mockGet = vi.mocked(get)
2833

2934
describe("Destructive Command Guard manager", () => {
3035
let tempDir: string
3136

3237
beforeEach(async () => {
3338
tempDir = await mkdtemp(path.join(tmpdir(), "dcg-manager-"))
3439
mockSpawn.mockReset()
40+
mockGet.mockReset()
3541
})
3642

3743
afterEach(async () => {
@@ -67,6 +73,7 @@ describe("Destructive Command Guard manager", () => {
6773
expect(isTrustedDownloadUrl("https://cdn.objects.githubusercontent.com/release")).toBe(true)
6874
expect(isTrustedDownloadUrl("http://github.com/release")).toBe(false)
6975
expect(isTrustedDownloadUrl("https://evilgithub.com/release")).toBe(false)
76+
expect(isTrustedDownloadUrl("not a URL")).toBe(false)
7077
})
7178

7279
it("rejects untrusted download URLs before opening a destination", async () => {
@@ -147,6 +154,47 @@ describe("Destructive Command Guard manager", () => {
147154
expect(mockSpawn).toHaveBeenCalledTimes(1)
148155
})
149156

157+
it("extracts a validated tar archive containing only the managed binary", async () => {
158+
const children = [0, 1].map(() =>
159+
Object.assign(new EventEmitter(), {
160+
stdout: new PassThrough(),
161+
stderr: new PassThrough(),
162+
kill: vi.fn(),
163+
}),
164+
)
165+
mockSpawn.mockReturnValueOnce(children[0] as unknown as ReturnType<typeof spawn>)
166+
mockSpawn.mockReturnValueOnce(children[1] as unknown as ReturnType<typeof spawn>)
167+
168+
const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"])
169+
children[0].stdout.write("./dcg\n")
170+
children[0].emit("close", 0)
171+
await new Promise<void>((resolve) => setImmediate(resolve))
172+
children[1].emit("close", 0)
173+
174+
await extraction
175+
expect(mockSpawn).toHaveBeenNthCalledWith(
176+
2,
177+
"tar",
178+
["-xJf", "/tmp/dcg.tar.xz", "-C", tempDir, "dcg"],
179+
expect.objectContaining({ shell: false }),
180+
)
181+
})
182+
183+
it("surfaces process failures during extraction", async () => {
184+
const child = Object.assign(new EventEmitter(), {
185+
stdout: new PassThrough(),
186+
stderr: new PassThrough(),
187+
kill: vi.fn(),
188+
})
189+
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>)
190+
191+
const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"])
192+
child.stderr.write("invalid archive")
193+
child.emit("close", 2)
194+
195+
await expect(extraction).rejects.toThrow("invalid archive")
196+
})
197+
150198
it("does not replace an installation completed by another process", async () => {
151199
const stagingDir = path.join(tempDir, "staging")
152200
const finalDir = path.join(tempDir, "final")
@@ -162,6 +210,19 @@ describe("Destructive Command Guard manager", () => {
162210
expect(await readFile(path.join(stagingDir, "dcg"), "utf8")).toBe("staged")
163211
})
164212

213+
it("promotes a staged installation when no completed installation exists", async () => {
214+
const stagingDir = path.join(tempDir, "staging")
215+
const finalDir = path.join(tempDir, "final")
216+
const binaryPath = path.join(finalDir, "dcg")
217+
await mkdir(stagingDir)
218+
await writeFile(path.join(stagingDir, "dcg"), "staged")
219+
220+
await promoteStagedInstallation(stagingDir, finalDir, binaryPath)
221+
222+
expect(await readFile(binaryPath, "utf8")).toBe("staged")
223+
await expect(access(stagingDir)).rejects.toThrow()
224+
})
225+
165226
it("removes only stale version directories after a successful update", async () => {
166227
const currentDir = path.join(tempDir, DCG_VERSION)
167228
const staleDir = path.join(tempDir, "v0.6.0")
@@ -176,4 +237,101 @@ describe("Destructive Command Guard manager", () => {
176237
Promise.all([currentDir, stagingDir, unrelatedDir].map((dir) => access(dir))),
177238
).resolves.toBeDefined()
178239
})
240+
241+
it("treats stale-installation cleanup failures as cosmetic", async () => {
242+
await expect(cleanupStaleInstallations(path.join(tempDir, "missing"))).resolves.toBeUndefined()
243+
})
244+
245+
it("reuses an existing managed binary and restores its executable permissions", async () => {
246+
const binaryPath = getDcgBinaryPath(tempDir)
247+
expect(binaryPath).toBeDefined()
248+
await mkdir(path.dirname(binaryPath!), { recursive: true })
249+
await writeFile(binaryPath!, "existing binary")
250+
if (process.platform !== "win32") {
251+
await chmod(binaryPath!, 0o600)
252+
}
253+
254+
await expect(ensureDcgInstalled(tempDir)).resolves.toBe(binaryPath)
255+
expect(mockSpawn).not.toHaveBeenCalled()
256+
if (process.platform !== "win32") {
257+
expect((await stat(binaryPath!)).mode & 0o111).toBe(0o111)
258+
}
259+
})
260+
261+
it("downloads, verifies, extracts, and deduplicates a new installation", async () => {
262+
const info = getDcgArchiveInfo()
263+
expect(info).toBeDefined()
264+
if (!info || info.archive.endsWith(".zip")) return
265+
266+
const archive = Buffer.from("test archive")
267+
const originalChecksum = info.sha256
268+
Object.defineProperty(info, "sha256", {
269+
value: createHash("sha256").update(archive).digest("hex"),
270+
configurable: true,
271+
})
272+
const now = vi.spyOn(Date, "now").mockReturnValue(1234)
273+
274+
const response = Object.assign(new PassThrough(), {
275+
statusCode: 200,
276+
headers: { "content-length": String(archive.length) },
277+
})
278+
const request = Object.assign(new EventEmitter(), {
279+
setTimeout: vi.fn(),
280+
destroy: vi.fn(),
281+
})
282+
mockGet.mockImplementation(
283+
(
284+
_url: string | URL,
285+
optionsOrCallback: RequestOptions | ((response: IncomingMessage) => void),
286+
optionalCallback?: (response: IncomingMessage) => void,
287+
) => {
288+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
289+
setImmediate(() => {
290+
// The downloader uses only the response stream/status subset supplied here.
291+
callback?.(response as unknown as IncomingMessage)
292+
response.end(archive)
293+
})
294+
// The downloader uses only timeout/error handling from ClientRequest.
295+
return request as unknown as ReturnType<typeof get>
296+
},
297+
)
298+
299+
mockSpawn.mockImplementation((executable, args) => {
300+
const child = Object.assign(new EventEmitter(), {
301+
stdout: new PassThrough(),
302+
stderr: new PassThrough(),
303+
kill: vi.fn(),
304+
})
305+
setImmediate(async () => {
306+
if (executable === "tar" && args[0] === "-tJf") {
307+
child.stdout.write(`${info.binary}\n`)
308+
} else if (executable === "tar") {
309+
const stagingDir = args[args.indexOf("-C") + 1]
310+
await writeFile(path.join(stagingDir, info.binary), "executable")
311+
} else {
312+
child.stdout.write(DCG_VERSION.replace(/^v/, ""))
313+
}
314+
child.emit("close", 0)
315+
})
316+
// The process runner uses only the event and stream subset supplied here.
317+
return child as unknown as ReturnType<typeof spawn>
318+
})
319+
320+
try {
321+
const firstInstallation = ensureDcgInstalled(tempDir)
322+
const concurrentInstallation = ensureDcgInstalled(tempDir)
323+
expect(concurrentInstallation).toBe(firstInstallation)
324+
325+
const binaryPath = await firstInstallation
326+
expect(await readFile(binaryPath, "utf8")).toBe("executable")
327+
expect(mockGet).toHaveBeenCalledTimes(1)
328+
expect(mockSpawn).toHaveBeenCalledTimes(3)
329+
await expect(
330+
access(path.join(tempDir, "destructive-command-guard", `${info.archive}.1234.download`)),
331+
).rejects.toThrow()
332+
} finally {
333+
now.mockRestore()
334+
Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true })
335+
}
336+
})
179337
})

webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ describe("AutoApproveSettings - Save/Discard contract", () => {
113113
expect(screen.getByTestId("destructive-command-guard-checkbox")).not.toBeChecked()
114114
})
115115

116+
it("renders destructive command guard enabled from cached settings", () => {
117+
renderSettings({ destructiveCommandGuardEnabled: true })
118+
119+
expect(screen.getByTestId("destructive-command-guard-checkbox")).toBeChecked()
120+
})
121+
122+
it("buffers disabling destructive command guard", () => {
123+
const { setCachedStateField } = renderSettings({ destructiveCommandGuardEnabled: true })
124+
125+
fireEvent.click(screen.getByTestId("destructive-command-guard-checkbox"))
126+
127+
expect(setCachedStateField).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false)
128+
expectNoImmediateUpdateSettings()
129+
})
130+
116131
it("hides Zoo command list editors while destructive command guard is enabled", () => {
117132
renderSettings({ destructiveCommandGuardEnabled: true, deniedCommands: ["rm -rf"] })
118133

0 commit comments

Comments
 (0)