/** * 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); }); });