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
10 changes: 9 additions & 1 deletion backend/src/api/public/v1/packages/blastRadius.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,18 @@ describe('blastRadiusJobRequestSchema', () => {
it('rejects an unsupported ecosystem', () => {
const result = blastRadiusJobRequestSchema.safeParse({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'pypi',
ecosystem: 'homebrew',
})
expect(result.success).toBe(false)
})

it('accepts pypi as a supported ecosystem', () => {
const result = blastRadiusJobRequestSchema.safeParse({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'pypi',
})
expect(result.success).toBe(true)
})
})

describe('toBlastRadiusJobEntry', () => {
Expand Down
1 change: 1 addition & 0 deletions backend/src/api/public/v1/packages/blastRadius.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const SUPPORTED_BLAST_RADIUS_ECOSYSTEMS = [
'cargo',
'nuget',
'rubygems',
'pypi',
Comment thread
ulemons marked this conversation as resolved.
] as const

// Always exactly one job per request — advisory-wide (package omitted) or narrowed
Expand Down
18 changes: 17 additions & 1 deletion backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,30 @@ describe('submitBlastRadiusJob', () => {
it('rejects an unsupported ecosystem without starting a workflow', async () => {
const { req, res, start } = mockReqRes({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'pypi',
ecosystem: 'homebrew',
})

await expect(submitBlastRadiusJob(req, res)).rejects.toThrow(/not supported/)
expect(start).not.toHaveBeenCalled()
expect(createAnalysis).not.toHaveBeenCalled()
})

it('starts a workflow for a pypi ecosystem request', async () => {
const { req, res, start } = mockReqRes({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'pypi',
})

await submitBlastRadiusJob(req, res)

expect(start).toHaveBeenCalledTimes(1)
const [, options] = start.mock.calls[0]
expect(options.args[0]).toMatchObject({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'pypi',
})
})

it('rejects a missing ecosystem without starting a workflow', async () => {
const { req, res, start } = mockReqRes({ advisoryId: 'GHSA-jf85-cpcp-j695' })

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe('submitBlastRadiusJobBatch', () => {
const { req, res, start } = mockReqRes({
jobs: [
{ advisoryId: 'GHSA-jf85-cpcp-j695', ecosystem: 'npm' },
{ advisoryId: 'GHSA-652q-gvq3-74qv', ecosystem: 'pypi' },
{ advisoryId: 'GHSA-652q-gvq3-74qv', ecosystem: 'homebrew' },
],
})

Expand All @@ -103,6 +103,16 @@ describe('submitBlastRadiusJobBatch', () => {
expect(createAnalysis).not.toHaveBeenCalled()
})

it('accepts a batch containing a pypi job', async () => {
const { req, res, start } = mockReqRes({
jobs: [{ advisoryId: 'GHSA-652q-gvq3-74qv', ecosystem: 'pypi' }],
})

await submitBlastRadiusJobBatch(req, res)

expect(start).toHaveBeenCalledTimes(1)
})

it('rejects a batch with more than 20 jobs without submitting any job', async () => {
const jobs = Array.from({ length: 21 }, () => ({
advisoryId: 'GHSA-jf85-cpcp-j695',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,16 @@ import { describe, expect, it } from 'vitest'
import { SUPPORTED_ECOSYSTEMS, buildEcosystemNotSupportedFailure } from '../ecosystemSupport'

describe('SUPPORTED_ECOSYSTEMS', () => {
it('includes cargo, nuget, and rubygems alongside npm, go, and maven', () => {
expect(SUPPORTED_ECOSYSTEMS).toEqual(['npm', 'go', 'maven', 'cargo', 'nuget', 'rubygems'])
it('includes cargo, nuget, rubygems, and pypi alongside npm, go, and maven', () => {
expect(SUPPORTED_ECOSYSTEMS).toEqual([
'npm',
'go',
'maven',
'cargo',
'nuget',
'rubygems',
'pypi',
])
})
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'

import { toBareGemName, toBareNpmName, toBareNuGetId, toDbCargoName } from '../packageIdentifier'
import {
toBareGemName,
toBareNpmName,
toBareNuGetId,
toBarePypiName,
toDbCargoName,
toPypiNormalizedName,
} from '../packageIdentifier'

describe('toBareNpmName', () => {
it('returns a bare name unchanged', () => {
Expand Down Expand Up @@ -91,3 +98,47 @@ describe('toBareGemName', () => {
expect(toBareGemName('pkg:gem/RedCloth@4.3.2')).toBe('RedCloth')
})
})

describe('toBarePypiName', () => {
it('returns a bare name unchanged', () => {
expect(toBarePypiName('flask')).toBe('flask')
})

it('strips the pkg:pypi/ prefix', () => {
expect(toBarePypiName('pkg:pypi/flask')).toBe('flask')
})

it('strips a trailing version', () => {
expect(toBarePypiName('pkg:pypi/flask@3.0.0')).toBe('flask')
})

it('strips qualifiers and subpath', () => {
expect(toBarePypiName('pkg:pypi/flask@3.0.0?foo=bar#sub')).toBe('flask')
})

it('does not lowercase the name — preserves the publisher-cased spelling', () => {
expect(toBarePypiName('pkg:pypi/Jinja2@3.1.2')).toBe('Jinja2')
})

it('decodes a percent-encoded name', () => {
expect(toBarePypiName('pkg:pypi/py%2Dyaml')).toBe('py-yaml')
})

it('keeps normalizing after a decode failure', () => {
expect(toBarePypiName('pkg:pypi/flask@3.0.0%')).toBe('flask')
})
})

describe('toPypiNormalizedName', () => {
it('applies PEP 503 normalization', () => {
expect(toPypiNormalizedName('Foo_Bar.Baz')).toBe('foo-bar-baz')
})

it('collapses repeated separators into a single dash', () => {
expect(toPypiNormalizedName('foo--bar__baz')).toBe('foo-bar-baz')
})

it('is idempotent on an already-normalized name', () => {
expect(toPypiNormalizedName('jinja2')).toBe('jinja2')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it } from 'vitest'

import { resolveAgentAuth } from '@crowd/common'
Comment thread
ulemons marked this conversation as resolved.

const AKRITES_BEDROCK_ENV_VAR_NAMES = {
accessKeyId: 'AKRITES_AWS_BEDROCK_ACCESS_KEY_ID',
secretAccessKey: 'AKRITES_AWS_BEDROCK_SECRET_ACCESS_KEY',
region: 'AKRITES_AWS_BEDROCK_REGION',
}

const ALL_ENV_VARS = [
'CROWD_AWS_BEDROCK_ACCESS_KEY_ID',
'CROWD_AWS_BEDROCK_SECRET_ACCESS_KEY',
'CROWD_AWS_BEDROCK_REGION',
'AKRITES_AWS_BEDROCK_ACCESS_KEY_ID',
'AKRITES_AWS_BEDROCK_SECRET_ACCESS_KEY',
'AKRITES_AWS_BEDROCK_REGION',
'BLAST_RADIUS_ANTHROPIC_API_KEY',
'BLAST_RADIUS_ANTHROPIC_BASE_URL',
'ANTHROPIC_API_KEY',
]

function clearAuthEnv() {
for (const key of ALL_ENV_VARS) {
delete process.env[key]
}
}

describe('resolveAgentAuth', () => {
afterEach(() => {
clearAuthEnv()
})

it('resolves bedrock mode using the default (CROWD_AWS_BEDROCK_*) env vars', () => {
clearAuthEnv()
process.env.CROWD_AWS_BEDROCK_ACCESS_KEY_ID = 'AKIA_TEST'
process.env.CROWD_AWS_BEDROCK_SECRET_ACCESS_KEY = 'secret'
process.env.ANTHROPIC_API_KEY = 'sk-ant-should-not-survive'

const auth = resolveAgentAuth()

expect(auth.mode).toBe('bedrock')
expect(auth.env.CLAUDE_CODE_USE_BEDROCK).toBe('1')
expect(auth.env.AWS_ACCESS_KEY_ID).toBe('AKIA_TEST')
expect(auth.env.AWS_SECRET_ACCESS_KEY).toBe('secret')
expect(auth.env.AWS_REGION).toBe('us-east-1')
expect(auth.env.ANTHROPIC_API_KEY).toBeUndefined()
})

it('resolves bedrock mode using a caller-supplied bedrockEnvVarNames (e.g. Akrites)', () => {
clearAuthEnv()
process.env.AKRITES_AWS_BEDROCK_ACCESS_KEY_ID = 'AKIA_AKRITES'
process.env.AKRITES_AWS_BEDROCK_SECRET_ACCESS_KEY = 'akrites-secret'
// A CROWD_AWS_BEDROCK_* credential being set for an unrelated consumer must not
// leak into an Akrites-scoped caller.
process.env.CROWD_AWS_BEDROCK_ACCESS_KEY_ID = 'AKIA_UNRELATED'
process.env.CROWD_AWS_BEDROCK_SECRET_ACCESS_KEY = 'unrelated-secret'

const auth = resolveAgentAuth({ bedrockEnvVarNames: AKRITES_BEDROCK_ENV_VAR_NAMES })

expect(auth.mode).toBe('bedrock')
expect(auth.env.AWS_ACCESS_KEY_ID).toBe('AKIA_AKRITES')
expect(auth.env.AWS_SECRET_ACCESS_KEY).toBe('akrites-secret')
})

it('uses the region env var named by bedrockEnvVarNames when set', () => {
clearAuthEnv()
process.env.AKRITES_AWS_BEDROCK_ACCESS_KEY_ID = 'AKIA_AKRITES'
process.env.AKRITES_AWS_BEDROCK_SECRET_ACCESS_KEY = 'akrites-secret'
process.env.AKRITES_AWS_BEDROCK_REGION = 'us-west-2'

const auth = resolveAgentAuth({ bedrockEnvVarNames: AKRITES_BEDROCK_ENV_VAR_NAMES })

expect(auth.env.AWS_REGION).toBe('us-west-2')
})

it('does not fall into bedrock mode with only one of the two credentials', () => {
clearAuthEnv()
process.env.AKRITES_AWS_BEDROCK_ACCESS_KEY_ID = 'AKIA_AKRITES'
process.env.BLAST_RADIUS_ANTHROPIC_API_KEY = 'sk-ant-fallback'

const auth = resolveAgentAuth({ bedrockEnvVarNames: AKRITES_BEDROCK_ENV_VAR_NAMES })

expect(auth.mode).toBe('anthropic-api-key')
})

it('resolves anthropic-api-key mode when no bedrock credentials are set', () => {
clearAuthEnv()
process.env.BLAST_RADIUS_ANTHROPIC_API_KEY = 'sk-ant-test'
process.env.BLAST_RADIUS_ANTHROPIC_BASE_URL = 'https://litellm.internal'

const auth = resolveAgentAuth({ bedrockEnvVarNames: AKRITES_BEDROCK_ENV_VAR_NAMES })

expect(auth.mode).toBe('anthropic-api-key')
expect(auth.env.ANTHROPIC_API_KEY).toBe('sk-ant-test')
expect(auth.env.ANTHROPIC_BASE_URL).toBe('https://litellm.internal')
expect(auth.resolveModel('claude-opus-4-8')).toBe('claude-opus-4-8')
})

it('supports a custom api key env var name', () => {
clearAuthEnv()
process.env.CUSTOM_ANTHROPIC_API_KEY = 'sk-ant-custom'

const auth = resolveAgentAuth({ apiKeyEnvVar: 'CUSTOM_ANTHROPIC_API_KEY' })

expect(auth.mode).toBe('anthropic-api-key')
expect(auth.env.ANTHROPIC_API_KEY).toBe('sk-ant-custom')

delete process.env.CUSTOM_ANTHROPIC_API_KEY
})

it('falls back to cli auth when nothing is configured', () => {
clearAuthEnv()

const auth = resolveAgentAuth({ bedrockEnvVarNames: AKRITES_BEDROCK_ENV_VAR_NAMES })

expect(auth.mode).toBe('cli-fallback')
expect(auth.env).toBeUndefined()
expect(auth.resolveModel('claude-sonnet-5')).toBe('claude-sonnet-5')
})

it('translates model IDs via modelBedrockMap in bedrock mode', () => {
clearAuthEnv()
process.env.AKRITES_AWS_BEDROCK_ACCESS_KEY_ID = 'AKIA_AKRITES'
process.env.AKRITES_AWS_BEDROCK_SECRET_ACCESS_KEY = 'akrites-secret'

const auth = resolveAgentAuth({
bedrockEnvVarNames: AKRITES_BEDROCK_ENV_VAR_NAMES,
modelBedrockMap: { 'claude-opus-4-8': 'us.anthropic.claude-opus-4-8-v1:0' },
})

expect(auth.resolveModel('claude-opus-4-8')).toBe('us.anthropic.claude-opus-4-8-v1:0')
})

it('throws on an unmapped model when a modelBedrockMap is provided', () => {
clearAuthEnv()
process.env.AKRITES_AWS_BEDROCK_ACCESS_KEY_ID = 'AKIA_AKRITES'
process.env.AKRITES_AWS_BEDROCK_SECRET_ACCESS_KEY = 'akrites-secret'

const auth = resolveAgentAuth({
bedrockEnvVarNames: AKRITES_BEDROCK_ENV_VAR_NAMES,
modelBedrockMap: { 'claude-opus-4-8': 'us.anthropic.claude-opus-4-8-v1:0' },
})

expect(() => auth.resolveModel('claude-haiku-9000')).toThrow()
})
})
Loading
Loading