Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6520e73
fix(login): report an existing session instead of re-prompting
kojiwakayama Aug 15, 2026
4d71f81
Keep bare login aligned with stored sessions
kojiwakayama Aug 15, 2026
53f2c7c
fix(login): say when an existing session came from the environment
kwakayama Aug 15, 2026
f635412
fix(login): bound the existing-session preflight with a deadline
kojiwakayama Aug 15, 2026
bbc148f
Bound login preflight to one credential deadline
kojiwakayama Aug 15, 2026
cf5c736
Keep login output on one declared contract
kojiwakayama Aug 15, 2026
4637eb3
Keep environment-backed account switching truthful
kojiwakayama Aug 15, 2026
e1474e2
Keep environment login precedence truthful
kojiwakayama Aug 15, 2026
56e7723
Describe environment precedence without erasing stored state
kojiwakayama Aug 15, 2026
8d5f92d
Keep credential precedence explicit in login guidance
kojiwakayama Aug 15, 2026
80f9ec6
Keep unverified credentials out of login claims
kojiwakayama Aug 15, 2026
dc149ce
Honor login JSON output for valid existing sessions
kojiwakayama Aug 15, 2026
4fec6c9
Keep JSON login failures machine-readable
kojiwakayama Aug 15, 2026
3444f67
Preserve login preflight network failures
kojiwakayama Aug 15, 2026
cf3e1c9
Preserve login JSON validation failure causes
kojiwakayama Aug 15, 2026
33770c9
Preserve login JSON validation failure classes
kojiwakayama Aug 15, 2026
d96d0e5
Preserve environment login validation priority
kojiwakayama Aug 15, 2026
32bdbf6
Preserve malformed environment login failures
kojiwakayama Aug 15, 2026
8cc8b84
Keep JSON login validation outcomes precise
kojiwakayama Aug 15, 2026
5ea395b
Keep explicit JSON login failures routable
kojiwakayama Aug 15, 2026
dbf7b7d
Keep shell token precedence visible in login
kojiwakayama Aug 15, 2026
e863559
Honor dotenv token precedence in login
kojiwakayama Aug 15, 2026
d9edc39
Keep stored-token precedence over dotenv login
kojiwakayama Aug 15, 2026
0b653d5
Keep login precedence mergeable with main
kojiwakayama Aug 15, 2026
590ee3b
Honor config-file token precedence in login
kojiwakayama Aug 15, 2026
0d5b04e
Honor config API metadata during login preflight
kojiwakayama Aug 15, 2026
854cc5f
Respect configured API URL for login preflight
kojiwakayama Aug 15, 2026
de23a1e
Keep login preflight bounded to JSON config
kojiwakayama Aug 15, 2026
99c1df5
Clarify config-token account switching
kojiwakayama Aug 15, 2026
84a2297
Stop login under rejected authoritative tokens
kojiwakayama Aug 15, 2026
4cf0e39
Stop replacement login after authoritative validation outages
kojiwakayama Aug 15, 2026
bddb5e9
Propagate unexpected strict credential failures
kojiwakayama Aug 15, 2026
b5f2b63
Align auth consumers with config-file credentials
kojiwakayama Aug 15, 2026
eabcfff
Clarify login JSON-mode method limits
kojiwakayama Aug 15, 2026
b402e05
Keep CLI authentication scoped to the command target
kojiwakayama Aug 15, 2026
9f00d33
Preserve dotenv fallback after stored session expiry
kojiwakayama Aug 15, 2026
65a38c2
Let init deploy with its validated project credential
kojiwakayama Aug 15, 2026
9559ebf
Keep authentication fallbacks usable by target commands
kojiwakayama Aug 15, 2026
029df04
Preserve stored sessions through validation outages
kojiwakayama Aug 16, 2026
840251c
Keep unavailable stored credentials authoritative
kojiwakayama Aug 16, 2026
65d5390
Prevent login help from promising ineffective account switches
kojiwakayama Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 280 additions & 0 deletions cli/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,286 @@ describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () =>
resetInteractiveMode();
}
});

it("reports an existing valid session instead of asking for a token again", async () => {
// `veryfront login` is step 1 of the documented deploy journey. Run by an
// already-authenticated developer it prompted for a token and exited 1,
// which makes the first documented step fail for the common case.
const originalFetch = globalThis.fetch;
const originalLog = console.log;
const output: string[] = [];
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
globalThis.fetch = (() =>
Promise.resolve(
Response.json({ id: "user-123", email: "test@example.com" }),
)) as typeof fetch;
console.log = (message?: unknown) => output.push(String(message));

const { login } = await import("./login.ts");
const result = await login(undefined, testEnv);

assertEquals(result, { id: "user-123", email: "test@example.com" });
const printed = output.join("\n");
assertStringIncludes(printed, "test@example.com");
// Must not have fallen through to the token prompt.
assertEquals(printed.includes("Enter your API token"), false);
// Never echo the stored credential.
assertEquals(printed.includes("stored-valid-token"), false);
} finally {
console.log = originalLog;
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("keeps existing-session output human-readable without a login JSON contract", async () => {
const originalFetch = globalThis.fetch;
const originalLog = console.log;
const output: string[] = [];
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
globalThis.fetch = (() =>
Promise.resolve(
Response.json({ id: "user-123", email: "test@example.com" }),
)) as typeof fetch;
console.log = (message?: unknown) => output.push(String(message));

const { setJsonMode } = await import("../shared/json-output.ts");
const { login } = await import("./login.ts");
setJsonMode(true);

const result = await login(undefined, testEnv);

// Login does not advertise structured output. Keep this result on the
// same human path as missing, invalid, and timed-out credentials instead
// of exposing JSON for only the already-authenticated outcome.
assertEquals(result, { id: "user-123", email: "test@example.com" });
assertStringIncludes(output.join("\n"), "Already logged in as test@example.com");
assertEquals(output.join("\n").includes('"command": "login"'), false);
} finally {
const { setJsonMode } = await import("../shared/json-output.ts");
setJsonMode(false);
console.log = originalLog;
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("falls back to a valid stored session when the env token is invalid", async () => {
const originalFetch = globalThis.fetch;
const originalLog = console.log;
const output: string[] = [];
const requestedAuth: string[] = [];
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
const auth = String(new Headers(init?.headers).get("authorization") ?? "");
requestedAuth.push(auth);
if (auth === "Bearer env-invalid-token") {
return Promise.resolve(new Response(null, { status: 401 }));
}
return Promise.resolve(
Response.json({ id: "user-123", email: "stored@example.com" }),
);
}) as typeof fetch;
console.log = (message?: unknown) => output.push(String(message));

const { login } = await import("./login.ts");
const env = { ...testEnv, apiToken: "env-invalid-token" };
const result = await login(undefined, env);

assertEquals(result, { id: "user-123", email: "stored@example.com" });
assertEquals(requestedAuth, [
"Bearer env-invalid-token",
"Bearer stored-valid-token",
]);
const printed = output.join("\n");
assertStringIncludes(printed, "stored@example.com");
assertEquals(printed.includes("Enter your API token"), false);
assertEquals(printed.includes("stored-valid-token"), false);
} finally {
console.log = originalLog;
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("says an environment session came from the environment, since nothing is stored", async () => {
// The variable is commonly set by a `.env` in the working directory the
// developer has forgotten about — the case `whoami` now names. Reporting a
// bare "already authenticated" implies `login` stored something, when the
// session actually ends at the directory boundary.
const originalFetch = globalThis.fetch;
const originalLog = console.log;
const output: string[] = [];

try {
setNonInteractive(true);
globalThis.fetch = (() =>
Promise.resolve(
new Response(JSON.stringify({ data: [], page_info: {} }), {
status: 200,
headers: { "content-type": "application/json" },
}),
)) as typeof fetch;
console.log = (message?: unknown) => output.push(String(message));

const { login } = await import("./login.ts");
const result = await login(undefined, { ...testEnv, apiToken: "vf_env_only" });

assertEquals(result, { authenticated: true, type: "apiKey" });
const printed = output.join("\n");
assertStringIncludes(printed, "VERYFRONT_API_TOKEN");
assertStringIncludes(printed, "no stored login");
// Nothing was persisted, and the credential is never echoed.
assertEquals(await readToken(testEnv), null);
assertEquals(printed.includes("vf_env_only"), false);
} finally {
console.log = originalLog;
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("does not claim the environment when the session is a stored login", async () => {
const originalFetch = globalThis.fetch;
const originalLog = console.log;
const output: string[] = [];
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
globalThis.fetch = (() =>
Promise.resolve(
Response.json({ id: "user-123", email: "test@example.com" }),
)) as typeof fetch;
console.log = (message?: unknown) => output.push(String(message));

const { login } = await import("./login.ts");
await login(undefined, testEnv);

const printed = output.join("\n");
assertEquals(printed.includes("no stored login"), false);
assertEquals(printed.includes("VERYFRONT_API_TOKEN"), false);
} finally {
console.log = originalLog;
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("does not hang when the API accepts the connection but never answers", async () => {
// The existing-session preflight is best-effort. Without a deadline a
// stalled API blocks bare `login` forever, so it never reaches the normal
// sign-in flow this change promises as the fallback.
const originalFetch = globalThis.fetch;
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
const { __setExistingSessionTimeoutForTests } = await import("./login.ts");
__setExistingSessionTimeoutForTests(50);

// Models a genuinely stalled request: the connection is accepted and the
// promise settles only once the abort signal fires. If the signal is not
// threaded through, this never resolves and the race below reports it.
globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("The signal has been aborted", "AbortError"));
});
})) as typeof fetch;

const { login } = await import("./login.ts");
const outcome = await Promise.race([
login(undefined, testEnv),
new Promise((resolve) =>
setTimeout(() => resolve("TIMED_OUT"), 3000)
),
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

assertEquals(outcome, null);
} finally {
const { __setExistingSessionTimeoutForTests } = await import("./login.ts");
__setExistingSessionTimeoutForTests();
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("shares one preflight deadline across environment and stored credentials", async () => {
const originalFetch = globalThis.fetch;
const signals: AbortSignal[] = [];
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
const { __setExistingSessionTimeoutForTests } = await import("./login.ts");
__setExistingSessionTimeoutForTests(50);

globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) =>
new Promise((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
return;
}
signals.push(signal);
const rejectAbort = () =>
reject(new DOMException("The signal has been aborted", "AbortError"));
if (signal.aborted) {
rejectAbort();
} else signal.addEventListener("abort", rejectAbort, { once: true });
})) as typeof fetch;

const { login } = await import("./login.ts");
const result = await login(undefined, { ...testEnv, apiToken: "env-token" });

assertEquals(result, null);
assertEquals(signals.length, 2);
assertEquals(signals[0], signals[1]);
} finally {
const { __setExistingSessionTimeoutForTests } = await import("./login.ts");
__setExistingSessionTimeoutForTests();
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("still re-authenticates when a method is explicitly requested", async () => {
// Switching accounts must stay possible: an explicit method is intent to
// sign in again, so the existing session must not short-circuit it.
const originalFetch = globalThis.fetch;
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
globalThis.fetch = (() =>
Promise.resolve(
Response.json({ id: "user-123", email: "test@example.com" }),
)) as typeof fetch;

const { login } = await import("./login.ts");
assertEquals(await login("token", testEnv), null);
} finally {
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});
});

describe("OAuth state", () => {
Expand Down
Loading