test/openagents/machines/constraint_reach_test.exs

main at 58e6347eeb72 · 7 KB

defmodule OpenAgents.Machines.ConstraintReachTest do
  @moduledoc """
  Issue #184 lists five PostgreSQL constraints on the computer tables with no
  `check_constraint/2` or `unique_constraint/2` beside them, so a violation
  arrives as a `Postgrex.Error` rather than an invalid changeset.

  That is a defect when a user-supplied value can reach the constraint: a form
  should have refused the input, and instead the request 500s. None of these
  five can be reached that way, so each is recorded here rather than mapped —
  and recorded as an assertion, because the reason it cannot be reached is a
  property of the code that can change.

  | Constraint | Guarded by |
  | --- | --- |
  | `machines_status_check` | `status` is not cast; writers pass literals |
  | `machine_pairings_status_check` | same |
  | `machines_token_expiry_after_creation` | `token_expires_at` is not cast, and its TTL is admitted at boot |
  | `machines_token_digest_index` | 256 random bits, not user input |
  | `machine_pairings_code_digest_index` | a random code, not user input |

  The constraints stay. They are the last defense against a future writer, and
  the tests below show each one still refusing a bad row when reached directly.
  Mapping them would claim a population that cannot occur.
  """

  use OpenAgents.DataCase, async: true

  alias OpenAgents.Machines
  alias OpenAgents.Machines.{Machine, Pairing}
  alias OpenAgents.Repo
  alias OpenAgents.RuntimeConfig

  # Everything an attacker controls on the two unauthenticated or
  # owner-facing write paths, plus every field the constraints guard.
  @hostile %{
    "name" => "box",
    "tier" => "probe",
    "status" => "compromised",
    "token_digest" => "chosen",
    "token_expires_at" => ~U[1970-01-01 00:00:00.000000Z],
    "revoked_at" => ~U[1970-01-01 00:00:00.000000Z],
    "code_digest" => "chosen",
    "poll_secret_digest" => "chosen",
    "expires_at" => ~U[1970-01-01 00:00:00.000000Z],
    "user_id" => Ecto.UUID.generate(),
    "machine_id" => Ecto.UUID.generate()
  }

  @guarded ~w(status token_digest token_expires_at code_digest expires_at)a

  describe "no user-supplied value reaches the guarded columns" do
    test "the computer changeset casts none of them" do
      changes = Machine.create_changeset(%Machine{}, @hostile).changes

      for field <- @guarded do
        refute Map.has_key?(changes, field),
               "#{field} became castable; it now reaches a constraint with no changeset mapping"
      end
    end

    test "the pairing changeset casts none of them" do
      changes = Pairing.create_changeset(%Pairing{}, @hostile).changes

      for field <- @guarded do
        refute Map.has_key?(changes, field),
               "#{field} became castable; it now reaches a constraint with no changeset mapping"
      end
    end

    test "a hostile pairing request still produces an ordinary pending row" do
      # The unauthenticated entry point, carrying the whole hostile map.
      assert {:ok, %{pairing: pairing}} = Machines.start_pairing(@hostile)

      assert pairing.status == "pending"
      assert pairing.code_digest != "chosen"
      assert DateTime.compare(pairing.expires_at, DateTime.utc_now()) == :gt
    end
  end

  describe "machines_token_expiry_after_creation is guarded before boot, not at insert" do
    test "the admitted TTL range cannot produce a row the constraint refuses" do
      settings = Application.get_all_env(:openagents)

      for ttl <- [0, -1, -86_400] do
        assert {:error, _reason} =
                 RuntimeConfig.validate(Keyword.put(settings, :machine_token_ttl_seconds, ttl)),
               "a TTL of #{ttl} would make every pairing approval raise, and boot admitted it"
      end

      assert {:ok, _config} =
               RuntimeConfig.validate(Keyword.put(settings, :machine_token_ttl_seconds, 300))
    end
  end

  describe "the constraints are still load-bearing" do
    test "machines_status_check refuses a status reached around the changeset" do
      owner = owner()

      assert_raise Postgrex.Error, ~r/machines_status_check/, fn ->
        Repo.query!(
          """
          INSERT INTO machines
            (id, user_id, name, tier, roots, token_digest, token_expires_at, status,
             scoped_forge_credentials_enabled, inserted_at, updated_at)
          VALUES (gen_random_uuid(), $1, 'x', 'probe', '{}', 'd', NOW() + INTERVAL '1 day',
                  'compromised', false, NOW(), NOW())
          """,
          [Ecto.UUID.dump!(owner.id)]
        )
      end
    end

    test "machines_token_expiry_after_creation refuses an expiry before creation" do
      owner = owner()

      assert_raise Postgrex.Error, ~r/machines_token_expiry_after_creation/, fn ->
        Repo.query!(
          """
          INSERT INTO machines
            (id, user_id, name, tier, roots, token_digest, token_expires_at, status,
             scoped_forge_credentials_enabled, inserted_at, updated_at)
          VALUES (gen_random_uuid(), $1, 'x', 'probe', '{}', 'd', NOW() - INTERVAL '1 day',
                  'active', false, NOW(), NOW())
          """,
          [Ecto.UUID.dump!(owner.id)]
        )
      end
    end

    test "machine_pairings_status_check refuses a status reached around the changeset" do
      assert_raise Postgrex.Error, ~r/machine_pairings_status_check/, fn ->
        Repo.query!(
          """
          INSERT INTO machine_pairings
            (id, code_digest, poll_secret_digest, name, tier, roots, status, expires_at,
             inserted_at, updated_at)
          VALUES (gen_random_uuid(), 'a', 'b', 'x', 'probe', '{}', 'compromised',
                  NOW() + INTERVAL '1 hour', NOW(), NOW())
          """,
          []
        )
      end
    end

    test "machine_pairings_code_digest_index refuses a duplicate code" do
      %{pairing: first} = start_pairing()
      digest = Repo.get!(Pairing, first.id).code_digest

      assert_raise Postgrex.Error, ~r/machine_pairings_code_digest_index/, fn ->
        Repo.query!(
          """
          INSERT INTO machine_pairings
            (id, code_digest, poll_secret_digest, name, tier, roots, status, expires_at,
             inserted_at, updated_at)
          VALUES (gen_random_uuid(), $1, 'b', 'x', 'probe', '{}', 'pending',
                  NOW() + INTERVAL '1 hour', NOW(), NOW())
          """,
          [digest]
        )
      end
    end

    test "machines_token_digest_index refuses a duplicate computer token" do
      owner = owner()
      %{code: code} = start_pairing()
      {:ok, machine} = Machines.approve_pairing(owner, code)
      digest = Repo.get!(Machine, machine.id).token_digest

      assert_raise Postgrex.Error, ~r/machines_token_digest_index/, fn ->
        Repo.query!(
          """
          INSERT INTO machines
            (id, user_id, name, tier, roots, token_digest, token_expires_at, status,
             scoped_forge_credentials_enabled, inserted_at, updated_at)
          VALUES (gen_random_uuid(), $1, 'x', 'probe', '{}', $2, NOW() + INTERVAL '1 day',
                  'active', false, NOW(), NOW())
          """,
          [Ecto.UUID.dump!(owner.id), digest]
        )
      end
    end
  end

  defp owner do
    {:ok, user} =
      OpenAgents.Accounts.upsert_github_user(%{
        github_id: :erlang.phash2({__MODULE__, System.unique_integer()}),
        github_login: "reach-#{System.unique_integer([:positive])}",
        github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
      })

    user
  end

  defp start_pairing do
    {:ok, started} = Machines.start_pairing(%{"name" => "box", "tier" => "probe"})
    started
  end
end