test/openagents_web/controllers/fleet_target_controller_test.exs

58e6347eeb72 · 11 KB

defmodule OpenAgentsWeb.FleetTargetControllerTest do
  @moduledoc """
  The operator-only fleet promotion API.

  Two conditions authorize every request and neither substitutes for the
  other: the exact `deployments:promote` scope, and live operator standing.
  A tenant credential — including one holding every other scope in the
  system — never reaches this surface.
  """

  use OpenAgentsWeb.ConnCase, async: false

  import OpenAgents.ForgePromotionFixtures

  alias OpenAgents.ApiTokens
  alias OpenAgents.Forge.{Target, Targets}
  alias OpenAgents.Repo
  alias OpenAgentsWeb.ApiError

  @repo "openagents.com"
  @create "/api/v1/admin/forge/targets"

  setup do
    isolate_forge_storage!()
    :ok
  end

  defp body(sha, overrides \\ %{}) do
    Map.merge(
      %{
        "environment" => "production",
        "idempotency_key" => "key-" <> Base.encode16(:crypto.strong_rand_bytes(8), case: :lower),
        "repo" => @repo,
        "sha" => sha
      },
      overrides
    )
  end

  defp operator_conn(conn, key) do
    operator = operator_fixture(key)
    {put_promotion_api_token(conn, operator), operator}
  end

  test "an operator promotes an exact pushed SHA and gets a status URL back", %{conn: conn} do
    {conn, operator} = operator_conn(conn, "api-promote-operator")
    sha = seeded_commit(@repo)

    conn = post(conn, @create, body(sha))
    response = json_response(conn, 202)

    assert response["sha"] == sha
    assert response["repo"] == @repo
    assert response["status"] == "promoted"
    assert response["environment"] == "production"
    assert response["source"] == "api"
    assert response["promoted_by"] == "operator:#{operator.github_id}"
    assert response["replayed"] == false
    assert response["terminal"] == false
    assert is_binary(response["request_id"])
    assert response["status_url"] =~ "/api/v1/admin/forge/targets/#{response["id"]}"

    assert Targets.current(@repo).id == response["id"]
  end

  test "the promoted target is readable and listable through the same credential", %{conn: conn} do
    {conn, _operator} = operator_conn(conn, "api-promote-read")
    sha = seeded_commit(@repo)

    created = conn |> post(@create, body(sha)) |> json_response(202)

    shown = conn |> get("#{@create}/#{created["id"]}") |> json_response(200)
    assert shown["id"] == created["id"]
    assert shown["sha"] == sha

    listed = conn |> get(@create) |> json_response(200)
    assert listed["repo"] == @repo
    assert Enum.map(listed["targets"], & &1["id"]) == [created["id"]]
  end

  test "a status response names no node, path, or credential", %{conn: conn} do
    {conn, _operator} = operator_conn(conn, "api-promote-bounded")
    sha = seeded_commit(@repo)

    created = conn |> post(@create, body(sha)) |> json_response(202)

    # A real target carries the rolling authority RELEASE-006 publishes, which
    # names every expected node. The status projection must show the lifecycle
    # without disclosing the fleet.
    {:ok, _advanced} =
      Targets.advance(created["id"], "failed", %{
        "error_code" => "build_failed",
        "rolling_authority" => %{
          "schema" => "openagents.forge.rolling-authority.v1",
          "expected_nodes" => ["openagents@10.0.0.4"]
        },
        "workspace_path" => "/var/lib/openagents/builds/17"
      })

    shown = conn |> get("#{@create}/#{created["id"]}") |> json_response(200)

    assert shown["status"] == "failed"
    assert shown["terminal"] == true
    assert shown["error_code"] == "build_failed"
    refute Jason.encode!(shown) =~ "10.0.0.4"
    refute Jason.encode!(shown) =~ "/var/lib/openagents"
    refute Jason.encode!(shown) =~ "rolling_authority"
  end

  test "an anonymous caller is refused before the controller runs", %{conn: conn} do
    sha = seeded_commit(@repo)

    refused = conn |> post(@create, body(sha)) |> json_response(401)

    assert Enum.sort(Map.keys(refused) -- ["error"]) == Enum.sort(ApiError.envelope_keys())
    assert code(refused) == "unauthenticated"
    assert refused["status"] == 401
    assert refused["error"] == "invalid_api_token"

    assert conn |> get(@create) |> json_response(401)
    assert Repo.aggregate(Target, :count) == 0
  end

  test "a token holding every other scope cannot promote", %{conn: conn} do
    user = promotion_user_fixture("api-promote-every-other-scope")
    sha = seeded_commit(@repo)

    conn = put_unprivileged_api_token(conn, user)

    assert conn |> post(@create, body(sha)) |> json_response(401)
    assert Repo.aggregate(Target, :count) == 0
  end

  test "a forge:write token cannot promote", %{conn: conn} do
    sha = seeded_commit(@repo)
    conn = put_forge_api_token(conn, "api-promote-forge-write")

    assert conn |> post(@create, body(sha)) |> json_response(401)
    assert Repo.aggregate(Target, :count) == 0
  end

  test "a deployments:write token cannot promote", %{conn: conn} do
    user = promotion_user_fixture("api-promote-deployments-write")
    sha = seeded_commit(@repo)
    conn = put_deployments_api_token(conn, user)

    assert conn |> post(@create, body(sha)) |> json_response(401)
    assert Repo.aggregate(Target, :count) == 0
  end

  test "an ordinary account cannot be issued the promotion scope at all" do
    user = promotion_user_fixture("api-promote-issuance")

    assert {:error, :invalid_api_token} =
             ApiTokens.create(user, %{
               name: "release",
               scopes: ["deployments:promote"],
               lifetime_days: 1
             })
  end

  test "a privileged credential has a shorter maximum lifetime than forge:write" do
    operator = operator_fixture("api-promote-lifetime")

    assert {:error, :invalid_api_token} =
             ApiTokens.create(operator, %{
               name: "too long",
               scopes: ["deployments:promote"],
               lifetime_days: 30
             })

    assert {:ok, _credential, _plaintext} =
             ApiTokens.create(operator, %{
               name: "release",
               scopes: ["deployments:promote"],
               lifetime_days: 7
             })

    assert {:ok, _credential, _plaintext} =
             ApiTokens.create(operator, %{
               name: "ordinary",
               scopes: ["forge:write"],
               lifetime_days: 90
             })
  end

  test "removing the operator invalidates an existing privileged token", %{conn: conn} do
    {conn, operator} = operator_conn(conn, "api-promote-revoked")
    first = seeded_commit(@repo, "first")
    second = seeded_commit(@repo, "second")

    assert conn |> post(@create, body(first)) |> json_response(202)

    revoke_operator(operator)

    refused = conn |> post(@create, body(second)) |> json_response(403)
    assert code(refused) == "not_operator"
    assert refused["status"] == 403
    assert conn |> get(@create) |> json_response(403)
    assert Repo.aggregate(Target, :count) == 1
  end

  test "a revoked credential is refused", %{conn: conn} do
    operator = operator_fixture("api-promote-revoked-token")
    sha = seeded_commit(@repo)

    {:ok, credential, plaintext} =
      ApiTokens.create(operator, %{
        name: "release",
        scopes: ["deployments:promote"],
        lifetime_days: 1
      })

    {:ok, _revoked} = ApiTokens.revoke(operator, credential.id)

    conn = put_req_header(conn, "authorization", "Bearer " <> plaintext)

    assert conn |> post(@create, body(sha)) |> json_response(401)
  end

  test "a malformed credential is refused", %{conn: conn} do
    sha = seeded_commit(@repo)
    conn = put_req_header(conn, "authorization", "Bearer oa_pat_not-a-real-token")

    assert conn |> post(@create, body(sha)) |> json_response(401)
  end

  test "branches, abbreviations, unknown commits, and foreign repositories are refused", %{
    conn: conn
  } do
    {conn, _operator} = operator_conn(conn, "api-promote-exactness")
    sha = seeded_commit(@repo)

    branch = conn |> post(@create, body("main")) |> json_response(422)
    assert code(branch) == "validation_failed"
    assert branch["errors"]["sha"] == ["must be one full 40-character commit SHA"]

    assert conn |> post(@create, body(String.slice(sha, 0, 12))) |> json_response(422) |> code() ==
             "validation_failed"

    assert conn
           |> post(@create, body(String.duplicate("b", 40)))
           |> json_response(422)
           |> code() == "unknown_commit"

    foreign =
      conn |> post(@create, body(sha, %{"repo" => "someone-elses-repo"})) |> json_response(422)

    assert code(foreign) == "validation_failed"
    assert foreign["errors"]["repo"] == ["has no fleet to promote to"]

    preview =
      conn |> post(@create, body(sha, %{"environment" => "preview"})) |> json_response(422)

    assert code(preview) == "validation_failed"
    assert preview["errors"]["environment"] == ["must be production"]

    assert Repo.aggregate(Target, :count) == 0
  end

  test "an idempotent retry returns one target and a conflicting retry is a 409", %{conn: conn} do
    {conn, _operator} = operator_conn(conn, "api-promote-idempotent")
    sha = seeded_commit(@repo, "one")
    other = seeded_commit(@repo, "two")
    request = body(sha, %{"idempotency_key" => "release-2026-08-23-0007"})

    created = conn |> post(@create, request) |> json_response(202)
    replayed = conn |> post(@create, request) |> json_response(200)

    assert replayed["id"] == created["id"]
    assert replayed["replayed"] == true

    conflict = conn |> post(@create, %{request | "sha" => other}) |> json_response(409)
    assert code(conflict) == "idempotency_conflict"
    assert Repo.aggregate(Target, :count) == 1
  end

  test "a missing idempotency key is refused", %{conn: conn} do
    {conn, _operator} = operator_conn(conn, "api-promote-missing-key")
    sha = seeded_commit(@repo)

    refused =
      conn |> post(@create, Map.delete(body(sha), "idempotency_key")) |> json_response(422)

    assert code(refused) == "validation_failed"

    assert refused["errors"] == %{
             "idempotency_key" => ["must be 8 to 255 characters, generated by the caller"]
           }

    assert Repo.aggregate(Target, :count) == 0
  end

  test "an expected-current-target precondition returns 409 once it is stale", %{conn: conn} do
    {conn, _operator} = operator_conn(conn, "api-promote-precondition")
    first = seeded_commit(@repo, "first")
    second = seeded_commit(@repo, "second")
    third = seeded_commit(@repo, "third")

    original = conn |> post(@create, body(first)) |> json_response(202)

    assert conn
           |> post(@create, body(second, %{"expected_current_target_id" => original["id"]}))
           |> json_response(202)

    stale =
      conn
      |> post(@create, body(third, %{"expected_current_target_id" => original["id"]}))
      |> json_response(409)

    assert code(stale) == "precondition_failed"
    assert Repo.aggregate(Target, :count) == 2
  end

  test "an unknown target ID is a bounded 404", %{conn: conn} do
    {conn, _operator} = operator_conn(conn, "api-promote-missing-target")

    missing =
      conn
      |> get("#{@create}/00000000-0000-4000-8000-000000000001")
      |> json_response(404)

    assert code(missing) == "not_found"
    assert conn |> get("#{@create}/not-a-uuid") |> json_response(404) |> code() == "not_found"
  end

  defp code(response), do: response["code"]
end