Compare commits

...

13 Commits

Author SHA1 Message Date
CI
52d90361e9 chore: release version v1.90.12 2025-12-19 09:38:36 +00:00
Dmitry Popov
1c902d3319 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-19 10:38:02 +01:00
Dmitry Popov
8f671a359b fix(core): fixed permissions update after character corp updates 2025-12-19 10:37:59 +01:00
CI
840c416684 chore: [skip ci] 2025-12-18 21:47:59 +00:00
CI
56e29ad30a chore: release version v1.90.11 2025-12-18 21:47:59 +00:00
Dmitry Popov
cd8f8b5801 chore: added promo codes support for map subs 2025-12-18 22:19:50 +01:00
Dmitry Popov
70e013fa3d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-18 22:19:38 +01:00
Dmitry Popov
d6bfaf8008 chore: added promo codes support for map subs 2025-12-18 22:19:26 +01:00
CI
95944199a0 chore: [skip ci] 2025-12-18 18:05:48 +00:00
CI
3bd5db8cf3 chore: release version v1.90.10 2025-12-18 18:05:48 +00:00
Dmitry Popov
a245330ada Merge branch 'advent-challenge' 2025-12-18 19:05:10 +01:00
CI
e5afa1d5bc chore: [skip ci] 2025-12-15 11:46:40 +00:00
CI
1473fe8646 chore: release version v1.90.9 2025-12-15 11:46:40 +00:00
17 changed files with 721 additions and 92 deletions

View File

@@ -16,3 +16,8 @@ export WANDERER_SSE_ENABLED="true"
export WANDERER_WEBHOOKS_ENABLED="true"
export WANDERER_SSE_MAX_CONNECTIONS="1000"
export WANDERER_WEBHOOK_TIMEOUT_MS="15000"
# Promo codes for map subscriptions (optional)
# Format: CODE:DISCOUNT_PERCENT,CODE2:DISCOUNT_PERCENT2
# Codes are case-insensitive, discounts stack with period discounts
# export WANDERER_PROMO_CODES="PROMO2025:10,NEWUSER:20"

View File

@@ -2,6 +2,34 @@
<!-- changelog -->
## [v1.90.12](https://github.com/wanderer-industries/wanderer/compare/v1.90.11...v1.90.12) (2025-12-19)
### Bug Fixes:
* core: fixed permissions update after character corp updates
## [v1.90.11](https://github.com/wanderer-industries/wanderer/compare/v1.90.10...v1.90.11) (2025-12-18)
## [v1.90.10](https://github.com/wanderer-industries/wanderer/compare/v1.90.9...v1.90.10) (2025-12-18)
## [v1.90.9](https://github.com/wanderer-industries/wanderer/compare/v1.90.8...v1.90.9) (2025-12-15)
### Bug Fixes:
* core: reduce chracters untrack grace period to 15 mins (after change/close/disconnect from map)
## [v1.90.8](https://github.com/wanderer-industries/wanderer/compare/v1.90.7...v1.90.8) (2025-12-15)

View File

@@ -92,6 +92,31 @@ map_subscription_extra_hubs_10_price =
config_dir
|> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_EXTRA_HUBS_10_PRICE", 10_000_000)
# Parse promo codes from environment variable
# Format: "CODE1:10,CODE2:20" where numbers are discount percentages
promo_codes =
config_dir
|> get_var_from_path_or_env("WANDERER_PROMO_CODES", "")
|> case do
"" ->
%{}
codes_string ->
codes_string
|> String.split(",")
|> Enum.map(fn entry ->
case String.split(String.trim(entry), ":") do
[code, discount] ->
{String.upcase(String.trim(code)), String.to_integer(String.trim(discount))}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
|> Map.new()
end
map_connection_auto_expire_hours =
config_dir
|> get_int_from_path_or_env("WANDERER_MAP_CONNECTION_AUTO_EXPIRE_HOURS", 24)
@@ -176,7 +201,8 @@ config :wanderer_app,
}
],
extra_characters_50: map_subscription_extra_characters_50_price,
extra_hubs_10: map_subscription_extra_hubs_10_price
extra_hubs_10: map_subscription_extra_hubs_10_price,
promo_codes: promo_codes
},
# Finch pool configuration - separate pools for different services
# ESI Character Tracking pool - high capacity for bulk character operations

View File

@@ -731,6 +731,14 @@ defmodule WandererApp.Character.Tracker do
{:character_alliance, {character_id, character_update}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{alliance_id: nil})
end
@@ -769,6 +777,14 @@ defmodule WandererApp.Character.Tracker do
{:character_alliance, {character_id, character_update}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{alliance_id: alliance_id})
@@ -823,6 +839,14 @@ defmodule WandererApp.Character.Tracker do
}}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{corporation_id: corporation_id})

View File

@@ -42,6 +42,35 @@ defmodule WandererApp.Env do
def corp_eve_id(), do: get_key(:corp_id, -1)
def subscription_settings(), do: get_key(:subscription_settings)
@doc """
Returns the promo code configuration map.
Keys are uppercase code strings, values are discount percentages.
"""
def promo_codes() do
case subscription_settings() do
%{promo_codes: codes} when is_map(codes) -> codes
_ -> %{}
end
end
@doc """
Validates a promo code and returns the discount percentage.
Returns {:ok, discount_percent} if valid, {:error, :invalid_code} otherwise.
Codes are case-insensitive.
"""
def validate_promo_code(nil), do: {:error, :invalid_code}
def validate_promo_code(""), do: {:error, :invalid_code}
def validate_promo_code(code) when is_binary(code) do
normalized = String.upcase(String.trim(code))
case Map.get(promo_codes(), normalized) do
nil -> {:error, :invalid_code}
discount when is_integer(discount) and discount > 0 and discount <= 100 -> {:ok, discount}
_ -> {:error, :invalid_code}
end
end
@decorate cacheable(
cache: WandererApp.Cache,
key: "restrict_maps_creation"

View File

@@ -7,7 +7,8 @@ defmodule WandererApp.EveDataService do
alias WandererApp.Utils.JSONUtil
@eve_db_dump_url "https://www.fuzzwork.co.uk/dump/latest"
# @eve_db_dump_url "https://www.fuzzwork.co.uk/dump/latest"
@eve_db_dump_url "https://wanderer-industries.github.io/wanderer-assets/sde-files"
@dump_file_names [
"invGroups.csv",

View File

@@ -23,7 +23,10 @@ defmodule WandererApp.Map do
characters_limit: nil,
hubs_limit: nil
def new(%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs} = input) do
def new(
%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs} =
input
) do
# Extract the new scopes array field if present (nil if not set)
scopes = Map.get(input, :scopes)

View File

@@ -72,28 +72,36 @@ defmodule WandererApp.Map.SubscriptionManager do
:ok
end
def estimate_price(params, renew?, promo_code \\ nil)
def estimate_price(
%{
"period" => period,
"characters_limit" => characters_limit,
"hubs_limit" => hubs_limit
},
renew?
} = params,
renew?,
promo_code
)
when is_binary(characters_limit),
do:
estimate_price(
%{
period: period |> String.to_integer(),
characters_limit: characters_limit |> String.to_integer(),
hubs_limit: hubs_limit |> String.to_integer()
},
renew?
)
when is_binary(characters_limit) do
# Extract promo_code from params if passed there (from form)
promo_code = promo_code || Map.get(params, "promo_code")
estimate_price(
%{
period: period |> String.to_integer(),
characters_limit: characters_limit |> String.to_integer(),
hubs_limit: hubs_limit |> String.to_integer()
},
renew?,
promo_code
)
end
def estimate_price(
%{characters_limit: characters_limit, hubs_limit: hubs_limit} = params,
renew?
renew?,
promo_code
) do
%{
plans: plans,
@@ -136,7 +144,7 @@ defmodule WandererApp.Map.SubscriptionManager do
total_price = estimated_price * period
{:ok, discount} =
{:ok, period_discount} =
calc_discount(
period,
total_price,
@@ -144,13 +152,27 @@ defmodule WandererApp.Map.SubscriptionManager do
renew?
)
{:ok, total_price, discount}
# Calculate promo discount on price after period discount
price_after_period_discount = total_price - period_discount
{:ok, promo_discount, promo_valid?} =
calc_promo_discount(promo_code, price_after_period_discount)
total_discount = period_discount + promo_discount
{:ok, total_price, total_discount, promo_valid?}
end
def calc_additional_price(params, selected_subscription, promo_code \\ nil)
def calc_additional_price(
%{"characters_limit" => characters_limit, "hubs_limit" => hubs_limit},
selected_subscription
%{"characters_limit" => characters_limit, "hubs_limit" => hubs_limit} = params,
selected_subscription,
promo_code
) do
# Extract promo_code from params if passed there (from form)
promo_code = promo_code || Map.get(params, "promo_code")
%{
plans: plans,
extra_characters_50: extra_characters_50,
@@ -189,7 +211,7 @@ defmodule WandererApp.Map.SubscriptionManager do
total_price = additional_price * period
{:ok, discount} =
{:ok, period_discount} =
calc_discount(
period,
total_price,
@@ -197,7 +219,15 @@ defmodule WandererApp.Map.SubscriptionManager do
false
)
{:ok, total_price, discount}
# Calculate promo discount on price after period discount
price_after_period_discount = total_price - period_discount
{:ok, promo_discount, promo_valid?} =
calc_promo_discount(promo_code, price_after_period_discount)
total_discount = period_discount + promo_discount
{:ok, total_price, total_discount, promo_valid?}
end
defp get_active_months(subscription) do
@@ -255,6 +285,22 @@ defmodule WandererApp.Map.SubscriptionManager do
when period >= 3,
do: {:ok, round(total_price * month_3_discount)}
# Calculates the promo code discount amount.
# Returns {:ok, discount_amount, is_valid?}
defp calc_promo_discount(nil, _price), do: {:ok, 0, false}
defp calc_promo_discount("", _price), do: {:ok, 0, false}
defp calc_promo_discount(promo_code, price) when is_binary(promo_code) do
case WandererApp.Env.validate_promo_code(promo_code) do
{:ok, discount_percent} ->
discount_amount = round(price * discount_percent / 100)
{:ok, discount_amount, true}
{:error, :invalid_code} ->
{:ok, 0, false}
end
end
def get_balance(map) do
map
|> WandererApp.MapRepo.load_relationships([
@@ -302,7 +348,8 @@ defmodule WandererApp.Map.SubscriptionManager do
defp renew_subscription(%{auto_renew?: true, map: map} = subscription)
when is_map(subscription) do
with {:ok, estimated_price, discount} <- estimate_price(subscription, true),
# No promo code for auto-renewals, ignore the promo_valid? return value
with {:ok, estimated_price, discount, _promo_valid?} <- estimate_price(subscription, true),
{:ok, map_balance} <- get_balance(map) do
case map_balance >= estimated_price do
true ->

View File

@@ -569,6 +569,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
)
# Broadcast permission update to trigger LiveView refresh
broadcast_permission_update(character_id)
:has_update
{:character_corporation, _info} ->
@@ -580,6 +583,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
)
# Broadcast permission update to trigger LiveView refresh
broadcast_permission_update(character_id)
:has_update
_ ->
@@ -953,4 +959,21 @@ defmodule WandererApp.Map.Server.CharactersImpl do
track: true
})
end
# Broadcasts permission update to trigger LiveView refresh for the character's user.
# This is called when a character's corporation or alliance changes, ensuring
# users are kicked off maps they no longer have access to.
defp broadcast_permission_update(character_id) do
case WandererApp.Character.get_character(character_id) do
{:ok, %{eve_id: eve_id}} when not is_nil(eve_id) ->
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{eve_id}",
:update_permissions
)
_ ->
:ok
end
end
end

View File

@@ -31,11 +31,7 @@
</div>
</aside>
<.new_version_banner
app_version={@app_version}
enabled={true}
latest_post={@latest_post}
/>
<.new_version_banner app_version={@app_version} enabled={true} latest_post={@latest_post} />
</div>
<.live_component module={WandererAppWeb.Alerts} id="notifications" view_flash={@flash} />

View File

@@ -117,43 +117,48 @@
</div>
</div>
<div>
<div :if={length(@members) > @members_per_page} class="flex items-center justify-between px-3 py-2 border-t border-gray-500 bg-black bg-opacity-25">
<span class="text-sm text-gray-400">
Page {@members_page} of {total_pages(@members, @members_per_page)} ({length(@members)} members)
</span>
<div class="flex gap-2">
<button
phx-click="members_prev_page"
disabled={@members_page <= 1}
class={"btn btn-sm btn-ghost " <> if(@members_page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="members_next_page"
disabled={@members_page >= total_pages(@members, @members_per_page)}
class={"btn btn-sm btn-ghost " <> if(@members_page >= total_pages(@members, @members_per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
<div
:if={length(@members) > @members_per_page}
class="flex items-center justify-between px-3 py-2 border-t border-gray-500 bg-black bg-opacity-25"
>
<span class="text-sm text-gray-400">
Page {@members_page} of {total_pages(@members, @members_per_page)} ({length(
@members
)} members)
</span>
<div class="flex gap-2">
<button
phx-click="members_prev_page"
disabled={@members_page <= 1}
class={"btn btn-sm btn-ghost " <> if(@members_page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="members_next_page"
disabled={@members_page >= total_pages(@members, @members_per_page)}
class={"btn btn-sm btn-ghost " <> if(@members_page >= total_pages(@members, @members_per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
<.link
:if={@selected_acl_id != "" and can_add_members?(@access_list, @current_user)}
class="btn w-full btn-neutral rounded-none"
patch={~p"/access-lists/#{@selected_acl_id}/add-members"}
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</.link>
<div
:if={@selected_acl_id == "" or not can_add_members?(@access_list, @current_user)}
class="btn mt-2 w-full btn-neutral rounded-none btn-disabled"
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</div>
<.link
:if={@selected_acl_id != "" and can_add_members?(@access_list, @current_user)}
class="btn w-full btn-neutral rounded-none"
patch={~p"/access-lists/#{@selected_acl_id}/add-members"}
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</.link>
<div
:if={@selected_acl_id == "" or not can_add_members?(@access_list, @current_user)}
class="btn mt-2 w-full btn-neutral rounded-none btn-disabled"
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</div>
</div>
</div>
</main>
@@ -179,10 +184,10 @@
placeholder="Select an owner"
options={Enum.map(@characters, fn character -> {character.label, character.id} end)}
/>
<!-- Divider between above inputs and the API key section -->
<hr class="my-4 border-gray-600" />
<!-- API Key Section with grid layout -->
<div class="mt-2">
<label class="block text-sm font-medium text-gray-200 mb-1">ACL API key</label>

View File

@@ -15,6 +15,9 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
is_adding_subscription?: false,
map_subscriptions: [],
selected_subscription: nil,
promo_code: "",
promo_code_valid?: false,
promo_code_error: nil,
subscription_periods: [
{"1 Month", "1"},
{"3 Months", "3"},
@@ -34,12 +37,13 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
"period" => "1",
"characters_limit" => "50",
"hubs_limit" => "20",
"auto_renew?" => true
"auto_renew?" => true,
"promo_code" => ""
}
{:ok, map} = WandererApp.MapRepo.get(map_id)
{:ok, estimated_price, discount} =
{:ok, estimated_price, discount, _promo_valid?} =
SubscriptionManager.estimate_price(subscription_form, false)
{:ok, map_subscriptions} =
@@ -53,7 +57,10 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
map_subscriptions: map_subscriptions,
subscription_form: subscription_form |> to_form(),
estimated_price: estimated_price,
discount: discount
discount: discount,
promo_code: "",
promo_code_valid?: false,
promo_code_error: nil
)
{:ok, socket}
@@ -73,10 +80,11 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
"plan" => "omega",
"characters_limit" => "#{selected_subscription.characters_limit}",
"hubs_limit" => "#{selected_subscription.hubs_limit}",
"auto_renew?" => selected_subscription.auto_renew?
"auto_renew?" => selected_subscription.auto_renew?,
"promo_code" => ""
}
{:ok, additional_price, discount} =
{:ok, additional_price, discount, _promo_valid?} =
SubscriptionManager.calc_additional_price(
subscription_form,
selected_subscription
@@ -89,6 +97,9 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
selected_subscription: selected_subscription,
additional_price: additional_price,
discount: discount,
promo_code: "",
promo_code_valid?: false,
promo_code_error: nil,
subscription_form: subscription_form |> to_form()
)}
end
@@ -142,23 +153,46 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
params,
%{assigns: %{selected_subscription: selected_subscription}} = socket
) do
promo_code = Map.get(params, "promo_code", "")
# Validate promo code and set error message
{promo_code_valid?, promo_code_error} =
case WandererApp.Env.validate_promo_code(promo_code) do
{:ok, _discount} -> {true, nil}
{:error, :invalid_code} when promo_code != "" -> {false, "Invalid promo code"}
_ -> {false, nil}
end
socket =
case is_nil(selected_subscription) do
true ->
{:ok, estimated_price, discount} =
{:ok, estimated_price, discount, _valid?} =
WandererApp.Map.SubscriptionManager.estimate_price(params, false)
socket
|> assign(estimated_price: estimated_price, discount: discount)
|> assign(
estimated_price: estimated_price,
discount: discount,
promo_code: promo_code,
promo_code_valid?: promo_code_valid?,
promo_code_error: promo_code_error
)
_ ->
{:ok, additional_price, discount} =
{:ok, additional_price, discount, _valid?} =
WandererApp.Map.SubscriptionManager.calc_additional_price(
params,
selected_subscription
)
socket |> assign(additional_price: additional_price, discount: discount)
socket
|> assign(
additional_price: additional_price,
discount: discount,
promo_code: promo_code,
promo_code_valid?: promo_code_valid?,
promo_code_error: promo_code_error
)
end
{:noreply, assign(socket, subscription_form: params)}
@@ -176,8 +210,9 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
%{assigns: %{map_id: map_id, map: map, current_user: current_user}} = socket
) do
period = period |> String.to_integer()
promo_code = Map.get(subscription_form, "promo_code", "")
{:ok, estimated_price, discount} =
{:ok, estimated_price, discount, _promo_valid?} =
WandererApp.Map.SubscriptionManager.estimate_price(subscription_form, false)
active_till =
@@ -219,7 +254,8 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
:telemetry.execute([:wanderer_app, :map, :subscription, :new], %{count: 1}, %{
map_id: map_id,
amount: estimated_price - discount
amount: estimated_price - discount,
promo_code: if(promo_code != "", do: String.upcase(promo_code), else: nil)
})
# Automatically create a license for the map
@@ -266,7 +302,7 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
}
} = socket
) do
{:ok, additional_price, discount} =
{:ok, additional_price, discount, _promo_valid?} =
WandererApp.Map.SubscriptionManager.calc_additional_price(
subscription_form,
selected_subscription
@@ -537,6 +573,17 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
class="range range-xs"
/>
<.input field={f[:auto_renew?]} label="Auto Renew" type="checkbox" />
<div :if={is_nil(@selected_subscription)} class="mt-2">
<.input
field={f[:promo_code]}
label="Promo Code (optional)"
type="text"
placeholder="Enter promo code"
class="input input-bordered w-full"
/>
<p :if={@promo_code_error} class="text-rose-500 text-xs mt-1">{@promo_code_error}</p>
<p :if={@promo_code_valid?} class="text-green-500 text-xs mt-1">✓ Promo code applied!</p>
</div>
<div
:if={is_nil(@selected_subscription)}
class="stats w-full bg-primary text-primary-content mt-2"
@@ -556,7 +603,12 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
</div>
</div>
<div>
<div class="stat-title">Discount</div>
<div class="stat-title">
Discount
<span :if={@promo_code_valid?} class="text-xs text-green-400 ml-1">
(incl. promo)
</span>
</div>
<div class="stat-value text-white relative">
ISK {@discount
|> Number.to_human(units: ["", "K", "M", "B", "T", "P"])}

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.90.8"
@version "1.90.12"
def project do
[

View File

@@ -186,7 +186,9 @@ defmodule WandererApp.AclMemberCacheInvalidationTest do
# Verify cache was invalidated
cached_data_after = WandererApp.Cache.lookup!(cache_key)
assert is_nil(cached_data_after), "Cache should be invalidated after adding corporation member"
assert is_nil(cached_data_after),
"Cache should be invalidated after adding corporation member"
end
@tag :integration

View File

@@ -0,0 +1,355 @@
defmodule WandererApp.Map.CorporationChangePermissionTest do
@moduledoc """
Integration tests for permission revocation when a character's corporation changes.
This tests the fix for the issue where:
- A user is granted map access via corporation-based ACL membership
- The user's character leaves or changes corporation
- The user could still see the map until they logged out
The fix ensures that when a character's corporation changes:
1. An :update_permissions broadcast is sent to the character's LiveView connections
2. The LiveView triggers a permission refresh
3. If access is revoked, the user is redirected away from the map
Related files:
- lib/wanderer_app/character/tracker.ex (broadcasts on corp change)
- lib/wanderer_app/map/server/map_server_characters_impl.ex (backup broadcast)
- lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex (handles broadcast)
"""
use WandererApp.DataCase, async: false
alias WandererAppWeb.Factory
import Mox
setup :verify_on_exit!
@test_corp_id_a 98000001
@test_corp_id_b 98000002
@test_alliance_id_a 99000001
setup do
# Configure the PubSubMock to forward to real Phoenix.PubSub for broadcast testing
Test.PubSubMock
|> Mox.stub(:broadcast!, fn server, topic, message ->
Phoenix.PubSub.broadcast!(server, topic, message)
end)
|> Mox.stub(:broadcast, fn server, topic, message ->
Phoenix.PubSub.broadcast(server, topic, message)
end)
|> Mox.stub(:subscribe, fn server, topic ->
Phoenix.PubSub.subscribe(server, topic)
end)
|> Mox.stub(:unsubscribe, fn server, topic ->
Phoenix.PubSub.unsubscribe(server, topic)
end)
:ok
end
describe "PubSub broadcast on corporation change" do
test "broadcasts :update_permissions to character channel when corporation update is simulated" do
# Create test data
user = Factory.create_user()
character =
Factory.create_character(%{
user_id: user.id,
corporation_id: @test_corp_id_a,
corporation_name: "Test Corp A",
corporation_ticker: "TCPA"
})
# Subscribe to the character's channel (this is what LiveView does via tracking_utils.ex)
Phoenix.PubSub.subscribe(WandererApp.PubSub, "character:#{character.eve_id}")
# Simulate what happens in tracker.ex when a corporation change is detected
# This simulates the fix: broadcasting :update_permissions after corp change
simulate_corporation_change(character, @test_corp_id_b)
# Should receive :update_permissions broadcast
assert_receive :update_permissions, 1000,
"Should receive :update_permissions when corporation changes"
end
test "broadcasts :update_permissions to character channel when alliance update is simulated" do
# Create test data
user = Factory.create_user()
character =
Factory.create_character(%{
user_id: user.id,
corporation_id: @test_corp_id_a,
alliance_id: @test_alliance_id_a,
alliance_name: "Test Alliance A",
alliance_ticker: "TALA"
})
# Subscribe to the character's channel
Phoenix.PubSub.subscribe(WandererApp.PubSub, "character:#{character.eve_id}")
# Simulate what happens when alliance is removed
simulate_alliance_removal(character)
# Should receive :update_permissions broadcast
assert_receive :update_permissions, 1000,
"Should receive :update_permissions when alliance is removed"
end
end
describe "Corporation-based ACL permission verification" do
test "character with corp A has access to map with corp A ACL" do
# Setup: Create a map with corporation-based ACL
owner_user = Factory.create_user()
owner = Factory.create_character(%{user_id: owner_user.id})
map =
Factory.create_map(%{
owner_id: owner.id,
name: "Corp Access Test Map",
slug: "corp-access-test-#{:rand.uniform(1_000_000)}"
})
# Create ACL that grants access to corporation A
acl = Factory.create_access_list(owner.id, %{name: "Corp A Access"})
_map_acl = Factory.create_map_access_list(map.id, acl.id)
_corp_member =
Factory.create_access_list_member(acl.id, %{
eve_corporation_id: "#{@test_corp_id_a}",
name: "Corporation A",
role: "member"
})
# Create user with character in corp A
test_user = Factory.create_user()
test_character =
Factory.create_character(%{
user_id: test_user.id,
corporation_id: @test_corp_id_a,
corporation_name: "Test Corp A",
corporation_ticker: "TCPA"
})
# Verify character has access via corporation membership
{:ok, map_with_acls} =
WandererApp.MapRepo.get(map.id,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
)
[character_permissions] =
WandererApp.Permissions.check_characters_access([test_character], map_with_acls.acls)
map_permissions =
WandererApp.Permissions.get_map_permissions(
character_permissions,
owner.id,
[test_character.id]
)
assert map_permissions.view_system == true,
"Character in corp A should have view_system permission"
end
test "character in corp B does not have access to map with corp A ACL" do
# Setup: Create a map with corporation-based ACL for corp A
owner_user = Factory.create_user()
owner = Factory.create_character(%{user_id: owner_user.id})
map =
Factory.create_map(%{
owner_id: owner.id,
name: "CorpB Test",
slug: "corp-access-test-2-#{:rand.uniform(1_000_000)}"
})
# Create ACL that grants access only to corporation A
acl = Factory.create_access_list(owner.id, %{name: "Corp A Only Access"})
_map_acl = Factory.create_map_access_list(map.id, acl.id)
_corp_member =
Factory.create_access_list_member(acl.id, %{
eve_corporation_id: "#{@test_corp_id_a}",
name: "Corporation A",
role: "member"
})
# Create user with character in corp B (not A)
test_user = Factory.create_user()
test_character =
Factory.create_character(%{
user_id: test_user.id,
corporation_id: @test_corp_id_b,
corporation_name: "Test Corp B",
corporation_ticker: "TCPB"
})
# Verify character does NOT have access
{:ok, map_with_acls} =
WandererApp.MapRepo.get(map.id,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
)
[character_permissions] =
WandererApp.Permissions.check_characters_access([test_character], map_with_acls.acls)
map_permissions =
WandererApp.Permissions.get_map_permissions(
character_permissions,
owner.id,
[test_character.id]
)
assert map_permissions.view_system == false,
"Character in corp B should NOT have view_system permission for corp A map"
end
test "permission check result changes when character changes from corp A to corp B" do
# Setup: Create a map with corporation-based ACL
owner_user = Factory.create_user()
owner = Factory.create_character(%{user_id: owner_user.id})
map =
Factory.create_map(%{
owner_id: owner.id,
name: "Corp Change Test Map",
slug: "corp-change-test-#{:rand.uniform(1_000_000)}"
})
# Create ACL that grants access to corporation A
acl = Factory.create_access_list(owner.id, %{name: "Corp A Access"})
_map_acl = Factory.create_map_access_list(map.id, acl.id)
_corp_member =
Factory.create_access_list_member(acl.id, %{
eve_corporation_id: "#{@test_corp_id_a}",
name: "Corporation A",
role: "member"
})
# Create user with character initially in corp A
test_user = Factory.create_user()
test_character =
Factory.create_character(%{
user_id: test_user.id,
corporation_id: @test_corp_id_a,
corporation_name: "Test Corp A",
corporation_ticker: "TCPA"
})
{:ok, map_with_acls} =
WandererApp.MapRepo.get(map.id,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
)
# Verify initial access
[initial_permissions] =
WandererApp.Permissions.check_characters_access([test_character], map_with_acls.acls)
initial_map_permissions =
WandererApp.Permissions.get_map_permissions(
initial_permissions,
owner.id,
[test_character.id]
)
assert initial_map_permissions.view_system == true,
"Initially character in corp A should have view_system permission"
# Now simulate the character changing corporation
# Update the character's corporation in the database
character_update = %{
corporation_id: @test_corp_id_b,
corporation_name: "Test Corp B",
corporation_ticker: "TCPB"
}
{:ok, updated_character} =
WandererApp.Api.Character.update_corporation(test_character, character_update)
WandererApp.Character.update_character(test_character.id, character_update)
# Verify character no longer has access after corporation change
[new_permissions] =
WandererApp.Permissions.check_characters_access([updated_character], map_with_acls.acls)
new_map_permissions =
WandererApp.Permissions.get_map_permissions(
new_permissions,
owner.id,
[updated_character.id]
)
assert new_map_permissions.view_system == false,
"After changing to corp B, character should NOT have view_system permission"
end
end
# Helper functions that simulate what the tracker does
defp simulate_corporation_change(character, new_corporation_id) do
# Update character in database
character_update = %{
corporation_id: new_corporation_id,
corporation_name: "Test Corp B",
corporation_ticker: "TCPB"
}
{:ok, _} = WandererApp.Api.Character.update_corporation(character, character_update)
WandererApp.Character.update_character(character.id, character_update)
# Broadcast corporation change (existing behavior)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character.id}:corporation",
{:character_corporation, {character.id, character_update}}
)
# Broadcast permission update (THE FIX)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
end
defp simulate_alliance_removal(character) do
# Update character in database
character_update = %{
alliance_id: nil,
alliance_name: nil,
alliance_ticker: nil
}
{:ok, _} = WandererApp.Api.Character.update_alliance(character, character_update)
WandererApp.Character.update_character(character.id, character_update)
# Broadcast alliance change (existing behavior)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character.id}:alliance",
{:character_alliance, {character.id, character_update}}
)
# Broadcast permission update (THE FIX)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
end
end

View File

@@ -237,7 +237,10 @@ defmodule WandererApp.Map.MapScopeFilteringTest do
# old_location is also high-sec (k-space to k-space)
old_location = %{solar_system_id: @hs_system_mili}
result = SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes, :null])
result =
SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes, :null])
assert result == :ok
end
@@ -245,7 +248,10 @@ defmodule WandererApp.Map.MapScopeFilteringTest do
location = %{solar_system_id: @ls_system_halmah}
# old_location is high-sec (k-space to k-space)
old_location = %{solar_system_id: @hs_system_halenan}
result = SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes, :null])
result =
SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes, :null])
assert result == :ok
end

View File

@@ -230,7 +230,8 @@ defmodule WandererApp.Map.Server.MapScopesTest do
assert ConnectionsImpl.is_connection_valid([:null], @hs_system_id, @ls_system_id) == false
# HS to LS with [:hi, :low]: valid (both match)
assert ConnectionsImpl.is_connection_valid([:hi, :low], @hs_system_id, @ls_system_id) == true
assert ConnectionsImpl.is_connection_valid([:hi, :low], @hs_system_id, @ls_system_id) ==
true
# HS to HS: valid with [:hi] (both match)
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, 30_000_002) == true
@@ -356,13 +357,15 @@ defmodule WandererApp.Map.Server.MapScopesTest do
test "WORMHOLE BORDER: WH->Hi-Sec with [:wormholes] is VALID (border k-space added)" do
# Border case: moving from WH to k-space
# Valid because :wormholes enabled AND one system is WH
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) == true
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) ==
true
end
test "WORMHOLE BORDER: Hi-Sec->WH with [:wormholes] is VALID (border k-space added)" do
# Border case: moving from k-space to WH
# Valid because :wormholes enabled AND one system is WH
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @wh_system_id) == true
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @wh_system_id) ==
true
end
test "K-SPACE ONLY: Hi-Sec->Hi-Sec with [:wormholes] is REJECTED" do
@@ -373,13 +376,21 @@ defmodule WandererApp.Map.Server.MapScopesTest do
test "K-SPACE ONLY: Null->Hi-Sec with [:wormholes, :null] is REJECTED (no border for k-space)" do
# Neither system is a wormhole, so no border behavior
# Null matches :null, but Hi-Sec doesn't match any scope -> BOTH must match
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @ns_system_id, @hs_system_id) ==
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@ns_system_id,
@hs_system_id
) ==
false
end
test "K-SPACE ONLY: Hi-Sec->Low-Sec with [:wormholes, :null] is REJECTED" do
# Neither Hi-Sec nor Low-Sec match [:wormholes, :null], no WH involved
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @hs_system_id, @ls_system_id) ==
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@hs_system_id,
@ls_system_id
) ==
false
end
@@ -402,7 +413,11 @@ defmodule WandererApp.Map.Server.MapScopesTest do
test "WORMHOLE BORDER: Pochven->WH with [:wormholes, :pochven] is VALID" do
# WH is wormhole, :wormholes enabled -> border behavior applies
assert ConnectionsImpl.is_connection_valid([:wormholes, :pochven], @pochven_id, @wh_system_id) ==
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :pochven],
@pochven_id,
@wh_system_id
) ==
true
end
@@ -414,9 +429,12 @@ defmodule WandererApp.Map.Server.MapScopesTest do
test "border systems: WH->Hi-Sec->WH path with [:wormholes] scope" do
# Simulates a character path through k-space between WHs
# First jump: WH to Hi-Sec - valid (wormhole border)
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) == true
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) ==
true
# Second jump: Hi-Sec to WH - valid (wormhole border)
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @c2_system_id) == true
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @c2_system_id) ==
true
end
test "excluded path: k-space chain with [:wormholes] scope remains excluded" do
@@ -428,10 +446,19 @@ defmodule WandererApp.Map.Server.MapScopesTest do
test "excluded path: Null->Hi-Sec->Low-Sec with [:wormholes, :null] - only Null tracked" do
# Character in Null (tracked) jumps to Hi-Sec (border - but NO wormhole!) -> REJECTED
# This is the key case: k-space to k-space should NOT add border systems
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @ns_system_id, @hs_system_id) ==
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@ns_system_id,
@hs_system_id
) ==
false
# Hi-Sec to Low-Sec also rejected (neither matches)
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @hs_system_id, @ls_system_id) ==
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@hs_system_id,
@ls_system_id
) ==
false
end
end