From 49bc88d97f6dcd2df6e99c101eade85de74ea7f7 Mon Sep 17 00:00:00 2001 From: Arthur d'Avray Date: Fri, 5 Jun 2026 11:02:36 +0200 Subject: [PATCH 1/3] feat: add credits endpoint client --- src/__tests__/credits.test.ts | 54 +++++++++++++++++++++++++++++++++++ src/credits.ts | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/__tests__/credits.test.ts create mode 100644 src/credits.ts diff --git a/src/__tests__/credits.test.ts b/src/__tests__/credits.test.ts new file mode 100644 index 0000000..1856a38 --- /dev/null +++ b/src/__tests__/credits.test.ts @@ -0,0 +1,54 @@ +import { CREDITS_URL, verifyApiKey } from '../credits'; + +function mockFetchResponse(response: Partial & { json?: () => Promise }): void { + jest.spyOn(globalThis, 'fetch').mockResolvedValue(response as Response); +} + +describe('verifyApiKey', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns the credit balance when verification succeeds', async () => { + mockFetchResponse({ + json: async () => ({ balance: 123.456 }), + ok: true, + status: 200, + statusText: 'OK', + }); + + await expect(verifyApiKey('test-api-key')).resolves.toEqual({ + balance: 123.456, + ok: true, + }); + expect(globalThis.fetch).toHaveBeenCalledWith(CREDITS_URL, { + headers: { + Authorization: 'Bearer test-api-key', + }, + }); + }); + + it('returns invalid when the API rejects the key', async () => { + mockFetchResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + await expect(verifyApiKey('bad-key')).resolves.toEqual({ + ok: false, + reason: 'invalid', + status: 401, + }); + }); + + it('returns network when fetch throws', async () => { + jest.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')); + + await expect(verifyApiKey('test-api-key')).resolves.toEqual({ + message: 'network down', + ok: false, + reason: 'network', + }); + }); +}); diff --git a/src/credits.ts b/src/credits.ts new file mode 100644 index 0000000..1e9fce2 --- /dev/null +++ b/src/credits.ts @@ -0,0 +1,53 @@ +export const CREDITS_URL = 'https://api.linkup.so/v1/credits/balance'; + +type VerifyApiKeyResult = + | { ok: true; balance: number } + | { ok: false; reason: 'invalid'; status: number } + | { ok: false; reason: 'network'; message: string }; + +function formatNetworkError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function readBalance(data: unknown): number { + if ( + typeof data === 'object' && + data !== null && + 'balance' in data && + typeof data.balance === 'number' + ) { + return data.balance; + } + + throw new Error('Credits endpoint returned an invalid response'); +} + +export async function verifyApiKey(apiKey: string): Promise { + try { + const response = await fetch(CREDITS_URL, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (response.ok) { + return { balance: readBalance(await response.json()), ok: true }; + } + + if (response.status === 401 || response.status === 403) { + return { ok: false, reason: 'invalid', status: response.status }; + } + + return { + message: `Credits endpoint returned ${response.status} ${response.statusText}`, + ok: false, + reason: 'network', + }; + } catch (error) { + return { + message: formatNetworkError(error), + ok: false, + reason: 'network', + }; + } +} From d20afdd0f944707252ff2c4551d726f6ff2b9afe Mon Sep 17 00:00:00 2001 From: Arthur d'Avray Date: Fri, 5 Jun 2026 11:03:36 +0200 Subject: [PATCH 2/3] refactor: use credits endpoint for auth --- src/__tests__/config.test.ts | 16 ---------------- src/__tests__/setup.test.ts | 29 ----------------------------- src/commands/setup.ts | 28 ++++++++++++++-------------- src/config.ts | 26 ++++---------------------- 4 files changed, 18 insertions(+), 81 deletions(-) delete mode 100644 src/__tests__/setup.test.ts diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index af63069..a658cbe 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -114,22 +114,6 @@ describe('saveApiKey', () => { expect(readFileSync(configPath, 'utf8')).toBe('api_key=my-secret-key\n'); }); - it('rejects empty keys', () => { - const configPath = tempConfigPath(); - - expect(() => saveApiKey('', configPath)).toThrow( - 'Invalid API key: must be at least 10 characters and single-line.', - ); - }); - - it('rejects keys containing newlines', () => { - const configPath = tempConfigPath(); - - expect(() => saveApiKey('abc\ninjected', configPath)).toThrow( - 'Invalid API key: must be at least 10 characters and single-line.', - ); - }); - it('trims keys before saving', () => { const configPath = tempConfigPath(); diff --git a/src/__tests__/setup.test.ts b/src/__tests__/setup.test.ts deleted file mode 100644 index 76a61e0..0000000 --- a/src/__tests__/setup.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { validateApiKey } from '../config'; - -describe('validateApiKey', () => { - it('rejects an empty key', () => { - expect(validateApiKey('')).toBe( - 'Invalid API key: must be at least 10 characters and single-line.', - ); - }); - - it('rejects keys shorter than 10 characters', () => { - expect(validateApiKey('short')).toBe( - 'Invalid API key: must be at least 10 characters and single-line.', - ); - expect(validateApiKey('123456789')).toBe( - 'Invalid API key: must be at least 10 characters and single-line.', - ); - }); - - it('rejects keys containing newlines', () => { - expect(validateApiKey('1234567890\ninjected')).toBe( - 'Invalid API key: must be at least 10 characters and single-line.', - ); - }); - - it('accepts keys of 10 characters or more', () => { - expect(validateApiKey('1234567890')).toBeNull(); - expect(validateApiKey('a-real-looking-api-key')).toBeNull(); - }); -}); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index e8596a2..145a701 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,5 +1,6 @@ import type { Command } from 'commander'; -import { getConfigPath, saveApiKey, validateApiKey } from '../config'; +import { getConfigPath, saveApiKey } from '../config'; +import { verifyApiKey } from '../credits'; import { exitWithCode, exitWithError, formatError } from '../output/errors'; const SETUP_URL = 'https://app.linkup.so'; @@ -27,12 +28,18 @@ async function runSetup(): Promise { exitWithCode(0); } - const validationError = validateApiKey(apiKey); - if (validationError) { - exitWithError(`Error: ${validationError}`); + apiKey = apiKey.trim(); + + console.log('\nStep 2: Verify API key'); + const verification = await verifyApiKey(apiKey); + if (!verification.ok && verification.reason === 'invalid') { + exitWithError('Error: Invalid API key. Get a valid key at https://app.linkup.so'); + } + if (verification.ok) { + console.log(`Verified: ${verification.balance} credits available`); } - console.log('\nStep 2: Save configuration'); + console.log('\nStep 3: Save configuration'); try { saveApiKey(apiKey); console.log(`API key saved to ${getConfigPath()}`); @@ -40,15 +47,8 @@ async function runSetup(): Promise { exitWithError(`Error: Saving config failed: ${formatError(error)}`); } - console.log('\nStep 3: Test connection'); - try { - const response = await fetch(SETUP_URL); - if (!response.ok) { - throw new Error(`Linkup website returned ${response.status} ${response.statusText}`); - } - console.log('Connected to Linkup website'); - } catch (error) { - console.error(`Warning: connection test failed: ${formatError(error)}`); + if (!verification.ok && verification.reason === 'network') { + console.error(`Warning: API key verification failed: ${verification.message}`); console.error('Your API key was saved. You can test it with \'linkup search "hello"\''); } diff --git a/src/config.ts b/src/config.ts index 636200a..15225f6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,7 +12,7 @@ import { dirname, join } from 'node:path'; const CONFIG_DIR_NAME = '.linkup'; const KEY_PREFIX = 'api_key='; -const MIN_API_KEY_LENGTH = 10; +const MASKED_API_KEY_MIN_LENGTH = 10; export type ConfigSource = 'env' | 'file' | 'none'; @@ -23,7 +23,7 @@ export type ResolvedConfig = { configPath: string; }; -export function getConfigDir(): string { +function getConfigDir(): string { return join(homedir(), CONFIG_DIR_NAME); } @@ -88,25 +88,7 @@ export function getApiKey(configPath: string = getConfigPath()): string | null { return resolveConfig(configPath).apiKey; } -export function validateApiKey(apiKey: string): string | null { - const normalized = apiKey.trim(); - if (!normalized || normalized.length < MIN_API_KEY_LENGTH || /[\r\n]/.test(apiKey)) { - return `Invalid API key: must be at least ${MIN_API_KEY_LENGTH} characters and single-line.`; - } - return null; -} - -function normalizeApiKeyForSave(apiKey: string): string { - const validationError = validateApiKey(apiKey); - if (validationError) { - throw new Error(validationError); - } - return apiKey.trim(); -} - export function saveApiKey(apiKey: string, configPath: string = getConfigPath()): void { - const normalizedApiKey = normalizeApiKeyForSave(apiKey); - const dirMode = 0o700; const fileMode = 0o600; const dir = dirname(configPath); @@ -115,7 +97,7 @@ export function saveApiKey(apiKey: string, configPath: string = getConfigPath()) const tmpPath = `${configPath}.tmp`; try { - writeFileSync(tmpPath, `${KEY_PREFIX}${normalizedApiKey}\n`, { + writeFileSync(tmpPath, `${KEY_PREFIX}${apiKey.trim()}\n`, { encoding: 'utf8', mode: fileMode, }); @@ -148,5 +130,5 @@ export function maskApiKey(key: string): string { if (key.length > minLength) { return `${key.slice(0, prefixLength)}...${key.slice(-suffixLength)}`; } - return '*'.repeat(Math.max(key.length, MIN_API_KEY_LENGTH)); + return '*'.repeat(Math.max(key.length, MASKED_API_KEY_MIN_LENGTH)); } From ec9bb1f7c93b1b2a82bd5b9b1cd60279e8c712c8 Mon Sep 17 00:00:00 2001 From: Arthur d'Avray Date: Fri, 5 Jun 2026 11:04:14 +0200 Subject: [PATCH 3/3] test: add integration tests for auths --- src/__tests__/auth-config.integration.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/__tests__/auth-config.integration.test.ts b/src/__tests__/auth-config.integration.test.ts index 41e8061..c617aec 100644 --- a/src/__tests__/auth-config.integration.test.ts +++ b/src/__tests__/auth-config.integration.test.ts @@ -12,6 +12,12 @@ jest.mock('@inquirer/prompts', () => ({ password: jest.fn(), })); +function mockCreditsResponse( + response: Partial & { json?: () => Promise }, +): void { + jest.spyOn(globalThis, 'fetch').mockResolvedValue(response as Response); +} + describe('auth and config integration', () => { const originalApiKey = process.env.LINKUP_API_KEY; @@ -79,6 +85,12 @@ describe('auth and config integration', () => { it('exits with an error when setup cannot save configuration', async () => { (password as jest.Mock).mockResolvedValueOnce('test-api-key-abcdefghijklmnop'); + mockCreditsResponse({ + json: async () => ({ balance: 12.34 }), + ok: true, + status: 200, + statusText: 'OK', + }); jest.spyOn(configModule, 'saveApiKey').mockImplementation(() => { throw new Error('disk full'); }); @@ -94,4 +106,44 @@ describe('auth and config integration', () => { expect(errorSpy).toHaveBeenCalledWith('Error: Saving config failed: disk full'); }); + + it('exits with an error when setup verification rejects the API key', async () => { + (password as jest.Mock).mockResolvedValueOnce('invalid-api-key'); + mockCreditsResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + const saveSpy = jest.spyOn(configModule, 'saveApiKey'); + const restoreExit = mockProcessExit(); + const { errorSpy } = captureConsole(); + + try { + await expect(run(['node', 'linkup', 'setup'])).rejects.toMatchObject({ code: 1 }); + } finally { + restoreExit(); + } + + expect(errorSpy).toHaveBeenCalledWith( + 'Error: Invalid API key. Get a valid key at https://app.linkup.so', + ); + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it('saves the API key and prints a warning when setup verification has a network error', async () => { + (password as jest.Mock).mockResolvedValueOnce('test-api-key-abcdefghijklmnop'); + jest.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')); + jest.spyOn(configModule, 'saveApiKey').mockImplementation(() => undefined); + jest.spyOn(configModule, 'getConfigPath').mockReturnValue('/tmp/linkup-config'); + const { errorSpy, logSpy } = captureConsole(); + + await run(['node', 'linkup', 'setup']); + + expect(configModule.saveApiKey).toHaveBeenCalledWith('test-api-key-abcdefghijklmnop'); + expect(logSpy).toHaveBeenCalledWith('API key saved to /tmp/linkup-config'); + expect(errorSpy).toHaveBeenCalledWith('Warning: API key verification failed: network down'); + expect(errorSpy).toHaveBeenCalledWith( + 'Your API key was saved. You can test it with \'linkup search "hello"\'', + ); + }); });