ggdgsdbsdbbb / api / endpoints_test.v
606 lines · 534 sloc · 15.99 KB · 937a2726741e4f17542d79eb9633d04946887b06
Raw
1// Integration tests for every /api/v1/ endpoint exposed by gitly.
2//
3// The suite spawns its own gitly process on a non-default port using a
4// dedicated sqlite database, so it can be executed independently of any
5// long-running dev instance (run with `v test api/` or `v test .`).
6//
7// Endpoints covered:
8// GET /api/v1/me
9// GET /api/v1/users/:username
10// GET /api/v1/users/:username/repos
11// GET /api/v1/repos/:username/:repo_name
12// GET /api/v1/repos/:username/:repo_name/issues
13// POST /api/v1/repos/:username/:repo_name/issues
14// GET /api/v1/repos/:username/:repo_name/issues/:id
15// GET /api/v1/repos/:username/:repo_name/pulls
16// GET /api/v1/repos/:username/:repo_name/pulls/:id
17// GET /api/v1/repos/:username/:repo_name/pulls/:id/comments
18// POST /api/v1/repos/:repo_id/star
19// POST /api/v1/repos/:repo_id/watch
20// GET /api/v1/repos/:repo_id_str/tree/files
21// GET /api/v1/:user/:repo_name/branches/count
22// GET /api/v1/:user/:repo_name/:branch_name/commits/count
23// GET /api/v1/:username/:repo_name/issues/count
24// POST /api/v1/users/avatar
25// POST /api/v1/ci/status
26module api
27
28import os
29import log
30import net.http
31import time
32import json
33
34const test_port = 8765
35const test_url = 'http://127.0.0.1:${test_port}'
36const test_username = 'apitester'
37const test_password = '1234zxcv'
38const test_email = '[email protected]'
39const test_repo = 'apitest'
40const test_other_user = 'apitester2'
41const test_other_password = '5678qwer'
42const test_other_email = '[email protected]'
43
44const test_binary = 'gitly_apitest.exe'
45const test_sqlite_path = 'gitly_apitest.sqlite'
46
47// Test-wide state is passed between testsuite_begin and individual tests via
48// environment variables, since `v test` does not allow `__global` declarations
49// in module test files.
50const env_session = 'GITLY_APITEST_SESSION'
51const env_other_session = 'GITLY_APITEST_OTHER_SESSION'
52const env_bearer = 'GITLY_APITEST_BEARER'
53const env_repo_id = 'GITLY_APITEST_REPO_ID'
54
55fn session_cookie() string {
56 return os.getenv(env_session)
57}
58
59fn other_session_cookie() string {
60 return os.getenv(env_other_session)
61}
62
63fn bearer_token() string {
64 return os.getenv(env_bearer)
65}
66
67fn repo_id() int {
68 return os.getenv(env_repo_id).int()
69}
70
71// -- testsuite plumbing -------------------------------------------------------
72
73fn testsuite_begin() {
74 chdir_to_project_root()
75 kill_test_gitly()
76 cleanup_test_state()
77 ensure_gitly_binary()
78 spawn_test_gitly()
79 wait_for_test_gitly()
80
81 session := register(test_username, test_password, test_email) or {
82 fail('register primary user: ${err}')
83 }
84 os.setenv(env_session, session, true)
85
86 other := register(test_other_user, test_other_password, test_other_email) or {
87 fail('register secondary user: ${err}')
88 }
89 os.setenv(env_other_session, other, true)
90
91 token := create_api_token(session, test_username) or { fail('create api token: ${err}') }
92 os.setenv(env_bearer, token, true)
93
94 create_repo(session, test_repo) or { fail('create repo: ${err}') }
95
96 rid := fetch_test_repo_id() or { fail('fetch repo id: ${err}') }
97 os.setenv(env_repo_id, rid.str(), true)
98}
99
100fn testsuite_end() {
101 kill_test_gitly()
102 cleanup_test_state()
103}
104
105@[noreturn]
106fn fail(msg string) {
107 log.error('api endpoints_test: ${msg}')
108 kill_test_gitly()
109 cleanup_test_state()
110 exit(1)
111}
112
113fn chdir_to_project_root() {
114 project_root := os.real_path(os.join_path(os.dir(@FILE), '..'))
115 os.chdir(project_root) or { fail('chdir to project root ${project_root}: ${err}') }
116}
117
118fn cleanup_test_state() {
119 for ext in ['', '-shm', '-wal'] {
120 path := test_sqlite_path + ext
121 if os.exists(path) {
122 os.rm(path) or {}
123 }
124 }
125 for user in [test_username, test_other_user] {
126 repo_path := os.join_path('repos', user)
127 if os.exists(repo_path) {
128 os.rmdir_all(repo_path) or {}
129 }
130 }
131}
132
133fn ensure_gitly_binary() {
134 if os.exists(test_binary) {
135 return
136 }
137 log.info('building ${test_binary} ...')
138 res := os.execute('v -d sqlite -d use_libbacktrace -d use_openssl -o ${test_binary} .')
139 if res.exit_code != 0 {
140 fail('failed to build gitly: ${res.output}')
141 }
142}
143
144fn spawn_test_gitly() {
145 os.setenv('GITLY_PORT', test_port.str(), true)
146 os.setenv('GITLY_SQLITE_PATH', test_sqlite_path, true)
147 spawn fn () {
148 os.execute('./${test_binary}')
149 }()
150}
151
152fn wait_for_test_gitly() {
153 for i := 0; i < 100; i++ {
154 time.sleep(100 * time.millisecond)
155 http.get(test_url + '/') or { continue }
156 return
157 }
158 fail('gitly did not start listening on ${test_url}')
159}
160
161fn kill_test_gitly() {
162 os.execute('pkill -9 ${test_binary}')
163}
164
165// -- helpers ------------------------------------------------------------------
166
167fn url(path string) string {
168 if path.starts_with('/') {
169 return '${test_url}${path}'
170 }
171 return '${test_url}/${path}'
172}
173
174fn extract_token_cookie(h http.Header) string {
175 for v in h.values(.set_cookie) {
176 t := v.find_between('token=', ';')
177 if t != '' {
178 return t
179 }
180 }
181 return ''
182}
183
184fn register(username string, password string, email string) !string {
185 body := 'username=${username}&password=${password}&email=${email}&no_redirect=1'
186 resp := http.post(url('/register'), body)!
187 if resp.status_code != 200 {
188 return error('register returned ${resp.status_code}: ${resp.body}')
189 }
190 tok := extract_token_cookie(resp.header)
191 if tok == '' {
192 return error('no session token cookie in register response')
193 }
194 return tok
195}
196
197fn create_repo(token string, name string) ! {
198 resp := http.fetch(
199 method: .post
200 url: url('/new')
201 cookies: {
202 'token': token
203 }
204 data: 'name=${name}&description=api+test&clone_url=&repo_visibility=public&no_redirect=1'
205 )!
206 if resp.status_code != 200 || resp.body != 'ok' {
207 return error('unexpected response ${resp.status_code}: ${resp.body}')
208 }
209}
210
211fn create_api_token(token string, username string) !string {
212 resp := http.fetch(
213 method: .post
214 url: url('/${username}/settings/api-tokens')
215 cookies: {
216 'token': token
217 }
218 data: 'name=api-test'
219 allow_redirect: false
220 )!
221 if resp.status_code != 302 && resp.status_code != 303 {
222 return error('expected redirect, got ${resp.status_code}: ${resp.body}')
223 }
224 location := resp.header.get(.location) or { return error('no Location header') }
225 plain := location.all_after('new_token=')
226 if plain == '' || plain == location {
227 return error('could not parse new_token from ${location}')
228 }
229 return plain
230}
231
232fn fetch_test_repo_id() !int {
233 resp := http.get(url('/api/v1/users/${test_username}/repos'))!
234 if resp.status_code != 200 {
235 return error('listing returned ${resp.status_code}')
236 }
237 repos := json.decode([]ApiRepoSummary, resp.body)!
238 for r in repos {
239 if r.name == test_repo {
240 return r.id
241 }
242 }
243 return error('repo not found in listing')
244}
245
246struct ApiRepoSummary {
247 id int
248 name string
249 user_name string
250}
251
252struct ApiUserSummary {
253 id int
254 username string
255 full_name string
256 avatar string
257}
258
259struct ApiIssueSummary {
260 id int
261 number int
262 repo_id int
263 title string
264 body string
265 author string
266 status string
267}
268
269struct ApiPullSummary {
270 id int
271 repo_id int
272 title string
273 description string
274 status string
275}
276
277struct ApiCommentSummary {
278 id int
279 author string
280 text string
281}
282
283struct ApiBoolResult {
284 success bool
285 result bool
286}
287
288struct ApiFilesResult {
289 success bool
290 result []FileSummary
291}
292
293struct FileSummary {
294 name string
295 last_msg string
296 last_hash string
297 last_time string
298 size string
299}
300
301fn bearer_header() http.Header {
302 return http.new_header(key: .authorization, value: 'Bearer ${bearer_token()}')
303}
304
305// -- tests --------------------------------------------------------------------
306
307fn test_api_v1_me_requires_auth() {
308 resp := http.get(url('/api/v1/me')) or { panic(err) }
309 assert resp.status_code == 401
310}
311
312fn test_api_v1_me_with_bearer() {
313 resp := http.fetch(
314 method: .get
315 url: url('/api/v1/me')
316 header: bearer_header()
317 ) or { panic(err) }
318 assert resp.status_code == 200
319 user := json.decode(ApiUserSummary, resp.body) or { panic(err) }
320 assert user.username == test_username
321}
322
323fn test_api_v1_me_with_session_cookie() {
324 resp := http.fetch(
325 method: .get
326 url: url('/api/v1/me')
327 cookies: {
328 'token': session_cookie()
329 }
330 ) or { panic(err) }
331 assert resp.status_code == 200
332 user := json.decode(ApiUserSummary, resp.body) or { panic(err) }
333 assert user.username == test_username
334}
335
336fn test_api_v1_user_lookup() {
337 resp := http.get(url('/api/v1/users/${test_username}')) or { panic(err) }
338 assert resp.status_code == 200
339 user := json.decode(ApiUserSummary, resp.body) or { panic(err) }
340 assert user.username == test_username
341
342 missing := http.get(url('/api/v1/users/ghost_user')) or { panic(err) }
343 assert missing.status_code == 404
344}
345
346fn test_api_v1_user_repos() {
347 resp := http.get(url('/api/v1/users/${test_username}/repos')) or { panic(err) }
348 assert resp.status_code == 200
349 repos := json.decode([]ApiRepoSummary, resp.body) or { panic(err) }
350 assert repos.len >= 1
351 mut found := false
352 for r in repos {
353 if r.name == test_repo {
354 found = true
355 break
356 }
357 }
358 assert found
359}
360
361fn test_api_v1_repo_show() {
362 resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}')) or { panic(err) }
363 assert resp.status_code == 200
364 r := json.decode(ApiRepoSummary, resp.body) or { panic(err) }
365 assert r.name == test_repo
366 assert r.user_name == test_username
367
368 missing := http.get(url('/api/v1/repos/${test_username}/nope')) or { panic(err) }
369 assert missing.status_code == 404
370}
371
372fn test_api_v1_repo_issues_list_empty() {
373 resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}/issues')) or { panic(err) }
374 assert resp.status_code == 200
375 issues := json.decode([]ApiIssueSummary, resp.body) or { panic(err) }
376 assert issues.len == 0
377}
378
379fn test_api_v1_create_issue_requires_auth() {
380 resp := http.post_form(url('/api/v1/repos/${test_username}/${test_repo}/issues'), {
381 'title': 'should-fail'
382 'body': 'no token'
383 }) or { panic(err) }
384 assert resp.status_code == 401
385}
386
387fn test_api_v1_create_issue_requires_title() {
388 resp := http.fetch(
389 method: .post
390 url: url('/api/v1/repos/${test_username}/${test_repo}/issues')
391 header: http.new_header_from_map({
392 .authorization: 'Bearer ${bearer_token()}'
393 .content_type: 'application/x-www-form-urlencoded'
394 })
395 data: 'body=missing-title'
396 ) or { panic(err) }
397 assert resp.status_code == 400
398}
399
400fn test_api_v1_create_issue_succeeds() {
401 resp := http.fetch(
402 method: .post
403 url: url('/api/v1/repos/${test_username}/${test_repo}/issues')
404 header: http.new_header_from_map({
405 .authorization: 'Bearer ${bearer_token()}'
406 .content_type: 'application/x-www-form-urlencoded'
407 })
408 data: 'title=first-issue&body=hello'
409 ) or { panic(err) }
410 assert resp.status_code == 200
411 issue := json.decode(ApiIssueSummary, resp.body) or { panic(err) }
412 assert issue.title == 'first-issue'
413 assert issue.status == 'open'
414
415 listing := http.get(url('/api/v1/repos/${test_username}/${test_repo}/issues')) or { panic(err) }
416 issues := json.decode([]ApiIssueSummary, listing.body) or { panic(err) }
417 assert issues.len >= 1
418
419 single := http.get(url('/api/v1/repos/${test_username}/${test_repo}/issues/${issue.id}')) or {
420 panic(err)
421 }
422 assert single.status_code == 200
423 got := json.decode(ApiIssueSummary, single.body) or { panic(err) }
424 assert got.id == issue.id
425}
426
427fn test_api_v1_repo_issue_not_found() {
428 resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}/issues/99999')) or {
429 panic(err)
430 }
431 assert resp.status_code == 404
432}
433
434fn test_api_v1_repo_pulls_empty() {
435 resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}/pulls')) or { panic(err) }
436 assert resp.status_code == 200
437 prs := json.decode([]ApiPullSummary, resp.body) or { panic(err) }
438 assert prs.len == 0
439}
440
441fn test_api_v1_repo_pull_not_found() {
442 resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}/pulls/1')) or { panic(err) }
443 assert resp.status_code == 404
444}
445
446fn test_api_v1_pull_comments_not_found() {
447 resp := http.get(url('/api/v1/repos/${test_username}/${test_repo}/pulls/1/comments')) or {
448 panic(err)
449 }
450 assert resp.status_code == 404
451}
452
453fn test_api_v1_issues_count() {
454 resp := http.fetch(
455 method: .get
456 url: url('/api/v1/${test_username}/${test_repo}/issues/count')
457 cookies: {
458 'token': session_cookie()
459 }
460 ) or { panic(err) }
461 assert resp.status_code == 200
462 decoded := json.decode(ApiIssueCount, resp.body) or { panic(err) }
463 assert decoded.success
464 assert decoded.result >= 1
465}
466
467fn test_api_v1_issues_count_unauthenticated() {
468 resp := http.get(url('/api/v1/${test_username}/${test_repo}/issues/count')) or { panic(err) }
469 assert resp.body.contains('Not found')
470}
471
472fn test_api_v1_branches_count() {
473 resp := http.fetch(
474 method: .get
475 url: url('/api/v1/${test_username}/${test_repo}/branches/count')
476 cookies: {
477 'token': session_cookie()
478 }
479 ) or { panic(err) }
480 assert resp.status_code == 200
481 decoded := json.decode(ApiBranchCount, resp.body) or { panic(err) }
482 assert decoded.success
483 assert decoded.result == 0
484}
485
486fn test_api_v1_commits_count() {
487 resp := http.fetch(
488 method: .get
489 url: url('/api/v1/${test_username}/${test_repo}/main/commits/count')
490 cookies: {
491 'token': session_cookie()
492 }
493 ) or { panic(err) }
494 assert resp.status_code == 200
495 decoded := json.decode(ApiCommitCount, resp.body) or { panic(err) }
496 assert decoded.success
497 assert decoded.result == 0
498}
499
500fn test_api_v1_repo_star_toggle() {
501 rid := repo_id()
502 resp := http.fetch(
503 method: .post
504 url: url('/api/v1/repos/${rid}/star')
505 cookies: {
506 'token': session_cookie()
507 }
508 ) or { panic(err) }
509 assert resp.status_code == 200
510 first := json.decode(ApiBoolResult, resp.body) or { panic(err) }
511 assert first.success
512 assert first.result == true
513
514 resp2 := http.fetch(
515 method: .post
516 url: url('/api/v1/repos/${rid}/star')
517 cookies: {
518 'token': session_cookie()
519 }
520 ) or { panic(err) }
521 second := json.decode(ApiBoolResult, resp2.body) or { panic(err) }
522 assert second.result == false
523}
524
525fn test_api_v1_repo_watch_toggle() {
526 rid := repo_id()
527 resp := http.fetch(
528 method: .post
529 url: url('/api/v1/repos/${rid}/watch')
530 cookies: {
531 'token': session_cookie()
532 }
533 ) or { panic(err) }
534 assert resp.status_code == 200
535 first := json.decode(ApiBoolResult, resp.body) or { panic(err) }
536 assert first.success
537}
538
539fn test_api_v1_repo_tree_files_requires_branch() {
540 rid := repo_id()
541 resp := http.get(url('/api/v1/repos/${rid}/tree/files')) or { panic(err) }
542 assert resp.body.contains('branch is required')
543}
544
545fn test_api_v1_repo_tree_files_with_branch() {
546 rid := repo_id()
547 resp := http.get(url('/api/v1/repos/${rid}/tree/files?branch=main')) or { panic(err) }
548 assert resp.status_code == 200
549 decoded := json.decode(ApiFilesResult, resp.body) or { panic(err) }
550 assert decoded.success
551}
552
553fn test_api_v1_repo_tree_files_unknown_repo() {
554 resp := http.get(url('/api/v1/repos/9999999/tree/files?branch=main')) or { panic(err) }
555 assert resp.body.contains('Not found')
556}
557
558fn test_api_v1_users_avatar_requires_auth() {
559 resp := http.post_multipart_form(url('/api/v1/users/avatar'),
560 files: {
561 'file': [
562 http.FileData{
563 filename: 'a.png'
564 content_type: 'image/png'
565 data: 'x'
566 },
567 ]
568 }
569 ) or { panic(err) }
570 assert resp.status_code == 404
571}
572
573fn test_api_v1_ci_status_callback() {
574 rid := repo_id()
575 payload := '{"run_id":"123","repo_id":"${rid}","commit_hash":"deadbeef","branch":"main","status":"running"}'
576 resp := http.fetch(
577 method: .post
578 url: url('/api/v1/ci/status')
579 header: http.new_header(key: .content_type, value: 'application/json')
580 data: payload
581 ) or { panic(err) }
582 assert resp.status_code == 200
583 assert resp.body.contains('"success":true') || resp.body.contains('"success": true')
584}
585
586fn test_api_v1_ci_status_callback_rejects_bad_json() {
587 resp := http.fetch(
588 method: .post
589 url: url('/api/v1/ci/status')
590 header: http.new_header(key: .content_type, value: 'application/json')
591 data: 'not-json'
592 ) or { panic(err) }
593 assert resp.body.contains('Invalid request body')
594}
595
596fn test_api_v1_private_repo_visibility_from_other_user() {
597 // Sanity check: a second authenticated user can see the public test repo.
598 resp := http.fetch(
599 method: .get
600 url: url('/api/v1/repos/${test_username}/${test_repo}')
601 cookies: {
602 'token': other_session_cookie()
603 }
604 ) or { panic(err) }
605 assert resp.status_code == 200
606}
607