181 lines
5.4 KiB
TypeScript
181 lines
5.4 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { EventEmitter } from "node:events";
|
|
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 prompt.cancel();
|
|
expect(await rejected).toMatchObject({ message: "Login cancelled" });
|
|
expect(calls).toBe(0);
|
|
expect(prompt.values.password).toBe("");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|