sharp was the only native dependency preventing a single-file binary. Replaced with: - jpeg-js (pure JS) for JPEG decode/resize/encode in thumbnail gen - exif-reader (pure JS) for EXIF tag parsing - Raw JPEG APP1 marker extraction for EXIF segment discovery - Raw XMP packet extraction from file bytes make build-bin produces a ~59MB self-contained Mach-O binary via bun build --compile (bun installed via nix-shell). Zero runtime dependencies. Tested: login, whoami, collections, files all work from the compiled binary. bin/quak.ts: init() called once at program start before commander parses, so libsodium is ready for all commands including those that restore sessions from disk. 118 tests pass.
624 lines
21 KiB
TypeScript
624 lines
21 KiB
TypeScript
/**
|
|
* Tests for `quak backup-metadata <dir>`.
|
|
*
|
|
* This command dumps all decrypted account metadata into a directory
|
|
* tree of plain JSON files, without downloading any file content. It
|
|
* is fast (no multi-megabyte downloads) and produces a complete
|
|
* plaintext record of every collection name, file title, creation
|
|
* date, GPS coordinate, camera model, caption, face label, and any
|
|
* other metadata the Ente clients have attached.
|
|
*
|
|
* Layout:
|
|
*
|
|
* <dir>/
|
|
* account.json { email, userID }
|
|
* collections/
|
|
* <id>-<sanitized-name>/
|
|
* _collection.json { id, name, type, magicMetadata?, ... }
|
|
* <fileID>.json { id, metadata, magicMetadata?, pubMagicMetadata? }
|
|
*
|
|
* The test builds a mock server with two collections, each with files
|
|
* that have different combinations of metadata layers, and verifies
|
|
* the output tree is correct and complete.
|
|
*/
|
|
|
|
import { gzipSync } from "node:zlib";
|
|
import {
|
|
existsSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
} from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import sodium from "libsodium-wrappers-sumo";
|
|
import { SRP, SrpServer } from "fast-srp-hap";
|
|
import { beforeAll, afterAll, describe, expect, it } from "vitest";
|
|
import {
|
|
init,
|
|
toBase64,
|
|
deriveKEK,
|
|
deriveLoginSubkey,
|
|
encryptBlob,
|
|
} from "../../src/crypto/index.js";
|
|
import * as jpegJs from "jpeg-js";
|
|
import { Client } from "../../src/client.js";
|
|
import { runMetadataBackup } from "../../src/metadata-backup.js";
|
|
import type { KeyAttributes } from "../../src/auth/types.js";
|
|
|
|
const TEST_EMAIL = "metabackup@example.com";
|
|
const TEST_PASSWORD = "metapass";
|
|
const TEST_OPS = 2;
|
|
const TEST_MEM = 64 * 1024 * 1024;
|
|
|
|
interface MetaMockState {
|
|
verifier: Buffer;
|
|
srpAttributes: Record<string, unknown>;
|
|
keyAttributes: KeyAttributes;
|
|
encryptedToken: string;
|
|
collections: Record<string, unknown>[];
|
|
filesByCollection: Record<number, Record<string, unknown>[]>;
|
|
// For ML data and EXIF tests
|
|
encryptedMLData: Record<
|
|
number,
|
|
{ encryptedData: string; decryptionHeader: string }
|
|
>;
|
|
fileCiphertexts: Record<number, Uint8Array>;
|
|
}
|
|
|
|
let mock: MetaMockState;
|
|
let testDir: string;
|
|
|
|
const encryptSecretbox = (plaintext: Uint8Array, key: Uint8Array) => {
|
|
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
|
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
|
|
return { ciphertext, nonce };
|
|
};
|
|
|
|
const encryptStreamBlob = (plaintext: Uint8Array, key: Uint8Array) => {
|
|
const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
|
const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
|
|
push.state,
|
|
plaintext,
|
|
null,
|
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
|
);
|
|
return { ciphertext, header: push.header };
|
|
};
|
|
|
|
const buildMetaMock = async (): Promise<MetaMockState> => {
|
|
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
|
const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM);
|
|
const loginSubKeyBytes = deriveLoginSubkey(kek);
|
|
const srpUserID = "meta-srp";
|
|
const srpSalt = sodium.randombytes_buf(16);
|
|
const verifier = SRP.computeVerifier(
|
|
SRP.params["4096"],
|
|
Buffer.from(srpSalt),
|
|
Buffer.from(srpUserID),
|
|
Buffer.from(loginSubKeyBytes),
|
|
);
|
|
|
|
const masterKey = sodium.randombytes_buf(32);
|
|
const { ciphertext: encMK, nonce: mkNonce } = encryptSecretbox(
|
|
masterKey,
|
|
kek,
|
|
);
|
|
const kp = sodium.crypto_box_keypair();
|
|
const { ciphertext: encSK, nonce: skNonce } = encryptSecretbox(
|
|
kp.privateKey,
|
|
masterKey,
|
|
);
|
|
const tokenBytes = sodium.randombytes_buf(32);
|
|
const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
|
|
|
|
const keyAttributes: KeyAttributes = {
|
|
kekSalt: toBase64(kekSalt),
|
|
encryptedKey: toBase64(encMK),
|
|
keyDecryptionNonce: toBase64(mkNonce),
|
|
publicKey: toBase64(kp.publicKey),
|
|
encryptedSecretKey: toBase64(encSK),
|
|
secretKeyDecryptionNonce: toBase64(skNonce),
|
|
memLimit: TEST_MEM,
|
|
opsLimit: TEST_OPS,
|
|
};
|
|
|
|
// Collection 1: "Vacation" with collection-level pubMagicMetadata
|
|
const ck1 = sodium.crypto_secretbox_keygen();
|
|
const { ciphertext: encCK1, nonce: ck1N } = encryptSecretbox(
|
|
ck1,
|
|
masterKey,
|
|
);
|
|
const { ciphertext: encCN1, nonce: cn1N } = encryptSecretbox(
|
|
new TextEncoder().encode("Vacation"),
|
|
ck1,
|
|
);
|
|
const collPubMagic = JSON.stringify({ coverID: 999, sortBy: "date" });
|
|
const { ciphertext: encCollPM, header: collPMHeader } = encryptStreamBlob(
|
|
new TextEncoder().encode(collPubMagic),
|
|
ck1,
|
|
);
|
|
|
|
const rawColl1 = {
|
|
id: 10,
|
|
owner: { id: 42 },
|
|
encryptedKey: toBase64(encCK1),
|
|
keyDecryptionNonce: toBase64(ck1N),
|
|
encryptedName: toBase64(encCN1),
|
|
nameDecryptionNonce: toBase64(cn1N),
|
|
type: "album",
|
|
updationTime: 1700000000000000,
|
|
pubMagicMetadata: {
|
|
version: 1,
|
|
count: 1,
|
|
data: toBase64(encCollPM),
|
|
header: toBase64(collPMHeader),
|
|
},
|
|
};
|
|
|
|
// Collection 2: "Work" with no magic metadata
|
|
const ck2 = sodium.crypto_secretbox_keygen();
|
|
const { ciphertext: encCK2, nonce: ck2N } = encryptSecretbox(
|
|
ck2,
|
|
masterKey,
|
|
);
|
|
const { ciphertext: encCN2, nonce: cn2N } = encryptSecretbox(
|
|
new TextEncoder().encode("Work"),
|
|
ck2,
|
|
);
|
|
const rawColl2 = {
|
|
id: 20,
|
|
owner: { id: 42 },
|
|
encryptedKey: toBase64(encCK2),
|
|
keyDecryptionNonce: toBase64(ck2N),
|
|
encryptedName: toBase64(encCN2),
|
|
nameDecryptionNonce: toBase64(cn2N),
|
|
type: "folder",
|
|
updationTime: 1700000000000000,
|
|
};
|
|
|
|
// File 100 in coll 10: has metadata + pubMagicMetadata
|
|
const fk1 = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const { ciphertext: encFK1, nonce: fk1N } = encryptSecretbox(fk1, ck1);
|
|
const meta1 = JSON.stringify({
|
|
title: "beach.jpg",
|
|
fileType: 0,
|
|
creationTime: 1700000000000000,
|
|
modificationTime: 1700000000000000,
|
|
latitude: 35.6762,
|
|
longitude: 139.6503,
|
|
});
|
|
const { ciphertext: encMeta1, header: meta1Header } = encryptStreamBlob(
|
|
new TextEncoder().encode(meta1),
|
|
fk1,
|
|
);
|
|
const pubMagic1 = JSON.stringify({
|
|
w: 3000,
|
|
h: 2000,
|
|
cameraMake: "SONY",
|
|
cameraModel: "DSC-RX1RM3",
|
|
});
|
|
const { ciphertext: encPM1, header: pm1Header } = encryptStreamBlob(
|
|
new TextEncoder().encode(pubMagic1),
|
|
fk1,
|
|
);
|
|
|
|
const rawFile1 = {
|
|
id: 100,
|
|
collectionID: 10,
|
|
ownerID: 42,
|
|
encryptedKey: toBase64(encFK1),
|
|
keyDecryptionNonce: toBase64(fk1N),
|
|
metadata: {
|
|
encryptedData: toBase64(encMeta1),
|
|
decryptionHeader: toBase64(meta1Header),
|
|
},
|
|
pubMagicMetadata: {
|
|
version: 1,
|
|
count: 1,
|
|
data: toBase64(encPM1),
|
|
header: toBase64(pm1Header),
|
|
},
|
|
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
|
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
|
updationTime: 1700000000000000,
|
|
};
|
|
|
|
// File 200 in coll 20: metadata only, no magic metadata
|
|
const fk2 = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const { ciphertext: encFK2, nonce: fk2N } = encryptSecretbox(fk2, ck2);
|
|
const meta2 = JSON.stringify({
|
|
title: "diagram.png",
|
|
fileType: 0,
|
|
creationTime: 1710000000000000,
|
|
modificationTime: 1710000000000000,
|
|
});
|
|
const { ciphertext: encMeta2, header: meta2Header } = encryptStreamBlob(
|
|
new TextEncoder().encode(meta2),
|
|
fk2,
|
|
);
|
|
|
|
const rawFile2 = {
|
|
id: 200,
|
|
collectionID: 20,
|
|
ownerID: 42,
|
|
encryptedKey: toBase64(encFK2),
|
|
keyDecryptionNonce: toBase64(fk2N),
|
|
metadata: {
|
|
encryptedData: toBase64(encMeta2),
|
|
decryptionHeader: toBase64(meta2Header),
|
|
},
|
|
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
|
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
|
updationTime: 1710000000000000,
|
|
};
|
|
|
|
// Encrypt ML data for file 100 (gzipped JSON, encrypted with file key)
|
|
const mlPayload = JSON.stringify({
|
|
face: {
|
|
version: 1,
|
|
client: "test",
|
|
width: 3000,
|
|
height: 2000,
|
|
faces: [
|
|
{
|
|
faceID: "face-abc",
|
|
detection: {
|
|
box: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
|
|
landmarks: [
|
|
{ x: 0.15, y: 0.25 },
|
|
{ x: 0.25, y: 0.25 },
|
|
],
|
|
},
|
|
score: 0.98,
|
|
blur: 12.5,
|
|
embedding: [0.1, 0.2, 0.3],
|
|
},
|
|
],
|
|
},
|
|
clip: {
|
|
version: 1,
|
|
client: "test",
|
|
embedding: [0.5, 0.6, 0.7],
|
|
},
|
|
});
|
|
const gzipped = gzipSync(Buffer.from(mlPayload));
|
|
const { header: mlHeader, ciphertext: mlCiphertext } = encryptBlob(
|
|
new Uint8Array(gzipped),
|
|
fk1,
|
|
);
|
|
|
|
// Generate a real JPEG for EXIF extraction tests
|
|
const jw = 100;
|
|
const jh = 80;
|
|
const jpixels = new Uint8Array(jw * jh * 4);
|
|
for (let i = 0; i < jpixels.length; i += 4) {
|
|
jpixels[i] = 255;
|
|
jpixels[i + 1] = 0;
|
|
jpixels[i + 2] = 0;
|
|
jpixels[i + 3] = 255;
|
|
}
|
|
const tinyJpeg = jpegJs.encode(
|
|
{ data: jpixels, width: jw, height: jh },
|
|
80,
|
|
).data;
|
|
const filePush1 =
|
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(fk1);
|
|
const encFileBody1 = sodium.crypto_secretstream_xchacha20poly1305_push(
|
|
filePush1.state,
|
|
new Uint8Array(tinyJpeg),
|
|
null,
|
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
|
);
|
|
// Patch rawFile1's file.decryptionHeader to match the push header
|
|
rawFile1.file.decryptionHeader = toBase64(filePush1.header);
|
|
|
|
return {
|
|
verifier,
|
|
srpAttributes: {
|
|
srpUserID,
|
|
srpSalt: toBase64(srpSalt),
|
|
memLimit: TEST_MEM,
|
|
opsLimit: TEST_OPS,
|
|
kekSalt: toBase64(kekSalt),
|
|
isEmailMFAEnabled: false,
|
|
},
|
|
keyAttributes,
|
|
encryptedToken: toBase64(encToken),
|
|
collections: [rawColl1, rawColl2],
|
|
filesByCollection: { 10: [rawFile1], 20: [rawFile2] },
|
|
encryptedMLData: {
|
|
100: {
|
|
encryptedData: toBase64(mlCiphertext),
|
|
decryptionHeader: toBase64(mlHeader),
|
|
},
|
|
},
|
|
fileCiphertexts: { 100: encFileBody1 },
|
|
};
|
|
};
|
|
|
|
const buildMetaFetch = (m: MetaMockState) => {
|
|
let srpServer: SrpServer;
|
|
return (async (
|
|
input: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
): Promise<Response> => {
|
|
const url =
|
|
typeof input === "string"
|
|
? input
|
|
: input instanceof URL
|
|
? input.href
|
|
: input.url;
|
|
const path = new URL(url).pathname;
|
|
const json = (body: unknown) =>
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
|
|
if (path === "/users/srp/attributes")
|
|
return json({ attributes: m.srpAttributes });
|
|
if (path === "/users/srp/create-session") {
|
|
const body = JSON.parse(init?.body as string);
|
|
const serverKey = await SRP.genKey();
|
|
srpServer = new SrpServer(
|
|
SRP.params["4096"],
|
|
m.verifier,
|
|
serverKey,
|
|
);
|
|
const B = srpServer.computeB();
|
|
srpServer.setA(Buffer.from(body.srpA, "base64"));
|
|
return json({ sessionID: "s1", srpB: B.toString("base64") });
|
|
}
|
|
if (path === "/users/srp/verify-session") {
|
|
const body = JSON.parse(init?.body as string);
|
|
srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
|
|
return json({
|
|
srpM2: srpServer.computeM2().toString("base64"),
|
|
id: 42,
|
|
keyAttributes: m.keyAttributes,
|
|
encryptedToken: m.encryptedToken,
|
|
});
|
|
}
|
|
if (path === "/collections/v2")
|
|
return json({ collections: m.collections });
|
|
if (path === "/collections/v2/diff") {
|
|
const collID = Number(
|
|
new URL(url).searchParams.get("collectionID"),
|
|
);
|
|
return json({
|
|
diff: m.filesByCollection[collID] ?? [],
|
|
hasMore: false,
|
|
});
|
|
}
|
|
if (path === "/files/data/fetch") {
|
|
const body = JSON.parse(init?.body as string);
|
|
const data = (body.fileIDs as number[])
|
|
.filter((id: number) => m.encryptedMLData[id])
|
|
.map((id: number) => ({
|
|
fileID: id,
|
|
...m.encryptedMLData[id],
|
|
updatedAt: 1700000000000000,
|
|
}));
|
|
return json({ data });
|
|
}
|
|
if (
|
|
url.includes("files.ente.io") ||
|
|
path.startsWith("/files/download/")
|
|
) {
|
|
const parsed = new URL(url);
|
|
const fileID = Number(
|
|
parsed.searchParams.get("fileID") ?? path.split("/").pop(),
|
|
);
|
|
const ct = m.fileCiphertexts[fileID];
|
|
if (ct) return new Response(ct, { status: 200 });
|
|
return new Response("not found", { status: 404 });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
}) as typeof globalThis.fetch;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
await init();
|
|
await sodium.ready;
|
|
mock = await buildMetaMock();
|
|
testDir = mkdtempSync(join(tmpdir(), "quak-meta-backup-test-"));
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (testDir && existsSync(testDir))
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("quak backup-metadata", () => {
|
|
it("writes account.json with email and userID", async () => {
|
|
const outDir = join(testDir, "full");
|
|
const client = await Client.login({
|
|
email: TEST_EMAIL,
|
|
password: TEST_PASSWORD,
|
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
|
});
|
|
|
|
await runMetadataBackup(client, outDir);
|
|
|
|
const account = JSON.parse(
|
|
readFileSync(join(outDir, "account.json"), "utf-8"),
|
|
);
|
|
expect(account.email).toBe(TEST_EMAIL);
|
|
expect(account.userID).toBe(42);
|
|
});
|
|
|
|
it("creates per-collection directories with _collection.json", async () => {
|
|
const outDir = join(testDir, "collections");
|
|
const client = await Client.login({
|
|
email: TEST_EMAIL,
|
|
password: TEST_PASSWORD,
|
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
|
});
|
|
|
|
await runMetadataBackup(client, outDir);
|
|
|
|
const collDirs = readdirSync(join(outDir, "collections"));
|
|
expect(collDirs.length).toBe(2);
|
|
|
|
// Find the Vacation collection dir (prefixed with ID)
|
|
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
|
expect(vacDir).toBeDefined();
|
|
const collMeta = JSON.parse(
|
|
readFileSync(
|
|
join(outDir, "collections", vacDir, "_collection.json"),
|
|
"utf-8",
|
|
),
|
|
);
|
|
expect(collMeta.id).toBe(10);
|
|
expect(collMeta.name).toBe("Vacation");
|
|
expect(collMeta.type).toBe("album");
|
|
});
|
|
|
|
it("decrypts collection-level pubMagicMetadata", async () => {
|
|
const outDir = join(testDir, "coll-magic");
|
|
const client = await Client.login({
|
|
email: TEST_EMAIL,
|
|
password: TEST_PASSWORD,
|
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
|
});
|
|
|
|
await runMetadataBackup(client, outDir);
|
|
|
|
const collDirs = readdirSync(join(outDir, "collections"));
|
|
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
|
const collMeta = JSON.parse(
|
|
readFileSync(
|
|
join(outDir, "collections", vacDir, "_collection.json"),
|
|
"utf-8",
|
|
),
|
|
);
|
|
expect(collMeta.pubMagicMetadata).toBeDefined();
|
|
expect(collMeta.pubMagicMetadata.coverID).toBe(999);
|
|
expect(collMeta.pubMagicMetadata.sortBy).toBe("date");
|
|
});
|
|
|
|
it("writes per-file JSON with all three metadata layers", async () => {
|
|
const outDir = join(testDir, "file-meta");
|
|
const client = await Client.login({
|
|
email: TEST_EMAIL,
|
|
password: TEST_PASSWORD,
|
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
|
});
|
|
|
|
await runMetadataBackup(client, outDir);
|
|
|
|
const collDirs = readdirSync(join(outDir, "collections"));
|
|
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
|
const fileMeta = JSON.parse(
|
|
readFileSync(
|
|
join(outDir, "collections", vacDir, "100.json"),
|
|
"utf-8",
|
|
),
|
|
);
|
|
expect(fileMeta.id).toBe(100);
|
|
expect(fileMeta.metadata.title).toBe("beach.jpg");
|
|
expect(fileMeta.metadata.latitude).toBeCloseTo(35.6762);
|
|
expect(fileMeta.pubMagicMetadata.cameraMake).toBe("SONY");
|
|
expect(fileMeta.pubMagicMetadata.w).toBe(3000);
|
|
});
|
|
|
|
it("handles files with no magic metadata gracefully", async () => {
|
|
const outDir = join(testDir, "no-magic");
|
|
const client = await Client.login({
|
|
email: TEST_EMAIL,
|
|
password: TEST_PASSWORD,
|
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
|
});
|
|
|
|
await runMetadataBackup(client, outDir);
|
|
|
|
const collDirs = readdirSync(join(outDir, "collections"));
|
|
const workDir = collDirs.find((d) => d.includes("Work"))!;
|
|
const fileMeta = JSON.parse(
|
|
readFileSync(
|
|
join(outDir, "collections", workDir, "200.json"),
|
|
"utf-8",
|
|
),
|
|
);
|
|
expect(fileMeta.id).toBe(200);
|
|
expect(fileMeta.metadata.title).toBe("diagram.png");
|
|
expect(fileMeta.pubMagicMetadata).toBeUndefined();
|
|
expect(fileMeta.magicMetadata).toBeUndefined();
|
|
});
|
|
|
|
it("is incremental: second run does not fail", async () => {
|
|
const outDir = join(testDir, "incremental");
|
|
const client = await Client.login({
|
|
email: TEST_EMAIL,
|
|
password: TEST_PASSWORD,
|
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
|
});
|
|
|
|
await runMetadataBackup(client, outDir);
|
|
await runMetadataBackup(client, outDir);
|
|
|
|
const account = JSON.parse(
|
|
readFileSync(join(outDir, "account.json"), "utf-8"),
|
|
);
|
|
expect(account.email).toBe(TEST_EMAIL);
|
|
});
|
|
|
|
it("fetches and decrypts ML data by default", async () => {
|
|
const outDir = join(testDir, "ml-data");
|
|
const client = await Client.login({
|
|
email: TEST_EMAIL,
|
|
password: TEST_PASSWORD,
|
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
|
});
|
|
|
|
await runMetadataBackup(client, outDir);
|
|
|
|
const collDirs = readdirSync(join(outDir, "collections"));
|
|
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
|
const fileMeta = JSON.parse(
|
|
readFileSync(
|
|
join(outDir, "collections", vacDir, "100.json"),
|
|
"utf-8",
|
|
),
|
|
);
|
|
|
|
// ML data should be present and decrypted
|
|
expect(fileMeta.mlData).toBeDefined();
|
|
expect(fileMeta.mlData.face).toBeDefined();
|
|
expect(fileMeta.mlData.face.faces.length).toBe(1);
|
|
expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc");
|
|
expect(fileMeta.mlData.face.faces[0].score).toBeCloseTo(0.98);
|
|
expect(fileMeta.mlData.face.faces[0].detection.box.x).toBeCloseTo(0.1);
|
|
expect(fileMeta.mlData.clip).toBeDefined();
|
|
expect(fileMeta.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
|
|
});
|
|
|
|
it("extracts EXIF from downloaded files when --exif is set", async () => {
|
|
const outDir = join(testDir, "exif-data");
|
|
const client = await Client.login({
|
|
email: TEST_EMAIL,
|
|
password: TEST_PASSWORD,
|
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
|
});
|
|
|
|
await runMetadataBackup(client, outDir, { exif: true });
|
|
|
|
const collDirs = readdirSync(join(outDir, "collections"));
|
|
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
|
const fileMeta = JSON.parse(
|
|
readFileSync(
|
|
join(outDir, "collections", vacDir, "100.json"),
|
|
"utf-8",
|
|
),
|
|
);
|
|
|
|
// imageMetadata from JPEG parsing should be present
|
|
expect(fileMeta.imageMetadata).toBeDefined();
|
|
expect(fileMeta.imageMetadata.format).toBe("jpeg");
|
|
expect(fileMeta.imageMetadata.width).toBe(100);
|
|
expect(fileMeta.imageMetadata.height).toBe(80);
|
|
});
|
|
});
|