test/openagents_web/controllers/push_receipt_controller_test.exs

main at 58e6347eeb72 · 6 KB

defmodule OpenAgentsWeb.PushReceiptControllerTest do
  @moduledoc """
  EXIT-001 and EXIT-005, stage 2. `git push` prints the pusher the WAL
  sequence and chain link their push produced; this is where they read it
  again when the terminal is gone.

  What the route can and cannot settle is asserted as carefully as it is
  written. It publishes the WAL's own record rather than the derived
  `forge_pushes` rows, so a pusher comparing a retained link against what this
  serves is comparing against the same record `Verification.verify/2` reads.
  It does not turn a re-fetch into evidence: an operator who rewrote the log
  would serve the rewritten link here too, which is why the link the pusher
  kept is the anchor and this is a convenience.
  """

  use OpenAgentsWeb.ConnCase, async: false

  alias OpenAgents.ApiTokens
  alias OpenAgents.Forge.WAL
  alias OpenAgents.Repositories

  setup %{conn: conn} do
    base = Path.join(System.tmp_dir!(), "push-receipts-#{System.unique_integer([:positive])}")
    File.mkdir_p!(base)
    previous = Application.get_env(:openagents, :forge_wal_dir)
    Application.put_env(:openagents, :forge_wal_dir, base)

    on_exit(fn ->
      Application.put_env(:openagents, :forge_wal_dir, previous)
      File.rm_rf(base)
    end)

    owner = github_user("push-receipt-owner", "receipt-owner")

    {:ok, public, :created} =
      Repositories.create_user_repository(
        owner,
        %{name: "open-work", visibility: "public"},
        "push-receipt-public"
      )

    {:ok, private, :created} =
      Repositories.create_user_repository(
        owner,
        %{name: "closed-work", visibility: "private"},
        "push-receipt-private"
      )

    public = ready!(public)
    private = ready!(private)

    {:ok, _credential, token} =
      ApiTokens.create(owner, %{name: "push receipt reader", scopes: ["forge:write"]})

    %{
      conn: conn,
      owner_conn: put_req_header(conn, "authorization", "Bearer " <> token),
      public: public,
      private: private
    }
  end

  describe "the receipts a pusher can re-read" do
    test "the list carries the sequence, the ref change, and the chain link", %{
      conn: conn,
      public: repository
    } do
      entries = seed_wal!(repository.storage_key, 3)

      body =
        conn
        |> get(~p"/api/v1/repos/receipt-owner/open-work/pushes")
        |> json_response(200)

      assert body["repo"] == "receipt-owner/open-work"
      assert body["chained_from"] == 0

      # Newest first, so the head — the link worth remembering — is first.
      assert Enum.map(body["pushes"], & &1["wal_seq"]) == [2, 1, 0]

      assert Enum.map(body["pushes"], & &1["link"]) ==
               entries |> Enum.map(&WAL.entry_link/1) |> Enum.reverse()

      head = hd(body["pushes"])
      assert head["principal"] == "user:pusher"
      assert head["refs"]["refs/heads/main"]["new"] == sha(2)
      assert head["refs"]["refs/heads/main"]["old"] == sha(1)
    end

    test "one receipt is fetchable by the sequence the push printed", %{
      conn: conn,
      public: repository
    } do
      entries = seed_wal!(repository.storage_key, 2)
      expected = Enum.at(entries, 1)

      body =
        conn
        |> get(~p"/api/v1/repos/receipt-owner/open-work/pushes/1")
        |> json_response(200)

      assert body["wal_seq"] == 1
      assert body["link"] == WAL.entry_link(expected)
      assert body["pushed_at"] == expected["pushed_at"]
    end

    test "the published link is the link the verifier recomputes", %{
      conn: conn,
      public: repository
    } do
      entries = seed_wal!(repository.storage_key, 2)
      head = List.last(entries)

      body =
        conn
        |> get(~p"/api/v1/repos/receipt-owner/open-work/pushes/1")
        |> json_response(200)

      assert body["link"] == WAL.entry_link(head)

      # The route is a projection of the WAL, so an anchor built from what it
      # served raises no anchor finding against the log it came from, and one
      # link short of it does. (These entries name objects no repository
      # holds, so the other findings are expected here and irrelevant.)
      assert anchor_codes(repository, %{seq: body["wal_seq"], link: body["link"]}) == []

      assert anchor_codes(repository, %{seq: body["wal_seq"], link: String.duplicate("0", 64)}) ==
               ["anchor_mismatch"]
    end

    test "a repository nobody has pushed to has an empty record, not a missing one", %{
      conn: conn,
      public: repository
    } do
      assert {:error, :not_found} = WAL.read_index(repository.storage_key)

      body =
        conn
        |> get(~p"/api/v1/repos/receipt-owner/open-work/pushes")
        |> json_response(200)

      assert body["pushes"] == []
      assert body["chained_from"] == nil
    end

    test "a sequence the log does not have is not found", %{conn: conn, public: repository} do
      seed_wal!(repository.storage_key, 1)

      assert conn
             |> get(~p"/api/v1/repos/receipt-owner/open-work/pushes/9")
             |> json_response(404)
    end
  end

  describe "who may read them" do
    test "a private repository refuses an anonymous reader", %{conn: conn, private: repository} do
      seed_wal!(repository.storage_key, 1)

      assert conn
             |> get(~p"/api/v1/repos/receipt-owner/closed-work/pushes")
             |> json_response(404)
    end

    test "a private repository serves its own member", %{
      owner_conn: conn,
      private: repository
    } do
      seed_wal!(repository.storage_key, 1)

      body =
        conn
        |> get(~p"/api/v1/repos/receipt-owner/closed-work/pushes")
        |> json_response(200)

      assert Enum.map(body["pushes"], & &1["wal_seq"]) == [0]
    end
  end

  defp anchor_codes(repository, anchor) do
    {_ok_or_error, report} =
      OpenAgents.Forge.Verification.verify(repository.storage_key, anchor: anchor)

    report.findings
    |> Enum.map(& &1.code)
    |> Enum.filter(&String.starts_with?(&1, "anchor_"))
  end

  defp ready!(repository) do
    repository
    |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
    |> OpenAgents.Repo.update!()
  end

  # Entries shaped exactly as `OpenAgents.Forge.Pushes.persist/4` writes them,
  # appended through `WAL.append_entry/2` so they carry real chain links.
  defp seed_wal!(storage_key, count) do
    index =
      Enum.reduce(0..(count - 1), WAL.new_index(), fn seq, index ->
        {:ok, object} = WAL.put_entry(storage_key, seq, "pack-#{seq}")

        WAL.append_entry(index, %{
          "seq" => seq,
          "object" => object,
          "format" => "receive_pack",
          "refs" => %{"refs/heads/main" => sha(seq)},
          "principal" => "user:pusher",
          "pushed_at" => DateTime.utc_now() |> DateTime.to_iso8601()
        })
      end)

    {:ok, _generation} = WAL.cas_index(storage_key, :none, index)
    WAL.entries(index)
  end

  defp sha(seq), do: String.duplicate(Integer.to_string(seq), 40)
end