fix(documents): an import of bytes deleted in HealthLog stays deleted under any source key

Keyed uploads and picker imports also check deleted, not yet purged
documents by content and answer deleted, remembering the new key on the
tombstone so the purge carries it into the ledger. A hand upload without
a key still stores a new copy. No content hash of a deleted document is
kept past the purge.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 14:42:13 +02:00
1 parent 933553a7bd
commit c5a63934bd
2 files changed
+96

No files matched your search

+32
View File
@@ -16,6 +16,8 @@
* id names nothing the caller owns;
* - sha256 duplicate detection against live rows, remembering the source key
* for the existing document so a later delete keeps that key deleted too;
* for an import, also against deleted (not yet purged) rows, which answer
* "deleted" rather than storing the file again;
* - the quota gate and the insert in one transaction under a per-user
* advisory lock, and the race on either partial unique index resolved the
* way the fast paths above would have answered;
@@ -216,6 +218,36 @@ export async function ingestDocument(
});
if (existing) return answerDuplicate(input, existing);
// An import (a source key is present) of bytes the person deleted here,
// arriving under a key HealthLog has not seen: another system, another
// instance, or a re-scan with a new id. The deletion was a decision about
// the document, not about one key, so it is answered "deleted", and the
// new key is remembered on the tombstone so the purge carries it into the
// ledger. After the 30-day purge the bytes and their hash are gone and only
// the keys remain; no content hash of a deleted document is kept. A person
// uploading the file by hand (no key) is making a new copy on purpose.
if (sourceSystem && sourceId) {
const tombstone = await prisma.inboundDocument.findFirst({
where: { userId, contentSha256, deletedAt: { not: null } },
select: { id: true },
orderBy: { deletedAt: "desc" },
});
if (tombstone) {
const remembered = await rememberSourceAlias(
userId,
tombstone.id,
sourceSystem,
sourceId,
sourceInstance,
);
if (remembered === "limit") return { kind: "aliasLimit" };
return {
kind: "sourceKey",
match: { state: "deleted", id: tombstone.id },
};
}
}
// Quota gate + insert + pre-links in ONE transaction. Usage counts every
// non-purged row — tombstones still hold TOAST bytes, so "deleted" bytes
// are never invisible weight (undo-delete never changes usage).
@@ -638,6 +638,70 @@ describe("search and import", () => {
});
describe("review follow-ups", () => {
it("keeps a document deleted in HealthLog deleted when the same bytes arrive under another source key", async () => {
await seedOwner();
await connect();
const prisma = getPrismaClient();
const first = await importDoc({ sourceId: "41" });
const id = (await first.json()).data.documentId as string;
await prisma.inboundDocument.update({
where: { id },
data: { deletedAt: new Date() },
});
// The same file, uploaded by another importer under its own key (a
// document token or the script sending from another system).
const form = new FormData();
form.append(
"file",
new Blob([
new Uint8Array(
Buffer.concat([DOCS[0].bytes, Buffer.from(`% ${ORIGIN}\n`)]),
),
]),
"x.pdf",
);
form.append("sourceSystem", "PAPRA");
form.append("sourceId", "doc_same_bytes");
const { POST } = await import("@/app/api/documents/inbound/route");
const res = await (POST as unknown as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/documents/inbound", {
method: "POST",
body: form,
}),
);
expect(res.status).toBe(200);
expect((await res.json()).data).toMatchObject({ deleted: true });
expect(await prisma.inboundDocument.count()).toBe(1);
// The new key is remembered on the deleted document, so the purge
// carries it into the ledger.
expect(
await prisma.documentSourceAlias.count({
where: { documentId: id, sourceId: "doc_same_bytes" },
}),
).toBe(1);
// A person uploading the same file by hand, without a key, is a
// deliberate new copy and is stored.
const manual = new FormData();
manual.append(
"file",
new Blob([
new Uint8Array(
Buffer.concat([DOCS[0].bytes, Buffer.from(`% ${ORIGIN}\n`)]),
),
]),
"x.pdf",
);
const byHand = await (POST as unknown as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/documents/inbound", {
method: "POST",
body: manual,
}),
);
expect(byHand.status).toBe(201);
});
it("never sends the stored token to a new origin", async () => {
await seedOwner();
await connect();