Skip to content
51 changes: 30 additions & 21 deletions packages/cli/__tests__/commands/whoami.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,47 @@
import { buildTestJwt, buildExpiredJwt, prepareAuthTokens } from '@spotify-confidence/testing/auth';
import { loadPersistedToken, validateToken, decodeJwtPayload } from '@spotify-confidence/core';
import { prepareAuthTokens } from '@spotify-confidence/testing/auth';
import { whoamiCommand } from '@commands/whoami.js';
import { captureOutput } from '../helpers/capture.js';
import { createRunner } from '../helpers/run-command.js';

describe('whoami prerequisites', () => {
it('loads and validates a persisted token', () => {
const run = createRunner(whoamiCommand);

describe('whoami', () => {
it('prints user info when logged in', async () => {
using _auth = prepareAuthTokens('valid');
using output = captureOutput();

const token = loadPersistedToken();
await run(['whoami']);

expect(token).not.toBeNull();
expect(validateToken(token!).valid).toBe(true);
expect(output.stdout).toContain('existing@example.com');
expect(process.exitCode).not.toBe(1);
});

it('decodes email from the token payload', () => {
using _auth = prepareAuthTokens('valid');
const token = loadPersistedToken()!;
it('fails with expired message when token is expired', async () => {
using _auth = prepareAuthTokens('expired');
using output = captureOutput();

const payload = decodeJwtPayload(token);
await run(['whoami']);

expect(payload.email).toBe('existing@example.com');
expect(output.stderr).toContain('Run "confidence login"');
expect(process.exitCode).toBe(1);
});

it('detects an expired token', () => {
const sut = validateToken;
it('fails with not-logged-in message when no credentials exist', async () => {
using _auth = prepareAuthTokens('none');
using output = captureOutput();

expect(sut(buildExpiredJwt()).valid).toBe(false);
await run(['whoami']);

expect(output.stderr).toContain('Not logged in');
expect(process.exitCode).toBe(1);
});

it('reads CONFIDENCE_TOKEN env var over persisted tokens', () => {
using _auth = prepareAuthTokens('valid');
const envToken = buildTestJwt({ email: 'ci@example.com' });
vi.stubEnv('CONFIDENCE_TOKEN', envToken);
it('never triggers a browser login', async () => {
using _auth = prepareAuthTokens('expired');
using _output = captureOutput();

const sut = loadPersistedToken();
await run(['whoami']);

expect(sut).toBe(envToken);
expect(_output.stdout).not.toContain('Opening browser');
});
});
7 changes: 5 additions & 2 deletions packages/cli/__tests__/helpers/simulate-tty.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
export function simulateTTY(isTTY: boolean): Disposable {
const originalTTY = process.stdin.isTTY;
const originalStdinTTY = process.stdin.isTTY;
const originalStdoutTTY = process.stdout.isTTY;
const originalCI = process.env.CI;
process.stdin.isTTY = (isTTY || undefined) as typeof process.stdin.isTTY;
process.stdout.isTTY = (isTTY || undefined) as typeof process.stdout.isTTY;

if (isTTY) delete process.env.CI;

return {
[Symbol.dispose]: () => {
process.stdin.isTTY = originalTTY;
process.stdin.isTTY = originalStdinTTY;
process.stdout.isTTY = originalStdoutTTY;
if (originalCI !== undefined) process.env.CI = originalCI;
else delete process.env.CI;
},
Expand Down
131 changes: 131 additions & 0 deletions packages/cli/__tests__/utils/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { prepareAuthTokens, buildTestJwt } from '@spotify-confidence/testing/auth';
import { server, tokenResponse, tokenError } from '@spotify-confidence/testing';
import { captureOutput } from '../helpers/capture.js';
import { simulateTTY } from '../helpers/simulate-tty.js';

import type { AuthResult } from '@spotify-confidence/core';

const mockAuthenticate = vi.fn<() => Promise<AuthResult>>();

vi.mock('@spotify-confidence/core', async (importOriginal) => ({
...(await importOriginal<typeof import('@spotify-confidence/core')>()),
authenticate: (...args: unknown[]) => mockAuthenticate(...(args as [])),
}));

const { tryResolveAuth } = await import('@utils/auth.js');

describe('resolveToken', () => {
it('returns a valid persisted token without triggering login', async () => {
using _auth = prepareAuthTokens('valid');
using output = captureOutput();

const sut = await tryResolveAuth();

expect(sut).toBeTruthy();
expect(mockAuthenticate).not.toHaveBeenCalled();
expect(output.stdout).toBe('');
});

it('refreshes an expired token when refresh token is available', async () => {
using _auth = prepareAuthTokens('stale');
using output = captureOutput();
const freshToken = buildTestJwt({ email: 'refreshed@example.com' });
server.use(tokenResponse(freshToken));

const sut = await tryResolveAuth();

expect(sut).toBe(freshToken);
expect(mockAuthenticate).not.toHaveBeenCalled();
expect(output.stdout).toBe('');
});

it('triggers interactive login when not logged in and TTY is available', async () => {
using _auth = prepareAuthTokens('none');
using _tty = simulateTTY(true);
using output = captureOutput();
const freshToken = buildTestJwt({ email: 'new@example.com' });
mockAuthenticate.mockResolvedValue({
accessToken: freshToken,
region: 'EU',
workspace: 'test-ws',
});

const sut = await tryResolveAuth();

expect(sut).toBe(freshToken);
expect(mockAuthenticate).toHaveBeenCalled();
expect(output.stdout).toContain('Not logged in to Confidence');
expect(output.stdout).toContain('Opening browser');
expect(output.stdout).toContain('Authenticated as test-ws (EU)');
});

it('triggers login after refresh failure in interactive mode', async () => {
using _auth = prepareAuthTokens('stale');
using _tty = simulateTTY(true);
using output = captureOutput();
server.use(tokenError());
const freshToken = buildTestJwt({ email: 'reauth@example.com' });
mockAuthenticate.mockResolvedValue({
accessToken: freshToken,
region: 'US',
workspace: 'ws-2',
});

const sut = await tryResolveAuth();

expect(sut).toBe(freshToken);
expect(mockAuthenticate).toHaveBeenCalled();
expect(output.stdout).toContain('Session expired');
expect(output.stdout).toContain('Opening browser to re-authenticate');
});

it('fails with error in non-interactive mode when not logged in', async () => {
using _auth = prepareAuthTokens('none');
using output = captureOutput();

const sut = await tryResolveAuth();

expect(sut).toBeNull();
expect(output.stderr).toContain('Not logged in');
expect(mockAuthenticate).not.toHaveBeenCalled();
});

it('fails with error in non-interactive mode when token is expired', async () => {
using _auth = prepareAuthTokens('expired');
using output = captureOutput();

const sut = await tryResolveAuth();

expect(sut).toBeNull();
expect(output.stderr).toContain('Token expired');
expect(mockAuthenticate).not.toHaveBeenCalled();
});

it('reports login failure when authentication throws', async () => {
using _auth = prepareAuthTokens('none');
using _tty = simulateTTY(true);
using output = captureOutput();
mockAuthenticate.mockRejectedValue(new Error('Port 8084 in use'));

const sut = await tryResolveAuth();

expect(sut).toBeNull();
expect(output.stderr).toContain('Login failed');
});

it('passes profile through to authenticate', async () => {
using _auth = prepareAuthTokens('none');
using _tty = simulateTTY(true);
using _output = captureOutput();
const freshToken = buildTestJwt();
mockAuthenticate.mockResolvedValue({
accessToken: freshToken,
region: 'EU',
workspace: 'ws',
});

await tryResolveAuth('staging');

expect(mockAuthenticate).toHaveBeenCalledWith(expect.objectContaining({ profile: 'staging' }));
});
});
36 changes: 18 additions & 18 deletions packages/cli/src/commands/whoami.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,39 @@
import { loadPersistedToken, validateToken, decodeJwtPayload } from '@spotify-confidence/core';
import { print, fail, extractFlags } from '@output/print.js';
import {
decodeJwtPayload,
extractRegion,
extractOrganization,
extractWorkspace,
} from '@spotify-confidence/core';
import { print, extractFlags, fail } from '@output/print.js';
import { tryLoadAuthToken } from '@utils/auth.js';

export const whoamiCommand = {
command: 'whoami',
describe: 'Show current user, org, region, and token expiry',
handler(argv: Record<string, unknown>) {
async handler(argv: Record<string, unknown>) {
const profile = argv.profile as string | undefined;

const token = loadPersistedToken(profile);
const { token, expired } = await tryLoadAuthToken(profile);
if (!token) {
fail('Not logged in. Run "confidence login" first.');
return;
}

const validation = validateToken(token);
if (!validation.valid) {
fail('Token is expired. Run "confidence login" to re-authenticate.');
fail(
expired
? 'Auth expired. Run "confidence login" to re-authenticate.'
: 'Not logged in. Run "confidence login" first.',
);
return;
}

const payload = decodeJwtPayload(token);
const email = (payload.email as string) ?? undefined;
const org =
(payload.org_id as string) ??
(payload['https://confidence.dev/org_login_id'] as string) ??
undefined;
const exp = payload.exp as number | undefined;
const expiresAt = exp ? new Date(exp * 1000).toISOString() : 'unknown';

print({
data: {
email: email ?? 'unknown',
organization: org ?? 'unknown',
region: validation.region ?? 'unknown',
workspace: validation.workspace ?? 'unknown',
organization: extractOrganization(token) ?? 'unknown',
region: extractRegion(token),
workspace: extractWorkspace(token) ?? 'unknown',
expires: expiresAt,
profile: profile ?? 'default',
},
Expand Down
20 changes: 9 additions & 11 deletions packages/cli/src/features/mcp/resolve-token.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
import { message, fail } from '@output/print.js';
import { loadPersistedToken, authenticate, validateToken } from '@spotify-confidence/core';
import { hint, fail } from '@output/print.js';
import { authenticate } from '@spotify-confidence/core';
import { tryLoadAuthToken } from '@utils/auth.js';

export async function resolveAuthToken(opts?: {
forceNew?: boolean;
profile?: string;
}): Promise<string | null> {
if (!opts?.forceNew) {
const existing = loadPersistedToken(opts?.profile);
if (existing) {
const { valid } = validateToken(existing);
if (valid) return existing;
try {
if (!opts?.forceNew) {
const { token } = await tryLoadAuthToken(opts?.profile);
if (token) return token;
}
}

try {
message('Authentication required. Opening browser...');
hint('Authentication required. Opening browser... (Ctrl+C to cancel)');
const result = await authenticate({
mode: 'login',
profile: opts?.profile,
onUrl: (url) => message(`If the browser did not open, visit:\n${url}`),
onUrl: (url) => hint(`If the browser did not open, visit:\n${url}`),
});
return result.accessToken;
} catch (err) {
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/output/print.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { extractText, type CallToolResult } from '@spotify-confidence/core';
import { extractText, isInteractive, type CallToolResult } from '@spotify-confidence/core';
import type { OutputFormat } from './detect.js';
import { resolveFormat } from './detect.js';
import { formatJson } from './json.js';
Expand Down Expand Up @@ -46,6 +46,11 @@ export function warn(text: string): void {
process.stderr.write(`Warning: ${text}\n`);
}

export function hint(text: string): void {
if (!isInteractive()) return;
process.stdout.write(text + '\n');
}

export function fail(text?: string): void {
if (text) error(text);
process.exitCode = 1;
Expand Down
Loading
Loading