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