From a889d6fc5212000b03de2937e92e06edb2ac3634 Mon Sep 17 00:00:00 2001 From: Dawid Antczak Date: Fri, 24 Apr 2026 11:16:30 +0200 Subject: [PATCH 1/2] chore: update scorecard to new project structure --- .../__tests__/fetch-scorecard.test.ts | 192 ++++++++++++++---- .../remote/fetch-scorecard.ts | 84 ++++++-- .../src/commands/scorecard-classic/types.ts | 9 +- 3 files changed, 231 insertions(+), 54 deletions(-) diff --git a/packages/cli/src/commands/scorecard-classic/__tests__/fetch-scorecard.test.ts b/packages/cli/src/commands/scorecard-classic/__tests__/fetch-scorecard.test.ts index 592f6b8a08..f980d93e30 100644 --- a/packages/cli/src/commands/scorecard-classic/__tests__/fetch-scorecard.test.ts +++ b/packages/cli/src/commands/scorecard-classic/__tests__/fetch-scorecard.test.ts @@ -5,6 +5,14 @@ describe('fetchRemoteScorecardAndPlugins', () => { const mockFetch = vi.fn(); const validProjectUrl = 'https://app.valid-url.com/org/test-org/project/test-project'; const testToken = 'test-token'; + const mockProjectId = 'prj_123'; + const mockOrgId = 'org_123'; + + const mockProject = { + id: mockProjectId, + organizationId: mockOrgId, + slug: 'test-project', + }; beforeEach(() => { global.fetch = mockFetch; @@ -80,15 +88,84 @@ describe('fetchRemoteScorecardAndPlugins', () => { ); }); + it('should throw error when project config fetch is not found (404)', async () => { + mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => mockProject, + }) + .mockResolvedValueOnce({ + status: 404, + }); + + await expect( + fetchRemoteScorecardAndPlugins({ projectUrl: validProjectUrl, auth: testToken }) + ).rejects.toThrow(); + + expect(errorUtils.exitWithError).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch project config') + ); + }); + + it('should throw error when project config fetch is unauthorized (401)', async () => { + mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => mockProject, + }) + .mockResolvedValueOnce({ + status: 401, + }); + + await expect( + fetchRemoteScorecardAndPlugins({ projectUrl: validProjectUrl, auth: testToken }) + ).rejects.toThrow(); + + expect(errorUtils.exitWithError).toHaveBeenCalledWith( + expect.stringContaining('Unauthorized access to project config') + ); + }); + it('should throw error when project has no scorecard config', async () => { - mockFetch.mockResolvedValueOnce({ - status: 200, - json: async () => ({ - id: 'project-123', - slug: 'test-project', - config: {}, - }), - }); + mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => mockProject, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + id: mockProjectId, + projectId: mockProjectId, + organizationId: mockOrgId, + config: {}, + }), + }); + + await expect( + fetchRemoteScorecardAndPlugins({ projectUrl: validProjectUrl, auth: testToken }) + ).rejects.toThrow(); + + expect(errorUtils.exitWithError).toHaveBeenCalledWith( + expect.stringContaining('No scorecard configuration found') + ); + }); + + it('should throw error when project config is null', async () => { + mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => mockProject, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + id: mockProjectId, + projectId: mockProjectId, + organizationId: mockOrgId, + config: null, + }), + }); await expect( fetchRemoteScorecardAndPlugins({ projectUrl: validProjectUrl, auth: testToken }) @@ -104,16 +181,22 @@ describe('fetchRemoteScorecardAndPlugins', () => { levels: [{ name: 'Gold', rules: {} }], }; - mockFetch.mockResolvedValueOnce({ - status: 200, - json: async () => ({ - id: 'project-123', - slug: 'test-project', - config: { - scorecard: mockScorecard, - }, - }), - }); + mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => mockProject, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + id: mockProjectId, + projectId: mockProjectId, + organizationId: mockOrgId, + config: { + scorecard: mockScorecard, + }, + }), + }); const result = await fetchRemoteScorecardAndPlugins({ projectUrl: validProjectUrl, @@ -134,11 +217,16 @@ describe('fetchRemoteScorecardAndPlugins', () => { const mockPluginsCode = 'export default [() => ({ id: "test-plugin" })]'; mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => mockProject, + }) .mockResolvedValueOnce({ status: 200, json: async () => ({ - id: 'project-123', - slug: 'test-project', + id: mockProjectId, + projectId: mockProjectId, + organizationId: mockOrgId, config: { scorecard: mockScorecard, pluginsUrl: 'https://example.com/plugins.js', @@ -159,7 +247,7 @@ describe('fetchRemoteScorecardAndPlugins', () => { scorecard: mockScorecard, plugins: mockPluginsCode, }); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(3); }); it('should return scorecard without plugins when plugin fetch fails', async () => { @@ -168,11 +256,16 @@ describe('fetchRemoteScorecardAndPlugins', () => { }; mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => mockProject, + }) .mockResolvedValueOnce({ status: 200, json: async () => ({ - id: 'project-123', - slug: 'test-project', + id: mockProjectId, + projectId: mockProjectId, + organizationId: mockOrgId, config: { scorecard: mockScorecard, pluginsUrl: 'https://example.com/plugins.js', @@ -195,13 +288,20 @@ describe('fetchRemoteScorecardAndPlugins', () => { }); it('should use correct auth headers with access token', async () => { - mockFetch.mockResolvedValueOnce({ - status: 200, - json: async () => ({ - id: 'project-123', - config: { scorecard: { levels: [] } }, - }), - }); + mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => mockProject, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + id: mockProjectId, + projectId: mockProjectId, + organizationId: mockOrgId, + config: { scorecard: { levels: [] } }, + }), + }); await fetchRemoteScorecardAndPlugins({ projectUrl: validProjectUrl, @@ -220,13 +320,20 @@ describe('fetchRemoteScorecardAndPlugins', () => { const apiKey = 'test-api-key'; process.env.REDOCLY_AUTHORIZATION = apiKey; - mockFetch.mockResolvedValueOnce({ - status: 200, - json: async () => ({ - id: 'project-123', - config: { scorecard: { levels: [] } }, - }), - }); + mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => mockProject, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + id: mockProjectId, + projectId: mockProjectId, + organizationId: mockOrgId, + config: { scorecard: { levels: [] } }, + }), + }); await fetchRemoteScorecardAndPlugins({ projectUrl: validProjectUrl, @@ -249,11 +356,16 @@ describe('fetchRemoteScorecardAndPlugins', () => { const mockPluginsCode = 'export default [() => ({ id: "test-plugin" })]'; mockFetch + .mockResolvedValueOnce({ + status: 200, + json: async () => mockProject, + }) .mockResolvedValueOnce({ status: 200, json: async () => ({ - id: 'project-123', - slug: 'test-project', + id: mockProjectId, + projectId: mockProjectId, + organizationId: mockOrgId, config: { scorecard: mockScorecard, pluginsUrl: 'https://example.com/plugins.js', @@ -276,6 +388,6 @@ describe('fetchRemoteScorecardAndPlugins', () => { scorecard: mockScorecard, plugins: mockPluginsCode, }); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(3); }); }); diff --git a/packages/cli/src/commands/scorecard-classic/remote/fetch-scorecard.ts b/packages/cli/src/commands/scorecard-classic/remote/fetch-scorecard.ts index 477fb56139..566f45914e 100644 --- a/packages/cli/src/commands/scorecard-classic/remote/fetch-scorecard.ts +++ b/packages/cli/src/commands/scorecard-classic/remote/fetch-scorecard.ts @@ -1,7 +1,7 @@ import { logger } from '@redocly/openapi-core'; import { exitWithError } from '../../../utils/error.js'; -import type { RemoteScorecardAndPlugins, Project } from '../types.js'; +import type { RemoteScorecardAndPlugins, Project, ProjectConfig } from '../types.js'; export type FetchRemoteScorecardAndPluginsParams = { projectUrl: string; @@ -29,7 +29,7 @@ export async function fetchRemoteScorecardAndPlugins({ const { residency, orgSlug, projectSlug } = parsedProjectUrl; try { - const project = await fetchProjectConfigBySlugs({ + const project = await fetchProjectBySlugs({ residency, orgSlug, projectSlug, @@ -37,7 +37,17 @@ export async function fetchRemoteScorecardAndPlugins({ isApiKey, verbose, }); - const scorecard = project?.config.scorecardClassic || project?.config.scorecard; + + const projectConfig = await fetchProjectConfig({ + residency, + orgId: project.organizationId, + projectId: project.id, + auth, + isApiKey, + verbose, + }); + + const scorecard = projectConfig?.config?.scorecardClassic || projectConfig?.config?.scorecard; if (!scorecard) { throw new Error('No scorecard configuration found.'); @@ -48,15 +58,14 @@ export async function fetchRemoteScorecardAndPlugins({ logger.info(`Scorecard levels found: ${scorecard.levels?.length || 0}\n`); } - const plugins = project.config.pluginsUrl - ? await fetchPlugins(project.config.pluginsUrl, verbose) - : undefined; + const pluginsUrl = projectConfig?.config?.pluginsUrl; + const plugins = pluginsUrl ? await fetchPlugins(pluginsUrl, verbose) : undefined; if (verbose) { if (plugins) { - logger.info(`Successfully fetched plugins from ${project.config.pluginsUrl}\n`); - } else if (project.config.pluginsUrl) { - logger.info(`No plugins were loaded from ${project.config.pluginsUrl}\n`); + logger.info(`Successfully fetched plugins from ${pluginsUrl}\n`); + } else if (pluginsUrl) { + logger.info(`No plugins were loaded from ${pluginsUrl}\n`); } else { logger.info(`No custom plugins configured for this scorecard.\n`); } @@ -98,7 +107,7 @@ function parseProjectUrl( }; } -type FetchProjectConfigBySlugsParams = { +type FetchProjectBySlugsParams = { residency: string; orgSlug: string; projectSlug: string; @@ -107,14 +116,14 @@ type FetchProjectConfigBySlugsParams = { verbose?: boolean; }; -async function fetchProjectConfigBySlugs({ +async function fetchProjectBySlugs({ residency, orgSlug, projectSlug, auth, isApiKey, verbose = false, -}: FetchProjectConfigBySlugsParams): Promise { +}: FetchProjectBySlugsParams): Promise { const authHeaders = createAuthHeaders(auth, isApiKey); const projectUrl = new URL(`${residency}/api/orgs/${orgSlug}/projects/${projectSlug}`); @@ -139,12 +148,61 @@ async function fetchProjectConfigBySlugs({ } if (verbose) { - logger.info(`Successfully received project configuration.\n`); + logger.info(`Successfully received project.\n`); } return projectResponse.json(); } +type FetchProjectConfigParams = { + residency: string; + orgId: string; + projectId: string; + auth: string; + isApiKey: boolean; + verbose?: boolean; +}; + +async function fetchProjectConfig({ + residency, + orgId, + projectId, + auth, + isApiKey, + verbose = false, +}: FetchProjectConfigParams): Promise { + const authHeaders = createAuthHeaders(auth, isApiKey); + const projectConfigUrl = new URL(`${residency}/api/orgs/${orgId}/project-configs/${projectId}`); + + const projectConfigResponse = await fetch(projectConfigUrl, { headers: authHeaders }); + + if (verbose) { + logger.info(`Project config fetch response status: ${projectConfigResponse.status}\n`); + } + + if (projectConfigResponse.status === 401 || projectConfigResponse.status === 403) { + if (verbose) { + logger.error(`Authentication failed with status ${projectConfigResponse.status}.\n`); + logger.error(`Check that your credentials are valid and have the necessary permissions.\n`); + } + throw new Error( + `Unauthorized access to project config: ${projectId}. Please check your credentials.` + ); + } + + if (projectConfigResponse.status !== 200) { + throw new Error( + `Failed to fetch project config: ${projectId}. Status: ${projectConfigResponse.status}` + ); + } + + if (verbose) { + logger.info(`Successfully received project configuration.\n`); + } + + return projectConfigResponse.json(); +} + async function fetchPlugins(pluginsUrl: string, verbose = false): Promise { if (verbose) { logger.info(`Fetching plugins from: ${pluginsUrl}\n`); diff --git a/packages/cli/src/commands/scorecard-classic/types.ts b/packages/cli/src/commands/scorecard-classic/types.ts index 295d262675..a7370e684f 100644 --- a/packages/cli/src/commands/scorecard-classic/types.ts +++ b/packages/cli/src/commands/scorecard-classic/types.ts @@ -23,6 +23,13 @@ export type RemoteScorecardAndPlugins = { export type Project = { id: `prj_${string}`; + organizationId: `org_${string}`; slug: string; - config: ResolvedConfig & { pluginsUrl?: string; scorecardClassic?: ScorecardConfig }; +}; + +export type ProjectConfig = { + id: `prj_${string}`; + projectId: `prj_${string}`; + organizationId: `org_${string}`; + config: (ResolvedConfig & { pluginsUrl?: string; scorecardClassic?: ScorecardConfig }) | null; }; From df982e747c08f403897b11bc0ea25dd463638467 Mon Sep 17 00:00:00 2001 From: Dawid Antczak Date: Mon, 27 Apr 2026 08:19:51 +0200 Subject: [PATCH 2/2] chore: add backward compatibility for project config --- .../scorecard-classic/__tests__/fetch-scorecard.test.ts | 4 ++-- .../src/commands/scorecard-classic/remote/fetch-scorecard.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/scorecard-classic/__tests__/fetch-scorecard.test.ts b/packages/cli/src/commands/scorecard-classic/__tests__/fetch-scorecard.test.ts index f980d93e30..6104626049 100644 --- a/packages/cli/src/commands/scorecard-classic/__tests__/fetch-scorecard.test.ts +++ b/packages/cli/src/commands/scorecard-classic/__tests__/fetch-scorecard.test.ts @@ -311,7 +311,7 @@ describe('fetchRemoteScorecardAndPlugins', () => { expect(mockFetch).toHaveBeenCalledWith( expect.any(URL), expect.objectContaining({ - headers: { Cookie: `accessToken=${testToken}` }, + headers: { Cookie: `accessToken=${testToken}`, version: '2' }, }) ); }); @@ -344,7 +344,7 @@ describe('fetchRemoteScorecardAndPlugins', () => { expect(mockFetch).toHaveBeenCalledWith( expect.any(URL), expect.objectContaining({ - headers: { Authorization: `Bearer ${apiKey}` }, + headers: { Authorization: `Bearer ${apiKey}`, version: '2' }, }) ); }); diff --git a/packages/cli/src/commands/scorecard-classic/remote/fetch-scorecard.ts b/packages/cli/src/commands/scorecard-classic/remote/fetch-scorecard.ts index 566f45914e..dd656a13fa 100644 --- a/packages/cli/src/commands/scorecard-classic/remote/fetch-scorecard.ts +++ b/packages/cli/src/commands/scorecard-classic/remote/fetch-scorecard.ts @@ -234,8 +234,8 @@ async function fetchPlugins(pluginsUrl: string, verbose = false): Promise { if (isApiKey) { - return { Authorization: `Bearer ${auth}` }; + return { Authorization: `Bearer ${auth}`, version: '2' }; } - return { Cookie: `accessToken=${auth}` }; + return { Cookie: `accessToken=${auth}`, version: '2' }; }