- 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)
641 lines
19 KiB
Elixir
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
|