Reviewed-on: graham/towerops-web#86
This commit is contained in:
Graham McIntire 2026-03-19 14:28:31 -05:00
parent 8dae83a592
commit 0c2c4cc354
8 changed files with 847 additions and 22 deletions

View file

@ -1634,4 +1634,90 @@ defmodule Towerops.Accounts do
_ -> :error
end
end
## Account Deletion (GDPR Right to Erasure)
@doc """
Returns organizations where the user is the sole owner.
Used to check if a user can safely delete their account without
orphaning organizations.
"""
@spec sole_owner_organizations(String.t()) :: [Towerops.Organizations.Organization.t()]
def sole_owner_organizations(user_id) do
alias Towerops.Organizations.Membership
alias Towerops.Organizations.Organization
# Find orgs where user is owner
user_owner_org_ids =
Membership
|> where([m], m.user_id == ^user_id and m.role == :owner)
|> select([m], m.organization_id)
|> Repo.all()
# For each org, check if there are other owners
user_owner_org_ids
|> Enum.filter(fn org_id ->
owner_count =
Membership
|> where([m], m.organization_id == ^org_id and m.role == :owner)
|> Repo.aggregate(:count, :id)
owner_count == 1
end)
|> then(fn org_ids ->
Organization
|> where([o], o.id in ^org_ids)
|> Repo.all()
end)
end
@doc """
Deletes a user's account (self-service, GDPR Article 17).
Verifies the user's password before proceeding. Checks for sole-owner
organizations and blocks deletion if found.
Within a transaction:
1. Creates an audit log entry
2. Anonymizes login history
3. Anonymizes browser sessions
4. Deletes the user (cascades to memberships, tokens)
Returns `{:ok, user}` on success, or an error tuple.
"""
@spec delete_account(User.t(), String.t()) ::
{:ok, User.t()}
| {:error, :invalid_password}
| {:error, :sole_owner, [String.t()]}
def delete_account(%User{} = user, password) when is_binary(password) do
if User.valid_password?(user, password) do
do_delete_account(user)
else
{:error, :invalid_password}
end
end
defp do_delete_account(user) do
case sole_owner_organizations(user.id) do
[] ->
Repo.transaction(fn ->
{:ok, _} =
Towerops.Admin.create_audit_log(%{
action: "account_deletion_requested",
superuser_id: user.id,
target_user_id: user.id,
metadata: %{email: user.email, self_service: true}
})
anonymize_user_login_history(user.id)
anonymize_user_browser_sessions(user.id)
Repo.delete!(user)
end)
sole_owner_orgs ->
org_names = Enum.map(sole_owner_orgs, & &1.name)
{:error, :sole_owner, org_names}
end
end
end

View file

@ -192,6 +192,31 @@ defmodule Towerops.Accounts.UserNotifier do
end
end
@doc """
Deliver a notification confirming account deletion.
Sent after the account has been deleted to the user's email address.
"""
def deliver_account_deleted_notification(email, name) do
subject = t_email("Your Towerops account has been deleted")
body =
t_email(
"""
Hi %{name},
Your Towerops account has been permanently deleted as requested.
All your personal data has been removed from our systems.
If you did not request this deletion, please contact us immediately at hi@towerops.net.
""",
name: name || email
)
deliver(email, subject, body)
end
@doc """
Deliver instructions to reset a user password.
"""

View file

@ -65,7 +65,9 @@ defmodule Towerops.Admin.AuditLog do
"privilege_escalation",
"sudo_mode_granted",
# Billing overrides
"org_billing_override_updated"
"org_billing_override_updated",
# Self-service account actions
"account_deletion_requested"
])
end
end

View file

@ -8,6 +8,7 @@ defmodule ToweropsWeb.UserSettingsLive do
alias Towerops.Accounts
alias Towerops.Accounts.HIBP
alias Towerops.Accounts.UserNotifier
alias Towerops.Admin.AuditLogger
alias ToweropsWeb.UserSettingsLive.ApiTokenManager
alias ToweropsWeb.UserSettingsLive.Helpers
@ -46,6 +47,9 @@ defmodule ToweropsWeb.UserSettingsLive do
|> assign(:new_device_qr_code, nil)
|> assign(:generated_recovery_codes, nil)
|> assign(:password_breach_count, nil)
|> assign(:show_delete_modal, false)
|> assign(:sole_owner_orgs, [])
|> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete))
{:ok, socket}
end
@ -71,6 +75,7 @@ defmodule ToweropsWeb.UserSettingsLive do
|> assign(:show_add_device_modal, modal == "add_device")
|> assign(:show_device_qr_modal, modal == "device_qr")
|> assign(:show_recovery_codes_modal, modal == "recovery_codes")
|> assign(:show_delete_modal, modal == "delete_account")
|> assign_login_history()
{:noreply, socket}
@ -270,6 +275,65 @@ defmodule ToweropsWeb.UserSettingsLive do
{:noreply, TotpManager.delete_device(socket, device_id)}
end
# Account deletion
@impl true
def handle_event("show_delete_modal", _params, socket) do
user = socket.assigns.current_scope.user
sole_owner_orgs = Accounts.sole_owner_organizations(user.id)
socket =
socket
|> assign(:sole_owner_orgs, sole_owner_orgs)
|> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete))
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "delete_account"}))}
end
@impl true
def handle_event("close_delete_modal", _params, socket) do
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("delete_account", %{"password" => password, "confirmation" => confirmation}, socket) do
user = socket.assigns.current_scope.user
if confirmation == "DELETE" do
case Accounts.delete_account(user, password) do
{:ok, _} ->
# Send farewell email after deletion
UserNotifier.deliver_account_deleted_notification(user.email, user.first_name)
socket =
socket
|> put_flash(:info, t("Your account has been permanently deleted."))
|> redirect(to: ~p"/")
{:noreply, socket}
{:error, :invalid_password} ->
socket =
socket
|> put_flash(:error, t("Incorrect password"))
|> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete))
{:noreply, socket}
{:error, :sole_owner, _org_names} ->
socket = put_flash(socket, :error, t("Cannot delete account while you are the sole owner of an organization."))
{:noreply, socket}
end
else
socket =
socket
|> put_flash(:error, t("Please type DELETE to confirm"))
|> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete))
{:noreply, socket}
end
end
# Recovery codes - delegated to TotpManager
@impl true
def handle_event("regenerate_recovery_codes", _params, socket) do

View file

@ -602,6 +602,50 @@
</div>
</.form>
</div>
<!-- Delete Account Danger Zone -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8 border-t border-red-200 dark:border-red-900/50">
<div>
<h2 class="text-base/7 font-semibold text-red-600 dark:text-red-400">
{t("Delete Account")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t(
"Permanently delete your account and all associated data. This action cannot be undone."
)}
</p>
</div>
<div class="md:col-span-2">
<div class="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-900/50 dark:bg-red-950/20">
<div class="flex items-start gap-3">
<.icon
name="hero-exclamation-triangle"
class="h-6 w-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
/>
<div>
<h3 class="text-sm font-semibold text-red-800 dark:text-red-300">
{t("Danger Zone")}
</h3>
<p class="mt-1 text-sm text-red-700 dark:text-red-400">
{t(
"Once you delete your account, all of your data will be permanently removed. Before deleting, you can export your data from the My Data page."
)}
</p>
<div class="mt-4">
<button
type="button"
phx-click="show_delete_modal"
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400"
>
{t("Delete My Account")}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<% end %>
<!-- API Tab -->
@ -1850,4 +1894,156 @@
</div>
</div>
<% end %>
<!-- Delete Account Confirmation Modal -->
<%= if @show_delete_modal do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="delete-account-modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div
phx-click="close_delete_modal"
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-950 dark:bg-opacity-75"
aria-hidden="true"
>
</div>
<span
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
>
&#8203;
</span>
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-gray-800/95 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<.icon
name="hero-exclamation-triangle"
class="h-6 w-6 text-red-600 dark:text-red-400"
/>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
id="delete-account-modal-title"
>
{t("Are you sure you want to delete your account?")}
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
{t(
"This action cannot be undone. All of your data will be permanently removed."
)}
</p>
</div>
</div>
</div>
<%= if @sole_owner_orgs != [] do %>
<div class="mt-4 rounded-md bg-yellow-50 p-4 dark:bg-yellow-900/20">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{t("You are the sole owner of these organizations:")}
</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<ul class="list-disc space-y-1 pl-5">
<%= for org <- @sole_owner_orgs do %>
<li>{org.name}</li>
<% end %>
</ul>
<p class="mt-2">
{t(
"Please transfer ownership or delete these organizations before deleting your account."
)}
</p>
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
type="button"
phx-click="close_delete_modal"
class="inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800"
>
{t("Close")}
</button>
</div>
<% else %>
<.form
for={@delete_form}
phx-submit="delete_account"
id="delete_account_form"
class="mt-4"
>
<div class="space-y-4">
<div>
<label
for="delete-password"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{t("Enter your password")}
</label>
<div class="mt-1">
<input
type="password"
name="password"
id="delete-password"
required
autocomplete="current-password"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-red-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-red-500"
/>
</div>
</div>
<div>
<label
for="delete-confirmation"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{t("Type DELETE to confirm")}
</label>
<div class="mt-1">
<input
type="text"
name="confirmation"
id="delete-confirmation"
required
placeholder="DELETE"
autocomplete="off"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-red-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-red-500"
/>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="submit"
phx-disable-with={t("Deleting...")}
class="inline-flex w-full justify-center rounded-lg bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 sm:col-start-2"
>
{t("Permanently Delete Account")}
</button>
<button
type="button"
phx-click="close_delete_modal"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
>
{t("Cancel")}
</button>
</div>
</.form>
<% end %>
</div>
</div>
</div>
<% end %>
</Layouts.authenticated>

View file

@ -0,0 +1,174 @@
defmodule Towerops.Accounts.AccountDeletionTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Accounts
alias Towerops.Accounts.BrowserSession
alias Towerops.Admin.AuditLog
alias Towerops.Repo
describe "delete_account/2" do
test "deletes user with correct password" do
user = user_fixture()
password = valid_user_password()
assert {:ok, _} = Accounts.delete_account(user, password)
assert Accounts.get_user(user.id) == nil
end
test "returns error with wrong password" do
user = user_fixture()
assert {:error, :invalid_password} = Accounts.delete_account(user, "wrongpassword")
assert Accounts.get_user(user.id)
end
test "blocks deletion when user is sole owner of an organization" do
user = user_fixture()
org = organization_fixture(user.id, %{name: "My Org"})
assert {:error, :sole_owner, org_names} = Accounts.delete_account(user, valid_user_password())
assert "My Org" in org_names
# User should still exist
assert Accounts.get_user(user.id)
# Org should still exist
assert Repo.get(Towerops.Organizations.Organization, org.id)
end
test "allows deletion when org has other owners" do
user = user_fixture()
other_user = user_fixture()
org = organization_fixture(user.id, %{name: "Shared Org"})
# Add another owner
membership_fixture(org.id, other_user.id, :owner)
assert {:ok, _} = Accounts.delete_account(user, valid_user_password())
assert Accounts.get_user(user.id) == nil
end
test "anonymizes login history" do
user = user_fixture()
# Create a login attempt
{:ok, _} =
Accounts.record_login_attempt(%{
user_id: user.id,
email: user.email,
success: true,
method: "password",
ip_address: "192.168.1.1"
})
assert {:ok, _} = Accounts.delete_account(user, valid_user_password())
# Login history should be anonymized (user_id set to nil)
attempts =
Towerops.Accounts.LoginAttempt
|> Ecto.Query.where([la], la.email == ^user.email)
|> Repo.all()
assert [_ | _] = attempts
assert Enum.all?(attempts, &is_nil(&1.user_id))
assert Enum.all?(attempts, &(not is_nil(&1.anonymized_at)))
end
test "anonymizes browser sessions before deletion" do
user = user_fixture()
# Create a browser session
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
{:ok, session} =
Accounts.create_browser_session(%{
user_id: user.id,
user_token_id: user_token.id,
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0",
last_activity_at: DateTime.utc_now(:second),
expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
})
# Verify session exists with user_id before deletion
assert Repo.get(BrowserSession, session.id)
assert {:ok, _} = Accounts.delete_account(user, valid_user_password())
# Browser session is removed by cascade (user_token deleted -> browser_session deleted)
# The anonymize runs first within the transaction, then cascade cleanup happens
assert Repo.get(BrowserSession, session.id) == nil
end
test "creates audit log entry" do
user = user_fixture()
user_email = user.email
assert {:ok, _} = Accounts.delete_account(user, valid_user_password())
# Check audit log was created
# Note: superuser_id and target_user_id are nilified by FK cascade after user deletion
audit_log =
AuditLog
|> Ecto.Query.where([al], al.action == "account_deletion_requested")
|> Repo.one()
assert audit_log
assert audit_log.metadata["email"] == user_email
assert audit_log.metadata["self_service"] == true
end
test "user is fully deleted from database" do
user = user_fixture()
assert {:ok, _} = Accounts.delete_account(user, valid_user_password())
# User should be gone
assert Repo.get(Towerops.Accounts.User, user.id) == nil
# User tokens should be gone
token_count =
Towerops.Accounts.UserToken
|> Ecto.Query.where([t], t.user_id == ^user.id)
|> Repo.aggregate(:count, :id)
assert token_count == 0
end
end
describe "sole_owner_organizations/1" do
test "returns empty list when user has no organizations" do
user = user_fixture()
assert Accounts.sole_owner_organizations(user.id) == []
end
test "returns orgs where user is the only owner" do
user = user_fixture()
org = organization_fixture(user.id, %{name: "Solo Org"})
result = Accounts.sole_owner_organizations(user.id)
assert length(result) == 1
assert hd(result).id == org.id
end
test "does not return orgs where other owners exist" do
user = user_fixture()
other_user = user_fixture()
org = organization_fixture(user.id, %{name: "Shared Org"})
membership_fixture(org.id, other_user.id, :owner)
assert Accounts.sole_owner_organizations(user.id) == []
end
test "does not return orgs where user is not an owner" do
owner = user_fixture()
user = user_fixture()
org = organization_fixture(owner.id, %{name: "Not My Org"})
membership_fixture(org.id, user.id, :admin)
assert Accounts.sole_owner_organizations(user.id) == []
end
end
end

View file

@ -2,59 +2,240 @@ defmodule ToweropsWeb.AccountLive.MyDataTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Accounts
alias Towerops.Organizations
setup :register_and_log_in_user_with_sudo
describe "My Data page" do
test "renders for organization owner without additional data", %{conn: conn} do
# register_and_log_in_user creates a user with a Personal org (as owner)
test "renders profile information", %{conn: conn, user: user} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "My Data"
assert html =~ "Profile Information"
assert html =~ "Organizations"
assert html =~ "Devices"
assert html =~ "Audit Log"
assert html =~ user.email
end
test "renders for organization owner with full data", %{conn: conn, user: user} do
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
test "displays user name when set", %{conn: conn, user: user} do
{:ok, _user} = Accounts.update_user_profile(user, %{first_name: "Jane", last_name: "Doe"})
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Jane"
assert html =~ "Doe"
end
test "shows consent records from registration", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Consent Records"
# User fixture grants privacy_policy and terms_of_service consents
assert html =~ "Privacy policy"
assert html =~ "Terms of service"
assert html =~ "Active"
end
test "shows revoke consent button for active consents", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Revoke Consent"
end
test "revoking consent updates the display", %{conn: conn, user: user} do
consent = Accounts.get_active_consent(user.id, "privacy_policy")
{:ok, view, _html} = live(conn, ~p"/users/my-data")
html =
view
|> element(~s|button[phx-click=revoke_consent][phx-value-consent-id="#{consent.id}"]|)
|> render_click()
assert html =~ "Consent revoked successfully"
assert html =~ "Revoked"
end
test "hides org/device/alert sections when user has no orgs", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
# User from fixture has no organizations, so owner-only sections are hidden
refute html =~ "Organizations you own"
refute html =~ "Network devices monitored"
refute html =~ "Recent Alerts"
end
test "shows export data section with download link", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Export Your Data"
assert html =~ "Download My Data (JSON)"
assert html =~ "/api/v1/account/data"
end
test "shows data processing information", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "How We Use Your Data"
assert html =~ "Profile Information"
assert html =~ "Email Address"
assert html =~ "Privacy Policy"
end
test "shows navigation links to settings tabs", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Personal"
assert html =~ "Account"
assert html =~ "Sessions"
assert html =~ "Security"
end
end
describe "My Data page with organization data" do
setup %{user: user} do
{:ok, org} = Organizations.create_organization(%{name: "WISP Corp"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
name: "Tower Alpha",
organization_id: org.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.1",
name: "MikroTik Router",
ip_address: "10.0.0.1",
site_id: site.id,
organization_id: organization.id,
organization_id: org.id,
monitoring_enabled: true
})
# Create an alert
%{org: org, site: site, device: device}
end
test "shows organizations section for owners", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Organizations"
assert html =~ "WISP Corp"
assert html =~ "Owner"
end
test "shows devices section with device details", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Devices"
assert html =~ "MikroTik Router"
assert html =~ "10.0.0.1"
assert html =~ "WISP Corp"
end
test "shows device count", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Total devices: 1"
end
test "shows alerts section with alert data", %{conn: conn, device: device} do
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.utc_now(),
message: "Device is down"
message: "Device unreachable"
})
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "My Data"
assert html =~ "Profile Information"
assert html =~ "Organizations"
assert html =~ "Devices"
assert html =~ "Recent Alerts"
assert html =~ "device_down"
assert html =~ "MikroTik Router"
end
test "shows correct alert status badges", %{conn: conn, device: device} do
# Active alert
{:ok, _} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.utc_now(),
message: "Still down"
})
# Resolved alert
{:ok, _} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "high_latency",
triggered_at: DateTime.add(DateTime.utc_now(), -3600, :second),
resolved_at: DateTime.utc_now(),
message: "Latency normalized"
})
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Active"
assert html =~ "Resolved"
end
test "shows no alerts message when none exist", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "No alerts in the last 90 days"
end
end
describe "My Data page as non-owner member" do
setup %{user: user} do
# Create org owned by someone else, add test user as viewer
owner = user_fixture()
org = organization_fixture(owner.id, %{name: "Other Org"})
membership_fixture(org.id, user.id, :viewer)
%{org: org}
end
test "hides org/device/alert sections for non-owner members", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
# Non-owner members should not see org-level data
refute html =~ "Organizations you own"
refute html =~ "Network devices monitored"
refute html =~ "Recent Alerts"
end
test "still shows profile and consent sections", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Profile Information"
assert html =~ "Consent Records"
assert html =~ "Export Your Data"
end
end
describe "My Data page audit logs" do
test "shows audit log section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "Audit Log"
assert html =~ "Test Org"
assert html =~ "Test Device"
assert html =~ "192.168.1.1"
end
test "shows audit log entries when they exist", %{conn: conn, user: user} do
# The sudo_mode_granted event from setup creates an audit log
{:ok, _} =
Towerops.Admin.create_audit_log(%{
action: "user_data_viewed",
superuser_id: user.id,
target_user_id: user.id,
metadata: %{}
})
{:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "user_data_viewed"
end
end
end

View file

@ -0,0 +1,97 @@
defmodule ToweropsWeb.UserSettingsLive.DeleteAccountTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Accounts
describe "delete account danger zone" do
setup :register_and_log_in_user_with_sudo
test "renders danger zone on account tab", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/users/settings?tab=account")
assert html =~ "Delete Account"
assert html =~ "Permanently delete your account"
end
test "opens delete confirmation modal", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=account")
html = view |> element("[phx-click=show_delete_modal]") |> render_click()
assert html =~ "Are you sure you want to delete your account?"
assert html =~ "This action cannot be undone"
end
test "closes delete confirmation modal", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=account&modal=delete_account")
html = view |> element("button[phx-click=close_delete_modal]") |> render_click()
refute html =~ "Are you sure you want to delete your account?"
end
test "successfully deletes account with correct password and confirmation", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=account&modal=delete_account")
assert {:error, {:redirect, %{to: "/"}}} =
view
|> form("#delete_account_form", %{
"password" => valid_user_password(),
"confirmation" => "DELETE"
})
|> render_submit()
# User should be deleted
assert Accounts.get_user(user.id) == nil
end
test "rejects deletion with wrong password", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=account&modal=delete_account")
html =
view
|> form("#delete_account_form", %{
"password" => "wrongpassword",
"confirmation" => "DELETE"
})
|> render_submit()
assert html =~ "Incorrect password"
# User should still exist
assert Accounts.get_user(user.id)
end
test "rejects deletion without typing DELETE", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=account&modal=delete_account")
html =
view
|> form("#delete_account_form", %{
"password" => valid_user_password(),
"confirmation" => "nope"
})
|> render_submit()
assert html =~ "Please type DELETE to confirm"
# User should still exist
assert Accounts.get_user(user.id)
end
test "shows sole owner warning and blocks deletion", %{conn: conn, user: user} do
_org = organization_fixture(user.id, %{name: "My Test Org"})
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=account")
html = view |> element("[phx-click=show_delete_modal]") |> render_click()
assert html =~ "My Test Org"
assert html =~ "sole owner"
end
end
end