test/openagents_web/controllers/release_controller_test.exs

main at 58e6347eeb72 · 7 KB

defmodule OpenAgentsWeb.ReleaseControllerTest do
  @moduledoc """
  The contract `priv/static/install.sh` reads.

  The installer resolves a channel, downloads an artifact, and verifies it
  against a sums file, all through `/releases/<name>`. Two parts of that are
  load-bearing beyond the usual "did it return the bytes": a `HEAD` must state
  the artifact's real size, and a ranged request must answer `206` with only
  the range. The installer fetches eight ranges concurrently for anything of
  16 MiB or more and concatenates them, so a `200` with the whole body in reply
  to a range produces eight complete copies in one file — a corrupt binary that
  still looks like a successful download.

  The bucket is stubbed rather than reached. The tests are about what this
  route does with an upstream answer, and a test that needs the network is a
  test that reports someone else's outage as our regression.
  """

  use OpenAgentsWeb.ConnCase, async: false

  @bucket "releases-test-bucket"
  @artifact "openagents-0.1.0-rc.1-macos-aarch64"

  setup do
    original_bucket = Application.get_env(:openagents, :releases_bucket)
    original_options = Application.get_env(:openagents, :releases_request_options)

    Application.put_env(:openagents, :releases_bucket, @bucket)
    Application.put_env(:openagents, :releases_request_options, plug: {Req.Test, __MODULE__})

    on_exit(fn ->
      Application.put_env(:openagents, :releases_bucket, original_bucket)
      Application.put_env(:openagents, :releases_request_options, original_options)
    end)

    :ok
  end

  test "an artifact is served as opaque bytes a client may keep forever", %{conn: conn} do
    body = :binary.copy("o", 64)

    Req.Test.stub(__MODULE__, fn upstream ->
      assert upstream.method == "GET"
      assert upstream.host == "storage.googleapis.com"
      assert upstream.request_path == "/#{@bucket}/#{@artifact}"

      # Compression would make the length this route forwards describe the
      # encoded body rather than the bytes the installer checksums.
      assert Plug.Conn.get_req_header(upstream, "accept-encoding") == ["identity"]

      upstream
      |> Plug.Conn.put_resp_header("content-length", "64")
      |> Plug.Conn.send_resp(200, body)
    end)

    conn = get(conn, ~p"/releases/#{@artifact}")

    assert conn.status == 200
    assert conn.resp_body == body
    assert get_resp_header(conn, "content-type") == ["application/octet-stream"]
    assert get_resp_header(conn, "content-length") == ["64"]
    assert get_resp_header(conn, "accept-ranges") == ["bytes"]
    assert get_resp_header(conn, "x-content-type-options") == ["nosniff"]
    assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"]
  end

  test "a sums file is readable text a client may keep forever", %{conn: conn} do
    sums = "#{String.duplicate("a", 64)}  #{@artifact}\n"

    Req.Test.stub(__MODULE__, fn upstream ->
      assert upstream.request_path == "/#{@bucket}/SHA256SUMS-0.1.0-rc.1"

      upstream
      |> Plug.Conn.put_resp_header("content-length", to_string(byte_size(sums)))
      |> Plug.Conn.send_resp(200, sums)
    end)

    conn = get(conn, ~p"/releases/SHA256SUMS-0.1.0-rc.1")

    assert conn.status == 200
    assert conn.resp_body == sums
    assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"]
    assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"]
  end

  test "a channel pointer is text that expires quickly", %{conn: conn} do
    Req.Test.stub(__MODULE__, fn upstream ->
      assert upstream.request_path == "/#{@bucket}/stable"

      upstream
      |> Plug.Conn.put_resp_header("content-length", "11")
      |> Plug.Conn.send_resp(200, "0.1.0-rc.1\n")
    end)

    conn = get(conn, ~p"/releases/stable")

    assert conn.status == 200
    assert conn.resp_body == "0.1.0-rc.1\n"
    assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"]

    # `stable` is a name that moves. Caching it the way an artifact is cached
    # would pin every installer that read it to the release it named that day.
    assert get_resp_header(conn, "cache-control") == ["public, max-age=60"]
  end

  test "a ranged request is answered with only the range that was asked for", %{conn: conn} do
    Req.Test.stub(__MODULE__, fn upstream ->
      assert Plug.Conn.get_req_header(upstream, "range") == ["bytes=8-15"]

      upstream
      |> Plug.Conn.put_resp_header("content-range", "bytes 8-15/64")
      |> Plug.Conn.put_resp_header("content-length", "8")
      |> Plug.Conn.send_resp(206, "34567890")
    end)

    conn =
      conn
      |> put_req_header("range", "bytes=8-15")
      |> get(~p"/releases/#{@artifact}")

    assert conn.status == 206
    assert conn.resp_body == "34567890"
    assert get_resp_header(conn, "content-range") == ["bytes 8-15/64"]
    assert get_resp_header(conn, "content-length") == ["8"]
    assert get_resp_header(conn, "accept-ranges") == ["bytes"]
  end

  test "a HEAD states the artifact's real size without sending it", %{conn: conn} do
    Req.Test.stub(__MODULE__, fn upstream ->
      # The size probe never asks for the body. Answering the client's `HEAD`
      # with a `GET` upstream would pull 40 MB out of the bucket to throw away.
      assert upstream.method == "HEAD"

      upstream
      |> Plug.Conn.put_resp_header("content-length", "41943040")
      |> Plug.Conn.send_resp(200, "")
    end)

    conn = head(conn, ~p"/releases/#{@artifact}")

    assert conn.status == 200
    assert conn.resp_body == ""
    assert get_resp_header(conn, "content-length") == ["41943040"]
    assert get_resp_header(conn, "accept-ranges") == ["bytes"]
    assert get_resp_header(conn, "content-type") == ["application/octet-stream"]
  end

  test "an object the bucket does not hold is a plain 404", %{conn: conn} do
    Req.Test.stub(__MODULE__, fn upstream ->
      Plug.Conn.send_resp(upstream, 404, "<?xml version='1.0'?><Error>NoSuchKey</Error>")
    end)

    conn = get(conn, ~p"/releases/openagents-9.9.9-linux-x86_64")

    assert conn.status == 404
    refute conn.resp_body =~ "NoSuchKey"
  end

  test "a bucket that answers with a fault is not reported as ours", %{conn: conn} do
    Req.Test.stub(__MODULE__, fn upstream ->
      Plug.Conn.send_resp(upstream, 503, "unavailable")
    end)

    conn = get(conn, ~p"/releases/stable")

    assert conn.status == 502
  end

  test "a name outside the allowlist is refused before the bucket is asked" do
    Req.Test.stub(__MODULE__, fn _upstream ->
      flunk("a rejected name reached the bucket")
    end)

    # `..` and a leading dot are refused by shape, and an encoded slash arrives
    # as one segment holding a slash, which the allowlist does not admit.
    for name <- ["..", ".ssh", "a%2Fb", "openagents%200.1.0", "under..dot"] do
      conn = get(build_conn(), "/releases/" <> name)
      assert conn.status == 404, "#{name} was not refused"
    end
  end

  test "every platform the installer can ask for is admitted", %{conn: conn} do
    Req.Test.stub(__MODULE__, fn upstream ->
      Plug.Conn.send_resp(upstream, 200, "bytes")
    end)

    # The musl artifacts carry two more hyphens than any name that existed when
    # the allowlist was written. A pattern tightened later without them in mind
    # would 404 every Alpine install while every other platform kept working.
    for platform <- [
          "macos-aarch64",
          "macos-x86_64",
          "linux-x86_64",
          "linux-x86_64-musl",
          "linux-aarch64",
          "linux-aarch64-musl",
          "windows-x86_64"
        ] do
      name = "openagents-0.1.0-rc.2-#{platform}"

      assert get(conn, ~p"/releases/#{name}").status == 200, "#{name} was refused"
    end
  end

  test "a deeper path names no object, and the reserved slug is what answers", %{conn: conn} do
    Req.Test.stub(__MODULE__, fn _upstream ->
      flunk("a multi-segment path reached the bucket")
    end)

    assert get(conn, "/releases/openagents/0.1.0").status == 404
  end
end