From 3bf006da95f5389c1473f2f327f33e6b573576e6 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:40:28 -0300 Subject: [PATCH] =?UTF-8?q?feat(db):=20deleteFileOwnedBy=20=E2=80=94=20own?= =?UTF-8?q?er-scoped=20file=20soft=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/db/files.ts | 19 ++++++++ tests/unit/files-delete-owned-by.test.ts | 56 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/unit/files-delete-owned-by.test.ts diff --git a/src/lib/db/files.ts b/src/lib/db/files.ts index 4d77cf59e4..64e0b64c3f 100644 --- a/src/lib/db/files.ts +++ b/src/lib/db/files.ts @@ -168,3 +168,22 @@ export function deleteFile(id: string): boolean { .run(Math.floor(Date.now() / 1000), id); return result.changes > 0; } + +/** + * Owner-scoped soft delete: same effect as `deleteFile`, but only when the + * file belongs to `apiKeyId`. Used by the key-scoped completed-batch sweep so + * a batch that references another tenant's (or an unowned) file never nulls + * that file's content (GHSA-wvxc-jp3v-5mg5, SEC-C). Returns false when the + * row is not the caller's; throws on an empty owner so a caller cannot widen + * the delete by passing a blank id. + */ +export function deleteFileOwnedBy(id: string, apiKeyId: string): boolean { + if (typeof apiKeyId !== "string" || apiKeyId.trim() === "") { + throw new Error("deleteFileOwnedBy: apiKeyId is required"); + } + const db = getDbInstance(); + const result = db + .prepare("UPDATE files SET deleted_at = ?, content = NULL WHERE id = ? AND api_key_id = ?") + .run(Math.floor(Date.now() / 1000), id, apiKeyId); + return result.changes > 0; +} diff --git a/tests/unit/files-delete-owned-by.test.ts b/tests/unit/files-delete-owned-by.test.ts new file mode 100644 index 0000000000..42fa52ca9b --- /dev/null +++ b/tests/unit/files-delete-owned-by.test.ts @@ -0,0 +1,56 @@ +// tests/unit/files-delete-owned-by.test.ts +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "files-owned-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { createFile, getFile, getFileContent, deleteFileOwnedBy } = + await import("../../src/lib/db/files.ts"); + +const seed = (apiKeyId: string | null, label: string) => + createFile({ + bytes: label.length, + filename: `${label}.jsonl`, + purpose: "batch", + content: Buffer.from(label), + apiKeyId, + }); + +describe("deleteFileOwnedBy — owner-scoped soft delete", () => { + after(() => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + it("soft-deletes the owner's own file and nulls its content", () => { + const own = seed("key-A", "own"); + assert.strictEqual(deleteFileOwnedBy(own.id, "key-A"), true); + assert.strictEqual(getFile(own.id), null, "metadata read hides a soft-deleted file"); + assert.strictEqual(getFileContent(own.id), null); + }); + + it("returns false and leaves another key's file intact", () => { + const other = seed("key-B", "other"); + assert.strictEqual(deleteFileOwnedBy(other.id, "key-A"), false); + assert.ok(getFile(other.id)); + assert.strictEqual(getFileContent(other.id)?.toString(), "other"); + }); + + it("returns false for an unowned file (api_key_id NULL) — only the instance sweep reaches those", () => { + const unowned = seed(null, "unowned"); + assert.strictEqual(deleteFileOwnedBy(unowned.id, "key-A"), false); + assert.strictEqual(getFileContent(unowned.id)?.toString(), "unowned"); + }); + + it("throws on an empty apiKeyId instead of widening", () => { + const own = seed("key-A", "guard"); + assert.throws(() => deleteFileOwnedBy(own.id, ""), /apiKeyId/); + assert.throws(() => deleteFileOwnedBy(own.id, undefined as unknown as string), /apiKeyId/); + assert.strictEqual(getFileContent(own.id)?.toString(), "guard"); + }); +});