test/openagents/forge/independence_disclosure_test.exs

58e6347eeb72 · 16 KB

defmodule OpenAgents.Forge.IndependenceDisclosureTest do
  @moduledoc """
  EXIT-006. A gap the forge records privately and reports publicly as health is
  a gap the forge has hidden.

  The disclosure is only worth publishing if it cannot drift from the thing it
  describes, so every assertion here derives its expectation from the source of
  truth rather than restating it: the export section is compared against
  `OpenAgents.DataRights.ExportInventory`, and the verification section is
  compared against the configured anchor source rather than a constant.
  """

  use OpenAgentsWeb.ConnCase, async: false

  import Phoenix.LiveViewTest

  alias OpenAgents.DataRights.ExportInventory
  alias OpenAgents.Forge.Anchor
  alias OpenAgents.Forge.Independence
  alias OpenAgents.Forge.Repos

  # Every string the disclosure may contain. A repository path, an account id,
  # a node name, or a commit sha reaching this projection fails here, which is
  # STATUS-001's rule applied to the one section that talks about the operator.
  @vocabulary ~w(
    openagents.forge_independence.v1
    docs/forge-operator-independence.md
    single_operator
    tamper_evident
    tamper_evident_published
    /.well-known/openagents-forge-anchor.json
    refs/heads/main
    portable partial blocked not_user_data
  )

  # The projection is briefly cached so anonymous traffic cannot become an rpc
  # storm. A test that publishes an anchor would otherwise leave that state in
  # the cache for whichever test runs next.
  setup do
    :persistent_term.erase({OpenAgents.NetworkStatus, :cache})
    on_exit(fn -> :persistent_term.erase({OpenAgents.NetworkStatus, :cache}) end)
    :ok
  end

  describe "the disclosure derives from the ledger" do
    test "every gap the export ledger records is published, and no other" do
      recorded =
        (ExportInventory.with_status(:partial) ++ ExportInventory.with_status(:blocked))
        |> Enum.map(&%{"family" => Atom.to_string(&1.family), "issue" => &1.issue})
        |> Enum.sort_by(& &1["family"])

      published =
        Independence.projection()["export"]["gaps"]
        |> Enum.map(&Map.take(&1, ["family", "issue"]))

      assert published == recorded
    end

    test "the family counts are the ledger's own counts" do
      export = Independence.projection()["export"]

      assert export["families"] == length(ExportInventory.entries())

      for status <- [:portable, :partial, :blocked, :not_user_data] do
        assert export[Atom.to_string(status)] == length(ExportInventory.with_status(status))
      end
    end
  end

  describe "degraded is decided, not asserted" do
    test "an unpublished anchor is enough on its own to report degraded" do
      projection = Independence.projection()

      refute projection["verification"]["anchor_published"]
      assert projection["verification"]["property"] == "tamper_evident"
      assert projection["verification"]["issue"] == 168
      assert projection["degraded"]
      assert Independence.degraded?()
    end

    test "a plaintext store is enough on its own to report degraded" do
      # #178 closed the export half of the private-data axis. If closing it
      # had quietly taken that axis out of the disjunction, a forge with
      # plaintext columns would report itself independent as soon as the
      # verification axis cleared. Every other axis is handed in clean here,
      # which `degraded?/3` exists for: the projection alone cannot show that
      # this axis is load-bearing, because the others are never clean.
      clean_export = %{"gaps" => []}
      clean_verification = %{"anchor_published" => true, "anchor_witnessed" => true}
      private_data = Independence.projection()["private_data"]

      refute Independence.degraded?(clean_export, clean_verification, %{
               private_data
               | "encrypted_at_rest" => true,
                 "operator_reads_source" => false
             })

      assert Independence.degraded?(clean_export, clean_verification, private_data)
    end

    # The publication state is read from the anchors that exist, not from a
    # flag someone can set, so this test publishes a real one.
    test "publishing an anchor changes the verification claim" do
      {:ok, _anchor} = Anchor.publish()

      verification = Independence.projection()["verification"]

      assert verification["anchor_published"]
      assert verification["property"] == "tamper_evident_published"
      assert verification["anchor"] == Anchor.path()
    end

    # The whole point of ADR 0008: the operator serves the anchor, so
    # publishing it does not make it evidence against the operator. A
    # disclosure that closed the verification axis on publication alone would
    # be claiming exactly what the anchor cannot show.
    test "a published anchor is still not witnessed, and still reports degraded" do
      {:ok, _anchor} = Anchor.publish()

      projection = Independence.projection()

      assert projection["verification"]["anchor_published"]
      refute projection["verification"]["anchor_witnessed"]
      assert projection["verification"]["issue"] == 151
      assert projection["degraded"]
      assert Independence.degraded?()
    end

    test "the export encryption and the plaintext store are published together" do
      # #178 decided that an export can be encrypted to a recipient-held key
      # and that the columns behind it stay plaintext. Publishing the first
      # without the second would let a reader conclude the operator cannot
      # read an export, so the second is what `operator_reads_source` says
      # and it is derived from the first rather than asserted beside it.
      private_data = Independence.projection()["private_data"]

      assert private_data["export_recipient_encryption"]
      refute private_data["encrypted_at_rest"]
      assert private_data["operator_reads_source"]
      assert private_data["access_controlled"]
      assert private_data["issue"] == 193
    end

    test "the export encryption claim is read from the route rather than asserted" do
      # The claim is true exactly while `OpenAgentsWeb.DataController` was
      # compiled against the module that encrypts. Nothing here restates it,
      # so removing the encryption removes the claim in the same commit.
      {:ok, {module, [imports: imports]}} =
        :beam_lib.chunks(:code.which(OpenAgentsWeb.DataController), [:imports])

      assert module == OpenAgentsWeb.DataController

      assert OpenAgents.DataRights.Age in Enum.map(imports, &elem(&1, 0)),
             "EXIT-006 derives `export_recipient_encryption` from this call. If the export " <>
               "route stopped encrypting, the disclosure must stop claiming it."
    end

    test "the account export still takes the account and nothing else" do
      # `access_controlled` is derived from this shape, which is the whole
      # access-control claim for this document: no parameter can widen it.
      assert function_exported?(OpenAgents.DataRights.AccountExport, :build, 1)
      refute function_exported?(OpenAgents.DataRights.AccountExport, :build, 2)
    end
  end

  describe "the disclosure publishes its own distance from the proven revision" do
    # #187 ran the forge 57 commits behind `main` for long enough for six exit
    # surfaces to be proven and absent at the same time, and every tip-shaped
    # check stayed green. The distance is the disclosure's own margin of error,
    # so it is derived from the repository this forge serves rather than
    # restated.
    setup :bare_repository

    test "the distance is the commit count between the running revision and the served head",
         %{shas: shas} do
      # Three commits on `refs/heads/main`. A node running the first is two
      # behind, a node running the head is current, and each answer is a count
      # `git` produced, not a number this test also computes by hand.
      assert %{"known" => true, "behind" => 2} = Independence.deployment(Enum.at(shas, 0))
      assert %{"known" => true, "behind" => 1} = Independence.deployment(Enum.at(shas, 1))
      assert %{"known" => true, "behind" => 0} = Independence.deployment(Enum.at(shas, 2))
    end

    test "the ref the distance is measured to is named" do
      # A distance with no ref names nothing. `RELEASE-004` binds the proof
      # matrix to the candidate sha before `.githooks/pre-push` lets it reach
      # this ref, which is what makes its head the proven revision.
      assert Independence.projection()["deployment"]["proven_ref"] == "refs/heads/main"
    end

    test "a revision this forge never accepted reports no distance" do
      assert %{"known" => false, "behind" => nil} =
               Independence.deployment(String.duplicate("a", 40))
    end

    test "a running revision that is not a commit at all reports no distance" do
      # The packaged image reports `"image"` until a deployment commits a sha.
      # That is a real state, and it must withhold rather than guess.
      assert %{"known" => false, "behind" => nil} = Independence.deployment("image")
      assert %{"known" => false, "behind" => nil} = Independence.deployment(nil)
    end

    test "a forge that does not serve its own repository reports no distance", %{shas: shas} do
      # The bound this section names. A forge withholding its own repository
      # reports nothing here, exactly as `EXIT-005` and `EXIT-006` decline to
      # detect a forge that withholds a log.
      previous = Application.get_env(:openagents, :forge_repos)
      Application.put_env(:openagents, :forge_repos, ["served-by-nobody"])
      on_exit(fn -> Application.put_env(:openagents, :forge_repos, previous) end)

      assert %{"known" => false, "behind" => nil} = Independence.deployment(Enum.at(shas, 0))
    end

    test "neither revision the distance lies between reaches the projection", %{shas: shas} do
      # `STATUS-001` keeps commit shas off this page, and adding one was among
      # the six mutations `EXIT-006`'s proof was confirmed against. A distance
      # is a number and fits; the revisions it is a distance between do not.
      published = strings(Independence.projection()) ++ strings(Independence.deployment(hd(shas)))

      for sha <- shas, published_value <- published do
        refute String.contains?(published_value, sha)
        refute String.contains?(published_value, String.slice(sha, 0, 12))
      end
    end

    test "the distance is read from the served repository rather than asserted" do
      # The same compiled-import-table read `export_recipient_encryption` uses.
      # `OpenAgents.Forge.Repos` is reached from this module for one reason —
      # counting the commits between the running revision and the served head —
      # so a hardcoded distance loses this call in the same commit.
      {:ok, {module, [imports: imports]}} =
        :beam_lib.chunks(:code.which(Independence), [:imports])

      assert module == Independence

      assert OpenAgents.Forge.Repos in Enum.map(imports, &elem(&1, 0)),
             "EXIT-006 derives the deployment distance from a git read of the repository " <>
               "this forge serves. A disclosure that stopped reading it must stop " <>
               "publishing a distance."
    end

    test "being behind is not an independence shortfall" do
      # Deliberate, and recorded in EXIT-006: a node one commit behind is not
      # less independent, and folding ordinary deploy lag into `degraded` would
      # make the verdict mean nothing on the day it mattered. The distance is
      # published beside the verdict, never inside it.
      clean_export = %{"gaps" => []}
      clean_verification = %{"anchor_published" => true, "anchor_witnessed" => true}

      clean_private_data = %{
        "export_recipient_encryption" => true,
        "encrypted_at_rest" => true
      }

      refute Independence.degraded?(clean_export, clean_verification, clean_private_data)
    end
  end

  describe "the disclosure carries no content" do
    test "every string in the projection is a family name, a status, or fixed vocabulary" do
      families = Enum.map(ExportInventory.entries(), &Atom.to_string(&1.family))
      allowed = MapSet.new(@vocabulary ++ families)

      for value <- strings(Independence.projection()) do
        assert MapSet.member?(allowed, value),
               "the independence disclosure published #{inspect(value)}, which is neither a " <>
                 "ledger family nor fixed vocabulary. STATUS-001 keeps content out of this page."
      end
    end
  end

  describe "the surfaces publish it" do
    test "GET /api/status carries the disclosure", %{conn: conn} do
      body = conn |> get(~p"/api/status") |> json_response(200)

      assert body["independence"]["schema"] == "openagents.forge_independence.v1"
      assert body["independence"]["degraded"]
      assert body["independence"]["operator"]["model"] == "single_operator"
      refute body["independence"]["operator"]["mirror_is_authority"]

      # A check reads the distance here without rehearsing a single exit
      # surface, which is what #246 asks of this section.
      assert body["independence"]["deployment"]["proven_ref"] == "refs/heads/main"
      assert is_boolean(body["independence"]["deployment"]["known"])
    end

    test "the status page names the degraded state and every gap", %{conn: conn} do
      conn = put_req_header(conn, "accept", "text/html")
      {:ok, view, _html} = live(conn, ~p"/status")

      assert has_element?(view, "#status-independence")
      assert view |> element("#status-independence-summary") |> render() =~ "degraded"
      assert has_element?(view, "#status-independence-verification")
      assert has_element?(view, "#status-independence-private-data")
      refute has_element?(view, "#status-independence-anchor-witness")

      # A person reads the distance on the page for the same reason: #187 was
      # found by hand, one surface at a time, and nothing said a word.
      assert view |> element("#status-independence-deployment") |> render() =~ "refs/heads/main"

      rendered = render(view)

      for entry <- ExportInventory.with_status(:partial) do
        assert rendered =~ Atom.to_string(entry.family)
      end
    end

    test "a published anchor is named on the page and still called unwitnessed",
         %{conn: conn} do
      Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})
      {:ok, _anchor} = Anchor.publish()
      :persistent_term.erase({OpenAgents.NetworkStatus, :cache})

      conn = put_req_header(conn, "accept", "text/html")
      {:ok, view, _html} = live(conn, ~p"/status")

      assert view |> element("#status-independence-verification") |> render() =~ Anchor.path()

      assert view |> element("#status-independence-anchor-witness") |> render() =~
               "witnessed by nobody"

      assert view |> element("#status-independence-summary") |> render() =~ "degraded"
    end
  end

  # A bare repository this node serves, with three commits on `refs/heads/main`
  # and no working tree — the shape `OpenAgents.Forge.Repos` maintains. The
  # commits are built with plumbing so the fixture never depends on a checkout.
  defp bare_repository(_context) do
    base = Path.join(System.tmp_dir!(), "independence-#{System.unique_integer([:positive])}")
    File.mkdir_p!(base)

    previous_data = Application.get_env(:openagents, :forge_data_dir)
    previous_repos = Application.get_env(:openagents, :forge_repos)
    Application.put_env(:openagents, :forge_data_dir, base)
    Application.put_env(:openagents, :forge_repos, ["disclosure-fixture"])

    on_exit(fn ->
      restore(:forge_data_dir, previous_data)
      restore(:forge_repos, previous_repos)
      File.rm_rf(base)
    end)

    path = Repos.ensure_repo!("disclosure-fixture")
    {tree, 0} = Repos.git(path, ["hash-object", "-w", "-t", "tree", "/dev/null"])
    tree = String.trim(tree)

    shas =
      Enum.reduce(1..3, [], fn index, acc ->
        parent = if acc == [], do: [], else: ["-p", List.last(acc)]

        {sha, 0} =
          Repos.git(path, ["commit-tree", "-m", "commit #{index}", tree] ++ parent,
            env: commit_identity()
          )

        acc ++ [String.trim(sha)]
      end)

    {_output, 0} = Repos.git(path, ["update-ref", "refs/heads/main", List.last(shas)])

    %{shas: shas}
  end

  defp commit_identity do
    [
      {"GIT_AUTHOR_NAME", "proof"},
      {"GIT_AUTHOR_EMAIL", "proof@example.test"},
      {"GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z"},
      {"GIT_COMMITTER_NAME", "proof"},
      {"GIT_COMMITTER_EMAIL", "proof@example.test"},
      {"GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z"}
    ]
  end

  defp restore(key, nil), do: Application.delete_env(:openagents, key)
  defp restore(key, value), do: Application.put_env(:openagents, key, value)

  defp strings(value) when is_map(value), do: value |> Map.values() |> Enum.flat_map(&strings/1)
  defp strings(value) when is_list(value), do: Enum.flat_map(value, &strings/1)
  defp strings(value) when is_binary(value), do: [value]
  defp strings(_other), do: []
end