quak login --server: log in to a self-hosted Ente server (closes #243)
check / check (push) Successful in 5m0s
check / check (push) Successful in 5m0s
`quak login` takes the server's API address from `--server` or `QUAK_SERVER`. `isServerAddress` refuses anything but an http or https URL with no query or fragment, there and in `Client.fromJSON`. The client's snapshot keeps a non-default `apiOrigin`, so the session file names the server and every later command uses it. Two servers can give two accounts, and two files, the same ID. A self-hosted account's default cache directory therefore names the server, and `metadata.json` records it: `Library.open` deletes a cache from another server whole. `originalOf` and `thumbnailOf` record the account in a cache that names none before storing a file, and `open` treats a cache with no collection as empty. This fixes #254 (closes #254). Model: opus-5-5
This commit was merged in pull request #253.
This commit is contained in:
1 parent
9bd7e8f099
commit
2492946e9d
10 files changed
+768
-68
No files matched your search
+35
-3
@@ -2,12 +2,14 @@
|
||||
* Tests for the retry options `bin/quak.ts` loads the saved session with
|
||||
* (`quak backup` gets the unattended ones, every other command the default),
|
||||
* each with the callbacks that log its requests and retries, for `quak files`
|
||||
* taking its collection ID as an optional argument, and for `quak get` and
|
||||
* `quak get-thumb` refusing `--collection`.
|
||||
* taking its collection ID as an optional argument, for `quak get` and
|
||||
* `quak get-thumb` refusing `--collection`, and for `quak login` taking
|
||||
* `--server` and sending its first request to the server it names.
|
||||
*
|
||||
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
|
||||
* replaced by one that records the options it is given and reports no session,
|
||||
* so the command stops before it makes any request.
|
||||
* so the command stops before it makes any request. `quak login` reads no
|
||||
* session, so its test stubs `fetch` instead and records each request.
|
||||
*
|
||||
* The first load of the sources `bin/quak.ts` imports compiles them, and the
|
||||
* first `init()` starts libsodium. On a loaded host that takes longer than a
|
||||
@@ -58,6 +60,8 @@ afterEach(() => {
|
||||
process.argv = argv;
|
||||
loaded.length = 0;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -117,3 +121,31 @@ describe("bin/quak.ts get and get-thumb", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("bin/quak.ts login", () => {
|
||||
// The command refuses an address that is not a URL before it asks for
|
||||
// anything or sends any request.
|
||||
it("hands --server to the command", async () => {
|
||||
expect(await quak("login", "--server", "ente.example.com")).toMatch(
|
||||
/^Invalid server address "ente\.example\.com"/,
|
||||
);
|
||||
});
|
||||
|
||||
// `fetch` answers 404, so the login fails at its first request. A login
|
||||
// that succeeded would save its session in this user's real data
|
||||
// directory, since env-paths is not mocked here.
|
||||
it("sends its first request to the server --server names", async () => {
|
||||
const requests: string[] = [];
|
||||
vi.stubGlobal("fetch", async (input: string) => {
|
||||
const { origin, pathname } = new URL(input);
|
||||
requests.push(origin + pathname);
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
vi.stubEnv("QUAK_EMAIL", "login@example.com");
|
||||
vi.stubEnv("QUAK_PASSWORD", "password");
|
||||
await quak("login", "--server", "https://ente.example.com");
|
||||
expect(requests).toEqual([
|
||||
"https://ente.example.com/users/srp/attributes",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+265
-17
@@ -328,6 +328,7 @@ describe("login", () => {
|
||||
prompts = [];
|
||||
vi.stubEnv("QUAK_EMAIL", "cli@example.com");
|
||||
vi.stubEnv("QUAK_PASSWORD", "hunter2");
|
||||
vi.stubEnv("QUAK_SERVER", undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -340,12 +341,14 @@ describe("login", () => {
|
||||
calls.push(opts);
|
||||
return loggedIn;
|
||||
});
|
||||
expect(await loginCommand(ctx)).toBe(0);
|
||||
expect(await loginCommand(ctx, {})).toBe(0);
|
||||
|
||||
expect(prompts).toEqual([]);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.email).toBe("cli@example.com");
|
||||
expect(calls[0]!.password).toBe("hunter2");
|
||||
// No server named: the client uses Ente's hosted service.
|
||||
expect(calls[0]!.apiOptions).toBeUndefined();
|
||||
const path = join(ctx.sessionDir, "session.json");
|
||||
expect(stderr.text).toBe(
|
||||
"Authenticating...\n" +
|
||||
@@ -356,7 +359,7 @@ describe("login", () => {
|
||||
|
||||
it("saves the session with mode 0600 in a directory with mode 0700", async () => {
|
||||
const ctx = loginContext(async () => loggedIn);
|
||||
expect(await loginCommand(ctx)).toBe(0);
|
||||
expect(await loginCommand(ctx, {})).toBe(0);
|
||||
|
||||
expect(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700);
|
||||
const path = join(ctx.sessionDir, "session.json");
|
||||
@@ -370,7 +373,7 @@ describe("login", () => {
|
||||
code = await opts.totp!();
|
||||
return loggedIn;
|
||||
});
|
||||
expect(await loginCommand(ctx)).toBe(0);
|
||||
expect(await loginCommand(ctx, {})).toBe(0);
|
||||
|
||||
expect(prompts).toEqual(["TOTP code: "]);
|
||||
expect(code).toBe("123456");
|
||||
@@ -380,13 +383,83 @@ describe("login", () => {
|
||||
const ctx = loginContext(async () => {
|
||||
throw new Error("HTTP 401 from server");
|
||||
});
|
||||
expect(await loginCommand(ctx)).toBe(1);
|
||||
expect(await loginCommand(ctx, {})).toBe(1);
|
||||
|
||||
expect(stderr.text).toBe(
|
||||
"Authenticating...\nLogin failed: HTTP 401 from server\n",
|
||||
);
|
||||
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("logs in to the server --server names, ahead of QUAK_SERVER", async () => {
|
||||
vi.stubEnv("QUAK_SERVER", "https://other.example.com");
|
||||
const calls: LoginOptions[] = [];
|
||||
const ctx = loginContext(async (opts) => {
|
||||
calls.push(opts);
|
||||
return loggedIn;
|
||||
});
|
||||
expect(
|
||||
await loginCommand(ctx, { server: "https://ente.example.com" }),
|
||||
).toBe(0);
|
||||
|
||||
expect(calls[0]!.apiOptions).toEqual({
|
||||
apiOrigin: "https://ente.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("logs in to the server QUAK_SERVER names without --server", async () => {
|
||||
vi.stubEnv("QUAK_SERVER", "http://192.0.2.1:8080/ente");
|
||||
const calls: LoginOptions[] = [];
|
||||
const ctx = loginContext(async (opts) => {
|
||||
calls.push(opts);
|
||||
return loggedIn;
|
||||
});
|
||||
expect(await loginCommand(ctx, {})).toBe(0);
|
||||
|
||||
expect(calls[0]!.apiOptions).toEqual({
|
||||
apiOrigin: "http://192.0.2.1:8080/ente",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"ente.example.com",
|
||||
"ftp://ente.example.com",
|
||||
"https://ente.example.com/?region=eu",
|
||||
"https://ente.example.com/#top",
|
||||
])(
|
||||
"refuses the server address %j before asking for anything",
|
||||
async (server) => {
|
||||
const ctx = loginContext(async () => {
|
||||
throw new Error("login must not be called");
|
||||
});
|
||||
expect(await loginCommand(ctx, { server })).toBe(1);
|
||||
|
||||
expect(prompts).toEqual([]);
|
||||
expect(stderr.text).toBe(
|
||||
`Invalid server address "${server}": it must be an http or ` +
|
||||
`https URL with no query or fragment\n`,
|
||||
);
|
||||
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses an empty QUAK_SERVER before asking for anything", async () => {
|
||||
vi.stubEnv("QUAK_SERVER", "");
|
||||
const ctx = loginContext(async () => {
|
||||
throw new Error("login must not be called");
|
||||
});
|
||||
expect(await loginCommand(ctx, {})).toBe(1);
|
||||
|
||||
expect(prompts).toEqual([]);
|
||||
expect(stderr.text).toBe(
|
||||
`Invalid server address "": it must be an http or https URL ` +
|
||||
`with no query or fragment\n`,
|
||||
);
|
||||
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// These use a real client read from the session file, over a fake API that
|
||||
@@ -461,6 +534,16 @@ describe("logout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("names a self-hosted account's default cache directory, under its server's address", async () => {
|
||||
const ctx = { ...logoutContext(200), cacheDir: undefined };
|
||||
const apiOrigin = "https://ente.example.com";
|
||||
saveSession(ctx.sessionDir, { ...snapshot, apiOrigin });
|
||||
expect(await logoutCommand(ctx)).toBe(0);
|
||||
expect(stderr.text).toContain(
|
||||
`Cache directory ${defaultCacheDirectory(USER_ID, apiOrigin)} still holds decrypted data`,
|
||||
);
|
||||
});
|
||||
|
||||
it("without a session says so, calls nothing and exits 0", async () => {
|
||||
expect(await logoutCommand(logoutContext(200))).toBe(0);
|
||||
expect(requests).toHaveLength(0);
|
||||
@@ -1618,18 +1701,19 @@ describe("an account with many albums", () => {
|
||||
// encrypted as the server stores it. The original's bytes are the title.
|
||||
// These tests fetch no thumbnail. The server records a thumbnail size, so
|
||||
// `helper fix-missing-thumbnails` downloads the original before it skips
|
||||
// the photo as not a JPEG.
|
||||
const photoRow = (photo: {
|
||||
id: number;
|
||||
collectionID: number;
|
||||
title: string;
|
||||
updationTime: number;
|
||||
}): { row: Record<string, unknown>; original: Uint8Array } => {
|
||||
// the photo as not a JPEG. `albumKey` is that album's key, by default the
|
||||
// one in `albumKeys`.
|
||||
const photoRow = (
|
||||
photo: {
|
||||
id: number;
|
||||
collectionID: number;
|
||||
title: string;
|
||||
updationTime: number;
|
||||
},
|
||||
albumKey = albumKeys.get(photo.collectionID)!,
|
||||
): { row: Record<string, unknown>; original: Uint8Array } => {
|
||||
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const photoKeyBox = encryptSecretbox(
|
||||
photoKey,
|
||||
albumKeys.get(photo.collectionID)!,
|
||||
);
|
||||
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
|
||||
const metadata = encryptStreamBlob(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
@@ -1742,6 +1826,77 @@ describe("an account with many albums", () => {
|
||||
};
|
||||
};
|
||||
|
||||
// The albums in the cache's `metadata.json`. A search on an empty cache
|
||||
// stores none there, only the account whose original or thumbnail it
|
||||
// stored.
|
||||
const cachedAlbums = (): unknown[] =>
|
||||
JSON.parse(readFileSync(join(root, "cache", "metadata.json"), "utf-8"))
|
||||
.collections;
|
||||
|
||||
// A context whose login and saved session send each request to the server
|
||||
// its host names: `ente.example.com` is the fake server above, and
|
||||
// `photos.example.org` a second one. Every account the fake server logs in
|
||||
// has user ID 42, so the second server's account has the first one's user
|
||||
// ID. Its one album holds photo 200, `other-200.jpg`; the first server
|
||||
// gives ID 200 to `photo-2.jpg`.
|
||||
const twoServerContext = async (): Promise<CliContext> => {
|
||||
const account = await buildAccount(PASSWORD);
|
||||
const albumKey = sodium.crypto_secretbox_keygen();
|
||||
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
|
||||
const name = encryptSecretbox(
|
||||
new TextEncoder().encode("Other album"),
|
||||
albumKey,
|
||||
);
|
||||
const { row, original } = photoRow(
|
||||
{
|
||||
id: 200,
|
||||
collectionID: 1,
|
||||
title: "other-200.jpg",
|
||||
updationTime: 1,
|
||||
},
|
||||
albumKey,
|
||||
);
|
||||
const other = buildMetaFetch({
|
||||
verifier: account.verifier,
|
||||
srpAttributes: account.srpAttributes,
|
||||
keyAttributes: account.keyAttributes,
|
||||
encryptedToken: account.encryptedToken,
|
||||
collections: [
|
||||
{
|
||||
id: 1,
|
||||
owner: { id: USER_ID },
|
||||
encryptedKey: toBase64(albumKeyBox.ciphertext),
|
||||
keyDecryptionNonce: toBase64(albumKeyBox.nonce),
|
||||
encryptedName: toBase64(name.ciphertext),
|
||||
nameDecryptionNonce: toBase64(name.nonce),
|
||||
type: "album",
|
||||
updationTime: 1,
|
||||
},
|
||||
],
|
||||
filesByCollection: { 1: [row] },
|
||||
encryptedMLData: {},
|
||||
fileCiphertexts: { 200: original },
|
||||
});
|
||||
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { host } = new URL(
|
||||
input instanceof Request ? input.url : input,
|
||||
);
|
||||
return (host === "photos.example.org" ? other : server)(
|
||||
input,
|
||||
init,
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
return {
|
||||
...context(),
|
||||
login: (opts) =>
|
||||
Client.login({
|
||||
...opts,
|
||||
apiOptions: { ...opts.apiOptions, fetch },
|
||||
}),
|
||||
loadSession: (path) => loadSession(path, { fetch }),
|
||||
};
|
||||
};
|
||||
|
||||
it("collections lists every album from one request, fetching no album's files", async () => {
|
||||
const ctx = accountContext();
|
||||
expect(await collectionsCommand(ctx, {})).toBe(0);
|
||||
@@ -1848,7 +2003,100 @@ describe("an account with many albums", () => {
|
||||
"/collections/v2/diff?collectionID=2&sinceTime=0",
|
||||
"/?fileID=200",
|
||||
]);
|
||||
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
|
||||
expect(cachedAlbums()).toEqual([]);
|
||||
});
|
||||
|
||||
// A self-hosted server serves downloads under its own address, at
|
||||
// `/files/download/<id>`, so no request goes to Ente's hosted service.
|
||||
it("login --server saves the server, and a later get sends every request to it", async () => {
|
||||
vi.stubEnv("QUAK_EMAIL", "many@example.com");
|
||||
vi.stubEnv("QUAK_PASSWORD", PASSWORD);
|
||||
vi.stubEnv("QUAK_SERVER", undefined);
|
||||
const hosts = new Set<string>();
|
||||
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
hosts.add(
|
||||
new URL(input instanceof Request ? input.url : input).host,
|
||||
);
|
||||
return server(input, init);
|
||||
}) as typeof globalThis.fetch;
|
||||
const ctx: CliContext = {
|
||||
...context(),
|
||||
login: (opts) =>
|
||||
Client.login({
|
||||
...opts,
|
||||
apiOptions: { ...opts.apiOptions, fetch },
|
||||
}),
|
||||
loadSession: (path) => loadSession(path, { fetch }),
|
||||
};
|
||||
const out = join(root, "photo.jpg");
|
||||
try {
|
||||
const opts = { server: "https://ente.example.com" };
|
||||
expect(await loginCommand(ctx, opts)).toBe(0);
|
||||
requests.length = 0;
|
||||
expect(await getCommand(ctx, "200", { out })).toBe(0);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
|
||||
const path = join(ctx.sessionDir, "session.json");
|
||||
expect(JSON.parse(readFileSync(path, "utf-8")).apiOrigin).toBe(
|
||||
"https://ente.example.com",
|
||||
);
|
||||
expect(readFileSync(out, "utf-8")).toBe("photo-2.jpg");
|
||||
expect(requests.at(-1)).toBe("/files/download/200");
|
||||
expect([...hosts]).toEqual(["ente.example.com"]);
|
||||
});
|
||||
|
||||
// The two servers' accounts share the cache directory `--cache-dir` names.
|
||||
it("after a login to another server whose account has the same user ID, files and get see none of the first server's albums and originals", async () => {
|
||||
const ctx = await twoServerContext();
|
||||
const first = join(root, "first.jpg");
|
||||
const second = join(root, "second.jpg");
|
||||
vi.stubEnv("QUAK_EMAIL", "many@example.com");
|
||||
vi.stubEnv("QUAK_PASSWORD", PASSWORD);
|
||||
vi.stubEnv("QUAK_SERVER", undefined);
|
||||
try {
|
||||
const opts = { server: "https://ente.example.com" };
|
||||
expect(await loginCommand(ctx, opts)).toBe(0);
|
||||
expect(await filesCommand(ctx, undefined, {})).toBe(0);
|
||||
expect(await getCommand(ctx, "200", { out: first })).toBe(0);
|
||||
|
||||
const otherOpts = { server: "https://photos.example.org" };
|
||||
expect(await loginCommand(ctx, otherOpts)).toBe(0);
|
||||
stdout.text = "";
|
||||
expect(await filesCommand(ctx, undefined, {})).toBe(0);
|
||||
expect(await getCommand(ctx, "200", { out: second })).toBe(0);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
|
||||
expect(stdout.text).toBe("200\timage\tother-200.jpg\n");
|
||||
expect(readFileSync(first, "utf-8")).toBe("photo-2.jpg");
|
||||
expect(readFileSync(second, "utf-8")).toBe("other-200.jpg");
|
||||
});
|
||||
|
||||
// `get` on an empty cache stores the original but no album.
|
||||
it("get on an empty cache, after the same on another server whose account has the same user ID, saves this server's photo", async () => {
|
||||
const ctx = await twoServerContext();
|
||||
const first = join(root, "first.jpg");
|
||||
const second = join(root, "second.jpg");
|
||||
vi.stubEnv("QUAK_EMAIL", "many@example.com");
|
||||
vi.stubEnv("QUAK_PASSWORD", PASSWORD);
|
||||
vi.stubEnv("QUAK_SERVER", undefined);
|
||||
try {
|
||||
const opts = { server: "https://ente.example.com" };
|
||||
expect(await loginCommand(ctx, opts)).toBe(0);
|
||||
expect(await getCommand(ctx, "200", { out: first })).toBe(0);
|
||||
|
||||
const otherOpts = { server: "https://photos.example.org" };
|
||||
expect(await loginCommand(ctx, otherOpts)).toBe(0);
|
||||
expect(await getCommand(ctx, "200", { out: second })).toBe(0);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
|
||||
expect(readFileSync(first, "utf-8")).toBe("photo-2.jpg");
|
||||
expect(readFileSync(second, "utf-8")).toBe("other-200.jpg");
|
||||
});
|
||||
|
||||
// `files` first fills the cache, so `get` waits for a refresh. No album has
|
||||
@@ -1906,7 +2154,7 @@ describe("an account with many albums", () => {
|
||||
"/collections/v2/diff?collectionID=39&sinceTime=0",
|
||||
"/?fileID=3900",
|
||||
]);
|
||||
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
|
||||
expect(cachedAlbums()).toEqual([]);
|
||||
});
|
||||
|
||||
// `files` first fills the cache, so the helper waits for a refresh. No
|
||||
|
||||
@@ -47,6 +47,35 @@ describe("Client.toJSON", () => {
|
||||
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("round-trips a snapshot that names a server", () => {
|
||||
const snapshot = {
|
||||
...validSnapshot(),
|
||||
apiOrigin: "https://ente.example.com",
|
||||
};
|
||||
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("names the server apiOptions gave fromJSON", async () => {
|
||||
const apiOrigin = "https://ente.example.com";
|
||||
const urls: string[] = [];
|
||||
const client = Client.fromJSON(validSnapshot(), {
|
||||
apiOrigin,
|
||||
fetch: async (input) => {
|
||||
urls.push(String(input));
|
||||
return new Response(JSON.stringify({ collections: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
expect(client.toJSON().apiOrigin).toBe(apiOrigin);
|
||||
|
||||
await client.listCollections();
|
||||
expect(urls).toEqual([
|
||||
"https://ente.example.com/collections/v2?sinceTime=0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws instead of emitting a snapshot without a token", () => {
|
||||
const client = Client.fromJSON(validSnapshot());
|
||||
client.getApiClient().clearAuthToken();
|
||||
@@ -77,6 +106,10 @@ describe("Client.fromJSON", () => {
|
||||
["publicKey", undefined],
|
||||
["publicKey", "not base64!"],
|
||||
["publicKey", shortKey],
|
||||
["apiOrigin", 7],
|
||||
["apiOrigin", ""],
|
||||
["apiOrigin", "ente.example.com"],
|
||||
["apiOrigin", "ftp://ente.example.com"],
|
||||
])("rejects %s = %j, naming the field", (field, value) => {
|
||||
const snapshot: Record<string, unknown> = { ...validSnapshot() };
|
||||
snapshot[field] = value;
|
||||
@@ -85,6 +118,30 @@ describe("Client.fromJSON", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// The token belongs to the snapshot's server, so that server wins over
|
||||
// the one in `apiOptions`.
|
||||
it("sends requests to the snapshot's server", async () => {
|
||||
const urls: string[] = [];
|
||||
const client = Client.fromJSON(
|
||||
{ ...validSnapshot(), apiOrigin: "https://ente.example.com/" },
|
||||
{
|
||||
apiOrigin: "https://other.example.com",
|
||||
fetch: async (input) => {
|
||||
urls.push(String(input));
|
||||
return new Response(JSON.stringify({ collections: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await client.listCollections();
|
||||
expect(urls).toEqual([
|
||||
"https://ente.example.com/collections/v2?sinceTime=0",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([null, "a string", 42])("rejects a non-object %j", (value) => {
|
||||
expect(() => Client.fromJSON(value)).toThrow(
|
||||
/^Invalid session data: not a JSON object/,
|
||||
|
||||
@@ -19,16 +19,22 @@
|
||||
* refresh fails (offline start from cache).
|
||||
* 5. `close()` stops the timer and is idempotent, and its promise resolves
|
||||
* only once an in-flight refresh has written the cache file.
|
||||
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id.
|
||||
* 7. `open()` branches on the cache: an empty cache awaits the first refresh
|
||||
* (it has nothing to serve yet); an existing cache serves its copy at once
|
||||
* and refreshes in the background, so a slow or dead server never stalls
|
||||
* opening.
|
||||
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id,
|
||||
* with a self-hosted server's address before the user id.
|
||||
* 7. `open()` branches on the cache: a cache that holds no collection awaits
|
||||
* the first refresh (it has nothing to serve yet); an existing cache serves
|
||||
* its copy at once and refreshes in the background, so a slow or dead
|
||||
* server never stalls opening.
|
||||
* 8. A save failure that leaves RAM ahead of disk keeps `status().lastError`
|
||||
* set and keeps retrying the write; a later empty refresh does not clear it.
|
||||
* 9. With `refreshOnOpen: false`, `open()` starts no refresh at all, and a
|
||||
* refresh reports through `onProgress` each collection whose files it
|
||||
* fetched.
|
||||
* 10. A cache another account filled starts empty. One filled from another
|
||||
* server also loses its originals and thumbnails, since two servers can
|
||||
* give two files the same ID. `thumbnailOf` on an empty cache saves the
|
||||
* account's user ID and server. A refresh saves the account's server, so a
|
||||
* self-hosted account keeps its own cache from one `open()` to the next.
|
||||
*
|
||||
* The client is a mock: no crypto, no network. It serves scripted pages and
|
||||
* records the `sinceTime` each call carried so cursor threading is provable.
|
||||
@@ -49,7 +55,13 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import envPaths from "env-paths";
|
||||
@@ -107,6 +119,7 @@ const file = (
|
||||
*/
|
||||
class MockClient {
|
||||
userID = USER_ID;
|
||||
apiOrigin?: string;
|
||||
failCollections = false;
|
||||
collectionsQueue: CollectionsPage[] = [];
|
||||
filesByCollection = new Map<number, FilesPage[]>();
|
||||
@@ -584,6 +597,44 @@ describe("Library.open and background refresh", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// `originalOf` on an empty cache leaves such a cache behind: it names the
|
||||
// account before it stores the original.
|
||||
it("awaits the first refresh on a cache that names the account but holds no collection", async () => {
|
||||
const seed = await MetadataStore.load(
|
||||
join(cacheDirectory, "metadata.json"),
|
||||
);
|
||||
seed.userID = USER_ID;
|
||||
await seed.save();
|
||||
let releaseFirstFetch: (page: CollectionsPage) => void = () => {};
|
||||
const gate = new Promise<CollectionsPage>((resolve) => {
|
||||
releaseFirstFetch = resolve;
|
||||
});
|
||||
const client = new MockClient();
|
||||
client.collectionsSince = async () => gate;
|
||||
|
||||
let opened = false;
|
||||
const openPromise = Library.open({ client, cacheDirectory }).then(
|
||||
(l) => {
|
||||
opened = true;
|
||||
return l;
|
||||
},
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(opened).toBe(false);
|
||||
|
||||
releaseFirstFetch({
|
||||
collections: [collection(1, 100)],
|
||||
deleted: [],
|
||||
cursor: 100,
|
||||
});
|
||||
const lib = await openPromise;
|
||||
try {
|
||||
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A caller that asks for each refresh it needs, as the CLI's read commands
|
||||
// do, opens without one: on an empty cache the refresh `open()` would
|
||||
// await fetches the files of every collection, which `quak collections`
|
||||
@@ -842,4 +893,165 @@ describe("Library.open and background refresh", () => {
|
||||
else process.env.XDG_CACHE_HOME = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("puts a self-hosted server's address, URL-encoded, before the user id in the default cacheDirectory", async () => {
|
||||
const xdg = join(dir, "xdg-cache");
|
||||
const prev = process.env.XDG_CACHE_HOME;
|
||||
process.env.XDG_CACHE_HOME = xdg;
|
||||
try {
|
||||
const client = new MockClient();
|
||||
client.apiOrigin = "https://ente.example.com:8443/api";
|
||||
const lib = await Library.open({ client });
|
||||
try {
|
||||
expect(lib.cacheDirectory).toBe(
|
||||
join(
|
||||
envPaths("quak", { suffix: "" }).cache,
|
||||
"https%3A%2F%2Fente.example.com%3A8443%2Fapi",
|
||||
String(USER_ID),
|
||||
),
|
||||
);
|
||||
expect(lib.cacheDirectory.startsWith(xdg)).toBe(true);
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.XDG_CACHE_HOME;
|
||||
else process.env.XDG_CACHE_HOME = prev;
|
||||
}
|
||||
});
|
||||
|
||||
// A cache that account USER_ID on `apiOrigin` filled: its records, and an
|
||||
// original and a thumbnail of file 1001.
|
||||
const fillCache = async (apiOrigin?: string): Promise<void> => {
|
||||
const seed = await MetadataStore.load(
|
||||
join(cacheDirectory, "metadata.json"),
|
||||
);
|
||||
seed.userID = USER_ID;
|
||||
seed.apiOrigin = apiOrigin;
|
||||
seed.collectionsSinceTime = 500;
|
||||
seed.putCollection(collection(1, 400));
|
||||
seed.putFile(file(1001, 1, 400));
|
||||
await seed.save();
|
||||
for (const name of ["originals", "thumbnails"]) {
|
||||
mkdirSync(join(cacheDirectory, name));
|
||||
writeFileSync(join(cacheDirectory, name, "1001.jpg"), "1001");
|
||||
}
|
||||
};
|
||||
|
||||
it("deletes the whole cache an account on another server filled", async () => {
|
||||
await fillCache("https://ente.example.com");
|
||||
const client = new MockClient();
|
||||
client.apiOrigin = "https://photos.example.org";
|
||||
const lib = await Library.open({
|
||||
client,
|
||||
cacheDirectory,
|
||||
refreshIntervalSeconds: 3600,
|
||||
});
|
||||
try {
|
||||
expect(client.collectionsSinceTimes).toEqual([0]);
|
||||
expect(lib.listCollections()).toEqual([]);
|
||||
for (const name of ["originals", "thumbnails"]) {
|
||||
expect(existsSync(join(cacheDirectory, name, "1001.jpg"))).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A server gives each file its own ID, so another account's cached file
|
||||
// is never served for this account's.
|
||||
it("keeps the originals and thumbnails another account on the same server cached", async () => {
|
||||
await fillCache();
|
||||
const client = new MockClient();
|
||||
client.userID = USER_ID + 1;
|
||||
const lib = await Library.open({
|
||||
client,
|
||||
cacheDirectory,
|
||||
refreshIntervalSeconds: 3600,
|
||||
});
|
||||
try {
|
||||
expect(client.collectionsSinceTimes).toEqual([0]);
|
||||
expect(lib.listCollections()).toEqual([]);
|
||||
for (const name of ["originals", "thumbnails"]) {
|
||||
expect(existsSync(join(cacheDirectory, name, "1001.jpg"))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
|
||||
// `quak get-thumb` on an empty cache stores a thumbnail before any refresh
|
||||
// runs. `open()` for an account on another server deletes that thumbnail
|
||||
// only if the cache names the account that stored it.
|
||||
it("thumbnailOf on an empty cache saves the account's user ID and server in metadata.json", async () => {
|
||||
const client = new MockClient();
|
||||
client.apiOrigin = "https://ente.example.com";
|
||||
const lib = await Library.open({
|
||||
client,
|
||||
cacheDirectory,
|
||||
refreshOnOpen: false,
|
||||
refreshIntervalSeconds: Infinity,
|
||||
contentSource: {
|
||||
original: async () => {
|
||||
throw new Error("original not expected");
|
||||
},
|
||||
thumbnail: async ({ destination }) => {
|
||||
writeFileSync(destination, "thumb");
|
||||
return { bytesWritten: 5 };
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await lib.thumbnailOf(file(1001, 1, 400));
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
|
||||
const saved = await MetadataStore.load(
|
||||
join(cacheDirectory, "metadata.json"),
|
||||
);
|
||||
expect(saved.userID).toBe(USER_ID);
|
||||
expect(saved.apiOrigin).toBe("https://ente.example.com");
|
||||
});
|
||||
|
||||
// A refresh saves the account's server in `metadata.json`. Without it,
|
||||
// `open()` would take a self-hosted account's own cache for another
|
||||
// server's and delete it.
|
||||
it("keeps a self-hosted account's own cache from one open to the next", async () => {
|
||||
const client = new MockClient();
|
||||
client.apiOrigin = "https://ente.example.com";
|
||||
client.collectionsQueue.push({
|
||||
collections: [collection(1, 100)],
|
||||
deleted: [],
|
||||
cursor: 100,
|
||||
});
|
||||
const first = await Library.open({
|
||||
client,
|
||||
cacheDirectory,
|
||||
refreshIntervalSeconds: Infinity,
|
||||
});
|
||||
await first.close();
|
||||
mkdirSync(join(cacheDirectory, "originals"));
|
||||
writeFileSync(join(cacheDirectory, "originals", "1001.jpg"), "1001");
|
||||
|
||||
const lib = await Library.open({
|
||||
client,
|
||||
cacheDirectory,
|
||||
refreshIntervalSeconds: Infinity,
|
||||
});
|
||||
try {
|
||||
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
|
||||
expect(
|
||||
existsSync(join(cacheDirectory, "originals", "1001.jpg")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
// The second open's refresh started from the saved cursor.
|
||||
expect(client.collectionsSinceTimes).toEqual([0, 100]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user