Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6020aca4e |
No files matched your search
@@ -229,7 +229,7 @@ below belong to quak itself, and go before or after the command's name.
|
|||||||
### The commands
|
### The commands
|
||||||
|
|
||||||
```
|
```
|
||||||
quak login
|
quak login [--server <url>]
|
||||||
quak whoami
|
quak whoami
|
||||||
quak logout
|
quak logout
|
||||||
quak collections [--json]
|
quak collections [--json]
|
||||||
@@ -246,13 +246,26 @@ Every command but `login` and `logout` needs a saved session. Every command but
|
|||||||
`login`, `whoami` and `logout` opens the library. How each one gets current data
|
`login`, `whoami` and `logout` opens the library. How each one gets current data
|
||||||
from the server is described under "How a command gets current data".
|
from the server is described under "How a command gets current data".
|
||||||
|
|
||||||
#### `quak login`
|
#### `quak login [--server <url>]`
|
||||||
|
|
||||||
Logs in to an account and saves the session. It reads the email from
|
Logs in to an account and saves the session. It reads the email from
|
||||||
`QUAK_EMAIL` and the password from `QUAK_PASSWORD`. For each one that is not
|
`QUAK_EMAIL` and the password from `QUAK_PASSWORD`. For each one that is not
|
||||||
set, it asks on the terminal. The password shows as `*` characters while it is
|
set, it asks on the terminal. The password shows as `*` characters while it is
|
||||||
typed.
|
typed.
|
||||||
|
|
||||||
|
quak logs in to Ente's hosted service unless you name a self-hosted Ente server.
|
||||||
|
Name it with `--server`, or with `QUAK_SERVER` when `--server` is not given.
|
||||||
|
Give the address of the server's API, for example
|
||||||
|
`quak login --server https://ente.example.com`. The address is an http or https
|
||||||
|
URL. It may include a path, but no query or fragment. quak refuses any other
|
||||||
|
address before it asks for anything. It logs
|
||||||
|
`Invalid server address "<url>": it must be an http or https URL with no query or fragment`,
|
||||||
|
and the exit status is 1.
|
||||||
|
|
||||||
|
The session file keeps the server's address. Every later command, cron runs
|
||||||
|
included, uses that server without further setting. To change server, log in
|
||||||
|
again.
|
||||||
|
|
||||||
When the account has a second factor, quak asks for the code. It asks
|
When the account has a second factor, quak asks for the code. It asks
|
||||||
`TOTP code: ` for a time-based code from an authenticator app. An account whose
|
`TOTP code: ` for a time-based code from an authenticator app. An account whose
|
||||||
only second factor is a passkey cannot log in with quak. When the account has
|
only second factor is a passkey cannot log in with quak. When the account has
|
||||||
@@ -597,9 +610,11 @@ Linux that is `$XDG_DATA_HOME/quak/session.json`, or
|
|||||||
mode `0600`, in a directory it creates with mode `0700`.
|
mode `0600`, in a directory it creates with mode `0700`.
|
||||||
|
|
||||||
The file holds the account's email, its user ID, the session token and the
|
The file holds the account's email, its user ID, the session token and the
|
||||||
account's three keys, in base64 and unencrypted. Treat it as you would treat the
|
account's three keys, in base64 and unencrypted. After a login to a self-hosted
|
||||||
password. A missing file means "not logged in". A file that cannot be read back
|
server, it also holds the server's address as `apiOrigin`. Treat it as you would
|
||||||
is reported as corrupt, with the reason. Run `quak logout`, then `quak login`.
|
treat the password. A missing file means "not logged in". A file that cannot be
|
||||||
|
read back is reported as corrupt, with the reason. Run `quak logout`, then
|
||||||
|
`quak login`.
|
||||||
|
|
||||||
A command that needs a session exits 3 when the file is missing or corrupt. It
|
A command that needs a session exits 3 when the file is missing or corrupt. It
|
||||||
also exits 3 when the server no longer accepts the session, and prints
|
also exits 3 when the server no longer accepts the session, and prints
|
||||||
@@ -1477,6 +1492,11 @@ write wherever it likes. `Client.fromJSON(snapshot, apiOptions?)` restores a
|
|||||||
working client from that snapshot without logging in again. It checks every
|
working client from that snapshot without logging in again. It checks every
|
||||||
field and each key's length first, and throws an error naming the bad field.
|
field and each key's length first, and throws an error naming the bad field.
|
||||||
|
|
||||||
|
A client whose `apiOptions` named an `apiOrigin` puts it in its snapshot. The
|
||||||
|
restored client then uses that server, even when the `apiOptions` given to
|
||||||
|
`Client.fromJSON` name another. A snapshot without `apiOrigin` uses the
|
||||||
|
`apiOrigin` of those `apiOptions`, or the default server.
|
||||||
|
|
||||||
`Client.fromJSON` works only once libsodium, the cryptography library quak uses,
|
`Client.fromJSON` works only once libsodium, the cryptography library quak uses,
|
||||||
is loaded. `Client.login` loads it, and `Client.fromJSON` does not. A program
|
is loaded. `Client.login` loads it, and `Client.fromJSON` does not. A program
|
||||||
that restores a session in a new process first runs `await sodium.ready`, with
|
that restores a session in a new process first runs `await sodium.ready`, with
|
||||||
|
|||||||
+11
-2
@@ -74,7 +74,11 @@ const context = (): CliContext => ({
|
|||||||
sessionDir: paths.data,
|
sessionDir: paths.data,
|
||||||
cacheDir: program.opts<ProgramOptions>().cacheDir,
|
cacheDir: program.opts<ProgramOptions>().cacheDir,
|
||||||
loadSession: (path) => loadSession(path, logRequests(log())),
|
loadSession: (path) => loadSession(path, logRequests(log())),
|
||||||
login: (opts) => Client.login({ ...opts, apiOptions: logRequests(log()) }),
|
login: (opts) =>
|
||||||
|
Client.login({
|
||||||
|
...opts,
|
||||||
|
apiOptions: { ...opts.apiOptions, ...logRequests(log()) },
|
||||||
|
}),
|
||||||
prompt: (message) => input({ message }),
|
prompt: (message) => input({ message }),
|
||||||
promptSecret: (message) => password({ message, mask: true }),
|
promptSecret: (message) => password({ message, mask: true }),
|
||||||
});
|
});
|
||||||
@@ -85,7 +89,12 @@ const run = (command: Promise<number>): Promise<void> =>
|
|||||||
program
|
program
|
||||||
.command("login")
|
.command("login")
|
||||||
.description("Log in to an Ente account and save the session")
|
.description("Log in to an Ente account and save the session")
|
||||||
.action(() => run(loginCommand(context())));
|
.option(
|
||||||
|
"--server <url>",
|
||||||
|
"Address of a self-hosted Ente server's API, saved with the session " +
|
||||||
|
"(default: QUAK_SERVER, then Ente's hosted service)",
|
||||||
|
)
|
||||||
|
.action((opts: { server?: string }) => run(loginCommand(context(), opts)));
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("whoami")
|
.command("whoami")
|
||||||
|
|||||||
+26
-1
@@ -155,8 +155,31 @@ const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
|
|||||||
onProgress: logRefresh(ctx.log),
|
onProgress: logRefresh(ctx.log),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
// Logs in to the server `--server` or `QUAK_SERVER` names, the flag first, or
|
||||||
|
// to Ente's hosted service when neither is set. The saved session keeps the
|
||||||
|
// server, so every later command uses it.
|
||||||
|
// Whether `text` can be an Ente server's address: an http or https URL, which
|
||||||
|
// may have a path, and no query or fragment, which request URLs could not
|
||||||
|
// carry.
|
||||||
|
const isServerAddress = (text: string): boolean =>
|
||||||
|
URL.canParse(text) &&
|
||||||
|
["http:", "https:"].includes(new URL(text).protocol) &&
|
||||||
|
!/[?#]/.test(text);
|
||||||
|
|
||||||
|
export const loginCommand = async (
|
||||||
|
ctx: CliContext,
|
||||||
|
opts: { server?: string },
|
||||||
|
): Promise<number> => {
|
||||||
await init();
|
await init();
|
||||||
|
const server = opts.server ?? process.env.QUAK_SERVER;
|
||||||
|
if (server !== undefined && !isServerAddress(server)) {
|
||||||
|
ctx.log(
|
||||||
|
"fatal",
|
||||||
|
`Invalid server address "${server}": it must be an http or ` +
|
||||||
|
`https URL with no query or fragment`,
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
const email = process.env.QUAK_EMAIL ?? (await ctx.prompt("Email"));
|
const email = process.env.QUAK_EMAIL ?? (await ctx.prompt("Email"));
|
||||||
const password =
|
const password =
|
||||||
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
|
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
|
||||||
@@ -168,6 +191,8 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
|||||||
password,
|
password,
|
||||||
totp: async () => ctx.prompt("TOTP code: "),
|
totp: async () => ctx.prompt("TOTP code: "),
|
||||||
emailOTP: async () => ctx.prompt("Email verification code: "),
|
emailOTP: async () => ctx.prompt("Email verification code: "),
|
||||||
|
apiOptions:
|
||||||
|
server === undefined ? undefined : { apiOrigin: server },
|
||||||
});
|
});
|
||||||
|
|
||||||
saveSession(ctx.sessionDir, client.toJSON());
|
saveSession(ctx.sessionDir, client.toJSON());
|
||||||
|
|||||||
+23
-1
@@ -40,6 +40,9 @@ export interface ClientSnapshot {
|
|||||||
masterKey: string;
|
masterKey: string;
|
||||||
secretKey: string;
|
secretKey: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
|
// The `apiOrigin` the client was given, so a restored client talks to
|
||||||
|
// the server that issued its token. Absent means the default server.
|
||||||
|
apiOrigin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The result of a resumable enumeration. Live decrypted records and deleted
|
// The result of a resumable enumeration. Live decrypted records and deleted
|
||||||
@@ -73,6 +76,7 @@ export class Client {
|
|||||||
private readonly masterKey: Uint8Array;
|
private readonly masterKey: Uint8Array;
|
||||||
private readonly secretKey: Uint8Array;
|
private readonly secretKey: Uint8Array;
|
||||||
private readonly publicKey: Uint8Array;
|
private readonly publicKey: Uint8Array;
|
||||||
|
private readonly apiOrigin: string | undefined;
|
||||||
private loggedOut = false;
|
private loggedOut = false;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
@@ -82,6 +86,7 @@ export class Client {
|
|||||||
masterKey: Uint8Array,
|
masterKey: Uint8Array,
|
||||||
secretKey: Uint8Array,
|
secretKey: Uint8Array,
|
||||||
publicKey: Uint8Array,
|
publicKey: Uint8Array,
|
||||||
|
apiOrigin: string | undefined,
|
||||||
) {
|
) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
this.email = email;
|
this.email = email;
|
||||||
@@ -89,6 +94,7 @@ export class Client {
|
|||||||
this.masterKey = masterKey;
|
this.masterKey = masterKey;
|
||||||
this.secretKey = secretKey;
|
this.secretKey = secretKey;
|
||||||
this.publicKey = publicKey;
|
this.publicKey = publicKey;
|
||||||
|
this.apiOrigin = apiOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async login(opts: LoginOptions): Promise<Client> {
|
static async login(opts: LoginOptions): Promise<Client> {
|
||||||
@@ -130,12 +136,15 @@ export class Client {
|
|||||||
unwrapped.masterKey,
|
unwrapped.masterKey,
|
||||||
unwrapped.secretKey,
|
unwrapped.secretKey,
|
||||||
unwrapped.publicKey,
|
unwrapped.publicKey,
|
||||||
|
opts.apiOptions?.apiOrigin,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore a client from a `toJSON()` snapshot. The snapshot usually comes
|
// Restore a client from a `toJSON()` snapshot. The snapshot usually comes
|
||||||
// straight from `JSON.parse` of a file on disk, so every field is checked
|
// straight from `JSON.parse` of a file on disk, so every field is checked
|
||||||
// before use; a bad one throws an error naming it. Needs `init()` first.
|
// before use; a bad one throws an error naming it. The snapshot's
|
||||||
|
// `apiOrigin`, when it has one, is used instead of `apiOptions.apiOrigin`.
|
||||||
|
// Needs `init()` first.
|
||||||
static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client {
|
static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client {
|
||||||
const invalid = (field: string, problem: string): Error =>
|
const invalid = (field: string, problem: string): Error =>
|
||||||
new Error(`Invalid session data: ${field} ${problem}`);
|
new Error(`Invalid session data: ${field} ${problem}`);
|
||||||
@@ -152,6 +161,14 @@ export class Client {
|
|||||||
if (!Number.isInteger(s.userID)) {
|
if (!Number.isInteger(s.userID)) {
|
||||||
throw invalid("userID", "must be an integer");
|
throw invalid("userID", "must be an integer");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
s.apiOrigin !== undefined &&
|
||||||
|
(typeof s.apiOrigin !== "string" || s.apiOrigin === "")
|
||||||
|
) {
|
||||||
|
throw invalid("apiOrigin", "must be a non-empty string");
|
||||||
|
}
|
||||||
|
const apiOrigin =
|
||||||
|
(s.apiOrigin as string | undefined) ?? apiOptions?.apiOrigin;
|
||||||
const key = (field: string): Uint8Array => {
|
const key = (field: string): Uint8Array => {
|
||||||
const value = s[field];
|
const value = s[field];
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@@ -175,6 +192,7 @@ export class Client {
|
|||||||
|
|
||||||
const api = new ApiClient({
|
const api = new ApiClient({
|
||||||
...apiOptions,
|
...apiOptions,
|
||||||
|
apiOrigin,
|
||||||
authToken: s.token as string,
|
authToken: s.token as string,
|
||||||
});
|
});
|
||||||
return new Client(
|
return new Client(
|
||||||
@@ -184,6 +202,7 @@ export class Client {
|
|||||||
key("masterKey"),
|
key("masterKey"),
|
||||||
key("secretKey"),
|
key("secretKey"),
|
||||||
key("publicKey"),
|
key("publicKey"),
|
||||||
|
apiOrigin,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +241,9 @@ export class Client {
|
|||||||
masterKey: toBase64(this.masterKey),
|
masterKey: toBase64(this.masterKey),
|
||||||
secretKey: toBase64(this.secretKey),
|
secretKey: toBase64(this.secretKey),
|
||||||
publicKey: toBase64(this.publicKey),
|
publicKey: toBase64(this.publicKey),
|
||||||
|
...(this.apiOrigin === undefined
|
||||||
|
? {}
|
||||||
|
: { apiOrigin: this.apiOrigin }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -2,8 +2,9 @@
|
|||||||
* Tests for the retry options `bin/quak.ts` loads the saved session with
|
* Tests for the retry options `bin/quak.ts` loads the saved session with
|
||||||
* (`quak backup` gets the unattended ones, every other command the default),
|
* (`quak backup` gets the unattended ones, every other command the default),
|
||||||
* each with the callbacks that log its requests and retries, for `quak files`
|
* 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
|
* taking its collection ID as an optional argument, for `quak get` and
|
||||||
* `quak get-thumb` refusing `--collection`.
|
* `quak get-thumb` refusing `--collection`, and for `quak login` taking
|
||||||
|
* `--server`.
|
||||||
*
|
*
|
||||||
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
|
* 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,
|
* replaced by one that records the options it is given and reports no session,
|
||||||
@@ -117,3 +118,13 @@ describe("bin/quak.ts get and get-thumb", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The command refuses an address that is not a URL before it asks for anything
|
||||||
|
// or sends any request.
|
||||||
|
describe("bin/quak.ts login", () => {
|
||||||
|
it("hands --server to the command", async () => {
|
||||||
|
expect(await quak("login", "--server", "ente.example.com")).toMatch(
|
||||||
|
/^Invalid server address "ente\.example\.com"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+103
-4
@@ -328,6 +328,7 @@ describe("login", () => {
|
|||||||
prompts = [];
|
prompts = [];
|
||||||
vi.stubEnv("QUAK_EMAIL", "cli@example.com");
|
vi.stubEnv("QUAK_EMAIL", "cli@example.com");
|
||||||
vi.stubEnv("QUAK_PASSWORD", "hunter2");
|
vi.stubEnv("QUAK_PASSWORD", "hunter2");
|
||||||
|
vi.stubEnv("QUAK_SERVER", undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -340,12 +341,14 @@ describe("login", () => {
|
|||||||
calls.push(opts);
|
calls.push(opts);
|
||||||
return loggedIn;
|
return loggedIn;
|
||||||
});
|
});
|
||||||
expect(await loginCommand(ctx)).toBe(0);
|
expect(await loginCommand(ctx, {})).toBe(0);
|
||||||
|
|
||||||
expect(prompts).toEqual([]);
|
expect(prompts).toEqual([]);
|
||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(1);
|
||||||
expect(calls[0]!.email).toBe("cli@example.com");
|
expect(calls[0]!.email).toBe("cli@example.com");
|
||||||
expect(calls[0]!.password).toBe("hunter2");
|
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");
|
const path = join(ctx.sessionDir, "session.json");
|
||||||
expect(stderr.text).toBe(
|
expect(stderr.text).toBe(
|
||||||
"Authenticating...\n" +
|
"Authenticating...\n" +
|
||||||
@@ -356,7 +359,7 @@ describe("login", () => {
|
|||||||
|
|
||||||
it("saves the session with mode 0600 in a directory with mode 0700", async () => {
|
it("saves the session with mode 0600 in a directory with mode 0700", async () => {
|
||||||
const ctx = loginContext(async () => loggedIn);
|
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);
|
expect(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700);
|
||||||
const path = join(ctx.sessionDir, "session.json");
|
const path = join(ctx.sessionDir, "session.json");
|
||||||
@@ -370,7 +373,7 @@ describe("login", () => {
|
|||||||
code = await opts.totp!();
|
code = await opts.totp!();
|
||||||
return loggedIn;
|
return loggedIn;
|
||||||
});
|
});
|
||||||
expect(await loginCommand(ctx)).toBe(0);
|
expect(await loginCommand(ctx, {})).toBe(0);
|
||||||
|
|
||||||
expect(prompts).toEqual(["TOTP code: "]);
|
expect(prompts).toEqual(["TOTP code: "]);
|
||||||
expect(code).toBe("123456");
|
expect(code).toBe("123456");
|
||||||
@@ -380,13 +383,68 @@ describe("login", () => {
|
|||||||
const ctx = loginContext(async () => {
|
const ctx = loginContext(async () => {
|
||||||
throw new Error("HTTP 401 from server");
|
throw new Error("HTTP 401 from server");
|
||||||
});
|
});
|
||||||
expect(await loginCommand(ctx)).toBe(1);
|
expect(await loginCommand(ctx, {})).toBe(1);
|
||||||
|
|
||||||
expect(stderr.text).toBe(
|
expect(stderr.text).toBe(
|
||||||
"Authenticating...\nLogin failed: HTTP 401 from server\n",
|
"Authenticating...\nLogin failed: HTTP 401 from server\n",
|
||||||
);
|
);
|
||||||
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// These use a real client read from the session file, over a fake API that
|
// These use a real client read from the session file, over a fake API that
|
||||||
@@ -1851,6 +1909,47 @@ describe("an account with many albums", () => {
|
|||||||
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
|
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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"]);
|
||||||
|
});
|
||||||
|
|
||||||
// `files` first fills the cache, so `get` waits for a refresh. No album has
|
// `files` first fills the cache, so `get` waits for a refresh. No album has
|
||||||
// changed since, so the refresh asks for no album's files.
|
// changed since, so the refresh asks for no album's files.
|
||||||
it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
|
it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ describe("Client.toJSON", () => {
|
|||||||
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
|
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", () => {
|
||||||
|
const apiOrigin = "https://ente.example.com";
|
||||||
|
const client = Client.fromJSON(validSnapshot(), { apiOrigin });
|
||||||
|
expect(client.toJSON().apiOrigin).toBe(apiOrigin);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws instead of emitting a snapshot without a token", () => {
|
it("throws instead of emitting a snapshot without a token", () => {
|
||||||
const client = Client.fromJSON(validSnapshot());
|
const client = Client.fromJSON(validSnapshot());
|
||||||
client.getApiClient().clearAuthToken();
|
client.getApiClient().clearAuthToken();
|
||||||
@@ -77,6 +91,8 @@ describe("Client.fromJSON", () => {
|
|||||||
["publicKey", undefined],
|
["publicKey", undefined],
|
||||||
["publicKey", "not base64!"],
|
["publicKey", "not base64!"],
|
||||||
["publicKey", shortKey],
|
["publicKey", shortKey],
|
||||||
|
["apiOrigin", 7],
|
||||||
|
["apiOrigin", ""],
|
||||||
])("rejects %s = %j, naming the field", (field, value) => {
|
])("rejects %s = %j, naming the field", (field, value) => {
|
||||||
const snapshot: Record<string, unknown> = { ...validSnapshot() };
|
const snapshot: Record<string, unknown> = { ...validSnapshot() };
|
||||||
snapshot[field] = value;
|
snapshot[field] = value;
|
||||||
@@ -85,6 +101,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) => {
|
it.each([null, "a string", 42])("rejects a non-object %j", (value) => {
|
||||||
expect(() => Client.fromJSON(value)).toThrow(
|
expect(() => Client.fromJSON(value)).toThrow(
|
||||||
/^Invalid session data: not a JSON object/,
|
/^Invalid session data: not a JSON object/,
|
||||||
|
|||||||
Reference in new issue
Block a user