Test the anchor publisher EXIT-005 leans on

150a8db6e051 · AtlantisPleb · · parent 0f60dffa6014

Test the anchor publisher EXIT-005 leans on

The findings side was proven — Verification reports anchor_mismatch
and anchor_unreachable, and independence_test asserts both. The thing
they compare against was not: the scheduled job that writes the anchor
had no test at all. An anchor is only evidence if it is actually
published, from the index it claims to describe, so a verifier that
correctly reports a mismatch is no help when the anchor was never
written.

Four tests, one per property. A published anchor matches the WAL head
and verifies clean, while a doctored one produces anchor_mismatch — so
the test is tied to the finding rather than to the publisher's own
opinion of itself. A failing publisher does not refuse a persisted
push, asserted rather than inferred from the moduledoc. A failed
publish keeps the previous anchor and reports the failure instead of
replacing good evidence with broken evidence or failing silently. And
republishing an unchanged index does not produce a contradictory
anchor.

The one production change is a seam: the publisher resolves its anchor
module from application config, defaulting to the real one, so a test
can make publication fail without touching what publication does. No
behavior was weakened to make a test pass.

Built by a Devin child through the openagents coder's delegate tool;
437 forge tests green, and the compile is clean under
--warnings-as-errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

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

pushed
by user · WAL seq 354 · 2026-08-25T08:20:06.842605Z

Changed files

  • modified lib/openagents/forge/anchor_publisher.ex
  • added test/openagents/forge/anchor_publisher_test.exs

Diff

2 files changed, +248 -1

lib/openagents/forge/anchor_publisher.ex modified +10 -1

@@ -44,9 +44,18 @@ defmodule OpenAgents.Forge.AnchorPublisher do

44 44
45 45
  def handle_info(_message, state), do: {:noreply, state}
46 46
47
  @doc false
48
  defp publish_impl do
49
    Application.get_env(:openagents, :forge_anchor_publish_impl, Anchor)
50
  end
51
47 52
  @doc "One publication pass. Public so a test can drive it without the timer."
48 53
  def publish do
49
    case Anchor.publish() do
54
    do_publish(publish_impl())
55
  end
56
57
  defp do_publish(anchor_mod) do
58
    case anchor_mod.publish() do
50 59
      {:ok, anchor} ->
51 60
        {:ok, anchor}
52 61
test/openagents/forge/anchor_publisher_test.exs added +238

@@ -0,0 +1,238 @@

1
defmodule OpenAgents.Forge.AnchorPublisherTest do
2
  @moduledoc """
3
  Tests for the scheduled WAL anchor publisher.
4
5
  The publisher reads the WAL and writes a public commitment at
6
  `/.well-known/openagents-forge-anchor.json`. It is not on the push path,
7
  and a failing or slow publication must not refuse a push.
8
  """
9
10
  use OpenAgents.DataCase, async: false
11
12
  import ExUnit.CaptureLog
13
14
  alias OpenAgents.Forge.{Anchor, AnchorPublisher, Verification, WAL}
15
  alias OpenAgents.Repo
16
17
  defmodule TestPipeline do
18
    @moduledoc false
19
    use Plug.Builder
20
21
    plug OpenAgentsWeb.Plugs.ForgeGitAuth
22
    plug OpenAgents.Forge.GitHTTP
23
  end
24
25
  defmodule FailingAnchor do
26
    @moduledoc "Injectable publication dependency that always fails."
27
    def publish, do: {:error, :injected}
28
  end
29
30
  setup do
31
    Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})
32
33
    base =
34
      Path.join(System.tmp_dir!(), "forge-anchor-publisher-#{System.unique_integer([:positive])}")
35
36
    File.mkdir_p!(base)
37
    previous_data = Application.get_env(:openagents, :forge_data_dir)
38
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
39
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
40
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
41
    OpenAgents.Forge.CacheReadiness.reset()
42
43
    user = OpenAgents.AccountsFixtures.repository_user_fixture("anchor-owner")
44
45
    {:ok, repository, :created} =
46
      OpenAgents.Repositories.create_user_repository(
47
        user,
48
        %{name: "demo"},
49
        "anchor-pub-#{System.unique_integer([:positive])}"
50
      )
51
52
    repository =
53
      repository
54
      |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
55
      |> Repo.update!()
56
57
    {:ok, _api_token, plaintext} =
58
      OpenAgents.ApiTokens.create(user, %{
59
        name: "anchor publisher test",
60
        scopes: ["forge:write"],
61
        lifetime_days: 1
62
      })
63
64
    port = free_port()
65
    start_supervised!({Bandit, plug: TestPipeline, port: port, ip: {127, 0, 0, 1}})
66
67
    on_exit(fn ->
68
      Application.put_env(:openagents, :forge_data_dir, previous_data)
69
      Application.put_env(:openagents, :forge_wal_dir, previous_wal)
70
      OpenAgents.Forge.CacheReadiness.reset()
71
      File.rm_rf(base)
72
    end)
73
74
    %{
75
      base: base,
76
      repo: repository.storage_key,
77
      repository: repository,
78
      url: "http://x:#{plaintext}@127.0.0.1:#{port}/anchor-owner/demo.git"
79
    }
80
  end
81
82
  describe "published anchor" do
83
    test "matches the WAL head and verifies clean, while a doctored anchor mismatches",
84
         context do
85
      publish_repository!(context)
86
      seed_history!(context)
87
88
      assert {:ok, published} = AnchorPublisher.publish()
89
      head = document_head!(published, context)
90
91
      {:ok, _generation, index} = WAL.read_index(context.repo)
92
      entry = List.last(WAL.entries(index))
93
      assert entry["seq"] == head.seq
94
      assert WAL.entry_link(entry) == head.link
95
96
      assert {:ok, %{findings: []}} = Verification.verify(context.repo, anchor: head)
97
98
      doctored = %{head | link: String.duplicate("0", 64)}
99
100
      assert {:error, %{findings: findings}} =
101
               Verification.verify(context.repo, anchor: doctored)
102
103
      assert %{"seq" => mismatch_seq} = detail(findings, "anchor_mismatch")
104
      assert mismatch_seq == head.seq
105
    end
106
  end
107
108
  describe "off the push path" do
109
    test "a failing publisher does not refuse a persisted push", context do
110
      seed_history!(context)
111
      previous_impl = Application.get_env(:openagents, :forge_anchor_publish_impl)
112
      Application.put_env(:openagents, :forge_anchor_publish_impl, FailingAnchor)
113
      on_exit(fn -> restore_env(:forge_anchor_publish_impl, previous_impl) end)
114
115
      assert {:error, :injected} = AnchorPublisher.publish()
116
117
      output = commit_and_push!(work_dir(context), "b.txt", "b\n", "b")
118
      {seq, link} = wal_receipt!(output)
119
120
      assert seq == 1
121
      assert link =~ ~r/^[0-9a-f]{64}$/
122
    end
123
  end
124
125
  describe "failure degradation" do
126
    test "keeps the previous anchor and reports the failure", _context do
127
      assert {:ok, first} = AnchorPublisher.publish()
128
129
      previous_impl = Application.get_env(:openagents, :forge_anchor_publish_impl)
130
      Application.put_env(:openagents, :forge_anchor_publish_impl, FailingAnchor)
131
      on_exit(fn -> restore_env(:forge_anchor_publish_impl, previous_impl) end)
132
133
      log =
134
        capture_log([level: :warning], fn ->
135
          assert {:error, :injected} = AnchorPublisher.publish()
136
        end)
137
138
      assert log =~ "forge_wal_anchor_publish_failed code=injected"
139
      assert Anchor.latest().anchor_seq == first.anchor_seq
140
      assert Anchor.latest().digest == first.digest
141
    end
142
  end
143
144
  describe "idempotent republishing" do
145
    test "does not produce a contradictory anchor for an unchanged index", context do
146
      publish_repository!(context)
147
      seed_history!(context)
148
149
      assert {:ok, first} = AnchorPublisher.publish()
150
      assert {:ok, second} = AnchorPublisher.publish()
151
152
      assert second.anchor_seq == first.anchor_seq + 1
153
      assert second.previous_digest == first.digest
154
      assert repository_section(first, context) == repository_section(second, context)
155
      refute second.digest == first.digest
156
    end
157
  end
158
159
  defp publish_repository!(context) do
160
    context.repository
161
    |> Ecto.Changeset.change(visibility: "public")
162
    |> Repo.update!()
163
  end
164
165
  defp document_head!(anchor, context) do
166
    case repository_section(anchor, context) do
167
      %{"head_seq" => seq, "head_link" => link} when is_integer(seq) and is_binary(link) ->
168
        %{seq: seq, link: link}
169
170
      other ->
171
        flunk("the published anchor carried no head for the repository: #{inspect(other)}")
172
    end
173
  end
174
175
  defp repository_section(anchor, context) do
176
    path = "#{context.repository.owner}/#{context.repository.name}"
177
178
    anchor.body
179
    |> Jason.decode!()
180
    |> Map.fetch!("repositories")
181
    |> Enum.find(&(&1["repo"] == path))
182
  end
183
184
  defp seed_history!(context) do
185
    work = work_dir(context)
186
187
    unless File.exists?(work) do
188
      sh!(context.base, "git", ["clone", context.url, work])
189
      sh!(work, "git", ["config", "user.email", "test@example.com"])
190
      sh!(work, "git", ["config", "user.name", "Forge Test"])
191
      commit_and_push!(work, "one.txt", "one\n", "one")
192
    end
193
194
    :ok
195
  end
196
197
  defp commit_and_push!(work, filename, contents, message) do
198
    File.write!(Path.join(work, filename), contents)
199
    sh!(work, "git", ["add", "."])
200
    sh!(work, "git", ["commit", "-m", message])
201
    sh!(work, "git", ["push", "origin", "HEAD:main"])
202
  end
203
204
  defp wal_receipt!(output) do
205
    case Regex.run(~r/openagents wal-receipt seq=(\d+) link=([0-9a-f]{64})/, output) do
206
      [_line, seq, link] -> {String.to_integer(seq), link}
207
      nil -> flunk("git push printed no WAL receipt line:\n#{output}")
208
    end
209
  end
210
211
  defp detail(findings, code) do
212
    Enum.find_value(findings, fn
213
      %{code: ^code, detail: detail} -> detail
214
      _other -> nil
215
    end)
216
  end
217
218
  defp work_dir(context), do: Path.join(context.base, "work")
219
220
  defp free_port do
221
    {:ok, socket} = :gen_tcp.listen(0, [])
222
    {:ok, port} = :inet.port(socket)
223
    :gen_tcp.close(socket)
224
    port
225
  end
226
227
  defp sh!(dir, "git", args), do: sh_raw!(dir, "git", ["-c", "credential.helper="] ++ args)
228
  defp sh!(dir, command, args), do: sh_raw!(dir, command, args)
229
230
  defp sh_raw!(dir, command, args) do
231
    {output, status} = System.cmd(command, args, cd: dir, stderr_to_stdout: true)
232
    if status != 0, do: flunk("#{command} #{Enum.join(args, " ")} failed:\n#{output}")
233
    output
234
  end
235
236
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
237
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
238
end

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