From d29589dfa45bcb218479b013334747ed4655b2b9 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 29 Jan 2026 16:36:27 +0700 Subject: [PATCH] Composition: Handle 401 responses with loginUrl from Chromatic Chromatic is changing their endpoints to return 401 instead of 200 when authentication is required. This change adds support for extracting loginUrl from 401 response bodies in addition to the existing 200 handling. --- .../common/utils/get-storybook-refs.test.ts | 35 +++++++++++++ .../src/common/utils/get-storybook-refs.ts | 2 +- code/core/src/manager-api/modules/refs.ts | 11 ++++ code/core/src/manager-api/tests/refs.test.ts | 50 ++++++++++++++++++- 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 code/core/src/common/utils/get-storybook-refs.test.ts diff --git a/code/core/src/common/utils/get-storybook-refs.test.ts b/code/core/src/common/utils/get-storybook-refs.test.ts new file mode 100644 index 000000000000..9c1c4f540945 --- /dev/null +++ b/code/core/src/common/utils/get-storybook-refs.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { checkRef } from './get-storybook-refs'; + +describe('checkRef', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns true when fetch returns 200', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + } as Response); + expect(await checkRef('https://chromatic.com')).toBe(true); + }); + + it('returns false when fetch returns 401', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 401 } as Response); + expect(await checkRef('https://chromatic.com')).toBe(false); + }); + + it('returns false when fetch returns 200 with loginUrl', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ loginUrl: 'https://chromatic.com/login' }), + } as Response); + expect(await checkRef('https://chromatic.com')).toBe(false); + }); + + it('returns false when fetch fails', async () => { + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error')); + expect(await checkRef('https://chromatic.com')).toBe(false); + }); +}); diff --git a/code/core/src/common/utils/get-storybook-refs.ts b/code/core/src/common/utils/get-storybook-refs.ts index 457025541997..c6e1d10d1427 100644 --- a/code/core/src/common/utils/get-storybook-refs.ts +++ b/code/core/src/common/utils/get-storybook-refs.ts @@ -58,7 +58,7 @@ export const getAutoRefs = async (options: Options): Promise ); }; -const checkRef = (url: string) => +export const checkRef = (url: string) => fetch(`${url}/iframe.html`).then( async ({ ok, status }) => { if (ok) { diff --git a/code/core/src/manager-api/modules/refs.ts b/code/core/src/manager-api/modules/refs.ts index 586453443c05..151003529eed 100644 --- a/code/core/src/manager-api/modules/refs.ts +++ b/code/core/src/manager-api/modules/refs.ts @@ -118,6 +118,17 @@ async function handleRequest( throw new Error('Unexpected boolean response'); } if (!response.ok) { + // Check for 401 responses that may contain loginUrl + if (response.status === 401) { + try { + const json = await response.json(); + if (json.loginUrl) { + return { loginUrl: json.loginUrl }; + } + } catch { + // Fall through to error handling if JSON parsing fails + } + } throw new Error(`Unexpected response not OK: ${response.statusText}`); } diff --git a/code/core/src/manager-api/tests/refs.test.ts b/code/core/src/manager-api/tests/refs.test.ts index bc337d3a7497..d3ec49e7cf7b 100644 --- a/code/core/src/manager-api/tests/refs.test.ts +++ b/code/core/src/manager-api/tests/refs.test.ts @@ -73,6 +73,7 @@ function createMockStore(initialState: Partial = {}) { interface ResponseResult { ok?: boolean; + status?: number; err?: Error; response?: () => never | object | Promise; } @@ -86,13 +87,14 @@ type ResponseKeys = | 'metadata'; function respond(result: ResponseResult): Promise { - const { err, ok, response } = result; + const { err, ok, status, response } = result; if (err) { return Promise.reject(err); } return Promise.resolve({ ok: ok ?? !!response, + status: status ?? (ok ? 200 : 500), json: response, } as Response); } @@ -784,6 +786,52 @@ describe('Refs API', () => { `); }); + it('checks refs (auth with 401)', async () => { + // given + const { api } = initRefs({ provider, store } as any, { runCheck: false }); + + setupResponses({ + indexPrivate: { + ok: false, + status: 401, + response: async () => ({ loginUrl: 'https://example.com/login' }), + }, + storiesPrivate: { + ok: false, + status: 401, + response: async () => ({ loginUrl: 'https://example.com/login' }), + }, + metadata: { + ok: false, + status: 401, + response: async () => ({ loginUrl: 'https://example.com/login' }), + }, + }); + + await api.checkRef({ + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }); + + expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(` + { + "refs": { + "fake": { + "filteredIndex": undefined, + "id": "fake", + "index": undefined, + "internal_index": undefined, + "loginUrl": "https://example.com/login", + "title": "Fake", + "type": "auto-inject", + "url": "https://example.com", + }, + }, + } + `); + }); + it('checks refs (basic-auth)', async () => { // given const { api } = initRefs({ provider, store } as any, { runCheck: false });