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
156 changes: 156 additions & 0 deletions src/commands/__tests__/auth.login-logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';

const spinnerInstances: Array<{ start: ReturnType<typeof vi.fn>; succeed: ReturnType<typeof vi.fn>; fail: ReturnType<typeof vi.fn>; text: string }> = [];

vi.mock('ora', () => ({
default: vi.fn(() => {
const spinner = {
start: vi.fn(),
succeed: vi.fn(),
fail: vi.fn(),
text: '',
};
spinnerInstances.push(spinner);
return spinner;
}),
}));

vi.mock('inquirer', () => ({
default: {
prompt: vi.fn(),
},
}));

vi.mock('../../services/auth.service.js', () => ({
authService: {
isAuthenticated: vi.fn(),
getCredentials: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
},
}));

import inquirer from 'inquirer';
import { authService } from '../../services/auth.service.js';
import { loginAction } from '../auth/login.js';
import { logoutAction } from '../auth/logout.js';

const mockPrompt = vi.mocked(inquirer.prompt);
const mockAuthService = vi.mocked(authService);

function mockProcessExit(): ReturnType<typeof vi.spyOn> {
return vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as any;
}

describe('auth login command', () => {
beforeEach(() => {
vi.clearAllMocks();
spinnerInstances.length = 0;
});

afterEach(() => {
vi.restoreAllMocks();
});

it('logs in with provided email/password without prompts', async () => {
mockAuthService.isAuthenticated.mockResolvedValue(false);
mockAuthService.login.mockResolvedValue(undefined);

await loginAction({ email: 'test@example.com', password: 'secret123' });

expect(mockPrompt).not.toHaveBeenCalled();
expect(mockAuthService.login).toHaveBeenCalledWith('test@example.com', 'secret123');
expect(spinnerInstances[0]?.start).toHaveBeenCalled();
expect(spinnerInstances[0]?.succeed).toHaveBeenCalled();
});

it('does not re-login when user cancels account switch', async () => {
mockAuthService.isAuthenticated.mockResolvedValue(true);
mockAuthService.getCredentials.mockResolvedValue({
accessToken: 'token',
refreshToken: 'refresh',
expiresAt: Date.now() + 1000 * 60 * 60,
user: { id: 'u1', email: 'old@example.com', orgs: [] },
} as any);
mockPrompt.mockResolvedValue({ confirm: false } as any);

await loginAction({});

expect(mockAuthService.login).not.toHaveBeenCalled();
expect(mockPrompt).toHaveBeenCalledTimes(1);
});

it('shows team-key switch prompt message and logs in when confirmed', async () => {
mockAuthService.isAuthenticated.mockResolvedValue(true);
mockAuthService.getCredentials.mockResolvedValue(null);
mockAuthService.login.mockResolvedValue(undefined);
mockPrompt
.mockResolvedValueOnce({ confirm: true } as any)
.mockResolvedValueOnce({ email: 'new@example.com', password: 'secret123' } as any);

await loginAction({});

expect(mockPrompt).toHaveBeenCalledTimes(2);
const firstPrompt = mockPrompt.mock.calls[0][0] as Array<{ message?: string }>;
expect(firstPrompt[0]?.message).toBe('Do you want to login with an account instead?');
expect(mockAuthService.login).toHaveBeenCalledWith('new@example.com', 'secret123');
});

it('exits with code 1 when login fails', async () => {
const exitSpy = mockProcessExit();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

mockAuthService.isAuthenticated.mockResolvedValue(false);
mockAuthService.login.mockRejectedValue(new Error('bad credentials'));

await expect(loginAction({ email: 'test@example.com', password: 'wrong' })).rejects.toThrow('process.exit:1');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalled();
});
});

describe('auth logout command', () => {
beforeEach(() => {
vi.clearAllMocks();
spinnerInstances.length = 0;
});

afterEach(() => {
vi.restoreAllMocks();
});

it('prints not authenticated when there is no session', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockAuthService.isAuthenticated.mockResolvedValue(false);

await logoutAction();

expect(mockAuthService.logout).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Not authenticated.'));
});

it('logs out when authenticated', async () => {
mockAuthService.isAuthenticated.mockResolvedValue(true);
mockAuthService.logout.mockResolvedValue(undefined);

await logoutAction();

expect(mockAuthService.logout).toHaveBeenCalledTimes(1);
expect(spinnerInstances[0]?.start).toHaveBeenCalled();
expect(spinnerInstances[0]?.succeed).toHaveBeenCalled();
});

it('exits with code 1 when logout fails', async () => {
const exitSpy = mockProcessExit();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

mockAuthService.isAuthenticated.mockResolvedValue(true);
mockAuthService.logout.mockRejectedValue(new Error('network'));

await expect(logoutAction()).rejects.toThrow('process.exit:1');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalled();
});
});
173 changes: 173 additions & 0 deletions src/commands/__tests__/auth.team-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('../../utils/config.js', () => ({
loadConfig: vi.fn(),
saveConfig: vi.fn(),
clearConfig: vi.fn(),
}));

vi.mock('../../utils/credentials.js', () => ({
clearCredentials: vi.fn(),
}));

import { clearConfig, loadConfig, saveConfig } from '../../utils/config.js';
import { clearCredentials } from '../../utils/credentials.js';
import { teamKeyAction, teamStatusAction } from '../auth/team-key.js';

const mockLoadConfig = vi.mocked(loadConfig);
const mockSaveConfig = vi.mocked(saveConfig);
const mockClearConfig = vi.mocked(clearConfig);
const mockClearCredentials = vi.mocked(clearCredentials);

function mockProcessExit(): ReturnType<typeof vi.spyOn> {
return vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as any;
}

describe('auth team-key command', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('fetch', vi.fn());
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('exits when key is missing', async () => {
const exitSpy = mockProcessExit();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(teamKeyAction({})).rejects.toThrow('process.exit:1');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalled();
});

it('exits when key format is invalid', async () => {
const exitSpy = mockProcessExit();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(teamKeyAction({ key: 'invalid' })).rejects.toThrow('process.exit:1');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalled();
});

it('saves config and clears credentials when key is valid', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: {
team: { name: 'Platform Team' },
organization: { name: 'Kodus' },
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
mockClearCredentials.mockResolvedValue(undefined);

await teamKeyAction({ key: 'kodus_abc123' });

expect(mockSaveConfig).toHaveBeenCalledWith({
teamKey: 'kodus_abc123',
teamName: 'Platform Team',
organizationName: 'Kodus',
});
expect(mockClearCredentials).toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/cli/validate-key'),
expect.objectContaining({
headers: { 'X-Team-Key': 'kodus_abc123' },
}),
);
});

it('fails and rolls back team config when clearing old credentials throws', async () => {
const fetchMock = vi.mocked(fetch);
const exitSpy = mockProcessExit();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: {
teamName: 'Backend Team',
organizationName: 'Kodus',
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
mockClearCredentials.mockRejectedValue(new Error('fs error'));

await expect(teamKeyAction({ key: 'kodus_abc123' })).rejects.toThrow('process.exit:1');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalled();
expect(mockClearConfig).toHaveBeenCalledTimes(1);
expect(mockSaveConfig).toHaveBeenCalled();
});

it('exits when API returns invalid key', async () => {
const fetchMock = vi.mocked(fetch);
const exitSpy = mockProcessExit();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ message: 'Invalid team key' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
},
),
);

await expect(teamKeyAction({ key: 'kodus_abc123' })).rejects.toThrow('process.exit:1');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalled();
});
});

describe('auth team-status command', () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('shows not-authenticated message when no team config exists', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockLoadConfig.mockResolvedValue(null);

await teamStatusAction();

const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(output).toContain('Not authenticated with team key');
});

it('shows team details when team config exists', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockLoadConfig.mockResolvedValue({
teamKey: 'kodus_abc123',
teamName: 'Platform Team',
organizationName: 'Kodus',
} as any);

await teamStatusAction();

const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(output).toContain('Authenticated');
expect(output).toContain('Kodus');
expect(output).toContain('Platform Team');
});
});
3 changes: 1 addition & 2 deletions src/commands/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ authCommand

authCommand
.command('logout')
.description('Remove local credentials')
.description('Remove local authentication (login and team key)')
.action(logoutAction);

authCommand
Expand All @@ -40,4 +40,3 @@ authCommand
.command('team-status')
.description('Show team authentication status')
.action(teamStatusAction);

9 changes: 7 additions & 2 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ export async function loginAction(options: LoginOptions): Promise<void> {

if (isAuthenticated && !options.email) {
const credentials = await authService.getCredentials();
console.log(chalk.yellow(`\nAlready logged in as ${credentials?.user.email}`));
const email = credentials?.user?.email;
console.log(
chalk.yellow(email ? `\nAlready logged in as ${email}` : '\nAlready authenticated with team key'),
);

const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Do you want to login with a different account?',
message: email
? 'Do you want to login with a different account?'
: 'Do you want to login with an account instead?',
default: false,
},
]);
Expand Down
3 changes: 1 addition & 2 deletions src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export async function logoutAction(): Promise<void> {
const isAuthenticated = await authService.isAuthenticated();

if (!isAuthenticated) {
console.log(chalk.yellow('\nNot logged in.'));
console.log(chalk.yellow('\nNot authenticated.'));
return;
}

Expand All @@ -27,4 +27,3 @@ export async function logoutAction(): Promise<void> {
process.exit(1);
}
}

Loading