Forge
csharp3407750f
1using System.Net.Http.Json;
2using System.Text.Json;
3
4namespace AgentForge.Tests;
5
6[Collection("ForgeEnvironment")]
7public sealed class ForgeApiTests(ForgeWebApplicationFactory factory) : IClassFixture<ForgeWebApplicationFactory>
8{
9 private readonly HttpClient _client = factory.CreateClient();
10 private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
11
12 [Fact]
13 public async Task Create_repo_issue_and_merge_request()
14 {
15 var createRepo = await _client.PostAsJsonAsync("/api/v1/repos", new
16 {
17 name = "cad-tools",
18 description = "Agent tooling pilot repo",
19 });
20 createRepo.EnsureSuccessStatusCode();
21 var repo = await createRepo.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
22 Assert.Equal("cad-tools", repo.GetProperty("name").GetString());
23 Assert.Equal("git@test.local:cad-tools.git", repo.GetProperty("cloneUrl").GetString());
24 Assert.True(Directory.Exists(Path.Combine(factory.ReposPath, "cad-tools.git")));
25
26 var createIssue = await _client.PostAsJsonAsync("/api/v1/repos/cad-tools/issues", new
27 {
28 title = "Add zone separation",
29 body = "Intelligence zones must not live in one pile.",
30 anchors = new[]
31 {
32 new { file = "src/Zones.cs", lineStart = 10, lineEnd = 40 },
33 },
34 });
35 createIssue.EnsureSuccessStatusCode();
36 var issue = await createIssue.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
37 Assert.Equal(1, issue.GetProperty("number").GetInt32());
38 Assert.Equal("http://test.local/repos/cad-tools/issues/1", issue.GetProperty("issueUrl").GetString());
39 Assert.Equal(1, issue.GetProperty("anchors").GetArrayLength());
40
41 var createFromBracket = await _client.PostAsJsonAsync("/api/v1/repos/cad-tools/issues", new
42 {
43 title = "Bracket anchor",
44 anchorBrackets = new[] { "[F:src/Zones.cs; M:Apply; L:12-14; S:for:1]" },
45 });
46 createFromBracket.EnsureSuccessStatusCode();
47 var bracketIssue = await createFromBracket.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
48 Assert.Equal(2, bracketIssue.GetProperty("number").GetInt32());
49 Assert.Equal("[FRG:cad-tools/issues/2]", bracketIssue.GetProperty("forgeBracket").GetString());
50 Assert.Equal("[F:src/Zones.cs; M:Apply; L:12-14; S:for:1]", bracketIssue.GetProperty("anchorBrackets")[0].GetString());
51
52 var createMr = await _client.PostAsJsonAsync("/api/v1/repos/cad-tools/merge-requests", new
53 {
54 title = "feat: intelligence zones",
55 sourceBranch = "feat/zones",
56 targetBranch = "main",
57 });
58 createMr.EnsureSuccessStatusCode();
59 var mr = await createMr.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
60 Assert.Equal("feat/zones", mr.GetProperty("sourceBranch").GetString());
61 Assert.Equal("http://test.local/repos/cad-tools/merge-requests/1", mr.GetProperty("mrUrl").GetString());
62
63 var issues = await _client.GetFromJsonAsync<JsonElement>("/api/v1/repos/cad-tools/issues", JsonOptions);
64 Assert.Equal(2, issues.GetProperty("total").GetInt32());
65
66 var mergeRequests = await _client.GetFromJsonAsync<JsonElement>("/api/v1/repos/cad-tools/merge-requests", JsonOptions);
67 Assert.Equal(1, mergeRequests.GetProperty("total").GetInt32());
68 }
69
70 [Fact]
71 public async Task Issue_lifecycle_get_update_comment_anchors()
72 {
73 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "lens-repo" });
74
75 await _client.PostAsJsonAsync("/api/v1/repos/lens-repo/issues", new
76 {
77 title = "First",
78 body = "alpha",
79 });
80
81 var link = await _client.PostAsJsonAsync("/api/v1/repos/lens-repo/issues/1/anchors", new
82 {
83 anchors = new[] { new { file = "src/Foo.cs", lineStart = 5 } },
84 });
85 link.EnsureSuccessStatusCode();
86
87 var get = await _client.GetFromJsonAsync<JsonElement>("/api/v1/repos/lens-repo/issues/1", JsonOptions);
88 Assert.Equal("First", get.GetProperty("title").GetString());
89 Assert.Equal(1, get.GetProperty("anchors").GetArrayLength());
90
91 var patch = await _client.PatchAsJsonAsync("/api/v1/repos/lens-repo/issues/1", new
92 {
93 status = "closed",
94 body = "done",
95 });
96 patch.EnsureSuccessStatusCode();
97
98 using var commentRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/repos/lens-repo/issues/1/comments")
99 {
100 Content = JsonContent.Create(new { body = "Investigation complete." }),
101 };
102 commentRequest.Headers.TryAddWithoutValidation("X-Forge-Actor", "oar:TestInvestigator");
103 var comment = await _client.SendAsync(commentRequest);
104 comment.EnsureSuccessStatusCode();
105
106 get = await _client.GetFromJsonAsync<JsonElement>("/api/v1/repos/lens-repo/issues/1", JsonOptions);
107 Assert.Equal("closed", get.GetProperty("status").GetString());
108 Assert.Equal(1, get.GetProperty("comments").GetArrayLength());
109
110 var search = await _client.GetFromJsonAsync<JsonElement>("/api/v1/repos/lens-repo/issues?q=First", JsonOptions);
111 Assert.Equal(1, search.GetProperty("total").GetInt32());
112 }
113
114 [Fact]
115 public async Task Ci_callback_and_status()
116 {
117 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "ci-repo" });
118 await _client.PostAsJsonAsync("/api/v1/repos/ci-repo/merge-requests", new
119 {
120 title = "ci test",
121 sourceBranch = "feat/ci",
122 });
123
124 var callback = await _client.PostAsJsonAsync("/api/v1/ci/callback", new
125 {
126 repo = "ci-repo",
127 mr = 1,
128 commit = "abc123",
129 status = "success",
130 url = "https://ci.example/run/1",
131 });
132 callback.EnsureSuccessStatusCode();
133
134 var status = await _client.GetFromJsonAsync<JsonElement>("/api/v1/repos/ci-repo/merge-requests/1/ci", JsonOptions);
135 Assert.Equal("success", status.GetProperty("status").GetString());
136 Assert.Equal("abc123", status.GetProperty("commit").GetString());
137 }
138
139 [Fact]
140 public async Task Get_repo_returns_not_found_for_missing()
141 {
142 var response = await _client.GetAsync("/api/v1/repos/no-such-repo");
143 Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode);
144 }
145
146 [Fact]
147 public async Task Get_merge_request_returns_not_found_for_missing()
148 {
149 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "empty-repo" });
150 var response = await _client.GetAsync("/api/v1/repos/empty-repo/merge-requests/99");
151 Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode);
152 }
153
154 [Fact]
155 public async Task Merge_request_accept_merges_git_and_sets_merged()
156 {
157 await _client.PostAsJsonAsync("/api/v1/repos", new
158 {
159 name = "merge-repo",
160 driveMode = "human-driven",
161 });
162
163 factory.Git.CreateCommitOnBranch("merge-repo", "main", "README.md", "main line", "main");
164 factory.Git.CreateCommitOnBranch("merge-repo", "feat/x", "src/X.cs", "feature", "feat");
165
166 await _client.PostAsJsonAsync("/api/v1/repos/merge-repo/merge-requests", new
167 {
168 title = "add X",
169 sourceBranch = "feat/x",
170 targetBranch = "main",
171 });
172
173 var diff = await _client.GetFromJsonAsync<JsonElement>(
174 "/api/v1/repos/merge-repo/merge-requests/1/diff",
175 JsonOptions);
176 Assert.Equal("feat/x", diff.GetProperty("sourceBranch").GetString());
177 Assert.Equal(1, diff.GetProperty("stats").GetProperty("changedFiles").GetInt32());
178 var file = diff.GetProperty("files")[0];
179 Assert.Equal("src/X.cs", file.GetProperty("path").GetString());
180 Assert.Contains("feature", file.GetProperty("patch").GetString());
181
182 var accept = await _client.PostAsJsonAsync("/api/v1/repos/merge-repo/merge-requests/1/accept", new { });
183 accept.EnsureSuccessStatusCode();
184 var merged = await accept.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
185 Assert.Equal("merged", merged.GetProperty("status").GetString());
186
187 var view = await _client.GetAsync("/view/repos/merge-repo/merge-requests/1");
188 view.EnsureSuccessStatusCode();
189 var html = await view.Content.ReadAsStringAsync();
190 Assert.Contains("merged", html, StringComparison.OrdinalIgnoreCase);
191 }
192
193 [Fact]
194 public async Task Issue_comment_requires_named_author()
195 {
196 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "actor-repo" });
197 await _client.PostAsJsonAsync("/api/v1/repos/actor-repo/issues", new { title = "t" });
198
199 var missing = await _client.PostAsJsonAsync("/api/v1/repos/actor-repo/issues/1/comments", new
200 {
201 body = "who am i?",
202 });
203 Assert.Equal(System.Net.HttpStatusCode.BadRequest, missing.StatusCode);
204 }
205
206 [Fact]
207 public async Task Bootstrap_token_creates_api_token()
208 {
209 Environment.SetEnvironmentVariable("FORGE_BOOTSTRAP_TOKEN", "bootstrap-secret");
210 using var factory = new ForgeWebApplicationFactory();
211 using var client = factory.CreateClient();
212 using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/admin/tokens")
213 {
214 Content = JsonContent.Create(new { name = "pilot" }),
215 };
216 request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "bootstrap-secret");
217
218 var response = await client.SendAsync(request);
219 response.EnsureSuccessStatusCode();
220 var body = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
221 Assert.StartsWith("fg_", body.GetProperty("token").GetString());
222 Environment.SetEnvironmentVariable("FORGE_BOOTSTRAP_TOKEN", null);
223 }
224
225 [Fact]
226 public async Task Human_driven_accept_requires_accept_merge_scope_when_auth_enabled()
227 {
228 Environment.SetEnvironmentVariable("FORGE_REQUIRE_AUTH", "true");
229 Environment.SetEnvironmentVariable("FORGE_BOOTSTRAP_TOKEN", "bootstrap");
230 using var factory = new ForgeWebApplicationFactory();
231 using var client = factory.CreateClient();
232
233 var apiToken = await CreateBootstrapTokenAsync(client, "bootstrap", "read,write,accept_merge");
234 var auth = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiToken);
235
236 using (var createRepo = new HttpRequestMessage(HttpMethod.Post, "/api/v1/repos")
237 {
238 Content = JsonContent.Create(new { name = "auth-repo", driveMode = "human-driven" }),
239 })
240 {
241 createRepo.Headers.Authorization = auth;
242 (await client.SendAsync(createRepo)).EnsureSuccessStatusCode();
243 }
244
245 factory.Git.CreateCommitOnBranch("auth-repo", "main", "README.md", "main", "main");
246 factory.Git.CreateCommitOnBranch("auth-repo", "feat", "a.txt", "a", "feat");
247
248 using (var createMr = new HttpRequestMessage(HttpMethod.Post, "/api/v1/repos/auth-repo/merge-requests")
249 {
250 Content = JsonContent.Create(new { title = "mr", sourceBranch = "feat" }),
251 })
252 {
253 createMr.Headers.Authorization = auth;
254 (await client.SendAsync(createMr)).EnsureSuccessStatusCode();
255 }
256
257 var unauthorized = await client.PostAsJsonAsync("/api/v1/repos/auth-repo/merge-requests/1/accept", new { });
258 Assert.Equal(System.Net.HttpStatusCode.Unauthorized, unauthorized.StatusCode);
259
260 using var acceptRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/repos/auth-repo/merge-requests/1/accept")
261 {
262 Content = JsonContent.Create(new { }),
263 };
264 acceptRequest.Headers.Authorization = auth;
265 (await client.SendAsync(acceptRequest)).EnsureSuccessStatusCode();
266
267 Environment.SetEnvironmentVariable("FORGE_REQUIRE_AUTH", null);
268 Environment.SetEnvironmentVariable("FORGE_BOOTSTRAP_TOKEN", null);
269 }
270
271 private static async Task<string> CreateBootstrapTokenAsync(HttpClient client, string bootstrap, string scopes)
272 {
273 using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/admin/tokens")
274 {
275 Content = JsonContent.Create(new { name = "merger", scopes }),
276 };
277 tokenRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bootstrap);
278 var tokenResponse = await client.SendAsync(tokenRequest);
279 tokenResponse.EnsureSuccessStatusCode();
280 var tokenBody = await tokenResponse.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
281 return tokenBody.GetProperty("token").GetString()!;
282 }
283
284 [Fact]
285 public async Task Git_push_hook_requires_secret_when_configured()
286 {
287 Environment.SetEnvironmentVariable("FORGE_REQUIRE_AUTH", "false");
288 Environment.SetEnvironmentVariable("FORGE_GIT_HOOK_SECRET", "hook-secret");
289 try
290 {
291 using var factory = new ForgeWebApplicationFactory();
292 using var client = factory.CreateClient();
293 (await client.PostAsJsonAsync("/api/v1/repos", new { name = "push-repo" })).EnsureSuccessStatusCode();
294
295 var unauthorized = await client.PostAsJsonAsync("/api/v1/git/push", new
296 {
297 repo = "push-repo",
298 branch = "main",
299 commit = "abc",
300 });
301 Assert.Equal(System.Net.HttpStatusCode.Unauthorized, unauthorized.StatusCode);
302
303 using var okRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/git/push")
304 {
305 Content = JsonContent.Create(new { repo = "push-repo", branch = "main", commit = "abc" }),
306 };
307 okRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "hook-secret");
308 var ok = await client.SendAsync(okRequest);
309 ok.EnsureSuccessStatusCode();
310 }
311 finally
312 {
313 Environment.SetEnvironmentVariable("FORGE_GIT_HOOK_SECRET", null);
314 Environment.SetEnvironmentVariable("FORGE_REQUIRE_AUTH", null);
315 }
316 }
317
318 [Fact]
319 public async Task View_pages_render_after_repo_create()
320 {
321 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "view-pilot" });
322 await _client.PostAsJsonAsync("/api/v1/repos/view-pilot/issues", new { title = "UI smoke" });
323
324 var home = await _client.GetAsync("/view");
325 home.EnsureSuccessStatusCode();
326 var homeHtml = await home.Content.ReadAsStringAsync();
327 Assert.Contains("view-pilot", homeHtml, StringComparison.Ordinal);
328
329 var repo = await _client.GetAsync("/view/repos/view-pilot");
330 repo.EnsureSuccessStatusCode();
331 var repoHtml = await repo.Content.ReadAsStringAsync();
332 Assert.Contains("#1", repoHtml, StringComparison.Ordinal);
333
334 var issue = await _client.GetAsync("/view/repos/view-pilot/issues/1");
335 issue.EnsureSuccessStatusCode();
336 var issueHtml = await issue.Content.ReadAsStringAsync();
337 Assert.Contains("UI smoke", issueHtml, StringComparison.Ordinal);
338 }
339
340 [Fact]
341 public async Task Device_login_view_has_user_code_input()
342 {
343 var page = await _client.GetAsync("/view/auth/device");
344 page.EnsureSuccessStatusCode();
345 var html = await page.Content.ReadAsStringAsync();
346 Assert.Contains("name=\"code\"", html, StringComparison.Ordinal);
347 Assert.Contains("Look up request", html, StringComparison.Ordinal);
348 }
349}
350
View only · write via MCP/CIDE