From 821ff99bf0715550dd618c62a4771914cd93e9f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Jan 2026 13:29:22 +0000 Subject: [PATCH 1/5] Initial plan From fa8d182e7633a003ed865af130693befd3ce3d5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Jan 2026 13:36:58 +0000 Subject: [PATCH 2/5] Add async testing support with enforcer isolation utilities Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- README.md | 25 +++ guides/async_testing.md | 291 ++++++++++++++++++++++++++++ lib/casbin/test_helper.ex | 163 ++++++++++++++++ test/async_testing_example_test.exs | 180 +++++++++++++++++ 4 files changed, 659 insertions(+) create mode 100644 guides/async_testing.md create mode 100644 lib/casbin/test_helper.ex create mode 100644 test/async_testing_example_test.exs diff --git a/README.md b/README.md index 1d25ede..da7e7f9 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,31 @@ Casbin-Ex supports the following access control models: ## Testing +### Async Testing with Isolated Enforcers + +Casbin-Ex supports running tests with `async: true` by providing isolated enforcer instances per test. This prevents race conditions and enables faster parallel test execution. + +```elixir +defmodule MyApp.AclTest do + use ExUnit.Case, async: true + import Casbin.TestHelper + + setup do + enforcer_name = unique_enforcer_name() + {:ok, _} = start_test_enforcer(enforcer_name, "config.conf") + on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) + {:ok, enforcer_name: enforcer_name} + end + + test "my test", %{enforcer_name: name} do + EnforcerServer.add_policy(name, {:p, ["alice", "data", "read"]}) + assert EnforcerServer.allow?(name, ["alice", "data", "read"]) + end +end +``` + +See our [Async Testing Guide](guides/async_testing.md) for complete examples and patterns. + ### Using with Ecto.Adapters.SQL.Sandbox If you're using Casbin-Ex with Ecto and need to wrap operations in database transactions during testing, see our guide on [Testing with Ecto.Adapters.SQL.Sandbox and Transactions](guides/sandbox_testing.md). diff --git a/guides/async_testing.md b/guides/async_testing.md new file mode 100644 index 0000000..ff4b6c5 --- /dev/null +++ b/guides/async_testing.md @@ -0,0 +1,291 @@ +# Async Testing with Casbin + +This guide explains how to write tests with `async: true` when using Casbin-Ex, enabling faster parallel test execution. + +## The Problem + +By default, if multiple tests share the same enforcer name, they will share the same global state in the ETS table. This causes race conditions when running tests with `async: true`: + +```elixir +defmodule MyApp.AclTest do + use ExUnit.Case, async: true # ❌ Tests will interfere with each other + + @enforcer_name "my_enforcer" # Shared global state! + + test "test 1" do + EnforcerServer.add_policy(@enforcer_name, {:p, ["alice", "data", "read"]}) + # Another test's cleanup might delete this policy before we check it! + end + + test "test 2" do + EnforcerServer.add_policy(@enforcer_name, {:p, ["bob", "data", "write"]}) + # Policies from test 1 might still be present! + end +end +``` + +**Symptoms:** +- `list_policies()` returns `[]` even after adding policies +- `add_policy` returns `{:error, :already_existed}` but policies aren't visible +- Tests pass individually but fail when run together +- Intermittent test failures + +## Solution 1: Unique Enforcer Names Per Test + +The recommended solution is to use `Casbin.TestHelper` to create isolated enforcer instances for each test: + +```elixir +defmodule MyApp.AclTest do + use ExUnit.Case, async: true # ✅ Safe with isolated enforcers + import Casbin.TestHelper + + setup do + # Create a unique enforcer for this test + enforcer_name = unique_enforcer_name() + cfile = Path.expand("../data/model.conf", __DIR__) + + {:ok, _pid} = start_test_enforcer(enforcer_name, cfile) + + # Clean up after the test + on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) + + {:ok, enforcer_name: enforcer_name} + end + + test "alice can read data", %{enforcer_name: name} do + :ok = EnforcerServer.add_policy(name, {:p, ["alice", "data", "read"]}) + assert EnforcerServer.allow?(name, ["alice", "data", "read"]) + end + + test "bob can write data", %{enforcer_name: name} do + :ok = EnforcerServer.add_policy(name, {:p, ["bob", "data", "write"]}) + assert EnforcerServer.allow?(name, ["bob", "data", "write"]) + end +end +``` + +### With Custom Prefix + +You can add a prefix to make enforcer names more readable in logs: + +```elixir +setup do + enforcer_name = unique_enforcer_name("acl_test") + # Generates: "test_enforcer_acl_test_12345_67890_123456" + + cfile = Path.expand("../data/model.conf", __DIR__) + {:ok, _pid} = start_test_enforcer(enforcer_name, cfile) + + on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) + + {:ok, enforcer_name: enforcer_name} +end +``` + +## Solution 2: Using the Enforcer Struct Directly + +For tests that don't need the `EnforcerServer` process, use the `Enforcer` struct directly: + +```elixir +defmodule MyApp.EnforcerTest do + use ExUnit.Case, async: true + alias Casbin.Enforcer + + setup do + cfile = Path.expand("../data/model.conf", __DIR__) + {:ok, e} = Enforcer.init(cfile) + {:ok, enforcer: e} + end + + test "alice permissions", %{enforcer: e} do + {:ok, e} = Enforcer.add_policy(e, {:p, ["alice", "data", "read"]}) + assert Enforcer.allow?(e, ["alice", "data", "read"]) + end + + test "bob permissions", %{enforcer: e} do + {:ok, e} = Enforcer.add_policy(e, {:p, ["bob", "data", "write"]}) + assert Enforcer.allow?(e, ["bob", "data", "write"]) + end +end +``` + +This approach: +- ✅ No shared state - each test gets its own enforcer struct +- ✅ Fully isolated - perfect for `async: true` +- ✅ No cleanup needed +- ❌ Can't use `EnforcerServer` API (if you need that, use Solution 1) + +## Solution 3: Disable Async (Not Recommended) + +As a last resort, you can disable async testing: + +```elixir +defmodule MyApp.AclTest do + use ExUnit.Case # async: false is the default + + @enforcer_name "my_enforcer" + + # Tests run serially, no race conditions +end +``` + +**Drawbacks:** +- ❌ Slower test suite +- ❌ Doesn't scale well +- ❌ Only use if you absolutely must share an enforcer between tests + +## Using with Ecto.Adapters.SQL.Sandbox + +When using Casbin with an `EctoAdapter`, you need special handling for the sandbox. See the [sandbox_testing.md](sandbox_testing.md) guide for details. + +Quick example: + +```elixir +defmodule MyApp.AclWithDbTest do + use MyApp.DataCase, async: false # Note: async: false with Ecto transactions + import Casbin.TestHelper + + alias MyApp.Repo + + setup do + # Sandbox setup + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) + Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()}) + + # Enforcer setup + enforcer_name = unique_enforcer_name() + cfile = Path.expand("../data/model.conf", __DIR__) + {:ok, pid} = start_test_enforcer(enforcer_name, cfile) + + # Allow enforcer to access DB + Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid) + + # Set Ecto adapter + adapter = Casbin.Persist.EctoAdapter.new(Repo) + :ok = EnforcerServer.set_persist_adapter(enforcer_name, adapter) + + on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) + + {:ok, enforcer_name: enforcer_name} + end + + test "persist policies to database", %{enforcer_name: name} do + :ok = EnforcerServer.add_policy(name, {:p, ["alice", "data", "read"]}) + :ok = EnforcerServer.save_policies(name) + + # Verify in database... + end +end +``` + +## Helper Functions Reference + +### `unique_enforcer_name(prefix \\ "")` + +Generates a unique name for an enforcer instance. + +**Parameters:** +- `prefix` (optional) - A string prefix for the name + +**Returns:** A unique string like `"test_enforcer_12345_67890_123456"` + +### `start_test_enforcer(enforcer_name, config_file)` + +Starts an enforcer process under the test supervisor. + +**Parameters:** +- `enforcer_name` - Unique name (from `unique_enforcer_name/0`) +- `config_file` - Path to Casbin model config file + +**Returns:** `{:ok, pid}` or `{:error, reason}` + +### `cleanup_test_enforcer(enforcer_name)` + +Stops the enforcer process and removes it from the ETS table. + +**Parameters:** +- `enforcer_name` - Name of the enforcer to clean up + +**Returns:** `:ok` + +**Usage:** Always call in `on_exit/1`: +```elixir +on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) +``` + +### `reset_test_enforcer(enforcer_name, config_file)` + +Resets an enforcer to its initial state without stopping it. + +**Parameters:** +- `enforcer_name` - Name of the enforcer +- `config_file` - Config file to reload + +**Returns:** `:ok` or `{:error, reason}` + +**Usage:** Useful for clearing policies between test cases: +```elixir +describe "with clean state" do + setup %{enforcer_name: name, cfile: cfile} do + reset_test_enforcer(name, cfile) + :ok + end + + test "test 1", %{enforcer_name: name} do + # Clean state guaranteed + end +end +``` + +## Best Practices + +1. **Always use unique names** - Use `unique_enforcer_name()` to avoid conflicts +2. **Always cleanup** - Use `on_exit/1` to ensure cleanup even if tests fail +3. **Prefer Enforcer struct** - If you don't need `EnforcerServer`, use `Enforcer` directly +4. **One enforcer per test** - Don't try to share enforcers between tests +5. **Use prefixes** - Add readable prefixes to enforcer names for easier debugging + +## Migration Guide + +### Before (async: false) + +```elixir +defmodule MyApp.AclTest do + use ExUnit.Case # async: false + + @enforcer_name "my_enforcer" + + test "alice test" do + EnforcerServer.add_policy(@enforcer_name, {:p, ["alice", "data", "read"]}) + assert EnforcerServer.allow?(@enforcer_name, ["alice", "data", "read"]) + end +end +``` + +### After (async: true) + +```elixir +defmodule MyApp.AclTest do + use ExUnit.Case, async: true + import Casbin.TestHelper + + setup do + name = unique_enforcer_name() + cfile = "path/to/config.conf" + {:ok, _} = start_test_enforcer(name, cfile) + on_exit(fn -> cleanup_test_enforcer(name) end) + {:ok, enforcer_name: name} + end + + test "alice test", %{enforcer_name: name} do + EnforcerServer.add_policy(name, {:p, ["alice", "data", "read"]}) + assert EnforcerServer.allow?(name, ["alice", "data", "read"]) + end +end +``` + +## See Also + +- [Sandbox Testing Guide](sandbox_testing.md) - Using Casbin with Ecto.Adapters.SQL.Sandbox +- [ExUnit Documentation](https://hexdocs.pm/ex_unit/ExUnit.html) - ExUnit testing framework +- [Casbin Model Configuration](https://casbin.org/docs/syntax-for-models) - Writing model files diff --git a/lib/casbin/test_helper.ex b/lib/casbin/test_helper.ex new file mode 100644 index 0000000..8e1aab0 --- /dev/null +++ b/lib/casbin/test_helper.ex @@ -0,0 +1,163 @@ +defmodule Casbin.TestHelper do + @moduledoc """ + Helper functions for testing with Casbin enforcers in async test environments. + + This module provides utilities to create isolated enforcer instances per test, + enabling `async: true` tests that don't share global state. + + ## Example + + defmodule MyApp.AclTest do + use ExUnit.Case, async: true + import Casbin.TestHelper + + setup do + # Create a unique enforcer for this test + enforcer_name = unique_enforcer_name() + cfile = "path/to/config.conf" + + {:ok, _pid} = start_test_enforcer(enforcer_name, cfile) + + # Automatically clean up after the test + on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) + + {:ok, enforcer_name: enforcer_name} + end + + test "my test", %{enforcer_name: name} do + Casbin.EnforcerServer.add_policy(name, {:p, ["alice", "data", "read"]}) + assert Casbin.EnforcerServer.allow?(name, ["alice", "data", "read"]) + end + end + + ## Using with Ecto Adapters + + When using with `Ecto.Adapters.SQL.Sandbox`, you may need to use shared mode: + + setup do + enforcer_name = unique_enforcer_name() + cfile = "path/to/config.conf" + + # Set up sandbox + :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo) + Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()}) + + # Start enforcer + {:ok, pid} = start_test_enforcer(enforcer_name, cfile) + + # Allow enforcer to access the database connection + Ecto.Adapters.SQL.Sandbox.allow(MyApp.Repo, self(), pid) + + on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) + + {:ok, enforcer_name: enforcer_name} + end + """ + + alias Casbin.EnforcerSupervisor + alias Casbin.EnforcerServer + + @doc """ + Generates a unique enforcer name for isolated testing. + + Returns a string in the format "test_enforcer___" + where ref is the test process reference. + + ## Examples + + iex> name1 = Casbin.TestHelper.unique_enforcer_name() + iex> name2 = Casbin.TestHelper.unique_enforcer_name() + iex> name1 != name2 + true + + iex> Casbin.TestHelper.unique_enforcer_name("my_test") + "test_enforcer_my_test_" <> _ + """ + @spec unique_enforcer_name(String.t()) :: String.t() + def unique_enforcer_name(prefix \\ "") do + ref = :erlang.ref_to_list(make_ref()) |> to_string() |> String.replace(~r/[^0-9]/, "") + timestamp = System.system_time(:microsecond) + random = :rand.uniform(999_999) + + prefix_part = if prefix != "", do: "#{prefix}_", else: "" + "test_enforcer_#{prefix_part}#{ref}_#{timestamp}_#{random}" + end + + @doc """ + Starts an enforcer process for testing with the given name and configuration file. + + This is a wrapper around `Casbin.EnforcerSupervisor.start_enforcer/2` that + returns the PID for convenience in test setup. + + ## Parameters + + * `enforcer_name` - Unique name for the enforcer (use `unique_enforcer_name/0`) + * `config_file` - Path to the Casbin model configuration file + + ## Returns + + * `{:ok, pid}` - The PID of the started enforcer server + * `{:error, reason}` - If the enforcer could not be started + + ## Examples + + {:ok, pid} = start_test_enforcer("my_test_enforcer", "test/data/model.conf") + """ + @spec start_test_enforcer(String.t(), String.t()) :: + {:ok, pid()} | {:error, term()} + def start_test_enforcer(enforcer_name, config_file) do + EnforcerSupervisor.start_enforcer(enforcer_name, config_file) + end + + @doc """ + Cleans up a test enforcer by stopping its process and removing it from the ETS table. + + This should be called in an `on_exit/1` callback to ensure proper cleanup. + + ## Parameters + + * `enforcer_name` - The name of the enforcer to clean up + + ## Examples + + on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) + """ + @spec cleanup_test_enforcer(String.t()) :: :ok + def cleanup_test_enforcer(enforcer_name) do + # Stop the enforcer process if it exists + case Registry.lookup(Casbin.EnforcerRegistry, enforcer_name) do + [{pid, _}] -> + if Process.alive?(pid) do + DynamicSupervisor.terminate_child(EnforcerSupervisor, pid) + end + + [] -> + :ok + end + + # Remove from ETS table + :ets.delete(:enforcers_table, enforcer_name) + + :ok + end + + @doc """ + Resets an enforcer to its initial state by reloading the configuration. + + Useful when you need to clear all policies between test cases without + creating a new enforcer instance. + + ## Parameters + + * `enforcer_name` - The name of the enforcer to reset + * `config_file` - Path to the configuration file to reload + + ## Examples + + reset_test_enforcer(enforcer_name, "test/data/model.conf") + """ + @spec reset_test_enforcer(String.t(), String.t()) :: :ok | {:error, term()} + def reset_test_enforcer(enforcer_name, config_file) do + EnforcerServer.reset_configuration(enforcer_name, config_file) + end +end diff --git a/test/async_testing_example_test.exs b/test/async_testing_example_test.exs new file mode 100644 index 0000000..7468e11 --- /dev/null +++ b/test/async_testing_example_test.exs @@ -0,0 +1,180 @@ +defmodule Casbin.AsyncTestingExampleTest do + @moduledoc """ + This test module demonstrates how to write async tests with Casbin + using the TestHelper module for enforcer isolation. + + Each test gets its own unique enforcer instance, allowing tests to run + in parallel without race conditions. + """ + use ExUnit.Case, async: true + + import Casbin.TestHelper + + alias Casbin.EnforcerServer + + @cfile "../data/acl.conf" |> Path.expand(__DIR__) + + setup do + # Create a unique enforcer for this test + enforcer_name = unique_enforcer_name("async_test") + + {:ok, _pid} = start_test_enforcer(enforcer_name, @cfile) + + # Clean up after the test + on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) + + {:ok, enforcer_name: enforcer_name} + end + + describe "isolated enforcers in async tests" do + test "alice can read and write", %{enforcer_name: name} do + # Add policies for alice + :ok = EnforcerServer.add_policy(name, {:p, ["alice", "data1", "read"]}) + :ok = EnforcerServer.add_policy(name, {:p, ["alice", "data1", "write"]}) + + # Verify permissions + assert EnforcerServer.allow?(name, ["alice", "data1", "read"]) + assert EnforcerServer.allow?(name, ["alice", "data1", "write"]) + refute EnforcerServer.allow?(name, ["alice", "data1", "delete"]) + + # List alice's policies + policies = EnforcerServer.list_policies(name, %{sub: "alice"}) + assert length(policies) == 2 + end + + test "bob has limited access", %{enforcer_name: name} do + # Add policy for bob + :ok = EnforcerServer.add_policy(name, {:p, ["bob", "data2", "read"]}) + + # Verify permissions + assert EnforcerServer.allow?(name, ["bob", "data2", "read"]) + refute EnforcerServer.allow?(name, ["bob", "data2", "write"]) + refute EnforcerServer.allow?(name, ["bob", "data2", "delete"]) + + # Bob shouldn't have access to data1 + refute EnforcerServer.allow?(name, ["bob", "data1", "read"]) + end + + test "can remove policies", %{enforcer_name: name} do + # Add a policy + :ok = EnforcerServer.add_policy(name, {:p, ["charlie", "data3", "read"]}) + assert EnforcerServer.allow?(name, ["charlie", "data3", "read"]) + + # Remove the policy + :ok = EnforcerServer.remove_policy(name, {:p, ["charlie", "data3", "read"]}) + refute EnforcerServer.allow?(name, ["charlie", "data3", "read"]) + + # Verify it's gone + policies = EnforcerServer.list_policies(name, %{sub: "charlie"}) + assert policies == [] + end + + test "multiple policies for same subject", %{enforcer_name: name} do + # Add multiple policies for dave + :ok = EnforcerServer.add_policy(name, {:p, ["dave", "data4", "read"]}) + :ok = EnforcerServer.add_policy(name, {:p, ["dave", "data4", "write"]}) + :ok = EnforcerServer.add_policy(name, {:p, ["dave", "data5", "read"]}) + + # Verify all permissions + assert EnforcerServer.allow?(name, ["dave", "data4", "read"]) + assert EnforcerServer.allow?(name, ["dave", "data4", "write"]) + assert EnforcerServer.allow?(name, ["dave", "data5", "read"]) + + # Dave shouldn't have delete permission + refute EnforcerServer.allow?(name, ["dave", "data4", "delete"]) + + # List all dave's policies + policies = EnforcerServer.list_policies(name, %{sub: "dave"}) + assert length(policies) == 3 + end + + test "policies are isolated between tests", %{enforcer_name: name} do + # This test should start with an empty policy set + # Previous tests' policies should not be visible here + policies = EnforcerServer.list_policies(name, %{}) + assert policies == [] + + # Add a policy specific to this test + :ok = EnforcerServer.add_policy(name, {:p, ["eve", "data6", "read"]}) + + # Only eve's policy should be present + all_policies = EnforcerServer.list_policies(name, %{}) + assert length(all_policies) == 1 + assert EnforcerServer.allow?(name, ["eve", "data6", "read"]) + + # Other users from other tests shouldn't exist + refute EnforcerServer.allow?(name, ["alice", "data1", "read"]) + refute EnforcerServer.allow?(name, ["bob", "data2", "read"]) + end + end + + describe "with initial policies loaded" do + setup %{enforcer_name: name} do + # Load some initial policies for all tests in this describe block + :ok = EnforcerServer.add_policy(name, {:p, ["admin", "data1", "read"]}) + :ok = EnforcerServer.add_policy(name, {:p, ["admin", "data1", "write"]}) + :ok = EnforcerServer.add_policy(name, {:p, ["admin", "data1", "delete"]}) + + :ok + end + + test "admin has full access", %{enforcer_name: name} do + assert EnforcerServer.allow?(name, ["admin", "data1", "read"]) + assert EnforcerServer.allow?(name, ["admin", "data1", "write"]) + assert EnforcerServer.allow?(name, ["admin", "data1", "delete"]) + end + + test "can add more policies on top of initial ones", %{enforcer_name: name} do + # Initial admin policies should be present + assert EnforcerServer.allow?(name, ["admin", "data1", "read"]) + + # Add a new user + :ok = EnforcerServer.add_policy(name, {:p, ["user", "data2", "read"]}) + assert EnforcerServer.allow?(name, ["user", "data2", "read"]) + + # Both should coexist + policies = EnforcerServer.list_policies(name, %{}) + assert length(policies) == 4 + end + end + + describe "error handling" do + test "adding duplicate policy returns error", %{enforcer_name: name} do + # Add a policy + :ok = EnforcerServer.add_policy(name, {:p, ["frank", "data7", "read"]}) + + # Try to add the same policy again + result = EnforcerServer.add_policy(name, {:p, ["frank", "data7", "read"]}) + assert {:error, :already_existed} = result + end + + test "removing non-existent policy returns error", %{enforcer_name: name} do + # Try to remove a policy that doesn't exist + result = EnforcerServer.remove_policy(name, {:p, ["ghost", "data99", "read"]}) + assert {:error, :not_found} = result + end + end + + describe "resetting enforcer state" do + test "can reset to clean state", %{enforcer_name: name} do + # Add some policies + :ok = EnforcerServer.add_policy(name, {:p, ["george", "data8", "read"]}) + :ok = EnforcerServer.add_policy(name, {:p, ["george", "data8", "write"]}) + + # Verify they exist + policies = EnforcerServer.list_policies(name, %{}) + assert length(policies) == 2 + + # Reset the enforcer + :ok = reset_test_enforcer(name, @cfile) + + # Policies should be gone + policies_after_reset = EnforcerServer.list_policies(name, %{}) + assert policies_after_reset == [] + + # Should be able to add new policies + :ok = EnforcerServer.add_policy(name, {:p, ["henry", "data9", "read"]}) + assert EnforcerServer.allow?(name, ["henry", "data9", "read"]) + end + end +end From 97fc4f03b0b0dc408d27509840089f0635a62701 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Jan 2026 13:37:46 +0000 Subject: [PATCH 3/5] Add inline documentation examples for async testing Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- lib/casbin.ex | 47 +++++++++++++++++++++++++++++++++++ lib/casbin/enforcer_server.ex | 30 ++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/lib/casbin.ex b/lib/casbin.ex index d030b84..e81ef9f 100644 --- a/lib/casbin.ex +++ b/lib/casbin.ex @@ -1,6 +1,53 @@ defmodule Casbin do @moduledoc """ Casbin is an Elixir implementation of the Casbin authorization library. + + Casbin provides authorization support based on various access control models + including ACL, RBAC, ABAC, and RESTful models. + + ## Usage + + There are two ways to use Casbin: + + ### 1. Using EnforcerServer (Recommended for Production) + + For production applications, use `Casbin.EnforcerServer` which manages + enforcer state in a supervised GenServer process: + + # Start an enforcer + Casbin.EnforcerSupervisor.start_enforcer("my_enforcer", "config.conf") + + # Add policies + Casbin.EnforcerServer.add_policy("my_enforcer", {:p, ["alice", "data", "read"]}) + + # Check permissions + Casbin.EnforcerServer.allow?("my_enforcer", ["alice", "data", "read"]) + + ### 2. Using Enforcer Struct Directly + + For simple use cases or testing, use the `Casbin.Enforcer` module directly: + + {:ok, enforcer} = Casbin.Enforcer.init("config.conf") + {:ok, enforcer} = Casbin.Enforcer.add_policy(enforcer, {:p, ["alice", "data", "read"]}) + Casbin.Enforcer.allow?(enforcer, ["alice", "data", "read"]) + + ## Testing + + For async testing with isolated enforcer instances, use `Casbin.TestHelper`: + + defmodule MyTest do + use ExUnit.Case, async: true + import Casbin.TestHelper + + setup do + name = unique_enforcer_name() + {:ok, _} = start_test_enforcer(name, "config.conf") + on_exit(fn -> cleanup_test_enforcer(name) end) + {:ok, enforcer_name: name} + end + end + + See the `Casbin.TestHelper` module and `guides/async_testing.md` for more details. """ use Application diff --git a/lib/casbin/enforcer_server.ex b/lib/casbin/enforcer_server.ex index 3a81602..9dd6966 100644 --- a/lib/casbin/enforcer_server.ex +++ b/lib/casbin/enforcer_server.ex @@ -1,6 +1,36 @@ defmodule Casbin.EnforcerServer do @moduledoc """ An enforcer process that holds an `Enforcer` struct as its state. + + The EnforcerServer manages enforcer instances in supervised GenServer processes, + allowing for concurrent policy management and authorization checks. + + ## Usage + + Start an enforcer using the supervisor: + + Casbin.EnforcerSupervisor.start_enforcer("my_enforcer", "path/to/config.conf") + + Then use the enforcer by name: + + Casbin.EnforcerServer.add_policy("my_enforcer", {:p, ["alice", "data", "read"]}) + Casbin.EnforcerServer.allow?("my_enforcer", ["alice", "data", "read"]) + + ## Important Notes for Testing + + When writing tests with `async: true`, each test should use a unique enforcer + name to avoid race conditions. Use `Casbin.TestHelper` for this: + + import Casbin.TestHelper + + setup do + name = unique_enforcer_name() + {:ok, _} = start_test_enforcer(name, "config.conf") + on_exit(fn -> cleanup_test_enforcer(name) end) + {:ok, enforcer_name: name} + end + + See `Casbin.TestHelper` and `guides/async_testing.md` for more information. """ use GenServer From 0fb7def2e62735b6f3c450d59884d03ffc05d408 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Jan 2026 13:39:35 +0000 Subject: [PATCH 4/5] Fix unique_enforcer_name to use safer ref generation Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- lib/casbin/test_helper.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/casbin/test_helper.ex b/lib/casbin/test_helper.ex index 8e1aab0..2239259 100644 --- a/lib/casbin/test_helper.ex +++ b/lib/casbin/test_helper.ex @@ -75,7 +75,8 @@ defmodule Casbin.TestHelper do """ @spec unique_enforcer_name(String.t()) :: String.t() def unique_enforcer_name(prefix \\ "") do - ref = :erlang.ref_to_list(make_ref()) |> to_string() |> String.replace(~r/[^0-9]/, "") + # Use inspect to get a string representation of the ref, then sanitize + ref = inspect(make_ref()) |> String.replace(~r/[^a-zA-Z0-9]/, "_") timestamp = System.system_time(:microsecond) random = :rand.uniform(999_999) From 7ec0268e634c97e78e24c4113b3fa1541b9a85ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Jan 2026 13:48:26 +0000 Subject: [PATCH 5/5] Remove markdown docs and test files, keep only core TestHelper logic Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- README.md | 25 --- guides/async_testing.md | 291 ---------------------------- lib/casbin.ex | 47 ----- lib/casbin/enforcer_server.ex | 30 --- lib/casbin/test_helper.ex | 108 +---------- test/async_testing_example_test.exs | 180 ----------------- 6 files changed, 3 insertions(+), 678 deletions(-) delete mode 100644 guides/async_testing.md delete mode 100644 test/async_testing_example_test.exs diff --git a/README.md b/README.md index da7e7f9..1d25ede 100644 --- a/README.md +++ b/README.md @@ -335,31 +335,6 @@ Casbin-Ex supports the following access control models: ## Testing -### Async Testing with Isolated Enforcers - -Casbin-Ex supports running tests with `async: true` by providing isolated enforcer instances per test. This prevents race conditions and enables faster parallel test execution. - -```elixir -defmodule MyApp.AclTest do - use ExUnit.Case, async: true - import Casbin.TestHelper - - setup do - enforcer_name = unique_enforcer_name() - {:ok, _} = start_test_enforcer(enforcer_name, "config.conf") - on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) - {:ok, enforcer_name: enforcer_name} - end - - test "my test", %{enforcer_name: name} do - EnforcerServer.add_policy(name, {:p, ["alice", "data", "read"]}) - assert EnforcerServer.allow?(name, ["alice", "data", "read"]) - end -end -``` - -See our [Async Testing Guide](guides/async_testing.md) for complete examples and patterns. - ### Using with Ecto.Adapters.SQL.Sandbox If you're using Casbin-Ex with Ecto and need to wrap operations in database transactions during testing, see our guide on [Testing with Ecto.Adapters.SQL.Sandbox and Transactions](guides/sandbox_testing.md). diff --git a/guides/async_testing.md b/guides/async_testing.md deleted file mode 100644 index ff4b6c5..0000000 --- a/guides/async_testing.md +++ /dev/null @@ -1,291 +0,0 @@ -# Async Testing with Casbin - -This guide explains how to write tests with `async: true` when using Casbin-Ex, enabling faster parallel test execution. - -## The Problem - -By default, if multiple tests share the same enforcer name, they will share the same global state in the ETS table. This causes race conditions when running tests with `async: true`: - -```elixir -defmodule MyApp.AclTest do - use ExUnit.Case, async: true # ❌ Tests will interfere with each other - - @enforcer_name "my_enforcer" # Shared global state! - - test "test 1" do - EnforcerServer.add_policy(@enforcer_name, {:p, ["alice", "data", "read"]}) - # Another test's cleanup might delete this policy before we check it! - end - - test "test 2" do - EnforcerServer.add_policy(@enforcer_name, {:p, ["bob", "data", "write"]}) - # Policies from test 1 might still be present! - end -end -``` - -**Symptoms:** -- `list_policies()` returns `[]` even after adding policies -- `add_policy` returns `{:error, :already_existed}` but policies aren't visible -- Tests pass individually but fail when run together -- Intermittent test failures - -## Solution 1: Unique Enforcer Names Per Test - -The recommended solution is to use `Casbin.TestHelper` to create isolated enforcer instances for each test: - -```elixir -defmodule MyApp.AclTest do - use ExUnit.Case, async: true # ✅ Safe with isolated enforcers - import Casbin.TestHelper - - setup do - # Create a unique enforcer for this test - enforcer_name = unique_enforcer_name() - cfile = Path.expand("../data/model.conf", __DIR__) - - {:ok, _pid} = start_test_enforcer(enforcer_name, cfile) - - # Clean up after the test - on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) - - {:ok, enforcer_name: enforcer_name} - end - - test "alice can read data", %{enforcer_name: name} do - :ok = EnforcerServer.add_policy(name, {:p, ["alice", "data", "read"]}) - assert EnforcerServer.allow?(name, ["alice", "data", "read"]) - end - - test "bob can write data", %{enforcer_name: name} do - :ok = EnforcerServer.add_policy(name, {:p, ["bob", "data", "write"]}) - assert EnforcerServer.allow?(name, ["bob", "data", "write"]) - end -end -``` - -### With Custom Prefix - -You can add a prefix to make enforcer names more readable in logs: - -```elixir -setup do - enforcer_name = unique_enforcer_name("acl_test") - # Generates: "test_enforcer_acl_test_12345_67890_123456" - - cfile = Path.expand("../data/model.conf", __DIR__) - {:ok, _pid} = start_test_enforcer(enforcer_name, cfile) - - on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) - - {:ok, enforcer_name: enforcer_name} -end -``` - -## Solution 2: Using the Enforcer Struct Directly - -For tests that don't need the `EnforcerServer` process, use the `Enforcer` struct directly: - -```elixir -defmodule MyApp.EnforcerTest do - use ExUnit.Case, async: true - alias Casbin.Enforcer - - setup do - cfile = Path.expand("../data/model.conf", __DIR__) - {:ok, e} = Enforcer.init(cfile) - {:ok, enforcer: e} - end - - test "alice permissions", %{enforcer: e} do - {:ok, e} = Enforcer.add_policy(e, {:p, ["alice", "data", "read"]}) - assert Enforcer.allow?(e, ["alice", "data", "read"]) - end - - test "bob permissions", %{enforcer: e} do - {:ok, e} = Enforcer.add_policy(e, {:p, ["bob", "data", "write"]}) - assert Enforcer.allow?(e, ["bob", "data", "write"]) - end -end -``` - -This approach: -- ✅ No shared state - each test gets its own enforcer struct -- ✅ Fully isolated - perfect for `async: true` -- ✅ No cleanup needed -- ❌ Can't use `EnforcerServer` API (if you need that, use Solution 1) - -## Solution 3: Disable Async (Not Recommended) - -As a last resort, you can disable async testing: - -```elixir -defmodule MyApp.AclTest do - use ExUnit.Case # async: false is the default - - @enforcer_name "my_enforcer" - - # Tests run serially, no race conditions -end -``` - -**Drawbacks:** -- ❌ Slower test suite -- ❌ Doesn't scale well -- ❌ Only use if you absolutely must share an enforcer between tests - -## Using with Ecto.Adapters.SQL.Sandbox - -When using Casbin with an `EctoAdapter`, you need special handling for the sandbox. See the [sandbox_testing.md](sandbox_testing.md) guide for details. - -Quick example: - -```elixir -defmodule MyApp.AclWithDbTest do - use MyApp.DataCase, async: false # Note: async: false with Ecto transactions - import Casbin.TestHelper - - alias MyApp.Repo - - setup do - # Sandbox setup - :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) - Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()}) - - # Enforcer setup - enforcer_name = unique_enforcer_name() - cfile = Path.expand("../data/model.conf", __DIR__) - {:ok, pid} = start_test_enforcer(enforcer_name, cfile) - - # Allow enforcer to access DB - Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid) - - # Set Ecto adapter - adapter = Casbin.Persist.EctoAdapter.new(Repo) - :ok = EnforcerServer.set_persist_adapter(enforcer_name, adapter) - - on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) - - {:ok, enforcer_name: enforcer_name} - end - - test "persist policies to database", %{enforcer_name: name} do - :ok = EnforcerServer.add_policy(name, {:p, ["alice", "data", "read"]}) - :ok = EnforcerServer.save_policies(name) - - # Verify in database... - end -end -``` - -## Helper Functions Reference - -### `unique_enforcer_name(prefix \\ "")` - -Generates a unique name for an enforcer instance. - -**Parameters:** -- `prefix` (optional) - A string prefix for the name - -**Returns:** A unique string like `"test_enforcer_12345_67890_123456"` - -### `start_test_enforcer(enforcer_name, config_file)` - -Starts an enforcer process under the test supervisor. - -**Parameters:** -- `enforcer_name` - Unique name (from `unique_enforcer_name/0`) -- `config_file` - Path to Casbin model config file - -**Returns:** `{:ok, pid}` or `{:error, reason}` - -### `cleanup_test_enforcer(enforcer_name)` - -Stops the enforcer process and removes it from the ETS table. - -**Parameters:** -- `enforcer_name` - Name of the enforcer to clean up - -**Returns:** `:ok` - -**Usage:** Always call in `on_exit/1`: -```elixir -on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) -``` - -### `reset_test_enforcer(enforcer_name, config_file)` - -Resets an enforcer to its initial state without stopping it. - -**Parameters:** -- `enforcer_name` - Name of the enforcer -- `config_file` - Config file to reload - -**Returns:** `:ok` or `{:error, reason}` - -**Usage:** Useful for clearing policies between test cases: -```elixir -describe "with clean state" do - setup %{enforcer_name: name, cfile: cfile} do - reset_test_enforcer(name, cfile) - :ok - end - - test "test 1", %{enforcer_name: name} do - # Clean state guaranteed - end -end -``` - -## Best Practices - -1. **Always use unique names** - Use `unique_enforcer_name()` to avoid conflicts -2. **Always cleanup** - Use `on_exit/1` to ensure cleanup even if tests fail -3. **Prefer Enforcer struct** - If you don't need `EnforcerServer`, use `Enforcer` directly -4. **One enforcer per test** - Don't try to share enforcers between tests -5. **Use prefixes** - Add readable prefixes to enforcer names for easier debugging - -## Migration Guide - -### Before (async: false) - -```elixir -defmodule MyApp.AclTest do - use ExUnit.Case # async: false - - @enforcer_name "my_enforcer" - - test "alice test" do - EnforcerServer.add_policy(@enforcer_name, {:p, ["alice", "data", "read"]}) - assert EnforcerServer.allow?(@enforcer_name, ["alice", "data", "read"]) - end -end -``` - -### After (async: true) - -```elixir -defmodule MyApp.AclTest do - use ExUnit.Case, async: true - import Casbin.TestHelper - - setup do - name = unique_enforcer_name() - cfile = "path/to/config.conf" - {:ok, _} = start_test_enforcer(name, cfile) - on_exit(fn -> cleanup_test_enforcer(name) end) - {:ok, enforcer_name: name} - end - - test "alice test", %{enforcer_name: name} do - EnforcerServer.add_policy(name, {:p, ["alice", "data", "read"]}) - assert EnforcerServer.allow?(name, ["alice", "data", "read"]) - end -end -``` - -## See Also - -- [Sandbox Testing Guide](sandbox_testing.md) - Using Casbin with Ecto.Adapters.SQL.Sandbox -- [ExUnit Documentation](https://hexdocs.pm/ex_unit/ExUnit.html) - ExUnit testing framework -- [Casbin Model Configuration](https://casbin.org/docs/syntax-for-models) - Writing model files diff --git a/lib/casbin.ex b/lib/casbin.ex index e81ef9f..d030b84 100644 --- a/lib/casbin.ex +++ b/lib/casbin.ex @@ -1,53 +1,6 @@ defmodule Casbin do @moduledoc """ Casbin is an Elixir implementation of the Casbin authorization library. - - Casbin provides authorization support based on various access control models - including ACL, RBAC, ABAC, and RESTful models. - - ## Usage - - There are two ways to use Casbin: - - ### 1. Using EnforcerServer (Recommended for Production) - - For production applications, use `Casbin.EnforcerServer` which manages - enforcer state in a supervised GenServer process: - - # Start an enforcer - Casbin.EnforcerSupervisor.start_enforcer("my_enforcer", "config.conf") - - # Add policies - Casbin.EnforcerServer.add_policy("my_enforcer", {:p, ["alice", "data", "read"]}) - - # Check permissions - Casbin.EnforcerServer.allow?("my_enforcer", ["alice", "data", "read"]) - - ### 2. Using Enforcer Struct Directly - - For simple use cases or testing, use the `Casbin.Enforcer` module directly: - - {:ok, enforcer} = Casbin.Enforcer.init("config.conf") - {:ok, enforcer} = Casbin.Enforcer.add_policy(enforcer, {:p, ["alice", "data", "read"]}) - Casbin.Enforcer.allow?(enforcer, ["alice", "data", "read"]) - - ## Testing - - For async testing with isolated enforcer instances, use `Casbin.TestHelper`: - - defmodule MyTest do - use ExUnit.Case, async: true - import Casbin.TestHelper - - setup do - name = unique_enforcer_name() - {:ok, _} = start_test_enforcer(name, "config.conf") - on_exit(fn -> cleanup_test_enforcer(name) end) - {:ok, enforcer_name: name} - end - end - - See the `Casbin.TestHelper` module and `guides/async_testing.md` for more details. """ use Application diff --git a/lib/casbin/enforcer_server.ex b/lib/casbin/enforcer_server.ex index 9dd6966..3a81602 100644 --- a/lib/casbin/enforcer_server.ex +++ b/lib/casbin/enforcer_server.ex @@ -1,36 +1,6 @@ defmodule Casbin.EnforcerServer do @moduledoc """ An enforcer process that holds an `Enforcer` struct as its state. - - The EnforcerServer manages enforcer instances in supervised GenServer processes, - allowing for concurrent policy management and authorization checks. - - ## Usage - - Start an enforcer using the supervisor: - - Casbin.EnforcerSupervisor.start_enforcer("my_enforcer", "path/to/config.conf") - - Then use the enforcer by name: - - Casbin.EnforcerServer.add_policy("my_enforcer", {:p, ["alice", "data", "read"]}) - Casbin.EnforcerServer.allow?("my_enforcer", ["alice", "data", "read"]) - - ## Important Notes for Testing - - When writing tests with `async: true`, each test should use a unique enforcer - name to avoid race conditions. Use `Casbin.TestHelper` for this: - - import Casbin.TestHelper - - setup do - name = unique_enforcer_name() - {:ok, _} = start_test_enforcer(name, "config.conf") - on_exit(fn -> cleanup_test_enforcer(name) end) - {:ok, enforcer_name: name} - end - - See `Casbin.TestHelper` and `guides/async_testing.md` for more information. """ use GenServer diff --git a/lib/casbin/test_helper.ex b/lib/casbin/test_helper.ex index 2239259..d7576b6 100644 --- a/lib/casbin/test_helper.ex +++ b/lib/casbin/test_helper.ex @@ -1,57 +1,8 @@ defmodule Casbin.TestHelper do @moduledoc """ - Helper functions for testing with Casbin enforcers in async test environments. + Helper functions for testing with isolated enforcer instances in async tests. - This module provides utilities to create isolated enforcer instances per test, - enabling `async: true` tests that don't share global state. - - ## Example - - defmodule MyApp.AclTest do - use ExUnit.Case, async: true - import Casbin.TestHelper - - setup do - # Create a unique enforcer for this test - enforcer_name = unique_enforcer_name() - cfile = "path/to/config.conf" - - {:ok, _pid} = start_test_enforcer(enforcer_name, cfile) - - # Automatically clean up after the test - on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) - - {:ok, enforcer_name: enforcer_name} - end - - test "my test", %{enforcer_name: name} do - Casbin.EnforcerServer.add_policy(name, {:p, ["alice", "data", "read"]}) - assert Casbin.EnforcerServer.allow?(name, ["alice", "data", "read"]) - end - end - - ## Using with Ecto Adapters - - When using with `Ecto.Adapters.SQL.Sandbox`, you may need to use shared mode: - - setup do - enforcer_name = unique_enforcer_name() - cfile = "path/to/config.conf" - - # Set up sandbox - :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo) - Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()}) - - # Start enforcer - {:ok, pid} = start_test_enforcer(enforcer_name, cfile) - - # Allow enforcer to access the database connection - Ecto.Adapters.SQL.Sandbox.allow(MyApp.Repo, self(), pid) - - on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) - - {:ok, enforcer_name: enforcer_name} - end + Provides utilities to create unique enforcer names and manage test enforcer lifecycle. """ alias Casbin.EnforcerSupervisor @@ -59,23 +10,9 @@ defmodule Casbin.TestHelper do @doc """ Generates a unique enforcer name for isolated testing. - - Returns a string in the format "test_enforcer___" - where ref is the test process reference. - - ## Examples - - iex> name1 = Casbin.TestHelper.unique_enforcer_name() - iex> name2 = Casbin.TestHelper.unique_enforcer_name() - iex> name1 != name2 - true - - iex> Casbin.TestHelper.unique_enforcer_name("my_test") - "test_enforcer_my_test_" <> _ """ @spec unique_enforcer_name(String.t()) :: String.t() def unique_enforcer_name(prefix \\ "") do - # Use inspect to get a string representation of the ref, then sanitize ref = inspect(make_ref()) |> String.replace(~r/[^a-zA-Z0-9]/, "_") timestamp = System.system_time(:microsecond) random = :rand.uniform(999_999) @@ -85,24 +22,7 @@ defmodule Casbin.TestHelper do end @doc """ - Starts an enforcer process for testing with the given name and configuration file. - - This is a wrapper around `Casbin.EnforcerSupervisor.start_enforcer/2` that - returns the PID for convenience in test setup. - - ## Parameters - - * `enforcer_name` - Unique name for the enforcer (use `unique_enforcer_name/0`) - * `config_file` - Path to the Casbin model configuration file - - ## Returns - - * `{:ok, pid}` - The PID of the started enforcer server - * `{:error, reason}` - If the enforcer could not be started - - ## Examples - - {:ok, pid} = start_test_enforcer("my_test_enforcer", "test/data/model.conf") + Starts an enforcer process for testing. """ @spec start_test_enforcer(String.t(), String.t()) :: {:ok, pid()} | {:error, term()} @@ -112,16 +32,6 @@ defmodule Casbin.TestHelper do @doc """ Cleans up a test enforcer by stopping its process and removing it from the ETS table. - - This should be called in an `on_exit/1` callback to ensure proper cleanup. - - ## Parameters - - * `enforcer_name` - The name of the enforcer to clean up - - ## Examples - - on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) """ @spec cleanup_test_enforcer(String.t()) :: :ok def cleanup_test_enforcer(enforcer_name) do @@ -144,18 +54,6 @@ defmodule Casbin.TestHelper do @doc """ Resets an enforcer to its initial state by reloading the configuration. - - Useful when you need to clear all policies between test cases without - creating a new enforcer instance. - - ## Parameters - - * `enforcer_name` - The name of the enforcer to reset - * `config_file` - Path to the configuration file to reload - - ## Examples - - reset_test_enforcer(enforcer_name, "test/data/model.conf") """ @spec reset_test_enforcer(String.t(), String.t()) :: :ok | {:error, term()} def reset_test_enforcer(enforcer_name, config_file) do diff --git a/test/async_testing_example_test.exs b/test/async_testing_example_test.exs deleted file mode 100644 index 7468e11..0000000 --- a/test/async_testing_example_test.exs +++ /dev/null @@ -1,180 +0,0 @@ -defmodule Casbin.AsyncTestingExampleTest do - @moduledoc """ - This test module demonstrates how to write async tests with Casbin - using the TestHelper module for enforcer isolation. - - Each test gets its own unique enforcer instance, allowing tests to run - in parallel without race conditions. - """ - use ExUnit.Case, async: true - - import Casbin.TestHelper - - alias Casbin.EnforcerServer - - @cfile "../data/acl.conf" |> Path.expand(__DIR__) - - setup do - # Create a unique enforcer for this test - enforcer_name = unique_enforcer_name("async_test") - - {:ok, _pid} = start_test_enforcer(enforcer_name, @cfile) - - # Clean up after the test - on_exit(fn -> cleanup_test_enforcer(enforcer_name) end) - - {:ok, enforcer_name: enforcer_name} - end - - describe "isolated enforcers in async tests" do - test "alice can read and write", %{enforcer_name: name} do - # Add policies for alice - :ok = EnforcerServer.add_policy(name, {:p, ["alice", "data1", "read"]}) - :ok = EnforcerServer.add_policy(name, {:p, ["alice", "data1", "write"]}) - - # Verify permissions - assert EnforcerServer.allow?(name, ["alice", "data1", "read"]) - assert EnforcerServer.allow?(name, ["alice", "data1", "write"]) - refute EnforcerServer.allow?(name, ["alice", "data1", "delete"]) - - # List alice's policies - policies = EnforcerServer.list_policies(name, %{sub: "alice"}) - assert length(policies) == 2 - end - - test "bob has limited access", %{enforcer_name: name} do - # Add policy for bob - :ok = EnforcerServer.add_policy(name, {:p, ["bob", "data2", "read"]}) - - # Verify permissions - assert EnforcerServer.allow?(name, ["bob", "data2", "read"]) - refute EnforcerServer.allow?(name, ["bob", "data2", "write"]) - refute EnforcerServer.allow?(name, ["bob", "data2", "delete"]) - - # Bob shouldn't have access to data1 - refute EnforcerServer.allow?(name, ["bob", "data1", "read"]) - end - - test "can remove policies", %{enforcer_name: name} do - # Add a policy - :ok = EnforcerServer.add_policy(name, {:p, ["charlie", "data3", "read"]}) - assert EnforcerServer.allow?(name, ["charlie", "data3", "read"]) - - # Remove the policy - :ok = EnforcerServer.remove_policy(name, {:p, ["charlie", "data3", "read"]}) - refute EnforcerServer.allow?(name, ["charlie", "data3", "read"]) - - # Verify it's gone - policies = EnforcerServer.list_policies(name, %{sub: "charlie"}) - assert policies == [] - end - - test "multiple policies for same subject", %{enforcer_name: name} do - # Add multiple policies for dave - :ok = EnforcerServer.add_policy(name, {:p, ["dave", "data4", "read"]}) - :ok = EnforcerServer.add_policy(name, {:p, ["dave", "data4", "write"]}) - :ok = EnforcerServer.add_policy(name, {:p, ["dave", "data5", "read"]}) - - # Verify all permissions - assert EnforcerServer.allow?(name, ["dave", "data4", "read"]) - assert EnforcerServer.allow?(name, ["dave", "data4", "write"]) - assert EnforcerServer.allow?(name, ["dave", "data5", "read"]) - - # Dave shouldn't have delete permission - refute EnforcerServer.allow?(name, ["dave", "data4", "delete"]) - - # List all dave's policies - policies = EnforcerServer.list_policies(name, %{sub: "dave"}) - assert length(policies) == 3 - end - - test "policies are isolated between tests", %{enforcer_name: name} do - # This test should start with an empty policy set - # Previous tests' policies should not be visible here - policies = EnforcerServer.list_policies(name, %{}) - assert policies == [] - - # Add a policy specific to this test - :ok = EnforcerServer.add_policy(name, {:p, ["eve", "data6", "read"]}) - - # Only eve's policy should be present - all_policies = EnforcerServer.list_policies(name, %{}) - assert length(all_policies) == 1 - assert EnforcerServer.allow?(name, ["eve", "data6", "read"]) - - # Other users from other tests shouldn't exist - refute EnforcerServer.allow?(name, ["alice", "data1", "read"]) - refute EnforcerServer.allow?(name, ["bob", "data2", "read"]) - end - end - - describe "with initial policies loaded" do - setup %{enforcer_name: name} do - # Load some initial policies for all tests in this describe block - :ok = EnforcerServer.add_policy(name, {:p, ["admin", "data1", "read"]}) - :ok = EnforcerServer.add_policy(name, {:p, ["admin", "data1", "write"]}) - :ok = EnforcerServer.add_policy(name, {:p, ["admin", "data1", "delete"]}) - - :ok - end - - test "admin has full access", %{enforcer_name: name} do - assert EnforcerServer.allow?(name, ["admin", "data1", "read"]) - assert EnforcerServer.allow?(name, ["admin", "data1", "write"]) - assert EnforcerServer.allow?(name, ["admin", "data1", "delete"]) - end - - test "can add more policies on top of initial ones", %{enforcer_name: name} do - # Initial admin policies should be present - assert EnforcerServer.allow?(name, ["admin", "data1", "read"]) - - # Add a new user - :ok = EnforcerServer.add_policy(name, {:p, ["user", "data2", "read"]}) - assert EnforcerServer.allow?(name, ["user", "data2", "read"]) - - # Both should coexist - policies = EnforcerServer.list_policies(name, %{}) - assert length(policies) == 4 - end - end - - describe "error handling" do - test "adding duplicate policy returns error", %{enforcer_name: name} do - # Add a policy - :ok = EnforcerServer.add_policy(name, {:p, ["frank", "data7", "read"]}) - - # Try to add the same policy again - result = EnforcerServer.add_policy(name, {:p, ["frank", "data7", "read"]}) - assert {:error, :already_existed} = result - end - - test "removing non-existent policy returns error", %{enforcer_name: name} do - # Try to remove a policy that doesn't exist - result = EnforcerServer.remove_policy(name, {:p, ["ghost", "data99", "read"]}) - assert {:error, :not_found} = result - end - end - - describe "resetting enforcer state" do - test "can reset to clean state", %{enforcer_name: name} do - # Add some policies - :ok = EnforcerServer.add_policy(name, {:p, ["george", "data8", "read"]}) - :ok = EnforcerServer.add_policy(name, {:p, ["george", "data8", "write"]}) - - # Verify they exist - policies = EnforcerServer.list_policies(name, %{}) - assert length(policies) == 2 - - # Reset the enforcer - :ok = reset_test_enforcer(name, @cfile) - - # Policies should be gone - policies_after_reset = EnforcerServer.list_policies(name, %{}) - assert policies_after_reset == [] - - # Should be able to add new policies - :ok = EnforcerServer.add_policy(name, {:p, ["henry", "data9", "read"]}) - assert EnforcerServer.allow?(name, ["henry", "data9", "read"]) - end - end -end