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
2 changes: 1 addition & 1 deletion CLAUDE.md
Comment thread
michael-jabbour-sonarsource marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Like the hook-based agents, SQAA is **project-scoped and opt-in**: integrate orc

`--help`, `-h`, and bare `sonar context` (no action) are forwarded to CAG.

Before installing, `sonar integrate claude|copilot|codex|antigravity` pre-flights the CAG entitlement check: `SonarQubeClient.hasCagEntitlement(orgKey)` resolves the org UUID via `/organizations/organizations` then calls `GET /a3s-analysis/cag-org-config/{uuid}` (SonarQube Cloud only). If `eligible && enabled` is false, CAG setup is skipped with a warning (cloud) or a plain info line (SonarQube Server). Any error in the check is treated as "not entitled". The `sonar context` passthrough is not gated — CAG itself enforces entitlement per-request.
Before installing, `sonar integrate claude|copilot|codex|antigravity` pre-flights the CAG entitlement check: `SonarQubeClient.hasCagEntitlement(orgKey)` resolves the org UUID via `/organizations/organizations` then calls `GET /a3s-analysis/cag-entitlement/{uuid}` (SonarQube Cloud only). If `allowed` is false, CAG setup is skipped with a warning (cloud) or a plain info line (SonarQube Server). Any error in the check is treated as "not entitled". The `sonar context` passthrough is not gated — CAG itself enforces entitlement per-request.

After the CAG entitlement check passes, the integrate flow also queries SCA availability via `SonarQubeClient.getScaEnablement(connectionType, orgKey)` (`/sca/feature-enabled` on cloud, `/api/v2/sca/feature-enabled` on SonarQube Server). The resolved boolean is passed to `sonar-context-augmentation tool print-skill` as `--sca-enabled=true|false`, and is persisted on declarative feature attrs together with the recorded `serverUrl`, `orgKey`, and `projectKey` so `sonar context` and post-update can reuse the same connection metadata without re-querying the server. A check failure (network/404) emits a warn line and proceeds with `--sca-enabled=false`.

Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands/integrate/_common/context-augmentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export async function resolveContextAugmentationSetup(
// that could not use CAG anyway).
if (p.auth.orgKey) {
const client = new SonarQubeClient(p.auth.serverUrl, p.auth.token);
if ((await client.hasCagEntitlement(p.auth.orgKey)) === 'enabled') {
if ((await client.hasCagEntitlement(p.auth.orgKey)) === 'allowed') {
warn(
'Skipping Context Augmentation: not supported with --global. Re-run without --global from a project directory to install it there.',
);
Expand All @@ -131,9 +131,9 @@ export async function resolveContextAugmentationSetup(
);
return null;
}
if (entitlement === 'not_enabled') {
if (entitlement === 'not_allowed') {
warn(
'Skipping Context Augmentation: not enabled for your organization. Enable it in your SonarQube Cloud organization settings.',
'Skipping Context Augmentation: not available for your organization. Access requires an eligible SonarQube Cloud plan.',
);
return null;
}
Expand Down
10 changes: 5 additions & 5 deletions src/sonarqube/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const GENERIC_HTTP_METHODS = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'] as
export const METHODS_WITH_BODY = new Set<HttpMethod>(['POST', 'PATCH', 'PUT']);
export type HttpMethod = (typeof GENERIC_HTTP_METHODS)[number];

export type CagEntitlementStatus = 'enabled' | 'not_enabled' | 'check_failed';
export type CagEntitlementStatus = 'allowed' | 'not_allowed' | 'check_failed';

export type SqaaEntitlementStatus = 'enabled' | 'not_enabled' | 'check_failed';

Expand Down Expand Up @@ -400,21 +400,21 @@ export class SonarQubeClient {

async checkCagEntitlement(organizationUuid: string): Promise<CagEntitlementStatus> {
try {
const endpoint = `/a3s-analysis/cag-org-config/${organizationUuid}`;
const result = await this.get<{ id: string; enabled: boolean; eligible: boolean }>(
const endpoint = `/a3s-analysis/cag-entitlement/${organizationUuid}`;
const result = await this.get<{ allowed: boolean; consumption?: object }>(
endpoint,
undefined,
resolveFromEndpoint(this.serverURL, endpoint),
);
return result.eligible && result.enabled ? 'enabled' : 'not_enabled';
return result.allowed ? 'allowed' : 'not_allowed';
} catch {
return 'check_failed';
}
}

async hasCagEntitlement(organizationKey?: string): Promise<CagEntitlementStatus> {
if (!organizationKey || !isSonarQubeCloud(this.serverURL)) {
return 'not_enabled';
return 'not_allowed';
}
const uuid = await this.getOrganizationId(organizationKey);
if (!uuid) {
Expand Down
10 changes: 5 additions & 5 deletions tests/e2e/context/cag-integrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
* the real CAG binary at all on connections where CAG must be skipped:
*
* - SonarQube Server (non-Cloud) connections.
* - Cloud connections where the org's CAG entitlement is disabled.
* - Cloud connections where the org is not allowed to use CAG.
*
* Both paths must exit before `installContextAugmentationBinary()` runs, so
* we assert no binary lands under `<cliHome>/bin/` and no declarative CAG
Expand Down Expand Up @@ -103,12 +103,12 @@ describe('sonar integrate <agent> — CAG pre-flight skip paths (real CLI, fake
expect(findRecordedCagFeature(harness.stateJsonFile.asJson() as CliState)).toBeUndefined();
});

it('skips CAG when entitlement is not enabled on the Cloud org', async () => {
it('skips CAG when the Cloud org is not allowed to use it', async () => {
const server = await harness
.newFakeServer()
.withAuthToken(TOKEN)
.withProject(PROJECT_KEY)
.withCagEntitlement(ORG_KEY, { enabled: false })
.withCagEntitlement(ORG_KEY, { allowed: false })
.start();
const serverUrl = server.baseUrl();
harness.withAuth(serverUrl, TOKEN, ORG_KEY);
Expand All @@ -132,9 +132,9 @@ describe('sonar integrate <agent> — CAG pre-flight skip paths (real CLI, fake

expect(result.exitCode, result.stderr).toBe(0);
expect(result.stderr).toContain(
'Skipping Context Augmentation: not enabled for your organization. Enable it in your SonarQube Cloud organization settings.',
'Skipping Context Augmentation: not available for your organization. Access requires an eligible SonarQube Cloud plan.',
);
expect(existsSync(cagBinaryPath), 'no CAG download when entitlement is disabled').toBe(false);
expect(existsSync(cagBinaryPath), 'no CAG download when access is denied').toBe(false);
expect(findRecordedCagFeature(harness.stateJsonFile.asJson() as CliState)).toBeUndefined();
});

Expand Down
27 changes: 5 additions & 22 deletions tests/integration/harness/fake-sonarqube-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,7 @@ describe('FakeSonarQubeServer — UUID consistency', () => {
await server?.stop();
});

it('returns cag-org-config 200 when org has both SQAA and CAG entitlement with a custom UUID', async () => {
// This test demonstrates the UUID mismatch bug:
//
// withSqaaEntitlement stores an explicit UUID ('test-uuid-1234').
// withCagEntitlement stores no UUID — the cag-org-config lookup derives it
// as `${orgKey}-uuid-v4`.
//
// When CAG calls /organizations/organizations (no param), the no-param
// branch synthesises org entries taking the UUID from SQAA ('test-uuid-1234').
// CAG then calls /a3s-analysis/cag-org-config/test-uuid-1234, but the handler
// looks for an org where `${orgKey}-uuid-v4 === 'test-uuid-1234'`, which never
// matches — so it returns 404 and CAG treats itself as not entitled.

it('returns cag-entitlement 200 when org has both SQAA and CAG entitlement', async () => {
server = await new FakeSonarQubeServerBuilder()
.withSqaaEntitlement('my-org', 'test-uuid-1234')
.withCagEntitlement('my-org')
Expand All @@ -61,17 +49,12 @@ describe('FakeSonarQubeServer — UUID consistency', () => {

const uuid = orgs[0].uuidV4;

// Step 2: fetch cag-org-config using the UUID CAG received from step 1.
const configResp = await fetch(`${base}/a3s-analysis/cag-org-config/${uuid}`);
// Step 2: fetch CAG entitlement using the UUID CAG received from step 1.
const configResp = await fetch(`${base}/a3s-analysis/cag-entitlement/${uuid}`);

// Expected: 200 with eligible/enabled from withCagEntitlement.
// Actual (bug): 404 because the handler derives UUID as `${orgKey}-uuid-v4`
// but the UUID returned by /organizations/organizations is 'test-uuid-1234'
// (taken from SQAA), not 'my-org-uuid-v4'.
expect(configResp.status).toBe(HTTP_OK);

const config = (await configResp.json()) as { eligible: boolean; enabled: boolean };
expect(config.eligible).toBe(true);
expect(config.enabled).toBe(true);
const config = (await configResp.json()) as { allowed: boolean };
expect(config.allowed).toBe(true);
});
});
60 changes: 38 additions & 22 deletions tests/integration/harness/fake-sonarqube-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,7 @@ export class FakeSonarQubeServerBuilder {
string,
{ uuid: string; eligible: boolean; enabled: boolean }
> = new Map();
private readonly cagEntitlementOrgs: Map<
string,
{ uuid: string; eligible: boolean; enabled: boolean }
> = new Map();
private readonly cagEntitlementOrgs: Map<string, { uuid: string; allowed: boolean }> = new Map();
private validToken?: string;
private systemStatusCode = 200;
private systemVersion = '9.9.0.00001';
Expand All @@ -137,6 +134,8 @@ export class FakeSonarQubeServerBuilder {
private sqaaStatusBody?: string;
private sqaaPayloadLimit?: { maxRequestSize?: number; maxFiles?: number };
private scaEnabled?: boolean;
private cagEntitlementStatusCode?: number;
private cagEntitlementStatusBody?: string;
private readonly projectSettings: Map<string, SettingsValue[]> = new Map();
private agentJobErrorCode?: number;
private agentJobErrorMessage?: string;
Expand Down Expand Up @@ -254,18 +253,24 @@ export class FakeSonarQubeServerBuilder {
return this;
}

withCagEntitlement(
orgKey: string,
options: { uuid?: string; eligible?: boolean; enabled?: boolean } = {},
): this {
withCagEntitlement(orgKey: string, options: { uuid?: string; allowed?: boolean } = {}): this {
this.cagEntitlementOrgs.set(orgKey, {
uuid: options.uuid ?? `${orgKey}-uuid-v4`,
eligible: options.eligible ?? true,
enabled: options.enabled ?? true,
allowed: options.allowed ?? true,
});
return this;
}

/**
* Force GET /a3s-analysis/cag-entitlement/{uuid} to return a specific HTTP
* status code. Useful for testing entitlement check failure paths.
*/
withCagEntitlementStatusCode(status: number, body?: string): this {
this.cagEntitlementStatusCode = status;
this.cagEntitlementStatusBody = body;
return this;
}

/**
* Configure the response of the SCA availability endpoints
* (`/sca/feature-enabled` for cloud, `/api/v2/sca/feature-enabled` for on-premise).
Expand Down Expand Up @@ -305,6 +310,8 @@ export class FakeSonarQubeServerBuilder {
sqaaEntitlementOrgs,
cagEntitlementOrgs,
scaEnabled,
cagEntitlementStatusCode,
cagEntitlementStatusBody,
projectSettings,
agentJobErrorCode,
agentJobErrorMessage,
Expand Down Expand Up @@ -557,11 +564,13 @@ export class FakeSonarQubeServerBuilder {
});
}
const orgKey = query.organizationKey;
const entitlement = orgKey ? sqaaEntitlementOrgs.get(orgKey) : undefined;
if (entitlement) {
const sqaaEntitlement = orgKey ? sqaaEntitlementOrgs.get(orgKey) : undefined;
const cagEntitlement = orgKey ? cagEntitlementOrgs.get(orgKey) : undefined;
const entitlementUuid = cagEntitlement?.uuid ?? sqaaEntitlement?.uuid;
if (orgKey && entitlementUuid) {
return new Response(
JSON.stringify([
{ id: `id-${orgKey}`, uuidV4: entitlement.uuid, key: orgKey, name: orgKey },
{ id: `id-${orgKey}`, uuidV4: entitlementUuid, key: orgKey, name: orgKey },
]),
{ headers: { 'Content-Type': 'application/json' } },
);
Expand All @@ -577,9 +586,9 @@ export class FakeSonarQubeServerBuilder {
{ headers: { 'Content-Type': 'application/json' } },
);
}
// No organizationKey query param: sonar-context-augmentation calls
// /organizations/organizations with no params during open-beta
// entitlement resolution and expects a flat list of accessible orgs.
// No organizationKey query param: sonar-context-augmentation can call
// /organizations/organizations with no params during entitlement
// resolution and expects a flat list of accessible orgs.
// Synthesize entries from the orgs that have CAG/SQAA entitlement
// registered so the daemon can find a matching org by key.
const knownOrgs = new Set<string>([
Expand Down Expand Up @@ -653,9 +662,18 @@ export class FakeSonarQubeServerBuilder {
);
}

const cagOrgConfigMatch = /^\/a3s-analysis\/cag-org-config\/(.+)$/.exec(path);
if (cagOrgConfigMatch) {
const uuid = cagOrgConfigMatch[1];
const cagEntitlementMatch = /^\/a3s-analysis\/cag-entitlement\/(.+)$/.exec(path);
if (cagEntitlementMatch) {
if (cagEntitlementStatusCode !== undefined) {
return new Response(
cagEntitlementStatusBody ?? JSON.stringify({ errors: [{ msg: 'CAG failed' }] }),
{
status: cagEntitlementStatusCode,
headers: { 'Content-Type': 'application/json' },
},
);
}
const uuid = cagEntitlementMatch[1];
const entitlement = [...cagEntitlementOrgs.values()].find((e) => e.uuid === uuid);
if (!entitlement) {
return new Response(JSON.stringify({ errors: [{ msg: 'Not found' }] }), {
Expand All @@ -665,9 +683,7 @@ export class FakeSonarQubeServerBuilder {
}
return new Response(
JSON.stringify({
id: uuid,
eligible: entitlement.eligible,
enabled: entitlement.enabled,
allowed: entitlement.allowed,
}),
{ headers: { 'Content-Type': 'application/json' } },
);
Expand Down
51 changes: 45 additions & 6 deletions tests/integration/specs/integrate/context-augmentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,13 +333,13 @@ describe('integrate claude — Context Augmentation', () => {
);

it(
'skips CAG with a warning when the org does not have it enabled',
'skips CAG with a warning when the org is not allowed to use it',
async () => {
const server = await harness
.newFakeServer()
.withAuthToken(TOKEN)
.withProject(PROJECT_KEY)
.withCagEntitlement(ORG_KEY, { enabled: false })
.withCagEntitlement(ORG_KEY, { allowed: false })
.start();
const serverUrl = server.baseUrl();
harness.withAuth(serverUrl, TOKEN, ORG_KEY);
Expand All @@ -366,7 +366,46 @@ describe('integrate claude — Context Augmentation', () => {
const state = loadState(harness);
expect(findRecordedCagFeature(state)).toBeUndefined();
expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false);
expect(result.stderr).toContain('not enabled for your organization');
expect(result.stderr).toContain('not available for your organization');
},
{ timeout: 30000 },
);

it(
'skips CAG with a warning when the entitlement check fails',
async () => {
const server = await harness
.newFakeServer()
.withAuthToken(TOKEN)
.withProject(PROJECT_KEY)
.withCagEntitlementStatusCode(500)
.start();
const serverUrl = server.baseUrl();
harness.withAuth(serverUrl, TOKEN, ORG_KEY);
harness.state().withContextAugmentationBinaryInstalled();
harness.cwd.writeFile(
'sonar-project.properties',
[
`sonar.host.url=${serverUrl}`,
`sonar.projectKey=${PROJECT_KEY}`,
`sonar.organization=${ORG_KEY}`,
].join('\n'),
);

const result = await harness.run('integrate claude --non-interactive', {
extraEnv: {
SONARQUBE_CLI_SONARCLOUD_URL: serverUrl,
SONARQUBE_CLI_SONARCLOUD_API_URL: serverUrl,
},
});

expect(result.exitCode).toBe(0);
const nonProbe = readInvocations(harness).filter((i) => i.argv[0] !== '--version');
expect(nonProbe).toEqual([]);
const state = loadState(harness);
expect(findRecordedCagFeature(state)).toBeUndefined();
expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false);
expect(result.stderr).toContain('could not verify entitlement');
},
{ timeout: 30000 },
);
Expand Down Expand Up @@ -774,13 +813,13 @@ describe('integrate codex — Context Augmentation', () => {
);

it(
'skips CAG with a warning when the org does not have it enabled',
'skips CAG with a warning when the org is not allowed to use it',
async () => {
const server = await harness
.newFakeServer()
.withAuthToken(TOKEN)
.withProject(PROJECT_KEY)
.withCagEntitlement(ORG_KEY, { enabled: false })
.withCagEntitlement(ORG_KEY, { allowed: false })
.start();
const serverUrl = server.baseUrl();
harness.withAuth(serverUrl, TOKEN, ORG_KEY);
Expand All @@ -805,7 +844,7 @@ describe('integrate codex — Context Augmentation', () => {
const nonProbe = readInvocations(harness).filter((i) => i.argv[0] !== '--version');
expect(nonProbe).toEqual([]);
expect(harness.cwd.file(CODEX_SKILL_PATH).exists()).toBe(false);
expect(result.stderr).toContain('not enabled for your organization');
expect(result.stderr).toContain('not available for your organization');
},
{ timeout: 30000 },
);
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/cli/commands/integrate/claude/integrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe('integrateCommand', () => {
hasSqaaEntitlementSpy = spyOn(SonarQubeClient.prototype, 'hasSqaaEntitlement');
hasSqaaEntitlementSpy.mockResolvedValue('not_enabled');
hasCagEntitlementSpy = spyOn(SonarQubeClient.prototype, 'hasCagEntitlement');
hasCagEntitlementSpy.mockResolvedValue('enabled');
hasCagEntitlementSpy.mockResolvedValue('allowed');
resolveContextAugmentationSetupSpy = spyOn(
contextAugmentation,
'resolveContextAugmentationSetup',
Expand Down
Loading
Loading