quak login --server: log in to a self-hosted Ente server (closes #243)
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:
clawbot committed 2026-11-13 22:57:49 +01:00
1 parent 9bd7e8f099
commit 2492946e9d
10 files changed
+768 -68

No files matched your search

+57
View File
@@ -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/,