@shipfox/api-integration-github 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +138 -0
- package/LICENSE +21 -0
- package/dist/api/client.d.ts +90 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +306 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/installation-token-envelope.d.ts +36 -0
- package/dist/api/installation-token-envelope.d.ts.map +1 -0
- package/dist/api/installation-token-envelope.js +123 -0
- package/dist/api/installation-token-envelope.js.map +1 -0
- package/dist/api/installation-token-provider.d.ts +23 -0
- package/dist/api/installation-token-provider.d.ts.map +1 -0
- package/dist/api/installation-token-provider.js +130 -0
- package/dist/api/installation-token-provider.js.map +1 -0
- package/dist/api/shared-installation-token-cache.d.ts +43 -0
- package/dist/api/shared-installation-token-cache.d.ts.map +1 -0
- package/dist/api/shared-installation-token-cache.js +224 -0
- package/dist/api/shared-installation-token-cache.js.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +44 -0
- package/dist/config.js.map +1 -0
- package/dist/core/actions-logs.d.ts +47 -0
- package/dist/core/actions-logs.d.ts.map +1 -0
- package/dist/core/actions-logs.js +134 -0
- package/dist/core/actions-logs.js.map +1 -0
- package/dist/core/agent-tool-selection.d.ts +4 -0
- package/dist/core/agent-tool-selection.d.ts.map +1 -0
- package/dist/core/agent-tool-selection.js +38 -0
- package/dist/core/agent-tool-selection.js.map +1 -0
- package/dist/core/agent-tools.d.ts +50 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +1122 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/errors.d.ts +15 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +22 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/install.d.ts +38 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +64 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/source-control.d.ts +15 -0
- package/dist/core/source-control.d.ts.map +1 -0
- package/dist/core/source-control.js +175 -0
- package/dist/core/source-control.js.map +1 -0
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/state.js +54 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/webhook.d.ts +20 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +216 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +433 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/installation-token-lock.d.ts +8 -0
- package/dist/db/installation-token-lock.d.ts.map +1 -0
- package/dist/db/installation-token-lock.js +37 -0
- package/dist/db/installation-token-lock.js.map +1 -0
- package/dist/db/installations.d.ts +33 -0
- package/dist/db/installations.d.ts.map +1 -0
- package/dist/db/installations.js +56 -0
- package/dist/db/installations.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/installations.d.ts +218 -0
- package/dist/db/schema/installations.d.ts.map +1 -0
- package/dist/db/schema/installations.js +46 -0
- package/dist/db/schema/installations.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +3 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +14 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +75 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/presentation/dto/integrations.d.ts +15 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +10 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts +11 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.js +57 -0
- package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +5 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +11 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +46 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +13 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +77 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/presentation/routes/webhooks.d.ts +16 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +110 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +17 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +71 -0
- package/src/api/client.test.ts +94 -0
- package/src/api/client.ts +525 -0
- package/src/api/installation-token-envelope.ts +171 -0
- package/src/api/installation-token-provider.test.ts +241 -0
- package/src/api/installation-token-provider.ts +224 -0
- package/src/api/shared-installation-token-cache.test.ts +294 -0
- package/src/api/shared-installation-token-cache.ts +346 -0
- package/src/config.test.ts +14 -0
- package/src/config.ts +47 -0
- package/src/connection-external-url.test.ts +78 -0
- package/src/core/actions-logs.test.ts +156 -0
- package/src/core/actions-logs.ts +186 -0
- package/src/core/agent-tool-selection.test.ts +41 -0
- package/src/core/agent-tool-selection.ts +42 -0
- package/src/core/agent-tools.test.ts +539 -0
- package/src/core/agent-tools.ts +1269 -0
- package/src/core/errors.ts +23 -0
- package/src/core/install.test.ts +238 -0
- package/src/core/install.ts +119 -0
- package/src/core/source-control.test.ts +327 -0
- package/src/core/source-control.ts +223 -0
- package/src/core/state.test.ts +43 -0
- package/src/core/state.ts +85 -0
- package/src/core/webhook.test.ts +522 -0
- package/src/core/webhook.ts +296 -0
- package/src/db/db.ts +18 -0
- package/src/db/installation-token-lock.test.ts +68 -0
- package/src/db/installation-token-lock.ts +39 -0
- package/src/db/installations.test.ts +67 -0
- package/src/db/installations.ts +108 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/installations.ts +46 -0
- package/src/index.ts +124 -0
- package/src/metrics/index.ts +1 -0
- package/src/metrics/instance.ts +82 -0
- package/src/presentation/dto/integrations.ts +8 -0
- package/src/presentation/e2eRoutes/create-connection.ts +70 -0
- package/src/presentation/e2eRoutes/index.test.ts +126 -0
- package/src/presentation/e2eRoutes/index.ts +14 -0
- package/src/presentation/routes/errors.ts +43 -0
- package/src/presentation/routes/install.test.ts +279 -0
- package/src/presentation/routes/install.ts +93 -0
- package/src/presentation/routes/webhooks.test.ts +560 -0
- package/src/presentation/routes/webhooks.ts +115 -0
- package/test/env.ts +16 -0
- package/test/factories/github-installation.ts +35 -0
- package/test/factories/index.ts +1 -0
- package/test/fixtures/github-webhook.ts +20 -0
- package/test/globalSetup.ts +14 -0
- package/test/index.ts +2 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import {createHmac, randomUUID} from 'node:crypto';
|
|
2
|
+
import {Webhooks} from '@octokit/webhooks';
|
|
3
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
4
|
+
import {closeApp, createApp} from '@shipfox/node-fastify';
|
|
5
|
+
import type {FastifyInstance} from 'fastify';
|
|
6
|
+
import {db} from '#db/db.js';
|
|
7
|
+
import {githubInstallations} from '#db/schema/installations.js';
|
|
8
|
+
import {githubInstallationFactory, githubPushPayload} from '#test/index.js';
|
|
9
|
+
import {createGithubWebhookRoutes} from './webhooks.js';
|
|
10
|
+
|
|
11
|
+
const WEBHOOK_SECRET = 'test-webhook-secret';
|
|
12
|
+
|
|
13
|
+
// The route persists through injected core functions (publishSourcePush,
|
|
14
|
+
// recordDeliveryOnly, getIntegrationConnectionById) that @shipfox/api-integration-core
|
|
15
|
+
// owns and wires in production. github only orchestrates them, so here we fake that
|
|
16
|
+
// interface with spies and assert the route's own behavior: signature/payload
|
|
17
|
+
// validation and which core function it calls with what arguments. The persistence
|
|
18
|
+
// itself (outbox + delivery rows, dedup) is tested in core, against core's own tables.
|
|
19
|
+
function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
|
|
20
|
+
return {
|
|
21
|
+
id: randomUUID(),
|
|
22
|
+
workspaceId: randomUUID(),
|
|
23
|
+
provider: 'github',
|
|
24
|
+
externalAccountId: '123',
|
|
25
|
+
slug: 'github_shipfox',
|
|
26
|
+
displayName: 'GitHub shipfox',
|
|
27
|
+
lifecycleStatus: 'active',
|
|
28
|
+
createdAt: new Date(),
|
|
29
|
+
updatedAt: new Date(),
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TestApp {
|
|
35
|
+
app: FastifyInstance;
|
|
36
|
+
publishIntegrationEventReceived: ReturnType<typeof vi.fn>;
|
|
37
|
+
publishSourcePush: ReturnType<typeof vi.fn>;
|
|
38
|
+
recordDeliveryOnly: ReturnType<typeof vi.fn>;
|
|
39
|
+
getIntegrationConnectionById: ReturnType<typeof vi.fn>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function createTestApp(options: {connection?: IntegrationConnection} = {}): Promise<TestApp> {
|
|
43
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
44
|
+
const publishSourcePush = vi.fn(() => Promise.resolve({published: true}));
|
|
45
|
+
const recordDeliveryOnly = vi.fn(() => Promise.resolve());
|
|
46
|
+
const getIntegrationConnectionById = vi.fn(() =>
|
|
47
|
+
Promise.resolve(options.connection ?? fakeConnection()),
|
|
48
|
+
);
|
|
49
|
+
const routes = createGithubWebhookRoutes({
|
|
50
|
+
coreDb: db,
|
|
51
|
+
publishIntegrationEventReceived,
|
|
52
|
+
publishSourcePush,
|
|
53
|
+
recordDeliveryOnly,
|
|
54
|
+
getIntegrationConnectionById,
|
|
55
|
+
});
|
|
56
|
+
const app = await createApp({routes: [routes], swagger: false});
|
|
57
|
+
await app.ready();
|
|
58
|
+
return {
|
|
59
|
+
app,
|
|
60
|
+
publishIntegrationEventReceived,
|
|
61
|
+
publishSourcePush,
|
|
62
|
+
recordDeliveryOnly,
|
|
63
|
+
getIntegrationConnectionById,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function seedInstallation(installationId: number, connectionId?: string): Promise<void> {
|
|
68
|
+
await githubInstallationFactory.create({
|
|
69
|
+
installationId: String(installationId),
|
|
70
|
+
...(connectionId !== undefined && {connectionId}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function signedHeaders(rawBody: string, event: string, deliveryId: string) {
|
|
75
|
+
const signature = `sha256=${createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex')}`;
|
|
76
|
+
return {
|
|
77
|
+
'content-type': 'application/json',
|
|
78
|
+
'x-hub-signature-256': signature,
|
|
79
|
+
'x-github-event': event,
|
|
80
|
+
'x-github-delivery': deliveryId,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('GitHub webhook route', () => {
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
await closeApp();
|
|
87
|
+
await db().delete(githubInstallations);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(async () => {
|
|
91
|
+
await closeApp();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('matches the @octokit/webhooks signature helper with raw HMAC', async () => {
|
|
95
|
+
const body = JSON.stringify({hello: 'world'});
|
|
96
|
+
|
|
97
|
+
const hooks = new Webhooks({secret: WEBHOOK_SECRET});
|
|
98
|
+
const expected = await hooks.sign(body);
|
|
99
|
+
const result = `sha256=${createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex')}`;
|
|
100
|
+
|
|
101
|
+
expect(result).toBe(expected);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('publishes a mapped event for a valid push from a known installation', async () => {
|
|
105
|
+
const installationId = 7777;
|
|
106
|
+
const connection = fakeConnection();
|
|
107
|
+
await seedInstallation(installationId, connection.id);
|
|
108
|
+
const {app, publishSourcePush, recordDeliveryOnly, getIntegrationConnectionById} =
|
|
109
|
+
await createTestApp({connection});
|
|
110
|
+
const deliveryId = randomUUID();
|
|
111
|
+
const rawPayload = githubPushPayload({
|
|
112
|
+
installationId,
|
|
113
|
+
repositoryId: 42,
|
|
114
|
+
ref: 'refs/heads/main',
|
|
115
|
+
defaultBranch: 'main',
|
|
116
|
+
sha: 'abc123',
|
|
117
|
+
});
|
|
118
|
+
const body = JSON.stringify(rawPayload);
|
|
119
|
+
|
|
120
|
+
const res = await app.inject({
|
|
121
|
+
method: 'POST',
|
|
122
|
+
url: '/webhooks/integrations/github',
|
|
123
|
+
headers: signedHeaders(body, 'push', deliveryId),
|
|
124
|
+
payload: body,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(res.statusCode).toBe(204);
|
|
128
|
+
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
129
|
+
expect(getIntegrationConnectionById).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(getIntegrationConnectionById).toHaveBeenCalledWith(
|
|
131
|
+
connection.id,
|
|
132
|
+
expect.objectContaining({tx: expect.anything()}),
|
|
133
|
+
);
|
|
134
|
+
expect(publishSourcePush).toHaveBeenCalledTimes(1);
|
|
135
|
+
const call = publishSourcePush.mock.calls[0]?.[0];
|
|
136
|
+
expect(call.tx).toBeDefined();
|
|
137
|
+
expect(call).toMatchObject({
|
|
138
|
+
provider: 'github',
|
|
139
|
+
deliveryId,
|
|
140
|
+
workspaceId: connection.workspaceId,
|
|
141
|
+
connectionId: connection.id,
|
|
142
|
+
connectionName: connection.displayName,
|
|
143
|
+
rawPayload,
|
|
144
|
+
push: {
|
|
145
|
+
externalRepositoryId: 'github:42',
|
|
146
|
+
ref: 'main',
|
|
147
|
+
headCommitSha: 'abc123',
|
|
148
|
+
isDefaultBranch: true,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('publishes with isDefaultBranch=false when ref is not the default branch', async () => {
|
|
154
|
+
const installationId = 7780;
|
|
155
|
+
await seedInstallation(installationId);
|
|
156
|
+
const {app, publishSourcePush} = await createTestApp();
|
|
157
|
+
const deliveryId = randomUUID();
|
|
158
|
+
const body = JSON.stringify(
|
|
159
|
+
githubPushPayload({
|
|
160
|
+
installationId,
|
|
161
|
+
repositoryId: 200,
|
|
162
|
+
ref: 'refs/heads/feature/x',
|
|
163
|
+
defaultBranch: 'main',
|
|
164
|
+
sha: 'feat1',
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const res = await app.inject({
|
|
169
|
+
method: 'POST',
|
|
170
|
+
url: '/webhooks/integrations/github',
|
|
171
|
+
headers: signedHeaders(body, 'push', deliveryId),
|
|
172
|
+
payload: body,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(res.statusCode).toBe(204);
|
|
176
|
+
expect(publishSourcePush).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(publishSourcePush.mock.calls[0]?.[0].push).toMatchObject({
|
|
178
|
+
ref: 'feature/x',
|
|
179
|
+
isDefaultBranch: false,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('publishes a generic envelope for a branch deletion without publishing a typed push', async () => {
|
|
184
|
+
const installationId = 7783;
|
|
185
|
+
const connection = fakeConnection();
|
|
186
|
+
await seedInstallation(installationId, connection.id);
|
|
187
|
+
const {app, publishIntegrationEventReceived, publishSourcePush, recordDeliveryOnly} =
|
|
188
|
+
await createTestApp({connection});
|
|
189
|
+
const deliveryId = randomUUID();
|
|
190
|
+
const rawPayload = githubPushPayload({
|
|
191
|
+
installationId,
|
|
192
|
+
repositoryId: 42,
|
|
193
|
+
ref: 'refs/heads/main',
|
|
194
|
+
defaultBranch: 'main',
|
|
195
|
+
sha: '0000000000000000000000000000000000000000',
|
|
196
|
+
});
|
|
197
|
+
const body = JSON.stringify(rawPayload);
|
|
198
|
+
|
|
199
|
+
const res = await app.inject({
|
|
200
|
+
method: 'POST',
|
|
201
|
+
url: '/webhooks/integrations/github',
|
|
202
|
+
headers: signedHeaders(body, 'push', deliveryId),
|
|
203
|
+
payload: body,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(res.statusCode).toBe(204);
|
|
207
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
208
|
+
expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
|
|
209
|
+
expect(publishIntegrationEventReceived.mock.calls[0]?.[0]).toMatchObject({
|
|
210
|
+
event: {
|
|
211
|
+
source: connection.slug,
|
|
212
|
+
event: 'push',
|
|
213
|
+
workspaceId: connection.workspaceId,
|
|
214
|
+
connectionId: connection.id,
|
|
215
|
+
connectionName: connection.displayName,
|
|
216
|
+
deliveryId,
|
|
217
|
+
payload: rawPayload,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('records the delivery only for events without an installation id', async () => {
|
|
224
|
+
const {app, publishIntegrationEventReceived, publishSourcePush, recordDeliveryOnly} =
|
|
225
|
+
await createTestApp();
|
|
226
|
+
const deliveryId = randomUUID();
|
|
227
|
+
const body = JSON.stringify({zen: 'Practicality beats purity.'});
|
|
228
|
+
|
|
229
|
+
const res = await app.inject({
|
|
230
|
+
method: 'POST',
|
|
231
|
+
url: '/webhooks/integrations/github',
|
|
232
|
+
headers: signedHeaders(body, 'ping', deliveryId),
|
|
233
|
+
payload: body,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(res.statusCode).toBe(204);
|
|
237
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
238
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
239
|
+
expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
240
|
+
expect(recordDeliveryOnly.mock.calls[0]?.[0]).toMatchObject({provider: 'github', deliveryId});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('publishes a generic envelope for a non-push event with an action', async () => {
|
|
244
|
+
const installationId = 7784;
|
|
245
|
+
const connection = fakeConnection();
|
|
246
|
+
await seedInstallation(installationId, connection.id);
|
|
247
|
+
const {app, publishIntegrationEventReceived, publishSourcePush, recordDeliveryOnly} =
|
|
248
|
+
await createTestApp({connection});
|
|
249
|
+
const deliveryId = randomUUID();
|
|
250
|
+
const rawPayload = {
|
|
251
|
+
action: 'opened',
|
|
252
|
+
installation: {id: installationId},
|
|
253
|
+
pull_request: {number: 17},
|
|
254
|
+
};
|
|
255
|
+
const body = JSON.stringify(rawPayload);
|
|
256
|
+
|
|
257
|
+
const res = await app.inject({
|
|
258
|
+
method: 'POST',
|
|
259
|
+
url: '/webhooks/integrations/github',
|
|
260
|
+
headers: signedHeaders(body, 'pull_request', deliveryId),
|
|
261
|
+
payload: body,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(res.statusCode).toBe(204);
|
|
265
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
266
|
+
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
267
|
+
expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
|
|
268
|
+
expect(publishIntegrationEventReceived.mock.calls[0]?.[0]).toMatchObject({
|
|
269
|
+
event: {
|
|
270
|
+
source: connection.slug,
|
|
271
|
+
event: 'pull_request.opened',
|
|
272
|
+
workspaceId: connection.workspaceId,
|
|
273
|
+
connectionId: connection.id,
|
|
274
|
+
connectionName: connection.displayName,
|
|
275
|
+
deliveryId,
|
|
276
|
+
payload: rawPayload,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('publishes a generic envelope for a non-push event without an action', async () => {
|
|
282
|
+
const installationId = 7785;
|
|
283
|
+
const connection = fakeConnection();
|
|
284
|
+
await seedInstallation(installationId, connection.id);
|
|
285
|
+
const {app, publishIntegrationEventReceived, publishSourcePush, recordDeliveryOnly} =
|
|
286
|
+
await createTestApp({connection});
|
|
287
|
+
const deliveryId = randomUUID();
|
|
288
|
+
const rawPayload = {
|
|
289
|
+
installation: {id: installationId},
|
|
290
|
+
forkee: {full_name: 'shipfox/forked'},
|
|
291
|
+
};
|
|
292
|
+
const body = JSON.stringify(rawPayload);
|
|
293
|
+
|
|
294
|
+
const res = await app.inject({
|
|
295
|
+
method: 'POST',
|
|
296
|
+
url: '/webhooks/integrations/github',
|
|
297
|
+
headers: signedHeaders(body, 'fork', deliveryId),
|
|
298
|
+
payload: body,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(res.statusCode).toBe(204);
|
|
302
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
303
|
+
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
304
|
+
expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
|
|
305
|
+
expect(publishIntegrationEventReceived.mock.calls[0]?.[0]).toMatchObject({
|
|
306
|
+
event: {
|
|
307
|
+
source: connection.slug,
|
|
308
|
+
event: 'fork',
|
|
309
|
+
workspaceId: connection.workspaceId,
|
|
310
|
+
connectionId: connection.id,
|
|
311
|
+
connectionName: connection.displayName,
|
|
312
|
+
deliveryId,
|
|
313
|
+
payload: rawPayload,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('records the delivery only for pushes from unknown installations', async () => {
|
|
319
|
+
const {app, publishSourcePush, recordDeliveryOnly, getIntegrationConnectionById} =
|
|
320
|
+
await createTestApp();
|
|
321
|
+
const deliveryId = randomUUID();
|
|
322
|
+
const body = JSON.stringify(
|
|
323
|
+
githubPushPayload({
|
|
324
|
+
installationId: 999999,
|
|
325
|
+
repositoryId: 300,
|
|
326
|
+
ref: 'refs/heads/main',
|
|
327
|
+
defaultBranch: 'main',
|
|
328
|
+
sha: 'orphan',
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const res = await app.inject({
|
|
333
|
+
method: 'POST',
|
|
334
|
+
url: '/webhooks/integrations/github',
|
|
335
|
+
headers: signedHeaders(body, 'push', deliveryId),
|
|
336
|
+
payload: body,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(res.statusCode).toBe(204);
|
|
340
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
341
|
+
expect(getIntegrationConnectionById).not.toHaveBeenCalled();
|
|
342
|
+
expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
343
|
+
expect(recordDeliveryOnly.mock.calls[0]?.[0]).toMatchObject({provider: 'github', deliveryId});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('records the delivery only when the installation has no connection', async () => {
|
|
347
|
+
const installationId = 7781;
|
|
348
|
+
const {app, publishSourcePush, recordDeliveryOnly, getIntegrationConnectionById} =
|
|
349
|
+
await createTestApp();
|
|
350
|
+
getIntegrationConnectionById.mockResolvedValue(undefined);
|
|
351
|
+
await seedInstallation(installationId);
|
|
352
|
+
const deliveryId = randomUUID();
|
|
353
|
+
const body = JSON.stringify(
|
|
354
|
+
githubPushPayload({
|
|
355
|
+
installationId,
|
|
356
|
+
repositoryId: 301,
|
|
357
|
+
ref: 'refs/heads/main',
|
|
358
|
+
defaultBranch: 'main',
|
|
359
|
+
sha: 'dangling',
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const res = await app.inject({
|
|
364
|
+
method: 'POST',
|
|
365
|
+
url: '/webhooks/integrations/github',
|
|
366
|
+
headers: signedHeaders(body, 'push', deliveryId),
|
|
367
|
+
payload: body,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(res.statusCode).toBe(204);
|
|
371
|
+
expect(getIntegrationConnectionById).toHaveBeenCalledTimes(1);
|
|
372
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
373
|
+
expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
374
|
+
expect(recordDeliveryOnly.mock.calls[0]?.[0]).toMatchObject({provider: 'github', deliveryId});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it.each([
|
|
378
|
+
'disabled',
|
|
379
|
+
'error',
|
|
380
|
+
] as const)('records the delivery only when the connection is %s', async (lifecycleStatus) => {
|
|
381
|
+
const installationId = 7782;
|
|
382
|
+
const connection = fakeConnection({lifecycleStatus});
|
|
383
|
+
const {app, publishSourcePush, recordDeliveryOnly, getIntegrationConnectionById} =
|
|
384
|
+
await createTestApp({connection});
|
|
385
|
+
await seedInstallation(installationId, connection.id);
|
|
386
|
+
const deliveryId = randomUUID();
|
|
387
|
+
const body = JSON.stringify(
|
|
388
|
+
githubPushPayload({
|
|
389
|
+
installationId,
|
|
390
|
+
repositoryId: 302,
|
|
391
|
+
ref: 'refs/heads/main',
|
|
392
|
+
defaultBranch: 'main',
|
|
393
|
+
sha: 'inactive',
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const res = await app.inject({
|
|
398
|
+
method: 'POST',
|
|
399
|
+
url: '/webhooks/integrations/github',
|
|
400
|
+
headers: signedHeaders(body, 'push', deliveryId),
|
|
401
|
+
payload: body,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
expect(res.statusCode).toBe(204);
|
|
405
|
+
expect(getIntegrationConnectionById).toHaveBeenCalledTimes(1);
|
|
406
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
407
|
+
expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
408
|
+
expect(recordDeliveryOnly.mock.calls[0]?.[0]).toMatchObject({provider: 'github', deliveryId});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('records the delivery only when a push payload has no installation id', async () => {
|
|
412
|
+
const {app, publishSourcePush, recordDeliveryOnly} = await createTestApp();
|
|
413
|
+
const deliveryId = randomUUID();
|
|
414
|
+
const body = JSON.stringify({
|
|
415
|
+
ref: 'refs/heads/main',
|
|
416
|
+
after: 'abc',
|
|
417
|
+
repository: {id: 42, default_branch: 'main'},
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const res = await app.inject({
|
|
421
|
+
method: 'POST',
|
|
422
|
+
url: '/webhooks/integrations/github',
|
|
423
|
+
headers: signedHeaders(body, 'push', deliveryId),
|
|
424
|
+
payload: body,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
expect(res.statusCode).toBe(204);
|
|
428
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
429
|
+
expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
430
|
+
expect(recordDeliveryOnly.mock.calls[0]?.[0]).toMatchObject({provider: 'github', deliveryId});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('records the delivery only when a malformed push payload has no installation id', async () => {
|
|
434
|
+
const {app, publishIntegrationEventReceived, publishSourcePush, recordDeliveryOnly} =
|
|
435
|
+
await createTestApp();
|
|
436
|
+
const deliveryId = randomUUID();
|
|
437
|
+
const body = JSON.stringify({ref: 'refs/heads/main'});
|
|
438
|
+
|
|
439
|
+
const res = await app.inject({
|
|
440
|
+
method: 'POST',
|
|
441
|
+
url: '/webhooks/integrations/github',
|
|
442
|
+
headers: signedHeaders(body, 'push', deliveryId),
|
|
443
|
+
payload: body,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(res.statusCode).toBe(204);
|
|
447
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
448
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
449
|
+
expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
|
|
450
|
+
expect(recordDeliveryOnly.mock.calls[0]?.[0]).toMatchObject({provider: 'github', deliveryId});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('rejects an invalid signature with 401 and persists nothing', async () => {
|
|
454
|
+
const {app, publishSourcePush, recordDeliveryOnly} = await createTestApp();
|
|
455
|
+
const body = JSON.stringify(
|
|
456
|
+
githubPushPayload({
|
|
457
|
+
installationId: 7779,
|
|
458
|
+
repositoryId: 100,
|
|
459
|
+
ref: 'refs/heads/main',
|
|
460
|
+
defaultBranch: 'main',
|
|
461
|
+
sha: 'zzz',
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const res = await app.inject({
|
|
466
|
+
method: 'POST',
|
|
467
|
+
url: '/webhooks/integrations/github',
|
|
468
|
+
headers: {
|
|
469
|
+
'content-type': 'application/json',
|
|
470
|
+
'x-hub-signature-256': 'sha256=deadbeef',
|
|
471
|
+
'x-github-event': 'push',
|
|
472
|
+
'x-github-delivery': randomUUID(),
|
|
473
|
+
},
|
|
474
|
+
payload: body,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
expect(res.statusCode).toBe(401);
|
|
478
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
479
|
+
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('rejects malformed signature headers (verify throws) with 401', async () => {
|
|
483
|
+
const {app, publishSourcePush, recordDeliveryOnly} = await createTestApp();
|
|
484
|
+
const body = JSON.stringify(
|
|
485
|
+
githubPushPayload({
|
|
486
|
+
installationId: 7782,
|
|
487
|
+
repositoryId: 101,
|
|
488
|
+
ref: 'refs/heads/main',
|
|
489
|
+
defaultBranch: 'main',
|
|
490
|
+
sha: 'aaa',
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const res = await app.inject({
|
|
495
|
+
method: 'POST',
|
|
496
|
+
url: '/webhooks/integrations/github',
|
|
497
|
+
headers: {
|
|
498
|
+
'content-type': 'application/json',
|
|
499
|
+
'x-hub-signature-256': 'not-a-real-signature-format',
|
|
500
|
+
'x-github-event': 'push',
|
|
501
|
+
'x-github-delivery': randomUUID(),
|
|
502
|
+
},
|
|
503
|
+
payload: body,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(res.statusCode).toBe(401);
|
|
507
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
508
|
+
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('rejects malformed JSON after a valid signature with 400', async () => {
|
|
512
|
+
const {app, publishSourcePush, recordDeliveryOnly} = await createTestApp();
|
|
513
|
+
const body = '{not valid json';
|
|
514
|
+
|
|
515
|
+
const res = await app.inject({
|
|
516
|
+
method: 'POST',
|
|
517
|
+
url: '/webhooks/integrations/github',
|
|
518
|
+
headers: signedHeaders(body, 'push', randomUUID()),
|
|
519
|
+
payload: body,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
expect(res.statusCode).toBe(400);
|
|
523
|
+
expect(res.json()).toMatchObject({error: 'malformed JSON'});
|
|
524
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
525
|
+
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('publishes a generic envelope when a routable push payload fails schema validation', async () => {
|
|
529
|
+
const installationId = 7786;
|
|
530
|
+
const connection = fakeConnection();
|
|
531
|
+
await seedInstallation(installationId, connection.id);
|
|
532
|
+
const {app, publishIntegrationEventReceived, publishSourcePush, recordDeliveryOnly} =
|
|
533
|
+
await createTestApp({connection});
|
|
534
|
+
const deliveryId = randomUUID();
|
|
535
|
+
const rawPayload = {ref: 'refs/heads/main', installation: {id: installationId}};
|
|
536
|
+
const body = JSON.stringify(rawPayload);
|
|
537
|
+
|
|
538
|
+
const res = await app.inject({
|
|
539
|
+
method: 'POST',
|
|
540
|
+
url: '/webhooks/integrations/github',
|
|
541
|
+
headers: signedHeaders(body, 'push', deliveryId),
|
|
542
|
+
payload: body,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
expect(res.statusCode).toBe(204);
|
|
546
|
+
expect(publishSourcePush).not.toHaveBeenCalled();
|
|
547
|
+
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
548
|
+
expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
|
|
549
|
+
expect(publishIntegrationEventReceived.mock.calls[0]?.[0]).toMatchObject({
|
|
550
|
+
event: {
|
|
551
|
+
source: connection.slug,
|
|
552
|
+
event: 'push',
|
|
553
|
+
workspaceId: connection.workspaceId,
|
|
554
|
+
connectionId: connection.id,
|
|
555
|
+
deliveryId,
|
|
556
|
+
payload: rawPayload,
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {Webhooks} from '@octokit/webhooks';
|
|
3
|
+
import type {
|
|
4
|
+
GetIntegrationConnectionByIdFn,
|
|
5
|
+
PublishIntegrationEventReceivedFn,
|
|
6
|
+
PublishSourcePushFn,
|
|
7
|
+
RecordDeliveryOnlyFn,
|
|
8
|
+
} from '@shipfox/api-integration-core-dto';
|
|
9
|
+
import {
|
|
10
|
+
defineRoute,
|
|
11
|
+
type RouteGroup,
|
|
12
|
+
rawBodyPlugin,
|
|
13
|
+
WEBHOOK_BODY_LIMIT,
|
|
14
|
+
} from '@shipfox/node-fastify';
|
|
15
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
16
|
+
import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
|
|
17
|
+
import {config} from '#config.js';
|
|
18
|
+
import {handleGithubEvent} from '#core/webhook.js';
|
|
19
|
+
|
|
20
|
+
const SIGNATURE_HEADER = 'x-hub-signature-256';
|
|
21
|
+
const EVENT_HEADER = 'x-github-event';
|
|
22
|
+
const DELIVERY_HEADER = 'x-github-delivery';
|
|
23
|
+
|
|
24
|
+
export interface CreateGithubWebhookRoutesOptions {
|
|
25
|
+
coreDb: () => NodePgDatabase<Record<string, unknown>>;
|
|
26
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
27
|
+
publishSourcePush: PublishSourcePushFn;
|
|
28
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
29
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
30
|
+
deleteInstallationTokenSecret?:
|
|
31
|
+
| ((params: {workspaceId: string; installationId: number}) => Promise<unknown>)
|
|
32
|
+
| undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createGithubWebhookRoutes(options: CreateGithubWebhookRoutesOptions): RouteGroup {
|
|
36
|
+
const webhooks = new Webhooks({secret: config.GITHUB_APP_WEBHOOK_SECRET});
|
|
37
|
+
|
|
38
|
+
const pushRoute = defineRoute({
|
|
39
|
+
method: 'POST',
|
|
40
|
+
path: '/',
|
|
41
|
+
auth: [],
|
|
42
|
+
description: 'GitHub App webhook receiver.',
|
|
43
|
+
options: {bodyLimit: WEBHOOK_BODY_LIMIT},
|
|
44
|
+
handler: async (request, reply) => {
|
|
45
|
+
const deliveryId = request.headers[DELIVERY_HEADER];
|
|
46
|
+
const signature = request.headers[SIGNATURE_HEADER];
|
|
47
|
+
const event = request.headers[EVENT_HEADER];
|
|
48
|
+
|
|
49
|
+
if (typeof deliveryId !== 'string' || !deliveryId) {
|
|
50
|
+
reply.code(400);
|
|
51
|
+
return {error: 'missing X-GitHub-Delivery header'};
|
|
52
|
+
}
|
|
53
|
+
if (typeof signature !== 'string' || !signature) {
|
|
54
|
+
reply.code(401);
|
|
55
|
+
return {error: 'missing X-Hub-Signature-256 header'};
|
|
56
|
+
}
|
|
57
|
+
if (typeof event !== 'string' || !event) {
|
|
58
|
+
reply.code(400);
|
|
59
|
+
return {error: 'missing X-GitHub-Event header'};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const body = request.body;
|
|
63
|
+
if (!Buffer.isBuffer(body)) {
|
|
64
|
+
reply.code(400);
|
|
65
|
+
return {error: 'expected raw JSON body'};
|
|
66
|
+
}
|
|
67
|
+
const rawBody = body.toString('utf8');
|
|
68
|
+
|
|
69
|
+
let verified: boolean;
|
|
70
|
+
try {
|
|
71
|
+
verified = await webhooks.verify(rawBody, signature);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
logger().warn({deliveryId, err: error}, 'github webhook signature verification threw');
|
|
74
|
+
verified = false;
|
|
75
|
+
}
|
|
76
|
+
if (!verified) {
|
|
77
|
+
reply.code(401);
|
|
78
|
+
return {error: 'invalid signature'};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let parsedJson: unknown;
|
|
82
|
+
try {
|
|
83
|
+
parsedJson = JSON.parse(rawBody);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
logger().warn({deliveryId, err: error}, 'github webhook payload JSON parse failed');
|
|
86
|
+
reply.code(400);
|
|
87
|
+
return {error: 'malformed JSON'};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await options.coreDb().transaction(async (tx) => {
|
|
91
|
+
await handleGithubEvent({
|
|
92
|
+
tx,
|
|
93
|
+
deliveryId,
|
|
94
|
+
event,
|
|
95
|
+
payload: parsedJson,
|
|
96
|
+
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
97
|
+
publishSourcePush: options.publishSourcePush,
|
|
98
|
+
recordDeliveryOnly: options.recordDeliveryOnly,
|
|
99
|
+
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
100
|
+
deleteInstallationTokenSecret: options.deleteInstallationTokenSecret,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
reply.code(204);
|
|
105
|
+
return null;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
prefix: '/webhooks/integrations/github',
|
|
111
|
+
auth: [],
|
|
112
|
+
plugins: [rawBodyPlugin],
|
|
113
|
+
routes: [pushRoute],
|
|
114
|
+
};
|
|
115
|
+
}
|
package/test/env.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
process.env.POSTGRES_HOST ??= 'localhost';
|
|
2
|
+
process.env.POSTGRES_PORT ??= '5432';
|
|
3
|
+
process.env.POSTGRES_USERNAME ??= 'shipfox';
|
|
4
|
+
process.env.POSTGRES_PASSWORD ??= 'password';
|
|
5
|
+
process.env.POSTGRES_DATABASE = 'api_test';
|
|
6
|
+
process.env.POSTGRES_MAX_CONNECTIONS ??= '5';
|
|
7
|
+
process.env.TZ = 'UTC';
|
|
8
|
+
process.env.GITHUB_APP_ID = '1';
|
|
9
|
+
process.env.GITHUB_APP_PRIVATE_KEY = 'test-private-key';
|
|
10
|
+
process.env.GITHUB_APP_CLIENT_ID = 'test-client-id';
|
|
11
|
+
process.env.GITHUB_APP_CLIENT_SECRET = 'test-client-secret';
|
|
12
|
+
process.env.GITHUB_APP_WEBHOOK_SECRET = 'test-webhook-secret';
|
|
13
|
+
process.env.GITHUB_APP_SLUG = 'shipfox-test';
|
|
14
|
+
process.env.GITHUB_APP_USERNAME = 'shipfox-test';
|
|
15
|
+
process.env.GITHUB_INSTALL_STATE_SECRET = 'test-install-state-secret';
|
|
16
|
+
process.env.GITHUB_API_BASE_URL = 'https://api.github.com';
|