test/openagents/threads/grant_fence_test.exs

main at 58e6347eeb72 · 4 KB

defmodule OpenAgents.Threads.GrantFenceTest do
  @moduledoc """
  THREAD-001, at the database. Every claim here bypasses the Ecto changeset and
  writes raw SQL, because the property is that PostgreSQL refuses the row —
  not that the application declines to build it.
  """

  use OpenAgents.DataCase, async: false

  import OpenAgentsWeb.ConnCase, only: [github_user: 1]

  alias OpenAgents.Conversations
  alias OpenAgents.Repo
  alias OpenAgents.Threads

  @insert """
  INSERT INTO inference_grants
    (id, owner_visitor_id, conversation_id, thread_id, machine_id, model_id,
     token_digest, status, max_total_tokens, max_calls, max_cost_microusd,
     call_count, usage, expires_at, inserted_at, updated_at)
  VALUES ($1, $2, $3, $4, NULL, 'model-under-test', $5, 'active',
          1000, 4, 1000, 0, '{}', $6, $6, $6)
  """

  setup do
    user = github_user("fence-#{System.unique_integer([:positive])}")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    {:ok, thread} = Threads.open(user, "Prove the fence")

    %{
      visitor_id: uuid(conversation.visitor_id),
      conversation_id: uuid(conversation.id),
      thread_id: uuid(thread.id),
      thread: thread,
      user: user
    }
  end

  describe "a grant names exactly one fence" do
    test "a conversation grant is admitted", context do
      assert {:ok, %{num_rows: 1}} =
               insert_grant(context.visitor_id, context.conversation_id, nil)
    end

    test "a thread grant is admitted", context do
      assert {:ok, %{num_rows: 1}} = insert_grant(context.visitor_id, nil, context.thread_id)
    end

    test "naming both is refused", context do
      assert {:error, %Postgrex.Error{} = error} =
               insert_grant(context.visitor_id, context.conversation_id, context.thread_id)

      assert error.postgres.constraint == "inference_grant_exactly_one_fence"
    end

    test "naming neither is refused", context do
      assert {:error, %Postgrex.Error{} = error} = insert_grant(context.visitor_id, nil, nil)
      assert error.postgres.constraint == "inference_grant_exactly_one_fence"
    end
  end

  describe "a thread holds at most one live grant" do
    test "a second active grant for the same thread is refused", context do
      assert {:ok, %{num_rows: 1}} = insert_grant(context.visitor_id, nil, context.thread_id)

      assert {:error, %Postgrex.Error{} = error} =
               insert_grant(context.visitor_id, nil, context.thread_id)

      assert error.postgres.constraint == "inference_grants_one_active_thread_index"
    end

    test "a revoked grant frees the slot", context do
      {:ok, _thread, grant, _token} = Threads.mint_grant(context.thread)
      {:ok, _revoked} = OpenAgents.Inference.revoke(grant)

      assert {:ok, %{num_rows: 1}} = insert_grant(context.visitor_id, nil, context.thread_id)
    end
  end

  describe "the immutability guard is NULL-safe on both fences" do
    test "a thread grant cannot acquire a conversation", context do
      {:ok, _thread, grant, _token} = Threads.mint_grant(context.thread)

      assert {:error, %Postgrex.Error{} = error} =
               Repo.query(
                 "UPDATE inference_grants SET conversation_id = $1 WHERE id = $2",
                 [context.conversation_id, uuid(grant.id)]
               )

      assert error.postgres.message =~ "immutable identity/budget fields"
    end

    test "a conversation grant cannot acquire a thread", context do
      {:ok, grant, _token} =
        OpenAgents.Inference.mint(%{
          owner_visitor_id: context.thread.owner_visitor_id,
          conversation_id: Ecto.UUID.load!(context.conversation_id),
          machine_id: nil
        })

      assert {:error, %Postgrex.Error{} = error} =
               Repo.query(
                 "UPDATE inference_grants SET thread_id = $1 WHERE id = $2",
                 [context.thread_id, uuid(grant.id)]
               )

      assert error.postgres.message =~ "immutable identity/budget fields"
    end
  end

  defp insert_grant(visitor_id, conversation_id, thread_id) do
    now = DateTime.utc_now()

    Repo.query(@insert, [
      uuid(Ecto.UUID.generate()),
      visitor_id,
      conversation_id,
      thread_id,
      :crypto.strong_rand_bytes(32),
      DateTime.add(now, 900, :second)
    ])
  end

  defp uuid(value), do: Ecto.UUID.dump!(value)
end