Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ If no credentials file exists, exits cleanly with no error.`,
}

const profileFlag = globalOpts.profile;
const logoutAll = !profileFlag;
const logoutAll = profileFlag === undefined;
// For logoutAll we don't need a specific profile; for single-profile
// removal, the user-supplied flag is the source of truth. We deliberately
// avoid resolveProfileName() so a corrupted credentials file doesn't
Expand Down
22 changes: 10 additions & 12 deletions src/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,21 +53,19 @@ Shows which profile is active and where the active credential comes from.`,
);

if (!resolved) {
const requestedProfile = profileFlag
? profileFlag
: resolveProfileName(profileFlag);
const requestedProfile = profileFlag ?? resolveProfileName(profileFlag);
const profiles = listProfiles();
const profileExists = profiles.some((p) => p.name === requestedProfile);
const explicitProfile = profileFlag || process.env.RESEND_PROFILE;
const explicitProfile = profileFlag ?? process.env.RESEND_PROFILE;
const explicitProfileNotFound =
explicitProfile !== undefined && !profileExists;

const message =
explicitProfile && !profileExists
? `Profile "${requestedProfile}" not found.\nAvailable profiles: ${profiles.map((p) => p.name).join(', ') || '(none)'}`
: 'Not authenticated.\nRun `resend login` to get started.';
const code =
explicitProfile && !profileExists
? 'profile_not_found'
: 'not_authenticated';
const message = explicitProfileNotFound
? `Profile "${requestedProfile}" not found.\nAvailable profiles: ${profiles.map((p) => p.name).join(', ') || '(none)'}`
: 'Not authenticated.\nRun `resend login` to get started.';
const code = explicitProfileNotFound
? 'profile_not_found'
: 'not_authenticated';

if (globalOpts.json || !isInteractive()) {
outputError({ message, code }, { json: globalOpts.json });
Expand Down
4 changes: 2 additions & 2 deletions src/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function createClient(
): Promise<Resend> {
const resolved = await resolveAuthentication(flagValue, profileName);
if (!resolved) {
if (profileName) {
if (profileName !== undefined) {
const profiles = listProfiles();
const exists = profiles.some((p) => p.name === profileName);
if (!exists) {
Expand All @@ -64,7 +64,7 @@ export async function requireClient(
try {
const resolved = await resolveAuthentication(opts.apiKey, profileName);
if (!resolved) {
if (profileName) {
if (profileName !== undefined) {
const profiles = listProfiles();
const exists = profiles.some((p) => p.name === profileName);
if (!exists) {
Expand Down
46 changes: 23 additions & 23 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { requireNonEmpty } from '../utils/require-non-empty';
import { CorruptedCredentialsError } from './corrupted-credentials-error';
import {
type CredentialBackend,
Expand Down Expand Up @@ -206,12 +207,16 @@ export function writeCredentials(creds: CredentialsFile): string {
}

export function resolveProfileName(flagValue?: string): string {
if (flagValue) {
return flagValue;
const flagProfile = requireNonEmpty(flagValue, '--profile');
if (flagProfile !== undefined) {
return flagProfile;
}

const envProfile = process.env.RESEND_PROFILE;
if (envProfile) {
const envProfile = requireNonEmpty(
process.env.RESEND_PROFILE,
'RESEND_PROFILE',
);
if (envProfile !== undefined) {
return envProfile;
}

Expand All @@ -227,12 +232,13 @@ export function resolveApiKey(
flagValue?: string,
profileName?: string,
): ResolvedApiKey | null {
if (flagValue) {
return { type: 'api_key', key: flagValue, source: 'flag' };
const flagKey = requireNonEmpty(flagValue, '--api-key');
if (flagKey !== undefined) {
return { type: 'api_key', key: flagKey, source: 'flag' };
}

const envKey = process.env.RESEND_API_KEY;
if (envKey) {
const envKey = requireNonEmpty(process.env.RESEND_API_KEY, 'RESEND_API_KEY');
if (envKey !== undefined) {
return { type: 'api_key', key: envKey, source: 'env' };
}

Expand Down Expand Up @@ -307,7 +313,8 @@ export function removeApiKey(profileName?: string): string {
throw new Error('No credentials file found.');
}

const profile = profileName || resolveProfileName();
const profile =
requireNonEmpty(profileName, '--profile') ?? resolveProfileName();
if (!creds.profiles[profile]) {
throw new Error(
`Profile "${profile}" not found. Available profiles: ${Object.keys(creds.profiles).join(', ')}`,
Expand Down Expand Up @@ -459,21 +466,18 @@ export async function resolveAuthentication(
profileName?: string,
options?: { refresh?: boolean },
): Promise<ResolvedAuthentication | null> {
if (flagValue) {
return { type: 'api_key', key: flagValue, source: 'flag' };
const flagKey = requireNonEmpty(flagValue, '--api-key');
if (flagKey !== undefined) {
return { type: 'api_key', key: flagKey, source: 'flag' };
}

const envKey = process.env.RESEND_API_KEY;
if (envKey) {
const envKey = requireNonEmpty(process.env.RESEND_API_KEY, 'RESEND_API_KEY');
if (envKey !== undefined) {
return { type: 'api_key', key: envKey, source: 'env' };
}

const creds = readCredentials();
const profile =
profileName ||
process.env.RESEND_PROFILE ||
creds?.active_profile ||
'default';
const profile = resolveProfileName(profileName);

if (creds?.storage === 'secure_storage' && creds.profiles[profile]) {
const credential = creds.profiles[profile];
Expand Down Expand Up @@ -642,11 +646,7 @@ export async function storeOAuthGrant(
export async function removeApiKeyAsync(profileName?: string): Promise<string> {
return withFileLock(getCredentialsLockPath(), async () => {
const creds = readCredentials();
const profile =
profileName ||
process.env.RESEND_PROFILE ||
creds?.active_profile ||
'default';
const profile = resolveProfileName(profileName);

if (!creds?.profiles[profile]) {
throw new Error(
Expand Down
14 changes: 14 additions & 0 deletions src/utils/require-non-empty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const requireNonEmpty = (
value: string | undefined,
source: string,
): string | undefined => {
if (value === undefined) {
return undefined;
}
if (value.trim().length === 0) {
throw new Error(
`${source} is set but empty. Provide a non-empty value or remove it.`,
);
}
return value;
};
35 changes: 34 additions & 1 deletion tests/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
Expand Down Expand Up @@ -133,6 +139,33 @@ describe('logout command', () => {
expect(output.profile).toBe('staging');
});

it('does not remove all profiles when --profile is empty', async () => {
spies = setupOutputSpies();
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
exitSpy = mockExitThrow();
writeCredentials({ staging: 're_staging_key', production: 're_prod_key' });

const { Command } = await import('@commander-js/extra-typings');
const { logoutCommand } = await import('../../../src/commands/auth/logout');
const program = new Command()
.option('--profile <name>')
.option('--json')
.addCommand(logoutCommand);

await expectExit1(() =>
program.parseAsync(['logout', '--profile', ''], { from: 'user' }),
);

const configPath = join(tmpDir, 'resend', 'credentials.json');
expect(existsSync(configPath)).toBe(true);
const remaining = JSON.parse(readFileSync(configPath, 'utf-8'));
expect(remaining.profiles.staging).toBeDefined();
expect(remaining.profiles.production).toBeDefined();

const output = JSON.parse(errorSpy?.mock.calls[0][0] as string);
expect(output.error.code).toBe('remove_failed');
});

it('exits with error when file removal fails', async () => {
spies = setupOutputSpies();
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
Expand Down
40 changes: 40 additions & 0 deletions tests/commands/whoami.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,46 @@ describe('whoami command', () => {
expect(parsed.error.message).toContain('not found');
});

it('errors on empty RESEND_PROFILE instead of falling back to the active profile', async () => {
const configDir = join(tmpDir, 'resend');
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, 'credentials.json'),
JSON.stringify({
active_profile: 'production',
profiles: { production: { api_key: 're_test_key_abcd' } },
}),
);
process.env.RESEND_PROFILE = '';

spies = setupOutputSpies();

const { whoamiCommand } = await import('../../src/commands/whoami');
await expect(
whoamiCommand.parseAsync([], { from: 'user' }),
).rejects.toThrow('RESEND_PROFILE is set but empty');
});

it('errors on empty RESEND_API_KEY instead of falling back to stored credentials', async () => {
const configDir = join(tmpDir, 'resend');
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, 'credentials.json'),
JSON.stringify({
active_profile: 'production',
profiles: { production: { api_key: 're_test_key_abcd' } },
}),
);
process.env.RESEND_API_KEY = '';

spies = setupOutputSpies();

const { whoamiCommand } = await import('../../src/commands/whoami');
await expect(
whoamiCommand.parseAsync([], { from: 'user' }),
).rejects.toThrow('RESEND_API_KEY is set but empty');
});

it('shows authenticated JSON when key exists in config', async () => {
const configDir = join(tmpDir, 'resend');
mkdirSync(configDir, { recursive: true });
Expand Down
110 changes: 110 additions & 0 deletions tests/lib/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,116 @@ describe('createClient', () => {

rmSync(tmpDir, { recursive: true, force: true });
});

it('rejects an empty api key flag instead of falling back to env', async () => {
process.env.RESEND_API_KEY = 're_env_key';
process.env.RESEND_CREDENTIAL_STORE = 'file';
const { createClient } = await import('../../src/lib/client');
await expect(createClient('')).rejects.toThrow(
'--api-key is set but empty',
);
});

it('rejects an empty profile name instead of using the active profile', async () => {
delete process.env.RESEND_API_KEY;
process.env.RESEND_CREDENTIAL_STORE = 'file';
const tmpDir = join(
tmpdir(),
`resend-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(tmpDir, { recursive: true });
process.env.XDG_CONFIG_HOME = tmpDir;

const configDir = join(tmpDir, 'resend');
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, 'credentials.json'),
JSON.stringify({
active_profile: 'default',
profiles: { default: { api_key: 're_default_key' } },
}),
);

const { createClient } = await import('../../src/lib/client');
await expect(createClient(undefined, '')).rejects.toThrow(
'--profile is set but empty',
);

rmSync(tmpDir, { recursive: true, force: true });
});
});

describe('requireClient input validation', () => {
const restoreEnv = captureTestEnv();
let tmpDir: string;
let exitSpy: MockInstance | undefined;

beforeEach(() => {
tmpDir = join(
tmpdir(),
`resend-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(tmpDir, { recursive: true });
process.env.XDG_CONFIG_HOME = tmpDir;
process.env.RESEND_CREDENTIAL_STORE = 'file';
delete process.env.RESEND_API_KEY;
delete process.env.RESEND_PROFILE;
});

afterEach(() => {
restoreEnv();
exitSpy?.mockRestore();
exitSpy = undefined;
rmSync(tmpDir, { recursive: true, force: true });
});

it('exits with auth_error when --profile is empty instead of using the active profile', async () => {
const configDir = join(tmpDir, 'resend');
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, 'credentials.json'),
JSON.stringify({
active_profile: 'default',
profiles: { default: { api_key: 're_default_key' } },
}),
);

setupOutputSpies();
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
exitSpy = mockExitThrow();

const { requireClient } = await import('../../src/lib/client');
await expectExit1(() => requireClient({ json: true, profile: '' }));

const output = errSpy.mock.calls[0][0] as string;
expect(output).toContain('auth_error');
expect(output).toContain('--profile is set but empty');
errSpy.mockRestore();
});

it('exits with auth_error when --api-key is empty instead of falling back', async () => {
const configDir = join(tmpDir, 'resend');
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, 'credentials.json'),
JSON.stringify({
active_profile: 'default',
profiles: { default: { api_key: 're_default_key' } },
}),
);

setupOutputSpies();
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
exitSpy = mockExitThrow();

const { requireClient } = await import('../../src/lib/client');
await expectExit1(() => requireClient({ json: true, apiKey: '' }));

const output = errSpy.mock.calls[0][0] as string;
expect(output).toContain('auth_error');
expect(output).toContain('--api-key is set but empty');
errSpy.mockRestore();
});
});

describe('requireClient permission check', () => {
Expand Down
Loading
Loading