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