Merge pull request #11215 from storybookjs/fix/refs-rename-authUrl

Fix/refs rename auth url & add tests for modules/refs
This commit is contained in:
Norbert de Langen 2020-06-19 14:12:02 +02:00 committed by GitHub
commit 194fdd5d60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 603 additions and 34 deletions

View File

@ -77,11 +77,11 @@ For an example what this file should look like, see: [here](https://next--storyb
If you have some authentication layer on your hosted storybook, the composing the storybook will fail. Storybook will show a message in the sidebar if that happens. If you have some authentication layer on your hosted storybook, the composing the storybook will fail. Storybook will show a message in the sidebar if that happens.
You can assist the user by creating a `metadata.json` file with a `authUrl` field, and ensure this file **is** loadable (even in the user is not authenticated): You can assist the user by creating a `metadata.json` file with a `loginUrl` field, and ensure this file **is** loadable (even in the user is not authenticated):
```json ```json
{ {
"authUrl": "https://example.com" "loginUrl": "https://example.com"
} }
``` ```

View File

@ -15,9 +15,11 @@ export interface SubState {
type Versions = Record<string, string>; type Versions = Record<string, string>;
export type SetRefData = Omit<ComposedRef, 'stories'> & { export type SetRefData = Partial<
stories?: StoriesRaw; Omit<ComposedRef, 'stories'> & {
}; stories?: StoriesRaw;
}
>;
export interface SubAPI { export interface SubAPI {
findRef: (source: string) => ComposedRef; findRef: (source: string) => ComposedRef;
@ -36,7 +38,7 @@ export interface ComposedRef {
type?: 'auto-inject' | 'unknown' | 'lazy'; type?: 'auto-inject' | 'unknown' | 'lazy';
stories: StoriesHash; stories: StoriesHash;
versions?: Versions; versions?: Versions;
authUrl?: string; loginUrl?: string;
ready?: boolean; ready?: boolean;
error?: any; error?: any;
} }
@ -48,12 +50,12 @@ export type RefUrl = string;
// eslint-disable-next-line no-useless-escape // eslint-disable-next-line no-useless-escape
const findFilename = /(\/((?:[^\/]+?)\.[^\/]+?)|\/)$/; const findFilename = /(\/((?:[^\/]+?)\.[^\/]+?)|\/)$/;
const allSettled = (promises: Promise<any>[]) => const allSettled = (promises: Promise<Response>[]): Promise<(Response | false)[]> =>
Promise.all( Promise.all(
promises.map((promise, i) => promises.map((promise) =>
promise.then( promise.then(
(r) => (r.ok ? r : false), (r) => (r.ok ? r : (false as const)),
() => false () => false as const
) )
) )
); );
@ -98,7 +100,7 @@ const map = (
return input; return input;
}; };
export const init: ModuleFn = ({ store, provider, fullAPI }) => { export const init: ModuleFn = ({ store, provider, fullAPI }, { runCheck = true } = {}) => {
const api: SubAPI = { const api: SubAPI = {
findRef: (source) => { findRef: (source) => {
const refs = api.getRefs(); const refs = api.getRefs();
@ -123,7 +125,7 @@ export const init: ModuleFn = ({ store, provider, fullAPI }) => {
checkRef: async (ref) => { checkRef: async (ref) => {
const { id, url } = ref; const { id, url } = ref;
const loadedData: { error?: Error; stories?: StoriesRaw } = {}; const loadedData: { error?: Error; stories?: StoriesRaw; loginUrl?: string } = {};
const [included, omitted, iframe] = await allSettled([ const [included, omitted, iframe] = await allSettled([
fetch(`${url}/stories.json`, { fetch(`${url}/stories.json`, {
@ -144,7 +146,7 @@ export const init: ModuleFn = ({ store, provider, fullAPI }) => {
}), }),
]); ]);
const handle = async (request: Promise<Response> | false) => { const handle = async (request: Response | false): Promise<SetRefData> => {
if (request) { if (request) {
return Promise.resolve(request) return Promise.resolve(request)
.then((response) => (response.ok ? response.json() : {})) .then((response) => (response.ok ? response.json() : {}))
@ -168,10 +170,10 @@ export const init: ModuleFn = ({ store, provider, fullAPI }) => {
`, `,
} as Error; } as Error;
} else if (omitted || included) { } else if (omitted || included) {
const credentials = !omitted ? 'include' : 'omit'; const credentials = included ? 'include' : 'omit';
const [stories, metadata] = await Promise.all([ const [stories, metadata] = await Promise.all([
handle(omitted || included), included ? handle(included) : handle(omitted),
handle( handle(
fetch(`${url}/metadata.json`, { fetch(`${url}/metadata.json`, {
headers: { headers: {
@ -179,14 +181,14 @@ export const init: ModuleFn = ({ store, provider, fullAPI }) => {
}, },
credentials, credentials,
cache: 'no-cache', cache: 'no-cache',
}) }).catch(() => false)
), ),
]); ]);
Object.assign(loadedData, { ...stories, ...metadata }); Object.assign(loadedData, { ...stories, ...metadata });
} }
api.setRef(id, { await api.setRef(id, {
id, id,
url, url,
...loadedData, ...loadedData,
@ -230,9 +232,11 @@ export const init: ModuleFn = ({ store, provider, fullAPI }) => {
r.type = 'unknown'; r.type = 'unknown';
}); });
Object.entries(refs).forEach(([k, v]) => { if (runCheck) {
api.checkRef(v as SetRefData); Object.entries(refs).forEach(([k, v]) => {
}); api.checkRef(v as SetRefData);
});
}
return { return {
api, api,

View File

@ -1,3 +1,3 @@
{ {
"authUrl": "https://example.com" "loginUrl": "https://example.com"
} }

View File

@ -1,7 +1,10 @@
import { getSourceType } from '../modules/refs'; import { fetch } from 'global';
import { getSourceType, init as initRefs } from '../modules/refs';
jest.mock('global', () => { jest.mock('global', () => {
const globalMock = {}; const globalMock = {
fetch: jest.fn(() => Promise.resolve({})),
};
// Change global.location value to handle edge cases // Change global.location value to handle edge cases
// Add additional variations of global.location mock return values in this array. // Add additional variations of global.location mock return values in this array.
// NOTE: The order must match the order that global.location is called in the unit tests. // NOTE: The order must match the order that global.location is called in the unit tests.
@ -20,7 +23,80 @@ jest.mock('global', () => {
return globalMock; return globalMock;
}); });
describe('refs', () => { const provider = {
getConfig: () => ({
refs: {
fake: {
id: 'fake',
url: 'https://example.com',
title: 'Fake',
},
},
}),
};
const store = {
getState: () => ({
refs: {
fake: {
id: 'fake',
url: 'https://example.com',
title: 'Fake',
},
},
}),
setState: jest.fn(() => {}),
};
const emptyResponse = Promise.resolve({
ok: true,
json: async () => ({}),
});
const setupResponses = (
a = emptyResponse,
b = emptyResponse,
c = emptyResponse,
d = emptyResponse
) => {
fetch.mockClear();
store.setState.mockClear();
fetch.mockImplementation((l, o) => {
if (l.includes('stories') && o.credentials === 'omit') {
return Promise.resolve({
ok: a.ok,
json: a.response,
});
}
if (l.includes('stories') && o.credentials === 'include') {
return Promise.resolve({
ok: b.ok,
json: b.response,
});
}
if (l.includes('iframe')) {
return Promise.resolve({
ok: c.ok,
json: c.response,
});
}
if (l.includes('metadata')) {
return Promise.resolve({
ok: d.ok,
json: d.response,
});
}
return Promise.resolve({
ok: false,
json: () => {
throw new Error('not ok');
},
});
});
};
describe('Refs API', () => {
describe('getSourceType(source)', () => { describe('getSourceType(source)', () => {
// These tests must be run first and in correct order. // These tests must be run first and in correct order.
// The order matches the "edgecaseLocations" order in the 'global' mock function above. // The order matches the "edgecaseLocations" order in the 'global' mock function above.
@ -53,4 +129,490 @@ describe('refs', () => {
]); ]);
}); });
}); });
describe('checkRef', () => {
it('on initialization it checks refs', async () => {
// given
initRefs({ provider, store });
expect(fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"https://example.com/stories.json",
Object {
"credentials": "include",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/stories.json",
Object {
"credentials": "omit",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/iframe.html",
Object {
"cors": "no-cors",
"credentials": "omit",
},
],
]
`);
});
it('checks refs (all fail)', async () => {
// given
const { api } = initRefs({ provider, store }, { runCheck: false });
setupResponses(
{
ok: false,
response: async () => {
throw new Error('Failed to fetch');
},
},
{
ok: false,
response: async () => {
throw new Error('Failed to fetch');
},
},
{
ok: false,
response: async () => {
throw new Error('not ok');
},
}
);
await api.checkRef({
id: 'fake',
url: 'https://example.com',
title: 'Fake',
});
expect(fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"https://example.com/stories.json",
Object {
"credentials": "include",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/stories.json",
Object {
"credentials": "omit",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/iframe.html",
Object {
"cors": "no-cors",
"credentials": "omit",
},
],
]
`);
expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"refs": Object {
"fake": Object {
"error": Object {
"message": "Error: Loading of ref failed
at fetch (lib/api/src/modules/refs.ts)
URL: https://example.com
We weren't able to load the above URL,
it's possible a CORS error happened.
Please check your dev-tools network tab.",
},
"id": "fake",
"ready": false,
"stories": undefined,
"title": "Fake",
"type": "auto-inject",
"url": "https://example.com",
},
},
}
`);
});
it('checks refs (success)', async () => {
// given
const { api } = initRefs({ provider, store }, { runCheck: false });
setupResponses(
{
ok: true,
response: async () => ({ stories: {} }),
},
{
ok: true,
response: async () => ({ stories: {} }),
},
{
ok: true,
response: async () => {
throw new Error('not ok');
},
},
{
ok: true,
response: async () => ({
versions: {},
}),
}
);
await api.checkRef({
id: 'fake',
url: 'https://example.com',
title: 'Fake',
});
expect(fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"https://example.com/stories.json",
Object {
"credentials": "include",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/stories.json",
Object {
"credentials": "omit",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/iframe.html",
Object {
"cors": "no-cors",
"credentials": "omit",
},
],
Array [
"https://example.com/metadata.json",
Object {
"cache": "no-cache",
"credentials": "include",
"headers": Object {
"Accept": "application/json",
},
},
],
]
`);
expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"refs": Object {
"fake": Object {
"id": "fake",
"ready": false,
"stories": Object {},
"title": "Fake",
"type": "lazy",
"url": "https://example.com",
"versions": Object {},
},
},
}
`);
});
it('checks refs (auth)', async () => {
// given
const { api } = initRefs({ provider, store }, { runCheck: false });
setupResponses(
{
ok: true,
response: async () => ({ loginUrl: 'https://example.com/login' }),
},
{
ok: false,
response: async () => {
throw new Error('not ok');
},
},
{
ok: true,
response: async () => {
throw new Error('not ok');
},
},
{
ok: false,
response: async () => {
throw new Error('not ok');
},
}
);
await api.checkRef({
id: 'fake',
url: 'https://example.com',
title: 'Fake',
});
expect(fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"https://example.com/stories.json",
Object {
"credentials": "include",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/stories.json",
Object {
"credentials": "omit",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/iframe.html",
Object {
"cors": "no-cors",
"credentials": "omit",
},
],
Array [
"https://example.com/metadata.json",
Object {
"cache": "no-cache",
"credentials": "omit",
"headers": Object {
"Accept": "application/json",
},
},
],
]
`);
expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"refs": Object {
"fake": Object {
"id": "fake",
"loginUrl": "https://example.com/login",
"ready": false,
"stories": undefined,
"title": "Fake",
"type": "auto-inject",
"url": "https://example.com",
},
},
}
`);
});
it('checks refs (mixed)', async () => {
// given
const { api } = initRefs({ provider, store }, { runCheck: false });
fetch.mockClear();
store.setState.mockClear();
setupResponses(
{
ok: true,
response: async () => ({ loginUrl: 'https://example.com/login' }),
},
{
ok: true,
response: async () => ({ stories: {} }),
},
{
ok: true,
response: async () => {
throw new Error('not ok');
},
},
{
ok: true,
response: async () => ({
versions: { '1.0.0': 'https://example.com/v1', '2.0.0': 'https://example.com' },
}),
}
);
await api.checkRef({
id: 'fake',
url: 'https://example.com',
title: 'Fake',
});
expect(fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"https://example.com/stories.json",
Object {
"credentials": "include",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/stories.json",
Object {
"credentials": "omit",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/iframe.html",
Object {
"cors": "no-cors",
"credentials": "omit",
},
],
Array [
"https://example.com/metadata.json",
Object {
"cache": "no-cache",
"credentials": "include",
"headers": Object {
"Accept": "application/json",
},
},
],
]
`);
expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"refs": Object {
"fake": Object {
"id": "fake",
"ready": false,
"stories": Object {},
"title": "Fake",
"type": "lazy",
"url": "https://example.com",
"versions": Object {
"1.0.0": "https://example.com/v1",
"2.0.0": "https://example.com",
},
},
},
}
`);
});
it('checks refs (cors)', async () => {
// given
const { api } = initRefs({ provider, store }, { runCheck: false });
setupResponses(
{
ok: false,
response: async () => {
throw new Error('Failed to fetch');
},
},
{
ok: false,
response: async () => {
throw new Error('Failed to fetch');
},
},
{
ok: true,
response: async () => {
throw new Error('not ok');
},
},
{
ok: false,
response: async () => {
throw new Error('Failed to fetch');
},
}
);
await api.checkRef({
id: 'fake',
url: 'https://example.com',
title: 'Fake',
});
expect(fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"https://example.com/stories.json",
Object {
"credentials": "include",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/stories.json",
Object {
"credentials": "omit",
"headers": Object {
"Accept": "application/json",
},
},
],
Array [
"https://example.com/iframe.html",
Object {
"cors": "no-cors",
"credentials": "omit",
},
],
]
`);
expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"refs": Object {
"fake": Object {
"id": "fake",
"ready": false,
"stories": undefined,
"title": "Fake",
"type": "auto-inject",
"url": "https://example.com",
},
},
}
`);
});
});
}); });

View File

@ -1 +1 @@
export const version = '6.0.0-beta.31'; export const version = '6.0.0-beta.31';

View File

@ -160,7 +160,10 @@ const ErrorFormatter: FunctionComponent<{ error: Error }> = ({ error }) => {
); );
}; };
export const AuthBlock: FunctionComponent<{ authUrl: string; id: string }> = ({ authUrl, id }) => { export const AuthBlock: FunctionComponent<{ loginUrl: string; id: string }> = ({
loginUrl,
id,
}) => {
const [isAuthAttempted, setAuthAttempted] = useState(false); const [isAuthAttempted, setAuthAttempted] = useState(false);
const refresh = useCallback(() => { const refresh = useCallback(() => {
@ -169,12 +172,12 @@ export const AuthBlock: FunctionComponent<{ authUrl: string; id: string }> = ({
const open = useCallback((e) => { const open = useCallback((e) => {
e.preventDefault(); e.preventDefault();
const childWindow = window.open(authUrl, `storybook_auth_${id}`, 'resizable,scrollbars'); const childWindow = window.open(loginUrl, `storybook_auth_${id}`, 'resizable,scrollbars');
// poll for window to close // poll for window to close
const timer = setInterval(() => { const timer = setInterval(() => {
if (!childWindow) { if (!childWindow) {
logger.error('unable to access authUrl window'); logger.error('unable to access loginUrl window');
clearInterval(timer); clearInterval(timer);
} else if (childWindow.closed) { } else if (childWindow.closed) {
clearInterval(timer); clearInterval(timer);
@ -189,8 +192,8 @@ export const AuthBlock: FunctionComponent<{ authUrl: string; id: string }> = ({
{isAuthAttempted ? ( {isAuthAttempted ? (
<Fragment> <Fragment>
<Text> <Text>
Authentication on <strong>{authUrl}</strong> seems to have concluded, refresh the page Authentication on <strong>{loginUrl}</strong> seems to have concluded, refresh the
to fetch this storybook page to fetch this storybook
</Text> </Text>
<div> <div>
<Button small gray onClick={refresh}> <Button small gray onClick={refresh}>

View File

@ -54,7 +54,7 @@ export const Ref: FunctionComponent<RefType & RefProps> = (ref) => {
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(true);
const indicatorRef = useRef<HTMLElement>(null); const indicatorRef = useRef<HTMLElement>(null);
const { stories, id: key, title = key, storyId, filter, isHidden = false, authUrl, error } = ref; const { stories, id: key, title = key, storyId, filter, isHidden = false, loginUrl, error } = ref;
const { dataSet, expandedSet, length, others, roots, setExpanded, selectedSet } = useDataset( const { dataSet, expandedSet, length, others, roots, setExpanded, selectedSet } = useDataset(
stories, stories,
filter, filter,
@ -77,7 +77,7 @@ export const Ref: FunctionComponent<RefType & RefProps> = (ref) => {
const isLoading = isLoadingMain || isLoadingInjected || ref.type === 'unknown'; const isLoading = isLoadingMain || isLoadingInjected || ref.type === 'unknown';
const isError = !!error; const isError = !!error;
const isEmpty = !isLoading && length === 0; const isEmpty = !isLoading && length === 0;
const isAuthRequired = !!authUrl; const isAuthRequired = !!loginUrl && length === 0;
const state = getStateType(isLoading, isAuthRequired, isError, isEmpty); const state = getStateType(isLoading, isAuthRequired, isError, isEmpty);
@ -97,7 +97,7 @@ export const Ref: FunctionComponent<RefType & RefProps> = (ref) => {
)} )}
{isExpanded && ( {isExpanded && (
<Wrapper data-title={title} isMain={isMain}> <Wrapper data-title={title} isMain={isMain}>
{state === 'auth' && <AuthBlock id={ref.id} authUrl={authUrl} />} {state === 'auth' && <AuthBlock id={ref.id} loginUrl={loginUrl} />}
{state === 'error' && <ErrorBlock error={error} />} {state === 'error' && <ErrorBlock error={error} />}
{state === 'loading' && <LoaderBlock isMain={isMain} />} {state === 'loading' && <LoaderBlock isMain={isMain} />}
{state === 'empty' && <EmptyBlock isMain={isMain} />} {state === 'empty' && <EmptyBlock isMain={isMain} />}

View File

@ -88,7 +88,7 @@ const refs: Record<string, RefType> = {
url: 'https://example.com', url: 'https://example.com',
type: 'lazy', type: 'lazy',
stories: {}, stories: {},
authUrl: 'https://example.com', loginUrl: 'https://example.com',
}, },
long: { long: {
id: 'long', id: 'long',