Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/__tests__/auth-config.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ jest.mock('@inquirer/prompts', () => ({
password: jest.fn(),
}));

function mockCreditsResponse(
response: Partial<Response> & { json?: () => Promise<unknown> },
): void {
jest.spyOn(globalThis, 'fetch').mockResolvedValue(response as Response);
}

describe('auth and config integration', () => {
const originalApiKey = process.env.LINKUP_API_KEY;

Expand Down Expand Up @@ -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');
});
Expand All @@ -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"\'',
);
});
});
16 changes: 0 additions & 16 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/credits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { CREDITS_URL, verifyApiKey } from '../credits';

function mockFetchResponse(response: Partial<Response> & { json?: () => Promise<unknown> }): 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',
});
});
});
29 changes: 0 additions & 29 deletions src/__tests__/setup.test.ts

@Turanic Turanic Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remark: Those checks are not needed anymore as the api key is now fully validated from the credits endpoint. See the new tests in credits.test.ts and auth-config.integration.test.ts

This file was deleted.

28 changes: 14 additions & 14 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -27,28 +28,27 @@ async function runSetup(): Promise<void> {
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()}`);
} catch (error) {
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"\'');
}

Expand Down
26 changes: 4 additions & 22 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remark: This length is now only used to print a masked api key. No normalisation is needed anymore, as the credits endpoint perform all the validation.

const MASKED_API_KEY_MIN_LENGTH = 10;

export type ConfigSource = 'env' | 'file' | 'none';

Expand All @@ -23,7 +23,7 @@ export type ResolvedConfig = {
configPath: string;
};

export function getConfigDir(): string {
function getConfigDir(): string {
return join(homedir(), CONFIG_DIR_NAME);
}

Expand Down Expand Up @@ -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);
Expand All @@ -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,
});
Expand Down Expand Up @@ -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));
}
53 changes: 53 additions & 0 deletions src/credits.ts
Original file line number Diff line number Diff line change
@@ -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<VerifyApiKeyResult> {
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',
};
}
}