Repository navigation
Expand file tree
/
Copy pathai_fix_authorization_test.v
More file actions
431 lines (405 loc) · 17 KB
/
Copy pathai_fix_authorization_test.v
File metadata and controls
431 lines (405 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
module main
import config
import git
import net.http
import os
import rand
import veb
fn ai_authorization_git(args []string) string {
result := git.Git.exec(args)
if result.exit_code != 0 {
panic('git ${args} failed: ${result.output}')
}
return result.output.trim_space()
}
fn ai_authorization_fixture(is_public bool) !(&App, string, Repo, PullRequest, string) {
root := os.join_path(os.temp_dir(), 'gitly_ai_authorization_${rand.ulid()}')
os.mkdir_all(root)!
work := os.join_path(root, 'work')
bare := os.join_path(root, 'project.git')
ai_authorization_git(['init', '-b', 'main', work])
ai_authorization_git(['-C', work, 'config', 'user.name', 'AI Test'])
ai_authorization_git(['-C', work, 'config', 'user.email', 'ai-test@example.test'])
os.write_file(os.join_path(work, 'README.md'), 'test repository\n')!
ai_authorization_git(['-C', work, 'add', 'README.md'])
ai_authorization_git(['-C', work, 'commit', '-m', 'Initial source'])
ai_authorization_git(['-C', work, 'branch', 'feature'])
ai_authorization_git(['clone', '--bare', work, bare])
head_oid := ai_authorization_git(['-C', bare, 'rev-parse', 'refs/heads/feature'])
conf := config.Config{
repo_storage_path: root
archive_path: root
avatars_path: root
ci_service_url: 'http://127.0.0.1:1'
ci_secret: 'test-ci-secret'
sqlite: config.SqliteConfig{
path: os.join_path(root, 'test.sqlite')
}
}
mut app := &App{
db: connect_db(conf)!
config: conf
}
app.create_tables()!
for user in [User{ id: 1, username: 'owner', is_registered: true },
User{ id: 2, username: 'developer', is_registered: true },
User{ id: 3, username: 'reader', is_registered: true }] {
sql app.db {
insert user into User
}!
}
app.add_repo(Repo{
id: 1
git_dir: bare
name: 'project'
user_id: 1
user_name: 'owner'
is_public: is_public
primary_branch: 'main'
status: .done
})!
repo := app.find_repo_by_id(1) or { panic('test repository missing') }
pr_id := app.add_pull_request(repo.id, 1, 'Fix feature', '', 'feature', 'main')!
pr := app.find_pull_request_by_id(pr_id) or { panic('test PR missing') }
app.add_ci_status(CiStatus{
repo_id: repo.id
commit_hash: head_oid
branch: 'feature'
status: .failure
ci_run_id: 101
})!
return app, root, repo, pr, head_oid
}
fn ai_authorization_cleanup(mut app App, root string) {
app.db.close() or {}
os.rmdir_all(root) or {}
}
fn test_ai_fix_requires_latest_failed_attempt_for_actual_full_head() {
$if sqlite ? {
mut app, root, repo, pr, head_oid := ai_authorization_fixture(true)!
defer { ai_authorization_cleanup(mut app, root) }
initial := app.ai_fix_context(1, repo, pr)!
assert initial.head_oid == head_oid
assert initial.source_repo.id == repo.id
assert initial.ci_status.ci_run_id == 101
assert app.can_ai_fix_pull_request(1, repo, pr)
// A newer retry supersedes an older failure even for the same commit.
app.upsert_ci_status(repo.id, head_oid, 'feature', .pending, 102)!
assert !app.can_ai_fix_pull_request(1, repo, pr)
assert (app.pull_request_ci_status(1, repo, pr) or { panic('CI missing') }).status == .pending
assert app.apply_ci_status_callback(repo.id, head_oid, 'feature', 102, .success)!
assert !app.can_ai_fix_pull_request(1, repo, pr)
app.upsert_ci_status(repo.id, head_oid, 'feature', .timed_out, 103)!
assert app.can_ai_fix_pull_request(1, repo, pr)
app.upsert_ci_status(repo.id, head_oid, 'feature', .cancelled, 104)!
assert !app.can_ai_fix_pull_request(1, repo, pr)
// Neither another branch nor a previous commit supplies current CI.
app.upsert_ci_status(repo.id, head_oid, 'unrelated', .failure, 105)!
assert !app.can_ai_fix_pull_request(1, repo, pr)
app.upsert_ci_status(repo.id, head_oid, 'feature', .failure, 106)!
assert app.can_ai_fix_pull_request(1, repo, pr)
tree := ai_authorization_git(['-C', repo.git_dir, 'rev-parse', '${head_oid}^{tree}'])
new_head := git_commit_tree(repo.git_dir, tree, [head_oid], 'Test User', 'New source revision')!
update_git_ref_expected(repo.git_dir, 'refs/heads/feature', new_head, head_oid)!
assert !app.can_ai_fix_pull_request(1, repo, pr)
assert app.pull_request_ci_status(1, repo, pr) == none
app.upsert_ci_status(repo.id, new_head, 'feature', .failure, 107)!
assert (app.ai_fix_context(1, repo, pr)!).head_oid == new_head
// A failed trigger has no runner logs, and ambiguous run IDs fail closed.
app.add_ci_status(CiStatus{
repo_id: repo.id
commit_hash: new_head
branch: 'feature'
status: .failure
ci_run_id: 0
})!
assert !app.can_ai_fix_pull_request(1, repo, pr)
app.upsert_ci_status(repo.id, new_head, 'feature', .failure, 108)!
assert app.can_ai_fix_pull_request(1, repo, pr)
app.add_ci_status(CiStatus{
repo_id: 999
commit_hash: new_head
branch: 'feature'
status: .failure
ci_run_id: 108
})!
assert !app.can_ai_fix_pull_request(1, repo, pr)
} $else {
assert true
}
}
fn test_ai_fix_respects_push_protection_signed_commits_and_open_pr_state() {
$if sqlite ? {
mut app, root, repo, pr, _ := ai_authorization_fixture(true)!
defer { ai_authorization_cleanup(mut app, root) }
assert !app.can_ai_fix_pull_request(0, repo, pr)
assert !app.can_ai_fix_pull_request(3, repo, pr)
assert app.pull_request_ci_status(3, repo, pr) != none
app.add_project_member(repo.id, 2, 'developer')!
assert app.can_ai_fix_pull_request(2, repo, pr)
rule := app.protect_branch(repo.id, 'feature', project_access_maintainer, project_access_maintainer)!
assert !app.can_ai_fix_pull_request(2, repo, pr)
assert app.can_ai_fix_pull_request(1, repo, pr)
app.update_protected_branch(repo.id, rule, project_access_no_one, project_access_maintainer)!
assert !app.can_ai_fix_pull_request(1, repo, pr)
app.unprotect_branch(repo.id, rule)!
assert app.can_ai_fix_pull_request(1, repo, pr)
for state in [PrStatus.closed, PrStatus.merged] {
assert !app.can_ai_fix_pull_request(1, repo, PullRequest{ ...pr, status: int(state) })
}
assert !app.can_ai_fix_pull_request(1, repo, PullRequest{ ...pr, repo_id: 999 })
assert !app.can_ai_fix_pull_request(1, repo, PullRequest{ ...pr, head_branch: 'missing' })
assert !app.can_ai_fix_pull_request(1, repo, PullRequest{ ...pr, head_branch: '../main' })
repo_id := repo.id
required := true
sql app.db {
update Repo set require_signed_commits = required where id == repo_id
}!
signed_repo := app.find_repo_by_id(repo.id) or { panic('repository missing') }
assert !app.can_ai_fix_pull_request(1, signed_repo, pr)
assert app.pull_request_ci_status(1, signed_repo, pr) != none
unsigned := false
sql app.db {
update Repo set require_signed_commits = unsigned where id == repo_id
}!
assert app.can_ai_fix_pull_request(1, repo, pr)
configured := app.config
app.config = config.Config{ ...configured, ci_service_url: '' }
assert !app.can_ai_fix_pull_request(1, repo, pr)
app.config = config.Config{ ...configured, ci_secret: '' }
assert !app.can_ai_fix_pull_request(1, repo, pr)
} $else {
assert true
}
}
fn test_ai_fix_revoked_private_member_loses_action_and_ci_visibility() {
$if sqlite ? {
mut app, root, repo, pr, _ := ai_authorization_fixture(false)!
defer { ai_authorization_cleanup(mut app, root) }
member := app.add_project_member(repo.id, 2, 'developer')!
assert app.can_ai_fix_pull_request(2, repo, pr)
assert app.pull_request_ci_status(2, repo, pr) != none
app.remove_project_member(repo.id, member)!
assert !app.can_ai_fix_pull_request(2, repo, pr)
assert app.pull_request_ci_status(2, repo, pr) == none
assert app.pull_request_ci_status(0, repo, pr) == none
} $else {
assert true
}
}
fn test_ai_fix_cross_fork_checks_source_permissions_and_source_ci_identity() {
$if sqlite ? {
mut app, root, target, _, head_oid := ai_authorization_fixture(true)!
defer { ai_authorization_cleanup(mut app, root) }
fork_dir := os.join_path(root, 'fork.git')
ai_authorization_git(['clone', '--bare', target.git_dir, fork_dir])
app.add_repo(Repo{
id: 2
git_dir: fork_dir
name: 'fork'
user_id: 2
user_name: 'developer'
primary_branch: 'main'
status: .done
})!
relation := RepoFork{ repo_id: 2, source_repo_id: target.id, root_repo_id: target.id, created_by: 2 }
sql app.db {
insert relation into RepoFork
}!
pr_id := app.add_pull_request_from_repo(target.id, 2, 2, 'Fork feature', '', 'feature', 'main')!
pr := app.find_pull_request_by_id(pr_id) or { panic('fork PR missing') }
// The target has a failure at this OID, but the source fork has no CI yet.
assert !app.can_ai_fix_pull_request(2, target, pr)
assert app.pull_request_ci_status(2, target, pr) == none
app.upsert_ci_status(2, head_oid, 'feature', .failure, 201)!
context := app.ai_fix_context(2, target, pr)!
assert context.source_repo.id == 2
assert context.ci_status.repo_id == 2
assert context.ci_status.ci_run_id == 201
assert context.head_oid == head_oid
// No cached target PR ref is needed to identify the actual source tip.
assert git.Git.exec_in_dir(target.git_dir, ['show-ref', '--verify', pr.head_ref()]).exit_code != 0
// Owning the target confers no access to this private source fork.
assert !app.can_ai_fix_pull_request(1, target, pr)
assert app.pull_request_ci_status(1, target, pr) == none
member := app.add_project_member(2, 1, 'reporter')!
assert app.pull_request_ci_status(1, target, pr) != none
assert !app.can_ai_fix_pull_request(1, target, pr)
app.update_project_member_role(2, member, 'developer')!
assert app.can_ai_fix_pull_request(1, target, pr)
app.remove_project_member(2, member)!
assert !app.can_ai_fix_pull_request(1, target, pr)
// A branch move in the fork cannot reuse the target or old source status.
tree := ai_authorization_git(['-C', fork_dir, 'rev-parse', '${head_oid}^{tree}'])
new_head := git_commit_tree(fork_dir, tree, [head_oid], 'Fork User', 'New fork revision')!
update_git_ref_expected(fork_dir, 'refs/heads/feature', new_head, head_oid)!
assert app.pull_request_ci_status(2, target, pr) == none
assert !app.can_ai_fix_pull_request(2, target, pr)
app.upsert_ci_status(2, new_head, 'feature', .failure, 202)!
assert (app.ai_fix_context(2, target, pr)!).head_oid == new_head
fork_id := 2
sql app.db {
delete from RepoFork where repo_id == fork_id
}!
assert !app.can_ai_fix_pull_request(2, target, pr)
assert app.pull_request_ci_status(2, target, pr) == none
} $else {
assert true
}
}
fn test_ai_fix_ci_details_require_exact_registered_failed_run() {
ci := CiStatus{
id: 1
repo_id: 1
commit_hash: '0123456789abcdef0123456789abcdef01234567'
branch: 'feature'
status: .failure
ci_run_id: 123
}
run := CiRunDetail{
id: ci.ci_run_id
commit_hash: ci.commit_hash
branch: ci.branch
status: 'failure'
}
assert ai_fix_ci_run_matches(ci, run)
assert !ai_fix_ci_run_matches(ci, CiRunDetail{ ...run, id: 124 })
assert !ai_fix_ci_run_matches(ci, CiRunDetail{ ...run, branch: 'main' })
assert !ai_fix_ci_run_matches(ci, CiRunDetail{ ...run, commit_hash: 'abcdef0123456789abcdef0123456789abcdef01' })
assert !ai_fix_ci_run_matches(ci, CiRunDetail{ ...run, commit_hash: ci.commit_hash[..8] })
for status in ['pending', 'running', 'success', 'cancelled', 'timed_out', 'unknown'] {
assert !ai_fix_ci_run_matches(ci, CiRunDetail{ ...run, status: status })
}
assert ai_fix_ci_run_matches(CiStatus{ ...ci, status: .timed_out }, CiRunDetail{ ...run, status: 'timed_out' })
assert !ai_fix_ci_run_matches(CiStatus{ ...ci, status: .success }, run)
assert !ai_fix_ci_run_matches(CiStatus{ ...ci, ci_run_id: 0 }, CiRunDetail{ ...run, id: 0 })
}
fn test_ai_fix_csrf_tokens_are_bound_to_session_repository_and_pr() {
token := ai_fix_csrf_token('server-secret', 'session-one', 1, 2)
assert token != ''
assert token == ai_fix_csrf_token('server-secret', 'session-one', 1, 2)
assert token != ai_fix_csrf_token('other-secret', 'session-one', 1, 2)
assert token != ai_fix_csrf_token('server-secret', 'session-two', 1, 2)
assert token != ai_fix_csrf_token('server-secret', 'session-one', 2, 2)
assert token != ai_fix_csrf_token('server-secret', 'session-one', 1, 3)
assert ai_fix_csrf_token('', 'session-one', 1, 2) == ''
assert ai_fix_csrf_token('server-secret', '', 1, 2) == ''
assert ai_fix_csrf_token('server-secret', 'session-one', 0, 2) == ''
assert ai_fix_csrf_token('server-secret', 'session-one', 1, 0) == ''
}
fn ai_authorization_request(user User, session_token string, form map[string]string) Context {
mut req := http.Request{
method: .post
url: '/owner/project/pull/1/ai-fix'
header: http.new_header()
}
if session_token != '' {
req.header.set(.cookie, 'token=${session_token}')
// Direct handler calls bypass HTTP parsing, which normally fills this map.
req.add_cookie(http.Cookie{ name: 'token', value: session_token })
}
return Context{
Context: veb.Context{
req: req
form: form.clone()
res: http.Response{
header: http.new_header()
}
}
logged_in: true
user: user
}
}
fn ai_authorization_post(mut app App, mut ctx Context, repo Repo, pr PullRequest, apply bool) {
if apply {
app.apply_pull_request_ai_fix(mut ctx, repo.user_name, repo.name, pr.id.str())
} else {
app.generate_pull_request_ai_fix(mut ctx, repo.user_name, repo.name, pr.id.str())
}
}
fn test_ai_fix_posts_reject_csrf_stale_identity_and_invalid_patch_before_side_effects() {
$if sqlite ? {
mut app, root, repo, pr, head_oid := ai_authorization_fixture(true)!
defer { ai_authorization_cleanup(mut app, root) }
user := app.get_user_by_id(1) or { panic('owner missing') }
session_token := 'test-browser-session'
csrf := ai_fix_csrf_token(app.config.ci_secret, session_token, repo.id, pr.id)
base_form := {
'csrf_token': csrf
'head_oid': head_oid
'ci_run_id': '101'
'provider': 'claude'
'api_key': 'request-secret-must-not-be-rendered'
'patch_base64': 'invalid!encoding'
}
for apply in [false, true] {
for submitted_token in ['', 'wrong-csrf-token'] {
mut form := base_form.clone()
form['csrf_token'] = submitted_token
mut ctx := ai_authorization_request(user, session_token, form)
ai_authorization_post(mut app, mut ctx, repo, pr, apply)
assert ctx.res.status_code == 403
assert !ctx.res.body.contains(base_form['api_key'])
assert ctx.res.header.get(.cache_control) or { '' } == 'no-store'
}
mut missing_cookie := ai_authorization_request(user, '', base_form)
ai_authorization_post(mut app, mut missing_cookie, repo, pr, apply)
assert missing_cookie.res.status_code == 403
mut stale_form := base_form.clone()
stale_form['head_oid'] = '0'.repeat(head_oid.len)
mut stale := ai_authorization_request(user, session_token, stale_form)
ai_authorization_post(mut app, mut stale, repo, pr, apply)
assert stale.res.status_code == 409
assert stale.res.body.contains('changed')
assert !stale.res.body.contains(base_form['api_key'])
}
mut malformed_patch := ai_authorization_request(user, session_token, base_form)
app.apply_pull_request_ai_fix(mut malformed_patch, repo.user_name, repo.name, pr.id.str())
assert malformed_patch.res.status_code == 400
assert malformed_patch.res.body.contains('encoding')
assert ai_authorization_git(['-C', repo.git_dir, 'rev-parse', 'refs/heads/feature']) == head_oid
// A newer failed attempt must invalidate a form for the old run too.
app.upsert_ci_status(repo.id, head_oid, 'feature', .failure, 102)!
for apply in [false, true] {
mut stale_run := ai_authorization_request(user, session_token, base_form)
ai_authorization_post(mut app, mut stale_run, repo, pr, apply)
assert stale_run.res.status_code == 409
assert stale_run.res.body.contains('changed')
}
assert ai_authorization_git(['-C', repo.git_dir, 'rev-parse', 'refs/heads/feature']) == head_oid
assert (app.find_ci_status_for_commit(repo.id, head_oid, 'feature') or {
panic('CI missing')
}).ci_run_id == 102
} $else {
assert true
}
}
fn test_ai_fix_direct_posts_reject_revoked_push_permission() {
$if sqlite ? {
mut app, root, repo, pr, head_oid := ai_authorization_fixture(true)!
defer { ai_authorization_cleanup(mut app, root) }
member := app.add_project_member(repo.id, 2, 'developer')!
assert app.can_ai_fix_pull_request(2, repo, pr)
user := app.get_user_by_id(2) or { panic('developer missing') }
session_token := 'developer-browser-session'
form := {
'csrf_token': ai_fix_csrf_token(app.config.ci_secret, session_token, repo.id, pr.id)
'head_oid': head_oid
'ci_run_id': '101'
'provider': 'claude'
'api_key': 'request-secret'
'patch_base64': 'invalid!encoding'
}
app.remove_project_member(repo.id, member)!
for apply in [false, true] {
mut ctx := ai_authorization_request(user, session_token, form)
ai_authorization_post(mut app, mut ctx, repo, pr, apply)
assert ctx.res.status_code == 409
assert ctx.res.body.contains('not allowed to push')
assert !ctx.res.body.contains(form['api_key'])
}
assert ai_authorization_git(['-C', repo.git_dir, 'rev-parse', 'refs/heads/feature']) == head_oid
} $else {
assert true
}
}