diff --git a/extensions/google-meet/src/cli-export.test.ts b/extensions/google-meet/src/cli-export.test.ts new file mode 100644 index 000000000000..25141e3ad0c2 --- /dev/null +++ b/extensions/google-meet/src/cli-export.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import JSZip from "jszip"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { writeMeetExportBundle } from "./cli-export.js"; +import type { GoogleMeetArtifactsResult, GoogleMeetAttendanceResult } from "./meet-api.js"; + +const emptyArtifacts: GoogleMeetArtifactsResult = { + conferenceRecords: [], + artifacts: [], +}; + +const emptyAttendance: GoogleMeetAttendanceResult = { + conferenceRecords: [], + attendance: [], +}; + +describe("Google Meet export publication", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-export-publication-")); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("keeps an existing bundle member when replacement fails", async () => { + const outputDir = path.join(tempDir, "bundle"); + const summaryPath = path.join(outputDir, "summary.md"); + fs.mkdirSync(outputDir); + fs.writeFileSync(summaryPath, "previous summary\n"); + const priorBytes = fs.readFileSync(summaryPath); + + vi.spyOn(fsp, "writeFile").mockImplementationOnce(async (file) => { + expect(typeof file).toBe("string"); + fs.writeFileSync(file as string, "partial replacement"); + throw new Error("injected write failure"); + }); + + await expect( + writeMeetExportBundle({ + outputDir, + artifacts: emptyArtifacts, + attendance: emptyAttendance, + }), + ).rejects.toThrow("injected write failure"); + + expect(fs.readFileSync(summaryPath)).toEqual(priorBytes); + expect(fs.readdirSync(outputDir)).toEqual(["summary.md"]); + }); + + it("keeps an existing ZIP when replacement fails", async () => { + const outputDir = path.join(tempDir, "bundle"); + const zipPath = `${outputDir}.zip`; + const priorZip = await new JSZip() + .file("previous.txt", "previous export") + .generateAsync({ type: "nodebuffer" }); + fs.writeFileSync(zipPath, priorZip); + const realWriteFile = fsp.writeFile; + + vi.spyOn(fsp, "writeFile").mockImplementation(async (...args) => { + const [file, data] = args; + if (Buffer.isBuffer(data)) { + expect(typeof file).toBe("string"); + fs.writeFileSync(file as string, "partial replacement"); + throw new Error("injected ZIP write failure"); + } + await Reflect.apply(realWriteFile, fsp, args); + }); + + await expect( + writeMeetExportBundle({ + outputDir, + artifacts: emptyArtifacts, + attendance: emptyAttendance, + zip: true, + }), + ).rejects.toThrow("injected ZIP write failure"); + + expect(fs.readFileSync(zipPath)).toEqual(priorZip); + expect(fs.readdirSync(tempDir).toSorted()).toEqual(["bundle", "bundle.zip"]); + }); +}); diff --git a/extensions/google-meet/src/cli-export.ts b/extensions/google-meet/src/cli-export.ts index 3fdd17245ee6..295c3eeb99b9 100644 --- a/extensions/google-meet/src/cli-export.ts +++ b/extensions/google-meet/src/cli-export.ts @@ -1,6 +1,7 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import fsp from "node:fs/promises"; import path from "node:path"; import JSZip from "jszip"; +import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { listGoogleMeetCalendarEvents, type GoogleMeetCalendarLookupResult } from "./calendar.js"; import { formatDuration, @@ -522,6 +523,17 @@ function defaultExportDirectory(): string { return `google-meet-export-${new Date().toISOString().replace(/[:.]/g, "-")}`; } +async function publishMeetExportFile(outputPath: string, content: string | Buffer): Promise { + const absolutePath = path.resolve(outputPath); + await writeExternalFileWithinRoot({ + rootDir: path.dirname(absolutePath), + path: path.basename(absolutePath), + write: async (tempPath) => { + await fsp.writeFile(tempPath, content); + }, + }); +} + export async function writeMeetExportBundle(params: { outputDir?: string; artifacts: GoogleMeetArtifactsResult; @@ -532,7 +544,7 @@ export async function writeMeetExportBundle(params: { calendarEvent?: GoogleMeetCalendarLookupResult; }): Promise<{ outputDir: string; files: string[]; zipFile?: string }> { const outputDir = params.outputDir?.trim() || defaultExportDirectory(); - await mkdir(outputDir, { recursive: true }); + await fsp.mkdir(outputDir, { recursive: true }); const zipFile = params.zip ? `${outputDir.replace(/\/$/, "")}.zip` : undefined; const fileNames = googleMeetExportFileNames(); const files = [ @@ -562,7 +574,7 @@ export async function writeMeetExportBundle(params: { }, ]; for (const file of files) { - await writeFile(path.join(outputDir, file.name), file.content, "utf8"); + await publishMeetExportFile(path.join(outputDir, file.name), file.content); } const result: { outputDir: string; files: string[]; zipFile?: string } = { outputDir, @@ -573,7 +585,7 @@ export async function writeMeetExportBundle(params: { for (const file of files) { zip.file(file.name, file.content); } - await writeFile(zipFile, await zip.generateAsync({ type: "nodebuffer" })); + await publishMeetExportFile(zipFile, await zip.generateAsync({ type: "nodebuffer" })); result.zipFile = zipFile; } return result;