From 0327e99d17cf873d85c00176bc33a4c88ef54f51 Mon Sep 17 00:00:00 2001 From: "aiste.grigaliunaite@decodo.com" Date: Tue, 8 Sep 2026 17:10:16 +0300 Subject: [PATCH 1/4] api key support --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/auth/commands/setup.ts | 63 ++++-- src/auth/commands/whoami.ts | 15 +- src/auth/constants.ts | 3 + src/auth/services/config.ts | 38 +++- src/auth/services/resolve-token.ts | 53 ++++- src/auth/types/config.ts | 3 +- src/auth/types/credential.ts | 6 + src/cli/services/global-opts.ts | 1 + src/index.ts | 8 +- src/scrape/services/auth-validation.ts | 7 +- src/scrape/services/client.ts | 13 +- src/scrape/services/run-target-scrape.ts | 18 +- src/scrape/types/run-target-scrape.ts | 3 +- tests/auth/commands/setup.test.ts | 85 ++++++++ tests/auth/commands/whoami.test.ts | 30 ++- tests/auth/services/resolve-token.test.ts | 200 ++++++++++++++++-- tests/index.test.ts | 26 +++ tests/scrape/commands/scrape.test.ts | 2 +- tests/scrape/commands/screenshot.test.ts | 2 +- tests/scrape/commands/search.test.ts | 2 +- tests/scrape/services/auth-validation.test.ts | 29 ++- .../scrape/services/run-target-scrape.test.ts | 6 +- 24 files changed, 546 insertions(+), 79 deletions(-) create mode 100644 src/auth/types/credential.ts diff --git a/package.json b/package.json index bea7b15..33380a2 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ }, "packageManager": "pnpm@10.33.3", "dependencies": { - "@decodo/sdk-ts": "^2.1.2", + "@decodo/sdk-ts": "^2.3.0", "commander": "^14.0.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0146088..e2b28b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ importers: .: dependencies: '@decodo/sdk-ts': - specifier: ^2.1.2 - version: 2.1.2 + specifier: ^2.3.0 + version: 2.3.0 commander: specifier: ^14.0.0 version: 14.0.3 @@ -105,8 +105,8 @@ packages: resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} engines: {node: '>= 20.12.0'} - '@decodo/sdk-ts@2.1.2': - resolution: {integrity: sha512-V/SdHS0DV9L8gBJc5ljQbTmbgV7C8Cn7V91qn3JfnHKgvSmuUq2kLH27UPgCbAa0T2ZVXOkBzbIog5ZnOzAUMg==} + '@decodo/sdk-ts@2.3.0': + resolution: {integrity: sha512-iTTmTvBhezY4SD9sQk+0jfKxMhNSTtUDGscw0J6cHDIhLsjpAtKE+duh5+HjTqjgMJ3o+8Be36ZLqbGTFBjWXA==} engines: {node: '>=18.0.0'} '@esbuild/aix-ppc64@0.28.1': @@ -963,7 +963,7 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@decodo/sdk-ts@2.1.2': + '@decodo/sdk-ts@2.3.0': dependencies: zod: 4.4.3 diff --git a/src/auth/commands/setup.ts b/src/auth/commands/setup.ts index 015f0ca..7bb31f7 100644 --- a/src/auth/commands/setup.ts +++ b/src/auth/commands/setup.ts @@ -1,35 +1,74 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { getRootOpts } from "../../cli/services/global-opts.js"; import { CliUsageError } from "../../platform/errors/cli-usage-error.js"; import { handleCliError } from "../../platform/services/handle-cli-error.js"; import { promptHidden } from "../../platform/services/prompt-hidden.js"; -import { validateAuthToken } from "../../scrape/services/auth-validation.js"; -import { PLAYGROUND_URL } from "../constants.js"; +import { validateCredential } from "../../scrape/services/auth-validation.js"; +import { AMBIGUOUS_CREDENTIAL_MESSAGE, PLAYGROUND_URL } from "../constants.js"; import { getConfigPath, writeConfig } from "../services/config.js"; +import type { DecodoConfig } from "../types/config.js"; +import type { AuthCredential } from "../types/credential.js"; const TOKEN_PROMPT = `Paste your Web Scraping API basic auth token (${PLAYGROUND_URL}): `; +interface SetupOptions { + apiKey?: string; + token?: string; +} + +function credentialFrom( + apiKey: string | undefined, + token: string | undefined +): AuthCredential | undefined { + if (token) { + return { kind: "token", value: token }; + } + + if (apiKey) { + return { kind: "apiKey", value: apiKey }; + } + + return; +} + +function toConfig(credential: AuthCredential): DecodoConfig { + if (credential.kind === "apiKey") { + return { apiKey: credential.value }; + } + + return { authToken: credential.value }; +} + export const setupCommand = new Command("setup") .description("Configure the Decodo CLI with your auth token") .option( "--token ", "Web Scraping API basic auth token (non-interactive)" ) - .action(async (options: { token?: string }, command) => { + .addOption( + new Option("--api-key ", "API key (non-interactive)").hideHelp() + ) + .action(async (options: SetupOptions, command) => { const rootOpts = getRootOpts(command); - const token = ( - options.token?.trim() || - rootOpts.token?.trim() || - (await promptHidden(TOKEN_PROMPT)) - ).trim(); + const apiKey = (options.apiKey ?? rootOpts.apiKey)?.trim(); + const token = (options.token ?? rootOpts.token)?.trim(); + + if (apiKey && token) { + handleCliError(new CliUsageError(AMBIGUOUS_CREDENTIAL_MESSAGE)); + } + + const credential: AuthCredential = credentialFrom(apiKey, token) ?? { + kind: "token", + value: (await promptHidden(TOKEN_PROMPT)).trim(), + }; - if (!token) { + if (!credential.value) { handleCliError(new CliUsageError("auth token is required.")); } try { - await validateAuthToken(token); - await writeConfig({ authToken: token }); + await validateCredential(credential); + await writeConfig(toConfig(credential)); console.log(`Setup complete. Configuration saved to ${getConfigPath()}`); } catch (err) { handleCliError(err, { fallbackMessage: "Setup failed." }); diff --git a/src/auth/commands/whoami.ts b/src/auth/commands/whoami.ts index c63d9ec..5153f7f 100644 --- a/src/auth/commands/whoami.ts +++ b/src/auth/commands/whoami.ts @@ -4,19 +4,28 @@ import { handleCliError } from "../../platform/services/handle-cli-error.js"; import { AuthRequiredError } from "../errors/auth-required-error.js"; import { mask } from "../services/mask.js"; import { resolveAuthToken } from "../services/resolve-token.js"; +import type { AuthType } from "../types/credential.js"; + +const CREDENTIAL_LABEL: Record = { + apiKey: "api key", + token: "token", +}; export const whoamiCommand = new Command("whoami") .description("Show the active auth source and masked token") .action(async (_options, command) => { const rootOpts = getRootOpts(command); - const { token, source } = await resolveAuthToken({ + const { credential, source } = await resolveAuthToken({ + apiKey: rootOpts.apiKey, token: rootOpts.token, }); - if (!token) { + if (!credential) { handleCliError(new AuthRequiredError()); } console.log(`source: ${source}`); - console.log(`token: ${mask(token, 4, -4)}`); + console.log( + `${CREDENTIAL_LABEL[credential.kind]}: ${mask(credential.value, 4, -4)}` + ); }); diff --git a/src/auth/constants.ts b/src/auth/constants.ts index 1a286d6..c2c198b 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -1,3 +1,6 @@ export const PLAYGROUND_URL = "https://dashboard.decodo.com/playground"; export const AUTH_MISSING_MESSAGE = "No auth token found."; + +export const AMBIGUOUS_CREDENTIAL_MESSAGE = + "Provide either --token or --api-key, not both."; diff --git a/src/auth/services/config.ts b/src/auth/services/config.ts index 5d8cfc8..bb5c1d7 100644 --- a/src/auth/services/config.ts +++ b/src/auth/services/config.ts @@ -10,6 +10,19 @@ export function getConfigPath(): string { return join(getConfigDir(), CONFIG_FILE); } +function readCredentialField( + parsed: Partial, + key: keyof DecodoConfig +): string | undefined { + const value = parsed[key]; + + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + + return; +} + function parseConfig( raw: string, configPath: string @@ -22,13 +35,28 @@ function parseConfig( throw new ConfigParseError(configPath); } - if (typeof parsed.authToken === "string" && parsed.authToken.length > 0) { - return { - authToken: parsed.authToken, - }; + if (!parsed || typeof parsed !== "object") { + return; } - return; + const apiKey = readCredentialField(parsed, "apiKey"); + const authToken = readCredentialField(parsed, "authToken"); + + if (!(apiKey || authToken)) { + return; + } + + const config: DecodoConfig = {}; + + if (apiKey) { + config.apiKey = apiKey; + } + + if (authToken) { + config.authToken = authToken; + } + + return config; } export async function readConfig(): Promise { diff --git a/src/auth/services/resolve-token.ts b/src/auth/services/resolve-token.ts index c9a59d8..3d7f4e4 100644 --- a/src/auth/services/resolve-token.ts +++ b/src/auth/services/resolve-token.ts @@ -1,34 +1,69 @@ +import { CliUsageError } from "../../platform/errors/cli-usage-error.js"; +import { AMBIGUOUS_CREDENTIAL_MESSAGE } from "../constants.js"; +import type { AuthCredential } from "../types/credential.js"; import { readConfig } from "./config.js"; export type AuthSource = "flag" | "env" | "config" | "none"; export interface ResolvedAuth { + credential: AuthCredential | undefined; source: AuthSource; - token: string | undefined; } export interface ResolveAuthOptions { + apiKey?: string; token?: string; } +function resolveFrom( + source: AuthSource, + apiKey: string | undefined, + token: string | undefined +): ResolvedAuth | undefined { + const resolvedToken = token?.trim(); + + if (resolvedToken) { + return { credential: { kind: "token", value: resolvedToken }, source }; + } + + const resolvedApiKey = apiKey?.trim(); + + if (resolvedApiKey) { + return { credential: { kind: "apiKey", value: resolvedApiKey }, source }; + } + + return; +} + export async function resolveAuthToken( options: ResolveAuthOptions = {} ): Promise { - if (options.token) { - return { token: options.token, source: "flag" }; + if (options.apiKey?.trim() && options.token?.trim()) { + throw new CliUsageError(AMBIGUOUS_CREDENTIAL_MESSAGE); + } + + const fromFlag = resolveFrom("flag", options.apiKey, options.token); + + if (fromFlag) { + return fromFlag; } - const envToken = process.env.DECODO_AUTH_TOKEN; + const fromEnv = resolveFrom( + "env", + process.env.DECODO_API_KEY, + process.env.DECODO_AUTH_TOKEN + ); - if (envToken) { - return { token: envToken, source: "env" }; + if (fromEnv) { + return fromEnv; } const config = await readConfig(); + const fromConfig = resolveFrom("config", config?.apiKey, config?.authToken); - if (config?.authToken) { - return { token: config.authToken, source: "config" }; + if (fromConfig) { + return fromConfig; } - return { token: undefined, source: "none" }; + return { credential: undefined, source: "none" }; } diff --git a/src/auth/types/config.ts b/src/auth/types/config.ts index 0e89951..ea6586e 100644 --- a/src/auth/types/config.ts +++ b/src/auth/types/config.ts @@ -1,3 +1,4 @@ export interface DecodoConfig { - authToken: string; + apiKey?: string; + authToken?: string; } diff --git a/src/auth/types/credential.ts b/src/auth/types/credential.ts new file mode 100644 index 0000000..f324cde --- /dev/null +++ b/src/auth/types/credential.ts @@ -0,0 +1,6 @@ +export type AuthType = "token" | "apiKey"; + +export interface AuthCredential { + kind: AuthType; + value: string; +} diff --git a/src/cli/services/global-opts.ts b/src/cli/services/global-opts.ts index 3867460..d166d85 100644 --- a/src/cli/services/global-opts.ts +++ b/src/cli/services/global-opts.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; export interface RootOptions { + apiKey?: string; token?: string; verbose?: boolean; } diff --git a/src/index.ts b/src/index.ts index 537f2cf..9c13587 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { Command } from "commander"; +import { Command, Option } from "commander"; import { createCommands } from "./cli/register.js"; import { configureCommanderExit } from "./cli/services/configure-commander-exit.js"; import { handleCliError } from "./platform/services/handle-cli-error.js"; @@ -27,6 +27,12 @@ const program = new Command() .option( "--token ", "Basic auth token (overrides DECODO_AUTH_TOKEN and saved config)" + ) + .addOption( + new Option( + "--api-key ", + "API key (overrides DECODO_API_KEY and saved config)" + ).hideHelp() ); async function main(): Promise { diff --git a/src/scrape/services/auth-validation.ts b/src/scrape/services/auth-validation.ts index 9e4a73a..97e491d 100644 --- a/src/scrape/services/auth-validation.ts +++ b/src/scrape/services/auth-validation.ts @@ -5,12 +5,15 @@ import { Target as ScrapeTarget, TimeoutError, } from "@decodo/sdk-ts"; +import type { AuthCredential } from "../../auth/types/credential.js"; import { createDecodoClient } from "./client.js"; const AUTH_PROBE_URL = "https://does-not-exist.decodo.com"; -export async function validateAuthToken(token: string): Promise { - const client = createDecodoClient(token); +export async function validateCredential( + credential: AuthCredential +): Promise { + const client = createDecodoClient(credential); try { await client.webScrapingApi.scrape({ diff --git a/src/scrape/services/client.ts b/src/scrape/services/client.ts index d1f1df9..76f1dd9 100644 --- a/src/scrape/services/client.ts +++ b/src/scrape/services/client.ts @@ -1,15 +1,18 @@ import { DecodoClient, type DecodoSchema } from "@decodo/sdk-ts"; +import type { AuthCredential } from "../../auth/types/credential.js"; import { INTEGRATION_HEADER } from "../constants.js"; export function createDecodoClient( - token: string, + credential: AuthCredential, schema?: DecodoSchema ): DecodoClient { + const credentials = + credential.kind === "apiKey" + ? { apiKey: credential.value } + : { token: credential.value }; + return new DecodoClient({ - webScrapingApi: { - token, - integrationHeader: INTEGRATION_HEADER, - }, + webScrapingApi: { ...credentials, integrationHeader: INTEGRATION_HEADER }, schema, }); } diff --git a/src/scrape/services/run-target-scrape.ts b/src/scrape/services/run-target-scrape.ts index 50c2a04..cb92599 100644 --- a/src/scrape/services/run-target-scrape.ts +++ b/src/scrape/services/run-target-scrape.ts @@ -18,7 +18,7 @@ import { buildScrapeBody, getTargetCommandConfig } from "./command-builder.js"; import { formatScrapeRequestLog } from "./format-scrape-request-log.js"; async function executeScrape({ - token, + credential, schema, body, options, @@ -26,7 +26,7 @@ async function executeScrape({ input, verbose = false, }: ExecuteScrapeOptions): Promise { - const client = createDecodoClient(token, schema); + const client = createDecodoClient(credential, schema); const startedAt = Date.now(); const response = await client.webScrapingApi.scrape( body as unknown as ScrapeRequest @@ -88,9 +88,15 @@ export function createTargetAction( const verbose = rootOpts.verbose === true; try { - const auth = await resolveAuthToken({ token: rootOpts.token }); - verboseLog(verbose, `auth source=${auth.source}`); - if (!auth.token) { + const auth = await resolveAuthToken({ + apiKey: rootOpts.apiKey, + token: rootOpts.token, + }); + verboseLog( + verbose, + `auth source=${auth.source} kind=${auth.credential?.kind ?? "none"}` + ); + if (!auth.credential) { throw new AuthRequiredError(); } @@ -102,7 +108,7 @@ export function createTargetAction( input ); await executeScrape({ - token: auth.token, + credential: auth.credential, schema, body, options, diff --git a/src/scrape/types/run-target-scrape.ts b/src/scrape/types/run-target-scrape.ts index b974b5f..0ace6b5 100644 --- a/src/scrape/types/run-target-scrape.ts +++ b/src/scrape/types/run-target-scrape.ts @@ -1,13 +1,14 @@ import type { DecodoSchema } from "@decodo/sdk-ts"; +import type { AuthCredential } from "../../auth/types/credential.js"; import type { WriteScrapeResponseContext } from "../../output/types/write-scrape-response.js"; export interface ExecuteScrapeOptions { body: Record; + credential: AuthCredential; input?: string; options: Record; outputContext?: Partial; schema: DecodoSchema; - token: string; verbose?: boolean; } diff --git a/tests/auth/commands/setup.test.ts b/tests/auth/commands/setup.test.ts index 9333eb1..40a4347 100644 --- a/tests/auth/commands/setup.test.ts +++ b/tests/auth/commands/setup.test.ts @@ -15,6 +15,7 @@ async function runSetup( const { setupCommand } = await import("../../../src/auth/commands/setup.js"); const program = new Command() .option("--token ", "global token") + .option("--api-key ", "global api key") .addCommand(setupCommand); await program.parseAsync([...globalArgs, "setup", ...setupArgs], { from: "user", @@ -73,6 +74,90 @@ describe("setupCommand", () => { expect(stdout.join("\n")).toContain("Setup complete"); }); + it("saves an api key when --api-key is provided", async () => { + await runSetup(["--api-key", "valid-key"]); + + const { readConfig } = await import("../../../src/auth/services/config.js"); + expect(await readConfig()).toEqual({ + apiKey: "valid-key", + }); + expect(stdout.join("\n")).toContain("Setup complete"); + }); + + it("saves an api key from global --api-key", async () => { + await runSetup([], ["--api-key", "global-key"]); + + const { readConfig } = await import("../../../src/auth/services/config.js"); + expect(await readConfig()).toEqual({ + apiKey: "global-key", + }); + }); + + it("rejects --api-key and --token together", async () => { + await expect( + runSetup(["--api-key", "setup-key", "--token", "setup-token"]) + ).rejects.toThrow("process.exit:2"); + + const { readConfig } = await import("../../../src/auth/services/config.js"); + expect(await readConfig()).toBeUndefined(); + expect(stderr.join("\n")).toContain( + "Provide either --token or --api-key, not both." + ); + }); + + it("validates an api key against the data api endpoint", async () => { + await runSetup(["--api-key", "valid-key"]); + + expect(fetch).toHaveBeenCalledWith( + "https://data.decodo.com/v1/scrape", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer valid-key", + }), + }) + ); + }); + + it("validates a token against the scraper api endpoint", async () => { + await runSetup(["--token", "valid-token"]); + + expect(fetch).toHaveBeenCalledWith( + "https://scraper-api.decodo.com/v2/scrape", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Basic valid-token", + }), + }) + ); + }); + + it("rejects a global --api-key mixed with a subcommand --token", async () => { + await expect( + runSetup(["--token", "setup-token"], ["--api-key", "global-key"]) + ).rejects.toThrow("process.exit:2"); + + const { readConfig } = await import("../../../src/auth/services/config.js"); + expect(await readConfig()).toBeUndefined(); + }); + + it("rejects a global --token mixed with a subcommand --api-key", async () => { + await expect( + runSetup(["--api-key", "setup-key"], ["--token", "global-token"]) + ).rejects.toThrow("process.exit:2"); + + const { readConfig } = await import("../../../src/auth/services/config.js"); + expect(await readConfig()).toBeUndefined(); + }); + + it("trims surrounding whitespace from a saved api key", async () => { + await runSetup(["--api-key", " spaced-key "]); + + const { readConfig } = await import("../../../src/auth/services/config.js"); + expect(await readConfig()).toEqual({ + apiKey: "spaced-key", + }); + }); + it("does not save config on 401", async () => { vi.mocked(fetch).mockResolvedValue({ ok: false, diff --git a/tests/auth/commands/whoami.test.ts b/tests/auth/commands/whoami.test.ts index b3275b3..67b2637 100644 --- a/tests/auth/commands/whoami.test.ts +++ b/tests/auth/commands/whoami.test.ts @@ -8,6 +8,7 @@ async function runWhoami(args: string[]): Promise { ); const program = new Command() .option("--token ", "global token") + .option("--api-key ", "global api key") .addCommand(whoamiCommand); await program.parseAsync(args, { from: "user" }); } @@ -15,13 +16,16 @@ async function runWhoami(args: string[]): Promise { describe("whoamiCommand", () => { let restoreConfigHome: () => void; let previousEnvToken: string | undefined; + let previousEnvApiKey: string | undefined; let exitCode: number | undefined; let stdout: string[]; beforeEach(async () => { ({ restore: restoreConfigHome } = await isolateConfigHome()); previousEnvToken = process.env.DECODO_AUTH_TOKEN; + previousEnvApiKey = process.env.DECODO_API_KEY; delete process.env.DECODO_AUTH_TOKEN; + delete process.env.DECODO_API_KEY; vi.resetModules(); exitCode = undefined; stdout = []; @@ -44,6 +48,11 @@ describe("whoamiCommand", () => { } else { process.env.DECODO_AUTH_TOKEN = previousEnvToken; } + if (previousEnvApiKey === undefined) { + delete process.env.DECODO_API_KEY; + } else { + process.env.DECODO_API_KEY = previousEnvApiKey; + } vi.resetModules(); }); @@ -78,7 +87,26 @@ describe("whoamiCommand", () => { expect(stdout).toContain("token: flag...alue"); }); - it("exits with code 3 when no token is available", async () => { + it("prints the api key label for a saved api key", async () => { + const { writeConfig } = await import( + "../../../src/auth/services/config.js" + ); + await writeConfig({ apiKey: "abcdefghijklmnop" }); + + await runWhoami(["whoami"]); + + expect(stdout).toContain("source: config"); + expect(stdout).toContain("api key: abcd...mnop"); + }); + + it("prints the api key from global --api-key", async () => { + await runWhoami(["--api-key", "abcdefghijklmnop", "whoami"]); + + expect(stdout).toContain("source: flag"); + expect(stdout).toContain("api key: abcd...mnop"); + }); + + it("exits with code 3 when no credential is available", async () => { await expect(runWhoami(["whoami"])).rejects.toThrow("process.exit:3"); expect(exitCode).toBe(3); }); diff --git a/tests/auth/services/resolve-token.test.ts b/tests/auth/services/resolve-token.test.ts index 7f3de9e..f55b435 100644 --- a/tests/auth/services/resolve-token.test.ts +++ b/tests/auth/services/resolve-token.test.ts @@ -1,23 +1,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isolateConfigHome } from "../../platform/helpers/config-home.js"; +const ENV_KEYS = ["DECODO_AUTH_TOKEN", "DECODO_API_KEY"] as const; + describe("resolveAuthToken", () => { let restoreConfigHome: () => void; - let previousEnvToken: string | undefined; + let previousEnv: Record; beforeEach(async () => { ({ restore: restoreConfigHome } = await isolateConfigHome()); - previousEnvToken = process.env.DECODO_AUTH_TOKEN; - delete process.env.DECODO_AUTH_TOKEN; + previousEnv = {}; + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + delete process.env[key]; + } vi.resetModules(); }); afterEach(() => { restoreConfigHome(); - if (previousEnvToken === undefined) { - delete process.env.DECODO_AUTH_TOKEN; - } else { - process.env.DECODO_AUTH_TOKEN = previousEnvToken; + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } } vi.resetModules(); }); @@ -33,7 +41,10 @@ describe("resolveAuthToken", () => { "../../../src/auth/services/resolve-token.js" ); const result = await resolveAuthToken({ token: "flag-token" }); - expect(result).toEqual({ token: "flag-token", source: "flag" }); + expect(result).toEqual({ + credential: { kind: "token", value: "flag-token" }, + source: "flag", + }); }); it("prefers env over config", async () => { @@ -47,7 +58,10 @@ describe("resolveAuthToken", () => { "../../../src/auth/services/resolve-token.js" ); const result = await resolveAuthToken(); - expect(result).toEqual({ token: "env-token", source: "env" }); + expect(result).toEqual({ + credential: { kind: "token", value: "env-token" }, + source: "env", + }); }); it("reads token from config file", async () => { @@ -60,14 +74,176 @@ describe("resolveAuthToken", () => { "../../../src/auth/services/resolve-token.js" ); const result = await resolveAuthToken(); - expect(result).toEqual({ token: "config-token", source: "config" }); + expect(result).toEqual({ + credential: { kind: "token", value: "config-token" }, + source: "config", + }); + }); + + it("returns none when no credential is available", async () => { + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken(); + expect(result).toEqual({ credential: undefined, source: "none" }); + }); + + it("rejects both credential flags supplied together", async () => { + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const { CliUsageError } = await import( + "../../../src/platform/errors/cli-usage-error.js" + ); + + await expect( + resolveAuthToken({ apiKey: "flag-key", token: "flag-token" }) + ).rejects.toThrow(CliUsageError); + }); + + it("allows both env vars to be set without erroring", async () => { + process.env.DECODO_API_KEY = "env-key"; + process.env.DECODO_AUTH_TOKEN = "env-token"; + + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken(); + expect(result).toEqual({ + credential: { kind: "token", value: "env-token" }, + source: "env", + }); + }); + + it("treats a whitespace-only flag as no credential", async () => { + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken({ apiKey: " ", token: " " }); + expect(result).toEqual({ credential: undefined, source: "none" }); + }); + + it("falls through a whitespace-only flag to the env var", async () => { + process.env.DECODO_AUTH_TOKEN = "env-token"; + + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken({ apiKey: " " }); + expect(result).toEqual({ + credential: { kind: "token", value: "env-token" }, + source: "env", + }); + }); + + it("trims surrounding whitespace from a resolved credential", async () => { + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken({ apiKey: " padded-key\n" }); + expect(result).toEqual({ + credential: { kind: "apiKey", value: "padded-key" }, + source: "flag", + }); + }); + + it("resolves an api key from the flag", async () => { + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken({ apiKey: "flag-key" }); + expect(result).toEqual({ + credential: { kind: "apiKey", value: "flag-key" }, + source: "flag", + }); + }); + + it("prefers DECODO_AUTH_TOKEN over DECODO_API_KEY", async () => { + process.env.DECODO_API_KEY = "env-key"; + process.env.DECODO_AUTH_TOKEN = "env-token"; + + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken(); + expect(result).toEqual({ + credential: { kind: "token", value: "env-token" }, + source: "env", + }); + }); + + it("prefers the api key flag over DECODO_AUTH_TOKEN", async () => { + process.env.DECODO_AUTH_TOKEN = "env-token"; + + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken({ apiKey: "flag-key" }); + expect(result).toEqual({ + credential: { kind: "apiKey", value: "flag-key" }, + source: "flag", + }); + }); + + it("prefers the token flag over DECODO_API_KEY", async () => { + process.env.DECODO_API_KEY = "env-key"; + + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken({ token: "flag-token" }); + expect(result).toEqual({ + credential: { kind: "token", value: "flag-token" }, + source: "flag", + }); + }); + + it("reads an api key from the config file", async () => { + const { writeConfig } = await import( + "../../../src/auth/services/config.js" + ); + await writeConfig({ apiKey: "config-key" }); + + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken(); + expect(result).toEqual({ + credential: { kind: "apiKey", value: "config-key" }, + source: "config", + }); }); - it("returns none when no token is available", async () => { + it("prefers the config token over the config api key", async () => { + const { writeConfig } = await import( + "../../../src/auth/services/config.js" + ); + await writeConfig({ apiKey: "config-key", authToken: "config-token" }); + + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + const result = await resolveAuthToken(); + expect(result).toEqual({ + credential: { kind: "token", value: "config-token" }, + source: "config", + }); + }); + + it("still prefers an env api key over a saved config token", async () => { + process.env.DECODO_API_KEY = "env-key"; + const { writeConfig } = await import( + "../../../src/auth/services/config.js" + ); + await writeConfig({ authToken: "config-token" }); + const { resolveAuthToken } = await import( "../../../src/auth/services/resolve-token.js" ); const result = await resolveAuthToken(); - expect(result).toEqual({ token: undefined, source: "none" }); + expect(result).toEqual({ + credential: { kind: "apiKey", value: "env-key" }, + source: "env", + }); }); }); diff --git a/tests/index.test.ts b/tests/index.test.ts index 27f3849..21e00ee 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -40,6 +40,32 @@ describe("cli", () => { expect(output).toContain("-v, --verbose"); }); + it("hides the api key flag from help", () => { + const output = execFileSync(process.execPath, [cliPath, "--help"], { + encoding: "utf8", + }); + + expect(output).not.toContain("--api-key"); + }); + + it("hides the api key flag from setup help", () => { + const output = execFileSync( + process.execPath, + [cliPath, "setup", "--help"], + { + encoding: "utf8", + } + ); + + expect(output).not.toContain("--api-key"); + }); + + it("accepts the hidden api key flag", () => { + const { exitCode } = runCli(["--api-key", "key", "whoami"]); + + expect(exitCode).toBe(0); + }); + it.each([ ["unknown flag", ["--bad-flag"], 2], ["unknown command", ["nosuchcmd"], 2], diff --git a/tests/scrape/commands/scrape.test.ts b/tests/scrape/commands/scrape.test.ts index 9bc9839..1c05282 100644 --- a/tests/scrape/commands/scrape.test.ts +++ b/tests/scrape/commands/scrape.test.ts @@ -27,7 +27,7 @@ describe("createScrapeCommand", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - token: "test-token", + credential: { kind: "token", value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/commands/screenshot.test.ts b/tests/scrape/commands/screenshot.test.ts index c8a89f0..5ff4627 100644 --- a/tests/scrape/commands/screenshot.test.ts +++ b/tests/scrape/commands/screenshot.test.ts @@ -31,7 +31,7 @@ describe("createScreenshotCommand", () => { stdoutBytes = undefined; vi.mocked(resolveAuthToken).mockResolvedValue({ - token: "test-token", + credential: { kind: "token", value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/commands/search.test.ts b/tests/scrape/commands/search.test.ts index 8bf840e..2421d45 100644 --- a/tests/scrape/commands/search.test.ts +++ b/tests/scrape/commands/search.test.ts @@ -27,7 +27,7 @@ describe("createSearchCommand", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - token: "test-token", + credential: { kind: "token", value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/services/auth-validation.test.ts b/tests/scrape/services/auth-validation.test.ts index 4fd054d..0dedda5 100644 --- a/tests/scrape/services/auth-validation.test.ts +++ b/tests/scrape/services/auth-validation.test.ts @@ -5,14 +5,17 @@ import { Target as ScrapeTarget, } from "@decodo/sdk-ts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { validateAuthToken } from "../../../src/scrape/services/auth-validation.js"; +import { validateCredential } from "../../../src/scrape/services/auth-validation.js"; import { createDecodoClient } from "../../../src/scrape/services/client.js"; vi.mock("../../../src/scrape/services/client.js", () => ({ createDecodoClient: vi.fn(), })); -describe("validateAuthToken", () => { +const TOKEN_CREDENTIAL = { kind: "token", value: "test-token" } as const; +const API_KEY_CREDENTIAL = { kind: "apiKey", value: "test-api-key" } as const; + +describe("validateCredential", () => { const scrape = vi.fn(); beforeEach(() => { @@ -29,33 +32,41 @@ describe("validateAuthToken", () => { it("probes auth with the stats-invisible URL", async () => { scrape.mockResolvedValue({ results: [] }); - await validateAuthToken("test-token"); + await validateCredential(TOKEN_CREDENTIAL); - expect(createDecodoClient).toHaveBeenCalledWith("test-token"); + expect(createDecodoClient).toHaveBeenCalledWith(TOKEN_CREDENTIAL); expect(scrape).toHaveBeenCalledWith({ target: ScrapeTarget.Universal, url: "https://does-not-exist.decodo.com", }); }); + it("probes auth with an api key credential", async () => { + scrape.mockResolvedValue({ results: [] }); + + await validateCredential(API_KEY_CREDENTIAL); + + expect(createDecodoClient).toHaveBeenCalledWith(API_KEY_CREDENTIAL); + }); + it("rejects invalid tokens", async () => { scrape.mockRejectedValue(new AuthenticationError("Username invalid.")); - await expect(validateAuthToken("bad-token")).rejects.toThrow( - AuthenticationError - ); + await expect( + validateCredential({ kind: "token", value: "bad-token" }) + ).rejects.toThrow(AuthenticationError); }); it("accepts valid tokens when the probe scrape fails with DecodoError", async () => { scrape.mockRejectedValue(new DecodoError("Request processing failed", 422)); - await expect(validateAuthToken("test-token")).resolves.toBeUndefined(); + await expect(validateCredential(TOKEN_CREDENTIAL)).resolves.toBeUndefined(); }); it("rethrows rate limit errors", async () => { scrape.mockRejectedValue(new RateLimitError("Rate limit exceeded")); - await expect(validateAuthToken("test-token")).rejects.toThrow( + await expect(validateCredential(TOKEN_CREDENTIAL)).rejects.toThrow( RateLimitError ); }); diff --git a/tests/scrape/services/run-target-scrape.test.ts b/tests/scrape/services/run-target-scrape.test.ts index 360b960..c65e977 100644 --- a/tests/scrape/services/run-target-scrape.test.ts +++ b/tests/scrape/services/run-target-scrape.test.ts @@ -42,7 +42,7 @@ describe("createTargetAction", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - token: "test-token", + credential: { kind: "token", value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { @@ -93,7 +93,7 @@ describe("createTargetAction", () => { markdown: false, }); expect(createDecodoClient).toHaveBeenCalledWith( - "test-token", + { kind: "token", value: "test-token" }, BundledSchema.shared ); expect(stdout).toBe('{"ok":true}\n'); @@ -121,7 +121,7 @@ describe("createTargetAction", () => { { from: "user" } ); - expect(stderr).toContain("[verbose] auth source=flag\n"); + expect(stderr).toContain("[verbose] auth source=flag kind=token\n"); expect(stderr).toContain( "[verbose] request target=google_search query=coffee\n" ); From 74c4944d7c7e2769441da4fefb6fa41f57b46de0 Mon Sep 17 00:00:00 2001 From: "aiste.grigaliunaite@decodo.com" Date: Wed, 9 Sep 2026 10:47:56 +0300 Subject: [PATCH 2/4] use one flag for auth --- README.md | 4 +- docs/ARCHITECTURE.md | 2 +- src/auth/commands/setup.ts | 85 +++--- src/auth/commands/whoami.ts | 3 +- src/auth/constants.ts | 3 - src/auth/services/detect-credential-type.ts | 13 + src/auth/services/resolve-token.ts | 67 ++--- src/auth/types/credential.ts | 2 +- src/cli/services/global-opts.ts | 1 - src/index.ts | 10 +- src/scrape/services/client.ts | 2 +- src/scrape/services/run-target-scrape.ts | 7 +- tests/auth/commands/setup.test.ts | 103 ++------ tests/auth/commands/whoami.test.ts | 45 ++-- .../services/detect-credential-type.test.ts | 30 +++ tests/auth/services/resolve-token.test.ts | 249 +++++------------- tests/index.test.ts | 26 -- tests/scrape/commands/scrape.test.ts | 2 +- tests/scrape/commands/screenshot.test.ts | 2 +- tests/scrape/commands/search.test.ts | 2 +- tests/scrape/services/auth-validation.test.ts | 6 +- .../scrape/services/run-target-scrape.test.ts | 6 +- 22 files changed, 236 insertions(+), 434 deletions(-) create mode 100644 src/auth/services/detect-credential-type.ts create mode 100644 tests/auth/services/detect-credential-type.test.ts diff --git a/README.md b/README.md index 09f646e..1c14c2e 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ npx @decodo/cli scrape https://ip.decodo.com --token "$DECODO_AUTH_TOKEN" ## Authentication -Get a basic auth token from the Decodo [Playground](https://dashboard.decodo.com/playground). +Get an auth token from the Decodo [Playground](https://dashboard.decodo.com/playground). ```bash # Interactive — saves token to config @@ -252,7 +252,7 @@ Use the CLI when your agent needs to scrape from a shell, terminal, CI/CD pipeli | Variable | Description | | --- | --- | -| `DECODO_AUTH_TOKEN` | Basic auth token (overrides saved config, below `--token`) | +| `DECODO_AUTH_TOKEN` | Auth token (overrides saved config, below `--token`) | | `DECODO_CONFIG_HOME` | Override config directory (default: `$XDG_CONFIG_HOME/decodo`, else `~/.config/decodo`) | ## Exit codes diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8220b7a..f9c683f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,7 +116,7 @@ then add a branch in `resolveCliExitCode` (and a hint in `handleCliError` if use reports its `source` (`flag` | `env` | `config` | `none`). Persistent config lives in a JSON file resolved through `platform/services/paths.ts` (via `env-paths`) and managed by `auth/services/config.ts` (`readConfig`/`writeConfig`/`clearConfig`). The config file is -written with `0o600` permissions and only persists a validated `authToken`. The `setup`, +written with `0o600` permissions and only persists a validated credential. The `setup`, `reset`, and `whoami` commands are the user-facing surface over these helpers; `mask.ts` keeps tokens from being printed in full. diff --git a/src/auth/commands/setup.ts b/src/auth/commands/setup.ts index 7bb31f7..b51a6fd 100644 --- a/src/auth/commands/setup.ts +++ b/src/auth/commands/setup.ts @@ -1,73 +1,80 @@ -import { Command, Option } from "commander"; +import { AuthenticationError } from "@decodo/sdk-ts"; +import { Command } from "commander"; import { getRootOpts } from "../../cli/services/global-opts.js"; import { CliUsageError } from "../../platform/errors/cli-usage-error.js"; import { handleCliError } from "../../platform/services/handle-cli-error.js"; import { promptHidden } from "../../platform/services/prompt-hidden.js"; import { validateCredential } from "../../scrape/services/auth-validation.js"; -import { AMBIGUOUS_CREDENTIAL_MESSAGE, PLAYGROUND_URL } from "../constants.js"; +import { PLAYGROUND_URL } from "../constants.js"; import { getConfigPath, writeConfig } from "../services/config.js"; +import { detectCredentialType } from "../services/detect-credential-type.js"; import type { DecodoConfig } from "../types/config.js"; -import type { AuthCredential } from "../types/credential.js"; +import type { AuthCredential, AuthType } from "../types/credential.js"; -const TOKEN_PROMPT = `Paste your Web Scraping API basic auth token (${PLAYGROUND_URL}): `; +const TOKEN_PROMPT = `Paste your Web Scraping API auth token (${PLAYGROUND_URL}): `; interface SetupOptions { - apiKey?: string; token?: string; } -function credentialFrom( - apiKey: string | undefined, - token: string | undefined -): AuthCredential | undefined { - if (token) { - return { kind: "token", value: token }; - } - - if (apiKey) { - return { kind: "apiKey", value: apiKey }; - } - - return; +function oppositeAuthType(type: AuthType): AuthType { + return type === "token" ? "apiKey" : "token"; } function toConfig(credential: AuthCredential): DecodoConfig { - if (credential.kind === "apiKey") { + if (credential.type === "apiKey") { return { apiKey: credential.value }; } return { authToken: credential.value }; } -export const setupCommand = new Command("setup") - .description("Configure the Decodo CLI with your auth token") - .option( - "--token ", - "Web Scraping API basic auth token (non-interactive)" - ) - .addOption( - new Option("--api-key ", "API key (non-interactive)").hideHelp() - ) - .action(async (options: SetupOptions, command) => { - const rootOpts = getRootOpts(command); - const apiKey = (options.apiKey ?? rootOpts.apiKey)?.trim(); - const token = (options.token ?? rootOpts.token)?.trim(); +async function verifyCredential(value: string): Promise { + const detected: AuthCredential = { + type: detectCredentialType(value), + value, + }; - if (apiKey && token) { - handleCliError(new CliUsageError(AMBIGUOUS_CREDENTIAL_MESSAGE)); + try { + await validateCredential(detected); + return detected; + } catch (err) { + if (!(err instanceof AuthenticationError)) { + throw err; } - const credential: AuthCredential = credentialFrom(apiKey, token) ?? { - kind: "token", - value: (await promptHidden(TOKEN_PROMPT)).trim(), + const fallback: AuthCredential = { + type: oppositeAuthType(detected.type), + value, }; - if (!credential.value) { + try { + await validateCredential(fallback); + } catch { + throw err; + } + + return fallback; + } +} + +export const setupCommand = new Command("setup") + .description("Configure the Decodo CLI with your auth token") + .option("--token ", "Web Scraping API auth token (non-interactive)") + .action(async (options: SetupOptions, command) => { + const rootOpts = getRootOpts(command); + const value = ( + options.token?.trim() || + rootOpts.token?.trim() || + (await promptHidden(TOKEN_PROMPT)) + ).trim(); + + if (!value) { handleCliError(new CliUsageError("auth token is required.")); } try { - await validateCredential(credential); + const credential = await verifyCredential(value); await writeConfig(toConfig(credential)); console.log(`Setup complete. Configuration saved to ${getConfigPath()}`); } catch (err) { diff --git a/src/auth/commands/whoami.ts b/src/auth/commands/whoami.ts index 5153f7f..0fefa01 100644 --- a/src/auth/commands/whoami.ts +++ b/src/auth/commands/whoami.ts @@ -16,7 +16,6 @@ export const whoamiCommand = new Command("whoami") .action(async (_options, command) => { const rootOpts = getRootOpts(command); const { credential, source } = await resolveAuthToken({ - apiKey: rootOpts.apiKey, token: rootOpts.token, }); @@ -26,6 +25,6 @@ export const whoamiCommand = new Command("whoami") console.log(`source: ${source}`); console.log( - `${CREDENTIAL_LABEL[credential.kind]}: ${mask(credential.value, 4, -4)}` + `${CREDENTIAL_LABEL[credential.type]}: ${mask(credential.value, 4, -4)}` ); }); diff --git a/src/auth/constants.ts b/src/auth/constants.ts index c2c198b..1a286d6 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -1,6 +1,3 @@ export const PLAYGROUND_URL = "https://dashboard.decodo.com/playground"; export const AUTH_MISSING_MESSAGE = "No auth token found."; - -export const AMBIGUOUS_CREDENTIAL_MESSAGE = - "Provide either --token or --api-key, not both."; diff --git a/src/auth/services/detect-credential-type.ts b/src/auth/services/detect-credential-type.ts new file mode 100644 index 0000000..86b5643 --- /dev/null +++ b/src/auth/services/detect-credential-type.ts @@ -0,0 +1,13 @@ +import type { AuthType } from "../types/credential.js"; + +const PRINTABLE_ASCII = /^[\x20-\x7e]+$/; + +export function detectCredentialType(value: string): AuthType { + const decoded = Buffer.from(value, "base64").toString("utf8"); + + if (PRINTABLE_ASCII.test(decoded) && decoded.includes(":")) { + return "token"; + } + + return "apiKey"; +} diff --git a/src/auth/services/resolve-token.ts b/src/auth/services/resolve-token.ts index 3d7f4e4..748cfde 100644 --- a/src/auth/services/resolve-token.ts +++ b/src/auth/services/resolve-token.ts @@ -1,7 +1,6 @@ -import { CliUsageError } from "../../platform/errors/cli-usage-error.js"; -import { AMBIGUOUS_CREDENTIAL_MESSAGE } from "../constants.js"; import type { AuthCredential } from "../types/credential.js"; import { readConfig } from "./config.js"; +import { detectCredentialType } from "./detect-credential-type.js"; export type AuthSource = "flag" | "env" | "config" | "none"; @@ -11,25 +10,40 @@ export interface ResolvedAuth { } export interface ResolveAuthOptions { - apiKey?: string; token?: string; } function resolveFrom( source: AuthSource, - apiKey: string | undefined, - token: string | undefined + value: string | undefined ): ResolvedAuth | undefined { - const resolvedToken = token?.trim(); + const resolved = value?.trim(); - if (resolvedToken) { - return { credential: { kind: "token", value: resolvedToken }, source }; + if (!resolved) { + return; } - const resolvedApiKey = apiKey?.trim(); + return { + credential: { type: detectCredentialType(resolved), value: resolved }, + source, + }; +} + +async function fromConfig(): Promise { + const config = await readConfig(); + + if (config?.authToken) { + return { + credential: { type: "token", value: config.authToken }, + source: "config", + }; + } - if (resolvedApiKey) { - return { credential: { kind: "apiKey", value: resolvedApiKey }, source }; + if (config?.apiKey) { + return { + credential: { type: "apiKey", value: config.apiKey }, + source: "config", + }; } return; @@ -38,32 +52,9 @@ function resolveFrom( export async function resolveAuthToken( options: ResolveAuthOptions = {} ): Promise { - if (options.apiKey?.trim() && options.token?.trim()) { - throw new CliUsageError(AMBIGUOUS_CREDENTIAL_MESSAGE); - } - - const fromFlag = resolveFrom("flag", options.apiKey, options.token); - - if (fromFlag) { - return fromFlag; - } - - const fromEnv = resolveFrom( - "env", - process.env.DECODO_API_KEY, - process.env.DECODO_AUTH_TOKEN + return ( + resolveFrom("flag", options.token) ?? + resolveFrom("env", process.env.DECODO_AUTH_TOKEN) ?? + (await fromConfig()) ?? { credential: undefined, source: "none" } ); - - if (fromEnv) { - return fromEnv; - } - - const config = await readConfig(); - const fromConfig = resolveFrom("config", config?.apiKey, config?.authToken); - - if (fromConfig) { - return fromConfig; - } - - return { credential: undefined, source: "none" }; } diff --git a/src/auth/types/credential.ts b/src/auth/types/credential.ts index f324cde..73dc87f 100644 --- a/src/auth/types/credential.ts +++ b/src/auth/types/credential.ts @@ -1,6 +1,6 @@ export type AuthType = "token" | "apiKey"; export interface AuthCredential { - kind: AuthType; + type: AuthType; value: string; } diff --git a/src/cli/services/global-opts.ts b/src/cli/services/global-opts.ts index d166d85..3867460 100644 --- a/src/cli/services/global-opts.ts +++ b/src/cli/services/global-opts.ts @@ -1,7 +1,6 @@ import type { Command } from "commander"; export interface RootOptions { - apiKey?: string; token?: string; verbose?: boolean; } diff --git a/src/index.ts b/src/index.ts index 9c13587..ed81eb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { Command, Option } from "commander"; +import { Command } from "commander"; import { createCommands } from "./cli/register.js"; import { configureCommanderExit } from "./cli/services/configure-commander-exit.js"; import { handleCliError } from "./platform/services/handle-cli-error.js"; @@ -26,13 +26,7 @@ const program = new Command() .option("-v, --verbose", "Print debug logs to stderr") .option( "--token ", - "Basic auth token (overrides DECODO_AUTH_TOKEN and saved config)" - ) - .addOption( - new Option( - "--api-key ", - "API key (overrides DECODO_API_KEY and saved config)" - ).hideHelp() + "Auth token (overrides DECODO_AUTH_TOKEN and saved config)" ); async function main(): Promise { diff --git a/src/scrape/services/client.ts b/src/scrape/services/client.ts index 76f1dd9..92f2a28 100644 --- a/src/scrape/services/client.ts +++ b/src/scrape/services/client.ts @@ -7,7 +7,7 @@ export function createDecodoClient( schema?: DecodoSchema ): DecodoClient { const credentials = - credential.kind === "apiKey" + credential.type === "apiKey" ? { apiKey: credential.value } : { token: credential.value }; diff --git a/src/scrape/services/run-target-scrape.ts b/src/scrape/services/run-target-scrape.ts index cb92599..177564e 100644 --- a/src/scrape/services/run-target-scrape.ts +++ b/src/scrape/services/run-target-scrape.ts @@ -88,13 +88,10 @@ export function createTargetAction( const verbose = rootOpts.verbose === true; try { - const auth = await resolveAuthToken({ - apiKey: rootOpts.apiKey, - token: rootOpts.token, - }); + const auth = await resolveAuthToken({ token: rootOpts.token }); verboseLog( verbose, - `auth source=${auth.source} kind=${auth.credential?.kind ?? "none"}` + `auth source=${auth.source} type=${auth.credential?.type ?? "none"}` ); if (!auth.credential) { throw new AuthRequiredError(); diff --git a/tests/auth/commands/setup.test.ts b/tests/auth/commands/setup.test.ts index 40a4347..491eb8a 100644 --- a/tests/auth/commands/setup.test.ts +++ b/tests/auth/commands/setup.test.ts @@ -15,7 +15,6 @@ async function runSetup( const { setupCommand } = await import("../../../src/auth/commands/setup.js"); const program = new Command() .option("--token ", "global token") - .option("--api-key ", "global api key") .addCommand(setupCommand); await program.parseAsync([...globalArgs, "setup", ...setupArgs], { from: "user", @@ -65,99 +64,28 @@ describe("setupCommand", () => { }); it("saves config on successful validation", async () => { - await runSetup(["--token", "valid-token"]); + await runSetup(["--token", "VTAwMDAwMDAwMDE6UFdfdmFsaWRzZWNyZXQ="]); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toEqual({ - authToken: "valid-token", + authToken: "VTAwMDAwMDAwMDE6UFdfdmFsaWRzZWNyZXQ=", }); expect(stdout.join("\n")).toContain("Setup complete"); }); - it("saves an api key when --api-key is provided", async () => { - await runSetup(["--api-key", "valid-key"]); - - const { readConfig } = await import("../../../src/auth/services/config.js"); - expect(await readConfig()).toEqual({ - apiKey: "valid-key", - }); - expect(stdout.join("\n")).toContain("Setup complete"); - }); - - it("saves an api key from global --api-key", async () => { - await runSetup([], ["--api-key", "global-key"]); - - const { readConfig } = await import("../../../src/auth/services/config.js"); - expect(await readConfig()).toEqual({ - apiKey: "global-key", - }); - }); - - it("rejects --api-key and --token together", async () => { - await expect( - runSetup(["--api-key", "setup-key", "--token", "setup-token"]) - ).rejects.toThrow("process.exit:2"); - - const { readConfig } = await import("../../../src/auth/services/config.js"); - expect(await readConfig()).toBeUndefined(); - expect(stderr.join("\n")).toContain( - "Provide either --token or --api-key, not both." - ); - }); - - it("validates an api key against the data api endpoint", async () => { - await runSetup(["--api-key", "valid-key"]); - - expect(fetch).toHaveBeenCalledWith( - "https://data.decodo.com/v1/scrape", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer valid-key", - }), - }) - ); - }); - it("validates a token against the scraper api endpoint", async () => { - await runSetup(["--token", "valid-token"]); + await runSetup(["--token", "VTAwMDAwMDAwMDE6UFdfdmFsaWRzZWNyZXQ="]); expect(fetch).toHaveBeenCalledWith( "https://scraper-api.decodo.com/v2/scrape", expect.objectContaining({ headers: expect.objectContaining({ - Authorization: "Basic valid-token", + Authorization: "Basic VTAwMDAwMDAwMDE6UFdfdmFsaWRzZWNyZXQ=", }), }) ); }); - it("rejects a global --api-key mixed with a subcommand --token", async () => { - await expect( - runSetup(["--token", "setup-token"], ["--api-key", "global-key"]) - ).rejects.toThrow("process.exit:2"); - - const { readConfig } = await import("../../../src/auth/services/config.js"); - expect(await readConfig()).toBeUndefined(); - }); - - it("rejects a global --token mixed with a subcommand --api-key", async () => { - await expect( - runSetup(["--api-key", "setup-key"], ["--token", "global-token"]) - ).rejects.toThrow("process.exit:2"); - - const { readConfig } = await import("../../../src/auth/services/config.js"); - expect(await readConfig()).toBeUndefined(); - }); - - it("trims surrounding whitespace from a saved api key", async () => { - await runSetup(["--api-key", " spaced-key "]); - - const { readConfig } = await import("../../../src/auth/services/config.js"); - expect(await readConfig()).toEqual({ - apiKey: "spaced-key", - }); - }); - it("does not save config on 401", async () => { vi.mocked(fetch).mockResolvedValue({ ok: false, @@ -175,21 +103,24 @@ describe("setupCommand", () => { }); it("saves config when token comes from global --token", async () => { - await runSetup([], ["--token", "global-token"]); + await runSetup([], ["--token", "VTAwMDAwMDAwMDI6UFdfZ2xvYmFsc2VjcmV0"]); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toEqual({ - authToken: "global-token", + authToken: "VTAwMDAwMDAwMDI6UFdfZ2xvYmFsc2VjcmV0", }); expect(stdout.join("\n")).toContain("Setup complete"); }); it("prefers setup --token over global --token", async () => { - await runSetup(["--token", "setup-token"], ["--token", "global-token"]); + await runSetup( + ["--token", "VTAwMDAwMDAwMDM6UFdfc2V0dXBzZWNyZXQ="], + ["--token", "VTAwMDAwMDAwMDI6UFdfZ2xvYmFsc2VjcmV0"] + ); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toEqual({ - authToken: "setup-token", + authToken: "VTAwMDAwMDAwMDM6UFdfc2V0dXBzZWNyZXQ=", }); }); @@ -232,9 +163,9 @@ describe("setupCommand", () => { }), } as Response); - await expect(runSetup(["--token", "valid-token"])).rejects.toThrow( - "process.exit:5" - ); + await expect( + runSetup(["--token", "VTAwMDAwMDAwMDE6UFdfdmFsaWRzZWNyZXQ="]) + ).rejects.toThrow("process.exit:5"); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toBeUndefined(); @@ -243,14 +174,16 @@ describe("setupCommand", () => { }); it("prompts for token interactively when no flags are provided", async () => { - mockPromptHidden.mockResolvedValue("prompted-token"); + mockPromptHidden.mockResolvedValue( + "VTAwMDAwMDAwMDQ6UFdfcHJvbXB0ZWRzZWNyZXQ=" + ); await runSetup([]); expect(mockPromptHidden).toHaveBeenCalledOnce(); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toEqual({ - authToken: "prompted-token", + authToken: "VTAwMDAwMDAwMDQ6UFdfcHJvbXB0ZWRzZWNyZXQ=", }); expect(stdout.join("\n")).toContain("Setup complete"); }); diff --git a/tests/auth/commands/whoami.test.ts b/tests/auth/commands/whoami.test.ts index 67b2637..54fcc34 100644 --- a/tests/auth/commands/whoami.test.ts +++ b/tests/auth/commands/whoami.test.ts @@ -8,7 +8,6 @@ async function runWhoami(args: string[]): Promise { ); const program = new Command() .option("--token ", "global token") - .option("--api-key ", "global api key") .addCommand(whoamiCommand); await program.parseAsync(args, { from: "user" }); } @@ -16,16 +15,13 @@ async function runWhoami(args: string[]): Promise { describe("whoamiCommand", () => { let restoreConfigHome: () => void; let previousEnvToken: string | undefined; - let previousEnvApiKey: string | undefined; let exitCode: number | undefined; let stdout: string[]; beforeEach(async () => { ({ restore: restoreConfigHome } = await isolateConfigHome()); previousEnvToken = process.env.DECODO_AUTH_TOKEN; - previousEnvApiKey = process.env.DECODO_API_KEY; delete process.env.DECODO_AUTH_TOKEN; - delete process.env.DECODO_API_KEY; vi.resetModules(); exitCode = undefined; stdout = []; @@ -48,11 +44,6 @@ describe("whoamiCommand", () => { } else { process.env.DECODO_AUTH_TOKEN = previousEnvToken; } - if (previousEnvApiKey === undefined) { - delete process.env.DECODO_API_KEY; - } else { - process.env.DECODO_API_KEY = previousEnvApiKey; - } vi.resetModules(); }); @@ -60,50 +51,54 @@ describe("whoamiCommand", () => { const { writeConfig } = await import( "../../../src/auth/services/config.js" ); - await writeConfig({ authToken: "abcdefghijklmnop" }); + await writeConfig({ authToken: "VTAwMDAwMDAwMDU6UFdfd2hvYW1pc2VjcmV0" }); await runWhoami(["whoami"]); expect(stdout).toContain("source: config"); - expect(stdout).toContain("token: abcd...mnop"); + expect(stdout).toContain("token: VTAw...cmV0"); }); it("prints auth source and masked token from global --token", async () => { - await runWhoami(["--token", "abcdefghijklmnop", "whoami"]); + await runWhoami([ + "--token", + "VTAwMDAwMDAwMDU6UFdfd2hvYW1pc2VjcmV0", + "whoami", + ]); expect(stdout).toContain("source: flag"); - expect(stdout).toContain("token: abcd...mnop"); + expect(stdout).toContain("token: VTAw...cmV0"); }); it("prefers global --token over saved config", async () => { const { writeConfig } = await import( "../../../src/auth/services/config.js" ); - await writeConfig({ authToken: "config-token-value" }); + await writeConfig({ authToken: "VTAwMDAwMDAwMDY6UFdfY29uZmlnc2VjcmV0" }); - await runWhoami(["--token", "flag-token-value", "whoami"]); + await runWhoami([ + "--token", + "VTAwMDAwMDAwMDI6UFdfZ2xvYmFsc2VjcmV0", + "whoami", + ]); expect(stdout).toContain("source: flag"); - expect(stdout).toContain("token: flag...alue"); + expect(stdout).toContain("token: VTAw...cmV0"); }); it("prints the api key label for a saved api key", async () => { const { writeConfig } = await import( "../../../src/auth/services/config.js" ); - await writeConfig({ apiKey: "abcdefghijklmnop" }); + await writeConfig({ + apiKey: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }); await runWhoami(["whoami"]); expect(stdout).toContain("source: config"); - expect(stdout).toContain("api key: abcd...mnop"); - }); - - it("prints the api key from global --api-key", async () => { - await runWhoami(["--api-key", "abcdefghijklmnop", "whoami"]); - - expect(stdout).toContain("source: flag"); - expect(stdout).toContain("api key: abcd...mnop"); + expect(stdout).toContain("api key: 0123...cdef"); }); it("exits with code 3 when no credential is available", async () => { diff --git a/tests/auth/services/detect-credential-type.test.ts b/tests/auth/services/detect-credential-type.test.ts new file mode 100644 index 0000000..89a3e34 --- /dev/null +++ b/tests/auth/services/detect-credential-type.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { detectCredentialType } from "../../../src/auth/services/detect-credential-type.js"; + +const BASIC_TOKEN = "VTAwMDAwMDAwMDA6UFdfZXhhbXBsZXNlY3JldA=="; +const API_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +describe("detectCredentialType", () => { + it("detects a base64 user:password token as a basic auth token", () => { + expect(detectCredentialType(BASIC_TOKEN)).toBe("token"); + }); + + it("detects a 64-character hex string as an api key", () => { + expect(detectCredentialType(API_KEY)).toBe("apiKey"); + }); + + it("treats a value that decodes without a colon as an api key", () => { + const noColon = Buffer.from("nocolonhere").toString("base64"); + expect(detectCredentialType(noColon)).toBe("apiKey"); + }); + + it("treats a non-base64 value as an api key", () => { + expect(detectCredentialType("not base64 at all!!")).toBe("apiKey"); + }); + + it("keeps a token with a colon inside the password as a basic token", () => { + const nested = Buffer.from("user:pa:ss").toString("base64"); + expect(detectCredentialType(nested)).toBe("token"); + }); +}); diff --git a/tests/auth/services/resolve-token.test.ts b/tests/auth/services/resolve-token.test.ts index f55b435..34621f1 100644 --- a/tests/auth/services/resolve-token.test.ts +++ b/tests/auth/services/resolve-token.test.ts @@ -1,249 +1,122 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isolateConfigHome } from "../../platform/helpers/config-home.js"; -const ENV_KEYS = ["DECODO_AUTH_TOKEN", "DECODO_API_KEY"] as const; +const BASIC_TOKEN = "VTAwMDAwMDAwMDA6UFdfZXhhbXBsZXNlY3JldA=="; +const API_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +async function resolve(options?: { token?: string }) { + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + return resolveAuthToken(options); +} describe("resolveAuthToken", () => { let restoreConfigHome: () => void; - let previousEnv: Record; + let previousEnvToken: string | undefined; beforeEach(async () => { ({ restore: restoreConfigHome } = await isolateConfigHome()); - previousEnv = {}; - for (const key of ENV_KEYS) { - previousEnv[key] = process.env[key]; - delete process.env[key]; - } + previousEnvToken = process.env.DECODO_AUTH_TOKEN; + delete process.env.DECODO_AUTH_TOKEN; vi.resetModules(); }); afterEach(() => { restoreConfigHome(); - for (const key of ENV_KEYS) { - const value = previousEnv[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } + if (previousEnvToken === undefined) { + delete process.env.DECODO_AUTH_TOKEN; + } else { + process.env.DECODO_AUTH_TOKEN = previousEnvToken; } vi.resetModules(); }); - it("prefers flag over env and config", async () => { - process.env.DECODO_AUTH_TOKEN = "env-token"; - const { writeConfig } = await import( - "../../../src/auth/services/config.js" - ); - await writeConfig({ authToken: "config-token" }); - - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken({ token: "flag-token" }); - expect(result).toEqual({ - credential: { kind: "token", value: "flag-token" }, - source: "flag", - }); - }); - - it("prefers env over config", async () => { - process.env.DECODO_AUTH_TOKEN = "env-token"; - const { writeConfig } = await import( - "../../../src/auth/services/config.js" - ); - await writeConfig({ authToken: "config-token" }); - - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken(); - expect(result).toEqual({ - credential: { kind: "token", value: "env-token" }, - source: "env", - }); - }); - - it("reads token from config file", async () => { + it("prefers the flag over env and config", async () => { + process.env.DECODO_AUTH_TOKEN = BASIC_TOKEN; const { writeConfig } = await import( "../../../src/auth/services/config.js" ); await writeConfig({ authToken: "config-token" }); - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken(); - expect(result).toEqual({ - credential: { kind: "token", value: "config-token" }, - source: "config", - }); - }); - - it("returns none when no credential is available", async () => { - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken(); - expect(result).toEqual({ credential: undefined, source: "none" }); - }); - - it("rejects both credential flags supplied together", async () => { - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const { CliUsageError } = await import( - "../../../src/platform/errors/cli-usage-error.js" - ); - - await expect( - resolveAuthToken({ apiKey: "flag-key", token: "flag-token" }) - ).rejects.toThrow(CliUsageError); - }); - - it("allows both env vars to be set without erroring", async () => { - process.env.DECODO_API_KEY = "env-key"; - process.env.DECODO_AUTH_TOKEN = "env-token"; - - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken(); - expect(result).toEqual({ - credential: { kind: "token", value: "env-token" }, - source: "env", - }); - }); - - it("treats a whitespace-only flag as no credential", async () => { - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken({ apiKey: " ", token: " " }); - expect(result).toEqual({ credential: undefined, source: "none" }); - }); - - it("falls through a whitespace-only flag to the env var", async () => { - process.env.DECODO_AUTH_TOKEN = "env-token"; - - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken({ apiKey: " " }); - expect(result).toEqual({ - credential: { kind: "token", value: "env-token" }, - source: "env", - }); - }); - - it("trims surrounding whitespace from a resolved credential", async () => { - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken({ apiKey: " padded-key\n" }); - expect(result).toEqual({ - credential: { kind: "apiKey", value: "padded-key" }, + expect(await resolve({ token: BASIC_TOKEN })).toEqual({ + credential: { type: "token", value: BASIC_TOKEN }, source: "flag", }); }); - it("resolves an api key from the flag", async () => { - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken({ apiKey: "flag-key" }); - expect(result).toEqual({ - credential: { kind: "apiKey", value: "flag-key" }, + it("infers an api key passed through --token", async () => { + expect(await resolve({ token: API_KEY })).toEqual({ + credential: { type: "apiKey", value: API_KEY }, source: "flag", }); }); - it("prefers DECODO_AUTH_TOKEN over DECODO_API_KEY", async () => { - process.env.DECODO_API_KEY = "env-key"; - process.env.DECODO_AUTH_TOKEN = "env-token"; + it("infers an api key from DECODO_AUTH_TOKEN", async () => { + process.env.DECODO_AUTH_TOKEN = API_KEY; - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken(); - expect(result).toEqual({ - credential: { kind: "token", value: "env-token" }, + expect(await resolve()).toEqual({ + credential: { type: "apiKey", value: API_KEY }, source: "env", }); }); - it("prefers the api key flag over DECODO_AUTH_TOKEN", async () => { - process.env.DECODO_AUTH_TOKEN = "env-token"; - - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" + it("prefers env over config", async () => { + process.env.DECODO_AUTH_TOKEN = BASIC_TOKEN; + const { writeConfig } = await import( + "../../../src/auth/services/config.js" ); - const result = await resolveAuthToken({ apiKey: "flag-key" }); - expect(result).toEqual({ - credential: { kind: "apiKey", value: "flag-key" }, - source: "flag", - }); - }); - - it("prefers the token flag over DECODO_API_KEY", async () => { - process.env.DECODO_API_KEY = "env-key"; + await writeConfig({ authToken: "config-token" }); - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken({ token: "flag-token" }); - expect(result).toEqual({ - credential: { kind: "token", value: "flag-token" }, - source: "flag", + expect(await resolve()).toEqual({ + credential: { type: "token", value: BASIC_TOKEN }, + source: "env", }); }); - it("reads an api key from the config file", async () => { + it("uses the persisted kind for a saved api key without re-detecting", async () => { const { writeConfig } = await import( "../../../src/auth/services/config.js" ); - await writeConfig({ apiKey: "config-key" }); + await writeConfig({ apiKey: BASIC_TOKEN }); - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken(); - expect(result).toEqual({ - credential: { kind: "apiKey", value: "config-key" }, + expect(await resolve()).toEqual({ + credential: { type: "apiKey", value: BASIC_TOKEN }, source: "config", }); }); - it("prefers the config token over the config api key", async () => { + it("reads a saved auth token from config", async () => { const { writeConfig } = await import( "../../../src/auth/services/config.js" ); - await writeConfig({ apiKey: "config-key", authToken: "config-token" }); + await writeConfig({ authToken: BASIC_TOKEN }); - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken(); - expect(result).toEqual({ - credential: { kind: "token", value: "config-token" }, + expect(await resolve()).toEqual({ + credential: { type: "token", value: BASIC_TOKEN }, source: "config", }); }); - it("still prefers an env api key over a saved config token", async () => { - process.env.DECODO_API_KEY = "env-key"; - const { writeConfig } = await import( - "../../../src/auth/services/config.js" - ); - await writeConfig({ authToken: "config-token" }); + it("returns none when no credential is available", async () => { + expect(await resolve()).toEqual({ + credential: undefined, + source: "none", + }); + }); - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken(); - expect(result).toEqual({ - credential: { kind: "apiKey", value: "env-key" }, - source: "env", + it("treats a whitespace-only flag as no credential", async () => { + expect(await resolve({ token: " " })).toEqual({ + credential: undefined, + source: "none", + }); + }); + + it("trims surrounding whitespace before detecting", async () => { + expect(await resolve({ token: ` ${API_KEY}\n` })).toEqual({ + credential: { type: "apiKey", value: API_KEY }, + source: "flag", }); }); }); diff --git a/tests/index.test.ts b/tests/index.test.ts index 21e00ee..27f3849 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -40,32 +40,6 @@ describe("cli", () => { expect(output).toContain("-v, --verbose"); }); - it("hides the api key flag from help", () => { - const output = execFileSync(process.execPath, [cliPath, "--help"], { - encoding: "utf8", - }); - - expect(output).not.toContain("--api-key"); - }); - - it("hides the api key flag from setup help", () => { - const output = execFileSync( - process.execPath, - [cliPath, "setup", "--help"], - { - encoding: "utf8", - } - ); - - expect(output).not.toContain("--api-key"); - }); - - it("accepts the hidden api key flag", () => { - const { exitCode } = runCli(["--api-key", "key", "whoami"]); - - expect(exitCode).toBe(0); - }); - it.each([ ["unknown flag", ["--bad-flag"], 2], ["unknown command", ["nosuchcmd"], 2], diff --git a/tests/scrape/commands/scrape.test.ts b/tests/scrape/commands/scrape.test.ts index 1c05282..e2332de 100644 --- a/tests/scrape/commands/scrape.test.ts +++ b/tests/scrape/commands/scrape.test.ts @@ -27,7 +27,7 @@ describe("createScrapeCommand", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - credential: { kind: "token", value: "test-token" }, + credential: { type: "token", value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/commands/screenshot.test.ts b/tests/scrape/commands/screenshot.test.ts index 5ff4627..230875e 100644 --- a/tests/scrape/commands/screenshot.test.ts +++ b/tests/scrape/commands/screenshot.test.ts @@ -31,7 +31,7 @@ describe("createScreenshotCommand", () => { stdoutBytes = undefined; vi.mocked(resolveAuthToken).mockResolvedValue({ - credential: { kind: "token", value: "test-token" }, + credential: { type: "token", value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/commands/search.test.ts b/tests/scrape/commands/search.test.ts index 2421d45..facddd5 100644 --- a/tests/scrape/commands/search.test.ts +++ b/tests/scrape/commands/search.test.ts @@ -27,7 +27,7 @@ describe("createSearchCommand", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - credential: { kind: "token", value: "test-token" }, + credential: { type: "token", value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/services/auth-validation.test.ts b/tests/scrape/services/auth-validation.test.ts index 0dedda5..2d66253 100644 --- a/tests/scrape/services/auth-validation.test.ts +++ b/tests/scrape/services/auth-validation.test.ts @@ -12,8 +12,8 @@ vi.mock("../../../src/scrape/services/client.js", () => ({ createDecodoClient: vi.fn(), })); -const TOKEN_CREDENTIAL = { kind: "token", value: "test-token" } as const; -const API_KEY_CREDENTIAL = { kind: "apiKey", value: "test-api-key" } as const; +const TOKEN_CREDENTIAL = { type: "token", value: "test-token" } as const; +const API_KEY_CREDENTIAL = { type: "apiKey", value: "test-api-key" } as const; describe("validateCredential", () => { const scrape = vi.fn(); @@ -53,7 +53,7 @@ describe("validateCredential", () => { scrape.mockRejectedValue(new AuthenticationError("Username invalid.")); await expect( - validateCredential({ kind: "token", value: "bad-token" }) + validateCredential({ type: "token", value: "bad-token" }) ).rejects.toThrow(AuthenticationError); }); diff --git a/tests/scrape/services/run-target-scrape.test.ts b/tests/scrape/services/run-target-scrape.test.ts index c65e977..4849a3d 100644 --- a/tests/scrape/services/run-target-scrape.test.ts +++ b/tests/scrape/services/run-target-scrape.test.ts @@ -42,7 +42,7 @@ describe("createTargetAction", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - credential: { kind: "token", value: "test-token" }, + credential: { type: "token", value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { @@ -93,7 +93,7 @@ describe("createTargetAction", () => { markdown: false, }); expect(createDecodoClient).toHaveBeenCalledWith( - { kind: "token", value: "test-token" }, + { type: "token", value: "test-token" }, BundledSchema.shared ); expect(stdout).toBe('{"ok":true}\n'); @@ -121,7 +121,7 @@ describe("createTargetAction", () => { { from: "user" } ); - expect(stderr).toContain("[verbose] auth source=flag kind=token\n"); + expect(stderr).toContain("[verbose] auth source=flag type=token\n"); expect(stderr).toContain( "[verbose] request target=google_search query=coffee\n" ); From c4a8bd5b4f6e527715c3905bacdeb554b9fa039f Mon Sep 17 00:00:00 2001 From: "aiste.grigaliunaite@decodo.com" Date: Wed, 9 Sep 2026 10:50:15 +0300 Subject: [PATCH 3/4] bump package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 33380a2..490a826 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@decodo/cli", - "version": "1.0.2", + "version": "1.0.3", "description": "Official CLI for the Decodo APIs", "license": "MIT", "type": "module", From 8307593d74c5a4450e11531fff71f808e2b65b8a Mon Sep 17 00:00:00 2001 From: "aiste.grigaliunaite@decodo.com" Date: Wed, 9 Sep 2026 11:09:51 +0300 Subject: [PATCH 4/4] cleanup --- src/auth/commands/setup.ts | 12 ++--- src/auth/commands/whoami.ts | 5 ++- src/auth/constants.ts | 5 +++ src/auth/services/detect-credential-type.ts | 5 ++- src/auth/services/resolve-token.ts | 45 ++++++++----------- src/auth/types/credential.ts | 4 +- src/scrape/services/client.ts | 3 +- tests/auth/commands/setup.test.ts | 38 ++++++++++++++++ tests/auth/services/resolve-token.test.ts | 15 ++++--- tests/scrape/commands/scrape.test.ts | 3 +- tests/scrape/commands/screenshot.test.ts | 3 +- tests/scrape/commands/search.test.ts | 3 +- tests/scrape/services/auth-validation.test.ts | 13 ++++-- .../scrape/services/run-target-scrape.test.ts | 5 ++- 14 files changed, 106 insertions(+), 53 deletions(-) diff --git a/src/auth/commands/setup.ts b/src/auth/commands/setup.ts index b51a6fd..b9ff471 100644 --- a/src/auth/commands/setup.ts +++ b/src/auth/commands/setup.ts @@ -5,7 +5,7 @@ import { CliUsageError } from "../../platform/errors/cli-usage-error.js"; import { handleCliError } from "../../platform/services/handle-cli-error.js"; import { promptHidden } from "../../platform/services/prompt-hidden.js"; import { validateCredential } from "../../scrape/services/auth-validation.js"; -import { PLAYGROUND_URL } from "../constants.js"; +import { AUTH_TYPE, PLAYGROUND_URL } from "../constants.js"; import { getConfigPath, writeConfig } from "../services/config.js"; import { detectCredentialType } from "../services/detect-credential-type.js"; import type { DecodoConfig } from "../types/config.js"; @@ -18,11 +18,11 @@ interface SetupOptions { } function oppositeAuthType(type: AuthType): AuthType { - return type === "token" ? "apiKey" : "token"; + return type === AUTH_TYPE.TOKEN ? AUTH_TYPE.API_KEY : AUTH_TYPE.TOKEN; } function toConfig(credential: AuthCredential): DecodoConfig { - if (credential.type === "apiKey") { + if (credential.type === AUTH_TYPE.API_KEY) { return { apiKey: credential.value }; } @@ -63,18 +63,18 @@ export const setupCommand = new Command("setup") .option("--token ", "Web Scraping API auth token (non-interactive)") .action(async (options: SetupOptions, command) => { const rootOpts = getRootOpts(command); - const value = ( + const token = ( options.token?.trim() || rootOpts.token?.trim() || (await promptHidden(TOKEN_PROMPT)) ).trim(); - if (!value) { + if (!token) { handleCliError(new CliUsageError("auth token is required.")); } try { - const credential = await verifyCredential(value); + const credential = await verifyCredential(token); await writeConfig(toConfig(credential)); console.log(`Setup complete. Configuration saved to ${getConfigPath()}`); } catch (err) { diff --git a/src/auth/commands/whoami.ts b/src/auth/commands/whoami.ts index 0fefa01..bf041eb 100644 --- a/src/auth/commands/whoami.ts +++ b/src/auth/commands/whoami.ts @@ -1,14 +1,15 @@ import { Command } from "commander"; import { getRootOpts } from "../../cli/services/global-opts.js"; import { handleCliError } from "../../platform/services/handle-cli-error.js"; +import { AUTH_TYPE } from "../constants.js"; import { AuthRequiredError } from "../errors/auth-required-error.js"; import { mask } from "../services/mask.js"; import { resolveAuthToken } from "../services/resolve-token.js"; import type { AuthType } from "../types/credential.js"; const CREDENTIAL_LABEL: Record = { - apiKey: "api key", - token: "token", + [AUTH_TYPE.API_KEY]: "api key", + [AUTH_TYPE.TOKEN]: "token", }; export const whoamiCommand = new Command("whoami") diff --git a/src/auth/constants.ts b/src/auth/constants.ts index 1a286d6..7460922 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -1,3 +1,8 @@ export const PLAYGROUND_URL = "https://dashboard.decodo.com/playground"; export const AUTH_MISSING_MESSAGE = "No auth token found."; + +export const AUTH_TYPE = { + TOKEN: "token", + API_KEY: "apiKey", +} as const; diff --git a/src/auth/services/detect-credential-type.ts b/src/auth/services/detect-credential-type.ts index 86b5643..7e47d1d 100644 --- a/src/auth/services/detect-credential-type.ts +++ b/src/auth/services/detect-credential-type.ts @@ -1,3 +1,4 @@ +import { AUTH_TYPE } from "../constants.js"; import type { AuthType } from "../types/credential.js"; const PRINTABLE_ASCII = /^[\x20-\x7e]+$/; @@ -6,8 +7,8 @@ export function detectCredentialType(value: string): AuthType { const decoded = Buffer.from(value, "base64").toString("utf8"); if (PRINTABLE_ASCII.test(decoded) && decoded.includes(":")) { - return "token"; + return AUTH_TYPE.TOKEN; } - return "apiKey"; + return AUTH_TYPE.API_KEY; } diff --git a/src/auth/services/resolve-token.ts b/src/auth/services/resolve-token.ts index 748cfde..2c91859 100644 --- a/src/auth/services/resolve-token.ts +++ b/src/auth/services/resolve-token.ts @@ -1,3 +1,4 @@ +import { AUTH_TYPE } from "../constants.js"; import type { AuthCredential } from "../types/credential.js"; import { readConfig } from "./config.js"; import { detectCredentialType } from "./detect-credential-type.js"; @@ -13,48 +14,40 @@ export interface ResolveAuthOptions { token?: string; } -function resolveFrom( - source: AuthSource, - value: string | undefined -): ResolvedAuth | undefined { - const resolved = value?.trim(); +function detect(value: string): AuthCredential { + return { type: detectCredentialType(value), value }; +} - if (!resolved) { - return; +export async function resolveAuthToken( + options: ResolveAuthOptions = {} +): Promise { + const flagToken = options.token?.trim(); + + if (flagToken) { + return { credential: detect(flagToken), source: "flag" }; } - return { - credential: { type: detectCredentialType(resolved), value: resolved }, - source, - }; -} + const envToken = process.env.DECODO_AUTH_TOKEN?.trim(); + + if (envToken) { + return { credential: detect(envToken), source: "env" }; + } -async function fromConfig(): Promise { const config = await readConfig(); if (config?.authToken) { return { - credential: { type: "token", value: config.authToken }, + credential: { type: AUTH_TYPE.TOKEN, value: config.authToken }, source: "config", }; } if (config?.apiKey) { return { - credential: { type: "apiKey", value: config.apiKey }, + credential: { type: AUTH_TYPE.API_KEY, value: config.apiKey }, source: "config", }; } - return; -} - -export async function resolveAuthToken( - options: ResolveAuthOptions = {} -): Promise { - return ( - resolveFrom("flag", options.token) ?? - resolveFrom("env", process.env.DECODO_AUTH_TOKEN) ?? - (await fromConfig()) ?? { credential: undefined, source: "none" } - ); + return { credential: undefined, source: "none" }; } diff --git a/src/auth/types/credential.ts b/src/auth/types/credential.ts index 73dc87f..b794f33 100644 --- a/src/auth/types/credential.ts +++ b/src/auth/types/credential.ts @@ -1,4 +1,6 @@ -export type AuthType = "token" | "apiKey"; +import type { AUTH_TYPE } from "../constants.js"; + +export type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; export interface AuthCredential { type: AuthType; diff --git a/src/scrape/services/client.ts b/src/scrape/services/client.ts index 92f2a28..38ee18d 100644 --- a/src/scrape/services/client.ts +++ b/src/scrape/services/client.ts @@ -1,4 +1,5 @@ import { DecodoClient, type DecodoSchema } from "@decodo/sdk-ts"; +import { AUTH_TYPE } from "../../auth/constants.js"; import type { AuthCredential } from "../../auth/types/credential.js"; import { INTEGRATION_HEADER } from "../constants.js"; @@ -7,7 +8,7 @@ export function createDecodoClient( schema?: DecodoSchema ): DecodoClient { const credentials = - credential.type === "apiKey" + credential.type === AUTH_TYPE.API_KEY ? { apiKey: credential.value } : { token: credential.value }; diff --git a/tests/auth/commands/setup.test.ts b/tests/auth/commands/setup.test.ts index 491eb8a..be99191 100644 --- a/tests/auth/commands/setup.test.ts +++ b/tests/auth/commands/setup.test.ts @@ -4,6 +4,9 @@ import { isolateConfigHome } from "../../platform/helpers/config-home.js"; const mockPromptHidden = vi.hoisted(() => vi.fn()); +const API_KEY_SHAPED = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + vi.mock("../../../src/platform/services/prompt-hidden.js", () => ({ promptHidden: mockPromptHidden, })); @@ -86,6 +89,41 @@ describe("setupCommand", () => { ); }); + it("falls back to the opposite auth type when the detected one is rejected", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ message: "Invalid credentials", status: "failed" }), + } as Response); + + await runSetup(["--token", API_KEY_SHAPED]); + + expect(fetch).toHaveBeenCalledTimes(2); + const { readConfig } = await import("../../../src/auth/services/config.js"); + expect(await readConfig()).toEqual({ authToken: API_KEY_SHAPED }); + }); + + it("reports the detected type's error when both auth types fail", async () => { + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ message: "ORIGINAL-error", status: "failed" }), + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ message: "FALLBACK-error", status: "failed" }), + } as Response); + + await expect(runSetup(["--token", API_KEY_SHAPED])).rejects.toThrow( + "process.exit:3" + ); + + expect(stderr.join("\n")).toContain("ORIGINAL-error"); + expect(stderr.join("\n")).not.toContain("FALLBACK-error"); + }); + it("does not save config on 401", async () => { vi.mocked(fetch).mockResolvedValue({ ok: false, diff --git a/tests/auth/services/resolve-token.test.ts b/tests/auth/services/resolve-token.test.ts index 34621f1..d8f2d86 100644 --- a/tests/auth/services/resolve-token.test.ts +++ b/tests/auth/services/resolve-token.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { isolateConfigHome } from "../../platform/helpers/config-home.js"; const BASIC_TOKEN = "VTAwMDAwMDAwMDA6UFdfZXhhbXBsZXNlY3JldA=="; @@ -41,14 +42,14 @@ describe("resolveAuthToken", () => { await writeConfig({ authToken: "config-token" }); expect(await resolve({ token: BASIC_TOKEN })).toEqual({ - credential: { type: "token", value: BASIC_TOKEN }, + credential: { type: AUTH_TYPE.TOKEN, value: BASIC_TOKEN }, source: "flag", }); }); it("infers an api key passed through --token", async () => { expect(await resolve({ token: API_KEY })).toEqual({ - credential: { type: "apiKey", value: API_KEY }, + credential: { type: AUTH_TYPE.API_KEY, value: API_KEY }, source: "flag", }); }); @@ -57,7 +58,7 @@ describe("resolveAuthToken", () => { process.env.DECODO_AUTH_TOKEN = API_KEY; expect(await resolve()).toEqual({ - credential: { type: "apiKey", value: API_KEY }, + credential: { type: AUTH_TYPE.API_KEY, value: API_KEY }, source: "env", }); }); @@ -70,7 +71,7 @@ describe("resolveAuthToken", () => { await writeConfig({ authToken: "config-token" }); expect(await resolve()).toEqual({ - credential: { type: "token", value: BASIC_TOKEN }, + credential: { type: AUTH_TYPE.TOKEN, value: BASIC_TOKEN }, source: "env", }); }); @@ -82,7 +83,7 @@ describe("resolveAuthToken", () => { await writeConfig({ apiKey: BASIC_TOKEN }); expect(await resolve()).toEqual({ - credential: { type: "apiKey", value: BASIC_TOKEN }, + credential: { type: AUTH_TYPE.API_KEY, value: BASIC_TOKEN }, source: "config", }); }); @@ -94,7 +95,7 @@ describe("resolveAuthToken", () => { await writeConfig({ authToken: BASIC_TOKEN }); expect(await resolve()).toEqual({ - credential: { type: "token", value: BASIC_TOKEN }, + credential: { type: AUTH_TYPE.TOKEN, value: BASIC_TOKEN }, source: "config", }); }); @@ -115,7 +116,7 @@ describe("resolveAuthToken", () => { it("trims surrounding whitespace before detecting", async () => { expect(await resolve({ token: ` ${API_KEY}\n` })).toEqual({ - credential: { type: "apiKey", value: API_KEY }, + credential: { type: AUTH_TYPE.API_KEY, value: API_KEY }, source: "flag", }); }); diff --git a/tests/scrape/commands/scrape.test.ts b/tests/scrape/commands/scrape.test.ts index e2332de..16f4cc3 100644 --- a/tests/scrape/commands/scrape.test.ts +++ b/tests/scrape/commands/scrape.test.ts @@ -1,6 +1,7 @@ import { BundledSchema, ValidationError } from "@decodo/sdk-ts"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js"; import { createScrapeCommand } from "../../../src/scrape/commands/scrape.js"; import { createDecodoClient } from "../../../src/scrape/services/client.js"; @@ -27,7 +28,7 @@ describe("createScrapeCommand", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - credential: { type: "token", value: "test-token" }, + credential: { type: AUTH_TYPE.TOKEN, value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/commands/screenshot.test.ts b/tests/scrape/commands/screenshot.test.ts index 230875e..98c93c0 100644 --- a/tests/scrape/commands/screenshot.test.ts +++ b/tests/scrape/commands/screenshot.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { BundledSchema, ValidationError } from "@decodo/sdk-ts"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js"; import { BINARY_TTY_ERROR } from "../../../src/platform/services/write-binary.js"; import { createScreenshotCommand } from "../../../src/scrape/commands/screenshot.js"; @@ -31,7 +32,7 @@ describe("createScreenshotCommand", () => { stdoutBytes = undefined; vi.mocked(resolveAuthToken).mockResolvedValue({ - credential: { type: "token", value: "test-token" }, + credential: { type: AUTH_TYPE.TOKEN, value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/commands/search.test.ts b/tests/scrape/commands/search.test.ts index facddd5..397b208 100644 --- a/tests/scrape/commands/search.test.ts +++ b/tests/scrape/commands/search.test.ts @@ -1,6 +1,7 @@ import { BundledSchema, ValidationError } from "@decodo/sdk-ts"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js"; import { createSearchCommand } from "../../../src/scrape/commands/search.js"; import { createDecodoClient } from "../../../src/scrape/services/client.js"; @@ -27,7 +28,7 @@ describe("createSearchCommand", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - credential: { type: "token", value: "test-token" }, + credential: { type: AUTH_TYPE.TOKEN, value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/services/auth-validation.test.ts b/tests/scrape/services/auth-validation.test.ts index 2d66253..74ea866 100644 --- a/tests/scrape/services/auth-validation.test.ts +++ b/tests/scrape/services/auth-validation.test.ts @@ -5,6 +5,7 @@ import { Target as ScrapeTarget, } from "@decodo/sdk-ts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { validateCredential } from "../../../src/scrape/services/auth-validation.js"; import { createDecodoClient } from "../../../src/scrape/services/client.js"; @@ -12,8 +13,14 @@ vi.mock("../../../src/scrape/services/client.js", () => ({ createDecodoClient: vi.fn(), })); -const TOKEN_CREDENTIAL = { type: "token", value: "test-token" } as const; -const API_KEY_CREDENTIAL = { type: "apiKey", value: "test-api-key" } as const; +const TOKEN_CREDENTIAL = { + type: AUTH_TYPE.TOKEN, + value: "test-token", +} as const; +const API_KEY_CREDENTIAL = { + type: AUTH_TYPE.API_KEY, + value: "test-api-key", +} as const; describe("validateCredential", () => { const scrape = vi.fn(); @@ -53,7 +60,7 @@ describe("validateCredential", () => { scrape.mockRejectedValue(new AuthenticationError("Username invalid.")); await expect( - validateCredential({ type: "token", value: "bad-token" }) + validateCredential({ type: AUTH_TYPE.TOKEN, value: "bad-token" }) ).rejects.toThrow(AuthenticationError); }); diff --git a/tests/scrape/services/run-target-scrape.test.ts b/tests/scrape/services/run-target-scrape.test.ts index 4849a3d..ad79477 100644 --- a/tests/scrape/services/run-target-scrape.test.ts +++ b/tests/scrape/services/run-target-scrape.test.ts @@ -1,6 +1,7 @@ import { BundledSchema, ValidationError } from "@decodo/sdk-ts"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { ConfigParseError } from "../../../src/auth/errors/config-parse-error.js"; import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js"; import { attachScrapeOutputOptions } from "../../../src/output/commands/attach-output-options.js"; @@ -42,7 +43,7 @@ describe("createTargetAction", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - credential: { type: "token", value: "test-token" }, + credential: { type: AUTH_TYPE.TOKEN, value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { @@ -93,7 +94,7 @@ describe("createTargetAction", () => { markdown: false, }); expect(createDecodoClient).toHaveBeenCalledWith( - { type: "token", value: "test-token" }, + { type: AUTH_TYPE.TOKEN, value: "test-token" }, BundledSchema.shared ); expect(stdout).toBe('{"ok":true}\n');