defmodule VavPipeline.NoTrackingLoggerTest do @moduledoc """ Tests for the Plug-layer enforcement of Form 3. Verifies: - The router uses NoTrackingLogger, not Plug.Logger - /peah/* paths get sanitized (query strings dropped) in logs - Each request increments MachtzitHaShekel - CI invariants: no Plug.Session, no Plug.Conn.Cookies imports anywhere in the source tree """ use ExUnit.Case, async: false import Plug.Test alias VavPipeline.{BigdeiKodesh.Router, MachtzitHaShekel, NoTrackingLogger} @opts Router.init([]) setup do if Process.whereis(MachtzitHaShekel), do: MachtzitHaShekel.reset() :ok end # ============================================================ # Each request increments the zikaron # ============================================================ test "GET /peah/ask increments the half-shekel count" do before_count = if Process.whereis(MachtzitHaShekel), do: MachtzitHaShekel.count(), else: 0 conn(:get, "/peah/ask") |> Router.call(@opts) after_count = if Process.whereis(MachtzitHaShekel), do: MachtzitHaShekel.count(), else: 0 assert after_count == before_count + 1 end test "multiple requests advance the count uniformly" do if Process.whereis(MachtzitHaShekel) do MachtzitHaShekel.reset() for _ <- 1..5 do conn(:get, "/peah/ask") |> Router.call(@opts) end assert MachtzitHaShekel.count() == 5 else flunk("MachtzitHaShekel not running — supervision tree must be up for this test") end end # ============================================================ # PRIVACY BOUNDARY — sanitized_path/1 and format_line/2 as pure functions # ============================================================ # The test config sets logger level to :warning, dropping # NoTrackingLogger's :info output before capture_log can see it. # The structural defense is the load-bearing thing; we test the # pure functions directly to verify the path-sanitization shape. test "sanitized_path drops query string on /peah/*" do conn = %Plug.Conn{request_path: "/peah/ask", query_string: "question=secret"} assert NoTrackingLogger.sanitized_path(conn) == "/peah/ask" end test "sanitized_path drops the question parameter on /peah/ask" do secret = "this-question-must-not-leak" conn = %Plug.Conn{request_path: "/peah/ask", query_string: "question=#{secret}"} sanitized = NoTrackingLogger.sanitized_path(conn) refute sanitized =~ secret refute sanitized =~ "question" refute sanitized =~ "?" end test "sanitized_path preserves path class for /peah/azazel_log" do conn = %Plug.Conn{request_path: "/peah/azazel_log", query_string: "since=2026-01-01"} assert NoTrackingLogger.sanitized_path(conn) == "/peah/azazel_log" end test "sanitized_path preserves request_path on non-peah tiers" do # Non-peah tiers also strip query string (the request_path itself # doesn't carry it; query is in conn.query_string). conn = %Plug.Conn{request_path: "/kodesh/health", query_string: "detail=full"} assert NoTrackingLogger.sanitized_path(conn) == "/kodesh/health" end test "format_line shape: METHOD PATH STATUS DURATIONus" do conn = %Plug.Conn{ method: "GET", request_path: "/peah/ask", query_string: "question=secret", status: 200 } line = NoTrackingLogger.format_line(conn, 1234) assert line == "GET /peah/ask 200 1234us" end test "format_line never contains a query string for /peah/*" do secret = "secret-question-content" conn = %Plug.Conn{ method: "GET", request_path: "/peah/ask", query_string: "question=#{secret}", status: 200 } line = NoTrackingLogger.format_line(conn, 100) refute line =~ secret end test "format_line never contains an Authorization header" do # The format_line function takes only conn and duration. It does # NOT extract headers. Verifying by inspection: the function # signature gives no path for headers to enter the log. bearer = "secret-bearer-token" conn = %Plug.Conn{ method: "POST", request_path: "/courtyard/offering", query_string: "", status: 200, req_headers: [{"authorization", "Bearer #{bearer}"}] } line = NoTrackingLogger.format_line(conn, 100) refute line =~ bearer refute line =~ "Bearer" refute line =~ "authorization" end test "format_line never contains conn.remote_ip even when set" do conn = %Plug.Conn{ method: "GET", request_path: "/peah/ask", query_string: "", status: 200, remote_ip: {192, 0, 2, 1} } line = NoTrackingLogger.format_line(conn, 100) refute line =~ "192.0.2.1" refute line =~ "192" end # ============================================================ # CI INVARIANTS — Form 3 architectural enforcement # ============================================================ # These tests walk the source tree and verify no module introduces # a surveillance surface that would defeat the architectural # commitment. @vav_pipeline_lib "lib/vav_pipeline" # NoTrackingLogger's own docstring mentions the forbidden module # names as the things it defends against. Exclude the defender file # from the CI scan to avoid self-detection. Tests are also excluded # — they can legitimately reference the names in assertions. @ci_invariant_exclusions [ "lib/vav_pipeline/no_tracking_logger.ex" ] defp source_files do Path.wildcard("#{@vav_pipeline_lib}/**/*.ex") |> Enum.reject(fn path -> path in @ci_invariant_exclusions end) end test "Plug.Logger is NOT mounted in any router" do offenders = Enum.filter(source_files(), fn path -> text = File.read!(path) String.contains?(text, "plug Plug.Logger") end) assert offenders == [], ( "Plug.Logger is mounted in: #{inspect(offenders)}. The Form 3 " <> "architectural commitment requires NoTrackingLogger as the " <> "request-scoped logger; Plug.Logger logs unconfigurable details " <> "(method+path+status+timing) without the path sanitization that " <> "honors /peah/*'s no-tracking promise." ) end test "no module imports Plug.Session" do offenders = Enum.filter(source_files(), fn path -> text = File.read!(path) String.contains?(text, "Plug.Session") and not String.contains?(text, "no module imports Plug.Session") end) assert offenders == [], ( "Plug.Session is imported in: #{inspect(offenders)}. Sessions " <> "are per-caller persistent state — exactly the surveillance " <> "surface Form 3 forbids. The system is stateless per-request " <> "by architectural commitment." ) end test "no module imports Plug.Conn.Cookies" do offenders = Enum.filter(source_files(), fn path -> text = File.read!(path) String.contains?(text, "Plug.Conn.Cookies") and not String.contains?(text, "no module imports Plug.Conn.Cookies") end) assert offenders == [], ( "Plug.Conn.Cookies imported in: #{inspect(offenders)}. Cookies " <> "are per-caller persistent state — Form 3 surveillance " <> "surface. The system is cookie-free by architectural " <> "commitment." ) end test "no module reads remote IP from conn.remote_ip" do # remote_ip captures the caller's IP — a per-caller identifier. offenders = Enum.filter(source_files(), fn path -> text = File.read!(path) String.contains?(text, "conn.remote_ip") and not String.contains?(text, "no module reads remote IP") end) assert offenders == [], ( "Modules reading conn.remote_ip: #{inspect(offenders)}. The IP " <> "is a caller identifier; persisting it would defeat the " <> "kofer-nefesh anonymity (Shemot 30:13) and the lo-tasgir " <> "refusal (Devarim 23:16)." ) end end