test/openagents/deployments/policy_test.exs

58e6347eeb72 · 7 KB

defmodule OpenAgents.Deployments.PolicyTest do
  use ExUnit.Case, async: true

  alias OpenAgents.Deployments.Approval
  alias OpenAgents.Deployments.CheckResult
  alias OpenAgents.Deployments.Environment
  alias OpenAgents.Deployments.Policy
  alias OpenAgents.Deployments.Protection
  alias OpenAgents.Deployments.Request

  @commit String.duplicate("ab", 20)
  @artifact "sha256:" <> String.duplicate("c", 64)
  # A Wednesday, 12:00 UTC.
  @now ~U[2026-08-19 12:00:00Z]

  describe "source rules" do
    test "an unrestricted policy admits any ref" do
      assert {:admit, "queued", _explanation} = evaluate(%{}, %{})
    end

    test "a branch outside the allowed list is blocked" do
      protection = %{allowed_branches: ["main", "release/*"]}

      assert {:admit, "queued", _} = evaluate(protection, %{source_ref: "refs/heads/main"})
      assert {:admit, "queued", _} = evaluate(protection, %{source_ref: "refs/heads/release/1.2"})

      assert {:deny, "source_ref_not_allowed", _} =
               evaluate(protection, %{source_ref: "refs/heads/release-escape"})

      assert {:deny, "source_ref_not_allowed", _} =
               evaluate(protection, %{source_ref: "refs/tags/v1.0.0"})
    end

    test "a tag policy admits tags and refuses branches" do
      protection = %{allowed_tags: ["v*"]}

      assert {:admit, "queued", _} = evaluate(protection, %{source_ref: "refs/tags/v1.0.0"})

      assert {:deny, "source_ref_not_allowed", _} =
               evaluate(protection, %{source_ref: "refs/heads/main"})
    end

    test "a source workflow outside the allowed list is blocked" do
      protection = %{allowed_workflows: ["deploy.yml"]}

      assert {:admit, "queued", _} = evaluate(protection, %{source_workflow: "deploy.yml"})

      assert {:deny, "source_workflow_not_allowed", _} =
               evaluate(protection, %{source_workflow: "pull-request.yml"})
    end
  end

  describe "freeze and windows" do
    test "a freeze blocks with its reason preserved" do
      assert {:deny, "frozen", explanation} =
               evaluate(%{frozen: true, freeze_reason: "incident 42"}, %{})

      entry = Enum.find(explanation, &(&1["rule"] == "freeze"))
      assert entry["detail"]["freeze_reason"] == "incident 42"
    end

    test "a window admits inside its hours and blocks outside them" do
      protection = %{window_weekdays: [3], window_start_minute: 600, window_end_minute: 1_020}

      assert {:admit, "queued", _} = evaluate(protection, %{}, @now)
      assert {:deny, "outside_window", _} = evaluate(protection, %{}, ~U[2026-08-19 18:00:00Z])
      assert {:deny, "outside_window", _} = evaluate(protection, %{}, ~U[2026-08-20 12:00:00Z])
    end
  end

  describe "artifact age" do
    test "an artifact older than the limit is blocked" do
      protection = %{maximum_artifact_age_seconds: 3_600}

      assert {:admit, "queued", _} =
               evaluate(
                 protection,
                 %{artifact_created_at: DateTime.add(@now, -60, :second)},
                 @now
               )

      assert {:deny, "artifact_too_old", _} =
               evaluate(
                 protection,
                 %{artifact_created_at: DateTime.add(@now, -7_200, :second)},
                 @now
               )
    end

    test "an unknown artifact age is blocked rather than assumed fresh" do
      assert {:deny, "artifact_age_unknown", _} =
               evaluate(%{maximum_artifact_age_seconds: 3_600}, %{artifact_created_at: nil}, @now)
    end
  end

  describe "required checks" do
    test "a missing check keeps the run checking" do
      assert {:admit, "checking", _} = evaluate(%{required_checks: ["build"]}, %{})
    end

    test "a check for other bytes does not count" do
      other = check(%{commit_sha: String.duplicate("ef", 20)})

      assert {:admit, "checking", _} = evaluate(%{required_checks: ["build"]}, %{}, @now, [other])
    end

    test "a green check for the exact bytes admits the run" do
      assert {:admit, "queued", _} =
               evaluate(%{required_checks: ["build"]}, %{}, @now, [check(%{})])
    end

    test "a pending check keeps the run checking" do
      assert {:admit, "checking", _} =
               evaluate(%{required_checks: ["build"]}, %{}, @now, [check(%{status: "pending"})])
    end

    test "a failed check denies the run" do
      assert {:deny, "checks_failed", _} =
               evaluate(%{required_checks: ["build"]}, %{}, @now, [check(%{status: "failed"})])
    end

    test "a check past its own validity is expired, not green" do
      expired = check(%{valid_until: DateTime.add(@now, -1, :second)})

      assert {:deny, "checks_expired", _} =
               evaluate(%{required_checks: ["build"]}, %{}, @now, [expired])
    end

    test "a check older than the policy validity window is expired" do
      stale = check(%{updated_at: DateTime.add(@now, -7_200, :second)})
      protection = %{required_checks: ["build"], check_validity_seconds: 3_600}

      assert {:deny, "checks_expired", _} = evaluate(protection, %{}, @now, [stale])
    end
  end

  describe "required approvals" do
    test "a run without its approvals waits" do
      assert {:admit, "waiting_for_approval", _} = evaluate(%{required_approvals: 1}, %{})
    end

    test "approvals for other bytes do not count" do
      approvals = [approval(%{request_digest: "other-digest"})]

      assert {:admit, "waiting_for_approval", _} =
               evaluate(%{required_approvals: 1}, %{}, @now, [], approvals)
    end

    test "one approval for the exact request admits the run" do
      assert {:admit, "queued", _} =
               evaluate(%{required_approvals: 1}, %{}, @now, [], [approval(%{})])
    end

    test "one rejection denies the run even alongside approvals" do
      approvals = [approval(%{}), approval(%{decision: "rejected"})]

      assert {:deny, "rejected", _} = evaluate(%{required_approvals: 1}, %{}, @now, [], approvals)
    end

    test "a denial explains every rule it considered" do
      assert {:deny, _reason, explanation} = evaluate(%{frozen: true}, %{})

      assert Enum.map(explanation, & &1["rule"]) == [
               "allowed_sources",
               "freeze",
               "deployment_window",
               "artifact_age",
               "required_checks",
               "required_approvals"
             ]
    end
  end

  describe "initial state" do
    test "an environment with required checks starts a run in checking" do
      assert Policy.initial_state(environment(%{required_checks: ["build"]})) == "checking"
      assert Policy.initial_state(environment(%{})) == "requested"
    end
  end

  defp evaluate(protection, request, now \\ @now, checks \\ [], approvals \\ []) do
    Policy.evaluate(environment(protection), request(request), checks, approvals, now)
  end

  defp environment(protection) do
    %Environment{
      name: "production",
      kind: "production",
      provider: "fake",
      protection: struct!(%Protection{}, protection)
    }
  end

  defp request(attrs) do
    struct!(
      %Request{
        commit_sha: @commit,
        artifact_digest: @artifact,
        source_ref: "refs/heads/main",
        source_workflow: "deploy.yml",
        principal_type: "user",
        request_digest: "request-digest",
        requested_by_user_id: Ecto.UUID.generate()
      },
      attrs
    )
  end

  defp check(attrs) do
    struct!(
      %CheckResult{
        name: "build",
        commit_sha: @commit,
        artifact_digest: @artifact,
        status: "succeeded",
        updated_at: @now
      },
      attrs
    )
  end

  defp approval(attrs) do
    struct!(
      %Approval{
        decision: "approved",
        rule: "required_approvals",
        request_digest: "request-digest",
        approver_user_id: Ecto.UUID.generate(),
        decided_at: @now
      },
      attrs
    )
  end
end