From 4f2a3864b5c531c26ed05d15047bd6e093c3c90f Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 22 Sep 2026 10:18:46 +0000 Subject: [PATCH] test(library/store): metadata store round-trip, empty-on-corrupt, perms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing first: the on-disk JSON metadata store does not exist yet. These tests fix its contract before the implementation lands — round-trip fidelity including the binary keys, missing/corrupt files loading empty, a schema-version mismatch discarded as stale cache, atomic writes leaving no temp sibling, and directory 0700 / file 0600 permissions. Model: opus-4-8 --- test/library/store.test.ts | 246 +++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 test/library/store.test.ts diff --git a/test/library/store.test.ts b/test/library/store.test.ts new file mode 100644 index 0000000..21212b3 --- /dev/null +++ b/test/library/store.test.ts @@ -0,0 +1,246 @@ +/** + * Tests for the on-disk JSON metadata store (`MetadataStore`). + * + * The store is the local cache the library keeps of the account's server + * state: one `metadata.json` file holding the user id, a schema version, the + * cursor for the incremental collections listing, and the decrypted + * collection and file records. The whole file is read into RAM on load and + * rewritten as a whole on save. A separate refresh unit (issue #42) is what + * populates it; this unit only stores. + * + * Four contracts are load-bearing and each is exercised below: + * + * 1. **Round-trip fidelity.** Everything put into the store — including the + * binary decryption keys, which JSON cannot hold directly and which the + * store base64-encodes — comes back byte-for-byte after a save and a fresh + * load. A cache that quietly dropped or mangled a field would hand the + * caller wrong keys or stale metadata. + * + * 2. **A missing or corrupt file loads as an empty store, never an error.** + * The file is only a cache: if it is absent (first run) or unreadable + * (interrupted write on an older build, disk corruption, hand-editing), + * the right answer is to start empty and let the refresh unit repopulate, + * not to crash the whole library. + * + * 3. **Writes are atomic and durable.** The store reuses the same + * fsync-before-rename atomic writer the download layer uses, so a reader + * never sees a half-written file and a crash cannot leave a truncated one. + * The observable consequence tested here is that a save leaves exactly the + * destination file behind — no temporary sibling — and that overwriting an + * existing store preserves a complete, re-loadable file. + * + * 4. **Permissions match `session.json`.** The directory is `0700` and the + * file is `0600`, because the records contain decrypted key material and + * must not be readable by other users on a shared machine. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + mkdtempSync, + rmSync, + readdirSync, + statSync, + writeFileSync, + mkdirSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + MetadataStore, + METADATA_SCHEMA_VERSION, +} from "../../src/library/store.js"; +import type { Collection, EnteFile } from "../../src/model/types.js"; + +// A representative decrypted collection, including a binary key and all three +// magic-metadata layers, so the round-trip test proves every field survives. +const sampleCollection = (): Collection => ({ + id: 12345, + ownerID: 42, + key: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + name: "Holiday 2026", + type: "album", + updationTime: 1_700_000_000_000_000, + isShared: true, + magicMetadata: { visibility: 0 }, + pubMagicMetadata: { subType: 0, coverID: 999 }, + sharedMagicMetadata: { note: "shared with a friend" }, +}); + +// A representative decrypted file membership: metadata, both blob headers, a +// binary key, a content hash, and file/thumbnail sizes. +const sampleFile = (): EnteFile => ({ + id: 67890, + collectionID: 12345, + ownerID: 42, + key: new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1]), + metadata: { + title: "IMG_0001.jpg", + fileType: "image", + creationTime: 1_699_000_000_000_000, + modificationTime: 1_699_000_500_000_000, + latitude: 52.52, + longitude: 13.405, + hash: "sha256:deadbeef", + }, + magicMetadata: { editedName: "sunset" }, + pubMagicMetadata: { editedTime: 1_699_000_600_000_000 }, + file: { decryptionHeader: "ZmlsZUhlYWRlcg==", size: 4_194_304 }, + thumbnail: { decryptionHeader: "dGh1bWJIZWFkZXI=", size: 8192 }, + updationTime: 1_700_000_100_000_000, +}); + +describe("MetadataStore", () => { + let dir: string; + let path: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "quak-store-")); + // Deliberately nest the store one level below the temp dir so save() + // has to create its own directory and set its mode. + path = join(dir, "cache", "metadata.json"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("round-trips the whole model, keys and all", async () => { + const store = await MetadataStore.load(path); + store.userID = 42; + store.collectionsSinceTime = 1_700_000_000_000_000; + store.putCollection(sampleCollection()); + store.putFile(sampleFile()); + await store.save(); + + const reloaded = await MetadataStore.load(path); + expect(reloaded.userID).toBe(42); + expect(reloaded.collectionsSinceTime).toBe(1_700_000_000_000_000); + + // The binary key must come back as the exact bytes, not a base64 + // string or a plain object of numbered keys. + const collection = reloaded.getCollection(12345); + expect(collection).toEqual(sampleCollection()); + expect(collection?.key).toBeInstanceOf(Uint8Array); + + const file = reloaded.getFile(12345, 67890); + expect(file).toEqual(sampleFile()); + expect(file?.key).toBeInstanceOf(Uint8Array); + + expect(reloaded.listCollections()).toHaveLength(1); + expect(reloaded.listFiles(12345)).toHaveLength(1); + }); + + it("writes the declared schema version", async () => { + const store = await MetadataStore.load(path); + await store.save(); + const reloaded = await MetadataStore.load(path); + expect(reloaded.schemaVersion).toBe(METADATA_SCHEMA_VERSION); + }); + + it("loads an empty store when the file is missing", async () => { + const store = await MetadataStore.load(path); + expect(store.userID).toBe(0); + expect(store.listCollections()).toEqual([]); + expect(store.getCollection(1)).toBeUndefined(); + }); + + it("loads an empty store when the file is corrupt", async () => { + mkdirSync(join(dir, "cache"), { recursive: true }); + writeFileSync(path, "{ this is not valid json ]["); + const store = await MetadataStore.load(path); + expect(store.listCollections()).toEqual([]); + expect(store.listFiles(12345)).toEqual([]); + }); + + it("loads an empty store when the schema version does not match", async () => { + // A cache written by a future build with an incompatible schema is + // discarded rather than misread; the refresh unit repopulates it. + mkdirSync(join(dir, "cache"), { recursive: true }); + writeFileSync( + path, + JSON.stringify({ + schemaVersion: METADATA_SCHEMA_VERSION + 1, + userID: 42, + collectionsSinceTime: 0, + collections: [], + files: [], + }), + ); + const store = await MetadataStore.load(path); + expect(store.userID).toBe(0); + expect(store.listCollections()).toEqual([]); + }); + + it("creates the directory 0700 and the file 0600", async () => { + const store = await MetadataStore.load(path); + store.putCollection(sampleCollection()); + await store.save(); + + // Directory 0700, file 0600: on a shared machine the decrypted keys + // in this file must be readable only by their owner. Mask to the + // permission bits; the file-type bits are not part of the assertion. + expect(statSync(join(dir, "cache")).mode & 0o777).toBe(0o700); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it("leaves exactly the destination behind, with no temp sibling", async () => { + const store = await MetadataStore.load(path); + store.putCollection(sampleCollection()); + await store.save(); + // The atomic writer stages a temporary file and renames it into + // place; on success nothing temporary is left in the directory. + expect(readdirSync(join(dir, "cache"))).toEqual(["metadata.json"]); + }); + + it("overwrites an existing store atomically and stays re-loadable", async () => { + const first = await MetadataStore.load(path); + first.userID = 1; + first.putCollection(sampleCollection()); + await first.save(); + + const second = await MetadataStore.load(path); + second.userID = 2; + second.deleteCollection(12345); + await second.save(); + + const reloaded = await MetadataStore.load(path); + expect(reloaded.userID).toBe(2); + expect(reloaded.getCollection(12345)).toBeUndefined(); + expect(readdirSync(join(dir, "cache"))).toEqual(["metadata.json"]); + }); + + it("deletes a collection together with its file memberships", async () => { + const store = await MetadataStore.load(path); + store.putCollection(sampleCollection()); + store.putFile(sampleFile()); + store.deleteCollection(12345); + expect(store.getCollection(12345)).toBeUndefined(); + expect(store.getFile(12345, 67890)).toBeUndefined(); + expect(store.listFiles(12345)).toEqual([]); + }); + + it("scopes file records to their collection membership", async () => { + // The same underlying file can be a member of two collections, each a + // separate record with its own key. Storing one must not touch the + // other, and lookups are per membership. + const store = await MetadataStore.load(path); + const inA = sampleFile(); + const inB: EnteFile = { + ...sampleFile(), + collectionID: 55555, + key: new Uint8Array([100, 101, 102]), + }; + store.putFile(inA); + store.putFile(inB); + + expect(store.getFile(12345, 67890)?.key).toEqual(inA.key); + expect(store.getFile(55555, 67890)?.key).toEqual(inB.key); + expect(store.listFiles(12345)).toHaveLength(1); + expect(store.listFiles(55555)).toHaveLength(1); + + store.deleteFile(12345, 67890); + expect(store.getFile(12345, 67890)).toBeUndefined(); + expect(store.getFile(55555, 67890)?.key).toEqual(inB.key); + }); +});