On-disk JSON metadata store for the local cache (closes #41) #58
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user