towerops/test/towerops/api_tokens_test.exs
Graham McIntire 83d47d1502
perf(tests): remove/reduce Process.sleep calls for faster tests
- Remove all 25ms and 50ms sleeps from agent_channel_test
- Add poll_until helper for async DB operations (early exit on success)
- Reduce timeout test sleeps from 100ms to 60ms
- Reduce polling delays from 10ms to 5ms in multiple tests
- Remove unnecessary timestamp and background task sleeps
- Reduce circuit breaker recovery sleep from 150ms to 110ms

Performance: 30.3s → 28.3s (6.6% faster)
Total improvement from baseline: 52s → 28.3s (45.6% faster)
2026-03-06 07:57:30 -06:00

641 lines
19 KiB
Elixir

defmodule Towerops.ApiTokensTest do
use Towerops.DataCase
use ExUnitProperties
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.ApiTokens
alias Towerops.ApiTokens.ApiToken
describe "create_api_token/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
test "creates an API token with valid attributes", %{
user: user,
organization: organization
} do
attrs = %{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
}
assert {:ok, {api_token, raw_token}} = ApiTokens.create_api_token(attrs)
assert api_token.name == "Test Token"
assert api_token.organization_id == organization.id
assert api_token.user_id == user.id
assert is_binary(api_token.token_hash)
assert String.starts_with?(raw_token, "towerops_")
assert is_nil(api_token.last_used_at)
assert is_nil(api_token.expires_at)
end
test "creates an API token without user_id", %{organization: organization} do
attrs = %{
organization_id: organization.id,
name: "Test Token"
}
assert {:ok, {api_token, raw_token}} = ApiTokens.create_api_token(attrs)
assert api_token.name == "Test Token"
assert api_token.organization_id == organization.id
assert is_nil(api_token.user_id)
assert is_binary(api_token.token_hash)
assert String.starts_with?(raw_token, "towerops_")
end
test "creates an API token with expiration", %{
user: user,
organization: organization
} do
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
attrs = %{
organization_id: organization.id,
user_id: user.id,
name: "Expiring Token",
expires_at: expires_at
}
assert {:ok, {api_token, _raw_token}} = ApiTokens.create_api_token(attrs)
assert api_token.expires_at == DateTime.truncate(expires_at, :second)
end
test "returns error with invalid attributes" do
assert {:error, %Ecto.Changeset{}} = ApiTokens.create_api_token(%{})
end
test "returns error with blank name", %{organization: organization} do
attrs = %{
organization_id: organization.id,
name: ""
}
assert {:error, changeset} = ApiTokens.create_api_token(attrs)
assert "can't be blank" in errors_on(changeset).name
end
test "returns error without organization_id" do
attrs = %{
name: "Test Token"
}
assert {:error, changeset} = ApiTokens.create_api_token(attrs)
assert "can't be blank" in errors_on(changeset).organization_id
end
test "generates unique tokens", %{user: user, organization: organization} do
attrs = %{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
}
{:ok, {_token1, raw_token1}} = ApiTokens.create_api_token(attrs)
{:ok, {_token2, raw_token2}} = ApiTokens.create_api_token(attrs)
assert raw_token1 != raw_token2
end
end
describe "list_organization_api_tokens/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
test "returns all tokens for an organization", %{
user: user,
organization: organization
} do
{:ok, {token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
{:ok, {token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 2"
})
tokens = ApiTokens.list_organization_api_tokens(organization.id)
assert length(tokens) == 2
assert Enum.any?(tokens, fn t -> t.id == token1.id end)
assert Enum.any?(tokens, fn t -> t.id == token2.id end)
end
test "returns tokens ordered by creation date", %{
user: user,
organization: organization
} do
{:ok, {token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
{:ok, {token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 2"
})
tokens = ApiTokens.list_organization_api_tokens(organization.id)
assert length(tokens) == 2
# Verify tokens are ordered by inserted_at (descending)
[first, second] = tokens
assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq]
# Verify both tokens are present
token_ids = Enum.map(tokens, & &1.id)
assert token1.id in token_ids
assert token2.id in token_ids
end
test "returns empty list for organization with no tokens", %{organization: organization} do
assert [] = ApiTokens.list_organization_api_tokens(organization.id)
end
test "does not return tokens from other organizations", %{user: user, organization: org1} do
org2 = organization_fixture(user.id)
{:ok, {_token1, _}} =
ApiTokens.create_api_token(%{
organization_id: org1.id,
user_id: user.id,
name: "Org 1 Token"
})
{:ok, {_token2, _}} =
ApiTokens.create_api_token(%{
organization_id: org2.id,
user_id: user.id,
name: "Org 2 Token"
})
tokens = ApiTokens.list_organization_api_tokens(org1.id)
assert length(tokens) == 1
assert hd(tokens).name == "Org 1 Token"
end
end
describe "list_user_api_tokens/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
test "returns all tokens created by a user", %{user: user, organization: organization} do
{:ok, {token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
{:ok, {token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 2"
})
tokens = ApiTokens.list_user_api_tokens(user.id)
assert length(tokens) == 2
assert Enum.any?(tokens, fn t -> t.id == token1.id end)
assert Enum.any?(tokens, fn t -> t.id == token2.id end)
end
test "preloads organization", %{user: user, organization: organization} do
{:ok, {_token, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
tokens = ApiTokens.list_user_api_tokens(user.id)
assert [token] = tokens
assert %Ecto.Association.NotLoaded{} != token.organization
assert token.organization.id == organization.id
end
test "returns tokens ordered by creation date", %{
user: user,
organization: organization
} do
{:ok, {token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
{:ok, {token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 2"
})
tokens = ApiTokens.list_user_api_tokens(user.id)
assert length(tokens) == 2
# Verify tokens are ordered by inserted_at (descending)
[first, second] = tokens
assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq]
# Verify both tokens are present
token_ids = Enum.map(tokens, & &1.id)
assert token1.id in token_ids
assert token2.id in token_ids
end
test "returns empty list for user with no tokens", %{user: user} do
assert [] = ApiTokens.list_user_api_tokens(user.id)
end
test "does not return tokens created by other users", %{organization: organization} do
user1 = user_fixture()
user2 = user_fixture()
{:ok, {_token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user1.id,
name: "User 1 Token"
})
{:ok, {_token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user2.id,
name: "User 2 Token"
})
tokens = ApiTokens.list_user_api_tokens(user1.id)
assert length(tokens) == 1
assert hd(tokens).name == "User 1 Token"
end
end
describe "get_api_token!/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {token, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
%{token: token}
end
test "returns the token with the given id", %{token: token} do
fetched_token = ApiTokens.get_api_token!(token.id)
assert fetched_token.id == token.id
assert fetched_token.name == token.name
end
test "raises if the token does not exist" do
assert_raise Ecto.NoResultsError, fn ->
ApiTokens.get_api_token!(Ecto.UUID.generate())
end
end
end
describe "verify_token/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
%{token: token, raw_token: raw_token, organization: organization, user: user}
end
test "returns organization_id and user for valid token", %{
raw_token: raw_token,
organization: organization,
user: user
} do
assert {:ok, org_id, returned_user} = ApiTokens.verify_token(raw_token)
assert org_id == organization.id
assert returned_user.id == user.id
end
test "updates last_used_at timestamp", %{token: token, raw_token: raw_token} do
assert is_nil(token.last_used_at)
{:ok, _org_id, _user} = ApiTokens.verify_token(raw_token)
# Give the async Task time to complete
Process.sleep(5)
updated_token = ApiTokens.get_api_token!(token.id)
assert updated_token.last_used_at
assert DateTime.diff(updated_token.last_used_at, DateTime.utc_now(), :second) <= 1
end
test "returns error for invalid token" do
assert {:error, :invalid_token} = ApiTokens.verify_token("invalid_token")
end
test "returns error for expired token", %{
user: user,
organization: organization
} do
expires_at = DateTime.add(DateTime.utc_now(), -1, :day)
{:ok, {_token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Expired Token",
expires_at: expires_at
})
assert {:error, :invalid_token} = ApiTokens.verify_token(raw_token)
end
test "works for token without expiration", %{
raw_token: raw_token,
organization: organization
} do
assert {:ok, org_id, _user} = ApiTokens.verify_token(raw_token)
assert org_id == organization.id
end
test "works for token without user_id", %{organization: organization} do
{:ok, {_token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
name: "No User Token"
})
assert {:ok, org_id, nil} = ApiTokens.verify_token(raw_token)
assert org_id == organization.id
end
end
describe "delete_api_token/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {token, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
%{token: token}
end
test "deletes the token", %{token: token} do
assert {:ok, %ApiToken{}} = ApiTokens.delete_api_token(token)
assert_raise Ecto.NoResultsError, fn ->
ApiTokens.get_api_token!(token.id)
end
end
end
describe "update_api_token/2" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {token, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
%{token: token}
end
test "updates the token name", %{token: token} do
assert {:ok, updated_token} = ApiTokens.update_api_token(token, %{name: "New Name"})
assert updated_token.name == "New Name"
end
test "updates the expiration", %{token: token} do
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
assert {:ok, updated_token} =
ApiTokens.update_api_token(token, %{expires_at: expires_at})
assert updated_token.expires_at == DateTime.truncate(expires_at, :second)
end
test "returns error with invalid name", %{token: token} do
assert {:error, changeset} = ApiTokens.update_api_token(token, %{name: ""})
assert "can't be blank" in errors_on(changeset).name
end
test "returns error with too long name", %{token: token} do
long_name = String.duplicate("a", 256)
assert {:error, changeset} = ApiTokens.update_api_token(token, %{name: long_name})
assert "should be at most 255 character(s)" in errors_on(changeset).name
end
end
describe "property-based tests" do
property "generated tokens always start with towerops_" do
check all(_ <- constant(nil), max_runs: 50) do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {_token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
assert String.starts_with?(raw_token, "towerops_")
# Token should be sufficiently long (prefix + base64 encoded 32 bytes)
assert String.length(raw_token) > 40
end
end
property "tokens with valid names can always be created" do
check all(name <- string(:alphanumeric, min_length: 1, max_length: 255), max_runs: 10) do
user = user_fixture()
organization = organization_fixture(user.id)
result =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: name
})
case result do
{:ok, {token, raw_token}} ->
assert token.name == name
assert String.starts_with?(raw_token, "towerops_")
{:error, _changeset} ->
flunk("Valid name '#{name}' was rejected")
end
end
end
property "verify_token roundtrip always works for non-expired tokens" do
check all(_ <- constant(nil), max_runs: 20) do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {_token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
# Verify the token we just created
assert {:ok, org_id, returned_user} = ApiTokens.verify_token(raw_token)
assert org_id == organization.id
assert returned_user.id == user.id
end
end
property "expired tokens always return error" do
# Create fixtures once outside the property check
user = user_fixture()
organization = organization_fixture(user.id)
check all(days_ago <- integer(1..365), max_runs: 20) do
expires_at = DateTime.add(DateTime.utc_now(), -days_ago, :day)
{:ok, {_token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Expired Token",
expires_at: expires_at
})
assert {:error, :invalid_token} = ApiTokens.verify_token(raw_token)
end
end
property "list operations return consistent counts" do
check all(token_count <- integer(0..5), max_runs: 10) do
user = user_fixture()
organization = organization_fixture(user.id)
# Create specified number of tokens
if token_count > 0 do
for i <- 1..token_count do
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token #{i}"
})
end
end
org_tokens = ApiTokens.list_organization_api_tokens(organization.id)
user_tokens = ApiTokens.list_user_api_tokens(user.id)
assert length(org_tokens) == token_count
assert length(user_tokens) == token_count
assert length(org_tokens) == length(user_tokens)
end
end
property "deleting tokens reduces count by one" do
check all(_ <- constant(nil), max_runs: 20) do
user = user_fixture()
organization = organization_fixture(user.id)
# Create two tokens
{:ok, {token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
{:ok, {_token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 2"
})
initial_count = length(ApiTokens.list_organization_api_tokens(organization.id))
assert initial_count == 2
# Delete one token
{:ok, _} = ApiTokens.delete_api_token(token1)
final_count = length(ApiTokens.list_organization_api_tokens(organization.id))
assert final_count == initial_count - 1
end
end
property "updating token name preserves token hash" do
check all(
original_name <- string(:alphanumeric, min_length: 1, max_length: 50),
new_name <- string(:alphanumeric, min_length: 1, max_length: 50),
max_runs: 10
) do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: original_name
})
original_hash = token.token_hash
# Update the name
{:ok, updated_token} = ApiTokens.update_api_token(token, %{name: new_name})
# Token hash should remain the same (name changes don't affect token)
assert updated_token.token_hash == original_hash
assert updated_token.name == new_name
# Original raw token should still work
assert {:ok, org_id, _user} = ApiTokens.verify_token(raw_token)
assert org_id == organization.id
end
end
end
end