-
Notifications
You must be signed in to change notification settings - Fork 8
test: contract tests improving coverage (toward #79) #81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| defmodule Hyper.Cluster.RoutingTest do | ||
| @moduledoc """ | ||
| Routing registry contracts beyond the resolution paths covered by `HyperTest`: | ||
|
|
||
| * `register_self/1` is idempotent-by-error: registering a key that is | ||
| already held returns `{:error, {:already_registered, _}}` rather than | ||
| crashing or silently replacing the owner. | ||
| * `all/0` reports every registered VM supervisor paired with the node | ||
| its process lives on (the cluster-wide inventory callers rely on). | ||
| """ | ||
|
|
||
| use ExUnit.Case, async: false | ||
|
|
||
| alias Hyper.Cluster.Routing | ||
|
|
||
| setup do | ||
| # Under `mix test --no-start` the app tree (which provisions Routing) is | ||
| # absent; start a test-scoped registry so these contracts are testable | ||
| # without the full supervision tree. Routing is a named singleton, so if | ||
| # another test (e.g. HyperTest) already started it, reuse that one rather | ||
| # than racing on start_supervised!/1. | ||
| if Process.whereis(Routing) do | ||
| :ok | ||
| else | ||
| _ = Application.ensure_all_started(:horde) | ||
|
|
||
| case start_supervised(Routing) do | ||
| {:ok, _pid} -> :ok | ||
| # Lost the start race to a parallel test: reuse the existing registry. | ||
| {:error, {:already_started, _pid}} -> :ok | ||
| end | ||
| end | ||
|
Comment on lines
+17
to
+32
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. my gut feeling is that this is going to cause some flaky tests in the future, but OK, good enough for now |
||
| end | ||
|
|
||
| # Horde materialises a registration into the local replica asynchronously, so | ||
| # poll until the entry is visible before asserting against it. | ||
| defp await_materialised?(key, tries \\ 200) | ||
| defp await_materialised?(_key, 0), do: false | ||
|
|
||
| defp await_materialised?(key, tries) do | ||
| if Horde.Registry.lookup(Routing, key) != [], | ||
| do: true, | ||
| else: await_materialised?(key, tries - 1) | ||
| end | ||
|
|
||
| test "register_self/1 returns {:error, {:already_registered, _}} for a held key" do | ||
| key = {Hyper.Vm.Id.generate(), :supervisor} | ||
| assert :ok = Routing.register_self(key) | ||
| assert await_materialised?(key) | ||
|
|
||
| # The same process registering the same key again must report the conflict, | ||
| # not replace the owner or crash -- callers branch on this tuple. | ||
| assert {:error, {:already_registered, pid}} = Routing.register_self(key) | ||
| assert pid == self() | ||
| end | ||
|
|
||
| test "all/0 lists every registered supervisor as {vm_id, node}" do | ||
| vm_id = Hyper.Vm.Id.generate() | ||
| :ok = Routing.register_self({vm_id, :supervisor}) | ||
| assert await_materialised?({vm_id, :supervisor}) | ||
|
|
||
| assert {^vm_id, node} = List.keyfind(Routing.all(), vm_id, 0) | ||
| assert node == node() | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,9 +3,14 @@ defmodule Hyper.Grpc.CodecTest do | |
|
|
||
| alias Hyper.Grpc.Codec | ||
| alias Hyper.Grpc.V1.CreateVmRequest | ||
| alias Hyper.Grpc.V1.CreateVmResponse | ||
| alias Hyper.Grpc.V1.GetVmResponse | ||
| alias Hyper.Grpc.V1.GetVmUsageResponse | ||
| alias Hyper.Grpc.V1.ListVmsResponse | ||
| alias Hyper.Grpc.V1.LoadImageRequest | ||
| alias Hyper.Grpc.V1.LoadImageResponse | ||
| alias Hyper.Grpc.V1.StopVmResponse | ||
| alias Hyper.Vm.Spec | ||
|
|
||
| test "usage encodes as microseconds on the wire" do | ||
| assert %GetVmUsageResponse{vm_id: "v1", cpu_usec: 1_500_000} = | ||
|
|
@@ -74,4 +79,110 @@ defmodule Hyper.Grpc.CodecTest do | |
| test "a malformed page_token maps to INVALID_ARGUMENT" do | ||
| assert %GRPC.RPCError{status: 3} = Codec.to_grpc({:error, :bad_page_token}) | ||
| end | ||
|
|
||
| # `from_grpc/1` -- the decode boundary. The contract: an inbound CreateVmRequest | ||
| # maps to a domain Spec with the right field placement, or is refused with a | ||
| # specific reason the server then classifies to a gRPC status. | ||
| describe "from_grpc/1 CreateVmRequest" do | ||
| test "a well-formed request decodes to a Spec preserving every field" do | ||
| assert {:ok, | ||
| %Spec{ | ||
| img_id: "img", | ||
| type: :micro, | ||
| arch: :aarch64, | ||
| boot_args: "console=ttyS0" | ||
| }} = | ||
| Codec.from_grpc(%CreateVmRequest{ | ||
| img_id: "img", | ||
| instance_type: :INSTANCE_TYPE_MICRO, | ||
| arch: :ARCHITECTURE_AARCH64, | ||
| boot_args: "console=ttyS0" | ||
| }) | ||
| end | ||
|
Comment on lines
+83
to
+101
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I generally don't see that much utility in these tests frankly. I will be honest I mostly vibe-coded these tests, so that's how we ended up with i'm comfortable with your PR, considering the existing precedent =)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, more following through on the issue's ask. I'l look into creating an e2e ask and see if I can help with that. |
||
|
|
||
| test "a missing img_id (nil or empty) is refused" do | ||
| assert {:error, :missing_img_id} = | ||
| Codec.from_grpc(%CreateVmRequest{img_id: nil, instance_type: :INSTANCE_TYPE_MICRO}) | ||
|
|
||
| assert {:error, :missing_img_id} = | ||
| Codec.from_grpc(%CreateVmRequest{img_id: "", instance_type: :INSTANCE_TYPE_MICRO}) | ||
| end | ||
| end | ||
|
|
||
| describe "from_grpc/1 LoadImageRequest" do | ||
| test "a request with no label decodes to an empty opts list" do | ||
| assert {:ok, {"alpine:3.19", []}} = | ||
| Codec.from_grpc(%LoadImageRequest{image_ref: "alpine:3.19", label: nil}) | ||
|
|
||
| assert {:ok, {"alpine:3.19", []}} = | ||
| Codec.from_grpc(%LoadImageRequest{image_ref: "alpine:3.19", label: ""}) | ||
| end | ||
|
|
||
| test "a request carrying a label forwards it as a labelled opt" do | ||
| assert {:ok, {"alpine:3.19", [label: "prod"]}} = | ||
| Codec.from_grpc(%LoadImageRequest{image_ref: "alpine:3.19", label: "prod"}) | ||
| end | ||
|
|
||
| test "a missing image_ref (nil or empty) is refused" do | ||
| assert {:error, :missing_image_ref} = Codec.from_grpc(%LoadImageRequest{image_ref: nil}) | ||
| assert {:error, :missing_image_ref} = Codec.from_grpc(%LoadImageRequest{image_ref: ""}) | ||
| end | ||
| end | ||
|
|
||
| # `to_grpc/1` -- the encode boundary. The contract: each domain result maps to | ||
| # the response message carrying exactly the fields a client reads. | ||
| describe "to_grpc/1 response encoding" do | ||
| test "a created result carries the vm_id and node string" do | ||
| assert %CreateVmResponse{vm_id: "vabc", node: "hyper@host"} = | ||
| Codec.to_grpc({:created, "vabc", :hyper@host}) | ||
| end | ||
|
|
||
| test "a located result carries the vm_id and node string" do | ||
| assert %GetVmResponse{vm_id: "vabc", node: "hyper@host"} = | ||
| Codec.to_grpc({:located, "vabc", :hyper@host}) | ||
| end | ||
|
|
||
| test "a loaded result carries the image id" do | ||
| assert %LoadImageResponse{img_id: "img-1"} = Codec.to_grpc({:loaded, "img-1"}) | ||
| end | ||
| end | ||
|
|
||
| # `rpc_error/1` -- the status-classification contract. The server promises each | ||
| # domain reason a specific gRPC status; a mutation that swaps two statuses would | ||
| # silently change what clients see. Table-driven: one assertion shape, rows | ||
| # differ only in the reason and expected status. | ||
| describe "to_grpc {:error, _} status classification" do | ||
| @status_cases [ | ||
| {:missing_img_id, GRPC.Status.invalid_argument()}, | ||
| {:missing_image_ref, GRPC.Status.invalid_argument()}, | ||
| {:invalid_ref, GRPC.Status.invalid_argument()}, | ||
| {:machine_unreachable, GRPC.Status.unavailable()}, | ||
| {:no_capacity, GRPC.Status.resource_exhausted()}, | ||
| {:exhausted, GRPC.Status.resource_exhausted()} | ||
| ] | ||
|
|
||
| test "each domain reason maps to its promised gRPC status" do | ||
| for {reason, status} <- @status_cases do | ||
| assert %GRPC.RPCError{status: ^status} = Codec.to_grpc({:error, reason}) | ||
| end | ||
| end | ||
|
|
||
| test "a missing-tools error names the offending tools in the message" do | ||
| status = GRPC.Status.failed_precondition() | ||
|
|
||
| assert %GRPC.RPCError{ | ||
| status: ^status, | ||
| message: "node is missing required image tools: umoci, skopeo" | ||
| } = Codec.to_grpc({:error, {:missing_tools, ["umoci", "skopeo"]}}) | ||
| end | ||
|
|
||
| test "an unrecognised reason never crashes and falls back to INTERNAL" do | ||
| status = GRPC.Status.internal() | ||
|
|
||
| assert %GRPC.RPCError{status: ^status, message: msg} = | ||
| Codec.to_grpc({:error, :something_truly_novel}) | ||
|
|
||
| assert msg =~ "something_truly_novel" | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -91,6 +91,19 @@ defmodule Redist.TargzTest do | |
| assert File.ls(dest) in [{:error, :enoent}, {:ok, []}] | ||
| end | ||
|
|
||
| test "install/3 surfaces a corrupt archive as {:extract_failed, _}", %{ | ||
| server: server, | ||
| dest: dest | ||
| } do | ||
|
Comment on lines
+94
to
+97
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| # Checksum passes (it is the hash of the bytes we serve); extraction fails | ||
| # because the payload is not a tarball. The contract: a corrupt archive | ||
| # classifies as extract_failed, distinct from the unsafe-path refusal. | ||
| bytes = :crypto.strong_rand_bytes(64) | ||
| url = HttpServer.put_file(server, "bogus.tar.gz", bytes) | ||
|
|
||
| assert {:error, {:extract_failed, _reason}} = Targz.install(url, sha256(bytes), dest) | ||
| end | ||
|
|
||
| defp sha256(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) | ||
|
|
||
| defp targz(files) do | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fair