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
61 changes: 1 addition & 60 deletions src/auth/__tests__/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { describe, expect, it } from 'vitest';

import {
decodeJwtPayload,
extractJwtExpiresAt,
hasBergetCodeSeat,
isTokenExpired,
} from '../jwt.js';
import { decodeJwtPayload, extractJwtExpiresAt, isTokenExpired } from '../jwt.js';

function base64urlEncode(data: string): string {
return Buffer.from(data).toString('base64url');
Expand Down Expand Up @@ -88,57 +83,3 @@ describe('isTokenExpired', () => {
expect(isTokenExpired(farFuture)).toBe(false);
});
});

describe('hasBergetCodeSeat', () => {
it('returns true when berget_code_seat is present', () => {
const token = makeJwt({
realm_access: { roles: ['berget_code_seat', 'default-roles-berget'] },
});
expect(hasBergetCodeSeat(token)).toBe(true);
});

it('returns true for a Pro seat (seat_plan_pro_seat)', () => {
const token = makeJwt({
realm_access: { roles: ['seat_plan_pro_seat', 'default-roles-berget'] },
});
expect(hasBergetCodeSeat(token)).toBe(true);
});

it('returns true for a Summit seat (seat_plan_summit_seat)', () => {
const token = makeJwt({
realm_access: { roles: ['seat_plan_summit_seat', 'default-roles-berget'] },
});
expect(hasBergetCodeSeat(token)).toBe(true);
});

it('returns true for a Mini seat (seat_plan_mini_seat)', () => {
const token = makeJwt({
realm_access: { roles: ['seat_plan_mini_seat', 'default-roles-berget'] },
});
expect(hasBergetCodeSeat(token)).toBe(true);
});

it('returns false for look-alike roles that are not seat roles', () => {
const token = makeJwt({
realm_access: { roles: ['seat_plan_pro', 'berget_code', 'seat_plan_pro_seat_v2'] },
});
expect(hasBergetCodeSeat(token)).toBe(false);
});

it('returns false when role is missing', () => {
const token = makeJwt({
realm_access: { roles: ['default-roles-berget'] },
});
expect(hasBergetCodeSeat(token)).toBe(false);
});

it('returns false when realm_access is missing', () => {
const token = makeJwt({ sub: '123' });
expect(hasBergetCodeSeat(token)).toBe(false);
});

it('returns false for invalid JWT', () => {
expect(hasBergetCodeSeat('invalid')).toBe(false);
expect(hasBergetCodeSeat('only.two')).toBe(false);
});
});
63 changes: 63 additions & 0 deletions src/auth/__tests__/seat-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { createSeatStatusService } from '../../commands/code/adapters/seat-status.js';

const BASE = 'https://api.berget.ai';

afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nitvi.unstubAllEnvs() restores stubs but no longer deletes a real BERGET_API_URL (the old afterEach did), so the default-URL assertions fail wherever that env var is set — delete it in beforeEach.

});

describe('seat-status service (GET /v1/auth/status)', () => {
it('returns seatId and tier when the user has a seat', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
json: async () => ({ authenticated: true, seatId: 168, tier: 'seat_plan_pro' }),
ok: true,
}),
);
const svc = createSeatStatusService();
expect(await svc.fetchSeatStatus('token')).toEqual({
seatId: 168,
tier: 'seat_plan_pro',
});
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe(`${BASE}/v1/auth/status`);
});

it('returns null seat when the user has no seat', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
json: async () => ({ authenticated: true, seatId: null, tier: null }),
ok: true,
}),
);
const svc = createSeatStatusService();
expect(await svc.fetchSeatStatus('token')).toEqual({ seatId: null, tier: null });
});

it('returns null on non-OK responses (e.g. 401)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ json: async () => ({}), ok: false, status: 401 }),
);
const svc = createSeatStatusService();
expect(await svc.fetchSeatStatus('token')).toBeNull();
});

it('returns null on network errors (never throws)', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ENOTFOUND')));
const svc = createSeatStatusService();
expect(await svc.fetchSeatStatus('token')).toBeNull();
});

it('honours BERGET_API_URL override', async () => {
vi.stubEnv('BERGET_API_URL', 'https://api.stage.berget.ai');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: async () => ({}), ok: true }));
const svc = createSeatStatusService();
await svc.fetchSeatStatus('token');
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe('https://api.stage.berget.ai/v1/auth/status');
});
});
2 changes: 1 addition & 1 deletion src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export { getAuthConfig } from './config.js';
export { resolveApiKey } from './credentials/api-key.js';
export { resolveAuth } from './credentials/resolver.js';
export { clearConfigurationCache, getConfiguration } from './issuer.js';
export { decodeJwtPayload, extractJwtExpiresAt, hasBergetCodeSeat, isTokenExpired } from './jwt.js';
export { decodeJwtPayload, extractJwtExpiresAt, isTokenExpired } from './jwt.js';
export { authMiddleware } from './middleware/auth-middleware.js';
export { startPkceFlow } from './oauth/pkce-flow.js';
export { refreshAccessToken } from './oauth/token-refresh.js';
Expand Down
28 changes: 0 additions & 28 deletions src/auth/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,34 +20,6 @@ export function extractJwtExpiresAt(accessToken: string): number {
return 0;
}

/**
* Seat roles that grant a paid Berget subscription, one per tier.
* `berget_code` is the legacy identifier for the Standard tier (do not rename);
* newer tiers follow `seat_plan_<tier>`. Mirrors
* backend-api/src/config/seat-product.config.ts.
*/
const SEAT_ROLES = [
'berget_code_seat',
'seat_plan_mini_seat',
'seat_plan_pro_seat',
'seat_plan_summit_seat',
] as const;

/**
* Check if the JWT token has any paid seat role (any tier: Standard/Mini/Pro/Summit).
* @param accessToken The JWT access token
* @returns true if the token holds a seat role, false otherwise
*/
export function hasBergetCodeSeat(accessToken: string): boolean {
const decoded = parseJwtBody(accessToken);
if (!decoded) return false;
const realmAccess = decoded.realm_access as Record<string, unknown> | undefined;
if (!realmAccess) return false;
const roles = realmAccess.roles as string[] | undefined;
if (!Array.isArray(roles)) return false;
return roles.some((role) => (SEAT_ROLES as readonly string[]).includes(role));
}

/**
* Check if a token is expired with a configurable buffer.
* Uses 10% of remaining lifetime or 30 seconds, whichever is smaller.
Expand Down
99 changes: 68 additions & 31 deletions src/commands/code/__tests__/auth-sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { decodeJwtPayload, hasBergetCodeSeat } from '../../../auth/jwt.js';
import { decodeJwtPayload } from '../../../auth/jwt.js';
import {
type AuthDeps,
type CliAuth,
Expand All @@ -16,6 +16,7 @@ import { FakeApiKeyService } from './fake-api-key-service.js';
import { FakeAuthService } from './fake-auth-service.js';
import { FakeFileStore } from './fake-file-store.js';
import { confirm, FakePrompter, select } from './fake-prompter.js';
import { FakeSeatStatusService } from './fake-seat-status-service.js';

const ENV_KEYS = [
'XDG_CONFIG_HOME',
Expand Down Expand Up @@ -151,31 +152,6 @@ describe('decodeJwtPayload', () => {
});
});

describe('hasBergetCodeSeat', () => {
it('returns true when berget_code_seat is present', () => {
const token = makeJwt({
realm_access: { roles: ['berget_code_seat', 'default-roles-berget'] },
});
expect(hasBergetCodeSeat(token)).toBe(true);
});

it('returns false when role is missing', () => {
const token = makeJwt({
realm_access: { roles: ['default-roles-berget'] },
});
expect(hasBergetCodeSeat(token)).toBe(false);
});

it('returns false when realm_access is missing', () => {
const token = makeJwt({ sub: '123' });
expect(hasBergetCodeSeat(token)).toBe(false);
});

it('returns false for invalid JWT', () => {
expect(hasBergetCodeSeat('invalid')).toBe(false);
});
});

describe('syncOAuthToTool', () => {
it('writes oauth tokens to opencode auth file', async () => {
const files = new FakeFileStore();
Expand Down Expand Up @@ -306,6 +282,7 @@ describe('configureAuth', () => {
files: new FakeFileStore(),
homeDir: HOME,
prompter: new FakePrompter([]),
seatStatusService: new FakeSeatStatusService({ seatId: null, tier: null }),
...overrides,
}) as AuthDeps;

Expand Down Expand Up @@ -334,7 +311,11 @@ describe('configureAuth', () => {

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

const deps = makeAuthDeps({ files, prompter });
const deps = makeAuthDeps({
files,
prompter,
seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }),
});
const result = await configureAuth(
deps,
'opencode',
Expand Down Expand Up @@ -363,7 +344,11 @@ describe('configureAuth', () => {

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

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

expect(result.authenticated).toBe(true);
Expand All @@ -387,7 +372,11 @@ describe('configureAuth', () => {

const prompter = new FakePrompter([select('api_key')]);

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

expect(result.authenticated).toBe(true);
Expand Down Expand Up @@ -430,7 +419,12 @@ describe('configureAuth', () => {
'Before you can create API keys, you need to finish setting up your account.',
);

const deps = makeAuthDeps({ apiKeyService: failingApiKeyService, files, prompter });
const deps = makeAuthDeps({
apiKeyService: failingApiKeyService,
files,
prompter,
seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }),
});

await expect(configureAuth(deps, 'opencode', cliAuth)).rejects.toThrow(FatalError);
expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
Expand Down Expand Up @@ -466,6 +460,45 @@ describe('configureAuth', () => {
expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false);
});

it('seat without recognized tier ({seatId, tier: null}) → still treated as seat-holder', async () => {
const files = new FakeFileStore();
const prompter = new FakePrompter([select('subscription')]);

const deps = makeAuthDeps({
files,
prompter,
seatStatusService: new FakeSeatStatusService({ seatId: 168, tier: null }),
});
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');
});

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

const deps = makeAuthDeps({
files,
prompter,
seatStatusService: new FakeSeatStatusService(null),
});
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
const notes = prompter.calls.filter((c) => c.method === 'note');
expect(notes.length).toBeGreaterThan(0);
});

it('Case E: login fails → returns false when cliAuth is null', async () => {
const files = new FakeFileStore();

Expand Down Expand Up @@ -523,7 +556,11 @@ describe('configureAuth', () => {

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

const deps = makeAuthDeps({ files, prompter });
const deps = makeAuthDeps({
files,
prompter,
seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }),
});
await configureAuth(
deps,
'opencode',
Expand Down
13 changes: 13 additions & 0 deletions src/commands/code/__tests__/fake-seat-status-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { SeatStatus, SeatStatusPort } from '../ports/auth-services.js';

/**
* Test double: pass a SeatStatus for "user has a seat", or null for
* "status could not be verified" (API down).
*/
export class FakeSeatStatusService implements SeatStatusPort {
constructor(private readonly result: null | SeatStatus) {}

async fetchSeatStatus(): Promise<null | SeatStatus> {
return this.result;
}
}
5 changes: 5 additions & 0 deletions src/commands/code/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FakeAuthService } from './fake-auth-service.js';
import { FakeCommandRunner } from './fake-command-runner.js';
import { FakeFileStore } from './fake-file-store.js';
import { CANCEL, confirm, FakePrompter, multiselect, select } from './fake-prompter.js';
import { FakeSeatStatusService } from './fake-seat-status-service.js';

const ENV_KEYS = [
'XDG_CONFIG_HOME',
Expand Down Expand Up @@ -48,6 +49,8 @@ const makeDeps = (
homeDir: '/home/user',
isTty: overrides.isTty ?? true,
prompter: overrides.prompter ?? new FakePrompter([]),
seatStatusService:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

good — Defaulting a null-seat FakeSeatStatusService in makeDeps repairs the three previously crashing runInit tests without weakening their assertions.

overrides.seatStatusService ?? new FakeSeatStatusService({ seatId: null, tier: null }),
...Object.fromEntries(
Object.entries(overrides).filter(
([k]) =>
Expand Down Expand Up @@ -575,6 +578,7 @@ describe('runInit', () => {
confirm(true, 'Create'),
multiselect([]),
]),
seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }),
});

await runInit(deps);
Expand Down Expand Up @@ -730,6 +734,7 @@ describe('runInit', () => {
select('project'),
select('subscription'),
]),
seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }),
});

await runInit(deps);
Expand Down
Loading
Loading