256 lines
8.3 KiB
TypeScript
256 lines
8.3 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { EventEmitter } from "node:events";
|
|
import { spawnSync } from "node:child_process";
|
|
import { Writable } from "node:stream";
|
|
import {
|
|
createLoginPrompt,
|
|
interactiveLogin,
|
|
type LoginPrompt,
|
|
} from "../../command/auth";
|
|
import { KuberApiError } from "../../lib/api";
|
|
import type { KuberSession } from "../../lib/session";
|
|
|
|
const session: KuberSession = {
|
|
token: "test-token",
|
|
expiresAt: "2099-01-01T00:00:00Z",
|
|
user: { username: "alice", roles: ["user"] },
|
|
};
|
|
|
|
async function start(prompt: LoginPrompt) {
|
|
const ready = new Promise<void>((resolve) => {
|
|
(prompt as unknown as EventEmitter).once("run", resolve);
|
|
});
|
|
const result = prompt.run();
|
|
await ready;
|
|
return { result };
|
|
}
|
|
|
|
async function credentials(
|
|
prompt: LoginPrompt,
|
|
username: string,
|
|
password: string,
|
|
) {
|
|
for (const choice of prompt.choices) {
|
|
choice.input = choice.value =
|
|
choice.name === "username" ? username : password;
|
|
}
|
|
await prompt.render();
|
|
}
|
|
|
|
describe("BasicAuth login", () => {
|
|
test("authenticates once against the supplied API and preserves persistence", async () => {
|
|
const calls: unknown[][] = [];
|
|
const prompt = createLoginPrompt(
|
|
"alice",
|
|
true,
|
|
async (...args) => {
|
|
calls.push(args);
|
|
return session;
|
|
},
|
|
{ show: false },
|
|
);
|
|
const { result } = await start(prompt);
|
|
expect(prompt.values.username).toBe("alice");
|
|
await credentials(prompt, " alice ", "sensitive-password");
|
|
await Promise.all([prompt.submit(), prompt.submit()]);
|
|
expect(await result).toEqual(session);
|
|
expect(calls).toEqual([["alice", "sensitive-password", true]]);
|
|
expect(prompt.values.password).toBe("");
|
|
});
|
|
|
|
test("incorrect authentication closes the prompt and retains the CLI API error", async () => {
|
|
const error = new KuberApiError("Incorrect credentials", 401);
|
|
const prompt = createLoginPrompt(
|
|
"",
|
|
false,
|
|
async () => {
|
|
throw error;
|
|
},
|
|
{ show: false },
|
|
);
|
|
const { result } = await start(prompt);
|
|
const rejected = result.catch((error: unknown) => error);
|
|
await credentials(prompt, "alice", "secret");
|
|
await prompt.submit();
|
|
expect(await rejected).toBe(error);
|
|
expect(prompt.state.closed).toBe(true);
|
|
expect(prompt.values.password).toBe("");
|
|
});
|
|
|
|
test("cancellation rejects without calling authentication", async () => {
|
|
let calls = 0;
|
|
const prompt = createLoginPrompt(
|
|
"",
|
|
false,
|
|
async () => {
|
|
calls++;
|
|
return session;
|
|
},
|
|
{ show: false },
|
|
);
|
|
const { result } = await start(prompt);
|
|
const rejected = result.catch((error: unknown) => error);
|
|
await credentials(prompt, "alice", "secret");
|
|
await Promise.all([prompt.cancel(), prompt.cancel()]);
|
|
expect(await rejected).toMatchObject({ message: "Login cancelled" });
|
|
expect(calls).toBe(0);
|
|
expect(prompt.values.password).toBe("");
|
|
});
|
|
|
|
test("preserves non-cancellation prompt initialization errors", async () => {
|
|
const error = new Error("terminal initialization failed");
|
|
const prompt = createLoginPrompt("", false, async () => session, {
|
|
show: false,
|
|
});
|
|
const enquirerPrototype = Object.getPrototypeOf(
|
|
Object.getPrototypeOf(prompt),
|
|
) as { run: () => Promise<unknown> };
|
|
const originalRun = enquirerPrototype.run;
|
|
enquirerPrototype.run = () => Promise.reject(error);
|
|
try {
|
|
await expect(prompt.run()).rejects.toBe(error);
|
|
} finally {
|
|
enquirerPrototype.run = originalRun;
|
|
}
|
|
});
|
|
|
|
test.skipIf(!Bun.which("python3"))(
|
|
"Ctrl+C in a real terminal cancels cleanly without authenticating",
|
|
() => {
|
|
const script = `import os, pty, select, subprocess, sys, time
|
|
master, slave = pty.openpty()
|
|
code = 'import { createLoginPrompt } from "./command/auth.ts"; const p = createLoginPrompt("alice", true, async () => { throw Error("API must not be called") }); p.run().catch(error => console.error(error.message))'
|
|
child = subprocess.Popen([sys.argv[1], "--eval", code], stdin=slave, stdout=slave, stderr=slave)
|
|
os.close(slave)
|
|
time.sleep(0.6)
|
|
os.write(master, b"\\x03")
|
|
output = b""
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline and child.poll() is None:
|
|
ready, _, _ = select.select([master], [], [], 0.1)
|
|
if ready:
|
|
try: output += os.read(master, 4096)
|
|
except OSError: break
|
|
if child.poll() is None: child.kill()
|
|
child.wait()
|
|
sys.stdout.buffer.write(output)
|
|
sys.exit(0 if b"Login cancelled" in output and b"ERR_USE_AFTER_CLOSE" not in output and b"API must not be called" not in output else 1)`;
|
|
const result = spawnSync("python3", ["-c", script, process.execPath], {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
timeout: 10_000,
|
|
});
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("Login cancelled");
|
|
expect(result.stdout).not.toContain("ERR_USE_AFTER_CLOSE");
|
|
expect(result.stdout).not.toContain("API must not be called");
|
|
},
|
|
);
|
|
|
|
test.each(["submit", "cancel"] as const)(
|
|
"masks password while editing and never prints it on %s",
|
|
async (action) => {
|
|
let output = "";
|
|
const stdout = new Writable({
|
|
write(chunk, _encoding, callback) {
|
|
output += chunk.toString();
|
|
callback();
|
|
},
|
|
}) as unknown as NodeJS.WriteStream;
|
|
const prompt = createLoginPrompt("", false, async () => session, {
|
|
show: false,
|
|
stdout,
|
|
});
|
|
const { result } = await start(prompt);
|
|
const settled = result.catch(() => undefined);
|
|
await credentials(prompt, "alice", "never-print-this");
|
|
// show:false disables terminal listeners; enable writes only after initialization.
|
|
(prompt as unknown as { state: { show: boolean } }).state.show = true;
|
|
await prompt.render();
|
|
expect(output).toContain("password");
|
|
expect(output).toContain("*".repeat("never-print-this".length));
|
|
expect(output).not.toContain("never-print-this");
|
|
await prompt[action]();
|
|
await settled;
|
|
expect(output).not.toContain("never-print-this");
|
|
},
|
|
);
|
|
|
|
test("empty username fails before contacting authentication", async () => {
|
|
let calls = 0;
|
|
const prompt = createLoginPrompt(
|
|
"",
|
|
false,
|
|
async () => {
|
|
calls++;
|
|
return session;
|
|
},
|
|
{ show: false },
|
|
);
|
|
const { result } = await start(prompt);
|
|
const rejected = result.catch((error: unknown) => error);
|
|
await prompt.submit();
|
|
expect(await rejected).toMatchObject({ message: "Username is required" });
|
|
expect(calls).toBe(0);
|
|
});
|
|
|
|
test("noninteractive onboarding and login fail before creating a prompt", async () => {
|
|
let calls = 0;
|
|
await expect(
|
|
interactiveLogin("alice", true, {
|
|
isTTY: false,
|
|
prompt: () => {
|
|
calls++;
|
|
throw new Error("Unexpected prompt");
|
|
},
|
|
}),
|
|
).rejects.toThrow("Login requires an interactive terminal");
|
|
expect(calls).toBe(0);
|
|
});
|
|
|
|
test("onboarding returns the session using the shared prompt", async () => {
|
|
const result = await interactiveLogin(" alice ", true, {
|
|
isTTY: true,
|
|
prompt: (username, persistent) => {
|
|
expect(username).toBe("alice");
|
|
expect(persistent).toBe(true);
|
|
const prompt = createLoginPrompt(
|
|
username,
|
|
persistent,
|
|
async () => session,
|
|
{ show: false },
|
|
);
|
|
(prompt as unknown as EventEmitter).once("run", () => {
|
|
void prompt.submit();
|
|
});
|
|
return prompt;
|
|
},
|
|
});
|
|
expect(result).toEqual(session);
|
|
});
|
|
|
|
test("interactive login persists by default and accepts temporary-session override", async () => {
|
|
const persistence: boolean[] = [];
|
|
for (const override of [undefined, false] as const) {
|
|
await interactiveLogin("alice", override, {
|
|
isTTY: true,
|
|
prompt: (_username, persistent) => {
|
|
persistence.push(persistent ?? true);
|
|
const prompt = createLoginPrompt(
|
|
"alice",
|
|
persistent ?? true,
|
|
async () => session,
|
|
{ show: false },
|
|
);
|
|
(prompt as unknown as EventEmitter).once("run", () => {
|
|
void prompt.submit();
|
|
});
|
|
return prompt;
|
|
},
|
|
});
|
|
}
|
|
expect(persistence).toEqual([true, false]);
|
|
});
|
|
});
|