test/openagents/data_rights/export_inventory_test.exs

58e6347eeb72 · 18 KB

defmodule OpenAgents.DataRights.ExportInventoryTest do
  @moduledoc """
  EXIT-001. The export ledger has to match the surface in both directions.

  A test that only checked the `:portable` claims would let the ledger go
  pessimistic without anyone noticing, and a fix that quietly closed a gap
  would leave a stale "you cannot export this" standing in the documentation.
  So every probed family is asserted against observed behavior, and a family
  that starts working turns this red until the ledger is corrected.
  """

  use OpenAgentsWeb.ConnCase, async: false

  alias OpenAgents.ApiTokens
  alias OpenAgents.DataRights.ExportInventory
  alias OpenAgents.Issues
  alias OpenAgents.Repo
  alias OpenAgents.Repositories
  alias OpenAgents.Reputation
  alias OpenAgentsWeb.ApiRouteAuthority

  # Families whose recorded status this test observes rather than trusts.
  @probed [
    :issue,
    :project,
    :repository,
    :comment,
    :label,
    :milestone,
    :assignee,
    :issue_label,
    :issue_assignee,
    :push_receipt,
    :forum,
    :thread,
    :box,
    :computer,
    :agent,
    :deployment,
    :pull_request,
    :stack,
    :issue_dependency,
    :reputation
  ]

  describe "coverage" do
    test "every published API family is classified, and no stale family remains" do
      assert ExportInventory.api_family_drift() == %{unclassified: [], stale: []}
      assert ApiRouteAuthority.families() != []
    end

    test "each family appears once" do
      families = Enum.map(ExportInventory.entries(), & &1.family)
      assert families == Enum.uniq(families)
    end
  end

  describe "claim discipline" do
    test "a portable claim names a mechanism and a proof, and owes no issue" do
      for entry <- ExportInventory.with_status(:portable) do
        assert is_binary(entry.mechanism) and entry.mechanism != "",
               "#{entry.family} is portable with no mechanism"

        assert entry.proof != nil, "#{entry.family} is portable with no proof"
        assert entry.issue == nil, "#{entry.family} is portable and still names an issue"
      end
    end

    # The ledger records no blocked family today: #142 opened the
    # private-repository metadata reads and #143 exported the forge-owned and
    # forum-owned families. The shape check stays rather than being deleted,
    # because the honest move when a family becomes unreadable is to record it
    # blocked, and this is what that record has to look like when it appears.
    test "a blocked family is probed here, not asserted from prose" do
      for entry <- ExportInventory.with_status(:blocked) do
        assert entry.proof == :inventory, "#{entry.family} is blocked without a probe"
        assert entry.mechanism == nil, "#{entry.family} is blocked and still names a mechanism"
        assert is_integer(entry.issue) and entry.issue > 0
      end
    end

    test "a gap names the open issue that closes it" do
      for entry <- ExportInventory.with_status(:partial) do
        assert is_integer(entry.issue) and entry.issue > 0,
               "#{entry.family} is partial with no issue"
      end
    end

    test "a family excluded as non-user data says why and claims nothing" do
      for entry <- ExportInventory.with_status(:not_user_data) do
        assert String.length(entry.note) > 20, "#{entry.family} is excluded without a reason"
        assert entry.mechanism == nil
        assert entry.proof == nil
        assert entry.issue == nil
      end
    end

    test "every named proof resolves to a file or a current invariant" do
      ledger = File.read!("INVARIANTS.md")

      for entry <- ExportInventory.entries() do
        case entry.proof do
          {:test, path} ->
            assert File.exists?(path), "#{entry.family} names missing proof #{path}"

          {:invariant, id} ->
            assert String.contains?(ledger, "### #{id} — "),
                   "#{entry.family} names missing invariant #{id}"

          _inventory_or_none ->
            :ok
        end
      end
    end

    test "every probed family is in the ledger and every probe has a family" do
      classified = Enum.map(ExportInventory.entries(), & &1.family)
      assert @probed -- classified == []
    end
  end

  describe "probes" do
    setup %{conn: conn} do
      owner = github_user("export-inventory-owner", "exit-owner")

      {:ok, repository, :created} =
        Repositories.create_user_repository(
          owner,
          %{name: "private-work", visibility: "private"},
          "export-inventory-create"
        )

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

      issue = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "exportable"})
      {:ok, _comment} = Issues.create_comment(issue, %{body: "a comment of mine"}, owner)
      _label = OpenAgents.LabelsFixtures.label_fixture(repository, %{name: "mine"})
      _milestone = OpenAgents.MilestonesFixtures.milestone_fixture(repository)
      _project = OpenAgents.ProjectsFixtures.project_fixture(repository, %{title: "my board"})

      {:ok, _credential, token} =
        ApiTokens.create(owner, %{name: "export inventory probe", scopes: ["forge:write"]})

      account_export = seed_and_export(owner, repository)

      %{
        conn: put_req_header(conn, "authorization", "Bearer " <> token),
        owner: owner,
        repository: repository,
        issue: issue,
        account_export: account_export
      }
    end

    test "the repository really is private, so a blocked probe means blocked", %{
      repository: repository
    } do
      assert repository.visibility == "private"

      assert build_conn()
             |> get(~p"/api/v1/repos/exit-owner/private-work/issues")
             |> json_response(404)
    end

    test "each probed family behaves the way the ledger records", context do
      for family <- @probed do
        entry = ExportInventory.entry(family)
        observed = probe(family, context)

        assert observed == entry.status,
               "#{family}: the ledger records #{inspect(entry.status)} but the surface is " <>
                 "#{inspect(observed)}. Update OpenAgents.DataRights.ExportInventory in the " <>
                 "same change that moved the surface."
      end
    end
  end

  ## ── the account-scoped export ──────────────────────────────────────────

  # Every family the ledger records as portable through GET /data/export/account
  # is probed by seeding one record and reading it back through the route, so a
  # portability claim here is a round trip and not a reading of the source.
  defp seed_and_export(owner, repository) do
    board =
      Repo.insert!(%OpenAgents.Forum.Forum{
        slug: "exit-inventory",
        title: "Exit inventory",
        visibility: "public"
      })

    topic =
      Repo.insert!(%OpenAgents.Forum.Topic{
        forum_id: board.id,
        idempotency_key: "exit-inventory-topic",
        slug: "exit-inventory-topic",
        title: "Mine",
        actor_ref: "user:" <> owner.id,
        actor_display_name: "exit-owner",
        post_count: 1
      })

    Repo.insert!(%OpenAgents.Forum.Post{
      topic_id: topic.id,
      idempotency_key: "exit-inventory-post",
      post_number: 1,
      body_text: "The account's own post.",
      actor_ref: "user:" <> owner.id,
      actor_display_name: "exit-owner"
    })

    {:ok, thread} = OpenAgents.Threads.open(owner, "Prove the export reaches a thread.")
    {:ok, _thread} = OpenAgents.Threads.record_event(thread, "thread.probe", %{"probe" => true})

    Repo.insert!(%OpenAgents.Forge.PushReceipt{
      repo: repository.storage_key,
      wal_seq: 0,
      principal: "user:" <> owner.id,
      refs: %{"refs/heads/main" => String.duplicate("0", 40)}
    })

    Repo.insert!(%OpenAgents.Machines.Machine{
      user_id: owner.id,
      name: "exit-inventory-computer",
      token_digest: :crypto.hash(:sha256, "exit-inventory"),
      token_expires_at: DateTime.add(DateTime.utc_now(), 3600, :second)
    })

    agent =
      Repo.insert!(%OpenAgents.Agents.Agent{
        handle: "exit-inventory-agent",
        display_name: "Exit inventory",
        registration_ip_digest: :crypto.hash(:sha256, "exit-inventory-agent")
      })

    Repo.insert!(%OpenAgents.Agents.AgentUserLink{agent_id: agent.id, user_id: owner.id})

    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)

    box =
      Repo.insert!(%OpenAgents.Box.ConversationBox{
        conversation_id: conversation.id,
        box_id: "box_exit_inventory",
        label: "exit-inventory",
        state: "ready"
      })

    now = DateTime.utc_now()

    Repo.insert!(%OpenAgents.Box.Run{
      conversation_id: conversation.id,
      conversation_box_id: box.id,
      requesting_principal: %{"kind" => "user", "id" => owner.id},
      idempotency_key: "exit-inventory-run",
      run_directory: "/tmp/exit-inventory-run",
      command: "echo exit",
      state: "completed",
      exit_status: 0,
      output: "exit\n",
      admitted_at: now,
      deadline_at: DateTime.add(now, 600, :second)
    })

    _environment = OpenAgents.DeploymentsFixtures.environment_fixture(repository, owner)
    _run = OpenAgents.DeploymentsFixtures.run_fixture(repository, owner)

    seed_repository_work(owner, repository)

    build_conn()
    |> Plug.Test.init_test_session(%{"user_id" => owner.id})
    |> get(~p"/data/export/account")
    |> json_response(200)
  end

  # The three repository-keyed families the account export reads across every
  # repository. They are seeded in the same private repository the metadata
  # probes use, so a widened read that lost the readable_by join would still
  # pass here — that direction is proven in the account export's own test,
  # where the account is not a member.
  defp seed_repository_work(owner, repository) do
    issue = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "PR exit-inventory"})

    {:ok, pull_request} =
      %OpenAgents.PullRequests.PullRequest{}
      |> OpenAgents.PullRequests.PullRequest.changeset(%{
        repository_id: repository.id,
        issue_id: issue.id,
        head_repository_id: repository.id,
        opened_by_user_id: owner.id,
        head_ref: "exit-inventory-branch",
        head_sha: String.duplicate("b", 40),
        base_ref: "main",
        base_sha: String.duplicate("c", 40),
        state: "open"
      })
      |> Repo.insert()

    {:ok, _stack} = OpenAgents.Stacks.create(repository, [pull_request], owner)

    blocked = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "exit blocked"})
    blocker = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "exit blocker"})
    :ok = Issues.add_dependencies(blocked, [blocker.number], owner)

    seed_attestation(owner, repository)

    :ok
  end

  # A reputation attestation names its subject with a bare string, so the seed
  # has to establish the binding as well as the record: a claim the account
  # makes and the operator decides. Without the linked claim the export
  # returns nothing here, which is what this family recorded before #171.
  defp seed_attestation(owner, repository) do
    {:ok, claim} =
      Reputation.claim_subject(owner, %{
        subject_kind: "account",
        subject_id: "user:" <> owner.id
      })

    {:ok, _linked} = Reputation.approve_subject_claim(claim)

    issue = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "exit attested"})

    policy =
      case Reputation.admit_policy(%{
             authenticated: true,
             actor_id: "operator:export-inventory",
             auth_method: "test_session",
             approval_receipt_ref: "export-inventory:#{System.unique_integer([:positive])}"
           }) do
        {:ok, policy} -> policy
        {:error, _already_admitted} -> List.last(Reputation.policies())
      end

    keypair = OpenAgents.Reputation.Claim.generate_keypair()

    {:ok, key} =
      Reputation.admit_key(%{public_key: keypair.public_key, issuer: "export-inventory"})

    decision = OpenAgents.CompensationFixtures.outcome_decision_fixture()

    {:ok, _attestation} =
      Reputation.issue(policy, %{key_id: key.key_id, private_key: keypair.private_key}, %{
        event_type: "completion",
        subject_id: "user:" <> owner.id,
        outcome: %{kind: "compensation_outcome_decision", ref: decision.decision_receipt_ref},
        repository: repository,
        issue_number: issue.number,
        revision: String.duplicate("a", 40),
        artifact_digest: String.duplicate("1", 64),
        confidence_ppm: 900_000,
        transparency_tier: "repository",
        evidence: [
          %{
            kind: "outcome",
            ref: decision.decision_receipt_ref,
            digest: decision.outcome_digest,
            observed_at: DateTime.to_iso8601(DateTime.utc_now())
          }
        ]
      })

    :ok
  end

  ## ── probes ─────────────────────────────────────────────────────────────

  defp probe(:reputation, %{account_export: export}) do
    attestations = export["repository_work"]["attestations"]

    if Enum.any?(
         attestations["records"],
         &(&1["subject_id"] in attestations["established_subjects"])
       ),
       do: :portable,
       else: :partial
  end

  defp probe(:pull_request, %{account_export: export}) do
    if Enum.any?(
         export["repository_work"]["pull_requests"]["records"],
         &(&1["head_ref"] == "exit-inventory-branch")
       ),
       do: :portable,
       else: :partial
  end

  defp probe(:stack, %{account_export: export}) do
    stack = List.first(export["repository_work"]["stacks"]["records"])

    cond do
      stack == nil -> :partial
      Enum.any?(stack["entries"], &(&1["head_ref"] == "exit-inventory-branch")) -> :portable
      true -> :partial
    end
  end

  defp probe(:issue_dependency, %{account_export: export}) do
    if Enum.any?(
         export["repository_work"]["issue_dependencies"]["records"],
         &(&1["blocked_by_issue_title"] == "exit blocker")
       ),
       do: :portable,
       else: :partial
  end

  defp probe(:forum, %{account_export: export}) do
    if Enum.any?(export["forum"]["posts"], &(&1["body_text"] == "The account's own post.")) and
         Enum.any?(export["forum"]["topics"], &(&1["title"] == "Mine")),
       do: :portable,
       else: :partial
  end

  defp probe(:thread, %{account_export: export}) do
    thread = Enum.find(export["threads"]["records"], &(&1["objective"] =~ "Prove the export"))

    cond do
      thread == nil -> :partial
      Enum.any?(thread["events"], &(&1["event_type"] == "thread.probe")) -> :portable
      true -> :partial
    end
  end

  defp probe(:box, %{account_export: export}) do
    if Enum.any?(export["boxes"]["leases"], &(&1["box_id"] == "box_exit_inventory")) and
         Enum.any?(export["boxes"]["runs"], &(&1["command"] == "echo exit")),
       do: :portable,
       else: :partial
  end

  defp probe(:computer, %{account_export: export}) do
    if Enum.any?(export["computers"], &(&1["name"] == "exit-inventory-computer")),
      do: :portable,
      else: :partial
  end

  defp probe(:agent, %{account_export: export}) do
    if Enum.any?(export["agent_links"], &(&1["agent"]["handle"] == "exit-inventory-agent")),
      do: :portable,
      else: :partial
  end

  defp probe(:deployment, %{account_export: export}) do
    if export["deployments"]["requests"] != [], do: :portable, else: :partial
  end

  defp probe(:issue, %{conn: conn, issue: issue}) do
    case get(conn, ~p"/api/v1/repos/exit-owner/private-work/issues") do
      %{status: 200} = response ->
        numbers =
          response |> json_response(200) |> Map.fetch!("issues") |> Enum.map(& &1["number"])

        if issue.number in numbers, do: :portable, else: :partial

      _refused ->
        :blocked
    end
  end

  defp probe(:project, %{conn: conn}) do
    case get(conn, ~p"/api/v1/repos/exit-owner/private-work/projectsV2") do
      %{status: 200} = response ->
        titles =
          response |> json_response(200) |> Map.fetch!("projects") |> Enum.map(& &1["title"])

        if "my board" in titles, do: :portable, else: :partial

      _refused ->
        :blocked
    end
  end

  defp probe(:repository, %{conn: conn}) do
    case get(conn, ~p"/api/v1/user/repos") do
      %{status: 200} = response ->
        names =
          response
          |> json_response(200)
          |> then(fn body -> body["repositories"] || body end)
          |> Enum.map(& &1["name"])

        if "private-work" in names, do: :portable, else: :partial

      _refused ->
        :blocked
    end
  end

  defp probe(:comment, %{conn: conn, issue: issue}) do
    read_probe(conn, ~p"/api/v1/repos/exit-owner/private-work/issues/#{issue.number}/comments")
  end

  defp probe(:label, %{conn: conn}) do
    read_probe(conn, ~p"/api/v1/repos/exit-owner/private-work/labels")
  end

  defp probe(:milestone, %{conn: conn}) do
    read_probe(conn, ~p"/api/v1/repos/exit-owner/private-work/milestones")
  end

  defp probe(:assignee, %{conn: conn}) do
    read_probe(conn, ~p"/api/v1/repos/exit-owner/private-work/assignees")
  end

  defp probe(:issue_label, %{conn: conn, issue: issue}) do
    read_probe(conn, ~p"/api/v1/repos/exit-owner/private-work/issues/#{issue.number}/labels")
  end

  defp probe(:issue_assignee, %{conn: conn, issue: issue}) do
    read_probe(conn, ~p"/api/v1/repos/exit-owner/private-work/issues/#{issue.number}/assignees")
  end

  # No /api/v1 route serves receipts and none is expected to; the claim is that
  # the account export returns the account's own rows, so that is what is
  # probed. A receipt whose principal is not this account must not appear.
  defp probe(:push_receipt, %{account_export: export, owner: owner}) do
    records = export["push_receipts"]["records"]
    principals = records |> Enum.map(& &1["principal"]) |> Enum.uniq()

    cond do
      records == [] -> :blocked
      principals != ["user:" <> owner.id] -> :partial
      true -> :portable
    end
  end

  defp read_probe(conn, path) do
    case get(conn, path) do
      %{status: 200} -> :portable
      _refused -> :blocked
    end
  end
end