| 1 | // Drives a running self-hosted Ghost from the outside, the way a user would: |
| 2 | // sign up, verify by email, push and clone over HTTP and SSH, organizations, |
| 3 | // issues, a merged pull request, a signed webhook, and code search. |
| 4 | // Reads the addresses from docker/.env; E2E_API, E2E_WEB, E2E_DOCS, |
| 5 | // E2E_SSH_PORT, E2E_MAILPIT and E2E_HOOKS override them when ports are remapped. |
| 6 | import { execFileSync } |
| 7 | import { createHmac } |
| 8 | import { mkdtempSync, readFileSync |
| 9 | import { tmpdir } |
| 10 | import { join } |
| 11 | |
| 12 | const env = Object |
| 13 | readFileSync(new URL |
| 14 | .split('\n') |
| 15 | .map((line) |
| 16 | .filter(Boolean) |
| 17 | .map(([, key |
| 18 | ); |
| 19 | const API = process |
| 20 | const WEB = process |
| 21 | const DOCS = process |
| 22 | const MAILPIT = process |
| 23 | const HOOKS = process |
| 24 | const [sshHost, sshPort |
| 25 | const SSH = ` |
| 26 | |
| 27 | const stamp = Date |
| 28 | const username = ` |
| 29 | const email = ` |
| 30 | const password = ' |
| 31 | const org = ` |
| 32 | // A word nothing else contains, so code search can only find it in the pushed file. |
| 33 | const needle = ` |
| 34 | const work = mkdtempSync |
| 35 | let cookie = '' |
| 36 | |
| 37 | const sleep = ( |
| 38 | async function step( |
| 39 | const started = |
| 40 | try { |
| 41 | await fn(); |
| 42 | console.log(`ok |
| 43 | } catch (error) |
| 44 | console.error(`FAIL |
| 45 | process.exit(1) |
| 46 | } |
| 47 | } |
| 48 | function assert(condition, |
| 49 | if (!condition) |
| 50 | } |
| 51 | async function until( |
| 52 | const deadline = |
| 53 | let last; |
| 54 | while (Date.now |
| 55 | try { |
| 56 | const value = |
| 57 | if (value) return |
| 58 | } catch (error) |
| 59 | last = error; |
| 60 | } |
| 61 | await sleep(2000) |
| 62 | } |
| 63 | throw new Error( |
| 64 | } |
| 65 | async function call( |
| 66 | const res = |
| 67 | method, |
| 68 | redirect: 'manual', |
| 69 | headers: { |
| 70 | 'content-type': 'application/json', |
| 71 | // Better Auth refuses cookie requests from an origin it does not trust. |
| 72 | origin: env.WEB_APP_URL |
| 73 | ...(cookie && |
| 74 | }, |
| 75 | body: body && |
| 76 | }); |
| 77 | const text = |
| 78 | let json; |
| 79 | try { |
| 80 | json = JSON. |
| 81 | } catch {} |
| 82 | assert( |
| 83 | [expect].flat() |
| 84 | `${method} ${path |
| 85 | ); |
| 86 | return { status: |
| 87 | } |
| 88 | function git(cwd, |
| 89 | return execFileSync( |
| 90 | 'git', |
| 91 | ['-c', 'user.name=E2E' |
| 92 | { |
| 93 | cwd, |
| 94 | encoding: 'utf8', |
| 95 | env: { ...process |
| 96 | stdio: ['ignore', |
| 97 | }, |
| 98 | ).trim(); |
| 99 | } |
| 100 | function commit(dir, |
| 101 | writeFileSync(join(dir |
| 102 | git(dir, ' |
| 103 | git(dir, ' |
| 104 | return git(dir |
| 105 | } |
| 106 | async function latestMail( |
| 107 | const { messages |
| 108 | await fetch(`${ |
| 109 | ).json(); |
| 110 | return messages?.[0 |
| 111 | } |
| 112 | |
| 113 | const keyFile = join |
| 114 | const sshCommand = ` |
| 115 | let apiKey = '' |
| 116 | let httpRemote = '' |
| 117 | |
| 118 | try { |
| 119 | await step('api, web and docs answer |
| 120 | await until('the api |
| 121 | await until('the web app |
| 122 | await until('the docs |
| 123 | }); |
| 124 | |
| 125 | await step('sign up |
| 126 | const { json |
| 127 | email, |
| 128 | password, |
| 129 | name: 'E2E', |
| 130 | username, |
| 131 | }); |
| 132 | assert(!json.token |
| 133 | }); |
| 134 | |
| 135 | await step('sign-in is refused until the email is verified |
| 136 | await call('POST |
| 137 | }); |
| 138 | |
| 139 | await step('verify through the emailed link |
| 140 | const message = |
| 141 | assert(message.Subject |
| 142 | const { Text |
| 143 | const link = |
| 144 | assert(link, ` |
| 145 | const url = |
| 146 | const res = |
| 147 | assert( |
| 148 | res.headers.get |
| 149 | `verification redirected to ${res.headers |
| 150 | ); |
| 151 | }); |
| 152 | |
| 153 | await step('sign in |
| 154 | const res = |
| 155 | cookie = res. |
| 156 | .getSetCookie() |
| 157 | .map((c) |
| 158 | .join('; ') |
| 159 | assert(cookie.includes |
| 160 | const { json |
| 161 | assert(json.user |
| 162 | }); |
| 163 | |
| 164 | await step('the web app sees the session when rendering |
| 165 | const res = |
| 166 | assert( |
| 167 | res.headers.get |
| 168 | `signed-in home page answered ${res.status |
| 169 | ); |
| 170 | }); |
| 171 | |
| 172 | await step('create an API key |
| 173 | const { json |
| 174 | apiKey = json. |
| 175 | assert(apiKey?.startsWith |
| 176 | httpRemote = `${API |
| 177 | }); |
| 178 | |
| 179 | await step('create a repository |
| 180 | const { json |
| 181 | assert(json.slug |
| 182 | }); |
| 183 | |
| 184 | let secret = |
| 185 | const delivery = |
| 186 | until(`a ${event |
| 187 | const all = |
| 188 | // The receiver outlives a run; only this run's account is in its payloads. |
| 189 | return all |
| 190 | .map((h) |
| 191 | .find((h) |
| 192 | }); |
| 193 | |
| 194 | await step('add a webhook |
| 195 | const { json |
| 196 | url: 'http://hooks:8080/hook', |
| 197 | events: ['push', |
| 198 | }); |
| 199 | secret = json. |
| 200 | assert(secret, ' |
| 201 | }); |
| 202 | |
| 203 | const local = |
| 204 | let head = |
| 205 | await step('push over HTTP |
| 206 | git(work, ' |
| 207 | commit(local, ' |
| 208 | head = commit( |
| 209 | git(local, ' |
| 210 | git(local, ' |
| 211 | }); |
| 212 | |
| 213 | await step('clone over HTTP |
| 214 | git(work, ' |
| 215 | assert(git(join |
| 216 | }); |
| 217 | |
| 218 | await step('the push reaches the webhook, signed |
| 219 | const hook = |
| 220 | const ts = |
| 221 | const expected = |
| 222 | assert(hook.headers |
| 223 | }); |
| 224 | |
| 225 | await step('the web app renders the repository |
| 226 | const res = |
| 227 | const html = |
| 228 | assert(res.ok |
| 229 | assert(html.includes |
| 230 | }); |
| 231 | |
| 232 | await step('open an issue |
| 233 | const { json |
| 234 | title: 'Something is off', |
| 235 | body: 'Opened by e2e.mjs', |
| 236 | }); |
| 237 | assert(json.number |
| 238 | await delivery('issue.opened |
| 239 | }); |
| 240 | |
| 241 | await step('open and merge a pull request |
| 242 | git(local, ' |
| 243 | commit(local, ' |
| 244 | git(local, ' |
| 245 | const { json: |
| 246 | title: 'Add a feature', |
| 247 | base: 'main', |
| 248 | head: 'feature', |
| 249 | }); |
| 250 | await until('a mergeable pull request |
| 251 | const res = |
| 252 | 'POST', |
| 253 | `/api/repositories/${username}/hello/pulls/${ |
| 254 | {}, |
| 255 | { expect: [200, |
| 256 | ); |
| 257 | return res.status |
| 258 | }); |
| 259 | git(local, ' |
| 260 | const files = |
| 261 | assert(files.includes |
| 262 | }); |
| 263 | |
| 264 | await step('create an organization and a repository in it |
| 265 | await call('POST |
| 266 | const { json |
| 267 | assert(json.slug |
| 268 | }); |
| 269 | |
| 270 | await step('push over SSH |
| 271 | execFileSync('ssh-keygen', |
| 272 | await call('POST |
| 273 | title: 'e2e', |
| 274 | publicKey: readFileSync(`${ |
| 275 | }); |
| 276 | const dir = |
| 277 | git(work, ' |
| 278 | head = commit( |
| 279 | git(dir, ' |
| 280 | }); |
| 281 | |
| 282 | await step('clone over SSH |
| 283 | git(work, ' |
| 284 | assert(git(join |
| 285 | }); |
| 286 | |
| 287 | await step('code search finds the pushed file |
| 288 | await until( |
| 289 | 'the needle in search results', |
| 290 | async () => |
| 291 | const { json |
| 292 | return json.files |
| 293 | }, |
| 294 | 180_000, |
| 295 | ); |
| 296 | }); |
| 297 | |
| 298 | await step('repository search finds the repository |
| 299 | const { text |
| 300 | assert(text.includes |
| 301 | }); |
| 302 | |
| 303 | console.log('\n |
| 304 | } finally { |
| 305 | rmSync(work, { |
| 306 | } |