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
236 changes: 67 additions & 169 deletions src/commands/code/__tests__/auth-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import {
ensureCliAuth,
isToolAuthenticated,
readCliAuth,
syncApiKeyToTool,
syncOAuthToTool,
} from '../auth-sync.js';
import { FatalError } from '../errors.js';
import { FakeApiKeyService } from './fake-api-key-service.js';
Expand Down Expand Up @@ -152,128 +150,6 @@ describe('decodeJwtPayload', () => {
});
});

describe('syncOAuthToTool', () => {
it('writes oauth tokens to opencode auth file', async () => {
const files = new FakeFileStore();
const auth = fakeCliAuth();

await syncOAuthToTool(files, HOME, 'opencode', auth);

const written = files.getWrittenFiles();
const content = written.get(HOME + '/.local/share/opencode/auth.json')!;
const parsed = JSON.parse(content);
// The expires field should now use the JWT's exp claim (converted to milliseconds)
const jwtPayload = JSON.parse(
Buffer.from(auth.access_token.split('.')[1], 'base64url').toString(),
);
expect(parsed.berget).toEqual({
access: auth.access_token,
expires: (jwtPayload.exp as number) * 1000,
refresh: auth.refresh_token,
type: 'oauth',
});
});

it('writes oauth tokens to pi auth file', async () => {
const files = new FakeFileStore();
const auth = fakeCliAuth();

await syncOAuthToTool(files, HOME, 'pi', auth);

const written = files.getWrittenFiles();
const content = written.get(HOME + '/.pi/agent/auth.json')!;
const parsed = JSON.parse(content);
expect(parsed.berget.type).toBe('oauth');
});

it('merges with existing providers', async () => {
const files = new FakeFileStore();
files.seed(
HOME + '/.local/share/opencode/auth.json',
JSON.stringify({ openai: { key: 'sk-openai', type: 'api' } }),
);

const auth = fakeCliAuth();
await syncOAuthToTool(files, HOME, 'opencode', auth);

const written = files.getWrittenFiles();
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
expect(parsed.openai).toEqual({ key: 'sk-openai', type: 'api' });
expect(parsed.berget.type).toBe('oauth');
});

it('sets 0o600 permissions on the auth file', async () => {
const files = new FakeFileStore();
const auth = fakeCliAuth();

await syncOAuthToTool(files, HOME, 'opencode', auth);

const chmodCalls = files.getChmodCalls();
expect(chmodCalls).toHaveLength(1);
expect(chmodCalls[0]).toEqual({
mode: 0o600,
path: HOME + '/.local/share/opencode/auth.json',
});
});
});

describe('syncApiKeyToTool', () => {
it('writes api key to opencode auth file with type "api"', async () => {
const files = new FakeFileStore();

await syncApiKeyToTool(files, HOME, 'opencode', 'sk_ber_test');

const written = files.getWrittenFiles();
const content = written.get(HOME + '/.local/share/opencode/auth.json')!;
const parsed = JSON.parse(content);
expect(parsed.berget).toEqual({
key: 'sk_ber_test',
type: 'api',
});
});

it('writes api key to pi auth file with type "api_key"', async () => {
const files = new FakeFileStore();

await syncApiKeyToTool(files, HOME, 'pi', 'sk_ber_pi');

const written = files.getWrittenFiles();
const content = written.get(HOME + '/.pi/agent/auth.json')!;
const parsed = JSON.parse(content);
expect(parsed.berget).toEqual({
key: 'sk_ber_pi',
type: 'api_key',
});
});

it('merges with existing providers', async () => {
const files = new FakeFileStore();
files.seed(
HOME + '/.local/share/opencode/auth.json',
JSON.stringify({ anthropic: { key: 'sk-ant', type: 'api' } }),
);

await syncApiKeyToTool(files, HOME, 'opencode', 'sk_ber_test');

const written = files.getWrittenFiles();
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
expect(parsed.anthropic).toEqual({ key: 'sk-ant', type: 'api' });
});

it('sets 0o600 permissions on the auth file', async () => {
const files = new FakeFileStore();

await syncApiKeyToTool(files, HOME, 'opencode', 'sk_ber_test');

const chmodCalls = files.getChmodCalls();
expect(chmodCalls).toHaveLength(1);
expect(chmodCalls[0]).toEqual({
mode: 0o600,
path: HOME + '/.local/share/opencode/auth.json',
});
});
});

describe('configureAuth', () => {
const makeAuthDeps = (overrides: Partial<AuthDeps> = {}): AuthDeps =>
({
Expand Down Expand Up @@ -302,12 +178,10 @@ describe('configureAuth', () => {
expect((deps.prompter as FakePrompter).calls).toHaveLength(1); // Only the select prompt
});

it('Case A reconfigure: already authenticated — reconfigure with valid CLI token', async () => {
it('Case A reconfigure: already authenticated — reconfigure delegates login to the tool and leaves the old entry untouched', async () => {
const files = new FakeFileStore();
files.seed(
HOME + '/.local/share/opencode/auth.json',
JSON.stringify({ berget: { type: 'oauth' } }),
);
const original = JSON.stringify({ berget: { type: 'oauth' } });
files.seed(HOME + '/.local/share/opencode/auth.json', original);

const prompter = new FakePrompter([select('reconfigure'), select('subscription')]);

Expand All @@ -327,10 +201,14 @@ describe('configureAuth', () => {
}),
);

expect(result.authenticated).toBe(true);
expect(result.authenticated).toBe(false);
// The old entry is never deleted mid-flow: the tool's own login
// (/connect) overwrites it on success, and a cancelled/failed run must
// not leave the user with less working auth than before.
expect(files.getWrittenFiles().get(HOME + '/.local/share/opencode/auth.json')).toBe(original);
});

it('Case B: login success + berget_code_seat → chooses subscription', async () => {
it('Case B: login success + berget_code_seat → chooses subscription → instructs /connect, writes nothing', async () => {
const files = new FakeFileStore();
const jwt = makeJwt({
exp: 9_999_999_999_999,
Expand All @@ -351,14 +229,35 @@ describe('configureAuth', () => {
});
const result = await configureAuth(deps, 'opencode', cliAuth);

expect(result.authenticated).toBe(true);
const written = files.getWrittenFiles();
expect(written.has(HOME + '/.local/share/opencode/auth.json')).toBe(true);
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
expect(parsed.berget.type).toBe('oauth');
expect(result.authenticated).toBe(false);
// The CLI must never write the tool's private auth store
expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
const notes = prompter.calls.filter((c) => c.method === 'note');
const instruction = notes.find((n) => JSON.stringify(n.args).includes('/connect'));
expect(instruction).toBeDefined();
expect(JSON.stringify(instruction!.args)).toContain('Login with Berget');
});

it('Case B variant: login success + seat → chooses api_key', async () => {
it('Case B (pi): subscription → instructs /login, writes nothing', async () => {
const files = new FakeFileStore();
const prompter = new FakePrompter([select('subscription')]);

const deps = makeAuthDeps({
files,
prompter,
seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }),
});
const result = await configureAuth(deps, 'pi', fakeCliAuth());

expect(result.authenticated).toBe(false);
expect(files.getWrittenFiles().has(HOME + '/.pi/agent/auth.json')).toBe(false);
const notes = prompter.calls.filter((c) => c.method === 'note');
const instruction = notes.find((n) => JSON.stringify(n.args).includes('/login'));
expect(instruction).toBeDefined();
expect(JSON.stringify(instruction!.args)).toContain('Use a subscription');
});

it('Case B variant: login success + seat → chooses api_key → displays key, writes nothing', async () => {
const files = new FakeFileStore();
const jwt = makeJwt({
exp: 9_999_999_999_999,
Expand All @@ -379,25 +278,26 @@ describe('configureAuth', () => {
});
const result = await configureAuth(deps, 'opencode', cliAuth);

expect(result.authenticated).toBe(true);
const written = files.getWrittenFiles();
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
expect(parsed.berget.type).toBe('api');
expect(parsed.berget.key).toBe('sk_ber_test');
expect(result.authenticated).toBe(false);
expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
const notes = prompter.calls.filter((c) => c.method === 'note');
const keyNote = notes.find((n) => JSON.stringify(n.args).includes('sk_ber_test'));
expect(keyNote).toBeDefined();
expect(JSON.stringify(keyNote!.args)).toContain('/connect');
});

it('Case C: login success + no seat → creates api key', async () => {
it('Case C: login success + no seat → creates api key → displays key, writes nothing', async () => {
const files = new FakeFileStore();
const prompter = new FakePrompter([confirm(true)]);

const deps = makeAuthDeps({ files, prompter });
const result = await configureAuth(deps, 'opencode', fakeCliAuth());

expect(result.authenticated).toBe(true);
const written = files.getWrittenFiles();
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
expect(parsed.berget.type).toBe('api');
expect(parsed.berget.key).toBe('sk_ber_test');
expect(result.authenticated).toBe(false);
expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
const notes = prompter.calls.filter((c) => c.method === 'note');
const keyNote = notes.find((n) => JSON.stringify(n.args).includes('sk_ber_test'));
expect(keyNote).toBeDefined();
});

it('Case B variant failure: login success + seat → chooses api_key + creation fails', async () => {
Expand Down Expand Up @@ -471,14 +371,15 @@ describe('configureAuth', () => {
});
const result = await configureAuth(deps, 'opencode', fakeCliAuth());

expect(result.authenticated).toBe(true);
// Seat path: OAuth synced, NOT the no-seat API-key prompt
const written = files.getWrittenFiles();
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
expect(parsed.berget.type).toBe('oauth');
expect(result.authenticated).toBe(false);
// Seat path: /connect instructions, NOT the no-seat API-key prompt
const notes = prompter.calls.filter((c) => c.method === 'note');
const instruction = notes.find((n) => JSON.stringify(n.args).includes('/connect'));
expect(instruction).toBeDefined();
expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
});

it('seat status unverifiable (API down) → warns and syncs OAuth anyway', async () => {
it('seat status unverifiable (API down) → warns and instructs, writes nothing', async () => {
const files = new FakeFileStore();
const prompter = new FakePrompter([select('browser')]);

Expand All @@ -489,14 +390,11 @@ describe('configureAuth', () => {
});
const result = await configureAuth(deps, 'opencode', fakeCliAuth());

expect(result.authenticated).toBe(true);
// OAuth synced despite unverified status
const written = files.getWrittenFiles();
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
expect(parsed.berget.type).toBe('oauth');
// Warning note displayed
expect(result.authenticated).toBe(false);
expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
const notes = prompter.calls.filter((c) => c.method === 'note');
expect(notes.length).toBeGreaterThan(0);
const warningNote = notes.find((n) => JSON.stringify(n.args).includes('Could not verify'));
expect(warningNote).toBeDefined();
});

it('Case E: login fails → returns false when cliAuth is null', async () => {
Expand All @@ -523,12 +421,12 @@ describe('configureAuth', () => {

const result = await configureAuth(deps, 'opencode', cliAuth);

expect(result.authenticated).toBe(true);
expect(result.authenticated).toBe(false);
// No seat-selection prompt should have been shown
const selectCalls = prompter.calls.filter((c) => c.method === 'select');
expect(selectCalls).toHaveLength(0);
// OAuth was synced to the tool
expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(true);
// Nothing written to the tool's auth store
expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
// Warning note displayed
const notes = prompter.calls.filter((c) => c.method === 'note');
const warningNote = notes.find((n) => (n.args as any).title === 'Authentication');
Expand All @@ -547,7 +445,7 @@ describe('configureAuth', () => {
expect(written.size).toBe(0); // No files should be written
});

it('preserves existing providers during sync', async () => {
it('never writes the tool auth store, regardless of path', async () => {
const files = new FakeFileStore();
files.seed(
HOME + '/.local/share/opencode/auth.json',
Expand All @@ -572,10 +470,10 @@ describe('configureAuth', () => {
}),
);

const written = files.getWrittenFiles();
const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!);
expect(parsed.openai).toEqual({ key: 'sk-openai', type: 'api' });
expect(parsed.berget).toBeDefined();
// Other providers' entries are left untouched and nothing is added
expect(files.getWrittenFiles().get(HOME + '/.local/share/opencode/auth.json')).toBe(
JSON.stringify({ openai: { key: 'sk-openai', type: 'api' } }),
);
});
});

Expand Down
23 changes: 14 additions & 9 deletions src/commands/code/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ describe('runInit', () => {
expect(JSON.stringify(lastNote)).toContain('/login');
});

it('creates api key for pi when no seat', async () => {
it('creates api key for pi when no seat and displays it without writing the tool auth store', async () => {
const files = new FakeFileStore();

const deps = makeDeps({
Expand All @@ -577,12 +577,14 @@ describe('runInit', () => {
await runInit(deps);

const written = files.getWrittenFiles();
expect(written.has('/home/user/.pi/agent/auth.json')).toBe(true);
const parsed = JSON.parse(written.get('/home/user/.pi/agent/auth.json')!);
expect(parsed.berget.type).toBe('api_key');
expect(written.has('/home/user/.pi/agent/auth.json')).toBe(false);
const prompter = deps.prompter as FakePrompter;
const notes = prompter.calls.filter((c) => c.method === 'note');
const keyNote = notes.find((n) => JSON.stringify(n.args).includes('sk_ber_test'));
expect(keyNote).toBeDefined();
});

it('uses subscription when berget_code_seat present', async () => {
it('subscription path delegates login to the tool via /connect instructions', async () => {
const files = new FakeFileStore();
const farFuture = Math.floor(Date.now() / 1000) + 3600 * 24 * 365; // 1 year from now in seconds
files.seed(
Expand All @@ -609,8 +611,11 @@ describe('runInit', () => {
await runInit(deps);

const written = files.getWrittenFiles();
const parsed = JSON.parse(written.get('/home/user/.local/share/opencode/auth.json')!);
expect(parsed.berget.type).toBe('oauth');
expect(written.has('/home/user/.local/share/opencode/auth.json')).toBe(false);
const prompter = deps.prompter as FakePrompter;
const notes = prompter.calls.filter((c) => c.method === 'note');
const instruction = notes.find((n) => JSON.stringify(n.args).includes('/connect'));
expect(instruction).toBeDefined();
});
});

Expand Down Expand Up @@ -744,7 +749,7 @@ describe('runInit', () => {
expect(JSON.stringify(lastNote)).not.toContain("You're all set");
});

it('shows auth-only next steps when toolConfigured=false but auth=true (pi)', async () => {
it('shows install + auth instructions when toolConfigured=false and subscription delegates login (pi)', async () => {
const files = new FakeFileStore();
const farFuture = Math.floor(Date.now() / 1000) + 3600 * 24 * 365;
files.seed(
Expand Down Expand Up @@ -776,7 +781,7 @@ describe('runInit', () => {
const noteText = JSON.stringify(lastNote);
expect(noteText).toContain('Install Pi:');
expect(noteText).toContain('Run: pi');
expect(noteText).toContain('Select model: /model');
expect(noteText).toContain('Authenticate with Berget AI');
});

it('shows full setup instructions when toolConfigured=false and auth=false (opencode)', async () => {
Expand Down
Loading
Loading