import { ExpoConfig } from '@expo/config'; import { isMultipartPartWithName, parseMultipartMixedResponseAsync, MultipartPart, } from '@expo/multipart-body-parser'; import { vol } from 'memfs'; import nullthrows from 'nullthrows'; import { asMock } from '../../../../__tests__/asMock'; import { getUserAsync } from '../../../../api/user/user'; import { mockExpoRootChain, mockSelfSigned, } from '../../../../utils/__tests__/fixtures/certificates'; import { ExpoGoManifestHandlerMiddleware, ResponseContentType, } from '../ExpoGoManifestHandlerMiddleware'; import { ManifestMiddlewareOptions } from '../ManifestMiddleware'; import { ServerHeaders, ServerRequest } from '../server.types'; jest.mock('../../../../api/user/user'); jest.mock('../../../../log'); jest.mock('../../../../api/graphql/queries/AppQuery', () => ({ AppQuery: { byIdAsync: jest.fn(async () => ({ id: 'blah', scopeKey: 'scope-key', ownerAccount: { id: 'blah-account', }, })), }, })); jest.mock('@expo/code-signing-certificates', () => ({ ...(jest.requireActual( '@expo/code-signing-certificates' ) as typeof import('@expo/code-signing-certificates')), generateKeyPair: jest.fn(() => ( jest.requireActual( '@expo/code-signing-certificates' ) as typeof import('@expo/code-signing-certificates') ).convertKeyPairPEMToKeyPair({ publicKeyPEM: mockExpoRootChain.publicKeyPEM, privateKeyPEM: mockExpoRootChain.privateKeyPEM, }) ), })); jest.mock('../../../../api/getProjectDevelopmentCertificate', () => ({ getProjectDevelopmentCertificateAsync: jest.fn(() => mockExpoRootChain.developmentCertificate), })); jest.mock('../../../../api/getExpoGoIntermediateCertificate', () => ({ getExpoGoIntermediateCertificateAsync: jest.fn( () => mockExpoRootChain.expoGoIntermediateCertificate ), })); jest.mock('@expo/config-plugins', () => ({ Updates: { getRuntimeVersionAsync: jest.fn(() => Promise.resolve('45.0.0')), }, })); jest.mock('../resolveAssets', () => ({ resolveManifestAssets: jest.fn(), resolveGoogleServicesFile: jest.fn(), })); jest.mock('../resolveEntryPoint', () => ({ resolveEntryPoint: jest.fn(() => './index.js'), })); jest.mock('@expo/config', () => ({ getProjectConfigDescriptionWithPaths: jest.fn(), getConfig: jest.fn(() => ({ pkg: {}, exp: { sdkVersion: '45.0.0', name: 'my-app', slug: 'my-app', }, })), })); const asReq = (req: Partial) => req as ServerRequest; async function getMultipartPartAsync( partName: string, response: { body: string; headers: ServerHeaders; } ): Promise { const multipartParts = await parseMultipartMixedResponseAsync( response.headers.get('content-type') as string, Buffer.from(response.body) ); const part = multipartParts.find((part) => isMultipartPartWithName(part, partName)); return part ?? null; } beforeEach(() => { vol.reset(); }); describe('getParsedHeaders', () => { beforeEach(() => { delete process.env.EXPO_OFFLINE; }); const middleware = new ExpoGoManifestHandlerMiddleware('/', {} as any); it('defaults to "ios" with no platform header', () => { expect( middleware.getParsedHeaders( asReq({ url: 'http://localhost:3000', headers: {}, }) ) ).toEqual({ expectSignature: null, responseContentType: ResponseContentType.TEXT_PLAIN, hostname: null, platform: 'ios', }); }); it('returns default values from headers', () => { expect( middleware.getParsedHeaders( asReq({ url: 'http://localhost:3000', headers: { 'expo-platform': 'android' } }) ) ).toEqual({ responseContentType: ResponseContentType.TEXT_PLAIN, expectSignature: null, hostname: null, platform: 'android', }); }); it('supports application/json and expo+json', () => { expect( middleware.getParsedHeaders( asReq({ url: 'http://localhost:3000', headers: { 'expo-platform': 'android', accept: 'application/json' }, }) ) ).toEqual({ responseContentType: ResponseContentType.APPLICATION_JSON, expectSignature: null, hostname: null, platform: 'android', }); expect( middleware.getParsedHeaders( asReq({ url: 'http://localhost:3000', headers: { 'expo-platform': 'android', accept: 'application/expo+json' }, }) ) ).toEqual({ responseContentType: ResponseContentType.APPLICATION_EXPO_JSON, expectSignature: null, hostname: null, platform: 'android', }); }); it(`returns a fully qualified object`, () => { expect( middleware.getParsedHeaders( asReq({ url: 'http://localhost:3000', headers: { accept: 'multipart/mixed', host: 'localhost:8081', 'expo-platform': 'ios', 'expo-expect-signature': 'wat', }, }) ) ).toEqual({ responseContentType: ResponseContentType.MULTIPART_MIXED, expectSignature: 'wat', hostname: 'localhost', // We don't care much about the platform here since it's already tested. platform: 'ios', }); }); }); describe('_getManifestResponseAsync', () => { beforeEach(() => { delete process.env.EXPO_OFFLINE; asMock(getUserAsync).mockImplementation(async () => ({ __typename: 'User', id: 'userwat', username: 'wat', primaryAccount: { id: 'blah-account' }, accounts: [], })); }); function createMiddleware( extraExpFields?: Partial, options: Partial = {} ) { const middleware = new ExpoGoManifestHandlerMiddleware('/', options as any); middleware._resolveProjectSettingsAsync = jest.fn( async () => ({ expoGoConfig: {}, hostUri: 'https://localhost:8081', bundleUrl: 'https://localhost:8081/bundle.js', exp: { slug: 'slug', extra: { eas: { projectId: 'projectId', }, }, ...extraExpFields, }, }) as any ); return middleware; } // Sanity it('returns an anon manifest when no code signing is requested', async () => { const middleware = createMiddleware(); process.env.EXPO_OFFLINE = '1'; const results = await middleware._getManifestResponseAsync({ responseContentType: ResponseContentType.MULTIPART_MIXED, platform: 'android', expectSignature: null, hostname: 'localhost', }); expect(results.version).toBe('45.0.0'); expect(results.headers).toEqual( new Map( Object.entries({ 'expo-protocol-version': 0, 'expo-sfv-version': 0, 'cache-control': 'private, max-age=0', 'content-type': expect.stringContaining('multipart/mixed'), }) ) ); const { body } = nullthrows(await getMultipartPartAsync('manifest', results)); expect(JSON.parse(body)).toEqual({ id: expect.any(String), createdAt: expect.any(String), runtimeVersion: '45.0.0', launchAsset: { key: 'bundle', contentType: 'application/javascript', url: 'https://localhost:8081/bundle.js', }, assets: [], metadata: {}, extra: { eas: { projectId: 'projectId', }, expoClient: { extra: { eas: { projectId: 'projectId', }, }, hostUri: 'https://localhost:8081', slug: 'slug', }, expoGo: {}, scopeKey: expect.stringMatching(/@anonymous\/.*/), }, }); }); it('returns a code signed manifest with developers own key when requested', async () => { vol.fromJSON({ 'certs/cert.pem': mockSelfSigned.certificate, 'custom/private/key/path/private-key.pem': mockSelfSigned.privateKey, }); const middleware = createMiddleware( { updates: { codeSigningCertificate: 'certs/cert.pem', codeSigningMetadata: { keyid: 'testkeyid', alg: 'rsa-v1_5-sha256', }, }, }, { privateKeyPath: 'custom/private/key/path/private-key.pem', } ); const results = await middleware._getManifestResponseAsync({ responseContentType: ResponseContentType.MULTIPART_MIXED, platform: 'android', expectSignature: 'sig, keyid="testkeyid", alg="rsa-v1_5-sha256"', hostname: 'localhost', }); expect(results.version).toBe('45.0.0'); const { body, headers } = nullthrows(await getMultipartPartAsync('manifest', results)); expect(headers.get('expo-signature')).toContain('keyid="testkeyid"'); expect(JSON.parse(body)).toEqual({ id: expect.any(String), createdAt: expect.any(String), runtimeVersion: '45.0.0', launchAsset: { key: 'bundle', contentType: 'application/javascript', url: 'https://localhost:8081/bundle.js', }, assets: [], metadata: {}, extra: { eas: { projectId: 'projectId', }, expoClient: expect.anything(), expoGo: {}, scopeKey: expect.stringMatching(/@anonymous\/.*/), }, }); const certificateChainMultipartPart = await getMultipartPartAsync('certificate_chain', results); expect(certificateChainMultipartPart).toBeNull(); }); it('returns a code signed manifest with expo-root chain when requested', async () => { const middleware = createMiddleware(); const results = await middleware._getManifestResponseAsync({ responseContentType: ResponseContentType.MULTIPART_MIXED, platform: 'android', expectSignature: 'sig, keyid="expo-root", alg="rsa-v1_5-sha256"', hostname: 'localhost', }); expect(results.version).toBe('45.0.0'); const { body: manifestPartBody, headers: manifestPartHeaders } = nullthrows( await getMultipartPartAsync('manifest', results) ); expect(manifestPartHeaders.get('expo-signature')).toContain('keyid="expo-go"'); expect(JSON.parse(manifestPartBody)).toEqual({ id: expect.any(String), createdAt: expect.any(String), runtimeVersion: '45.0.0', launchAsset: { key: 'bundle', contentType: 'application/javascript', url: 'https://localhost:8081/bundle.js', }, assets: [], metadata: {}, extra: { eas: { projectId: 'projectId', }, expoClient: expect.anything(), expoGo: {}, scopeKey: 'scope-key', }, }); const { body: certificateChainPartBody } = nullthrows( await getMultipartPartAsync('certificate_chain', results) ); expect(certificateChainPartBody).toMatchSnapshot(); }); it('returns a code signed manifest with developers own key when requested when offline', async () => { vol.fromJSON({ 'certs/cert.pem': mockSelfSigned.certificate, 'custom/private/key/path/private-key.pem': mockSelfSigned.privateKey, }); process.env.EXPO_OFFLINE = '1'; const middleware = createMiddleware( { updates: { codeSigningCertificate: 'certs/cert.pem', codeSigningMetadata: { keyid: 'testkeyid', alg: 'rsa-v1_5-sha256', }, }, }, { privateKeyPath: 'custom/private/key/path/private-key.pem', } ); const results = await middleware._getManifestResponseAsync({ responseContentType: ResponseContentType.MULTIPART_MIXED, platform: 'android', expectSignature: 'sig, keyid="testkeyid", alg="rsa-v1_5-sha256"', hostname: 'localhost', }); expect(results.version).toBe('45.0.0'); const { body, headers } = nullthrows(await getMultipartPartAsync('manifest', results)); expect(headers.get('expo-signature')).toContain('keyid="testkeyid"'); expect(JSON.parse(body)).toEqual({ id: expect.any(String), createdAt: expect.any(String), runtimeVersion: '45.0.0', launchAsset: { key: 'bundle', contentType: 'application/javascript', url: 'https://localhost:8081/bundle.js', }, assets: [], metadata: {}, extra: { eas: { projectId: 'projectId', }, expoClient: expect.anything(), expoGo: {}, scopeKey: expect.stringMatching(/@anonymous\/.*/), }, }); const certificateChainMultipartPart = await getMultipartPartAsync('certificate_chain', results); expect(certificateChainMultipartPart).toBeNull(); }); it('returns a code signed manifest with expo-root chain when requested when offline and has cached dev cert', async () => { // start online to cache cert and stuff delete process.env.EXPO_OFFLINE; const middlewareOnline = createMiddleware(); await middlewareOnline._getManifestResponseAsync({ responseContentType: ResponseContentType.MULTIPART_MIXED, platform: 'android', expectSignature: 'sig, keyid="expo-root", alg="rsa-v1_5-sha256"', hostname: 'localhost', }); // go offline process.env.EXPO_OFFLINE = '1'; const middleware = createMiddleware(); const results = await middleware._getManifestResponseAsync({ responseContentType: ResponseContentType.MULTIPART_MIXED, platform: 'android', expectSignature: 'sig, keyid="expo-root", alg="rsa-v1_5-sha256"', hostname: 'localhost', }); expect(results.version).toBe('45.0.0'); const { body: manifestPartBody, headers: manifestPartHeaders } = nullthrows( await getMultipartPartAsync('manifest', results) ); expect(manifestPartHeaders.get('expo-signature')).toContain('keyid="expo-go"'); expect(JSON.parse(manifestPartBody)).toEqual({ id: expect.any(String), createdAt: expect.any(String), runtimeVersion: '45.0.0', launchAsset: { key: 'bundle', contentType: 'application/javascript', url: 'https://localhost:8081/bundle.js', }, assets: [], metadata: {}, extra: { eas: { projectId: 'projectId', }, expoClient: expect.anything(), expoGo: {}, scopeKey: 'scope-key', }, }); const { body: certificateChainPartBody } = nullthrows( await getMultipartPartAsync('certificate_chain', results) ); expect(certificateChainPartBody).toMatchSnapshot(); }); it('returns application/json when requested', async () => { const middleware = createMiddleware(); process.env.EXPO_OFFLINE = '1'; const results = await middleware._getManifestResponseAsync({ responseContentType: ResponseContentType.APPLICATION_JSON, platform: 'android', expectSignature: null, hostname: 'localhost', }); expect(JSON.parse(results.body)).toMatchObject({ id: expect.any(String), createdAt: expect.any(String), runtimeVersion: '45.0.0', launchAsset: { key: 'bundle', contentType: 'application/javascript', url: 'https://localhost:8081/bundle.js', }, assets: [], metadata: {}, extra: { eas: { projectId: 'projectId', }, expoClient: expect.anything(), expoGo: {}, scopeKey: expect.stringMatching(/@anonymous\/.*/), }, }); expect(results.version).toBe('45.0.0'); expect(results.headers).toEqual( new Map( Object.entries({ 'expo-protocol-version': 0, 'expo-sfv-version': 0, 'cache-control': 'private, max-age=0', 'content-type': 'application/json', }) ) ); }); it('returns text/plain when explicitlyPrefersMultipartMixed is false', async () => { const middleware = createMiddleware(); process.env.EXPO_OFFLINE = '1'; const results = await middleware._getManifestResponseAsync({ responseContentType: ResponseContentType.TEXT_PLAIN, platform: 'android', expectSignature: null, hostname: 'localhost', }); expect(JSON.parse(results.body)).toMatchObject({ id: expect.any(String), createdAt: expect.any(String), runtimeVersion: '45.0.0', launchAsset: { key: 'bundle', contentType: 'application/javascript', url: 'https://localhost:8081/bundle.js', }, assets: [], metadata: {}, extra: { eas: { projectId: 'projectId', }, expoClient: expect.anything(), expoGo: {}, scopeKey: expect.stringMatching(/@anonymous\/.*/), }, }); expect(results.version).toBe('45.0.0'); expect(results.headers).toEqual( new Map( Object.entries({ 'expo-protocol-version': 0, 'expo-sfv-version': 0, 'cache-control': 'private, max-age=0', 'content-type': 'text/plain', }) ) ); }); });