Client class red: literate usage tests and stub
test/client/usage.test.ts is a tutorial-as-test-suite that walks through the entire quack API in order: login, whoami, listCollections, listFiles, downloadFile, downloadThumbnail, toJSON/fromJSON, logout. Each it() block is a self-contained example with prose commentary explaining what the code does and why, with code samples showing the API as a consumer would use it. The mock server performs real SRP and crypto so the test data is structurally identical to production. 8 tests, all failing against the stub.
This commit is contained in:
72
src/client.ts
Normal file
72
src/client.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Stub: see the README "Development workflow" section for TDD policy.
|
||||
|
||||
import type { ApiClientOptions } from "./api/client.js";
|
||||
import type { Collection, EnteFile } from "./model/types.js";
|
||||
import type { DownloadResult } from "./download/index.js";
|
||||
|
||||
export interface LoginOptions {
|
||||
email: string;
|
||||
password: string;
|
||||
totp?: () => Promise<string>;
|
||||
emailOTP?: () => Promise<string>;
|
||||
apiOptions?: ApiClientOptions;
|
||||
}
|
||||
|
||||
export interface ClientSnapshot {
|
||||
email: string;
|
||||
userID: number;
|
||||
token: string;
|
||||
masterKey: string;
|
||||
secretKey: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export class Client {
|
||||
static async login(_opts: LoginOptions): Promise<Client> {
|
||||
throw new Error("Client.login not implemented");
|
||||
}
|
||||
|
||||
static fromJSON(
|
||||
_snapshot: ClientSnapshot,
|
||||
_apiOptions?: ApiClientOptions,
|
||||
): Client {
|
||||
throw new Error("Client.fromJSON not implemented");
|
||||
}
|
||||
|
||||
whoami(): { email: string; userID: number } {
|
||||
throw new Error("Client.whoami not implemented");
|
||||
}
|
||||
|
||||
toJSON(): ClientSnapshot {
|
||||
throw new Error("Client.toJSON not implemented");
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
throw new Error("Client.logout not implemented");
|
||||
}
|
||||
|
||||
async listCollections(): Promise<Collection[]> {
|
||||
throw new Error("Client.listCollections not implemented");
|
||||
}
|
||||
|
||||
async listFiles(
|
||||
_collectionID: number,
|
||||
_collectionKey: Uint8Array,
|
||||
): Promise<EnteFile[]> {
|
||||
throw new Error("Client.listFiles not implemented");
|
||||
}
|
||||
|
||||
async downloadFile(
|
||||
_file: EnteFile,
|
||||
_outPath?: string,
|
||||
): Promise<DownloadResult> {
|
||||
throw new Error("Client.downloadFile not implemented");
|
||||
}
|
||||
|
||||
async downloadThumbnail(
|
||||
_file: EnteFile,
|
||||
_outPath?: string,
|
||||
): Promise<DownloadResult> {
|
||||
throw new Error("Client.downloadThumbnail not implemented");
|
||||
}
|
||||
}
|
||||
620
test/client/usage.test.ts
Normal file
620
test/client/usage.test.ts
Normal file
@@ -0,0 +1,620 @@
|
||||
/**
|
||||
* # Using the quack Client
|
||||
*
|
||||
* This test file is a tutorial. It walks through every operation the
|
||||
* library supports, in the order you would use them in a real program.
|
||||
* Each `it()` block is a self-contained example with commentary
|
||||
* explaining what is happening and why. If you are reading the quack
|
||||
* source for the first time, start here.
|
||||
*
|
||||
* The tests run against a mock Ente server built from the same SRP and
|
||||
* crypto primitives the real server uses, so the data flowing through
|
||||
* the Client is structurally identical to production data. Nothing hits
|
||||
* the network.
|
||||
*
|
||||
*
|
||||
* ## Table of contents
|
||||
*
|
||||
* 1. Logging in
|
||||
* 2. Inspecting account info
|
||||
* 3. Listing collections (albums)
|
||||
* 4. Listing files in a collection
|
||||
* 5. Downloading a file
|
||||
* 6. Downloading a thumbnail
|
||||
* 7. Serializing and restoring a session
|
||||
* 8. Logging out
|
||||
*
|
||||
*
|
||||
* ## Prerequisites
|
||||
*
|
||||
* All you need is the `Client` class:
|
||||
*
|
||||
* ```ts
|
||||
* import { Client } from "quack";
|
||||
* ```
|
||||
*
|
||||
* The Client wraps every lower-level module (crypto, auth, api, model,
|
||||
* download) into a single object. You create one by logging in, and
|
||||
* then call methods on it for the lifetime of your program. The session
|
||||
* (auth token, master key, secret key) lives in memory on the Client
|
||||
* instance. If you want to persist it across process restarts, call
|
||||
* `client.toJSON()` to get a serializable snapshot, store it however
|
||||
* you like, and later restore it with `Client.fromJSON(snapshot)`.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync, existsSync } 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,
|
||||
} from "../../src/crypto/index.js";
|
||||
import { Client } from "../../src/client.js";
|
||||
import type { KeyAttributes } from "../../src/auth/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock server
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Everything below this line builds a fake Ente backend that the Client
|
||||
// talks to via an injected `fetch`. You would not write any of this in a
|
||||
// real program; it exists only so the tests are self-contained.
|
||||
|
||||
const TEST_EMAIL = "user@example.com";
|
||||
const TEST_PASSWORD = "hunter2";
|
||||
const TEST_OPS = 2;
|
||||
const TEST_MEM = 64 * 1024 * 1024;
|
||||
|
||||
interface ServerState {
|
||||
srpAttributes: {
|
||||
srpUserID: string;
|
||||
srpSalt: string;
|
||||
memLimit: number;
|
||||
opsLimit: number;
|
||||
kekSalt: string;
|
||||
isEmailMFAEnabled: boolean;
|
||||
};
|
||||
verifier: Buffer;
|
||||
keyAttributes: KeyAttributes;
|
||||
encryptedToken: string;
|
||||
masterKey: Uint8Array;
|
||||
photoPlaintext: Uint8Array;
|
||||
photoKey: Uint8Array;
|
||||
photoHeader: Uint8Array;
|
||||
photoCiphertext: Uint8Array;
|
||||
thumbPlaintext: Uint8Array;
|
||||
thumbHeader: Uint8Array;
|
||||
thumbCiphertext: Uint8Array;
|
||||
collectionKey: Uint8Array;
|
||||
}
|
||||
|
||||
let server: ServerState;
|
||||
let testDir: string;
|
||||
|
||||
const buildServer = async (): Promise<ServerState> => {
|
||||
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 = "mock-srp-user";
|
||||
const srpSalt = sodium.randombytes_buf(16);
|
||||
const verifier = SRP.computeVerifier(
|
||||
SRP.params["4096"],
|
||||
Buffer.from(srpSalt),
|
||||
Buffer.from(srpUserID),
|
||||
Buffer.from(loginSubKeyBytes),
|
||||
);
|
||||
|
||||
// Master key, keypair, token
|
||||
const masterKey = sodium.randombytes_buf(32);
|
||||
const keyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const encryptedKey = sodium.crypto_secretbox_easy(masterKey, keyNonce, kek);
|
||||
const kp = sodium.crypto_box_keypair();
|
||||
const skNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const encryptedSecretKey = sodium.crypto_secretbox_easy(
|
||||
kp.privateKey,
|
||||
skNonce,
|
||||
masterKey,
|
||||
);
|
||||
const tokenBytes = sodium.randombytes_buf(32);
|
||||
const encryptedToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
|
||||
|
||||
const keyAttributes: KeyAttributes = {
|
||||
kekSalt: toBase64(kekSalt),
|
||||
encryptedKey: toBase64(encryptedKey),
|
||||
keyDecryptionNonce: toBase64(keyNonce),
|
||||
publicKey: toBase64(kp.publicKey),
|
||||
encryptedSecretKey: toBase64(encryptedSecretKey),
|
||||
secretKeyDecryptionNonce: toBase64(skNonce),
|
||||
memLimit: TEST_MEM,
|
||||
opsLimit: TEST_OPS,
|
||||
};
|
||||
|
||||
// A collection with one photo
|
||||
const collectionKey = sodium.crypto_secretbox_keygen();
|
||||
const ckNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const encCollKey = sodium.crypto_secretbox_easy(
|
||||
collectionKey,
|
||||
ckNonce,
|
||||
masterKey,
|
||||
);
|
||||
const collName = new TextEncoder().encode("Vacation");
|
||||
const cnNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const encCollName = sodium.crypto_secretbox_easy(
|
||||
collName,
|
||||
cnNonce,
|
||||
collectionKey,
|
||||
);
|
||||
|
||||
// A file inside that collection
|
||||
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const fkNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const encFileKey = sodium.crypto_secretbox_easy(
|
||||
photoKey,
|
||||
fkNonce,
|
||||
collectionKey,
|
||||
);
|
||||
|
||||
const metadata = JSON.stringify({
|
||||
title: "beach.jpg",
|
||||
fileType: 0,
|
||||
creationTime: 1700000000000000,
|
||||
modificationTime: 1700000000000000,
|
||||
latitude: 35.6762,
|
||||
longitude: 139.6503,
|
||||
});
|
||||
const metaPush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey);
|
||||
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||
metaPush.state,
|
||||
new TextEncoder().encode(metadata),
|
||||
null,
|
||||
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
||||
);
|
||||
|
||||
// Encrypted file body (a small fake JPEG)
|
||||
const photoPlaintext = sodium.randombytes_buf(5000);
|
||||
const filePush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey);
|
||||
const photoCiphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||
filePush.state,
|
||||
photoPlaintext,
|
||||
null,
|
||||
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
||||
);
|
||||
|
||||
// Encrypted thumbnail
|
||||
const thumbPlaintext = sodium.randombytes_buf(800);
|
||||
const thumbPush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey);
|
||||
const thumbCiphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||
thumbPush.state,
|
||||
thumbPlaintext,
|
||||
null,
|
||||
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
||||
);
|
||||
|
||||
// Wire up the mock fetch responses
|
||||
const rawCollection = {
|
||||
id: 1,
|
||||
owner: { id: 42 },
|
||||
encryptedKey: toBase64(encCollKey),
|
||||
keyDecryptionNonce: toBase64(ckNonce),
|
||||
encryptedName: toBase64(encCollName),
|
||||
nameDecryptionNonce: toBase64(cnNonce),
|
||||
type: "album",
|
||||
updationTime: 1700000000000000,
|
||||
};
|
||||
|
||||
const rawFile = {
|
||||
id: 100,
|
||||
collectionID: 1,
|
||||
ownerID: 42,
|
||||
encryptedKey: toBase64(encFileKey),
|
||||
keyDecryptionNonce: toBase64(fkNonce),
|
||||
metadata: {
|
||||
encryptedData: toBase64(encMeta),
|
||||
decryptionHeader: toBase64(metaPush.header),
|
||||
},
|
||||
file: { decryptionHeader: toBase64(filePush.header) },
|
||||
thumbnail: { decryptionHeader: toBase64(thumbPush.header) },
|
||||
updationTime: 1700000000000000,
|
||||
};
|
||||
|
||||
// Store the raw JSON responses on the server state so the mock fetch
|
||||
// can return them. This is ugly plumbing; in a real program you
|
||||
// never see any of it.
|
||||
(globalThis as Record<string, unknown>).__mockRawCollection = rawCollection;
|
||||
(globalThis as Record<string, unknown>).__mockRawFile = rawFile;
|
||||
|
||||
return {
|
||||
srpAttributes: {
|
||||
srpUserID,
|
||||
srpSalt: toBase64(srpSalt),
|
||||
memLimit: TEST_MEM,
|
||||
opsLimit: TEST_OPS,
|
||||
kekSalt: toBase64(kekSalt),
|
||||
isEmailMFAEnabled: false,
|
||||
},
|
||||
verifier,
|
||||
keyAttributes,
|
||||
encryptedToken: toBase64(encryptedToken),
|
||||
masterKey,
|
||||
photoPlaintext,
|
||||
photoKey,
|
||||
photoHeader: filePush.header,
|
||||
photoCiphertext,
|
||||
thumbPlaintext,
|
||||
thumbHeader: thumbPush.header,
|
||||
thumbCiphertext,
|
||||
collectionKey,
|
||||
};
|
||||
};
|
||||
|
||||
const buildMockFetch = (s: ServerState) => {
|
||||
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: s.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"],
|
||||
s.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"));
|
||||
const M2 = srpServer.computeM2();
|
||||
return json({
|
||||
srpM2: M2.toString("base64"),
|
||||
id: 42,
|
||||
keyAttributes: s.keyAttributes,
|
||||
encryptedToken: s.encryptedToken,
|
||||
});
|
||||
}
|
||||
if (path === "/collections/v2") {
|
||||
const raw = (globalThis as Record<string, unknown>)
|
||||
.__mockRawCollection;
|
||||
return json({ collections: [raw] });
|
||||
}
|
||||
if (path === "/collections/v2/diff") {
|
||||
const raw = (globalThis as Record<string, unknown>).__mockRawFile;
|
||||
return json({ diff: [raw], hasMore: false });
|
||||
}
|
||||
if (url.includes("files.ente.io") || path === "/files/download/100") {
|
||||
return new Response(s.photoCiphertext, { status: 200 });
|
||||
}
|
||||
if (
|
||||
url.includes("thumbnails.ente.io") ||
|
||||
path === "/files/preview/100"
|
||||
) {
|
||||
return new Response(s.thumbCiphertext, { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof globalThis.fetch;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
server = await buildServer();
|
||||
testDir = mkdtempSync(join(tmpdir(), "quack-usage-test-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (testDir && existsSync(testDir))
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The tutorial
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("quack Client usage guide", () => {
|
||||
/**
|
||||
* ## 1. Logging in
|
||||
*
|
||||
* Create a Client by calling `Client.login()`. This performs the
|
||||
* entire authentication handshake (SRP key exchange, key derivation,
|
||||
* key unwrapping) and returns a ready-to-use Client. The password
|
||||
* never leaves the process.
|
||||
*
|
||||
* If the account requires a second factor, `login()` accepts
|
||||
* optional callbacks for TOTP and email OTP. If neither is provided
|
||||
* and the server demands 2FA, `login()` throws.
|
||||
*
|
||||
* ```ts
|
||||
* const client = await Client.login({
|
||||
* email: "you@example.com",
|
||||
* password: "your-password",
|
||||
* // Optional: supply these if the account has 2FA enabled.
|
||||
* // totp: async () => prompt("Enter TOTP code: "),
|
||||
* // emailOTP: async () => prompt("Enter email code: "),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
it("1. log in with email and password", async () => {
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(server) },
|
||||
});
|
||||
|
||||
// The client is now authenticated. All subsequent calls use the
|
||||
// auth token internally.
|
||||
expect(client).toBeInstanceOf(Client);
|
||||
});
|
||||
|
||||
/**
|
||||
* ## 2. Inspecting account info
|
||||
*
|
||||
* After login, the client knows the user's email and numeric ID.
|
||||
* The master key and secret key are held in memory but not directly
|
||||
* exposed; they are used internally by collection/file decryption.
|
||||
*
|
||||
* ```ts
|
||||
* const { email, userID } = client.whoami();
|
||||
* console.log(`Logged in as ${email} (id ${userID})`);
|
||||
* ```
|
||||
*/
|
||||
it("2. inspect account info with whoami()", async () => {
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(server) },
|
||||
});
|
||||
|
||||
const info = client.whoami();
|
||||
|
||||
expect(info.email).toBe(TEST_EMAIL);
|
||||
expect(info.userID).toBe(42);
|
||||
});
|
||||
|
||||
/**
|
||||
* ## 3. Listing collections
|
||||
*
|
||||
* Collections are Ente's name for albums. Each collection has a
|
||||
* name, a type (album, folder, favorites, uncategorized), and an
|
||||
* encryption key. `listCollections()` fetches them from the server,
|
||||
* decrypts the keys and names, and returns typed objects.
|
||||
*
|
||||
* ```ts
|
||||
* const collections = await client.listCollections();
|
||||
* for (const c of collections) {
|
||||
* console.log(`${c.name} [${c.type}] — ${c.id}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
it("3. list and decrypt collections", async () => {
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(server) },
|
||||
});
|
||||
|
||||
const collections = await client.listCollections();
|
||||
|
||||
expect(collections.length).toBe(1);
|
||||
expect(collections[0]!.name).toBe("Vacation");
|
||||
expect(collections[0]!.type).toBe("album");
|
||||
expect(collections[0]!.id).toBe(1);
|
||||
// The decrypted collection key is available for advanced use.
|
||||
expect(collections[0]!.key.length).toBe(32);
|
||||
});
|
||||
|
||||
/**
|
||||
* ## 4. Listing files in a collection
|
||||
*
|
||||
* Each collection contains files (photos, videos, live photos).
|
||||
* `listFiles()` fetches the file list, decrypts each file's key
|
||||
* and metadata (title, type, creation time, GPS coordinates), and
|
||||
* returns typed objects.
|
||||
*
|
||||
* ```ts
|
||||
* const files = await client.listFiles(collection.id, collection.key);
|
||||
* for (const f of files) {
|
||||
* console.log(`${f.metadata.title} [${f.metadata.fileType}]`);
|
||||
* if (f.metadata.latitude) {
|
||||
* console.log(` at ${f.metadata.latitude}, ${f.metadata.longitude}`);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
it("4. list and decrypt files", async () => {
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(server) },
|
||||
});
|
||||
const collections = await client.listCollections();
|
||||
const col = collections[0]!;
|
||||
|
||||
const files = await client.listFiles(col.id, col.key);
|
||||
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0]!.metadata.title).toBe("beach.jpg");
|
||||
expect(files[0]!.metadata.fileType).toBe("image");
|
||||
expect(files[0]!.metadata.latitude).toBeCloseTo(35.6762);
|
||||
expect(files[0]!.metadata.longitude).toBeCloseTo(139.6503);
|
||||
});
|
||||
|
||||
/**
|
||||
* ## 5. Downloading a file
|
||||
*
|
||||
* `downloadFile()` fetches the encrypted file body from the CDN,
|
||||
* decrypts it chunk by chunk using the file's secretstream key and
|
||||
* header, and writes the plaintext to disk.
|
||||
*
|
||||
* If you omit `outPath`, the file is written to the current
|
||||
* directory using the title from the decrypted metadata.
|
||||
*
|
||||
* ```ts
|
||||
* const result = await client.downloadFile(file, "/tmp/beach.jpg");
|
||||
* console.log(`Wrote ${result.bytesWritten} bytes to ${result.path}`);
|
||||
* ```
|
||||
*/
|
||||
it("5. download and decrypt a file to disk", async () => {
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(server) },
|
||||
});
|
||||
const collections = await client.listCollections();
|
||||
const files = await client.listFiles(
|
||||
collections[0]!.id,
|
||||
collections[0]!.key,
|
||||
);
|
||||
const file = files[0]!;
|
||||
const outPath = join(testDir, "beach.jpg");
|
||||
|
||||
const result = await client.downloadFile(file, outPath);
|
||||
|
||||
expect(result.path).toBe(outPath);
|
||||
expect(result.bytesWritten).toBe(server.photoPlaintext.length);
|
||||
expect(readFileSync(outPath)).toEqual(
|
||||
Buffer.from(server.photoPlaintext),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ## 6. Downloading a thumbnail
|
||||
*
|
||||
* Thumbnails are encrypted the same way as full files, just served
|
||||
* from a different CDN endpoint. The API is identical.
|
||||
*
|
||||
* ```ts
|
||||
* const result = await client.downloadThumbnail(file, "/tmp/thumb.jpg");
|
||||
* ```
|
||||
*/
|
||||
it("6. download and decrypt a thumbnail", async () => {
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(server) },
|
||||
});
|
||||
const collections = await client.listCollections();
|
||||
const files = await client.listFiles(
|
||||
collections[0]!.id,
|
||||
collections[0]!.key,
|
||||
);
|
||||
const file = files[0]!;
|
||||
const outPath = join(testDir, "thumb.jpg");
|
||||
|
||||
const result = await client.downloadThumbnail(file, outPath);
|
||||
|
||||
expect(result.bytesWritten).toBe(server.thumbPlaintext.length);
|
||||
expect(readFileSync(outPath)).toEqual(
|
||||
Buffer.from(server.thumbPlaintext),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ## 7. Serializing and restoring a session
|
||||
*
|
||||
* The Client holds the auth token and key material in memory. If
|
||||
* you need to persist a session across process restarts (for a CLI
|
||||
* that doesn't re-prompt for a password every time, or a long-lived
|
||||
* daemon), call `toJSON()` to get a plain object you can
|
||||
* JSON.stringify and store however you like. Later, pass it to
|
||||
* `Client.fromJSON()` to get back a working Client without logging
|
||||
* in again.
|
||||
*
|
||||
* The serialized form contains the master key and secret key. Treat
|
||||
* it as you would treat the password itself. Encrypt it at rest if
|
||||
* you store it on disk.
|
||||
*
|
||||
* ```ts
|
||||
* // Save
|
||||
* const snapshot = client.toJSON();
|
||||
* fs.writeFileSync("session.json", JSON.stringify(snapshot));
|
||||
*
|
||||
* // Restore (in a later process)
|
||||
* const data = JSON.parse(fs.readFileSync("session.json", "utf-8"));
|
||||
* const restored = Client.fromJSON(data);
|
||||
* const collections = await restored.listCollections(); // works
|
||||
* ```
|
||||
*/
|
||||
it("7. serialize with toJSON() and restore with fromJSON()", async () => {
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(server) },
|
||||
});
|
||||
|
||||
// Serialize to a plain object. This is what you would persist.
|
||||
const snapshot = client.toJSON();
|
||||
expect(snapshot.email).toBe(TEST_EMAIL);
|
||||
expect(snapshot.userID).toBe(42);
|
||||
expect(typeof snapshot.token).toBe("string");
|
||||
expect(typeof snapshot.masterKey).toBe("string"); // base64
|
||||
|
||||
// Restore from the snapshot. The new client must be able to do
|
||||
// everything the original could, without logging in again.
|
||||
const restored = Client.fromJSON(snapshot, {
|
||||
fetch: buildMockFetch(server),
|
||||
});
|
||||
expect(restored.whoami().email).toBe(TEST_EMAIL);
|
||||
|
||||
const collections = await restored.listCollections();
|
||||
expect(collections[0]!.name).toBe("Vacation");
|
||||
});
|
||||
|
||||
/**
|
||||
* ## 8. Logging out
|
||||
*
|
||||
* `logout()` clears the in-memory session. After this call, every
|
||||
* method that requires authentication will throw. The Client object
|
||||
* is no longer usable.
|
||||
*
|
||||
* ```ts
|
||||
* client.logout();
|
||||
* // client.listCollections() would now throw
|
||||
* ```
|
||||
*/
|
||||
it("8. logout clears the session", async () => {
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(server) },
|
||||
});
|
||||
|
||||
client.logout();
|
||||
|
||||
expect(() => client.whoami()).toThrow();
|
||||
await expect(client.listCollections()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user