mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
Policies: enforce input on uploads only; badge follows edited files (#6627)
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
fileContextReducer,
|
||||
initialFileContextState,
|
||||
} from "@app/contexts/file/FileReducer";
|
||||
import type {
|
||||
FileContextState,
|
||||
StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
function stub(
|
||||
id: string,
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub {
|
||||
return {
|
||||
id: id as FileId,
|
||||
name: `${id}.pdf`,
|
||||
type: "application/pdf",
|
||||
size: 1,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stateWith(stubs: StirlingFileStub[]): FileContextState {
|
||||
return {
|
||||
...initialFileContextState,
|
||||
files: {
|
||||
ids: stubs.map((s) => s.id),
|
||||
byId: Object.fromEntries(stubs.map((s) => [s.id, s])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("fileContextReducer — derivedFromTool provenance", () => {
|
||||
it("ADD_FILES leaves uploads unmarked (a genuine upload is not tool-derived)", () => {
|
||||
const next = fileContextReducer(initialFileContextState, {
|
||||
type: "ADD_FILES",
|
||||
payload: { stirlingFileStubs: [stub("a")] },
|
||||
});
|
||||
expect(next.files.byId["a" as FileId].derivedFromTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it("CONSUME_FILES marks every output as tool-derived", () => {
|
||||
// An upload "a" is consumed by a tool producing an independent artifact "b"
|
||||
// (version metadata identical to an upload — only the flag distinguishes it).
|
||||
const next = fileContextReducer(stateWith([stub("a")]), {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId],
|
||||
outputStirlingFileStubs: [stub("b")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["b" as FileId].derivedFromTool).toBe(true);
|
||||
// Provenance: "b" records the input it derived from.
|
||||
expect(next.files.byId["b" as FileId].sourceFileIds).toEqual(["a"]);
|
||||
expect(next.files.byId["a" as FileId]).toBeUndefined(); // input consumed
|
||||
});
|
||||
|
||||
it("CONSUME_FILES accumulates sourceFileIds transitively", () => {
|
||||
// "b" already derived from "a"; consuming "b" → "c" carries both, so the
|
||||
// badge still resolves after the intermediate "b" is gone (e.g. split).
|
||||
const start = stateWith([stub("b", { sourceFileIds: ["a" as FileId] })]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["b" as FileId],
|
||||
outputStirlingFileStubs: [stub("c")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["c" as FileId].sourceFileIds).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("CONSUME_FILES with multiple inputs (merge) records all sources", () => {
|
||||
const start = stateWith([stub("a"), stub("b")]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId, "b" as FileId],
|
||||
outputStirlingFileStubs: [stub("merged")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["merged" as FileId].sourceFileIds).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("UNDO_CONSUME_FILES restores the original upload without mislabelling it", () => {
|
||||
// Reverses the swap above: the original upload "a" comes back through the
|
||||
// same helper, but must NOT be flagged tool-derived.
|
||||
const consumed = fileContextReducer(stateWith([stub("a")]), {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId],
|
||||
outputStirlingFileStubs: [stub("b")],
|
||||
},
|
||||
});
|
||||
const undone = fileContextReducer(consumed, {
|
||||
type: "UNDO_CONSUME_FILES",
|
||||
payload: {
|
||||
inputStirlingFileStubs: [stub("a")],
|
||||
outputFileIds: ["b" as FileId],
|
||||
},
|
||||
});
|
||||
expect(undone.files.byId["a" as FileId].derivedFromTool).toBeUndefined();
|
||||
expect(undone.files.byId["b" as FileId]).toBeUndefined(); // output removed
|
||||
});
|
||||
});
|
||||
@@ -288,7 +288,33 @@ export function fileContextReducer(
|
||||
case "CONSUME_FILES": {
|
||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||
|
||||
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||
// Transitive provenance: the outputs derive from these inputs AND from
|
||||
// whatever those inputs themselves derived from. Accumulating the closure
|
||||
// (rather than just the immediate inputs) means a policy badge still
|
||||
// resolves after an intermediate edit has been consumed and removed —
|
||||
// e.g. redact → edit → split still tags each split part. Captured before
|
||||
// the inputs are swapped out below.
|
||||
const sourceFileIds = Array.from(
|
||||
new Set(
|
||||
inputFileIds.flatMap((id) => [
|
||||
id,
|
||||
...(state.files.byId[id]?.sourceFileIds ?? []),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// Mark every consume output as tool-produced (the single chokepoint for
|
||||
// both versioned edits and independent artifacts like convert/split/merge)
|
||||
// and stamp its provenance. Tag here, not in processFileSwap, so
|
||||
// UNDO_CONSUME (which restores the original inputs through the same helper)
|
||||
// doesn't mislabel real uploads.
|
||||
const taggedOutputs = outputStirlingFileStubs.map((stub) => ({
|
||||
...stub,
|
||||
derivedFromTool: true,
|
||||
sourceFileIds,
|
||||
}));
|
||||
|
||||
return processFileSwap(state, inputFileIds, taggedOutputs);
|
||||
}
|
||||
|
||||
case "UNDO_CONSUME_FILES": {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
legacyDerivedFromTool,
|
||||
type StoredStirlingFileRecord,
|
||||
} from "@app/services/fileStorage";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/**
|
||||
* Backfill of `derivedFromTool` for files persisted before the field existed
|
||||
* (old users' IndexedDB). New records always carry an explicit flag, so this
|
||||
* helper only governs pre-upgrade records.
|
||||
*/
|
||||
function record(
|
||||
overrides: Partial<StoredStirlingFileRecord>,
|
||||
): StoredStirlingFileRecord {
|
||||
return {
|
||||
id: "f" as FileId,
|
||||
fileId: "f" as FileId,
|
||||
quickKey: "k",
|
||||
name: "f.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: "f",
|
||||
versionNumber: 1,
|
||||
data: new ArrayBuffer(0),
|
||||
...overrides,
|
||||
} as StoredStirlingFileRecord;
|
||||
}
|
||||
|
||||
describe("legacyDerivedFromTool — IndexedDB backfill for pre-upgrade files", () => {
|
||||
it("flags a legacy versioned edit (has tool history)", () => {
|
||||
expect(
|
||||
legacyDerivedFromTool(
|
||||
record({ toolHistory: [{ toolId: "compress" as any, timestamp: 0 }] }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a legacy file past its first version", () => {
|
||||
expect(legacyDerivedFromTool(record({ versionNumber: 2 }))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a legacy file with a parent", () => {
|
||||
expect(legacyDerivedFromTool(record({ parentFileId: "p" as FileId }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a clean legacy root unflagged — treated as an upload (safe default for enforcement)", () => {
|
||||
// A genuine upload AND a legacy independent artifact (convert/split/merge)
|
||||
// both look like this; old data can't tell them apart, so we enforce.
|
||||
expect(legacyDerivedFromTool(record({}))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,25 @@ export interface StorageStats {
|
||||
quota?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort provenance for records persisted before `derivedFromTool`
|
||||
* existed. A version chain (a tool history, a version past the first, or a
|
||||
* parent) is unambiguously a tool output, so flag it. Legacy independent
|
||||
* artifacts (convert/split/merge) recorded none of that and are
|
||||
* indistinguishable from uploads in old data — they stay unflagged, which for
|
||||
* an enforcement feature is the safe default (enforce rather than silently
|
||||
* skip). New records always carry an explicit flag, so this only fires for
|
||||
* pre-existing files on first read after upgrade.
|
||||
*/
|
||||
export function legacyDerivedFromTool(
|
||||
record: StoredStirlingFileRecord,
|
||||
): boolean | undefined {
|
||||
if ((record.toolHistory?.length ?? 0) > 0) return true;
|
||||
if ((record.versionNumber ?? 1) > 1) return true;
|
||||
if (record.parentFileId != null) return true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class FileStorageService {
|
||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||
private readonly storeName = "files";
|
||||
@@ -124,6 +143,8 @@ class FileStorageService {
|
||||
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
|
||||
parentFileId: stub.parentFileId ?? undefined,
|
||||
toolHistory: stub.toolHistory ?? [],
|
||||
derivedFromTool: stub.derivedFromTool ?? false,
|
||||
sourceFileIds: stub.sourceFileIds,
|
||||
|
||||
// Folder organisation (root when null)
|
||||
folderId: stub.folderId ?? null,
|
||||
@@ -247,6 +268,9 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory,
|
||||
derivedFromTool:
|
||||
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
};
|
||||
@@ -303,6 +327,9 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
derivedFromTool:
|
||||
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
@@ -391,6 +418,9 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
derivedFromTool:
|
||||
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
|
||||
@@ -37,6 +37,30 @@ export interface BaseFileMetadata {
|
||||
parentFileId?: FileId; // Immediate parent file ID
|
||||
toolHistory?: ToolOperation[]; // Tool chain for history tracking
|
||||
|
||||
/**
|
||||
* True if this file was produced by a tool/automation in-app (any
|
||||
* `consumeFiles` output — a versioned edit OR an independent artifact like a
|
||||
* convert/split/merge result) rather than entering the system as a genuine
|
||||
* upload. Set at the consume chokepoint so it covers both kinds, including
|
||||
* independent artifacts whose version metadata is otherwise indistinguishable
|
||||
* from a fresh upload. Persisted so the distinction survives a reload.
|
||||
* Used by input-mode (upload) policy auto-run to enforce only on real uploads.
|
||||
*/
|
||||
derivedFromTool?: boolean;
|
||||
|
||||
/**
|
||||
* Transitive set of fileIds this file was derived from — the inputs of the
|
||||
* tool operation that produced it, plus those inputs' own `sourceFileIds`.
|
||||
* Recorded at the `consumeFiles` boundary, the only place that knows the
|
||||
* input→output mapping. Unlike `parentFileId` (the version chain) this is a
|
||||
* pure provenance link, so it covers independent artifacts — split (1→N),
|
||||
* merge (N→1), convert — that intentionally have no parent. Being transitive,
|
||||
* it survives an intermediate edit being consumed/removed. Persisted; used so
|
||||
* a policy badge follows a document onto everything derived from it. Legacy
|
||||
* files predate it (the link was never recorded) and stay empty.
|
||||
*/
|
||||
sourceFileIds?: FileId[];
|
||||
|
||||
/**
|
||||
* The cloud folder this file lives in. Semantics:
|
||||
* - `remoteStorageId == null` → file is local-only; folderId MUST be null.
|
||||
|
||||
Reference in New Issue
Block a user