diff --git a/cli/auth/exit-code.integration.test.ts b/cli/auth/exit-code.integration.test.ts index f76196acae..16c9c286e6 100644 --- a/cli/auth/exit-code.integration.test.ts +++ b/cli/auth/exit-code.integration.test.ts @@ -70,10 +70,45 @@ describe("cli/auth exit codes", () => { assertEquals(result.code, 1); }); - // `login --provider anthropic|openai` also exits 1 on failure (see cli/router.ts), - // but it cannot be driven from here: `promptPassword` calls `Deno.stdin.setRaw()`, - // which throws ENODEV on a non-TTY stdin. A subprocess test would exit 1 from that - // crash rather than from the failure path, and would pass with the fix reverted. + it("login --json --token exits as a structured usage error", async () => { + const result = await runUnauthenticated(["login", "--json", "--token"]); + + assertEquals(result.code, 2); + assertEquals(JSON.parse(result.stdout), { + success: false, + command: "login", + error: { + code: "USAGE_ERROR", + slug: "invalid-arguments", + registrySlug: "invalid-argument", + message: "Explicit login methods are not supported with --json.", + }, + }); + assertEquals(result.stderr, ""); + }); + + it("login --json --provider exits as a structured usage error without prompting", async () => { + const result = await runUnauthenticated([ + "login", + "--json", + "--provider", + "anthropic", + ]); + + assertEquals(result.code, 2); + assertEquals(JSON.parse(result.stdout), { + success: false, + command: "login", + error: { + code: "USAGE_ERROR", + slug: "invalid-arguments", + registrySlug: "invalid-argument", + message: "Explicit login methods are not supported with --json.", + }, + }); + assertEquals(result.stdout.includes("API key"), false); + assertEquals(result.stderr, ""); + }); it("whoami still exits zero when a credential validates", async () => { const server = Deno.serve( diff --git a/cli/auth/login.test.ts b/cli/auth/login.test.ts index 9403413a46..b368357212 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -27,6 +27,28 @@ import { import type { UserInfo } from "./login.ts"; import { resetInteractiveMode, setNonInteractive } from "../shared/interactive.ts"; +const STORED_CREDENTIAL_OUTAGES: ReadonlyArray<{ + name: string; + expectedLoginSlug: string; + respond: () => Promise; +}> = [ + { + name: "a network failure", + expectedLoginSlug: "network-error", + respond: () => Promise.reject(new TypeError("network unavailable")), + }, + { + name: "a timeout", + expectedLoginSlug: "timeout-error", + respond: () => Promise.reject(new DOMException("timed out", "TimeoutError")), + }, + { + name: "a 503 response", + expectedLoginSlug: "api-client-error", + respond: () => Promise.resolve(new Response(null, { status: 503 })), + }, +]; + describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () => { let tempDir = ""; let testEnv: EnvironmentConfig; @@ -221,6 +243,48 @@ describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () => } }); + it("rethrows unexpected token validation failures in strict mode", async () => { + const originalFetch = globalThis.fetch; + + try { + globalThis.fetch = (() => + Promise.reject(new Error("unexpected token failure"))) as typeof fetch; + const { validateCredential } = await import("./login.ts"); + + await assertRejects( + () => + validateCredential("session-token", testEnv, { + throwOnCredentialValidationUnavailable: true, + }), + Error, + "unexpected token failure", + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("rethrows unexpected API key validation failures in strict mode", async () => { + const originalFetch = globalThis.fetch; + + try { + globalThis.fetch = (() => + Promise.reject(new Error("unexpected API key failure"))) as typeof fetch; + const { validateCredential } = await import("./login.ts"); + + await assertRejects( + () => + validateCredential("vf_test_secret", testEnv, { + throwOnCredentialValidationUnavailable: true, + }), + Error, + "unexpected API key failure", + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("reports an API key as authenticated in whoami JSON without exposing the key", async () => { const originalFetch = globalThis.fetch; const originalLog = console.log; @@ -324,132 +388,2736 @@ describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () => } }); - it("does not prompt for an auth method or token in non-interactive mode", async () => { - const { login } = await import("./login.ts"); + for ( + const { name, rejectValidation } of [ + { + name: "a network failure", + rejectValidation: () => new TypeError("network unavailable"), + }, + { + name: "a timeout", + rejectValidation: () => new DOMException("timed out", "TimeoutError"), + }, + { + name: "a 503 response", + rejectValidation: null, + }, + ] + ) { + it(`retains a stored session after ${name}`, async () => { + const originalFetch = globalThis.fetch; + await saveToken("stored-unavailable-token", testEnv); + + try { + setNonInteractive(true); + globalThis.fetch = (rejectValidation + ? (() => + Promise.reject(rejectValidation())) + : (() => Promise.resolve(new Response(null, { status: 503 })))) as typeof fetch; + + const { ensureAuthenticated } = await import("./login.ts"); + assertEquals(await ensureAuthenticated(testEnv), null); + assertEquals(await readToken(testEnv), "stored-unavailable-token"); + } finally { + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + } + + it("retains a stored session and propagates unexpected validation failures", async () => { + const originalFetch = globalThis.fetch; + await saveToken("stored-unexpected-token", testEnv); try { setNonInteractive(true); - assertEquals(await login(undefined, testEnv), null); - assertEquals(await login("token", testEnv), null); + globalThis.fetch = (() => + Promise.reject(new Error("unexpected validation failure"))) as typeof fetch; + + const { ensureAuthenticated } = await import("./login.ts"); + await assertRejects( + () => ensureAuthenticated(testEnv), + Error, + "unexpected validation failure", + ); + assertEquals(await readToken(testEnv), "stored-unexpected-token"); } finally { + globalThis.fetch = originalFetch; resetInteractiveMode(); + await safeDeleteToken(); } }); - }); - describe("OAuth state", () => { - it("should generate distinct OAuth state values", async () => { - const loginModule = await import("./login.ts") as typeof import("./login.ts") & { - createOAuthState?: () => string; - }; + it("deletes stored sessions rejected with 401 or 403", async () => { + const originalFetch = globalThis.fetch; - assertEquals(typeof loginModule.createOAuthState, "function"); + try { + setNonInteractive(true); + const { ensureAuthenticated } = await import("./login.ts"); + for (const status of [401, 403]) { + await saveToken(`stored-rejected-${status}`, testEnv); + globalThis.fetch = (() => + Promise.resolve(new Response(null, { status }))) as typeof fetch; + + assertEquals(await ensureAuthenticated(testEnv), null); + assertEquals(await readToken(testEnv), null); + } + } finally { + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); - const first = loginModule.createOAuthState!(); - const second = loginModule.createOAuthState!(); + it("falls back to a valid project dotenv credential after a rejected stored session", async () => { + const originalFetch = globalThis.fetch; + const originalToken = getEnv("VERYFRONT_API_TOKEN"); + const envDir = await makeTempDir({ prefix: "ensure-dotenv-fallback-" }); + const requestedAuth: string[] = []; + await saveToken("stored-invalid-token", testEnv); - assertEquals(first.length >= 32, true); - assertEquals(second.length >= 32, true); - assertEquals(first !== second, true); + try { + deleteEnv("VERYFRONT_API_TOKEN"); + await Deno.writeTextFile( + `${envDir}/.env`, + "VERYFRONT_API_TOKEN=env-file-valid-token\n", + ); + const { __resetEnvLoaderForTests, loadEnv } = await import( + "veryfront/utils/env-loader" + ); + __resetEnvLoaderForTests(); + await loadEnv({ cwd: envDir }); + setNonInteractive(true); + + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer stored-invalid-token") { + return Promise.resolve(new Response(null, { status: 401 })); + } + return Promise.resolve( + Response.json({ id: "env-user", email: "env@example.com" }), + ); + }) as typeof fetch; + + const { ensureAuthenticated } = await import("./login.ts"); + const credential = await ensureAuthenticated({ + ...testEnv, + apiToken: "env-file-valid-token", + }); + + assertEquals(credential, { id: "env-user", email: "env@example.com" }); + assertEquals(requestedAuth, [ + "Bearer stored-invalid-token", + "Bearer env-file-valid-token", + ]); + assertEquals(await readToken(testEnv), null); + } finally { + const { __resetEnvLoaderForTests } = await import("veryfront/utils/env-loader"); + __resetEnvLoaderForTests(); + if (originalToken) setEnv("VERYFRONT_API_TOKEN", originalToken); + else deleteEnv("VERYFRONT_API_TOKEN"); + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(envDir, { recursive: true }); + } }); - it("should include state in the OAuth authorization URL", async () => { - const loginModule = await import("./login.ts") as typeof import("./login.ts") & { - createOAuthAuthorizationUrl?: ( - provider: "google" | "github" | "microsoft", - callbackUrl: string, - state: string, - env?: EnvironmentConfig, - ) => string; - }; + it("uses a valid veryfront.json API key without executing module config", async () => { + const originalFetch = globalThis.fetch; + const projectDir = await makeTempDir({ prefix: "ensure-config-token-" }); + const markerPath = `${projectDir}/executed-module-config`; + let requestedUrl = ""; + let requestedAuth = ""; - assertEquals(typeof loginModule.createOAuthAuthorizationUrl, "function"); + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.config.ts`, + `await Deno.writeTextFile(${ + JSON.stringify(markerPath) + }, "executed");\nexport default { projectSlug: "module-project" };\n`, + ); + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ + apiToken: "vf_config_secret", + apiUrl: "https://config-auth.example.test", + projectSlug: "json-project", + }) + "\n", + ); + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + requestedUrl = String(input); + requestedAuth = String(new Headers(init?.headers).get("Authorization")); + return Promise.resolve( + new Response(JSON.stringify({ data: [], page_info: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + }) as typeof fetch; - const authUrl = loginModule.createOAuthAuthorizationUrl!( - "github", - "http://localhost:3456/callback", - "expected-state", - createTestEnvironmentConfig({ - apiBaseUrl: "https://auth.example.test", - apiUrl: undefined, - }), - ); - const parsed = new URL(authUrl); - const redirectUri = parsed.searchParams.get("redirect_uri"); + const { ensureAuthenticated } = await import("./login.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const credential = await withCwd(projectDir, () => ensureAuthenticated(testEnv)); - assertEquals(parsed.origin, "https://auth.example.test"); - assertEquals(parsed.pathname, "/auth/github"); - assertEquals(redirectUri, "http://localhost:3456/callback?state=expected-state"); - assertEquals(parsed.searchParams.get("state"), "expected-state"); - assertEquals(new URL(redirectUri!).searchParams.get("state"), "expected-state"); + assertEquals(credential, { authenticated: true, type: "apiKey" }); + assertEquals(requestedUrl, "https://config-auth.example.test/projects?limit=1"); + assertEquals(requestedAuth, "Bearer vf_config_secret"); + assertEquals(await Deno.stat(markerPath).then(() => true).catch(() => false), false); + } finally { + globalThis.fetch = originalFetch; + await Deno.remove(projectDir, { recursive: true }); + } }); - it("prints a manual login URL when the browser cannot be opened", async () => { + it("keeps an existing-session login scoped to the requested project directory", async () => { + const originalFetch = globalThis.fetch; const originalLog = console.log; + const cwdDir = await makeTempDir({ prefix: "ensure-login-cwd-config-" }); + const targetDir = await makeTempDir({ prefix: "ensure-login-target-" }); const output: string[] = []; - const spinnerEvents: string[] = []; + let requests = 0; try { - console.log = (...args: unknown[]) => output.push(args.map(String).join(" ")); - const { openOAuthLogin } = await import("./login.ts"); - const opened = await openOAuthLogin( - "https://auth.example.test/login?state=expected-state", - { - update: (text) => spinnerEvents.push(`update:${text}`), - success: (text) => spinnerEvents.push(`success:${text ?? ""}`), - error: (text) => spinnerEvents.push(`error:${text ?? ""}`), - stop: () => spinnerEvents.push("stop"), - }, - () => Promise.reject(new Error("browser unavailable")), + await Deno.writeTextFile( + `${cwdDir}/veryfront.json`, + JSON.stringify({ + apiToken: "vf_cwd_config", + apiUrl: "https://cwd-config.example.test", + projectSlug: "cwd-project", + }) + "\n", ); + globalThis.fetch = (() => { + requests++; + return Promise.resolve( + Response.json({ data: [], page_info: {} }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); - assertEquals(opened, false); - assertEquals(spinnerEvents, ["stop"]); - assertStringIncludes(output.join("\n"), "Could not open the browser"); - assertStringIncludes( - output.join("\n"), - "https://auth.example.test/login?state=expected-state", + const { setJsonMode } = await import("../shared/json-output.ts"); + const { login } = await import("./login.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + setJsonMode(true); + + const result = await withCwd( + cwdDir, + () => login(undefined, testEnv, targetDir), ); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(requests, 0); + assertEquals(envelope.error.slug, "authentication-required"); } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await remove(cwdDir, { recursive: true }); + await remove(targetDir, { recursive: true }); } }); - }); - describe("logout", { sanitizeOps: false, sanitizeResources: false }, () => { - it("should clear stored token", async () => { - await saveToken("test-token", testEnv); - assertEquals(await readToken(testEnv), "test-token"); + it("keeps shared config fallback scoped to the requested project directory", async () => { + const originalFetch = globalThis.fetch; + const cwdDir = await makeTempDir({ prefix: "config-auth-cwd-" }); + const targetDir = await makeTempDir({ prefix: "config-auth-target-" }); + let requests = 0; - const { logout } = await import("./login.ts"); - await logout(testEnv); + try { + await Deno.writeTextFile( + `${cwdDir}/veryfront.json`, + JSON.stringify({ + apiToken: "vf_cwd_config", + apiUrl: "https://cwd-config.example.test", + projectSlug: "cwd-project", + }) + "\n", + ); + globalThis.fetch = (() => { + requests++; + return Promise.resolve(Response.json({ data: [], page_info: {} })); + }) as typeof fetch; - assertEquals(await readToken(testEnv), null); + const { resolveConfigWithAuth } = await import("../shared/config.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + + await assertRejects( + () => withCwd(cwdDir, () => resolveConfigWithAuth(targetDir, testEnv)), + Error, + "Authentication required", + ); + assertEquals(requests, 0); + } finally { + globalThis.fetch = originalFetch; + await remove(cwdDir, { recursive: true }); + await remove(targetDir, { recursive: true }); + } }); - }); - describe("whoami", () => { - it("should use the provided token store and API URL", async () => { + it("does not prompt for an auth method or token in non-interactive mode", async () => { + const { login } = await import("./login.ts"); + + try { + setNonInteractive(true); + assertEquals(await login(undefined, testEnv), null); + assertEquals(await login("token", testEnv), null); + } finally { + 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; - let requestedUrl = ""; - await saveToken("test-token", testEnv); + const originalLog = console.log; + const output: string[] = []; + await saveToken("stored-valid-token", testEnv); try { - globalThis.fetch = ((input: string | URL | Request) => { - requestedUrl = String(input); - return Promise.resolve( + setNonInteractive(true); + globalThis.fetch = (() => + Promise.resolve( Response.json({ id: "user-123", email: "test@example.com" }), - ); - }) as typeof fetch; + )) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); - const { whoami } = await import("./login.ts"); - const env = { ...testEnv, apiBaseUrl: "https://auth.example.test", apiUrl: undefined }; - const user = await whoami(env); + const { login } = await import("./login.ts"); + const result = await login(undefined, testEnv); - assertEquals(user, { id: "user-123", email: "test@example.com" }); - assertEquals(requestedUrl, "https://auth.example.test/me"); + 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("reports an existing stored session as login JSON when JSON mode is enabled", 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); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, { id: "user-123", email: "test@example.com" }); + assertEquals(envelope.success, true); + assertEquals(envelope.command, "login"); + assertEquals(envelope.data, { + id: "user-123", + email: "test@example.com", + source: "token-store", + }); + assertEquals(output.join("\n").includes("Already logged in as test@example.com"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("explains account switching for an existing config-file login in human mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const output: string[] = []; + const projectDir = await makeTempDir({ prefix: "login-config-human-" }); + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ apiToken: "config-valid-token", projectSlug: "test-project" }) + "\n", + ); + + globalThis.fetch = (() => + Promise.resolve( + Response.json({ id: "config-user", email: "config@example.com" }), + )) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const { login } = await import("./login.ts"); + + const result = await withCwd(projectDir, () => login(undefined, testEnv)); + const printed = output.join("\n"); + + assertEquals(result, { id: "config-user", email: "config@example.com" }); + assertStringIncludes(printed, "Already logged in as config@example.com"); + assertStringIncludes(printed, "Using apiToken from veryfront.json"); + assertStringIncludes( + printed, + "Remove or replace apiToken in veryfront.json before signing in with another method.", + ); + assertEquals(printed.includes("config-valid-token"), false); + } finally { + console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(projectDir, { recursive: true }); + } + }); + + it("reports missing credentials as login JSON without prompting", async () => { + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + + try { + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope, { + success: false, + command: "login", + error: { + code: "AUTHENTICATION_ERROR", + slug: "authentication-required", + registrySlug: "authentication-required", + message: "Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.", + }, + }); + assertEquals(output.join("\n").includes("Enter your API token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("reports rejected stored credentials as login JSON without prompting", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + await saveToken("stored-invalid-token", testEnv); + + try { + globalThis.fetch = (() => + Promise.resolve(new Response(null, { status: 401 }))) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.success, false); + assertEquals(envelope.command, "login"); + assertEquals(envelope.error.slug, "authentication-required"); + assertEquals(envelope.error.registrySlug, "authentication-required"); + assertEquals(output.join("\n").includes("Enter your API token"), false); + assertEquals(output.join("\n").includes("stored-invalid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("reports timed-out credential validation as login JSON without prompting", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + const { __setExistingSessionTimeoutForTests } = await import("./login.ts"); + __setExistingSessionTimeoutForTests(50); + globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(init.signal?.reason); + }); + })) as typeof fetch; + console.log = (message?: unknown) => + output.push(String(message)); + console.error = (message?: unknown) => errors.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); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.success, false); + assertEquals(envelope.command, "login"); + assertEquals(envelope.error, { + code: "TIMEOUT_ERROR", + slug: "timeout-error", + registrySlug: "timeout-error", + message: "Timed out while checking existing login credentials. Try again.", + }); + assertEquals(output.join("\n").includes("Enter your API token"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + const { __setExistingSessionTimeoutForTests } = await import("./login.ts"); + setJsonMode(false); + __setExistingSessionTimeoutForTests(); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("reports unreachable credential validation as login JSON without prompting", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + globalThis.fetch = (() => + Promise.reject(new TypeError("network unavailable"))) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "NETWORK_ERROR", + slug: "network-error", + registrySlug: "network-error", + message: "Could not reach the Veryfront API while checking existing login credentials.", + }); + assertEquals(output.join("\n").includes("Enter your API token"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("reports service validation failures as login JSON without prompting", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + globalThis.fetch = (() => + Promise.resolve(new Response(null, { status: 503 }))) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "API_CLIENT_ERROR", + slug: "api-client-error", + registrySlug: "api-client-error", + message: "Veryfront API could not validate existing login credentials.", + context: { status: 503 }, + }); + assertEquals(output.join("\n").includes("Enter your API token"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("reports non-auth token validation statuses as login JSON service failures", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + globalThis.fetch = (() => + Promise.resolve(new Response(null, { status: 429 }))) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "API_CLIENT_ERROR", + slug: "api-client-error", + registrySlug: "api-client-error", + message: "Veryfront API could not validate existing login credentials.", + context: { status: 429 }, + }); + assertEquals(output.join("\n").includes("Enter your API token"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("reports non-auth API key validation statuses as login JSON service failures", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + + try { + globalThis.fetch = (() => + Promise.resolve(new Response(null, { status: 408 }))) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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, apiToken: "vf_env_only" }); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "API_CLIENT_ERROR", + slug: "api-client-error", + registrySlug: "api-client-error", + message: "Veryfront API could not validate existing login credentials.", + context: { status: 408 }, + }); + assertEquals(output.join("\n").includes("VERYFRONT_API_TOKEN"), false); + assertEquals(output.join("\n").includes("vf_env_only"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("preserves an environment service failure over a valid stored login in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + const requestedAuth: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer env-token") { + return Promise.resolve(new Response(null, { status: 429 })); + } + return Promise.resolve( + Response.json({ id: "user-123", email: "stored@example.com" }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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, apiToken: "env-token" }); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "API_CLIENT_ERROR", + slug: "api-client-error", + registrySlug: "api-client-error", + message: "Veryfront API could not validate existing login credentials.", + context: { status: 429 }, + }); + assertEquals(requestedAuth, ["Bearer env-token"]); + assertEquals(output.join("\n").includes("stored@example.com"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("preserves an environment rejection over a valid stored login in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + const requestedAuth: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + 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)); + console.error = (message?: unknown) => errors.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, apiToken: "env-invalid-token" }); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "AUTHENTICATION_ERROR", + slug: "authentication-required", + registrySlug: "authentication-required", + message: "Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.", + }); + assertEquals(requestedAuth, ["Bearer env-invalid-token"]); + assertEquals(output.join("\n").includes("stored@example.com"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("uses a valid stored login before a rejected project dotenv token in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const originalToken = getEnv("VERYFRONT_API_TOKEN"); + const output: string[] = []; + const errors: string[] = []; + const requestedAuth: string[] = []; + const envDir = await makeTempDir({ prefix: "login-dotenv-token-" }); + await saveToken("stored-valid-token", testEnv); + + try { + deleteEnv("VERYFRONT_API_TOKEN"); + await Deno.writeTextFile( + `${envDir}/.env`, + "VERYFRONT_API_TOKEN=env-file-invalid-token\n", + ); + const { __resetEnvLoaderForTests, loadEnv } = await import( + "veryfront/utils/env-loader" + ); + __resetEnvLoaderForTests(); + await loadEnv({ cwd: envDir }); + + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer env-file-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)); + console.error = (message?: unknown) => errors.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, + apiToken: "env-file-invalid-token", + }); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, { id: "user-123", email: "stored@example.com" }); + assertEquals(envelope.data, { + id: "user-123", + email: "stored@example.com", + source: "token-store", + }); + assertEquals(requestedAuth, ["Bearer stored-valid-token"]); + assertEquals(output.join("\n").includes("env-file-invalid-token"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { __resetEnvLoaderForTests } = await import("veryfront/utils/env-loader"); + const { setJsonMode } = await import("../shared/json-output.ts"); + __resetEnvLoaderForTests(); + setJsonMode(false); + if (originalToken) setEnv("VERYFRONT_API_TOKEN", originalToken); + else deleteEnv("VERYFRONT_API_TOKEN"); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(envDir, { recursive: true }); + } + }); + + for (const outage of STORED_CREDENTIAL_OUTAGES) { + it(`does not accept a project dotenv token after stored validation hits ${outage.name}`, async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const originalToken = getEnv("VERYFRONT_API_TOKEN"); + const output: string[] = []; + const errors: string[] = []; + const requestedAuth: string[] = []; + const envDir = await makeTempDir({ prefix: "login-dotenv-outage-" }); + await saveToken("stored-unavailable-token", testEnv); + + try { + deleteEnv("VERYFRONT_API_TOKEN"); + await Deno.writeTextFile( + `${envDir}/.env`, + "VERYFRONT_API_TOKEN=env-file-valid-token\n", + ); + const { __resetEnvLoaderForTests, loadEnv } = await import( + "veryfront/utils/env-loader" + ); + __resetEnvLoaderForTests(); + await loadEnv({ cwd: envDir }); + + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer stored-unavailable-token") return outage.respond(); + return Promise.resolve( + Response.json({ id: "env-user", email: "env@example.com" }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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, apiToken: "env-file-valid-token" }, + envDir, + ); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.success, false); + assertEquals(envelope.error.slug, outage.expectedLoginSlug); + assertEquals(requestedAuth, ["Bearer stored-unavailable-token"]); + assertEquals(await readToken(testEnv), "stored-unavailable-token"); + assertEquals(output.join("\n").includes("env@example.com"), false); + assertEquals(errors, []); + } finally { + const { __resetEnvLoaderForTests } = await import("veryfront/utils/env-loader"); + const { setJsonMode } = await import("../shared/json-output.ts"); + __resetEnvLoaderForTests(); + setJsonMode(false); + if (originalToken) setEnv("VERYFRONT_API_TOKEN", originalToken); + else deleteEnv("VERYFRONT_API_TOKEN"); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(envDir, { recursive: true }); + } + }); + } + + it("accepts a project dotenv token after a rejected stored login in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const originalToken = getEnv("VERYFRONT_API_TOKEN"); + const output: string[] = []; + const errors: string[] = []; + const requestedAuth: string[] = []; + const envDir = await makeTempDir({ prefix: "login-dotenv-token-" }); + await saveToken("stored-invalid-token", testEnv); + + try { + deleteEnv("VERYFRONT_API_TOKEN"); + await Deno.writeTextFile( + `${envDir}/.env`, + "VERYFRONT_API_TOKEN=env-file-valid-token\n", + ); + const { __resetEnvLoaderForTests, loadEnv } = await import( + "veryfront/utils/env-loader" + ); + __resetEnvLoaderForTests(); + await loadEnv({ cwd: envDir }); + + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer stored-invalid-token") { + return Promise.resolve(new Response(null, { status: 401 })); + } + return Promise.resolve( + Response.json({ id: "env-user", email: "env@example.com" }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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, + apiToken: "env-file-valid-token", + }); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, { id: "env-user", email: "env@example.com" }); + assertEquals(envelope.data, { + id: "env-user", + email: "env@example.com", + source: "env", + }); + assertEquals(requestedAuth, [ + "Bearer stored-invalid-token", + "Bearer env-file-valid-token", + ]); + assertEquals(output.join("\n").includes("env-file-valid-token"), false); + assertEquals(output.join("\n").includes("stored-invalid-token"), false); + assertEquals(errors, []); + const { resolveConfigWithAuth } = await import("../shared/config.ts"); + const config = await resolveConfigWithAuth(envDir, { + ...testEnv, + apiToken: "env-file-valid-token", + projectSlug: "fallback-project", + }); + assertEquals(config.apiToken, "env-file-valid-token"); + assertEquals(await readToken(testEnv), null); + } finally { + const { __resetEnvLoaderForTests } = await import("veryfront/utils/env-loader"); + const { setJsonMode } = await import("../shared/json-output.ts"); + __resetEnvLoaderForTests(); + setJsonMode(false); + if (originalToken) setEnv("VERYFRONT_API_TOKEN", originalToken); + else deleteEnv("VERYFRONT_API_TOKEN"); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(envDir, { recursive: true }); + } + }); + + it("does not accept a stored login after a rejected veryfront.json token in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + const requestedAuth: string[] = []; + const projectDir = await makeTempDir({ prefix: "login-config-token-" }); + await saveToken("stored-valid-token", testEnv); + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ apiToken: "config-invalid-token", projectSlug: "test-project" }) + + "\n", + ); + + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer config-invalid-token") { + return Promise.resolve(new Response(null, { status: 401 })); + } + return Promise.resolve( + Response.json({ id: "stored-user", email: "stored@example.com" }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.push(String(message)); + + const { setJsonMode } = await import("../shared/json-output.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const { login } = await import("./login.ts"); + setJsonMode(true); + + const result = await withCwd(projectDir, () => login(undefined, testEnv)); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "AUTHENTICATION_ERROR", + slug: "authentication-required", + registrySlug: "authentication-required", + message: "Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.", + }); + assertEquals(requestedAuth, ["Bearer config-invalid-token"]); + assertEquals(output.join("\n").includes("stored@example.com"), false); + assertEquals(output.join("\n").includes("config-invalid-token"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(projectDir, { recursive: true }); + } + }); + + it("validates a veryfront.json token against the configured API URL in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + const requestedUrls: string[] = []; + const requestedAuth: string[] = []; + const projectDir = await makeTempDir({ prefix: "login-config-api-url-" }); + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ + apiToken: "config-valid-token", + apiUrl: "https://control.example.test/api", + projectSlug: "test-project", + }) + "\n", + ); + + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + requestedUrls.push(String(input)); + requestedAuth.push(String(new Headers(init?.headers).get("authorization") ?? "")); + if (String(input) === "https://control.example.test/api/me") { + return Promise.resolve( + Response.json({ id: "config-user", email: "config@example.com" }), + ); + } + return Promise.resolve(new Response(null, { status: 401 })); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.push(String(message)); + + const { setJsonMode } = await import("../shared/json-output.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const { login } = await import("./login.ts"); + setJsonMode(true); + + const result = await withCwd(projectDir, () => login(undefined, testEnv)); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, { id: "config-user", email: "config@example.com" }); + assertEquals(envelope.data, { + id: "config-user", + email: "config@example.com", + source: "config-file", + }); + assertEquals(requestedUrls, ["https://control.example.test/api/me"]); + assertEquals(requestedAuth, ["Bearer config-valid-token"]); + assertEquals(output.join("\n").includes("config-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(projectDir, { recursive: true }); + } + }); + + it("does not execute module config while reading veryfront.json preflight settings", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + const projectDir = await makeTempDir({ prefix: "login-json-only-config-" }); + const sideEffectPath = `${projectDir}/module-executed.txt`; + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.config.ts`, + [ + 'await Deno.writeTextFile(new URL("./module-executed.txt", import.meta.url), "yes");', + 'export default { projectSlug: "module-project" };', + ].join("\n"), + ); + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ + apiToken: "config-valid-token", + apiUrl: "https://control.example.test/api", + projectSlug: "json-project", + }) + "\n", + ); + + globalThis.fetch = (() => + Promise.resolve( + Response.json({ id: "config-user", email: "config@example.com" }), + )) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.push(String(message)); + + const { setJsonMode } = await import("../shared/json-output.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const { login } = await import("./login.ts"); + setJsonMode(true); + + const result = await withCwd(projectDir, () => login(undefined, testEnv)); + const envelope = JSON.parse(output.join("\n")); + const moduleWasExecuted = await Deno.stat(sideEffectPath).then(() => true, () => false); + + assertEquals(result, { id: "config-user", email: "config@example.com" }); + assertEquals(envelope.data, { + id: "config-user", + email: "config@example.com", + source: "config-file", + }); + assertEquals(moduleWasExecuted, false); + assertEquals(output.join("\n").includes("config-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(projectDir, { recursive: true }); + } + }); + + it("validates an environment token against the configured API URL in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + const requestedUrls: string[] = []; + const requestedAuth: string[] = []; + const projectDir = await makeTempDir({ prefix: "login-env-config-api-url-" }); + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ + apiUrl: "https://control.example.test/api", + projectSlug: "test-project", + }) + "\n", + ); + + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + requestedUrls.push(String(input)); + requestedAuth.push(String(new Headers(init?.headers).get("authorization") ?? "")); + if (String(input) === "https://control.example.test/api/me") { + return Promise.resolve( + Response.json({ id: "env-user", email: "env@example.com" }), + ); + } + return Promise.resolve(new Response(null, { status: 401 })); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.push(String(message)); + + const { setJsonMode } = await import("../shared/json-output.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const { login } = await import("./login.ts"); + setJsonMode(true); + + const result = await withCwd( + projectDir, + () => login(undefined, { ...testEnv, apiToken: "env-valid-token" }), + ); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, { id: "env-user", email: "env@example.com" }); + assertEquals(envelope.data, { + id: "env-user", + email: "env@example.com", + source: "env", + }); + assertEquals(requestedUrls, ["https://control.example.test/api/me"]); + assertEquals(requestedAuth, ["Bearer env-valid-token"]); + assertEquals(output.join("\n").includes("env-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(projectDir, { recursive: true }); + } + }); + + it("validates a stored token against the configured API URL in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + const requestedUrls: string[] = []; + const requestedAuth: string[] = []; + const projectDir = await makeTempDir({ prefix: "login-stored-config-api-url-" }); + await saveToken("stored-valid-token", testEnv); + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ + apiUrl: "https://control.example.test/api", + projectSlug: "test-project", + }) + "\n", + ); + + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + requestedUrls.push(String(input)); + requestedAuth.push(String(new Headers(init?.headers).get("authorization") ?? "")); + if (String(input) === "https://control.example.test/api/me") { + return Promise.resolve( + Response.json({ id: "stored-user", email: "stored@example.com" }), + ); + } + return Promise.resolve(new Response(null, { status: 401 })); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.push(String(message)); + + const { setJsonMode } = await import("../shared/json-output.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const { login } = await import("./login.ts"); + setJsonMode(true); + + const result = await withCwd(projectDir, () => login(undefined, testEnv)); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, { id: "stored-user", email: "stored@example.com" }); + assertEquals(envelope.data, { + id: "stored-user", + email: "stored@example.com", + source: "token-store", + }); + assertEquals(requestedUrls, ["https://control.example.test/api/me"]); + assertEquals(requestedAuth, ["Bearer stored-valid-token"]); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(projectDir, { recursive: true }); + } + }); + + it("ignores a schema-invalid veryfront.json token before reporting a stored login in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + const requestedAuth: string[] = []; + const projectDir = await makeTempDir({ prefix: "login-invalid-config-schema-" }); + await saveToken("stored-valid-token", testEnv); + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ apiToken: "config-invalid-token", projectSlug: 123 }) + "\n", + ); + + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer stored-valid-token") { + return Promise.resolve( + Response.json({ id: "stored-user", email: "stored@example.com" }), + ); + } + return Promise.resolve(new Response(null, { status: 401 })); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.push(String(message)); + + const { setJsonMode } = await import("../shared/json-output.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const { login } = await import("./login.ts"); + setJsonMode(true); + + const result = await withCwd(projectDir, () => login(undefined, testEnv)); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, { id: "stored-user", email: "stored@example.com" }); + assertEquals(envelope.data, { + id: "stored-user", + email: "stored@example.com", + source: "token-store", + }); + assertEquals(requestedAuth, ["Bearer stored-valid-token"]); + assertEquals(output.join("\n").includes("config-invalid-token"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(projectDir, { recursive: true }); + } + }); + + it("preserves a malformed environment validation response over a valid stored login in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + const requestedAuth: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer env-token") { + return Promise.resolve( + new Response("{", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.resolve( + Response.json({ id: "user-123", email: "stored@example.com" }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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, apiToken: "env-token" }); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "API_CLIENT_ERROR", + slug: "api-client-error", + registrySlug: "api-client-error", + message: "Veryfront API could not validate existing login credentials.", + context: { status: 200 }, + }); + assertEquals(requestedAuth, ["Bearer env-token"]); + assertEquals(output.join("\n").includes("stored@example.com"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("preserves a structurally invalid environment validation response over a valid stored login in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + const requestedAuth: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer env-token") { + return Promise.resolve( + Response.json({ error: "upstream unavailable" }), + ); + } + return Promise.resolve( + Response.json({ id: "user-123", email: "stored@example.com" }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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, apiToken: "env-token" }); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "API_CLIENT_ERROR", + slug: "api-client-error", + registrySlug: "api-client-error", + message: "Veryfront API could not validate existing login credentials.", + context: { status: 200 }, + }); + assertEquals(requestedAuth, ["Bearer env-token"]); + assertEquals(output.join("\n").includes("stored@example.com"), false); + assertEquals(output.join("\n").includes("stored-valid-token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("preserves network failures while decoding an existing login response in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + globalThis.fetch = (() => + Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + controller.error(new TypeError("body reset")); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + )) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "NETWORK_ERROR", + slug: "network-error", + registrySlug: "network-error", + message: "Could not reach the Veryfront API while checking existing login credentials.", + }); + assertEquals(output.join("\n").includes("Enter your API token"), false); + assertEquals(errors, []); + assertEquals(await readToken(testEnv), "stored-valid-token"); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("preserves timeout failures while decoding an existing login response in JSON mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + globalThis.fetch = (() => + Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + controller.error(new DOMException("deadline exceeded", "TimeoutError")); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + )) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.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); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.error, { + code: "TIMEOUT_ERROR", + slug: "timeout-error", + registrySlug: "timeout-error", + message: "Timed out while checking existing login credentials. Try again.", + }); + assertEquals(output.join("\n").includes("Enter your API token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("does not mask a rejected environment token with a valid stored session", 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, null); + assertEquals(requestedAuth, ["Bearer env-invalid-token"]); + const printed = output.join("\n"); + assertEquals(printed.includes("stored@example.com"), false); + 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("does not sign in again under a rejected environment token in human mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalPrompt = globalThis.prompt; + const output: string[] = []; + const requestedAuth: string[] = []; + + try { + 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: "replacement-user", email: "replacement@example.com" }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + globalThis.prompt = (() => "replacement-token") as typeof prompt; + + const { login } = await import("./login.ts"); + const result = await login(undefined, { ...testEnv, apiToken: "env-invalid-token" }); + + assertEquals(result, null); + assertEquals(requestedAuth, ["Bearer env-invalid-token"]); + const printed = output.join("\n"); + assertStringIncludes(printed, "VERYFRONT_API_TOKEN was rejected by the Veryfront API."); + assertStringIncludes( + printed, + "Unset VERYFRONT_API_TOKEN or replace the variable before signing in with another method.", + ); + assertEquals(printed.includes("Enter your API token"), false); + assertEquals(printed.includes("replacement@example.com"), false); + assertEquals(await readToken(testEnv), null); + } finally { + globalThis.prompt = originalPrompt; + console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("does not sign in again under a rejected config-file token in human mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalPrompt = globalThis.prompt; + const output: string[] = []; + const requestedAuth: string[] = []; + const projectDir = await makeTempDir({ prefix: "login-rejected-config-human-" }); + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ apiToken: "config-invalid-token", projectSlug: "test-project" }) + + "\n", + ); + + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer config-invalid-token") { + return Promise.resolve(new Response(null, { status: 401 })); + } + return Promise.resolve( + Response.json({ id: "replacement-user", email: "replacement@example.com" }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + globalThis.prompt = (() => "replacement-token") as typeof prompt; + + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const { login } = await import("./login.ts"); + const result = await withCwd(projectDir, () => login(undefined, testEnv)); + + assertEquals(result, null); + assertEquals(requestedAuth, ["Bearer config-invalid-token"]); + const printed = output.join("\n"); + assertStringIncludes( + printed, + "apiToken from veryfront.json was rejected by the Veryfront API.", + ); + assertStringIncludes( + printed, + "Remove or replace apiToken in veryfront.json before signing in with another method.", + ); + assertEquals(printed.includes("Enter your API token"), false); + assertEquals(printed.includes("replacement@example.com"), false); + assertEquals(await readToken(testEnv), null); + } finally { + globalThis.prompt = originalPrompt; + console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(projectDir, { recursive: true }); + } + }); + + it("stops under an unavailable shell environment token in human mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalPrompt = globalThis.prompt; + const originalEnvToken = getEnv("VERYFRONT_API_TOKEN"); + const output: string[] = []; + const requestedAuth: string[] = []; + + try { + const { __resetEnvLoaderForTests } = await import("veryfront/utils/env-loader"); + __resetEnvLoaderForTests(); + setEnv("VERYFRONT_API_TOKEN", "env-timeout-token"); + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + requestedAuth.push(String(new Headers(init?.headers).get("authorization") ?? "")); + return Promise.reject(new DOMException("deadline exceeded", "TimeoutError")); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + globalThis.prompt = (() => "replacement-token") as typeof prompt; + + const { login } = await import("./login.ts"); + const result = await login(undefined, { ...testEnv, apiToken: "env-timeout-token" }); + + assertEquals(result, null); + assertEquals(requestedAuth, ["Bearer env-timeout-token"]); + const printed = output.join("\n"); + assertStringIncludes( + printed, + "Timed out while checking VERYFRONT_API_TOKEN with the Veryfront API.", + ); + assertStringIncludes( + printed, + "Try again before signing in with another method.", + ); + assertStringIncludes( + printed, + "Unset VERYFRONT_API_TOKEN before signing in with another method.", + ); + assertEquals(printed.includes("Enter your API token"), false); + assertEquals(printed.includes("replacement-token"), false); + assertEquals(await readToken(testEnv), null); + } finally { + const { __resetEnvLoaderForTests } = await import("veryfront/utils/env-loader"); + __resetEnvLoaderForTests(); + if (originalEnvToken) setEnv("VERYFRONT_API_TOKEN", originalEnvToken); + else deleteEnv("VERYFRONT_API_TOKEN"); + globalThis.prompt = originalPrompt; + console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("stops under an unreachable shell environment token in human mode", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalPrompt = globalThis.prompt; + const originalEnvToken = getEnv("VERYFRONT_API_TOKEN"); + const output: string[] = []; + const requestedAuth: string[] = []; + + try { + const { __resetEnvLoaderForTests } = await import("veryfront/utils/env-loader"); + __resetEnvLoaderForTests(); + setEnv("VERYFRONT_API_TOKEN", "env-network-token"); + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + requestedAuth.push(String(new Headers(init?.headers).get("authorization") ?? "")); + return Promise.reject(new TypeError("network unavailable")); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + globalThis.prompt = (() => "replacement-token") as typeof prompt; + + const { login } = await import("./login.ts"); + const result = await login(undefined, { ...testEnv, apiToken: "env-network-token" }); + + assertEquals(result, null); + assertEquals(requestedAuth, ["Bearer env-network-token"]); + const printed = output.join("\n"); + assertStringIncludes( + printed, + "Could not reach the Veryfront API while checking VERYFRONT_API_TOKEN.", + ); + assertStringIncludes( + printed, + "Try again before signing in with another method.", + ); + assertStringIncludes( + printed, + "Unset VERYFRONT_API_TOKEN before signing in with another method.", + ); + assertEquals(printed.includes("Enter your API token"), false); + assertEquals(printed.includes("replacement-token"), false); + assertEquals(await readToken(testEnv), null); + } finally { + const { __resetEnvLoaderForTests } = await import("veryfront/utils/env-loader"); + __resetEnvLoaderForTests(); + if (originalEnvToken) setEnv("VERYFRONT_API_TOKEN", originalEnvToken); + else deleteEnv("VERYFRONT_API_TOKEN"); + globalThis.prompt = originalPrompt; + console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + for (const status of [429, 503]) { + it(`stops under a ${status} config-file token validation response in human mode`, async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalPrompt = globalThis.prompt; + const output: string[] = []; + const requestedAuth: string[] = []; + const projectDir = await makeTempDir({ prefix: "login-unavailable-config-human-" }); + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ apiToken: "config-unavailable-token", projectSlug: "test-project" }) + + "\n", + ); + + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + requestedAuth.push(String(new Headers(init?.headers).get("authorization") ?? "")); + return Promise.resolve(new Response(null, { status })); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + globalThis.prompt = (() => "replacement-token") as typeof prompt; + + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const { login } = await import("./login.ts"); + const result = await withCwd(projectDir, () => login(undefined, testEnv)); + + assertEquals(result, null); + assertEquals(requestedAuth, ["Bearer config-unavailable-token"]); + const printed = output.join("\n"); + assertStringIncludes( + printed, + `Veryfront API could not validate apiToken from veryfront.json (${status}).`, + ); + assertStringIncludes( + printed, + "Try again before signing in with another method.", + ); + assertStringIncludes( + printed, + "Remove or replace apiToken in veryfront.json before signing in with another method.", + ); + assertEquals(printed.includes("Enter your API token"), false); + assertEquals(printed.includes("replacement-token"), false); + assertEquals(await readToken(testEnv), null); + } finally { + globalThis.prompt = originalPrompt; + console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(projectDir, { recursive: true }); + } + }); + } + + 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"); + assertStringIncludes( + printed, + "Unset VERYFRONT_API_TOKEN before using another login method", + ); + // 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("mentions config-file credentials when explaining environment account switching", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const output: string[] = []; + const requestedAuth: string[] = []; + const projectDir = await makeTempDir({ prefix: "login-env-config-guidance-" }); + + try { + setNonInteractive(true); + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ apiToken: "config-valid-token", projectSlug: "test-project" }) + + "\n", + ); + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + requestedAuth.push(String(new Headers(init?.headers).get("authorization") ?? "")); + return 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 { withCwd } = await import("#veryfront/testing/cwd.ts"); + const { login } = await import("./login.ts"); + const result = await withCwd( + projectDir, + () => login(undefined, { ...testEnv, apiToken: "vf_env_only" }), + ); + + assertEquals(result, { authenticated: true, type: "apiKey" }); + assertEquals(requestedAuth, ["Bearer vf_env_only"]); + const printed = output.join("\n"); + assertStringIncludes( + printed, + "Unset VERYFRONT_API_TOKEN before using another login method", + ); + assertStringIncludes( + printed, + "Remove or replace apiToken in veryfront.json after unsetting VERYFRONT_API_TOKEN.", + ); + assertEquals(printed.includes("vf_env_only"), false); + assertEquals(printed.includes("config-valid-token"), false); + } finally { + console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + await remove(projectDir, { recursive: true }); + } + }); + + it("reports an existing environment API key as login JSON when JSON mode is enabled", async () => { + 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 { setJsonMode } = await import("../shared/json-output.ts"); + const { login } = await import("./login.ts"); + setJsonMode(true); + + const result = await login(undefined, { ...testEnv, apiToken: "vf_env_only" }); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, { authenticated: true, type: "apiKey" }); + assertEquals(envelope.success, true); + assertEquals(envelope.command, "login"); + assertEquals(envelope.data, { + authenticated: true, + credential_type: "api_key", + source: "env", + }); + assertEquals(output.join("\n").includes("VERYFRONT_API_TOKEN"), false); + assertEquals(output.join("\n").includes("vf_env_only"), false); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("does not present an unverified stored credential as a login", 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) => { + requestedAuth.push(String(new Headers(init?.headers).get("authorization") ?? "")); + return 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_existing" }); + + assertEquals(result, { authenticated: true, type: "apiKey" }); + assertEquals(requestedAuth, ["Bearer vf_env_existing"]); + const printed = output.join("\n"); + assertStringIncludes(printed, "VERYFRONT_API_TOKEN"); + assertStringIncludes(printed, "takes precedence over a stored credential"); + assertStringIncludes( + printed, + "Unset VERYFRONT_API_TOKEN before attempting to use the stored credential", + ); + assertEquals(printed.includes("stored login"), false); + assertEquals(printed.includes("vf_env_existing"), false); + assertEquals(printed.includes("stored-valid-token"), false); + } finally { + console.log = originalLog; + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("rethrows unexpected existing-session validation failures", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const output: string[] = []; + await saveToken("stored-valid-token", testEnv); + + try { + setNonInteractive(true); + globalThis.fetch = (() => + Promise.reject(new Error("unexpected existing-session failure"))) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + + const { login } = await import("./login.ts"); + + await assertRejects( + () => login(undefined, testEnv), + Error, + "unexpected existing-session failure", + ); + assertEquals(output.join("\n").includes("Not logged in"), false); + assertEquals(output.join("\n").includes("Enter your API token"), 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); + assertEquals(printed.includes("Unset 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"); + let timer: number | undefined; + try { + const outcome = await Promise.race([ + login(undefined, testEnv), + new Promise((resolve) => { + timer = setTimeout(() => + resolve("TIMED_OUT"), 3000); + }), + ]); + + assertEquals(outcome, null); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } finally { + const { __setExistingSessionTimeoutForTests } = await import("./login.ts"); + __setExistingSessionTimeoutForTests(); + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("does not start stored validation after a rejected environment credential", 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) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + if (init?.signal) signals.push(init.signal); + if (auth === "Bearer env-token") { + return Promise.resolve(new Response(null, { status: 401 })); + } + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + return; + } + 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, 1); + } finally { + const { __setExistingSessionTimeoutForTests } = await import("./login.ts"); + __setExistingSessionTimeoutForTests(); + globalThis.fetch = originalFetch; + resetInteractiveMode(); + await safeDeleteToken(); + } + }); + + it("rejects explicit login methods as login JSON without prompting", async () => { + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + const errors: string[] = []; + + try { + console.log = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => errors.push(String(message)); + + const { setJsonMode } = await import("../shared/json-output.ts"); + const { login } = await import("./login.ts"); + setJsonMode(true); + + const result = await login("token", testEnv); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope, { + success: false, + command: "login", + error: { + code: "USAGE_ERROR", + slug: "invalid-arguments", + registrySlug: "invalid-argument", + message: "Explicit login methods are not supported with --json.", + }, + }); + assertEquals(output.join("\n").includes("Enter your API token"), false); + assertEquals(errors, []); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + console.error = originalError; + 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", () => { + it("should generate distinct OAuth state values", async () => { + const loginModule = await import("./login.ts") as typeof import("./login.ts") & { + createOAuthState?: () => string; + }; + + assertEquals(typeof loginModule.createOAuthState, "function"); + + const first = loginModule.createOAuthState!(); + const second = loginModule.createOAuthState!(); + + assertEquals(first.length >= 32, true); + assertEquals(second.length >= 32, true); + assertEquals(first !== second, true); + }); + + it("should include state in the OAuth authorization URL", async () => { + const loginModule = await import("./login.ts") as typeof import("./login.ts") & { + createOAuthAuthorizationUrl?: ( + provider: "google" | "github" | "microsoft", + callbackUrl: string, + state: string, + env?: EnvironmentConfig, + ) => string; + }; + + assertEquals(typeof loginModule.createOAuthAuthorizationUrl, "function"); + + const authUrl = loginModule.createOAuthAuthorizationUrl!( + "github", + "http://localhost:3456/callback", + "expected-state", + createTestEnvironmentConfig({ + apiBaseUrl: "https://auth.example.test", + apiUrl: undefined, + }), + ); + const parsed = new URL(authUrl); + const redirectUri = parsed.searchParams.get("redirect_uri"); + + assertEquals(parsed.origin, "https://auth.example.test"); + assertEquals(parsed.pathname, "/auth/github"); + assertEquals(redirectUri, "http://localhost:3456/callback?state=expected-state"); + assertEquals(parsed.searchParams.get("state"), "expected-state"); + assertEquals(new URL(redirectUri!).searchParams.get("state"), "expected-state"); + }); + + it("prints a manual login URL when the browser cannot be opened", async () => { + const originalLog = console.log; + const output: string[] = []; + const spinnerEvents: string[] = []; + + try { + console.log = (...args: unknown[]) => output.push(args.map(String).join(" ")); + const { openOAuthLogin } = await import("./login.ts"); + const opened = await openOAuthLogin( + "https://auth.example.test/login?state=expected-state", + { + update: (text) => spinnerEvents.push(`update:${text}`), + success: (text) => spinnerEvents.push(`success:${text ?? ""}`), + error: (text) => spinnerEvents.push(`error:${text ?? ""}`), + stop: () => spinnerEvents.push("stop"), + }, + () => Promise.reject(new Error("browser unavailable")), + ); + + assertEquals(opened, false); + assertEquals(spinnerEvents, ["stop"]); + assertStringIncludes(output.join("\n"), "Could not open the browser"); + assertStringIncludes( + output.join("\n"), + "https://auth.example.test/login?state=expected-state", + ); + } finally { + console.log = originalLog; + } + }); + }); + + describe("logout", { sanitizeOps: false, sanitizeResources: false }, () => { + it("should clear stored token", async () => { + await saveToken("test-token", testEnv); + assertEquals(await readToken(testEnv), "test-token"); + + const { logout } = await import("./login.ts"); + await logout(testEnv); + + assertEquals(await readToken(testEnv), null); + }); + }); + + describe("whoami", () => { + it("should use the provided token store and API URL", async () => { + const originalFetch = globalThis.fetch; + let requestedUrl = ""; + await saveToken("test-token", testEnv); + + try { + globalThis.fetch = ((input: string | URL | Request) => { + requestedUrl = String(input); + return Promise.resolve( + Response.json({ id: "user-123", email: "test@example.com" }), + ); + }) as typeof fetch; + + const { whoami } = await import("./login.ts"); + const env = { ...testEnv, apiBaseUrl: "https://auth.example.test", apiUrl: undefined }; + const user = await whoami(env); + + assertEquals(user, { id: "user-123", email: "test@example.com" }); + assertEquals(requestedUrl, "https://auth.example.test/me"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("falls back to a valid project dotenv credential after a rejected stored session", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalToken = getEnv("VERYFRONT_API_TOKEN"); + const output: string[] = []; + const envDir = await makeTempDir({ prefix: "whoami-dotenv-fallback-" }); + const requestedAuth: string[] = []; + await saveToken("stored-invalid-token", testEnv); + + try { + deleteEnv("VERYFRONT_API_TOKEN"); + await Deno.writeTextFile( + `${envDir}/.env`, + "VERYFRONT_API_TOKEN=env-file-valid-token\n", + ); + const { __resetEnvLoaderForTests, loadEnv } = await import( + "veryfront/utils/env-loader" + ); + __resetEnvLoaderForTests(); + await loadEnv({ cwd: envDir }); + + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer stored-invalid-token") { + return Promise.resolve(new Response(null, { status: 401 })); + } + return Promise.resolve( + Response.json({ id: "env-user", email: "env@example.com" }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + + const { whoami } = await import("./login.ts"); + const user = await whoami({ + ...testEnv, + apiToken: "env-file-valid-token", + }); + + assertEquals(user, { id: "env-user", email: "env@example.com" }); + assertEquals(requestedAuth, [ + "Bearer stored-invalid-token", + "Bearer env-file-valid-token", + ]); + const printed = output.join("\n"); + assertStringIncludes(printed, "env@example.com"); + assertStringIncludes(printed, ".env"); + assertEquals(printed.includes("env-file-valid-token"), false); + assertEquals(printed.includes("stored-invalid-token"), false); + const { resolveConfigWithAuth } = await import("../shared/config.ts"); + const config = await resolveConfigWithAuth(envDir, { + ...testEnv, + apiToken: "env-file-valid-token", + projectSlug: "fallback-project", + }); + assertEquals(config.apiToken, "env-file-valid-token"); + assertEquals(await readToken(testEnv), null); + } finally { + const { __resetEnvLoaderForTests } = await import("veryfront/utils/env-loader"); + __resetEnvLoaderForTests(); + if (originalToken) setEnv("VERYFRONT_API_TOKEN", originalToken); + else deleteEnv("VERYFRONT_API_TOKEN"); + console.log = originalLog; + globalThis.fetch = originalFetch; + await safeDeleteToken(); + await remove(envDir, { recursive: true }); + } + }); + + it("retains a stored session when credential validation is unavailable", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const output: string[] = []; + await saveToken("stored-unavailable-token", testEnv); + + try { + globalThis.fetch = (() => + Promise.reject(new TypeError("network unavailable"))) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + + const { whoami } = await import("./login.ts"); + assertEquals(await whoami(testEnv), null); + assertEquals(await readToken(testEnv), "stored-unavailable-token"); + } finally { + console.log = originalLog; + globalThis.fetch = originalFetch; + await safeDeleteToken(); + } + }); + + for (const outage of STORED_CREDENTIAL_OUTAGES) { + it(`does not report a project dotenv identity after stored validation hits ${outage.name}`, async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalToken = getEnv("VERYFRONT_API_TOKEN"); + const output: string[] = []; + const requestedAuth: string[] = []; + const envDir = await makeTempDir({ prefix: "whoami-dotenv-outage-" }); + await saveToken("stored-unavailable-token", testEnv); + + try { + deleteEnv("VERYFRONT_API_TOKEN"); + await Deno.writeTextFile( + `${envDir}/.env`, + "VERYFRONT_API_TOKEN=env-file-valid-token\n", + ); + const { __resetEnvLoaderForTests, loadEnv } = await import( + "veryfront/utils/env-loader" + ); + __resetEnvLoaderForTests(); + await loadEnv({ cwd: envDir }); + + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("authorization") ?? ""); + requestedAuth.push(auth); + if (auth === "Bearer stored-unavailable-token") return outage.respond(); + return Promise.resolve( + Response.json({ id: "env-user", email: "env@example.com" }), + ); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + + const { setJsonMode } = await import("../shared/json-output.ts"); + const { whoami } = await import("./login.ts"); + setJsonMode(true); + + const result = await whoami({ + ...testEnv, + apiToken: "env-file-valid-token", + }); + const envelope = JSON.parse(output.join("\n")); + + assertEquals(result, null); + assertEquals(envelope.data, { authenticated: false }); + assertEquals(requestedAuth, ["Bearer stored-unavailable-token"]); + assertEquals(await readToken(testEnv), "stored-unavailable-token"); + assertEquals(output.join("\n").includes("env@example.com"), false); + } finally { + const { __resetEnvLoaderForTests } = await import("veryfront/utils/env-loader"); + const { setJsonMode } = await import("../shared/json-output.ts"); + __resetEnvLoaderForTests(); + setJsonMode(false); + if (originalToken) setEnv("VERYFRONT_API_TOKEN", originalToken); + else deleteEnv("VERYFRONT_API_TOKEN"); + console.log = originalLog; + globalThis.fetch = originalFetch; + await safeDeleteToken(); + await remove(envDir, { recursive: true }); + } + }); + } + + it("reports a veryfront.json API key in human mode without exposing it", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const output: string[] = []; + const projectDir = await makeTempDir({ prefix: "whoami-config-human-" }); + const markerPath = `${projectDir}/executed-module-config`; + let requestedUrl = ""; + let requestedAuth = ""; + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.config.ts`, + `await Deno.writeTextFile(${ + JSON.stringify(markerPath) + }, "executed");\nexport default { projectSlug: "module-project" };\n`, + ); + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ + apiToken: "vf_config_human_secret", + apiUrl: "https://config-whoami.example.test", + projectSlug: "json-project", + }) + "\n", + ); + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + requestedUrl = String(input); + requestedAuth = String(new Headers(init?.headers).get("Authorization")); + return 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 { whoami } = await import("./login.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const result = await withCwd(projectDir, () => whoami(testEnv)); + + assertEquals(result, { authenticated: true, type: "apiKey" }); + assertEquals(requestedUrl, "https://config-whoami.example.test/projects?limit=1"); + assertEquals(requestedAuth, "Bearer vf_config_human_secret"); + const printed = output.join("\n"); + assertStringIncludes(printed, "Authenticated with an API key"); + assertStringIncludes(printed, "apiToken from veryfront.json"); + assertEquals(printed.includes("vf_config_human_secret"), false); + assertEquals(await Deno.stat(markerPath).then(() => true).catch(() => false), false); + } finally { + console.log = originalLog; + globalThis.fetch = originalFetch; + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("reports a veryfront.json API key in JSON mode with config-file source", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const output: string[] = []; + const projectDir = await makeTempDir({ prefix: "whoami-config-json-" }); + let requestedAuth = ""; + + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ + apiToken: "vf_config_json_secret", + apiUrl: "https://config-whoami-json.example.test", + projectSlug: "json-project", + }) + "\n", + ); + globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => { + requestedAuth = String(new Headers(init?.headers).get("Authorization")); + return 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 { setJsonMode } = await import("../shared/json-output.ts"); + const { whoami } = await import("./login.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + setJsonMode(true); + const result = await withCwd(projectDir, () => whoami(testEnv)); + + assertEquals(result, { authenticated: true, type: "apiKey" }); + assertEquals(requestedAuth, "Bearer vf_config_json_secret"); + const envelope = JSON.parse(output.join("\n")); + assertEquals(envelope.command, "whoami"); + assertEquals(envelope.data, { + authenticated: true, + credential_type: "api_key", + source: "config-file", + }); + assertEquals(output.join("\n").includes("vf_config_json_secret"), false); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; + globalThis.fetch = originalFetch; + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("uses veryfront.json before a stored credential and does not fall through when rejected", async () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const output: string[] = []; + const projectDir = await makeTempDir({ prefix: "whoami-config-precedence-" }); + const requestedAuth: string[] = []; + + try { + await saveToken("stored-valid-token", testEnv); + await Deno.writeTextFile( + `${projectDir}/veryfront.json`, + JSON.stringify({ apiToken: "config-invalid-token", projectSlug: "json-project" }) + + "\n", + ); + globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => { + const auth = String(new Headers(init?.headers).get("Authorization")); + requestedAuth.push(auth); + if (auth === "Bearer stored-valid-token") { + return Promise.resolve(Response.json({ id: "stored-user", email: "stored@test" })); + } + return Promise.resolve(new Response(null, { status: 401 })); + }) as typeof fetch; + console.log = (message?: unknown) => output.push(String(message)); + + const { setJsonMode } = await import("../shared/json-output.ts"); + const { whoami } = await import("./login.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + setJsonMode(true); + const result = await withCwd(projectDir, () => whoami(testEnv)); + + assertEquals(result, null); + assertEquals(requestedAuth, ["Bearer config-invalid-token"]); + assertEquals(JSON.parse(output.join("\n")).data, { authenticated: false }); + assertEquals(output.join("\n").includes("config-invalid-token"), false); + } finally { + const { setJsonMode } = await import("../shared/json-output.ts"); + setJsonMode(false); + console.log = originalLog; globalThis.fetch = originalFetch; + await Deno.remove(projectDir, { recursive: true }); + await safeDeleteToken(); } }); diff --git a/cli/auth/login.ts b/cli/auth/login.ts index 9eedad029f..ea39ef7572 100644 --- a/cli/auth/login.ts +++ b/cli/auth/login.ts @@ -13,7 +13,13 @@ import { DEFAULT_LOGIN_TIMEOUT_MS, getApiUrl, } from "../shared/constants.ts"; -import { createSuccessEnvelope, isJsonMode, outputJson } from "../shared/json-output.ts"; +import { type ApiTokenSource, resolveApiCredentialCandidatesForAuth } from "../shared/config.ts"; +import { + createErrorEnvelope, + createSuccessEnvelope, + isJsonMode, + outputJson, +} from "../shared/json-output.ts"; import { isInteractive } from "../shared/interactive.ts"; import { getEnvSource } from "veryfront/utils/env-loader"; import { basename, isAbsolute, relative } from "veryfront/platform/path"; @@ -73,6 +79,130 @@ export type AuthIdentity = UserInfo | ApiKeyIdentity; export interface CredentialValidationOptions { throwOnNetworkError?: boolean; + throwOnCredentialValidationUnavailable?: boolean; + /** Reuse a caller-owned cancellation or deadline across validation attempts. */ + signal?: AbortSignal; + /** + * Abort the request after this many milliseconds. Callers that must stay + * responsive pass a deadline; omitting it keeps the previous unbounded + * behaviour for callers that are already the user's main action. + */ + timeoutMs?: number; +} + +/** + * How long the bare-`login` preflight will wait on a credential check. + * + * That check is best-effort: it exists only to say "already logged in" instead + * of prompting. A connection the API accepts but never answers would otherwise + * block sign-in forever, so the check is bounded and a timeout simply falls + * through to the normal flow. + */ +const DEFAULT_EXISTING_SESSION_TIMEOUT_MS = 5_000; +let existingSessionTimeoutMs = DEFAULT_EXISTING_SESSION_TIMEOUT_MS; + +function loginIdentityData( + identity: AuthIdentity, + source: ApiTokenSource, +): Record { + const displayedSource = source === "env-file" ? "env" : source; + if (isApiKeyIdentity(identity)) { + return { + authenticated: true, + credential_type: "api_key", + source: displayedSource, + }; + } + + return { ...identity, source: displayedSource }; +} + +async function outputLoginAuthenticationRequiredJson(): Promise { + await outputJson(createErrorEnvelope("login", { + code: "AUTHENTICATION_ERROR", + slug: "authentication-required", + registrySlug: "authentication-required", + message: "Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.", + })); +} + +type CredentialValidationUnavailableKind = "network" | "service" | "timeout"; + +class CredentialValidationUnavailableError extends Error { + override name = "CredentialValidationUnavailableError"; + + constructor( + readonly kind: CredentialValidationUnavailableKind, + readonly status?: number, + ) { + super("Could not validate existing login credentials"); + } +} + +function isCredentialTimeoutFailure(error: unknown): boolean { + return error instanceof DOMException && + (error.name === "AbortError" || error.name === "TimeoutError"); +} + +function isCredentialRejectionStatus(status: number): boolean { + return status === 401 || status === 403; +} + +function isUserInfo(value: unknown): value is UserInfo { + if (value === null || typeof value !== "object") return false; + const candidate = value as Partial; + return typeof candidate.id === "string" && candidate.id.trim() !== "" && + typeof candidate.email === "string" && candidate.email.trim() !== ""; +} + +async function outputLoginExplicitMethodJson(): Promise { + await outputJson(createErrorEnvelope("login", { + code: "USAGE_ERROR", + slug: "invalid-arguments", + registrySlug: "invalid-argument", + message: "Explicit login methods are not supported with --json.", + })); +} + +async function outputLoginValidationUnavailableJson( + failure: CredentialValidationUnavailableError, +): Promise { + if (failure.kind === "timeout") { + await outputJson(createErrorEnvelope("login", { + code: "TIMEOUT_ERROR", + slug: "timeout-error", + registrySlug: "timeout-error", + message: "Timed out while checking existing login credentials. Try again.", + })); + return; + } + + if (failure.kind === "network") { + await outputJson(createErrorEnvelope("login", { + code: "NETWORK_ERROR", + slug: "network-error", + registrySlug: "network-error", + message: "Could not reach the Veryfront API while checking existing login credentials.", + })); + return; + } + + await outputJson(createErrorEnvelope("login", { + code: "API_CLIENT_ERROR", + slug: "api-client-error", + registrySlug: "api-client-error", + message: "Veryfront API could not validate existing login credentials.", + context: failure.status ? { status: failure.status } : undefined, + })); +} + +/** Test seam: shrink the preflight deadline so a stall is observable quickly. */ +export function __setExistingSessionTimeoutForTests(ms?: number): void { + existingSessionTimeoutMs = ms ?? DEFAULT_EXISTING_SESSION_TIMEOUT_MS; +} + +function requestSignal(options: CredentialValidationOptions): AbortSignal | undefined { + return options.signal ?? (options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined); } const AUTH_OPTIONS: { id: AuthMethod; label: string }[] = [ @@ -86,6 +216,14 @@ class NetworkError extends Error { override name = "NetworkError"; } +function isCredentialNetworkFailure(error: unknown): boolean { + return error instanceof TypeError || isCredentialTimeoutFailure(error); +} + +function throwNetworkError(): never { + throw new NetworkError("Could not reach the Veryfront API"); +} + export function createOAuthState(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); @@ -117,19 +255,59 @@ export async function validateToken( try { const response = await fetch(`${getApiUrl(env).replace(/\/$/, "")}/me`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + signal: requestSignal(options), }); if (!response.ok) { // Consume response body to prevent resource leak await response.body?.cancel(); + if ( + options.throwOnCredentialValidationUnavailable && + !isCredentialRejectionStatus(response.status) + ) { + throw new CredentialValidationUnavailableError("service", response.status); + } + if (options.throwOnNetworkError && response.status >= 500) throwNetworkError(); return null; } - return (await response.json()) as UserInfo; + try { + const userInfo = await response.json(); + if (isUserInfo(userInfo)) return userInfo; + if (options.throwOnCredentialValidationUnavailable) { + throw new CredentialValidationUnavailableError("service", response.status); + } + return null; + } catch (error) { + if (options.throwOnCredentialValidationUnavailable) { + if (error instanceof CredentialValidationUnavailableError) { + throw error; + } + if (isCredentialTimeoutFailure(error)) { + throw new CredentialValidationUnavailableError("timeout"); + } + if (error instanceof TypeError) { + throw new CredentialValidationUnavailableError("network"); + } + throw new CredentialValidationUnavailableError("service", response.status); + } + throw error; + } } catch (e) { - if (options.throwOnNetworkError && e instanceof TypeError) { - throw new NetworkError("Could not reach the Veryfront API"); + if (options.throwOnCredentialValidationUnavailable) { + if (e instanceof CredentialValidationUnavailableError) { + throw e; + } + if (isCredentialTimeoutFailure(e)) { + throw new CredentialValidationUnavailableError("timeout"); + } + if (e instanceof TypeError) { + throw new CredentialValidationUnavailableError("network"); + } + throw e; } + if (e instanceof NetworkError) throw e; + if (options.throwOnNetworkError && isCredentialNetworkFailure(e)) throwNetworkError(); return null; } } @@ -154,13 +332,35 @@ async function validateApiKey( url.searchParams.set("limit", "1"); const response = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + signal: requestSignal(options), }); await response.body?.cancel(); - return response.ok; + if (!response.ok) { + if ( + options.throwOnCredentialValidationUnavailable && + !isCredentialRejectionStatus(response.status) + ) { + throw new CredentialValidationUnavailableError("service", response.status); + } + if (options.throwOnNetworkError && response.status >= 500) throwNetworkError(); + return false; + } + return true; } catch (e) { - if (options.throwOnNetworkError && e instanceof TypeError) { - throw new NetworkError("Could not reach the Veryfront API"); + if (options.throwOnCredentialValidationUnavailable) { + if (e instanceof CredentialValidationUnavailableError) { + throw e; + } + if (isCredentialTimeoutFailure(e)) { + throw new CredentialValidationUnavailableError("timeout"); + } + if (e instanceof TypeError) { + throw new CredentialValidationUnavailableError("network"); + } + throw e; } + if (e instanceof NetworkError) throw e; + if (options.throwOnNetworkError && isCredentialNetworkFailure(e)) throwNetworkError(); return false; } } @@ -300,10 +500,239 @@ async function loginWithToken(): Promise { return token; } +function writeConfigFileSwitchingGuidance(): void { + console.log( + " " + + dim("Remove or replace apiToken in veryfront.json before signing in with another method."), + ); +} + +function writeEnvironmentConfigFileSwitchingGuidance(): void { + console.log( + " " + + dim("Remove or replace apiToken in veryfront.json after unsetting VERYFRONT_API_TOKEN."), + ); +} + +function writeAuthoritativeCredentialRejectedMessage( + source: "config-file" | "environment", + hasConfigToken: boolean, +): void { + console.log(); + if (source === "environment") { + console.log(" " + error("✗") + " VERYFRONT_API_TOKEN was rejected by the Veryfront API."); + console.log( + " " + + dim( + "Unset VERYFRONT_API_TOKEN or replace the variable before signing in with another method.", + ), + ); + if (hasConfigToken) writeEnvironmentConfigFileSwitchingGuidance(); + return; + } + + console.log( + " " + error("✗") + " apiToken from veryfront.json was rejected by the Veryfront API.", + ); + writeConfigFileSwitchingGuidance(); +} + +function credentialSourceForDisplay(source: "config-file" | "environment"): string { + return source === "environment" ? "VERYFRONT_API_TOKEN" : "apiToken from veryfront.json"; +} + +function writeAuthoritativeCredentialUnavailableMessage( + source: "config-file" | "environment", + failure: CredentialValidationUnavailableError, + hasConfigToken: boolean, +): void { + const credential = credentialSourceForDisplay(source); + console.log(); + if (failure.kind === "timeout") { + console.log( + " " + error("✗") + ` Timed out while checking ${credential} with the Veryfront API.`, + ); + } else if (failure.kind === "network") { + console.log( + " " + error("✗") + ` Could not reach the Veryfront API while checking ${credential}.`, + ); + } else { + const status = failure.status ? ` (${failure.status})` : ""; + console.log( + " " + error("✗") + ` Veryfront API could not validate ${credential}${status}.`, + ); + } + console.log(" " + dim("Try again before signing in with another method.")); + + if (source === "environment") { + console.log( + " " + dim("Unset VERYFRONT_API_TOKEN before signing in with another method."), + ); + if (hasConfigToken) writeEnvironmentConfigFileSwitchingGuidance(); + return; + } + + writeConfigFileSwitchingGuidance(); +} + +/** + * Report an already-valid session, or null when there is nothing usable. + * + * Returns a sentinel when JSON or human output already explained why login + * must stop instead of falling through to lower-priority credentials or a new + * login that later commands will not use. + */ +async function describeExistingSession( + env: EnvironmentConfig, + projectDir: string = cwd(), +): Promise { + const candidates = await resolveApiCredentialCandidatesForAuth(env, projectDir); + if (candidates.length === 0) return null; + const hasConfigToken = candidates.some((candidate) => candidate.apiTokenSource === "config-file"); + let hasStoredToken = candidates.some((candidate) => candidate.apiTokenSource === "token-store"); + const signal = AbortSignal.timeout(existingSessionTimeoutMs); + let unavailable: CredentialValidationUnavailableError | null = null; + + for (const { apiToken, apiTokenSource, authoritative, validationEnv } of candidates) { + const source = apiTokenSource === "config-file" + ? "config-file" + : apiTokenSource === "token-store" + ? "stored" + : "environment"; + let identity: AuthIdentity | null; + try { + // Bounded: this preflight only decides whether to say "already logged in" + // instead of prompting. An authoritative credential that cannot be + // checked must still stop, because later commands will resolve it ahead + // of any replacement login. + identity = await validateCredential(apiToken, validationEnv, { + signal, + throwOnCredentialValidationUnavailable: true, + }); + } catch (error) { + if (error instanceof CredentialValidationUnavailableError) { + unavailable ??= error; + if (authoritative || apiTokenSource === "token-store") { + if (!isJsonMode() && source !== "stored") { + writeAuthoritativeCredentialUnavailableMessage( + source, + error, + hasConfigToken, + ); + return "failure-output"; + } + break; + } + continue; + } + throw error; + } + if (!identity) { + if (apiTokenSource === "token-store") { + await deleteToken(env); + hasStoredToken = false; + } + if (authoritative) { + if (!isJsonMode()) { + if (source !== "stored") { + writeAuthoritativeCredentialRejectedMessage(source, hasConfigToken); + return "failure-output"; + } + } + break; + } + continue; + } + + if (isJsonMode()) { + await outputJson(createSuccessEnvelope( + "login", + loginIdentityData(identity, apiTokenSource), + )); + return identity; + } + + console.log(); + console.log( + " ✓ " + + (isApiKeyIdentity(identity) + ? "Already authenticated with an API key" + : "Already logged in as " + brand(identity.email)), + ); + // An environment credential is a valid session for every command, but this + // path stores nothing, and `login` implies it did. The variable is often set + // by a `.env` in the working directory the developer has forgotten about, + // the case `whoami` now names, so the session ends at the directory + // boundary. Say so rather than let them discover it elsewhere. + if (source === "environment") { + if (hasStoredToken) { + console.log( + " " + dim("Using VERYFRONT_API_TOKEN; it takes precedence over a stored credential."), + ); + console.log( + " " + + dim( + "Unset VERYFRONT_API_TOKEN before attempting to use the stored credential, or replace the variable to switch tokens.", + ), + ); + } else { + console.log(" " + dim("Using VERYFRONT_API_TOKEN; no stored login was created.")); + console.log( + " " + + dim( + "Unset VERYFRONT_API_TOKEN before using another login method, or replace the variable to switch tokens.", + ), + ); + } + if (hasConfigToken) writeEnvironmentConfigFileSwitchingGuidance(); + } else if (source === "config-file") { + console.log( + " " + + dim("Using apiToken from veryfront.json; it takes precedence over stored credentials."), + ); + writeConfigFileSwitchingGuidance(); + } + console.log( + " " + + dim( + "Run 'veryfront login --token' (or --google, --github, --microsoft) to sign in again.", + ), + ); + return identity; + } + + if (isJsonMode() && unavailable) { + await outputLoginValidationUnavailableJson(unavailable); + return "failure-output"; + } + return null; +} + export async function login( method?: AuthMethod, env: EnvironmentConfig = getEnvironmentConfig(), + projectDir: string = cwd(), ): Promise { + if (isJsonMode() && method !== undefined) { + await outputLoginExplicitMethodJson(); + return null; + } + + // A bare `veryfront login` is the documented first step of the deploy + // journey, and an already-authenticated developer ran it only to be asked for + // a token they do not need. Report the session instead. An explicit method is + // intent to sign in again, so account switching still works — and a session + // that no longer validates falls through to the normal flow. + if (method === undefined) { + const existing = await describeExistingSession(env, projectDir); + if (existing === "failure-output") return null; + if (existing) return existing; + if (isJsonMode()) { + await outputLoginAuthenticationRequiredJson(); + return null; + } + } + if (!isInteractive() && (method === undefined || method === "token")) { cliLogger.error("Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode."); return null; @@ -382,24 +811,44 @@ export async function login( export async function ensureAuthenticated( env: EnvironmentConfig = getEnvironmentConfig(), + projectDir: string = cwd(), ): Promise { const humanOutput = !isJsonMode(); - if (env.apiToken) { - const credential = await validateCredential(env.apiToken, env); + const candidates = await resolveApiCredentialCandidatesForAuth(env, projectDir); + for (const candidate of candidates) { + let credential: AuthIdentity | null; + try { + credential = await validateCredential( + candidate.apiToken, + candidate.validationEnv, + candidate.apiTokenSource === "token-store" + ? { throwOnCredentialValidationUnavailable: true } + : undefined, + ); + } catch (error) { + if ( + candidate.apiTokenSource === "token-store" && + error instanceof CredentialValidationUnavailableError + ) { + return null; + } + throw error; + } if (credential) return credential; - if (humanOutput) { + + if (candidate.apiTokenSource === "env" && humanOutput) { console.log(" " + warning("Warning: VERYFRONT_API_TOKEN is invalid")); } - } - - const storedToken = await readToken(env); - if (storedToken) { - const credential = await validateCredential(storedToken, env); - if (credential) return credential; - await deleteToken(env); - if (humanOutput) { - console.log(" " + warning("Session expired. Please log in again.")); + if (candidate.apiTokenSource === "config-file" && humanOutput) { + console.log(" " + warning("Warning: apiToken from veryfront.json is invalid")); + } + if (candidate.authoritative) return null; + if (candidate.apiTokenSource === "token-store") { + await deleteToken(env); + if (humanOutput) { + console.log(" " + warning("Session expired. Please log in again.")); + } } } @@ -410,9 +859,11 @@ export async function ensureAuthenticated( return null; } - return login(undefined, env); + return login(undefined, env, projectDir); } +const CREDENTIAL_VALIDATION_UNAVAILABLE = Symbol("credential-validation-unavailable"); + export async function logout(env: EnvironmentConfig = getEnvironmentConfig()): Promise { await deleteToken(env); console.log(); @@ -421,16 +872,30 @@ export async function logout(env: EnvironmentConfig = getEnvironmentConfig()): P async function reportCredential( token: string, - source: "env" | "token-store", + source: ApiTokenSource, env: EnvironmentConfig, -): Promise { - const credential = await validateCredential(token, env); - if (!credential) return null; +): Promise { + let credential: AuthIdentity | null; + try { + credential = await validateCredential(token, env, { + throwOnCredentialValidationUnavailable: true, + }); + } catch (error) { + if (error instanceof CredentialValidationUnavailableError) { + return CREDENTIAL_VALIDATION_UNAVAILABLE; + } + throw error; + } + if (!credential) { + if (source === "token-store") await deleteToken(env); + return null; + } + const displayedSource = source === "env-file" ? "env" : source; if (!isApiKeyIdentity(credential)) { const userInfo = credential; if (isJsonMode()) { - await outputJson(createSuccessEnvelope("whoami", { ...userInfo, source })); + await outputJson(createSuccessEnvelope("whoami", { ...userInfo, source: displayedSource })); return userInfo; } @@ -441,7 +906,7 @@ async function reportCredential( await outputJson(createSuccessEnvelope("whoami", { authenticated: true, credential_type: "api_key", - source, + source: displayedSource, })); return { authenticated: true, type: "apiKey" }; } @@ -452,8 +917,10 @@ async function reportCredential( console.log( " " + dim( - source === "env" + source === "env" || source === "env-file" ? describeApiTokenSource(token) + : source === "config-file" + ? "apiToken from veryfront.json" : `Token stored at: ${getTokenLocation(env)}`, ), ); @@ -463,15 +930,16 @@ async function reportCredential( export async function whoami( env: EnvironmentConfig = getEnvironmentConfig(), ): Promise { - if (env.apiToken) { - const result = await reportCredential(env.apiToken, "env", env); - if (result) return result; - } - - const storedToken = await readToken(env); - if (storedToken) { - const result = await reportCredential(storedToken, "token-store", env); + const candidates = await resolveApiCredentialCandidatesForAuth(env); + for (const candidate of candidates) { + const result = await reportCredential( + candidate.apiToken, + candidate.apiTokenSource, + candidate.validationEnv, + ); + if (result === CREDENTIAL_VALIDATION_UNAVAILABLE) break; if (result) return result; + if (candidate.authoritative) break; } if (isJsonMode()) { diff --git a/cli/commands/init/init-command.ts b/cli/commands/init/init-command.ts index 362fe04068..3828da3f1e 100644 --- a/cli/commands/init/init-command.ts +++ b/cli/commands/init/init-command.ts @@ -262,44 +262,38 @@ export async function initCommand( } } else { const { chdir } = await import("veryfront/platform"); - const { ensureAuthenticated, readToken } = await import("../../auth/index.ts"); + const { ensureAuthenticated } = await import("../../auth/index.ts"); const { deployCommand } = await import("../deploy/index.ts"); - const authResult = await ensureAuthenticated(); + const authResult = await ensureAuthenticated(undefined, createdProjectDir); if (!authResult) { if (!quiet) console.log(); log(` Authentication required for --deploy. ${manualDeployHint}`); } else { - const token = await readToken(); - if (!token) { - if (!quiet) console.log(); - log(` Could not read auth token. ${manualDeployHint}`); - } else { - if (!quiet) console.log(); - log(` Deploying project...`); - - try { - chdir(createdProjectDir); - - const deployment = await deployCommand({ - projectDir: createdProjectDir, - branch: "main", - env: "production", - force: true, - dryRun: false, - quiet: true, - }); - - if (!deployment) { - throw new Error("Deploy completed without a verified result."); - } - deployedUrl = deployment.url; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!quiet) console.log(); - log(` Deploy failed: ${message}`); - log(` Your project was created locally. ${manualDeployHint}`); + if (!quiet) console.log(); + log(` Deploying project...`); + + try { + chdir(createdProjectDir); + + const deployment = await deployCommand({ + projectDir: createdProjectDir, + branch: "main", + env: "production", + force: true, + dryRun: false, + quiet: true, + }); + + if (!deployment) { + throw new Error("Deploy completed without a verified result."); } + deployedUrl = deployment.url; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!quiet) console.log(); + log(` Deploy failed: ${message}`); + log(` Your project was created locally. ${manualDeployHint}`); } } } diff --git a/cli/commands/init/init.integration.test.ts b/cli/commands/init/init.integration.test.ts index 1e003b08d8..1c6fad3b6f 100644 --- a/cli/commands/init/init.integration.test.ts +++ b/cli/commands/init/init.integration.test.ts @@ -750,6 +750,158 @@ describe("init command integration", () => { }); }); + describe("--deploy authentication", () => { + it("does not treat a parent config credential as the new project's stored session", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-init-auth-parent-" }); + const name = `deploy-auth-${randomSuffix()}`; + const projectDir = join(parentDir, name); + const server = Deno.serve( + { hostname: "127.0.0.1", port: 0, onListen: () => {} }, + () => { + requests++; + return Response.json({ id: "user-1", email: "dev@example.test" }); + }, + ); + const baseUrl = `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`; + let requests = 0; + + try { + await Deno.writeTextFile( + join(parentDir, "veryfront.json"), + `${ + JSON.stringify( + { + apiToken: "parent-config-token", + apiUrl: baseUrl, + projectSlug: "parent-project", + }, + null, + 2, + ) + }\n`, + ); + + const result = await runInitCommand( + [ + name, + "--template", + "minimal", + "--skip-install", + "--skip-env-prompt", + "--deploy", + "--no-color", + ], + { + cwd: parentDir, + env: { + VERYFRONT_API_TOKEN: "", + XDG_CONFIG_HOME: join(parentDir, "config"), + VERYFRONT_NO_UPDATE_CHECK: "1", + CI: "1", + NO_COLOR: "1", + }, + }, + ); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + + assertEquals(result.code, 0); + assertEquals(requests, 0); + assertEquals(output.includes("Authentication required for --deploy."), true); + assertEquals(output.includes("Could not read auth token."), false); + assertEquals(await exists(join(projectDir, "app", "page.tsx")), true); + } finally { + await server.shutdown(); + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("deploys with a credential from the created project's config", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-init-auth-project-" }); + const name = `deploy-auth-${randomSuffix()}`; + const projectDir = join(parentDir, name); + const requests: Array<{ path: string; authorization: string | null }> = []; + const server = Deno.serve( + { hostname: "127.0.0.1", port: 0, onListen: () => {} }, + (request) => { + const url = new URL(request.url); + requests.push({ + path: url.pathname, + authorization: request.headers.get("authorization"), + }); + if (url.pathname === "/me") { + return Response.json({ id: "user-1", email: "dev@example.test" }); + } + return Response.json({ error: "deployment unavailable" }, { status: 500 }); + }, + ); + const baseUrl = `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`; + + try { + await Deno.mkdir(projectDir); + await Deno.writeTextFile( + join(projectDir, "veryfront.json"), + `${ + JSON.stringify( + { + apiToken: "project-config-token", + apiUrl: baseUrl, + projectSlug: "created-project", + }, + null, + 2, + ) + }\n`, + ); + + const result = await runInitCommand( + [ + name, + "--template", + "minimal", + "--skip-install", + "--skip-env-prompt", + "--force", + "--deploy", + "--no-color", + ], + { + cwd: parentDir, + env: { + VERYFRONT_API_TOKEN: "", + XDG_CONFIG_HOME: join(parentDir, "config"), + VERYFRONT_NO_UPDATE_CHECK: "1", + CI: "1", + NO_COLOR: "1", + }, + }, + ); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + + assertEquals(result.code, 0); + assertEquals(output.includes("Deploying project..."), true); + assertEquals(output.includes("Could not read auth token."), false); + assertEquals(output.includes("Deploy failed:"), true); + assertEquals(output.includes("Your project was created locally."), true); + assertEquals(output.includes("to deploy later."), true); + assertEquals(requests[0], { + path: "/me", + authorization: "Bearer project-config-token", + }); + assertEquals( + requests.some((request) => + request.path !== "/me" && + request.authorization === "Bearer project-config-token" + ), + true, + ); + assertEquals(await exists(join(projectDir, "app", "page.tsx")), true); + } finally { + await server.shutdown(); + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + }); + describe("output messages", () => { it("should show success message", async () => { const result = await runInitCommand([projectName, "-t", "minimal", "--skip-install"]); diff --git a/cli/commands/login/command-help.test.ts b/cli/commands/login/command-help.test.ts new file mode 100644 index 0000000000..7152ba09ce --- /dev/null +++ b/cli/commands/login/command-help.test.ts @@ -0,0 +1,35 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { showCommandHelp } from "../../help/command-help.ts"; + +function captureConsoleLog(run: () => void): string { + const output: string[] = []; + const originalLog = console.log; + try { + console.log = (msg?: unknown, ...rest: unknown[]) => { + output.push(String(msg), ...rest.map(String)); + }; + run(); + } finally { + console.log = originalLog; + } + return output.join("\n"); +} + +describe("cli/commands/login/command-help", () => { + it("documents that explicit login methods are unavailable in JSON mode", () => { + const output = captureConsoleLog(() => showCommandHelp("login")); + + assertStringIncludes(output, "veryfront login --google"); + assertStringIncludes(output, "--json"); + assertStringIncludes(output, "usage error"); + }); + + it("documents shell-token precedence when switching accounts", () => { + const output = captureConsoleLog(() => showCommandHelp("login")); + + assertStringIncludes(output, "VERYFRONT_API_TOKEN"); + assertStringIncludes(output, "unset or replace"); + }); +}); diff --git a/cli/commands/login/command-help.ts b/cli/commands/login/command-help.ts index 785255619b..9e49a132ba 100644 --- a/cli/commands/login/command-help.ts +++ b/cli/commands/login/command-help.ts @@ -31,7 +31,9 @@ export const loginHelp: CommandHelp = { "veryfront login --token", ], notes: [ - "Without options, prompts for authentication method", + "Without options, a valid session returns immediately. Use an explicit method to sign in again. If veryfront.json contains apiToken, remove or replace it before using another method to switch accounts", + "If VERYFRONT_API_TOKEN is set in your shell, unset or replace it before using another method to switch accounts.", + "Explicit methods (--google, --github, --microsoft, --token) are not supported with --json. Combining them with --json returns a usage error.", "OAuth methods open browser for authentication", "Token is stored in ~/.config/veryfront/token", "Exits 1 when no credential was obtained, so scripts can gate on it", diff --git a/cli/commands/up/command.test.ts b/cli/commands/up/command.test.ts index 225d995c36..1d2944db8b 100644 --- a/cli/commands/up/command.test.ts +++ b/cli/commands/up/command.test.ts @@ -67,6 +67,26 @@ async function captureExit(run: () => Promise): Promise { } } +async function rejectExit(run: () => Promise): Promise { + const originalExit = Deno.exit; + // deno-lint-ignore no-explicit-any + (Deno as any).exit = (code = 0) => { + throw new ExitSentinel(code); + }; + + try { + await run(); + } catch (error) { + if (error instanceof ExitSentinel) { + throw new Error(`Command exited unexpectedly with code ${error.code}`); + } + throw error; + } finally { + // deno-lint-ignore no-explicit-any + (Deno as any).exit = originalExit; + } +} + async function captureLog(run: () => Promise): Promise<{ result: T; output: string[] }> { const output: string[] = []; const originalLog = console.log; @@ -261,6 +281,57 @@ describe("Up Command", () => { }); describe("upCommand", () => { + it("authenticates from the explicit project directory", async () => { + const projectDir = await Deno.makeTempDir(); + const authHome = await Deno.makeTempDir(); + const { deployProject, requests } = recordingDeployProject(VERIFIED_OUTCOME); + let requestedUrl = ""; + let requestedAuth = ""; + + try { + setNonInteractive(true); + await Deno.writeTextFile(join(projectDir, "package.json"), "{}\n"); + await Deno.writeTextFile( + join(projectDir, "veryfront.json"), + `${ + JSON.stringify( + { + projectSlug: "target-project", + apiToken: "target-config-token", + apiUrl: "https://target-control.example.test/api", + }, + null, + 2, + ) + }\n`, + ); + const env = createTestEnvironmentConfig({ + apiToken: undefined, + homeDir: authHome, + xdgConfigHome: authHome, + }); + + await withMockFetch( + ((input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + requestedUrl = request.url; + requestedAuth = request.headers.get("Authorization") ?? ""; + return Promise.resolve(identityResponse()); + }) as typeof fetch, + () => rejectExit(() => upCommand({ projectDir }, env, { deployProject })), + ); + + assertEquals(requestedUrl, "https://target-control.example.test/api/me"); + assertEquals(requestedAuth, "Bearer target-config-token"); + assertEquals(requests.length, 1); + assertEquals(requests[0]?.projectDir, projectDir); + } finally { + resetInteractiveMode(); + await Deno.remove(projectDir, { recursive: true }); + await Deno.remove(authHome, { recursive: true }); + } + }); + it("exits nonzero after an unauthenticated JSON result", async () => { const tempDir = await Deno.makeTempDir(); diff --git a/cli/commands/up/command.ts b/cli/commands/up/command.ts index 1b103e79b5..aa91316143 100644 --- a/cli/commands/up/command.ts +++ b/cli/commands/up/command.ts @@ -148,7 +148,7 @@ export async function upCommand( const { projectDir = cwd(), force = false, dryRun = false } = options; const jsonOutput = isJsonMode(); - const userInfo = await ensureAuthenticated(env); + const userInfo = await ensureAuthenticated(env, projectDir); if (!userInfo) { if (jsonOutput) { const message = "Not authenticated. Set VERYFRONT_API_TOKEN or run veryfront login."; diff --git a/cli/help/command-definitions.test.ts b/cli/help/command-definitions.test.ts index 9c3888939c..1c61cb131c 100644 --- a/cli/help/command-definitions.test.ts +++ b/cli/help/command-definitions.test.ts @@ -3,7 +3,7 @@ import "#veryfront/schemas/_test-setup.ts"; * Tests for command definitions */ -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertExists, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { COMMANDS } from "./command-definitions.ts"; @@ -110,6 +110,17 @@ describe("command-definitions", () => { assertExists(githubOpt); assertExists(microsoftOpt); }); + + it("documents bare login existing-session behavior", () => { + const notes = login.notes ?? []; + const text = notes.join(" "); + + assertStringIncludes(text, "valid session returns immediately"); + assertStringIncludes(text, "explicit method"); + assertStringIncludes(text, "veryfront.json"); + assertStringIncludes(text, "apiToken"); + assertStringIncludes(text, "remove or replace"); + }); }); describe("mcp command", () => { diff --git a/cli/router.ts b/cli/router.ts index c4d1cb275d..c73ad2fc74 100644 --- a/cli/router.ts +++ b/cli/router.ts @@ -60,6 +60,17 @@ const commands: Record = { "login": async () => async (args) => { const { parseLoginMethod, parseProvider } = await import("./auth/utils.ts"); const provider = parseProvider(args); + const method = parseLoginMethod(args); + if (isJsonMode() && (provider || method)) { + await outputCliJsonError("login", { + code: "USAGE_ERROR", + slug: "invalid-arguments", + registrySlug: "invalid-argument", + message: "Explicit login methods are not supported with --json.", + }); + exitProcess(2); + return; + } // Every branch reports failure the same way: exit non-zero so scripts can // tell a failed login from a successful one, whichever credential was asked for. if (provider === "anthropic") { @@ -73,7 +84,7 @@ const commands: Record = { return; } const { login } = await import("./auth/index.ts"); - if (!await login(parseLoginMethod(args))) exitProcess(1); + if (!await login(method)) exitProcess(1); }, "logout": async () => async (args) => { const { parseProvider } = await import("./auth/utils.ts"); diff --git a/cli/shared/config.ts b/cli/shared/config.ts index 3eebaca49c..d5094e1eeb 100644 --- a/cli/shared/config.ts +++ b/cli/shared/config.ts @@ -70,7 +70,14 @@ export const getResolvedConfigSchema = defineSchema((v) => ); export const ResolvedConfigSchema = lazySchema(getResolvedConfigSchema); export type ResolvedConfig = InferSchema>; -type ApiTokenSource = NonNullable; +export type ApiTokenSource = NonNullable; + +export interface ApiCredentialCandidate { + apiToken: string; + apiTokenSource: ApiTokenSource; + validationEnv: EnvironmentConfig; + authoritative: boolean; +} interface ConfigFileResolution { config: VeryfrontConfig | null; @@ -129,18 +136,7 @@ async function readConfigFileResolution(projectDir: string): Promise { + const fs = createFileSystem(); + const configJsonPath = join(projectDir, "veryfront.json"); + + try { + if (await fs.exists(configJsonPath)) { + const content = await fs.readTextFile(configJsonPath); + const parsed = VeryfrontConfigSchema.safeParse(JSON.parse(content)); + return parsed.success ? parsed.data : null; + } + } catch (error) { + cliLogger.debug(`Failed to read veryfront.json:`, error); + } + + return null; +} + export async function writeProjectSlug(projectDir: string, slug: string): Promise { const fs = createFileSystem(); const configJsonPath = join(projectDir, "veryfront.json"); @@ -211,37 +224,92 @@ async function resolveApiTokenForMode( configFile: VeryfrontConfig | null, interactive: boolean, ): Promise<{ apiToken: string | null; apiTokenSource?: ApiTokenSource }> { + const [candidate] = await resolveApiCredentialCandidates(env, configFile, interactive, env); + if (candidate) { + return { + apiToken: candidate.apiToken, + apiTokenSource: candidate.apiTokenSource, + }; + } + + return { apiToken: null }; +} + +async function resolveApiCredentialCandidates( + env: EnvironmentConfig, + configFile: VeryfrontConfig | null, + interactive: boolean, + validationEnv: EnvironmentConfig, +): Promise { const envToken = env.apiToken; const envSource = envToken ? getEnvSource("VERYFRONT_API_TOKEN") : { source: "unset" as const }; const storedToken = await readToken(env); + const candidates: ApiCredentialCandidate[] = []; - if (envToken && envSource.source !== "env-file") { - return { + const shellEnvToken = envToken && envSource.source !== "env-file"; + const projectEnvTokenAfterStored = interactive && envToken && envSource.source === "env-file" && + storedToken; + + if (shellEnvToken) { + candidates.push({ apiToken: envToken, apiTokenSource: "env", - }; + validationEnv, + authoritative: true, + }); } if (configFile?.apiToken) { - return { apiToken: configFile.apiToken, apiTokenSource: "config-file" }; + candidates.push({ + apiToken: configFile.apiToken, + apiTokenSource: "config-file", + validationEnv, + authoritative: true, + }); } - if (interactive && envToken && envSource.source === "env-file" && storedToken) { - return { apiToken: storedToken, apiTokenSource: "token-store" }; + if (projectEnvTokenAfterStored) { + candidates.push({ + apiToken: storedToken, + apiTokenSource: "token-store", + validationEnv, + authoritative: false, + }); } - if (envToken) { - return { + if (envToken && !shellEnvToken) { + candidates.push({ apiToken: envToken, apiTokenSource: envSource.source === "env-file" ? "env-file" : "env", - }; + validationEnv, + authoritative: envSource.source !== "env-file", + }); } - if (storedToken) { - return { apiToken: storedToken, apiTokenSource: "token-store" }; + if (storedToken && !projectEnvTokenAfterStored) { + candidates.push({ + apiToken: storedToken, + apiTokenSource: "token-store", + validationEnv, + authoritative: false, + }); } - return { apiToken: null }; + return candidates; +} + +export async function resolveApiCredentialCandidatesForAuth( + env: EnvironmentConfig = getEnvironmentConfig(), + projectDir: string = cwd(), + interactive = true, +): Promise { + const configFile = await readConfigJsonFile(projectDir); + const validationEnv = { + ...env, + apiUrl: resolveCliApiUrl(env, configFile?.apiUrl), + }; + + return resolveApiCredentialCandidates(env, configFile, interactive, validationEnv); } async function resolveConfigBase( @@ -258,7 +326,7 @@ async function resolveConfigBase( let { apiToken, apiTokenSource } = await resolveApiTokenForMode(env, configFile, interactive); if (!apiToken && interactive) { - const userInfo = await ensureAuthenticated(env); + const userInfo = await ensureAuthenticated(env, dir); if (!userInfo) throw new Error("Authentication required for this operation."); apiToken = (await readToken(env)) ?? null; apiTokenSource = apiToken ? "token-store" : undefined;