defmodule VavPipeline.BigdeiKodeshTest do @moduledoc """ Tests for the Bigdei Kodesh — the HTTP API layer. Shemot 28:2 — V'asita vigdei kodesh l'Aharon achicha, l'khavod ul'tifaret. Four garments for four access tiers: /peah/* — public, no auth /courtyard/* — authenticated /chatzer/* — full access /kodesh/* — operator """ use ExUnit.Case, async: false import Plug.Test import Plug.Conn alias VavPipeline.BigdeiKodesh.Router @opts Router.init([]) # Tablet 55: a consultation the judges can be pointed at (judge.erl reads # {M, F} from the sanhedrin env). Seat 2 (challenge) finds a kushya; the # other seats read the verse as resolved. def fake_consult(:challenge, _ref, _roots, _q, _ctx), do: {:ok, %{status: "challenged", text: "", kushya: "but the verse says otherwise", verses: [{:devarim, 6, 4}]}} def fake_consult(_step, _ref, _roots, _q, _ctx), do: {:ok, %{status: "resolved", text: "the reading", verses: [{:bereishit, 1, 1}]}} # A day's manna for the chatzer tests: a panel spends one unit of # sanhedrin_panels_max (tablet 55), so the ledger must hold a provision. defp write_provision(panels) do VavPipeline.MannaLedger.reset() on_exit(fn -> VavPipeline.MannaLedger.reset() end) today = Date.utc_today() |> Date.to_iso8601() expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601() provision = %{ "date" => today, "provision" => %{"compute_units" => 50, "memory_mb" => 512, "api_calls_external" => 10, "storage_delta_mb" => 2, "sanhedrin_panels_max" => panels}, "type" => "regular", "expires_at" => expires, "written_by" => "bigdei_kodesh_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)) on_exit(fn -> File.rm(path) end) end # ============================================================ # Pe'ah — Public Endpoint (Machaneh Yisrael) # ============================================================ describe "Pe'ah endpoint" do test "GET /peah/ask without question returns usage" do conn = conn(:get, "/peah/ask") |> Router.call(@opts) assert conn.status == 200 body = Jason.decode!(conn.resp_body) assert body["message"] =~ "Bikoret" assert body["usage"] =~ "/peah/ask" end test "GET /peah/ask with question parameter processes it" do conn = conn(:get, "/peah/ask?question=What+is+bereishit") |> Router.call(@opts) # Should return 200 or 503 (if waiting for provision) assert conn.status in [200, 503] end test "POST /peah/ask with valid question" do conn = conn(:post, "/peah/ask", Jason.encode!(%{question: "What is bereishit?"})) |> put_req_header("content-type", "application/json") |> Router.call(@opts) assert conn.status in [200, 503] body = Jason.decode!(conn.resp_body) if conn.status == 200 do assert Map.has_key?(body, "answer") or Map.has_key?(body, "processing") end end test "POST /peah/ask without question returns 422" do conn = conn(:post, "/peah/ask", Jason.encode!(%{not_question: "wrong field"})) |> put_req_header("content-type", "application/json") |> Router.call(@opts) assert conn.status == 422 body = Jason.decode!(conn.resp_body) assert body["error"]["code"] == "KIYOR_VALIDATION_FAILED" assert body["error"]["type"] == "purification_needed" end test "POST /peah/ask with empty question returns 422" do conn = conn(:post, "/peah/ask", Jason.encode!(%{question: ""})) |> put_req_header("content-type", "application/json") |> Router.call(@opts) assert conn.status == 422 end end # ============================================================ # Pe'ah — Azazel disposal log (commit 8, public half of property 9) # ============================================================ # Privacy boundary tests run WITHOUT a database (the empty L3 case # exercises the response shape and the privacy guarantees). Tests # that need actual disposal records to query are tagged :l3. describe "GET /peah/azazel_log — public observability" do # When L3 is up (BIKORET_L3=1) the endpoint queries the sandboxed # Repo, so the test process must own a connection; without L3 the # endpoint answers from an empty log and there is nothing to check out. setup do if Process.whereis(VavPipeline.Repo), do: :ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo) :ok end test "responds with 200 even when no disposals are recorded" do conn = conn(:get, "/peah/azazel_log") |> Router.call(@opts) assert conn.status == 200 body = Jason.decode!(conn.resp_body) assert is_integer(body["total_disposals"]) assert is_list(body["recent"]) assert is_map(body["categorical_aggregate"]) end test "response carries the privacy boundary statement" do conn = conn(:get, "/peah/azazel_log") |> Router.call(@opts) body = Jason.decode!(conn.resp_body) # Property 9's privacy boundary must be visible to the caller — # they need to know that the items they don't see are # operator-internal, not missing. assert body["privacy_boundary"] =~ "operator-internal" assert body["privacy_boundary"] =~ "Property 9" end test "response carries the disclosure block (kichush + horaah)" do conn = conn(:get, "/peah/azazel_log") |> Router.call(@opts) body = Jason.decode!(conn.resp_body) # Mirrors every other Torah-content response from commit 5e00f75 assert body["disclosure"]["origin"] assert body["disclosure"]["not_horaah"] assert body["disclosure"]["torah_basis"] end test "since=invalid returns all_time" do conn = conn(:get, "/peah/azazel_log?since=not-a-timestamp") |> Router.call(@opts) body = Jason.decode!(conn.resp_body) assert body["since"] == "all_time" end test "since=valid_iso8601 is echoed in response" do conn = conn(:get, "/peah/azazel_log?since=2026-04-28T00:00:00Z") |> Router.call(@opts) body = Jason.decode!(conn.resp_body) assert body["since"] == "2026-04-28T00:00:00Z" end test "categorical_aggregate has all three slots even when empty" do conn = conn(:get, "/peah/azazel_log") |> Router.call(@opts) body = Jason.decode!(conn.resp_body) agg = body["categorical_aggregate"] # All three slots present (the public sees the SHAPE of the # vidui structure, not its contents) assert Map.has_key?(agg, "avonot") assert Map.has_key?(agg, "peshaim") assert Map.has_key?(agg, "chatot") end @tag :l3 test "with disposals: aggregate counts categories accurately" do base = %{ case_ref: "log-001", vidui: %{ "avonot" => ["halt avodah_zarah: x"], "peshaim" => [], "chatot" => [], "confessor" => "kohen", "confessed_at" => "2026-04-28T12:00:00Z" }, routing: "eretz_gezerah", authorized_by: "kohen-gadol", shaliach: "ish-itti", disposed_at: ~U[2026-04-28 12:00:01Z] } {:ok, _} = VavPipeline.L3.Store.record_disposal(base) {:ok, _} = VavPipeline.L3.Store.record_disposal(Map.put(base, :case_ref, "log-002")) conn = conn(:get, "/peah/azazel_log") |> Router.call(@opts) body = Jason.decode!(conn.resp_body) assert body["total_disposals"] == 2 assert body["categorical_aggregate"]["avonot"] == 2 end @tag :l3 test "PRIVACY BOUNDARY: response NEVER contains vidui item strings" do secret_phrase = "kohen-confidential-content-that-must-never-leak-to-public-endpoint" attrs = %{ case_ref: "privacy-001", vidui: %{ "avonot" => [], "peshaim" => [], "chatot" => [secret_phrase], "confessor" => "kohen", "confessed_at" => "2026-04-28T12:00:00Z" }, routing: "eretz_gezerah", authorized_by: "kohen-gadol", shaliach: "ish-itti", disposed_at: ~U[2026-04-28 12:00:01Z] } {:ok, _} = VavPipeline.L3.Store.record_disposal(attrs) conn = conn(:get, "/peah/azazel_log") |> Router.call(@opts) # The response body — as a string — must NOT contain the # vidui item content. This is the load-bearing privacy # guarantee; if the secret_phrase appears anywhere in the # response, the public endpoint is leaking operator-internal # state. refute conn.resp_body =~ secret_phrase end end # ============================================================ # Courtyard — Authenticated Endpoint # ============================================================ describe "Courtyard endpoint" do test "POST /courtyard/offering without auth returns 401" do conn = conn(:post, "/courtyard/offering", Jason.encode!(%{content: "test"})) |> put_req_header("content-type", "application/json") |> Router.call(@opts) assert conn.status == 401 body = Jason.decode!(conn.resp_body) assert body["error"]["code"] == "UNAUTHORIZED" end test "POST /courtyard/offering with auth but no content returns 422" do conn = conn(:post, "/courtyard/offering", Jason.encode!(%{offering_type: "shelamim"})) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer test-token") |> Router.call(@opts) assert conn.status == 422 body = Jason.decode!(conn.resp_body) assert body["error"]["code"] == "KIYOR_VALIDATION_FAILED" end test "POST /courtyard/offering with auth and content processes" do conn = conn(:post, "/courtyard/offering", Jason.encode!(%{ content: "bereishit bara elohim", offering_type: "shelamim" })) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer test-token") |> Router.call(@opts) assert conn.status in [200, 503] if conn.status == 200 do body = Jason.decode!(conn.resp_body) assert Map.has_key?(body, "offering_id") assert Map.has_key?(body, "offering_receipt") assert body["offering_receipt"]["type"] == "shelamim" end end end # ============================================================ # Chatzer — Sanhedrin Endpoint # ============================================================ describe "Chatzer endpoint" do test "POST /chatzer/sanhedrin without auth returns 401" do conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "test case"})) |> put_req_header("content-type", "application/json") |> Router.call(@opts) assert conn.status == 401 end test "POST /chatzer/sanhedrin without case returns 422" do conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{not_case: "wrong field"})) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) assert conn.status == 422 end test "POST /chatzer/sanhedrin with valid case deliberates" do write_provision(2) conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{ case: "Is cooking on Shabbat permitted for pikuach nefesh?" })) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) assert conn.status == 200 body = Jason.decode!(conn.resp_body) assert Map.has_key?(body, "case_ref") assert Map.has_key?(body, "deliberation") assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"] assert body["note"] =~ "not a binding ruling" end # Tablet 55: the Tamid's own example — a case WITH sides — returned # 500 and took the halt monitor and escalation down with it. test "POST /chatzer/sanhedrin with sides (the Tamid's example) deliberates" do write_provision(2) conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{ case: "A borrower repaid before one witness; the lender denies receipt. Who bears the burden?", sides: [ %{position: "the borrower is believed", evidence: ["the money was handed over before a witness"]}, %{position: "the lender is believed", evidence: ["one witness does not establish payment"]} ], complexity_hint: "monetary" })) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) assert conn.status == 200 body = Jason.decode!(conn.resp_body) assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"] # the hint names the panel: monetary → three judges (sanhedrin.hrl) assert body["deliberation"]["panel_size"] == 3 # the Tamid's shape — votes and sources, not vote_count and torah_refs assert Map.keys(body["deliberation"]["majority"]) |> Enum.sort() == ["position", "reasoning", "sources", "votes"] assert is_list(body["deliberation"]["majority"]["sources"]) # the case was resolved before it was heard assert is_map(body["grounding"]["scope"]) assert is_list(body["grounding"]["roots_consulted"]) # and the gate is still standing afterwards assert Process.whereis(:halt_monitor) != nil assert Process.whereis(:escalation) != nil end # Tablet 55: without a Talmudic Engine to consult, the judges abstain # and the court says teiku — it does not accept on nothing. test "POST /chatzer/sanhedrin with no engine to consult is teiku, not accepted" do # The socket process may run in the test env; no engine answers behind it. Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult}) write_provision(2) conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "bereishit 1:1 — what is created first?"})) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) assert conn.status == 200 body = Jason.decode!(conn.resp_body) assert body["deliberation"]["outcome"] == "teiku" assert body["deliberation"]["panel_size"] == 1 # the one judge abstained: an abstaining majority of one, no dissent assert body["deliberation"]["majority"]["votes"] == 1 assert body["deliberation"]["majority"]["reasoning"] =~ "no consultation" assert body["deliberation"]["minority"]["votes"] == 0 end # Tablet 55: with a consultation behind the judges, the dialectic # decides — interpret resolved, challenge finds a kushya, resolve # resolved → 2 accept, 1 reject; the minority preserved with its verse. test "POST /chatzer/sanhedrin monetary panel splits 2–1 from the dialectic" do Application.put_env(:sanhedrin, :consult, {__MODULE__, :fake_consult}) write_provision(2) try do conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{ case: "devarim 6:4 — is the unity declared or commanded?", complexity_hint: "monetary" })) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) assert conn.status == 200 d = Jason.decode!(conn.resp_body)["deliberation"] assert d["panel_size"] == 3 assert d["outcome"] == "accepted" assert d["majority"]["votes"] == 2 assert d["majority"]["position"] == "the reading" assert d["majority"]["sources"] == ["bereishit 1:1"] assert d["minority"]["votes"] == 1 assert d["minority"]["position"] == "but the verse says otherwise" assert d["minority"]["sources"] == ["devarim 6:4"] after Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult}) end end # Devarim 17:9 — the decision is engraved in L3: an interpretation row # with the panel and the votes, and the minority beside it. @tag :l3 test "POST /chatzer/sanhedrin engraves the decision and the minority in L3" do # The record is written from the request process (the router), so a # checkout here covers it; the sandbox is shared for the sake of the # panel's processes, which do not touch the Repo. :ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo) Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, {:shared, self()}) on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, :manual) end) Application.put_env(:sanhedrin, :consult, {__MODULE__, :fake_consult}) write_provision(2) try do before = VavPipeline.L3.Store.count_interpretations() before_min = VavPipeline.L3.Store.count_minority_opinions() conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "devarim 6:4", complexity_hint: "monetary"})) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) assert conn.status == 200 body = Jason.decode!(conn.resp_body) assert body["grounding"]["recorded"]["l3"] == "recorded" assert VavPipeline.L3.Store.count_interpretations() == before + 1 assert VavPipeline.L3.Store.count_minority_opinions() == before_min + 1 after Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult}) end end # A panel is manna (Vayakhel §3.7): the day's sanhedrin_panels_max spent → 503. test "POST /chatzer/sanhedrin with the day's panels spent returns 503 MANNA_DEPLETED" do write_provision(1) body = Jason.encode!(%{case: "bereishit 1:1"}) post = fn -> conn(:post, "/chatzer/sanhedrin", body) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) end assert post.().status == 200 second = post.() assert second.status == 503 assert Jason.decode!(second.resp_body)["error"]["code"] == "MANNA_DEPLETED" end test "POST /chatzer/sanhedrin with an unknown complexity_hint returns 422" do conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "x", complexity_hint: "cosmic"})) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) assert conn.status == 422 assert Jason.decode!(conn.resp_body)["error"]["guidance"]["issue"] =~ "complexity_hint" end test "POST /chatzer/sanhedrin with malformed sides returns 422, not 500" do for bad <- ["not a list", [%{"no_position" => 1}], [%{"position" => "p", "evidence" => "not a list"}]] do conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "x", sides: bad})) |> put_req_header("content-type", "application/json") |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) assert conn.status == 422, "expected 422 for sides=#{inspect(bad)}, got #{conn.status}" assert Jason.decode!(conn.resp_body)["error"]["code"] == "KIYOR_VALIDATION_FAILED" end end end # ============================================================ # Kodesh — Operator Endpoint # ============================================================ describe "Kodesh endpoint" do test "GET /kodesh/health returns health check" do conn = conn(:get, "/kodesh/health") |> Router.call(@opts) assert conn.status in [200, 503] body = Jason.decode!(conn.resp_body) assert Map.has_key?(body, "overall") assert Map.has_key?(body, "checks") assert body["overall"] in ["healthy", "degraded", "critical", "shabbat"] # Required health checks assert Map.has_key?(body["checks"], "l1_hash") assert Map.has_key?(body["checks"], "halt_monitor_alive") assert Map.has_key?(body["checks"], "esh_tamid") end test "GET /kodesh/status returns system status" do conn = conn(:get, "/kodesh/status") |> Router.call(@opts) assert conn.status == 200 body = Jason.decode!(conn.resp_body) assert Map.has_key?(body, "cloud_state") assert Map.has_key?(body, "components") assert Map.has_key?(body, "halt_status") assert Map.has_key?(body, "signal_count") end end # ============================================================ # Error handling — Chatat protocol # ============================================================ describe "error handling" do test "unknown endpoint returns 404" do conn = conn(:get, "/unknown/path") |> Router.call(@opts) assert conn.status == 404 body = Jason.decode!(conn.resp_body) assert body["error"]["code"] == "NOT_FOUND" end test "all errors follow chatat format" do conn = conn(:post, "/peah/ask", Jason.encode!(%{})) |> put_req_header("content-type", "application/json") |> Router.call(@opts) assert conn.status == 422 body = Jason.decode!(conn.resp_body) # Chatat format: error.code, error.type, error.message, error.guidance assert Map.has_key?(body["error"], "code") assert Map.has_key?(body["error"], "type") assert Map.has_key?(body["error"], "message") assert Map.has_key?(body["error"], "guidance") end test "all responses are JSON" do conn = conn(:get, "/kodesh/health") |> Router.call(@opts) content_type = get_resp_header(conn, "content-type") assert Enum.any?(content_type, &String.contains?(&1, "application/json")) end end end