Files
bikoret/vav_pipeline/test/tablet38_l1_integrity_test.exs
T
Scott KarpandClaude Fable 5.1 33caedb240 tests: the L1 witness may be gone before its teardown; and a correction
The previous commit's message says "Elixir green"; the run it cited had
one failure, which the builder read only after committing (tablet 35's
lesson, repeated). The failure: tablet38_l1_integrity_test's on_exit
stopped a witness linked to the test process after it had already
exited (seed 221974). The teardown now tolerates that. Full suite with
that seed: 607 tests, 0 failures, 46 excluded

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 15:03:07 +00:00

277 lines
9.8 KiB
Elixir

defmodule VavPipeline.Tablet38L1IntegrityTest do
@moduledoc """
Tablet 38 (2026-10-04) — the integrity check did not check integrity.
`L1Reader.verify_integrity/0` compared the hash recorded in
`integrity.sha256` with the hash compiled into the module. It never read
a verse. Every test of it passed, because every test asked it about an
untouched L1 — the one question a check that checks nothing answers
correctly.
These tests change the scroll and ask again.
"""
use ExUnit.Case, async: false
import Plug.Test
import Plug.Conn
alias VavPipeline.{HaltMonitor, L1Integrity, L1Reader, MannaLedger}
alias VavPipeline.BigdeiKodesh.Router
@opts Router.init([])
# ============================================================
# The hash itself
# ============================================================
describe "compute_tree_hash/1" do
test "of the real scroll is the pinned hash, and the hash build_l1.py recorded" do
assert {:ok, hash} = L1Reader.compute_tree_hash()
assert hash == L1Reader.expected_hash()
recorded = L1Reader.l1_root() |> Path.join("integrity.sha256") |> File.read!() |> String.trim()
assert hash == recorded
end
test "hashes path then bytes, directories then files, in plain string order" do
root = small_scroll()
# The order build_l1.py walks: the torah root's own files, then each
# directory by path; within a directory, names as strings — so
# 10.json comes before 2.json.
expected =
[
{"index.json", "INDEX"},
{"alef/1/1.json", "one"},
{"alef/1/10.json", "ten"},
{"alef/1/2.json", "two"},
{"alef/2/1.json", "second chapter"}
]
|> Enum.reduce(:crypto.hash_init(:sha256), fn {rel, bytes}, ctx ->
ctx |> :crypto.hash_update(rel) |> :crypto.hash_update(bytes)
end)
|> :crypto.hash_final()
|> Base.encode16(case: :lower)
assert {:ok, ^expected} = L1Reader.compute_tree_hash(root)
end
test "one changed byte, one added file, one renamed file — each changes the hash" do
root = small_scroll()
{:ok, original} = L1Reader.compute_tree_hash(root)
verse = Path.join([root, "torah", "alef", "1", "2.json"])
File.write!(verse, "twO")
assert {:ok, changed} = L1Reader.compute_tree_hash(root)
assert changed != original
File.write!(verse, "two")
assert {:ok, ^original} = L1Reader.compute_tree_hash(root)
File.write!(Path.join([root, "torah", "alef", "1", "3.json"]), "an added verse")
assert {:ok, added} = L1Reader.compute_tree_hash(root)
assert added != original
File.rm!(Path.join([root, "torah", "alef", "1", "3.json"]))
File.rename!(verse, Path.join([root, "torah", "alef", "1", "20.json"]))
assert {:ok, renamed} = L1Reader.compute_tree_hash(root)
assert renamed != original
end
test "a scroll that is not there is an error, not a hash" do
assert {:error, reason} = L1Reader.compute_tree_hash("/nonexistent/l1")
assert reason =~ "not a directory"
end
end
# ============================================================
# The witness
# ============================================================
describe "L1Integrity" do
test "reads the scroll when it starts, and again when asked" do
root = small_scroll()
{:ok, pinned} = L1Reader.compute_tree_hash(root)
{:ok, pid} =
L1Integrity.start_link(name: :tablet38_witness, l1_root: root, expected_hash: pinned, interval: :never)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
assert :ok = L1Integrity.status(:tablet38_witness)
assert %{computed: ^pinned, expected: ^pinned} = L1Integrity.reading(:tablet38_witness)
# Someone alters a verse. The last reading still says tov — it is a
# reading, not a guard — until the scroll is read again.
File.write!(Path.join([root, "torah", "alef", "1", "1.json"]), "0ne")
assert {:error, reason} = L1Integrity.recheck(:tablet38_witness)
assert reason =~ "L1_INTEGRITY_FAILED"
assert reason =~ "expected #{pinned}"
assert {:error, _} = L1Integrity.status(:tablet38_witness)
# Restored, it is tov again.
File.write!(Path.join([root, "torah", "alef", "1", "1.json"]), "one")
assert :ok = L1Integrity.recheck(:tablet38_witness)
end
test "re-reads on its own at the interval" do
root = small_scroll()
{:ok, pinned} = L1Reader.compute_tree_hash(root)
{:ok, pid} =
L1Integrity.start_link(name: :tablet38_periodic, l1_root: root, expected_hash: pinned, interval: 50)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
assert :ok = L1Integrity.status(:tablet38_periodic)
File.write!(Path.join([root, "torah", "index.json"]), "INDEX, altered")
assert eventually(fn -> match?({:error, _}, L1Integrity.status(:tablet38_periodic)) end)
# And it keeps re-reading: restored, it is tov again without being asked.
File.write!(Path.join([root, "torah", "index.json"]), "INDEX")
assert eventually(fn -> L1Integrity.status(:tablet38_periodic) == :ok end)
end
test "answers from the last reading while a periodic re-reading is under way" do
root = small_scroll()
{:ok, pinned} = L1Reader.compute_tree_hash(root)
{:ok, pid} =
L1Integrity.start_link(name: :tablet38_busy, l1_root: root, expected_hash: pinned, interval: :never)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
# Trigger a periodic reading by hand; the process must not be busy
# with it — a status call is answered at once, from the last reading.
send(pid, :read)
{micros, status} = :timer.tc(fn -> L1Integrity.status(:tablet38_busy) end)
assert status == :ok
assert micros < 50_000
end
end
# ============================================================
# The whole path: an altered scroll stops the vessel
# ============================================================
describe "with a scroll that does not match its pin" do
setup do
if HaltMonitor.halted?(), do: HaltMonitor.resolve("tablet38 test setup")
MannaLedger.reset()
write_provision(20)
# Sanity: with the real scroll, all is well.
assert :ok = L1Reader.verify_integrity()
# Replace the supervised witness with one that reads an altered
# copy — the recorded hash file is untouched and still "matches",
# which is exactly the case the old check could not see.
root = small_scroll()
:ok = Supervisor.terminate_child(VavPipeline.Supervisor, L1Integrity)
{:ok, pid} =
L1Integrity.start_link(l1_root: root, expected_hash: L1Reader.expected_hash(), interval: :never)
on_exit(fn ->
# The witness is linked to the test process and may already be
# gone when on_exit runs (seed 221974, 2026-10-15): stopping a
# process that has exited is not a failure of the scroll.
try do
if Process.alive?(pid), do: GenServer.stop(pid)
catch
:exit, _ -> :ok
end
{:ok, _} = Supervisor.restart_child(VavPipeline.Supervisor, L1Integrity)
MannaLedger.reset()
File.rm(VavPipeline.Manna.provision_file())
end)
:ok
end
test "verify_integrity fails, though integrity.sha256 still holds the pinned hash" do
recorded = L1Reader.l1_root() |> Path.join("integrity.sha256") |> File.read!() |> String.trim()
assert recorded == L1Reader.expected_hash()
assert {:error, reason} = L1Reader.verify_integrity()
assert reason =~ "L1_INTEGRITY_FAILED"
assert reason =~ "the scroll on disk hashes to"
end
test "health says l1_hash: fail and the vessel is not ready" do
conn = conn(:get, "/kodesh/health") |> Router.call(@opts)
body = Jason.decode!(conn.resp_body)
assert conn.status == 503
assert body["checks"]["l1_hash"] == "fail"
end
test "no cycle runs on it — /peah/ask does not answer from an altered scroll" do
conn =
conn(:post, "/peah/ask", Jason.encode!(%{question: "בראשית ברא אלהים"}))
|> put_req_header("content-type", "application/json")
|> Router.call(@opts)
refute conn.status == 200
refute Map.has_key?(Jason.decode!(conn.resp_body), "answer")
end
end
# ------------------------------------------------------------
# Helpers
# ------------------------------------------------------------
defp small_scroll do
root = Path.join(System.tmp_dir!(), "tablet38_#{System.unique_integer([:positive])}")
on_exit(fn -> File.rm_rf!(root) end)
files = %{
"index.json" => "INDEX",
"alef/1/1.json" => "one",
"alef/1/2.json" => "two",
"alef/1/10.json" => "ten",
"alef/2/1.json" => "second chapter"
}
for {rel, bytes} <- files do
path = Path.join([root, "torah", rel])
File.mkdir_p!(Path.dirname(path))
File.write!(path, bytes)
end
root
end
defp eventually(fun, tries \\ 100) do
cond do
fun.() -> true
tries == 0 -> false
true -> Process.sleep(20) && eventually(fun, tries - 1)
end
end
defp write_provision(compute_units) do
today = Date.utc_today() |> Date.to_iso8601()
expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601()
provision = %{
"date" => today,
"provision" => %{
"compute_units" => compute_units,
"memory_mb" => 512,
"api_calls_external" => 5,
"storage_delta_mb" => 2,
"sanhedrin_panels_max" => 2
},
"type" => "regular",
"expires_at" => expires,
"written_by" => "tablet38_test",
"written_at" => DateTime.utc_now() |> DateTime.to_iso8601()
}
path = VavPipeline.Manna.provision_file()
File.mkdir_p!(Path.dirname(path))
File.write!(path, Jason.encode!(provision))
end
end