Pin the cross-repository project item contract with tests

503f15f86fde · AtlantisPleb · · parent 2740bd179527

Pin the cross-repository project item contract with tests

A board that tracks an issue from another repository already stores the
project repository and the source issue repository separately, but the
suite only measured the public happy path and one hidden repository. The
authority split, the duplicate guard, and the reader-facing omissions
were unmeasured, so a later change could quietly widen project writes or
leak a private source issue.

Measures what the boundary promises: a private source issue a member can
read is added, a project reader who merely writes the source repository
gets 404, an unknown source repository or number gets 404, the same
source issue twice gets 422, the number resolves in the named repository
rather than the project's, an unreadable source item is absent from item
lists, item updates, LiveView cards, and the rendered HTML, and the
composite key accepts a genuine cross-repository pair.

Documents the 404, omission, and duplicate outcomes for CLI readers.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/openagents-cli/api.md
  • modified test/openagents/projects_test.exs
  • modified test/openagents/repositories_test.exs
  • modified test/openagents_web/controllers/project_controller_test.exs
  • modified test/openagents_web/live/project_show_live_test.exs

Diff

5 files changed, +364 -0

docs/openagents-cli/api.md modified +4

@@ -173,6 +173,10 @@ Item responses include the source issue's `owner`, `repo`, `number`, `url`,

173 173
and `html_url`. The legacy `issue_number` form continues to select an issue
174 174
from the project repository.
175 175
176
A source repository or issue that you cannot read returns `404`, and an item
177
list omits an issue that you cannot read. Adding the same source issue to a
178
project twice returns `422`.
179
176 180
Update the stored values for an item:
177 181
178 182
```sh
test/openagents/projects_test.exs modified +97

@@ -263,6 +263,103 @@ defmodule OpenAgents.ProjectsTest do

263 263
      assert Projects.list_project_items(project) == []
264 264
    end
265 265
266
    test "create_project_item/2 records a source issue from another repository", %{
267
      project: project
268
    } do
269
      source = repository_fixture(%{visibility: "public"})
270
      source_issue = issue_fixture(source, title: "Cross-repository work")
271
272
      assert {:ok, %ProjectItem{} = item} =
273
               Projects.create_project_item(
274
                 %{"issue_number" => source_issue.number, "issue_repository_id" => source.id},
275
                 project
276
               )
277
278
      assert item.issue_id == source_issue.id
279
      assert item.issue_repository_id == source.id
280
      assert item.repository_id == project.repository_id
281
      assert item.issue.repository.id == source.id
282
    end
283
284
    test "create_project_item/2 reads the number in the named source repository", %{
285
      project: project,
286
      issue: issue
287
    } do
288
      source = repository_fixture(%{visibility: "public"})
289
      source_issue = issue_fixture(source, title: "Same number, other repository")
290
291
      assert source_issue.number == issue.number
292
293
      assert {:ok, %ProjectItem{} = item} =
294
               Projects.create_project_item(
295
                 %{"issue_number" => source_issue.number, "issue_repository_id" => source.id},
296
                 project
297
               )
298
299
      assert item.issue_id == source_issue.id
300
      refute item.issue_id == issue.id
301
    end
302
303
    test "create_project_item/2 refuses the same source issue twice", %{
304
      project: project,
305
      issue: issue
306
    } do
307
      {:ok, _item} = Projects.create_project_item(%{"issue_number" => issue.number}, project)
308
309
      assert {:error, %Ecto.Changeset{} = changeset} =
310
               Projects.create_project_item(%{"issue_number" => issue.number}, project)
311
312
      assert %{project_id: ["has already been taken"]} = errors_on(changeset)
313
      assert length(Projects.list_project_items(project)) == 1
314
    end
315
316
    test "list_visible_project_items/2 omits an unreadable source issue", %{
317
      project: project,
318
      issue: issue
319
    } do
320
      {:ok, local} = Projects.create_project_item(%{"issue_number" => issue.number}, project)
321
      private = repository_fixture(%{visibility: "private"})
322
      private_issue = issue_fixture(private, title: "Private work")
323
324
      {:ok, hidden} =
325
        Projects.create_project_item(
326
          %{"issue_number" => private_issue.number, "issue_repository_id" => private.id},
327
          project
328
        )
329
330
      viewer = repository_user_fixture("project-source-viewer")
331
332
      assert Enum.map(Projects.list_visible_project_items(project, viewer), & &1.id) == [local.id]
333
      assert Enum.map(Projects.list_visible_project_items(project, nil), & &1.id) == [local.id]
334
335
      {:ok, _membership} = OpenAgents.Repositories.add_member(private, viewer, "viewer")
336
337
      assert Projects.list_visible_project_items(project, viewer)
338
             |> Enum.map(& &1.id)
339
             |> Enum.sort() == Enum.sort([local.id, hidden.id])
340
    end
341
342
    test "get_visible_project_item!/3 hides an unreadable source issue", %{project: project} do
343
      private = repository_fixture(%{visibility: "private"})
344
      private_issue = issue_fixture(private, title: "Private work")
345
346
      {:ok, hidden} =
347
        Projects.create_project_item(
348
          %{"issue_number" => private_issue.number, "issue_repository_id" => private.id},
349
          project
350
        )
351
352
      viewer = repository_user_fixture("project-item-viewer")
353
354
      assert_raise Ecto.NoResultsError, fn ->
355
        Projects.get_visible_project_item!(project, hidden.id, viewer)
356
      end
357
358
      {:ok, _membership} = OpenAgents.Repositories.add_member(private, viewer, "viewer")
359
360
      assert Projects.get_visible_project_item!(project, hidden.id, viewer).id == hidden.id
361
    end
362
266 363
    test "update_project_item/2 merges into existing values", %{
267 364
      project: project,
268 365
      issue: issue
test/openagents/repositories_test.exs modified +23

@@ -122,6 +122,29 @@ defmodule OpenAgents.RepositoriesTest do

122 122
    end
123 123
  end
124 124
125
  test "a project item may name a source issue in another repository", %{
126
    initial: initial,
127
    second: second,
128
    user: user
129
  } do
130
    {:ok, _membership} = Repositories.add_member(initial, user, "maintainer")
131
    assert {:ok, project} = Projects.create_project(initial, %{title: "Initial"}, user)
132
    assert {:ok, second_issue} = Issues.create_issue(second, %{title: "Second"})
133
134
    assert {:ok, item} =
135
             %ProjectItem{}
136
             |> ProjectItem.changeset(%{
137
               project_id: project.id,
138
               issue_id: second_issue.id,
139
               repository_id: initial.id,
140
               issue_repository_id: second.id
141
             })
142
             |> Repo.insert()
143
144
    assert item.repository_id == initial.id
145
    assert item.issue_repository_id == second.id
146
  end
147
125 148
  test "project-item and comment constraints reject mismatched repository identities", %{
126 149
    initial: initial,
127 150
    second: second,
test/openagents_web/controllers/project_controller_test.exs modified +182

@@ -151,6 +151,39 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

151 151
      assert rendered["values"] == %{"Status" => "Todo"}
152 152
    end
153 153
154
    test "GET .../projectsV2/:project_number/items omits an unreadable source issue", %{
155
      conn: conn
156
    } do
157
      project = project_fixture(%{title: "Roadmap", owner: "alice"})
158
      {:ok, local_issue} = create_issue(%{title: "Local work"})
159
160
      {:ok, _local_item} =
161
        Projects.create_project_item(%{"issue_number" => local_issue.number}, project)
162
163
      source =
164
        repository_fixture(%{owner: "HiddenOrg", name: "hidden-api", visibility: "private"})
165
166
      {:ok, hidden_issue} = Issues.create_issue(source, %{title: "Confidential work"})
167
168
      {:ok, _hidden_item} =
169
        Projects.create_project_item(
170
          %{"issue_number" => hidden_issue.number, "issue_repository_id" => source.id},
171
          project
172
        )
173
174
      conn =
175
        get(conn, ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items")
176
177
      body = response(conn, 200)
178
      refute body =~ "hidden-api"
179
      refute body =~ "HiddenOrg"
180
181
      assert %{"items" => [%{"issue" => %{"repo" => "project-api", "number" => number}}]} =
182
               Jason.decode!(body)
183
184
      assert number == local_issue.number
185
    end
186
154 187
    test "GET .../projectsV2/:project_number/items returns 404 for a missing project", %{
155 188
      conn: conn
156 189
    } do

@@ -225,6 +258,129 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

225 258
      assert source_id == source.id
226 259
    end
227 260
261
    test "POST .../items adds a private source issue the member can read", %{
262
      conn: conn,
263
      project: project,
264
      user: user
265
    } do
266
      source =
267
        repository_with_member_fixture(
268
          user,
269
          %{owner: "ReadableOrg", name: "readable-api", visibility: "private"},
270
          "viewer"
271
        )
272
273
      {:ok, issue} = Issues.create_issue(source, %{title: "Private but readable"})
274
275
      conn =
276
        post(
277
          conn,
278
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
279
          %{issue: %{owner: "ReadableOrg", repo: "readable-api", number: issue.number}}
280
        )
281
282
      assert %{"items" => [%{"issue_id" => issue_id}]} = json_response(conn, 201)
283
      assert issue_id == issue.id
284
    end
285
286
    test "POST .../items refuses a project reader who can write the source repository", %{
287
      project: project,
288
      repository: repository
289
    } do
290
      reader = github_user("api-token-project-reader", "carol")
291
      {:ok, _membership} = OpenAgents.Repositories.add_member(repository, reader, "viewer")
292
293
      source =
294
        repository_with_member_fixture(
295
          reader,
296
          %{owner: "WriterOrg", name: "writer-api", visibility: "private"},
297
          "owner"
298
        )
299
300
      {:ok, issue} = Issues.create_issue(source, %{title: "Their own work"})
301
302
      conn =
303
        post(
304
          put_forge_api_token(build_conn(), "project-reader", "carol"),
305
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
306
          %{issue: %{owner: "WriterOrg", repo: "writer-api", number: issue.number}}
307
        )
308
309
      assert json_response(conn, 404) == %{"message" => "Not Found"}
310
      assert Projects.list_project_items(project) == []
311
    end
312
313
    test "POST .../items returns 404 for an unknown source repository", %{
314
      conn: conn,
315
      project: project
316
    } do
317
      conn =
318
        post(
319
          conn,
320
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
321
          %{issue: %{owner: "NoSuchOrg", repo: "no-such-api", number: 1}}
322
        )
323
324
      assert json_response(conn, 404) == %{"message" => "Not Found"}
325
      assert Projects.list_project_items(project) == []
326
    end
327
328
    test "POST .../items returns 404 for an unknown issue in a readable source repository", %{
329
      conn: conn,
330
      project: project
331
    } do
332
      repository_fixture(%{owner: "SourceOrg", name: "empty-api", visibility: "public"})
333
334
      conn =
335
        post(
336
          conn,
337
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
338
          %{issue: %{owner: "SourceOrg", repo: "empty-api", number: 999_999}}
339
        )
340
341
      assert json_response(conn, 404) == %{"message" => "Not Found"}
342
      assert Projects.list_project_items(project) == []
343
    end
344
345
    test "POST .../items reads the number in the named source repository", %{
346
      conn: conn,
347
      project: project,
348
      issue: issue
349
    } do
350
      source =
351
        repository_fixture(%{owner: "SourceOrg", name: "same-number-api", visibility: "public"})
352
353
      {:ok, source_issue} = Issues.create_issue(source, %{title: "Same number elsewhere"})
354
      assert source_issue.number == issue.number
355
356
      conn =
357
        post(
358
          conn,
359
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
360
          %{issue: %{owner: "SourceOrg", repo: "same-number-api", number: source_issue.number}}
361
        )
362
363
      assert %{"items" => [%{"issue_id" => issue_id}]} = json_response(conn, 201)
364
      assert issue_id == source_issue.id
365
      refute issue_id == issue.id
366
    end
367
368
    test "POST .../items refuses the same source issue twice", %{
369
      conn: conn,
370
      project: project
371
    } do
372
      source =
373
        repository_fixture(%{owner: "SourceOrg", name: "repeat-api", visibility: "public"})
374
375
      {:ok, issue} = Issues.create_issue(source, %{title: "Added once"})
376
      body = %{issue: %{owner: "SourceOrg", repo: "repeat-api", number: issue.number}}
377
      path = ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items"
378
379
      assert %{"items" => [_item]} = json_response(post(conn, path, body), 201)
380
      assert %{"errors" => _errors} = json_response(post(conn, path, body), 422)
381
      assert length(Projects.list_project_items(project)) == 1
382
    end
383
228 384
    test "POST .../items hides an unreadable source repository", %{
229 385
      conn: conn,
230 386
      project: project

@@ -367,6 +523,32 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

367 523
      assert Projects.get_project_item!(project, item.id).values == %{"Status" => "Done"}
368 524
    end
369 525
526
    test "PATCH .../items/:item_id returns 404 for an unreadable source issue", %{
527
      conn: conn,
528
      project: project
529
    } do
530
      source =
531
        repository_fixture(%{owner: "HiddenOrg", name: "hidden-patch-api", visibility: "private"})
532
533
      {:ok, issue} = Issues.create_issue(source, %{title: "Confidential work"})
534
535
      {:ok, item} =
536
        Projects.create_project_item(
537
          %{"issue_number" => issue.number, "issue_repository_id" => source.id},
538
          project
539
        )
540
541
      conn =
542
        patch(
543
          conn,
544
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}",
545
          %{values: %{"Status" => "Done"}}
546
        )
547
548
      assert json_response(conn, 404) == %{"message" => "Not Found"}
549
      assert Projects.get_project_item!(project, item.id).values == %{}
550
    end
551
370 552
    test "PATCH .../items/:item_id keeps values it was not asked to change", %{
371 553
      conn: conn,
372 554
      project: project,
test/openagents_web/live/project_show_live_test.exs modified +58

@@ -198,6 +198,64 @@ defmodule OpenAgentsWeb.ProjectShowLiveTest do

198 198
           )
199 199
  end
200 200
201
  test "a cross-repository card names and links to its source repository", %{conn: conn} do
202
    project = project!()
203
204
    source =
205
      repository_fixture(%{owner: "SourceOrg", name: "source-board", visibility: "public"})
206
207
    {:ok, issue} = Issues.create_issue(source, %{"title" => "Cross-repository card"})
208
209
    {:ok, _item} =
210
      Projects.create_project_item(
211
        %{
212
          "issue_number" => issue.number,
213
          "issue_repository_id" => source.id,
214
          "values" => %{"Status" => "To Do"}
215
        },
216
        project
217
      )
218
219
    {:ok, view, html} = live(conn, path(project))
220
221
    assert html =~ "SourceOrg/source-board##{issue.number}"
222
223
    assert has_element?(
224
             view,
225
             ~s{a[href="/SourceOrg/source-board/issues/#{issue.number}"]},
226
             "Cross-repository card"
227
           )
228
  end
229
230
  test "a card whose source repository is unreadable never renders", %{conn: conn} do
231
    project = project!()
232
233
    source =
234
      repository_fixture(%{owner: "HiddenOrg", name: "hidden-board", visibility: "private"})
235
236
    {:ok, issue} = Issues.create_issue(source, %{"title" => "Confidential card"})
237
238
    {:ok, _item} =
239
      Projects.create_project_item(
240
        %{
241
          "issue_number" => issue.number,
242
          "issue_repository_id" => source.id,
243
          "values" => %{"Status" => "To Do"}
244
        },
245
        project
246
      )
247
248
    {:ok, view, html} = live(conn, path(project))
249
250
    refute html =~ "Confidential card"
251
    refute html =~ "hidden-board"
252
253
    refute has_element?(
254
             view,
255
             ~s{a[href="/HiddenOrg/hidden-board/issues/#{issue.number}"]}
256
           )
257
  end
258
201 259
  test "a missing project number raises rather than rendering an empty board", %{conn: conn} do
202 260
    assert_raise Ecto.NoResultsError, fn ->
203 261
      live(conn, ~p"/OpenAgentsInc/openagents.com/projects/9999")

This page updates live while a promote is in flight · changelog