@flink-app/github-app-plugin 0.12.1-alpha.38
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/CHANGELOG.md +209 -0
- package/LICENSE +21 -0
- package/README.md +667 -0
- package/SECURITY.md +498 -0
- package/dist/GitHubAppInternalContext.d.ts +44 -0
- package/dist/GitHubAppInternalContext.js +2 -0
- package/dist/GitHubAppPlugin.d.ts +45 -0
- package/dist/GitHubAppPlugin.js +367 -0
- package/dist/GitHubAppPluginContext.d.ts +242 -0
- package/dist/GitHubAppPluginContext.js +2 -0
- package/dist/GitHubAppPluginOptions.d.ts +369 -0
- package/dist/GitHubAppPluginOptions.js +2 -0
- package/dist/handlers/InitiateInstallation.d.ts +32 -0
- package/dist/handlers/InitiateInstallation.js +66 -0
- package/dist/handlers/InstallationCallback.d.ts +42 -0
- package/dist/handlers/InstallationCallback.js +248 -0
- package/dist/handlers/UninstallHandler.d.ts +37 -0
- package/dist/handlers/UninstallHandler.js +153 -0
- package/dist/handlers/WebhookHandler.d.ts +54 -0
- package/dist/handlers/WebhookHandler.js +157 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +23 -0
- package/dist/repos/GitHubAppSessionRepo.d.ts +24 -0
- package/dist/repos/GitHubAppSessionRepo.js +32 -0
- package/dist/repos/GitHubInstallationRepo.d.ts +53 -0
- package/dist/repos/GitHubInstallationRepo.js +83 -0
- package/dist/repos/GitHubWebhookEventRepo.d.ts +29 -0
- package/dist/repos/GitHubWebhookEventRepo.js +42 -0
- package/dist/schemas/GitHubAppSession.d.ts +13 -0
- package/dist/schemas/GitHubAppSession.js +2 -0
- package/dist/schemas/GitHubInstallation.d.ts +28 -0
- package/dist/schemas/GitHubInstallation.js +2 -0
- package/dist/schemas/InstallationCallbackRequest.d.ts +10 -0
- package/dist/schemas/InstallationCallbackRequest.js +2 -0
- package/dist/schemas/WebhookEvent.d.ts +16 -0
- package/dist/schemas/WebhookEvent.js +2 -0
- package/dist/schemas/WebhookPayload.d.ts +35 -0
- package/dist/schemas/WebhookPayload.js +2 -0
- package/dist/services/GitHubAPIClient.d.ts +143 -0
- package/dist/services/GitHubAPIClient.js +167 -0
- package/dist/services/GitHubAuthService.d.ts +85 -0
- package/dist/services/GitHubAuthService.js +160 -0
- package/dist/services/WebhookValidator.d.ts +93 -0
- package/dist/services/WebhookValidator.js +123 -0
- package/dist/utils/error-utils.d.ts +67 -0
- package/dist/utils/error-utils.js +121 -0
- package/dist/utils/jwt-utils.d.ts +35 -0
- package/dist/utils/jwt-utils.js +67 -0
- package/dist/utils/state-utils.d.ts +38 -0
- package/dist/utils/state-utils.js +74 -0
- package/dist/utils/token-cache-utils.d.ts +47 -0
- package/dist/utils/token-cache-utils.js +74 -0
- package/dist/utils/webhook-signature-utils.d.ts +22 -0
- package/dist/utils/webhook-signature-utils.js +57 -0
- package/examples/basic-installation.ts +246 -0
- package/examples/create-issue.ts +392 -0
- package/examples/error-handling.ts +396 -0
- package/examples/multi-event-webhook.ts +367 -0
- package/examples/organization-installation.ts +316 -0
- package/examples/repository-access.ts +480 -0
- package/examples/webhook-handling.ts +343 -0
- package/examples/with-jwt-auth.ts +319 -0
- package/package.json +41 -0
- package/spec/core-utilities.spec.ts +243 -0
- package/spec/handlers.spec.ts +216 -0
- package/spec/helpers/reporter.ts +41 -0
- package/spec/integration-and-security.spec.ts +483 -0
- package/spec/plugin-core.spec.ts +258 -0
- package/spec/project-setup.spec.ts +56 -0
- package/spec/repos-and-schemas.spec.ts +288 -0
- package/spec/services.spec.ts +108 -0
- package/spec/support/jasmine.json +7 -0
- package/src/GitHubAppPlugin.ts +411 -0
- package/src/GitHubAppPluginContext.ts +254 -0
- package/src/GitHubAppPluginOptions.ts +412 -0
- package/src/handlers/InstallationCallback.ts +292 -0
- package/src/handlers/WebhookHandler.ts +179 -0
- package/src/index.ts +29 -0
- package/src/repos/GitHubAppSessionRepo.ts +36 -0
- package/src/repos/GitHubInstallationRepo.ts +95 -0
- package/src/repos/GitHubWebhookEventRepo.ts +48 -0
- package/src/schemas/GitHubAppSession.ts +13 -0
- package/src/schemas/GitHubInstallation.ts +28 -0
- package/src/schemas/InstallationCallbackRequest.ts +10 -0
- package/src/schemas/WebhookEvent.ts +16 -0
- package/src/schemas/WebhookPayload.ts +35 -0
- package/src/services/GitHubAPIClient.ts +244 -0
- package/src/services/GitHubAuthService.ts +188 -0
- package/src/services/WebhookValidator.ts +159 -0
- package/src/utils/error-utils.ts +148 -0
- package/src/utils/jwt-utils.ts +64 -0
- package/src/utils/state-utils.ts +72 -0
- package/src/utils/token-cache-utils.ts +89 -0
- package/src/utils/webhook-signature-utils.ts +57 -0
- package/tsconfig.dist.json +4 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Core Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for plugin factory function and initialization.
|
|
5
|
+
* Focused tests covering critical functionality:
|
|
6
|
+
* - Plugin factory validates required options
|
|
7
|
+
* - Plugin initializes repositories correctly
|
|
8
|
+
* - Plugin registers handlers conditionally
|
|
9
|
+
* - Plugin exposes context methods
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { FlinkApp } from "@flink-app/flink";
|
|
13
|
+
import { Db } from "mongodb";
|
|
14
|
+
import { githubAppPlugin } from "../src/GitHubAppPlugin";
|
|
15
|
+
import { GitHubAppPluginOptions } from "../src/GitHubAppPluginOptions";
|
|
16
|
+
|
|
17
|
+
describe("Plugin Core", () => {
|
|
18
|
+
let mockApp: any;
|
|
19
|
+
let mockDb: any;
|
|
20
|
+
let mockCollection: any;
|
|
21
|
+
|
|
22
|
+
// Valid test RSA private key (PKCS#1 format, Base64 encoded)
|
|
23
|
+
const testPrivateKeyBase64 =
|
|
24
|
+
"LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb2dJQkFBS0NBUUVBcTZ1Ykh3d1BET1FNMitHaHJZUlN3OEs2UytKcXAzem5YRGFITU1JZzg5WTZ1MUcyClZibm9TNjA4RjYwNnVSWGt1WUx2MUh6aHRLczJaOU9MaGh0YW1aV05hZVVEVlZPY2hzQ21MbDVNaCt6MTFLd2gKSG8wcU1NOUxxTHNOL3RYZDJZMDE0TkVhQ2hEZjdEMjUxLzFEWFh4WkNJTGk2NHROYm03SXh3U3J3bDlzeFBJRApPQzZrZU50UEEybGEydEFmNlc0OUJ2MVlxcDBLYnhObnBkeG90SkZsMEZBRjRYeUJSeWF6Mi9VWTN0S1BybjcyCnF6Ti9QMDN4RzhkMlZPU3hpZ3grOWJ0cndFazhRSE5qUGxiR0s1dzI3NU9vMHF2NFhjVTIvUzNsMS9JLzVxeG8KQW1haEJJN0ZoWThxQWRWd0hheGN3NGRkR1VHMzhmSXl0RlhKRlFJREFRQUJBb0lCQUFrQngzRkJFamNVYmdKSgpXOUM5VFJSZFRxWDFtcS92OHptWTJNMzdtWHdCcFBJNERzOS9vZ3I2YTFrNHF3aVQ5L3l0dklTVENzcU9ZeHZlCmN3Y1Z2MUtva0pOYVF5c0NhSWQvYXhpcXRPdzZ5QWtoQU5uWUFUc3ZYU0pjc2hiSlJNc0p5Q1prQWpBK0EybWoKTVhGK0piOHRhNFJ4VFpPYkt2UmMxcWJ1ZlU2RTJpQXY1aDNHcGhjL2RrSTJhRkJyQ29vdndhbjIzUlo0aXVuQgpKZGpxQXZaeFFremlrNy9OeEZUZjJMYUk4L2VqaGhGNlU4aXBuYU84VUNhS2FsMHUwSG9IVmd1eFR6UTRFSjhmCmh0QUdKYi8wRyswZ2xNK3ZHa3hxdHhDUXNhMnBKdmlHaTIwOWFSS3NIYnlZVG05amFnRk9SR2hIZERzY05DWUoKMFdQK20rOENnWUVBNHdVU0dHY0ZpZXJoZEdTTVovTFF6OTlBWWpaSVczR09YRUpEcG1vYmhPMzZXVEluM1hESwpPVDdpdHBWQlptSWxHQXROdTJ5clZEeFRqM3BrQVBIWDRhckwvUUx1Y3hDckZldFROODhubEJ5N2lIUkh1UjA0CkprU0RCL0EwVW9STXlOUzhqTG9JblErUS9VZUVwY1k4ejN6cUdFaVhXMUJLZlVFRjVXZmVEbWNDZ1lFQXdaVzcKeVRCNXRQbExGR0d3Yk5VMWphc1ZRb0pZWGo2MHR3TTU0SktjZElNVmFYS1RLOSt6cGcydjc5aTVzTHNIOG50TApXQ0hOSDlyandiaHRHMmZOT0FweWtnY1pjTHg2VUR3R1RYM3R1NC9NMld3YnFNL0crOTAvL1NYSSttQzVvYTRZCnp1Y1NZeGxyeWxrMlp6ZzRTN005VzBKQTNDNjFQeERSaXNXakJ5TUNnWUFseVFaR0FYK3VnT1dkbGM2NHpuVnEKNCtHM2R3bDhEdDUvQkpoMTdscytPTTNlWXJhMzZMbi81VE9lNkNER2hiZGUxU0xPK3p0WS9lRjZsQWhwRDllNgp1ODdRQWRqbVZmUGo1aE1ueXRidmxBaXlvWWYraTVwNDVCWmJEK1BsaUJldnBaanNZMXBqcWQrY0NIZFBrRHMyCjNiZW82d3dtS3FyN1JnTlJONFNDS1FLQmdCOGk0MEpYM3F1Q0VWWms1QWlOUG9EYnpKNlc4bm11SWtqeFp1UzkKRUJjWllsOUVnM0ZpR0xZVHE0R3JYU3FVMnBGZ3pWeU9penlkYTFha1FFQlJNTXZidWxQTWVvWU1lcXZmQzdCNQpHYnk2UTF1UkxOMjVGYXM3Q2VqQXBCUEpiUElaVzNvajVtdzBFWWRKVkJ2RUNpSDY0VnFGVElOZHE5OUo2RG9tCjBiTDdBb0dBRW9hUk9tUVpVMHJ6UUhXNUkxWnRIM0hKZDk1YTBPSFNlTVdNYllaeE9ST1ZMNWdQckhQSFRzU2YKbTNpWmNLaFgvdXRleEdjWm1zN0M4U3M5NWp4TzRiV3pBcDYxVU1aU3dVYStYNTRUNHkwNUxWRXRUOG5TdVRySwpxV1dMOGVDNXJGMktIYjJ3UFV3Vm1mUEc4SWlMS1FkUHR2b1ZzRGM2dU95NURSdkpzV0k9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t";
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
// Mock collection with all necessary MongoDB methods
|
|
28
|
+
mockCollection = {
|
|
29
|
+
createIndex: jasmine.createSpy("createIndex").and.returnValue(Promise.resolve("index_name")),
|
|
30
|
+
insertOne: jasmine.createSpy("insertOne").and.returnValue(Promise.resolve({ insertedId: "mock-id" })),
|
|
31
|
+
findOne: jasmine.createSpy("findOne").and.returnValue(Promise.resolve(null)),
|
|
32
|
+
updateOne: jasmine.createSpy("updateOne").and.returnValue(Promise.resolve({ modifiedCount: 1 })),
|
|
33
|
+
deleteOne: jasmine.createSpy("deleteOne").and.returnValue(Promise.resolve({ deletedCount: 1 })),
|
|
34
|
+
find: jasmine.createSpy("find").and.returnValue({
|
|
35
|
+
toArray: jasmine.createSpy("toArray").and.returnValue(Promise.resolve([])),
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Mock database
|
|
40
|
+
mockDb = {
|
|
41
|
+
collection: jasmine.createSpy("collection").and.returnValue(mockCollection),
|
|
42
|
+
} as unknown as Db;
|
|
43
|
+
|
|
44
|
+
// Mock FlinkApp with ctx
|
|
45
|
+
mockApp = {
|
|
46
|
+
ctx: {
|
|
47
|
+
repos: {},
|
|
48
|
+
},
|
|
49
|
+
addRepo: jasmine.createSpy("addRepo").and.callFake((name: string, repo: any) => {
|
|
50
|
+
mockApp.ctx.repos[name] = repo;
|
|
51
|
+
}),
|
|
52
|
+
addHandler: jasmine.createSpy("addHandler"),
|
|
53
|
+
} as unknown as FlinkApp<any>;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("Plugin Factory Validation", () => {
|
|
57
|
+
it("should throw error if appId is missing", () => {
|
|
58
|
+
const invalidOptions = {
|
|
59
|
+
privateKey: testPrivateKeyBase64,
|
|
60
|
+
webhookSecret: "test-secret",
|
|
61
|
+
clientId: "test-client-id",
|
|
62
|
+
clientSecret: "test-client-secret",
|
|
63
|
+
onInstallationSuccess: async () => ({ userId: "test", redirectUrl: "/" }),
|
|
64
|
+
} as any;
|
|
65
|
+
|
|
66
|
+
expect(() => githubAppPlugin(invalidOptions)).toThrowError(/appId is required/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should throw error if privateKey is missing", () => {
|
|
70
|
+
const invalidOptions = {
|
|
71
|
+
appId: "12345",
|
|
72
|
+
webhookSecret: "test-secret",
|
|
73
|
+
clientId: "test-client-id",
|
|
74
|
+
clientSecret: "test-client-secret",
|
|
75
|
+
onInstallationSuccess: async () => ({ userId: "test", redirectUrl: "/" }),
|
|
76
|
+
} as any;
|
|
77
|
+
|
|
78
|
+
expect(() => githubAppPlugin(invalidOptions)).toThrowError(/privateKey is required/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should throw error if onInstallationSuccess callback is missing", () => {
|
|
82
|
+
const invalidOptions = {
|
|
83
|
+
appId: "12345",
|
|
84
|
+
privateKey: testPrivateKeyBase64,
|
|
85
|
+
webhookSecret: "test-secret",
|
|
86
|
+
clientId: "test-client-id",
|
|
87
|
+
clientSecret: "test-client-secret",
|
|
88
|
+
} as any;
|
|
89
|
+
|
|
90
|
+
expect(() => githubAppPlugin(invalidOptions)).toThrowError(/onInstallationSuccess callback is required/);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("Plugin Initialization", () => {
|
|
95
|
+
let validOptions: GitHubAppPluginOptions;
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
validOptions = {
|
|
99
|
+
appId: "12345",
|
|
100
|
+
appSlug: "test-app",
|
|
101
|
+
privateKey: testPrivateKeyBase64,
|
|
102
|
+
webhookSecret: "test-webhook-secret",
|
|
103
|
+
clientId: "test-client-id",
|
|
104
|
+
clientSecret: "test-client-secret",
|
|
105
|
+
onInstallationSuccess: async () => ({ userId: "test-user", redirectUrl: "/dashboard" }),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should initialize plugin successfully with valid options", async () => {
|
|
110
|
+
const plugin = githubAppPlugin(validOptions);
|
|
111
|
+
|
|
112
|
+
expect(plugin).toBeDefined();
|
|
113
|
+
expect(plugin.id).toBe("githubApp");
|
|
114
|
+
expect(plugin.init).toBeDefined();
|
|
115
|
+
expect(plugin.ctx).toBeDefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("should initialize repositories when init is called", async () => {
|
|
119
|
+
const plugin = githubAppPlugin(validOptions);
|
|
120
|
+
await plugin.init!(mockApp, mockDb);
|
|
121
|
+
|
|
122
|
+
// Verify addRepo was called for session and installation repos
|
|
123
|
+
expect(mockApp.addRepo).toHaveBeenCalledTimes(2);
|
|
124
|
+
expect(mockApp.addRepo).toHaveBeenCalledWith("githubAppSessionRepo", jasmine.any(Object));
|
|
125
|
+
expect(mockApp.addRepo).toHaveBeenCalledWith("githubInstallationRepo", jasmine.any(Object));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should register only callback and webhook handlers when registerRoutes is true (default)", async () => {
|
|
129
|
+
const plugin = githubAppPlugin(validOptions);
|
|
130
|
+
await plugin.init!(mockApp, mockDb);
|
|
131
|
+
|
|
132
|
+
// Verify only 2 handlers were registered (callback and webhook, NOT initiate and uninstall)
|
|
133
|
+
expect(mockApp.addHandler).toHaveBeenCalledTimes(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should NOT register handlers when registerRoutes is false", async () => {
|
|
137
|
+
const optionsWithoutRoutes = {
|
|
138
|
+
...validOptions,
|
|
139
|
+
registerRoutes: false,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const plugin = githubAppPlugin(optionsWithoutRoutes);
|
|
143
|
+
await plugin.init!(mockApp, mockDb);
|
|
144
|
+
|
|
145
|
+
// Verify handlers were NOT registered
|
|
146
|
+
expect(mockApp.addHandler).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should create TTL indexes for sessions", async () => {
|
|
150
|
+
const plugin = githubAppPlugin(validOptions);
|
|
151
|
+
await plugin.init!(mockApp, mockDb);
|
|
152
|
+
|
|
153
|
+
// Verify createIndex was called for sessions TTL
|
|
154
|
+
expect(mockDb.collection).toHaveBeenCalledWith("github_app_sessions");
|
|
155
|
+
expect(mockCollection.createIndex).toHaveBeenCalledWith(
|
|
156
|
+
{ createdAt: 1 },
|
|
157
|
+
{ expireAfterSeconds: 600 } // default sessionTTL
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should expose initiateInstallation context method", async () => {
|
|
162
|
+
const plugin = githubAppPlugin(validOptions);
|
|
163
|
+
await plugin.init!(mockApp, mockDb);
|
|
164
|
+
|
|
165
|
+
expect(plugin.ctx.initiateInstallation).toBeDefined();
|
|
166
|
+
expect(typeof plugin.ctx.initiateInstallation).toBe("function");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should expose uninstall context method", async () => {
|
|
170
|
+
const plugin = githubAppPlugin(validOptions);
|
|
171
|
+
await plugin.init!(mockApp, mockDb);
|
|
172
|
+
|
|
173
|
+
expect(plugin.ctx.uninstall).toBeDefined();
|
|
174
|
+
expect(typeof plugin.ctx.uninstall).toBe("function");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("Context Methods", () => {
|
|
179
|
+
let validOptions: GitHubAppPluginOptions;
|
|
180
|
+
let plugin: any;
|
|
181
|
+
|
|
182
|
+
beforeEach(async () => {
|
|
183
|
+
validOptions = {
|
|
184
|
+
appId: "12345",
|
|
185
|
+
appSlug: "test-app",
|
|
186
|
+
privateKey: testPrivateKeyBase64,
|
|
187
|
+
webhookSecret: "test-webhook-secret",
|
|
188
|
+
clientId: "test-client-id",
|
|
189
|
+
clientSecret: "test-client-secret",
|
|
190
|
+
onInstallationSuccess: async () => ({ userId: "test-user", redirectUrl: "/dashboard" }),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
plugin = githubAppPlugin(validOptions);
|
|
194
|
+
await plugin.init!(mockApp, mockDb);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("initiateInstallation", () => {
|
|
198
|
+
it("should generate state and return installation URL", async () => {
|
|
199
|
+
const result = await plugin.ctx.initiateInstallation({
|
|
200
|
+
userId: "user-123",
|
|
201
|
+
metadata: { source: "settings" },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(result).toBeDefined();
|
|
205
|
+
expect(result.redirectUrl).toContain("https://github.com/apps/test-app/installations/new");
|
|
206
|
+
expect(result.redirectUrl).toContain("state=");
|
|
207
|
+
expect(result.state).toBeDefined();
|
|
208
|
+
expect(result.sessionId).toBeDefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("should throw error if appSlug is not configured", async () => {
|
|
212
|
+
const optionsWithoutSlug = {
|
|
213
|
+
...validOptions,
|
|
214
|
+
appSlug: undefined,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const pluginWithoutSlug = githubAppPlugin(optionsWithoutSlug as any);
|
|
218
|
+
await pluginWithoutSlug.init!(mockApp, mockDb);
|
|
219
|
+
|
|
220
|
+
await expectAsync(
|
|
221
|
+
pluginWithoutSlug.ctx.initiateInstallation({
|
|
222
|
+
userId: "user-123",
|
|
223
|
+
})
|
|
224
|
+
).toBeRejectedWithError(/appSlug is required/);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("uninstall", () => {
|
|
229
|
+
it("should return error if installation not found", async () => {
|
|
230
|
+
// Mock repo to return null
|
|
231
|
+
const mockInstallationRepo = mockApp.ctx.repos.githubInstallationRepo;
|
|
232
|
+
spyOn(mockInstallationRepo, "findByUserAndInstallationId").and.returnValue(Promise.resolve(null));
|
|
233
|
+
|
|
234
|
+
const result = await plugin.ctx.uninstall({
|
|
235
|
+
userId: "user-123",
|
|
236
|
+
installationId: 12345,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(result.success).toBe(false);
|
|
240
|
+
expect(result.error).toBe("installation-not-found");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("should return error if user doesn't own installation", async () => {
|
|
244
|
+
// Mock repo to return installation owned by different user
|
|
245
|
+
const mockInstallationRepo = mockApp.ctx.repos.githubInstallationRepo;
|
|
246
|
+
spyOn(mockInstallationRepo, "findByUserAndInstallationId").and.returnValue(Promise.resolve({ userId: "other-user", installationId: 12345 }));
|
|
247
|
+
|
|
248
|
+
const result = await plugin.ctx.uninstall({
|
|
249
|
+
userId: "user-123",
|
|
250
|
+
installationId: 12345,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(result.success).toBe(false);
|
|
254
|
+
expect(result.error).toBe("installation-not-owned");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Setup Tests
|
|
3
|
+
* Tests for Task 1.1: Verify basic project structure and compilation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { githubAppPlugin } from '../src/index';
|
|
7
|
+
import type { GitHubAppPluginOptions, GitHubAppPluginContext } from '../src/index';
|
|
8
|
+
|
|
9
|
+
describe('GitHub App Plugin - Project Setup', () => {
|
|
10
|
+
describe('Package Exports', () => {
|
|
11
|
+
it('should export githubAppPlugin factory function', () => {
|
|
12
|
+
expect(typeof githubAppPlugin).toBe('function');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should export GitHubAppPluginOptions type', () => {
|
|
16
|
+
// Type test - if this compiles, the type exists
|
|
17
|
+
const options: GitHubAppPluginOptions = {
|
|
18
|
+
appId: 'test',
|
|
19
|
+
privateKey: 'test',
|
|
20
|
+
webhookSecret: 'test',
|
|
21
|
+
clientId: 'test',
|
|
22
|
+
clientSecret: 'test',
|
|
23
|
+
onInstallationSuccess: async () => ({ userId: 'test', redirectUrl: '/' }),
|
|
24
|
+
};
|
|
25
|
+
expect(options).toBeDefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should export GitHubAppPluginContext type', () => {
|
|
29
|
+
// Type test - if this compiles, the type exists
|
|
30
|
+
// GitHubAppPluginContext has a githubApp property with methods
|
|
31
|
+
const context: Partial<GitHubAppPluginContext> = {
|
|
32
|
+
githubApp: {} as any,
|
|
33
|
+
};
|
|
34
|
+
expect(context).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('Plugin Factory', () => {
|
|
39
|
+
it('should create a plugin instance with required options', () => {
|
|
40
|
+
const plugin = githubAppPlugin({
|
|
41
|
+
appId: 'test-app-id',
|
|
42
|
+
privateKey: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQp0ZXN0Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t', // Base64 encoded
|
|
43
|
+
webhookSecret: 'test-webhook-secret',
|
|
44
|
+
clientId: 'test-client-id',
|
|
45
|
+
clientSecret: 'test-client-secret',
|
|
46
|
+
onInstallationSuccess: async () => ({ userId: 'test', redirectUrl: '/' }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(plugin).toBeDefined();
|
|
50
|
+
expect(plugin.id).toBe('githubApp');
|
|
51
|
+
expect(plugin.db).toBeDefined();
|
|
52
|
+
expect(plugin.db?.useHostDb).toBe(true);
|
|
53
|
+
expect(typeof plugin.init).toBe('function');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { FlinkApp } from "@flink-app/flink";
|
|
2
|
+
import { MongoMemoryServer } from "mongodb-memory-server";
|
|
3
|
+
import GitHubAppSessionRepo from "../src/repos/GitHubAppSessionRepo";
|
|
4
|
+
import GitHubInstallationRepo from "../src/repos/GitHubInstallationRepo";
|
|
5
|
+
import GitHubWebhookEventRepo from "../src/repos/GitHubWebhookEventRepo";
|
|
6
|
+
import GitHubAppSession from "../src/schemas/GitHubAppSession";
|
|
7
|
+
import GitHubInstallation from "../src/schemas/GitHubInstallation";
|
|
8
|
+
import WebhookEvent from "../src/schemas/WebhookEvent";
|
|
9
|
+
|
|
10
|
+
describe("Repos and Schemas", () => {
|
|
11
|
+
let mongoServer: MongoMemoryServer;
|
|
12
|
+
let app: FlinkApp<any>;
|
|
13
|
+
let sessionRepo: GitHubAppSessionRepo;
|
|
14
|
+
let installationRepo: GitHubInstallationRepo;
|
|
15
|
+
let webhookEventRepo: GitHubWebhookEventRepo;
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
mongoServer = await MongoMemoryServer.create();
|
|
19
|
+
const mongoUri = mongoServer.getUri();
|
|
20
|
+
|
|
21
|
+
app = new FlinkApp({
|
|
22
|
+
name: "Test App",
|
|
23
|
+
port: 3340,
|
|
24
|
+
db: {
|
|
25
|
+
uri: mongoUri,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await app.start();
|
|
30
|
+
|
|
31
|
+
// Ensure db is defined before instantiating repos
|
|
32
|
+
if (!app.db) {
|
|
33
|
+
throw new Error("Database not initialized");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Manually instantiate repos for testing
|
|
37
|
+
sessionRepo = new GitHubAppSessionRepo(app.ctx, app.db);
|
|
38
|
+
installationRepo = new GitHubInstallationRepo(app.ctx, app.db);
|
|
39
|
+
webhookEventRepo = new GitHubWebhookEventRepo(app.ctx, app.db);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
await app.stop();
|
|
44
|
+
await mongoServer.stop();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
// Clean up test data after each test
|
|
49
|
+
await sessionRepo.collection.deleteMany({});
|
|
50
|
+
await installationRepo.collection.deleteMany({});
|
|
51
|
+
await webhookEventRepo.collection.deleteMany({});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("GitHubAppSessionRepo", () => {
|
|
55
|
+
it("should create and find session by sessionId", async () => {
|
|
56
|
+
const session: GitHubAppSession = {
|
|
57
|
+
sessionId: "test-session-123",
|
|
58
|
+
state: "test-state-abc",
|
|
59
|
+
userId: "user-456",
|
|
60
|
+
metadata: { test: true },
|
|
61
|
+
createdAt: new Date(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const created = await sessionRepo.create(session);
|
|
65
|
+
expect(created._id).toBeDefined();
|
|
66
|
+
|
|
67
|
+
const found = await sessionRepo.findBySessionId("test-session-123");
|
|
68
|
+
expect(found).not.toBeNull();
|
|
69
|
+
expect(found?.sessionId).toBe("test-session-123");
|
|
70
|
+
expect(found?.state).toBe("test-state-abc");
|
|
71
|
+
expect(found?.userId).toBe("user-456");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should delete session by sessionId", async () => {
|
|
75
|
+
const session: GitHubAppSession = {
|
|
76
|
+
sessionId: "delete-test",
|
|
77
|
+
state: "state-xyz",
|
|
78
|
+
createdAt: new Date(),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
await sessionRepo.create(session);
|
|
82
|
+
const deletedCount = await sessionRepo.deleteBySessionId("delete-test");
|
|
83
|
+
expect(deletedCount).toBe(1);
|
|
84
|
+
|
|
85
|
+
const found = await sessionRepo.findBySessionId("delete-test");
|
|
86
|
+
expect(found).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("GitHubInstallationRepo", () => {
|
|
91
|
+
it("should create and find installation by userId", async () => {
|
|
92
|
+
const installation: GitHubInstallation = {
|
|
93
|
+
userId: "user-123",
|
|
94
|
+
installationId: 12345,
|
|
95
|
+
accountId: 67890,
|
|
96
|
+
accountLogin: "testuser",
|
|
97
|
+
accountType: "User",
|
|
98
|
+
avatarUrl: "https://example.com/avatar.png",
|
|
99
|
+
repositories: [
|
|
100
|
+
{
|
|
101
|
+
id: 1,
|
|
102
|
+
name: "repo1",
|
|
103
|
+
fullName: "testuser/repo1",
|
|
104
|
+
private: false,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
permissions: { contents: "read", issues: "write" },
|
|
108
|
+
events: ["push", "pull_request"],
|
|
109
|
+
createdAt: new Date(),
|
|
110
|
+
updatedAt: new Date(),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const created = await installationRepo.create(installation);
|
|
114
|
+
expect(created._id).toBeDefined();
|
|
115
|
+
|
|
116
|
+
const found = await installationRepo.findByUserId("user-123");
|
|
117
|
+
expect(found.length).toBe(1);
|
|
118
|
+
expect(found[0].installationId).toBe(12345);
|
|
119
|
+
expect(found[0].accountLogin).toBe("testuser");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should find installation by installationId", async () => {
|
|
123
|
+
const installation: GitHubInstallation = {
|
|
124
|
+
userId: "user-456",
|
|
125
|
+
installationId: 99999,
|
|
126
|
+
accountId: 11111,
|
|
127
|
+
accountLogin: "orguser",
|
|
128
|
+
accountType: "Organization",
|
|
129
|
+
repositories: [],
|
|
130
|
+
permissions: {},
|
|
131
|
+
events: [],
|
|
132
|
+
createdAt: new Date(),
|
|
133
|
+
updatedAt: new Date(),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
await installationRepo.create(installation);
|
|
137
|
+
|
|
138
|
+
const found = await installationRepo.findByInstallationId(99999);
|
|
139
|
+
expect(found).not.toBeNull();
|
|
140
|
+
expect(found?.installationId).toBe(99999);
|
|
141
|
+
expect(found?.accountType).toBe("Organization");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should update repositories for an installation", async () => {
|
|
145
|
+
const installation: GitHubInstallation = {
|
|
146
|
+
userId: "user-789",
|
|
147
|
+
installationId: 55555,
|
|
148
|
+
accountId: 22222,
|
|
149
|
+
accountLogin: "testorg",
|
|
150
|
+
accountType: "Organization",
|
|
151
|
+
repositories: [
|
|
152
|
+
{
|
|
153
|
+
id: 1,
|
|
154
|
+
name: "old-repo",
|
|
155
|
+
fullName: "testorg/old-repo",
|
|
156
|
+
private: true,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
permissions: {},
|
|
160
|
+
events: [],
|
|
161
|
+
createdAt: new Date(),
|
|
162
|
+
updatedAt: new Date(),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
await installationRepo.create(installation);
|
|
166
|
+
|
|
167
|
+
const newRepos = [
|
|
168
|
+
{
|
|
169
|
+
id: 2,
|
|
170
|
+
name: "new-repo",
|
|
171
|
+
fullName: "testorg/new-repo",
|
|
172
|
+
private: false,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 3,
|
|
176
|
+
name: "another-repo",
|
|
177
|
+
fullName: "testorg/another-repo",
|
|
178
|
+
private: true,
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const updated = await installationRepo.updateRepositories(55555, newRepos);
|
|
183
|
+
expect(updated).not.toBeNull();
|
|
184
|
+
expect(updated?.repositories.length).toBe(2);
|
|
185
|
+
expect(updated?.repositories[0].name).toBe("new-repo");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("should suspend an installation", async () => {
|
|
189
|
+
const installation: GitHubInstallation = {
|
|
190
|
+
userId: "user-suspend",
|
|
191
|
+
installationId: 77777,
|
|
192
|
+
accountId: 33333,
|
|
193
|
+
accountLogin: "suspendtest",
|
|
194
|
+
accountType: "User",
|
|
195
|
+
repositories: [],
|
|
196
|
+
permissions: {},
|
|
197
|
+
events: [],
|
|
198
|
+
createdAt: new Date(),
|
|
199
|
+
updatedAt: new Date(),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
await installationRepo.create(installation);
|
|
203
|
+
|
|
204
|
+
const suspendedBy = { id: 999, login: "admin" };
|
|
205
|
+
const suspended = await installationRepo.suspend(77777, suspendedBy);
|
|
206
|
+
|
|
207
|
+
expect(suspended).not.toBeNull();
|
|
208
|
+
expect(suspended?.suspendedAt).toBeDefined();
|
|
209
|
+
expect(suspended?.suspendedBy?.login).toBe("admin");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should delete installation by installationId", async () => {
|
|
213
|
+
const installation: GitHubInstallation = {
|
|
214
|
+
userId: "user-delete",
|
|
215
|
+
installationId: 88888,
|
|
216
|
+
accountId: 44444,
|
|
217
|
+
accountLogin: "deletetest",
|
|
218
|
+
accountType: "User",
|
|
219
|
+
repositories: [],
|
|
220
|
+
permissions: {},
|
|
221
|
+
events: [],
|
|
222
|
+
createdAt: new Date(),
|
|
223
|
+
updatedAt: new Date(),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
await installationRepo.create(installation);
|
|
227
|
+
|
|
228
|
+
const deletedCount = await installationRepo.deleteByInstallationId(88888);
|
|
229
|
+
expect(deletedCount).toBe(1);
|
|
230
|
+
|
|
231
|
+
const found = await installationRepo.findByInstallationId(88888);
|
|
232
|
+
expect(found).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("GitHubWebhookEventRepo", () => {
|
|
237
|
+
it("should find unprocessed webhook events", async () => {
|
|
238
|
+
const event1: WebhookEvent = {
|
|
239
|
+
installationId: 12345,
|
|
240
|
+
event: "push",
|
|
241
|
+
action: "created",
|
|
242
|
+
deliveryId: "delivery-1",
|
|
243
|
+
payload: { test: "data1" },
|
|
244
|
+
processed: false,
|
|
245
|
+
createdAt: new Date(),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const event2: WebhookEvent = {
|
|
249
|
+
installationId: 12345,
|
|
250
|
+
event: "pull_request",
|
|
251
|
+
action: "opened",
|
|
252
|
+
deliveryId: "delivery-2",
|
|
253
|
+
payload: { test: "data2" },
|
|
254
|
+
processed: true,
|
|
255
|
+
processedAt: new Date(),
|
|
256
|
+
createdAt: new Date(),
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
await webhookEventRepo.create(event1);
|
|
260
|
+
await webhookEventRepo.create(event2);
|
|
261
|
+
|
|
262
|
+
const unprocessed = await webhookEventRepo.findUnprocessed();
|
|
263
|
+
expect(unprocessed.length).toBe(1);
|
|
264
|
+
expect(unprocessed[0].event).toBe("push");
|
|
265
|
+
expect(unprocessed[0].processed).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("should mark event as processed", async () => {
|
|
269
|
+
const event: WebhookEvent = {
|
|
270
|
+
installationId: 67890,
|
|
271
|
+
event: "installation",
|
|
272
|
+
action: "created",
|
|
273
|
+
deliveryId: "delivery-3",
|
|
274
|
+
payload: { installation: { id: 67890 } },
|
|
275
|
+
processed: false,
|
|
276
|
+
createdAt: new Date(),
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const created = await webhookEventRepo.create(event);
|
|
280
|
+
expect(created._id).toBeDefined();
|
|
281
|
+
|
|
282
|
+
const marked = await webhookEventRepo.markProcessed(created._id!);
|
|
283
|
+
expect(marked).not.toBeNull();
|
|
284
|
+
expect(marked?.processed).toBe(true);
|
|
285
|
+
expect(marked?.processedAt).toBeDefined();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|