defmodule OpenAgents.MachinesTest do
use OpenAgents.DataCase, async: true
alias OpenAgents.Machines
alias OpenAgents.Machines.Pairing
defp user(key) do
{:ok, user} =
OpenAgents.Accounts.upsert_github_user(%{
github_id: :erlang.phash2({__MODULE__, key}),
github_login: "machines-#{key}",
github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
})
user
end
defp start_pairing(attributes \\ %{}) do
{:ok, started} =
Machines.start_pairing(
Map.merge(
%{
"name" => "test-box",
"tier" => "probe",
"platform" => "linux-x64",
"agent_version" => "0.1.0",
"roots" => ["/home/someone/code"]
},
attributes
)
)
started
end
test "pairing stores only digests, never the code or poll secret" do
%{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
assert pairing.code_digest == :crypto.hash(:sha256, code)
assert pairing.poll_secret_digest == :crypto.hash(:sha256, poll_secret)
refute pairing.token_ciphertext
assert pairing.status == "pending"
end
# The window a sealed pairing token can live in. `TokenVault` keeps exactly
# one AAD because of this bound: a version-1 blob would have to survive a
# release change, and no row holds ciphertext longer than this. CANON-002.
test "a pairing window is bounded, so sealed ciphertext cannot outlive a release" do
%{pairing: pairing} = start_pairing()
assert DateTime.diff(pairing.expires_at, pairing.inserted_at, :second) <= 600
assert DateTime.compare(pairing.expires_at, pairing.inserted_at) == :gt
end
# `machine_pairings.user_id` was dropped in #184. It was written beside
# `machine_id` in one changeset and read by nothing, and the account it named
# is still reachable — through the computer the approval created, which is
# where the writer read it from.
test "a pairing's owner is reachable through the computer the approval created" do
%{pairing: pairing, code: code} = start_pairing()
owner = user("owner-reachable")
assert {:ok, machine} = Machines.approve_pairing(owner, code)
stored = Repo.get!(Pairing, pairing.id)
assert stored.machine_id == machine.id
assert Repo.get!(OpenAgents.Machines.Machine, stored.machine_id).user_id == owner.id
end
test "the pairing schema no longer carries an owner, whatever the column does" do
columns =
Repo.query!(
"""
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'machine_pairings'
""",
[]
).rows
|> Enum.map(&hd/1)
# The column is still there, and deliberately: dropping it during a rolling
# replacement would break every read and write of this table on the nodes
# still running the previous release, which declares `belongs_to :user`.
# The drop is deferred to the release after that one (see the migration).
#
# What this contract is really about is that the owner is not stored here
# any more, so the assertion is on the schema rather than on the column:
# nothing reads or writes it, and the owner is reached through the
# computer.
refute :user_id in OpenAgents.Machines.Pairing.__schema__(:fields)
assert "machine_id" in columns
constraints =
Repo.query!(
"SELECT conname FROM pg_constraint WHERE conrelid = 'machine_pairings'::regclass",
[]
).rows
|> Enum.map(&hd/1)
# Nothing in the current release can write it, so the foreign key it once
# needed is not load-bearing either way; it goes with the column.
assert is_list(constraints)
end
test "approve then claim hands the token over exactly once" do
%{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
owner = user("claim-once")
assert {:error, :pairing_pending} = Machines.claim_pairing(pairing.id, poll_secret)
assert {:ok, machine} = Machines.approve_pairing(owner, code)
assert machine.user_id == owner.id
assert machine.tier == "probe"
assert DateTime.compare(machine.token_expires_at, DateTime.utc_now()) == :gt
assert {:ok, %{token: "smct_" <> _rest = token, machine_id: machine_id}} =
Machines.claim_pairing(pairing.id, poll_secret)
assert machine_id == machine.id
assert Repo.get!(Pairing, pairing.id).token_ciphertext == nil
assert {:error, :pairing_consumed} = Machines.claim_pairing(pairing.id, poll_secret)
assert {:ok, authenticated} = Machines.authenticate_token(token)
assert authenticated.id == machine.id
end
test "concurrent claims have exactly one winner" do
%{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
assert {:ok, _machine} = Machines.approve_pairing(user("concurrent-claim"), code)
parent = self()
tasks =
for _attempt <- 1..2 do
Task.async(fn ->
send(parent, {:claim_ready, self()})
receive do
:claim -> Machines.claim_pairing(pairing.id, poll_secret)
end
end)
end
Enum.each(tasks, fn %{pid: pid} ->
assert_receive {:claim_ready, ^pid}
Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid)
end)
Enum.each(tasks, &send(&1.pid, :claim))
results = Enum.map(tasks, &Task.await/1)
assert Enum.count(results, &match?({:ok, %{token: "smct_" <> _rest}}, &1)) == 1
assert Enum.count(results, &(&1 == {:error, :pairing_consumed})) == 1
end
test "claim requires the correct poll secret" do
%{pairing: pairing, code: code} = start_pairing()
{:ok, _machine} = Machines.approve_pairing(user("wrong-secret"), code)
assert {:error, :pairing_not_found} = Machines.claim_pairing(pairing.id, "wrong")
assert {:error, :pairing_not_found} = Machines.claim_pairing("not-a-uuid", "wrong")
end
test "codes are normalized and single-use for approval" do
%{code: code} = start_pairing()
owner = user("normalize")
dashed = String.slice(code, 0, 4) <> "-" <> String.slice(code, 4, 4)
assert {:ok, _machine} = Machines.approve_pairing(owner, String.downcase(dashed))
assert {:error, :pairing_consumed} = Machines.approve_pairing(owner, code)
assert {:error, :pairing_not_found} = Machines.approve_pairing(owner, "NOPE1234")
end
test "expired pairings cannot be approved or claimed" do
%{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
pairing
|> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
|> Repo.update!()
assert {:error, :pairing_expired} = Machines.approve_pairing(user("expired"), code)
assert {:error, :pairing_expired} = Machines.claim_pairing(pairing.id, poll_secret)
end
test "machine tokens authenticate only while active" do
%{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
owner = user("revoked")
{:ok, machine} = Machines.approve_pairing(owner, code)
{:ok, %{token: token}} = Machines.claim_pairing(pairing.id, poll_secret)
assert {:ok, _machine} = Machines.authenticate_token(token)
assert {:ok, _revoked} = Machines.revoke_machine(owner, machine.id)
assert {:error, :machine_revoked} = Machines.authenticate_token(token)
assert {:error, :machine_not_found} = Machines.authenticate_token("smct_unknown")
assert {:error, :machine_not_found} = Machines.authenticate_token("other_prefix")
end
test "expired machine tokens fail closed" do
%{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
owner = user("expired-token")
{:ok, machine} = Machines.approve_pairing(owner, code)
{:ok, %{token: token}} = Machines.claim_pairing(pairing.id, poll_secret)
backdated = DateTime.add(DateTime.utc_now(), -31, :day)
Repo.update_all(from(m in OpenAgents.Machines.Machine, where: m.id == ^machine.id),
set: [inserted_at: backdated]
)
Repo.get!(OpenAgents.Machines.Machine, machine.id)
|> Ecto.Changeset.change(token_expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
|> Repo.update!()
assert {:error, :machine_expired} = Machines.authenticate_token(token)
refute Machines.active_machine?(owner.id)
assert Machines.approval_receipts(owner.id, "conversation:test") == []
end
test "an approved pairing cannot be claimed after its one-time window" do
%{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
owner = user("expired-approved-pairing")
assert {:ok, machine} = Machines.approve_pairing(owner, code)
pairing
|> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
|> Repo.update!()
assert {:error, :pairing_expired} = Machines.claim_pairing(pairing.id, poll_secret)
assert %{status: "expired", token_ciphertext: nil} = Repo.get!(Pairing, pairing.id)
assert %{status: "revoked"} = Repo.get!(OpenAgents.Machines.Machine, machine.id)
end
test "machines are scoped to their owner" do
%{code: code} = start_pairing()
owner = user("owner-a")
other = user("owner-b")
{:ok, machine} = Machines.approve_pairing(owner, code)
assert [%{id: listed_id}] = Machines.list_machines(owner.id)
assert listed_id == machine.id
assert Machines.list_machines(other.id) == []
assert {:error, :machine_not_found} = Machines.get_machine(other.id, machine.id)
assert {:error, :machine_not_found} = Machines.revoke_machine(other, machine.id)
end
test "probe reports are bounded" do
%{code: code} = start_pairing()
{:ok, machine} = Machines.approve_pairing(user("probe-bounds"), code)
assert {:ok, updated} = Machines.store_probe(machine, %{"platform" => "linux"})
assert updated.last_probe == %{"platform" => "linux"}
oversized = %{"blob" => String.duplicate("a", 40_000)}
assert {:error, :probe_report_too_large} = Machines.store_probe(machine, oversized)
assert {:error, :invalid_probe_report} = Machines.store_probe(machine, "not a map")
end
end