| 1 | using System.Net.Http.Json; |
| 2 | using System.Text.Json; |
| 3 | |
| 4 | namespace AgentForge.Tests; |
| 5 | |
| 6 | [Collection("ForgeEnvironment")] |
| 7 | public 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 | |