| 1 | import { execFile, execFileSync } from 'node:child_process'; |
| 2 | import { mkdtempSync, rmSync |
| 3 | import { tmpdir } |
| 4 | import path from 'node:path |
| 5 | import { promisify } |
| 6 | import type { INestApplication |
| 7 | import request from 'supertest |
| 8 | import { afterAll, beforeAll |
| 9 | |
| 10 | import { createDatabase, schema |
| 11 | import { eq } |
| 12 | |
| 13 | import { DATABASE_URL, hasBackends |
| 14 | |
| 15 | type Account |
| 16 | |
| 17 | describe.skipIf(!hasBackends |
| 18 | let app: |
| 19 | let origin: |
| 20 | let owner: |
| 21 | let alice: |
| 22 | let mallory: |
| 23 | let work: |
| 24 | const stamp = |
| 25 | const ownerName = |
| 26 | const aliceName = |
| 27 | const malloryName = |
| 28 | let repo: |
| 29 | |
| 30 | const api = |
| 31 | const at = |
| 32 | const git = |
| 33 | execFileSync( |
| 34 | 'git', |
| 35 | ['-c', 'user.name=E2E' |
| 36 | { cwd: work, |
| 37 | ).trim(); |
| 38 | // The server runs in this process, so anything that talks to it must not block the event loop. |
| 39 | const push = |
| 40 | promisify(execFile)( |
| 41 | 'git', |
| 42 | [ |
| 43 | 'push', |
| 44 | '-q', |
| 45 | `${origin.replace( |
| 46 | 'HEAD:refs/heads/main', |
| 47 | ], |
| 48 | { cwd: work, |
| 49 | ); |
| 50 | const invite = |
| 51 | api() |
| 52 | .put(at(` |
| 53 | .set('cookie', |
| 54 | .send({ role |
| 55 | const pendingInvitation = |
| 56 | ( |
| 57 | await api() |
| 58 | .get('/api/invitations') |
| 59 | .set('cookie', |
| 60 | .expect(200) |
| 61 | ).body.invitations[ |
| 62 | |
| 63 | beforeAll(async () |
| 64 | ({ app, origin |
| 65 | owner = await |
| 66 | alice = await |
| 67 | mallory = await |
| 68 | |
| 69 | repo = ( |
| 70 | await api() |
| 71 | .post('/api/repositories') |
| 72 | .set('cookie', |
| 73 | .send({ name: |
| 74 | .expect(201) |
| 75 | ).body.slug; |
| 76 | work = mkdtempSync( |
| 77 | git('init', |
| 78 | git('commit', |
| 79 | await push(ownerName |
| 80 | await api() |
| 81 | .post(at(' |
| 82 | .set('cookie', |
| 83 | .send({ title: |
| 84 | .expect(201); |
| 85 | }, 120_000); |
| 86 | |
| 87 | afterAll(async () |
| 88 | await app?.close |
| 89 | if (work) rmSync |
| 90 | }); |
| 91 | |
| 92 | it('refuses to invite the owner or someone who does not exist', |
| 93 | await invite(ownerName |
| 94 | await invite(`nobody |
| 95 | }); |
| 96 | |
| 97 | it('grants nothing until the invitation is accepted', |
| 98 | const { body |
| 99 | expect(body).toMatchObject |
| 100 | username: aliceName, |
| 101 | role: 'read', |
| 102 | status: 'pending', |
| 103 | }); |
| 104 | |
| 105 | await api().get |
| 106 | expect(await pendingInvitation |
| 107 | repository: { username: ownerName |
| 108 | role: 'read', |
| 109 | invitedByUsername: ownerName, |
| 110 | }); |
| 111 | }); |
| 112 | |
| 113 | it('keeps invitations to themselves', |
| 114 | const invitation = |
| 115 | await api() |
| 116 | .post(`/api/invitations/${invitation |
| 117 | .set('cookie', |
| 118 | .expect(404); |
| 119 | await api() |
| 120 | .get(at(' |
| 121 | .set('cookie', |
| 122 | .expect(404); |
| 123 | }); |
| 124 | |
| 125 | it('lets a reader read and nothing more', |
| 126 | const invitation = |
| 127 | await api() |
| 128 | .post(`/api/invitations/${invitation |
| 129 | .set('cookie', |
| 130 | .expect(204); |
| 131 | |
| 132 | const { body |
| 133 | .get(at()) |
| 134 | .set('cookie', |
| 135 | .expect(200); |
| 136 | expect(body.viewerRole |
| 137 | const listed = |
| 138 | .get(`/api/repositories/${ownerName |
| 139 | .set('cookie', |
| 140 | .expect(200); |
| 141 | expect( |
| 142 | listed.body.repositories |
| 143 | ).toContain(repo) |
| 144 | const mine = |
| 145 | .get('/api/repositories') |
| 146 | .set('cookie', |
| 147 | .expect(200); |
| 148 | expect(mine.body |
| 149 | expect.objectContaining({ |
| 150 | owner: ownerName, |
| 151 | slug: repo, |
| 152 | visibility: 'private', |
| 153 | viewerRole: 'read', |
| 154 | }), |
| 155 | ]); |
| 156 | |
| 157 | git('commit', |
| 158 | await expect(push |
| 159 | await api() |
| 160 | .post(at(' |
| 161 | .set('cookie', |
| 162 | .expect(403); |
| 163 | await invite(malloryName |
| 164 | }); |
| 165 | |
| 166 | it('lets triage close issues but not edit them', |
| 167 | await invite(aliceName |
| 168 | |
| 169 | await api() |
| 170 | .patch(at(' |
| 171 | .set('cookie', |
| 172 | .send({ title: |
| 173 | .expect(403); |
| 174 | await api() |
| 175 | .post(at(' |
| 176 | .set('cookie', |
| 177 | .expect(201); |
| 178 | }); |
| 179 | |
| 180 | it('lets write push', |
| 181 | await invite(aliceName |
| 182 | await push(aliceName |
| 183 | }); |
| 184 | |
| 185 | it('lets maintain change settings, but not visibility', |
| 186 | await invite(aliceName |
| 187 | |
| 188 | await api() |
| 189 | .patch(at()) |
| 190 | .set('cookie', |
| 191 | .send({ description: |
| 192 | .expect(200); |
| 193 | await api() |
| 194 | .patch(at()) |
| 195 | .set('cookie', |
| 196 | .send({ visibility: |
| 197 | .expect(403); |
| 198 | await api() |
| 199 | .get(at(' |
| 200 | .set('cookie', |
| 201 | .expect(403); |
| 202 | }); |
| 203 | |
| 204 | it('lets admin manage collaborators', |
| 205 | await invite(aliceName |
| 206 | |
| 207 | await invite(malloryName |
| 208 | const { body |
| 209 | .get(at(' |
| 210 | .set('cookie', |
| 211 | .expect(200); |
| 212 | expect( |
| 213 | body.collaborators.map |
| 214 | c.username, |
| 215 | c.status, |
| 216 | ]), |
| 217 | ).toEqual([ |
| 218 | [aliceName, 'accepted' |
| 219 | [malloryName, 'pending' |
| 220 | ]); |
| 221 | }); |
| 222 | |
| 223 | it('lets an invitation lapse after a week, and sends it again on a new invite', |
| 224 | const invitation = |
| 225 | const { db, |
| 226 | await db |
| 227 | .update(schema.repositoryCollaborator |
| 228 | .set({ createdAt: |
| 229 | .where(eq(schema |
| 230 | await pool.end |
| 231 | |
| 232 | expect(await pendingInvitation |
| 233 | await api() |
| 234 | .post(`/api/invitations/${invitation |
| 235 | .set('cookie', |
| 236 | .expect(404); |
| 237 | const { body |
| 238 | .get(at(' |
| 239 | .set('cookie', |
| 240 | .expect(200); |
| 241 | expect(body.collaborators |
| 242 | expect.objectContaining({ |
| 243 | ); |
| 244 | |
| 245 | await invite(malloryName |
| 246 | expect(await pendingInvitation |
| 247 | id: invitation.id |
| 248 | role: 'triage', |
| 249 | }); |
| 250 | }); |
| 251 | |
| 252 | it('lets an invitee decline', |
| 253 | const invitation = |
| 254 | await api() |
| 255 | .delete(`/api/invitations/${invitation |
| 256 | .set('cookie', |
| 257 | .expect(204); |
| 258 | await api() |
| 259 | .post(`/api/invitations/${invitation |
| 260 | .set('cookie', |
| 261 | .expect(404); |
| 262 | }); |
| 263 | |
| 264 | it('lets a collaborator leave', |
| 265 | await api() |
| 266 | .delete(at(` |
| 267 | .set('cookie', |
| 268 | .expect(204); |
| 269 | await api().get |
| 270 | }); |
| 271 | }); |