1import { vol } from 'memfs';
2
3import { asMock } from '../../__tests__/asMock';
4import { getProjectDevelopmentCertificateAsync } from '../../api/getProjectDevelopmentCertificate';
5import { APISettings } from '../../api/settings';
6import { getUserAsync } from '../../api/user/user';
7import { getCodeSigningInfoAsync, signManifestString } from '../codesigning';
8import { mockExpoRootChain, mockSelfSigned } from './fixtures/certificates';
9
10jest.mock('../../api/user/user');
11jest.mock('../../api/graphql/queries/AppQuery', () => ({
12  AppQuery: {
13    byIdAsync: jest.fn(async () => ({
14      id: 'blah',
15      scopeKey: 'scope-key',
16      ownerAccount: {
17        id: 'blah-account',
18      },
19    })),
20  },
21}));
22jest.mock('../../log');
23jest.mock('@expo/code-signing-certificates', () => ({
24  ...(jest.requireActual(
25    '@expo/code-signing-certificates'
26  ) as typeof import('@expo/code-signing-certificates')),
27  generateKeyPair: jest.fn(() =>
28    (
29      jest.requireActual(
30        '@expo/code-signing-certificates'
31      ) as typeof import('@expo/code-signing-certificates')
32    ).convertKeyPairPEMToKeyPair({
33      publicKeyPEM: mockExpoRootChain.publicKeyPEM,
34      privateKeyPEM: mockExpoRootChain.privateKeyPEM,
35    })
36  ),
37}));
38jest.mock('../../api/getProjectDevelopmentCertificate', () => ({
39  getProjectDevelopmentCertificateAsync: jest.fn(() => mockExpoRootChain.developmentCertificate),
40}));
41jest.mock('../../api/getExpoGoIntermediateCertificate', () => ({
42  getExpoGoIntermediateCertificateAsync: jest.fn(
43    () => mockExpoRootChain.expoGoIntermediateCertificate
44  ),
45}));
46
47// Mock the CLI global to prevent side-effects in other tests.
48jest.mock('../../api/settings', () => ({
49  APISettings: {
50    isOffline: true,
51  },
52}));
53
54beforeEach(() => {
55  vol.reset();
56
57  asMock(getUserAsync).mockImplementation(async () => ({
58    __typename: 'User',
59    id: 'userwat',
60    username: 'wat',
61    primaryAccount: { id: 'blah-account' },
62    accounts: [],
63  }));
64});
65
66describe(getCodeSigningInfoAsync, () => {
67  it('returns null when no expo-expect-signature header is requested', async () => {
68    await expect(getCodeSigningInfoAsync({} as any, null, undefined)).resolves.toBeNull();
69  });
70
71  it('throws when expo-expect-signature header has invalid format', async () => {
72    await expect(getCodeSigningInfoAsync({} as any, 'hello', undefined)).rejects.toThrowError(
73      'keyid not present in expo-expect-signature header'
74    );
75    await expect(getCodeSigningInfoAsync({} as any, 'keyid=1', undefined)).rejects.toThrowError(
76      'Invalid value for keyid in expo-expect-signature header: 1'
77    );
78    await expect(
79      getCodeSigningInfoAsync({} as any, 'keyid="hello", alg=1', undefined)
80    ).rejects.toThrowError('Invalid value for alg in expo-expect-signature header');
81  });
82
83  describe('expo-root keyid requested', () => {
84    describe('online', () => {
85      beforeEach(() => {
86        APISettings.isOffline = false;
87      });
88
89      it('normal case gets a development certificate', async () => {
90        const result = await getCodeSigningInfoAsync(
91          { extra: { eas: { projectId: 'testprojectid' } } } as any,
92          'keyid="expo-root", alg="rsa-v1_5-sha256"',
93          undefined
94        );
95        expect(result).toMatchSnapshot();
96      });
97
98      it('requires easProjectId to be configured', async () => {
99        const result = await getCodeSigningInfoAsync(
100          { extra: { eas: {} } } as any,
101          'keyid="expo-root", alg="rsa-v1_5-sha256"',
102          undefined
103        );
104        expect(result).toBeNull();
105      });
106
107      it('falls back to cached when there is a network error', async () => {
108        const result = await getCodeSigningInfoAsync(
109          { extra: { eas: { projectId: 'testprojectid' } } } as any,
110          'keyid="expo-root", alg="rsa-v1_5-sha256"',
111          undefined
112        );
113
114        asMock(getProjectDevelopmentCertificateAsync).mockImplementationOnce(
115          async (): Promise<string> => {
116            throw Error('wat');
117          }
118        );
119
120        const result2 = await getCodeSigningInfoAsync(
121          { extra: { eas: { projectId: 'testprojectid' } } } as any,
122          'keyid="expo-root", alg="rsa-v1_5-sha256"',
123          undefined
124        );
125        expect(result2).toEqual(result);
126      });
127
128      it('throws when it tried to falls back to cached when there is a network error but no cached value exists', async () => {
129        asMock(getProjectDevelopmentCertificateAsync).mockImplementationOnce(
130          async (): Promise<string> => {
131            throw Error('wat');
132          }
133        );
134
135        await expect(
136          getCodeSigningInfoAsync(
137            { extra: { eas: { projectId: 'testprojectid' } } } as any,
138            'keyid="expo-root", alg="rsa-v1_5-sha256"',
139            undefined
140          )
141        ).rejects.toThrowError('wat');
142      });
143
144      it('falls back to cached when offline', async () => {
145        const result = await getCodeSigningInfoAsync(
146          { extra: { eas: { projectId: 'testprojectid' } } } as any,
147          'keyid="expo-root", alg="rsa-v1_5-sha256"',
148          undefined
149        );
150        APISettings.isOffline = true;
151        const result2 = await getCodeSigningInfoAsync(
152          { extra: { eas: { projectId: 'testprojectid' } } } as any,
153          'keyid="expo-root", alg="rsa-v1_5-sha256"',
154          undefined
155        );
156        expect(result2).toEqual(result);
157        APISettings.isOffline = false;
158      });
159    });
160  });
161
162  describe('expo-go keyid requested', () => {
163    it('throws', async () => {
164      await expect(
165        getCodeSigningInfoAsync({} as any, 'keyid="expo-go"', undefined)
166      ).rejects.toThrowError(
167        'Invalid certificate requested: cannot sign with embedded keyid=expo-go key'
168      );
169    });
170  });
171
172  describe('non expo-root certificate keyid requested', () => {
173    it('normal case gets the configured certificate', async () => {
174      vol.fromJSON({
175        'certs/cert.pem': mockSelfSigned.certificate,
176        'keys/private-key.pem': mockSelfSigned.privateKey,
177      });
178
179      const result = await getCodeSigningInfoAsync(
180        {
181          updates: {
182            codeSigningCertificate: 'certs/cert.pem',
183            codeSigningMetadata: { keyid: 'test', alg: 'rsa-v1_5-sha256' },
184          },
185        } as any,
186        'keyid="test", alg="rsa-v1_5-sha256"',
187        'keys/private-key.pem'
188      );
189      expect(result).toMatchSnapshot();
190    });
191
192    it('throws when private key path is not supplied', async () => {
193      await expect(
194        getCodeSigningInfoAsync(
195          {
196            updates: { codeSigningCertificate: 'certs/cert.pem' },
197          } as any,
198          'keyid="test", alg="rsa-v1_5-sha256"',
199          undefined
200        )
201      ).rejects.toThrowError(
202        'Must specify --private-key-path argument to sign development manifest for requested code signing key'
203      );
204    });
205
206    it('throws when it cannot generate the requested keyid due to no code signing configuration in app.json', async () => {
207      await expect(
208        getCodeSigningInfoAsync(
209          {
210            updates: { codeSigningCertificate: 'certs/cert.pem' },
211          } as any,
212          'keyid="test", alg="rsa-v1_5-sha256"',
213          'keys/private-key.pem'
214        )
215      ).rejects.toThrowError(
216        'Must specify "codeSigningMetadata" under the "updates" field of your app config file to use EAS code signing'
217      );
218    });
219
220    it('throws when it cannot generate the requested keyid due to configured keyid or alg mismatch', async () => {
221      await expect(
222        getCodeSigningInfoAsync(
223          {
224            updates: {
225              codeSigningCertificate: 'certs/cert.pem',
226              codeSigningMetadata: { keyid: 'test2', alg: 'rsa-v1_5-sha256' },
227            },
228          } as any,
229          'keyid="test", alg="rsa-v1_5-sha256"',
230          'keys/private-key.pem'
231        )
232      ).rejects.toThrowError('keyid mismatch: client=test, project=test2');
233
234      await expect(
235        getCodeSigningInfoAsync(
236          {
237            updates: {
238              codeSigningCertificate: 'certs/cert.pem',
239              codeSigningMetadata: { keyid: 'test', alg: 'fake' },
240            },
241          } as any,
242          'keyid="test", alg="fake2"',
243          'keys/private-key.pem'
244        )
245      ).rejects.toThrowError('"alg" field mismatch (client=fake2, project=fake)');
246    });
247
248    it('throws when it cannot load configured code signing info', async () => {
249      await expect(
250        getCodeSigningInfoAsync(
251          {
252            updates: {
253              codeSigningCertificate: 'certs/cert.pem',
254              codeSigningMetadata: { keyid: 'test', alg: 'rsa-v1_5-sha256' },
255            },
256          } as any,
257          'keyid="test", alg="rsa-v1_5-sha256"',
258          'keys/private-key.pem'
259        )
260      ).rejects.toThrowError('Code signing certificate cannot be read from path: certs/cert.pem');
261    });
262  });
263});
264
265describe(signManifestString, () => {
266  it('generates signature', () => {
267    expect(
268      signManifestString('hello', {
269        keyId: 'test',
270        certificateChainForResponse: [],
271        certificateForPrivateKey: mockSelfSigned.certificate,
272        privateKey: mockSelfSigned.privateKey,
273        scopeKey: null,
274      })
275    ).toMatchSnapshot();
276  });
277  it('validates generated signature against certificate', () => {
278    expect(() =>
279      signManifestString('hello', {
280        keyId: 'test',
281        certificateChainForResponse: [],
282        certificateForPrivateKey: '',
283        privateKey: mockSelfSigned.privateKey,
284        scopeKey: null,
285      })
286    ).toThrowError('Invalid PEM formatted message.');
287  });
288});
289