mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 03:42:21 +03:00
feat(db): deleteFileOwnedBy — owner-scoped file soft delete
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
56
tests/unit/files-delete-owned-by.test.ts
Normal file
56
tests/unit/files-delete-owned-by.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user