Compare commits

...

3 Commits

Author SHA1 Message Date
CI
078e5fc19e chore: release version v1.90.4 2025-12-12 17:07:55 +00:00
Dmitry Popov
3877e121c3 fix(core): fixed map scopes & signatures clean up behaviour 2025-12-12 18:07:18 +01:00
CI
dcb2a0cdb2 chore: [skip ci] 2025-12-11 00:17:06 +00:00
7 changed files with 384 additions and 77 deletions

View File

@@ -2,6 +2,15 @@
<!-- changelog -->
## [v1.90.4](https://github.com/wanderer-industries/wanderer/compare/v1.90.3...v1.90.4) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes & signatures clean up behaviour
## [v1.90.3](https://github.com/wanderer-industries/wanderer/compare/v1.90.2...v1.90.3) (2025-12-11)

View File

@@ -829,7 +829,8 @@ defmodule WandererApp.Map.Server.CharactersImpl do
)
|> case do
true ->
# Add new location system
# Connection is valid (at least one system matches scopes)
# Add BOTH systems including border systems - filtering already done by is_connection_valid
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts) do
:ok ->
:ok

View File

@@ -744,15 +744,33 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
when is_list(scopes) and from_solar_system_id != to_solar_system_id do
with {:ok, from_system_static_info} <- get_system_static_info(from_solar_system_id),
{:ok, to_system_static_info} <- get_system_static_info(to_solar_system_id) do
# Connection is valid if:
# 1. Neither system is prohibited
# 2. At least one system matches one of the selected scopes
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
not (@prohibited_systems |> Enum.member?(to_solar_system_id)) and
(system_matches_any_scope?(from_system_static_info.system_class, scopes) or
system_matches_any_scope?(to_system_static_info.system_class, scopes))
# First check: neither system is prohibited
not_prohibited =
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
not (@prohibited_systems |> Enum.member?(to_solar_system_id))
if not_prohibited do
from_is_wormhole = from_system_static_info.system_class in @wh_space
to_is_wormhole = to_system_static_info.system_class in @wh_space
wormholes_enabled = :wormholes in scopes
# Wormhole border behavior: if wormholes scope is enabled AND at least one
# system is a wormhole, allow the connection (adds border k-space systems)
# Otherwise: BOTH systems must match the configured scopes
if wormholes_enabled and (from_is_wormhole or to_is_wormhole) do
# At least one system matches (wormhole matches :wormholes, or other matches its scope)
system_matches_any_scope?(from_system_static_info.system_class, scopes) or
system_matches_any_scope?(to_system_static_info.system_class, scopes)
else
# Non-wormhole movement: both systems must match scopes
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
end
else
false
end
else
_ -> false
end

View File

@@ -403,64 +403,76 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end)
end
# When destination systems are deleted, unlink signatures instead of destroying them.
# This preserves the user's scan data while removing the stale link.
defp cleanup_linked_signatures(map_id, removed_solar_system_ids) do
removed_solar_system_ids
|> Enum.map(fn solar_system_id ->
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
end)
|> List.flatten()
|> Enum.uniq_by(& &1.system_id)
|> Enum.each(fn s ->
try do
{:ok, %{eve_id: eve_id, system: system}} = s |> Ash.load([:system])
# Group signatures by their source system for efficient broadcasting
signatures_by_system =
removed_solar_system_ids
|> Enum.flat_map(fn solar_system_id ->
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
end)
|> Enum.uniq_by(& &1.id)
|> Enum.group_by(fn sig -> sig.system_id end)
# Use Ash.destroy (not destroy!) to handle already-deleted signatures gracefully
case Ash.destroy(s) do
:ok ->
# Handle case where parent system was already deleted
case system do
nil ->
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} destroyed (parent system already deleted)"
end)
signatures_by_system
|> Enum.each(fn {_system_id, signatures} ->
signatures
|> Enum.each(fn sig ->
try do
{:ok, %{eve_id: eve_id, system: system}} = sig |> Ash.load([:system])
%{solar_system_id: solar_system_id} ->
Logger.debug(fn ->
"[cleanup_linked_signatures] for system #{solar_system_id}: #{inspect(eve_id)}"
end)
# Clear the linked_system_id instead of destroying the signature
case WandererApp.Api.MapSystemSignature.update_linked_system(sig, %{
linked_system_id: nil
}) do
{:ok, _updated_sig} ->
case system do
nil ->
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} unlinked (parent system already deleted)"
end)
# Audit logging for cascade deletion (no user/character context)
WandererApp.User.ActivityTracker.track_map_event(:signatures_removed, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_id: solar_system_id,
signatures: [eve_id]
})
%{solar_system_id: solar_system_id} ->
Logger.debug(fn ->
"[cleanup_linked_signatures] unlinked signature #{eve_id} in system #{solar_system_id}"
end)
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
end
# Audit logging for cascade unlink (no user/character context)
WandererApp.User.ActivityTracker.track_map_event(:signatures_unlinked, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_id: solar_system_id,
signatures: [eve_id]
})
end
{:error, %Ash.Error.Invalid{errors: errors}} ->
# Check if this is a StaleRecord error (signature already deleted)
if Enum.any?(errors, &match?(%Ash.Error.Changes.StaleRecord{}, &1)) do
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} already deleted (StaleRecord)"
end)
else
{:error, error} ->
Logger.error(
"[cleanup_linked_signatures] Failed to destroy signature #{eve_id}: #{inspect(errors)}"
"[cleanup_linked_signatures] Failed to unlink signature #{sig.eve_id}: #{inspect(error)}"
)
end
{:error, error} ->
Logger.error(
"[cleanup_linked_signatures] Failed to destroy signature: #{inspect(error)}"
)
end
rescue
e ->
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
end
rescue
e ->
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
end)
# Broadcast once per source system after all its signatures are processed
case List.first(signatures) do
%{system: %{solar_system_id: solar_system_id}} ->
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
_ ->
# Try to get the system info if not preloaded
case List.first(signatures) |> Ash.load([:system]) do
{:ok, %{system: %{solar_system_id: solar_system_id}}} ->
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
_ ->
:ok
end
end
end)
end
@@ -485,8 +497,32 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end)
end
def maybe_add_system(map_id, location, old_location, map_opts)
def maybe_add_system(map_id, location, old_location, map_opts, scopes \\ nil)
def maybe_add_system(map_id, location, old_location, map_opts, scopes)
when not is_nil(location) do
alias WandererApp.Map.Server.ConnectionsImpl
# Check if the system matches the map's configured scopes before adding
should_add =
case scopes do
nil -> true
[] -> true
scopes when is_list(scopes) ->
ConnectionsImpl.can_add_location(scopes, location.solar_system_id)
end
if should_add do
do_add_system_from_location(map_id, location, old_location, map_opts)
else
# System filtered out by scope settings - this is expected behavior
:ok
end
end
def maybe_add_system(_map_id, _location, _old_location, _map_opts, _scopes), do: :ok
defp do_add_system_from_location(map_id, location, old_location, map_opts) do
:telemetry.execute(
[:wanderer_app, :map, :system_addition, :start],
%{system_time: System.system_time()},
@@ -694,8 +730,6 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end
end
def maybe_add_system(_map_id, _location, _old_location, _map_opts), do: :ok
defp do_add_system(
map_id,
%{

View File

@@ -42,12 +42,18 @@ defmodule WandererAppWeb.AuthController do
WandererApp.Character.update_character(character.id, character_update)
# Update corporation/alliance data from ESI to ensure access control is current
update_character_affiliation(character)
{:ok, character}
{:error, _error} ->
{:ok, character} = WandererApp.Api.Character.create(character_data)
:telemetry.execute([:wanderer_app, :user, :character, :registered], %{count: 1})
# Fetch initial corporation/alliance data for new characters
update_character_affiliation(character)
{:ok, character}
end
@@ -113,4 +119,102 @@ defmodule WandererAppWeb.AuthController do
end
def maybe_update_character_user_id(_character, _user_id), do: :ok
# Updates character's corporation and alliance data from ESI.
# This ensures ACL-based access control uses current corporation membership,
# even for characters not actively being tracked on any map.
defp update_character_affiliation(%{id: character_id, eve_id: eve_id} = character) do
# Run async to not block the SSO callback
Task.start(fn ->
character_eve_id = eve_id |> String.to_integer()
case WandererApp.Esi.post_characters_affiliation([character_eve_id]) do
{:ok, [affiliation_info]} when is_map(affiliation_info) ->
new_corporation_id = Map.get(affiliation_info, "corporation_id")
new_alliance_id = Map.get(affiliation_info, "alliance_id")
# Check if corporation changed
corporation_changed = character.corporation_id != new_corporation_id
alliance_changed = character.alliance_id != new_alliance_id
if corporation_changed or alliance_changed do
update_affiliation_data(character_id, character, new_corporation_id, new_alliance_id)
end
{:error, error} ->
Logger.warning(
"[AuthController] Failed to fetch affiliation for character #{character_id}: #{inspect(error)}"
)
_ ->
:ok
end
end)
end
defp update_character_affiliation(_character), do: :ok
defp update_affiliation_data(character_id, character, corporation_id, alliance_id) do
# Fetch corporation info
corporation_update =
case WandererApp.Esi.get_corporation_info(corporation_id) do
{:ok, %{"name" => corp_name, "ticker" => corp_ticker}} ->
%{
corporation_id: corporation_id,
corporation_name: corp_name,
corporation_ticker: corp_ticker
}
_ ->
%{corporation_id: corporation_id}
end
# Fetch alliance info if present
alliance_update =
case alliance_id do
nil ->
%{alliance_id: nil, alliance_name: nil, alliance_ticker: nil}
_ ->
case WandererApp.Esi.get_alliance_info(alliance_id) do
{:ok, %{"name" => alliance_name, "ticker" => alliance_ticker}} ->
%{
alliance_id: alliance_id,
alliance_name: alliance_name,
alliance_ticker: alliance_ticker
}
_ ->
%{alliance_id: alliance_id}
end
end
full_update = Map.merge(corporation_update, alliance_update)
# Update database
case character.corporation_id != corporation_id do
true ->
{:ok, _} = WandererApp.Api.Character.update_corporation(character, corporation_update)
false ->
:ok
end
case character.alliance_id != alliance_id do
true ->
{:ok, _} = WandererApp.Api.Character.update_alliance(character, alliance_update)
false ->
:ok
end
# Update cache
WandererApp.Character.update_character(character_id, full_update)
Logger.info(
"[AuthController] Updated affiliation for character #{character_id}: " <>
"corp #{character.corporation_id} -> #{corporation_id}, " <>
"alliance #{character.alliance_id} -> #{alliance_id}"
)
end
end

View File

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

View File

@@ -206,37 +206,55 @@ defmodule WandererApp.Map.Server.MapScopesTest do
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @hs_system_id) == false
end
test "connection valid when at least one system matches a scope" do
# WH to HS: valid if either :wormholes or :hi is selected
test "wormhole border behavior: WH connections allow border k-space systems" do
# WH to HS with [:wormholes]: valid (wormhole border behavior)
# At least one system is WH, :wormholes is enabled -> border k-space allowed
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) ==
true
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @hs_system_id) == true
# WH to HS with [:hi] only: INVALID (no wormhole scope, WH doesn't match :hi)
# Neither system matches when we require both to match (no wormhole border behavior)
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @hs_system_id) == false
# WH to WH: valid only if :wormholes is selected
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @c2_system_id) ==
true
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @c2_system_id) == false
# HS to LS: valid if :hi or :low is selected
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @ls_system_id) == true
assert ConnectionsImpl.is_connection_valid([:low], @hs_system_id, @ls_system_id) == true
assert ConnectionsImpl.is_connection_valid([:null], @hs_system_id, @ls_system_id) == false
end
test "connection with multiple scopes allows cross-space movement" do
# With [:wormholes, :hi], all of these should be valid:
# - WH to WH (wormholes matches)
# - HS to HS (hi matches)
# - WH to HS (either matches)
test "k-space connections require BOTH systems to match scopes" do
# HS to LS: requires BOTH to match, so single scope is not enough
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @ls_system_id) == false
assert ConnectionsImpl.is_connection_valid([:low], @hs_system_id, @ls_system_id) == false
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
# HS to HS: valid with [:hi] (both match)
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, 30_000_002) == true
# NS to NS: valid with [:null] (both match)
assert ConnectionsImpl.is_connection_valid([:null], @ns_system_id, @ns_system_id) == false
# (same system returns false)
end
test "connection with multiple scopes" do
# With [:wormholes, :hi]:
# - WH to WH: valid (both match :wormholes)
# - HS to HS: valid (both match :hi)
# - WH to HS: valid (wormhole border behavior - WH is wormhole, :wormholes enabled)
scopes = [:wormholes, :hi]
assert ConnectionsImpl.is_connection_valid(scopes, @wh_system_id, @c2_system_id) == true
assert ConnectionsImpl.is_connection_valid(scopes, @hs_system_id, 30_000_002) == true
assert ConnectionsImpl.is_connection_valid(scopes, @wh_system_id, @hs_system_id) == true
# But LS to NS should not be valid with [:wormholes, :hi]
# LS to NS should not be valid with [:wormholes, :hi] (neither is WH, neither matches)
assert ConnectionsImpl.is_connection_valid(scopes, @ls_system_id, @ns_system_id) == false
# HS to LS should not be valid with [:wormholes, :hi] (neither is WH, only HS matches)
assert ConnectionsImpl.is_connection_valid(scopes, @hs_system_id, @ls_system_id) == false
end
test "all scopes allows any connection" do
@@ -294,4 +312,127 @@ defmodule WandererApp.Map.Server.MapScopesTest do
assert ConnectionsImpl.is_prohibited_system_class?(25) == false
end
end
describe "maybe_add_system/5 scope filtering" do
alias WandererApp.Map.Server.SystemsImpl
test "returns :ok without filtering when scopes is nil" do
# When scopes is nil, should not filter (backward compatibility)
result = SystemsImpl.maybe_add_system("map_id", nil, nil, [])
assert result == :ok
end
test "returns :ok without filtering when scopes is empty list" do
# Empty scopes should not filter (let through)
result = SystemsImpl.maybe_add_system("map_id", nil, nil, [], [])
assert result == :ok
end
test "filters system when scopes provided and system doesn't match" do
# When scopes is [:wormholes] and system is Hi-Sec, should filter (return :ok without adding)
location = %{solar_system_id: @hs_system_id}
result = SystemsImpl.maybe_add_system("map_id", location, nil, [], [:wormholes])
# Returns :ok because system was filtered out (not an error, just skipped)
assert result == :ok
end
test "allows system through when scopes match (verified via can_add_location)" do
# When scopes is [:wormholes] and system is WH, filtering should allow it
# We test this via can_add_location which is what maybe_add_system uses internally
assert ConnectionsImpl.can_add_location([:wormholes], @wh_system_id) == true
assert ConnectionsImpl.can_add_location([:null], @ns_system_id) == true
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_id) == true
assert ConnectionsImpl.can_add_location([:wormholes, :null], @ns_system_id) == true
end
end
describe "border system auto-addition behavior" do
# Tests that verify bordered systems are correctly auto-added ONLY for wormholes.
# Key behavior:
# - Wormhole border: WH to Hi-Sec with [:wormholes] -> BOTH added (border behavior)
# - K-space only: Null to Hi-Sec with [:wormholes, :null] -> REJECTED (no border for k-space)
# - K-space must match: both systems must match scopes when no wormhole involved
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
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
end
test "K-SPACE ONLY: Hi-Sec->Hi-Sec with [:wormholes] is REJECTED" do
# No wormhole involved, neither matches :wormholes
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002) == false
end
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) ==
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) ==
false
end
test "K-SPACE ONLY: Low-Sec->Hi-Sec with [:low] is REJECTED (no border for k-space)" do
# Low-Sec matches :low, but Hi-Sec doesn't match
# No wormhole involved, so BOTH must match -> rejected
assert ConnectionsImpl.is_connection_valid([:low], @ls_system_id, @hs_system_id) == false
end
test "K-SPACE MATCH: Low-Sec->Low-Sec with [:low] is VALID (both match)" do
# Both systems match :low
assert ConnectionsImpl.is_connection_valid([:low], @ls_system_id, 30_000_101) == true
end
test "K-SPACE MATCH: Null->Null with [:null] is VALID (both match)" do
# Would need two different null-sec systems for this test
# Using same system returns false (same system check)
assert ConnectionsImpl.is_connection_valid([:null], @ns_system_id, @ns_system_id) == false
end
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) ==
true
end
test "WORMHOLE BORDER: WH->Pochven with [:wormholes] is VALID (border k-space)" do
# WH is wormhole, :wormholes enabled -> border behavior, Pochven added as border
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @pochven_id) == true
end
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
# Second jump: Hi-Sec to WH - valid (wormhole border)
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
# If character moves within k-space (no WH involved), should be excluded
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002) == false
assert ConnectionsImpl.is_connection_valid([:wormholes], 30_000_002, @ls_system_id) == false
end
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) ==
false
# Hi-Sec to Low-Sec also rejected (neither matches)
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @hs_system_id, @ls_system_id) ==
false
end
end
end