fix: 11 critical security/correctness bugs from code audit

- C1: Replace innerHTML template literals with DOM textContent in sites_map.ts
- C2: Derive user_id from current_scope instead of client params in policy consent
- C3: Fix broken halt() in GDPR data export (orphaned _ = ... halt())
- C4: Add owner/admin authorization check to MembersController update/delete
- C5: Require HMAC signature on agent release webhook (was optional)
- C6: Fix Repo.all_by/2 (not a real Ecto function) → Repo.all with query
- C7: Move auth exemption to before_send callback in BruteForceProtection
- C8: Block </style>/<script injection in status page custom_css validation
- C9: Create secrets.example.yaml with placeholders; secrets.yaml already gitignored
- C10: Load Stripe key from STRIPE_SECRET_KEY env var instead of hardcoded value
- C13: Remove credentials from PubSub backup broadcast; channel resolves them

C11 (no force_ssl): by design — Cloudflared terminates TLS
C12 (device quota race): false positive — FOR UPDATE serializes correctly
This commit is contained in:
Graham McIntire 2026-05-12 10:20:52 -05:00
parent 39e588c686
commit ca7bb75472
20 changed files with 1335 additions and 293 deletions

View file

@ -72,35 +72,41 @@ export const SitesMap = {
if (site.latitude && site.longitude) {
const marker = L.marker([site.latitude, site.longitude])
let popupContent = `<div class="p-2">
<h3 class="font-semibold text-lg mb-1">${site.name}</h3>`
const popupEl = document.createElement('div')
popupEl.className = 'p-2'
const h3 = popupEl.appendChild(document.createElement('h3'))
h3.className = 'font-semibold text-lg mb-1'
h3.textContent = site.name
if (site.description) {
popupContent += `<p class="text-sm text-gray-600 mb-1">${site.description}</p>`
const descP = popupEl.appendChild(document.createElement('p'))
descP.className = 'text-sm text-gray-600 mb-1'
descP.textContent = site.description
}
if (site.address) {
popupContent += `<p class="text-xs text-gray-500 mb-1">${site.address}</p>`
const addrP = popupEl.appendChild(document.createElement('p'))
addrP.className = 'text-xs text-gray-500 mb-1'
addrP.textContent = site.address
}
popupContent += `<p class="text-xs text-gray-500 mb-2">
${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}
</p>`
const coordsP = popupEl.appendChild(document.createElement('p'))
coordsP.className = 'text-xs text-gray-500 mb-2'
coordsP.textContent = `${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}`
if (site.device_count > 0) {
popupContent += `<p class="text-xs text-blue-600 mb-2">
${site.device_count} device${site.device_count !== 1 ? 's' : ''}
</p>`
const deviceP = popupEl.appendChild(document.createElement('p'))
deviceP.className = 'text-xs text-blue-600 mb-2'
deviceP.textContent = `${site.device_count} device${site.device_count !== 1 ? 's' : ''}`
}
popupContent += `<button
data-site-id="${site.id}"
data-action="view-site"
class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700"
>
View Site
</button></div>`
const button = popupEl.appendChild(document.createElement('button'))
button.setAttribute('data-site-id', site.id)
button.setAttribute('data-action', 'view-site')
button.className = 'text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700'
button.textContent = 'View Site →'
marker.bindPopup(popupContent)
marker.bindPopup(popupEl)
marker.on('popupopen', () => {
const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement
if (button) {

1090
bugs.md

File diff suppressed because it is too large Load diff

View file

@ -239,8 +239,7 @@ config :towerops, dev_routes: true
# Stripe configuration for development
config :towerops,
stripe_secret_key:
"sk_test_51T7zcQS77kvnTfgyu0DCQU2xVKzeaneVCueHaXV3jsIw6HAwGWWllEL3J8jGZOybHAtPyu6oYIiuvJHhReFtTycE00VrJYJ8Ao",
stripe_secret_key: System.get_env("STRIPE_SECRET_KEY", "sk_test_use_your_own_key"),
stripe_webhook_secret: "whsec_dev_fake_secret",
stripe_price_id: "price_1T81XBS77kvnTfgyPlw1jF8N",
stripe_meter_id: "mtr_test_61UHPU067veEZ7bhl41S77kvnTfgyODY"

View file

@ -1,5 +1,5 @@
# Unified template for every Kubernetes Secret the towerops Deployment
# references. Bootstrap your local copy:
# references. Copy to k8s/secrets.yaml and fill in real values:
#
# cp k8s/secrets.example.yaml k8s/secrets.yaml
# # edit k8s/secrets.yaml — fill in real values
@ -26,12 +26,12 @@ metadata:
type: Opaque
stringData:
# Generate with: openssl rand -base64 32
RELEASE_COOKIE: ""
RELEASE_COOKIE: "CHANGE_ME_openssl_rand_-base64_32"
# Generate with: mix phx.gen.secret (or openssl rand -base64 64)
SECRET_KEY_BASE: ""
SECRET_KEY_BASE: "CHANGE_ME_mix_phx_gen_secret"
# Generate with: openssl rand -base64 32
# CRITICAL: losing this makes encrypted columns unrecoverable.
CLOAK_KEY: ""
CLOAK_KEY: "CHANGE_ME_openssl_rand_-base64_32"
---
apiVersion: v1
kind: Secret
@ -41,14 +41,12 @@ metadata:
type: Opaque
stringData:
# Full ecto:// URL — what runtime.exs reads.
DATABASE_URL: "ecto://USER:PASSWORD@HOST:5432/DATABASE"
# The individual fields are convenience for ops — not required by the
# app but kept here for parity with the existing prod secret.
POSTGRES_HOST: ""
DATABASE_URL: "postgresql://towerops:CHANGE_ME@postgres-host:5432/towerops"
POSTGRES_HOST: "CHANGE_ME_postgres_host"
POSTGRES_PORT: "5432"
POSTGRES_DB: ""
POSTGRES_USER: ""
POSTGRES_PASSWORD: ""
POSTGRES_DB: "towerops"
POSTGRES_USER: "towerops"
POSTGRES_PASSWORD: "CHANGE_ME"
---
apiVersion: v1
kind: Secret
@ -57,9 +55,9 @@ metadata:
namespace: towerops
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: ""
AWS_SECRET_ACCESS_KEY: ""
AWS_REGION: ""
AWS_ACCESS_KEY_ID: "CHANGE_ME"
AWS_SECRET_ACCESS_KEY: "CHANGE_ME"
AWS_REGION: "us-east-1"
---
apiVersion: v1
kind: Secret
@ -68,11 +66,9 @@ metadata:
namespace: towerops
type: Opaque
stringData:
# K8s pattern — runtime.exs builds the connection from these
# individual fields. Dokku alternative is REDIS_URL.
REDIS_HOST: ""
REDIS_HOST: "CHANGE_ME_redis_host"
REDIS_PORT: "6379"
REDIS_PASSWORD: ""
REDIS_PASSWORD: "CHANGE_ME"
---
apiVersion: v1
kind: Secret
@ -81,11 +77,10 @@ metadata:
namespace: towerops
type: Opaque
stringData:
# Stripe — every key is optional; the deployment marks them optional: true.
STRIPE_SECRET_KEY: ""
STRIPE_WEBHOOK_SECRET: ""
STRIPE_PRICE_ID: ""
STRIPE_METER_ID: ""
STRIPE_SECRET_KEY: "CHANGE_ME_sk_live_..."
STRIPE_WEBHOOK_SECRET: "CHANGE_ME_whsec_..."
STRIPE_PRICE_ID: "CHANGE_ME_price_..."
STRIPE_METER_ID: "CHANGE_ME_mtr_..."
---
apiVersion: v1
kind: Secret
@ -94,8 +89,5 @@ metadata:
namespace: towerops
type: Opaque
stringData:
# DeepSeek API key — get one at https://platform.deepseek.com/
# Without this, insight LLM enrichment stays no-op (insights still render).
DEEPSEEK_API_KEY: ""
# Optional: override the default model (default: deepseek-v4-pro).
DEEPSEEK_API_KEY: "CHANGE_ME_sk_..."
DEEPSEEK_MODEL: ""

View file

@ -383,7 +383,7 @@ defmodule Towerops.Accounts do
fn ->
case Repo.update(changeset) do
{:ok, user} ->
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
tokens_to_expire = Repo.all(from t in UserToken, where: t.user_id == ^user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{:ok, {user, tokens_to_expire}}

View file

@ -53,9 +53,22 @@ defmodule Towerops.StatusPages.StatusPageConfig do
changeset
css when is_binary(css) ->
if String.contains?(String.downcase(css), "url(") do
downcased = String.downcase(css)
cond do
String.contains?(downcased, "url(") ->
add_error(changeset, :custom_css, "url() references are not allowed in custom CSS")
else
String.contains?(downcased, "</style") ->
add_error(changeset, :custom_css, "HTML tags are not allowed in custom CSS")
String.contains?(downcased, "</script") ->
add_error(changeset, :custom_css, "HTML tags are not allowed in custom CSS")
String.contains?(downcased, "<script") ->
add_error(changeset, :custom_css, "HTML tags are not allowed in custom CSS")
true ->
changeset
end

View file

@ -14,9 +14,6 @@ defmodule Towerops.Workers.MikrotikBackupWorker do
use Oban.Worker, queue: :maintenance, max_attempts: 3
alias Towerops.Agent.AgentJob
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Devices
alias Towerops.Devices.BackupRequests
@ -65,68 +62,16 @@ defmodule Towerops.Workers.MikrotikBackupWorker do
# Build backup job
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
mikrotik_config = Devices.get_mikrotik_config(device)
# Generate unique filename for this backup (without .rsc extension, RouterOS adds it)
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
# Build chunked read commands
# Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB
# Most MikroTik configs are under this size
chunk_size = 32_768
num_chunks = 10
read_commands =
for i <- 0..(num_chunks - 1) do
%MikrotikCommand{
command: "/file/read",
args: %{
"file" => "#{backup_filename}.rsc",
"offset" => to_string(i * chunk_size),
"chunk-size" => to_string(chunk_size)
}
}
end
job = %AgentJob{
job_id: job_id,
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: %MikrotikDevice{
ip: device.ip_address,
username: mikrotik_config.username,
password: mikrotik_config.password,
port: mikrotik_config.port,
ssh_port: mikrotik_config.ssh_port,
use_ssl: mikrotik_config.use_ssl
},
mikrotik_commands:
[
# Step 1: Export config to file on router (compact format, no defaults)
%MikrotikCommand{
command: "/export",
args: %{"file" => backup_filename, "compact" => ""}
}
] ++
read_commands ++
[
# Final step: Delete the temporary file
%MikrotikCommand{
command: "/file/remove",
args: %{"numbers" => "#{backup_filename}.rsc"}
}
]
}
# Create tracking request
case BackupRequests.create_request(device.id, job_id) do
{:ok, _request} ->
# Broadcast to agent via PubSub
# Broadcast device id to agent channel via PubSub — the channel
# resolves credentials itself to avoid exposing them on PubSub.
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token_id}:backup",
{:backup_requested, job}
{:backup_requested, device.id, job_id}
)
Logger.info("Backup job sent to agent",

View file

@ -316,17 +316,27 @@ defmodule ToweropsWeb.AgentChannel do
end
# Handle PubSub broadcast when backup is requested for a device
def handle_info({:backup_requested, job}, socket) do
Logger.info("Backup requested for device, sending backup job to agent",
def handle_info({:backup_requested, device_id, job_id}, socket) do
Logger.info("Backup requested for device, assembling and sending backup job to agent",
agent_token_id: socket.assigns.agent_token_id,
device_id: job.device_id,
job_id: job.job_id
device_id: device_id,
job_id: job_id
)
case Devices.get_device_with_details(device_id) do
nil ->
Logger.warning("Backup requested but device not found",
device_id: device_id,
job_id: job_id
)
device ->
job = build_backup_job(device, job_id)
job_list = %AgentJobList{jobs: [job]}
binary = AgentJobList.encode(job_list)
push(socket, "backup_job", %{binary: Base.encode64(binary)})
end
{:noreply, socket}
end
@ -452,6 +462,53 @@ defmodule ToweropsWeb.AgentChannel do
{:noreply, socket}
end
defp build_backup_job(device, job_id) do
mikrotik_config = Devices.get_mikrotik_config(device)
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
chunk_size = 32_768
num_chunks = 10
read_commands =
for i <- 0..(num_chunks - 1) do
%MikrotikCommand{
command: "/file/read",
args: %{
"file" => "#{backup_filename}.rsc",
"offset" => to_string(i * chunk_size),
"chunk-size" => to_string(chunk_size)
}
}
end
%AgentJob{
job_id: job_id,
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: %MikrotikDevice{
ip: device.ip_address || "",
username: mikrotik_config.username || "",
password: mikrotik_config.password || "",
port: mikrotik_config.port || 8729,
ssh_port: mikrotik_config.ssh_port || 22,
use_ssl: mikrotik_config.use_ssl || false
},
mikrotik_commands:
[
%MikrotikCommand{
command: "/export",
args: %{"file" => backup_filename, "compact" => "true"}
}
] ++
read_commands ++
[
%MikrotikCommand{
command: "/file/remove",
args: %{"numbers" => "#{backup_filename}.rsc"}
}
]
}
end
@impl true
@spec handle_in(String.t(), map(), socket()) :: {:noreply, socket()}
def handle_in("result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do

View file

@ -17,14 +17,7 @@ defmodule ToweropsWeb.Api.AccountDataController do
user = conn.assigns.current_scope.user
# Verify user account is confirmed before allowing data export
_ =
if !user.confirmed_at do
conn
|> put_status(:forbidden)
|> json(%{error: "Email address must be confirmed before exporting account data"})
|> halt()
end
if user.confirmed_at do
# Log the data export for audit trail
AuditLogger.log_user_data_exported(conn, user.id)
@ -49,6 +42,12 @@ defmodule ToweropsWeb.Api.AccountDataController do
"attachment; filename=\"towerops-data-#{user.id}-#{DateTime.to_unix(DateTime.utc_now())}.json\""
)
|> json(data)
else
conn
|> put_status(:forbidden)
|> json(%{error: "Email address must be confirmed before exporting account data"})
|> halt()
end
end
# Build profile data

View file

@ -14,7 +14,7 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookController do
@max_age_seconds 300
def create(conn, _params) do
case verify_optional_signature(conn) do
case verify_signature(conn) do
:ok ->
case Oban.insert(AgentReleaseWebhookWorker.new(%{})) do
{:ok, _job} ->
@ -34,17 +34,17 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookController do
end
end
defp verify_optional_signature(conn) do
defp verify_signature(conn) do
sig_headers = Plug.Conn.get_req_header(conn, "x-agent-webhook-signature")
case sig_headers do
[] ->
:ok
[header] ->
secret = Application.get_env(:towerops, :agent_webhook_secret)
raw_body = conn.private[:raw_body] || ""
verify_hmac_signature(header, raw_body, secret)
[] ->
{:error, :missing_signature}
end
end

View file

@ -20,6 +20,8 @@ defmodule ToweropsWeb.Api.V1.MembersController do
def update(conn, %{"id" => user_id, "role" => role}) do
organization_id = conn.assigns.current_organization_id
case authorize_member_management(conn, organization_id) do
{:ok, conn} ->
case Organizations.update_member_role(organization_id, user_id, role) do
{:ok, membership} ->
membership = Towerops.Repo.preload(membership, :user)
@ -34,6 +36,10 @@ defmodule ToweropsWeb.Api.V1.MembersController do
{:error, changeset} ->
conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)})
end
{:halt, conn} ->
conn
end
end
def update(conn, _params) do
@ -43,11 +49,45 @@ defmodule ToweropsWeb.Api.V1.MembersController do
def delete(conn, %{"id" => user_id}) do
organization_id = conn.assigns.current_organization_id
case authorize_member_management(conn, organization_id) do
{:ok, conn} ->
case Organizations.remove_member(organization_id, user_id) do
{:ok, _} -> conn |> put_status(:no_content) |> send_resp(204, "")
{:error, :cannot_remove_owner} -> conn |> put_status(:forbidden) |> json(%{error: "Cannot remove owner"})
{:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Member not found"})
end
{:halt, conn} ->
conn
end
end
defp authorize_member_management(conn, organization_id) do
user = conn.assigns[:current_user]
if user do
membership = Organizations.get_membership(organization_id, user.id)
if membership && membership.role in [:owner, :admin] do
{:ok, conn}
else
conn =
conn
|> put_status(:forbidden)
|> json(%{error: "Only owners and admins can manage members"})
|> halt()
{:halt, conn}
end
else
conn =
conn
|> put_status(:unauthorized)
|> json(%{error: "Authentication required"})
|> halt()
{:halt, conn}
end
end
defp format_member(membership) do

View file

@ -932,71 +932,19 @@ defmodule ToweropsWeb.DeviceLive.Show do
end
defp trigger_manual_backup(socket, device, agent_token_id) do
alias Towerops.Agent.AgentJob
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Devices.BackupRequests
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
mikrotik_config = Devices.get_mikrotik_config(device)
# Generate unique filename for this backup (without .rsc extension, RouterOS adds it)
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
# Build chunked read commands
# Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB
chunk_size = 32_768
num_chunks = 10
read_commands =
for i <- 0..(num_chunks - 1) do
%MikrotikCommand{
command: "/file/read",
args: %{
"file" => "#{backup_filename}.rsc",
"offset" => to_string(i * chunk_size),
"chunk-size" => to_string(chunk_size)
}
}
end
job = %AgentJob{
job_id: job_id,
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: %MikrotikDevice{
ip: device.ip_address,
username: mikrotik_config.username,
password: mikrotik_config.password,
port: mikrotik_config.port,
ssh_port: mikrotik_config.ssh_port,
use_ssl: mikrotik_config.use_ssl
},
mikrotik_commands:
[
# Step 1: Export config to file on router (compact format, no defaults)
%MikrotikCommand{
command: "/export",
args: %{"file" => backup_filename, "compact" => ""}
}
] ++
read_commands ++
[
# Final step: Delete the temporary file
%MikrotikCommand{
command: "/file/remove",
args: %{"numbers" => "#{backup_filename}.rsc"}
}
]
}
case BackupRequests.create_request(device.id, job_id, "manual") do
{:ok, _request} ->
# Send device id + job id over PubSub — agent channel resolves
# credentials itself to avoid exposing them on PubSub.
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token_id}:backup",
{:backup_requested, job}
{:backup_requested, device.id, job_id}
)
{:noreply,

View file

@ -15,7 +15,7 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
## Exemptions
- Authenticated users (conn.assigns[:current_user] present)
- Authenticated users (checked in before_send callback, since auth runs after this plug)
- Agent WebSocket connections (/socket/agent - authenticated at channel join)
- Whitelisted IPs (checked via BruteForce context)
@ -43,10 +43,6 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
ip_address = RemoteIp.from_conn(conn)
cond do
# Skip for authenticated users
conn.assigns[:current_user] != nil ->
conn
# Skip for agent WebSocket connections (authenticated at channel join)
String.starts_with?(conn.request_path, "/socket/agent") ->
conn
@ -69,7 +65,12 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
defp register_404_tracker(conn, ip_address) do
register_before_send(conn, fn response_conn ->
# Skip tracking for authenticated users (auth runs after this plug,
# but before_send runs after the full pipeline including auth).
if !response_conn.assigns[:current_user] do
track_404_if_needed(response_conn, ip_address, conn.request_path)
end
response_conn
end)
end

View file

@ -690,9 +690,10 @@ defmodule ToweropsWeb.UserAuth do
# Attach LiveView event handler for accepting updated policies
socket =
LiveView.attach_hook(socket, :policy_consent_handler, :handle_event, fn
"accept_updated_policies", %{"user-id" => user_id}, socket ->
"accept_updated_policies", _params, socket ->
# Grant consent for all policies that need re-consent
policies_needing_consent = Map.get(socket.assigns, :policies_needing_consent, [])
user_id = socket.assigns.current_scope.user.id
Enum.each(policies_needing_consent, fn policy_type ->
Accounts.grant_consent(user_id, policy_type)

View file

@ -2,7 +2,7 @@
# Setup script for Stripe billing meter
stripe_key = "sk_test_51T7zcQS77kvnTfgyu0DCQU2xVKzeaneVCueHaXV3jsIw6HAwGWWllEL3J8jGZOybHAtPyu6oYIiuvJHhReFtTycE00VrJYJ8Ao"
stripe_key = System.get_env("STRIPE_SECRET_KEY", "sk_test_use_your_own_key")
product_id = "prod_U6DpFdl21ftiDz"
IO.puts("Creating Stripe billing meter...")

View file

@ -93,22 +93,13 @@ defmodule Towerops.Workers.MikrotikBackupWorkerTest do
assert :ok = perform_job(MikrotikBackupWorker, %{})
end)
assert_receive {:backup_requested, job}, 1_000
assert job.device_id == device.id
assert job.mikrotik_device.username == "admin"
assert job.mikrotik_device.port == 8729
assert job.mikrotik_device.ssh_port == 22
assert job.mikrotik_device.use_ssl == true
assert job.job_type == :MIKROTIK
# 1 export + 10 read chunks + 1 remove = 12 commands
assert length(job.mikrotik_commands) == 12
[first | _] = job.mikrotik_commands
assert first.command == "/export"
last = List.last(job.mikrotik_commands)
assert last.command == "/file/remove"
assert_receive {:backup_requested, received_device_id, job_id}, 1_000
assert received_device_id == device.id
assert is_binary(job_id)
assert String.starts_with?(job_id, "backup:#{device.id}:")
# Backup request was created in DB
assert BackupRequests.get_request_by_job_id(job.job_id)
assert BackupRequests.get_request_by_job_id(job_id)
end
test "returns {:error, _} when at least one backup request fails to create" do

View file

@ -10,7 +10,6 @@ defmodule ToweropsWeb.AgentChannelProcessingTest do
alias Towerops.AccountsFixtures
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.MikrotikResult
alias Towerops.Agent.MikrotikSentence
@ -288,23 +287,19 @@ defmodule ToweropsWeb.AgentChannelProcessingTest do
agent_token: agent_token,
device: device
} do
job = %AgentJob{
job_id: "backup:#{device.id}",
job_type: :MIKROTIK,
device_id: device.id
}
job_id = "backup:#{device.id}"
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token.id}:backup",
{:backup_requested, job}
{:backup_requested, device.id, job_id}
)
assert_push "backup_job", %{binary: jobs_binary}, 200
{:ok, decoded} = Base.decode64(jobs_binary)
{:ok, job_list} = AgentJobList.decode(decoded)
assert hd(job_list.jobs).job_id == "backup:#{device.id}"
assert hd(job_list.jobs).job_id == job_id
end
end

View file

@ -6,7 +6,6 @@ defmodule ToweropsWeb.AgentChannelTest do
alias Towerops.AccountsFixtures
alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.CheckResult, as: CheckResultProto
alias Towerops.Agent.CredentialTestResult
@ -771,17 +770,11 @@ defmodule ToweropsWeb.AgentChannelTest do
end
end
describe "handle_info {:backup_requested, job}" do
describe "handle_info {:backup_requested, device_id, job_id}" do
test "pushes backup job to agent", %{socket: socket, device: device} do
job = %AgentJob{
job_id: "backup:#{device.id}",
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: nil,
mikrotik_commands: []
}
job_id = "backup:#{device.id}"
send(socket.channel_pid, {:backup_requested, job})
send(socket.channel_pid, {:backup_requested, device.id, job_id})
assert_push "backup_job", %{binary: jobs_binary}
@ -789,7 +782,7 @@ defmodule ToweropsWeb.AgentChannelTest do
{:ok, job_list} = AgentJobList.decode(decoded_binary)
backup_job = hd(job_list.jobs)
assert backup_job.job_id == "backup:#{device.id}"
assert backup_job.job_id == job_id
assert backup_job.device_id == device.id
end
end
@ -2170,17 +2163,12 @@ defmodule ToweropsWeb.AgentChannelTest do
agent_token: agent_token,
device: device
} do
# Channel should be subscribed to "agent:#{agent_token.id}:backup"
job = %AgentJob{
job_id: "backup:#{device.id}",
job_type: :MIKROTIK,
device_id: device.id
}
job_id = "backup:#{device.id}"
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token.id}:backup",
{:backup_requested, job}
{:backup_requested, device.id, job_id}
)
assert_push "backup_job", %{binary: jobs_binary}, 200

View file

@ -36,11 +36,29 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookControllerTest do
describe "create/2 response" do
test "enqueues job and returns 200", %{conn: conn} do
conn = post(conn, ~p"/api/v1/webhooks/agent-release")
ts = :second |> System.system_time() |> to_string()
secret = Application.get_env(:towerops, :agent_webhook_secret)
raw_body = ""
sig =
:hmac
|> :crypto.mac(:sha256, secret, ts <> "." <> raw_body)
|> Base.encode16(case: :lower)
conn =
conn
|> put_req_header("x-agent-webhook-signature", "t=#{ts},v1=#{sig}")
|> post(~p"/api/v1/webhooks/agent-release")
assert json_response(conn, 200) == %{"status" => "ok"}
assert_enqueued(worker: AgentReleaseWebhookWorker)
end
test "returns 401 when signature header is missing", %{conn: conn} do
conn = post(conn, ~p"/api/v1/webhooks/agent-release")
assert json_response(conn, 401)["error"] == "Signature verification failed"
end
end
describe "check_timestamp/1" do

View file

@ -132,9 +132,10 @@ defmodule ToweropsWeb.Plugs.BruteForceProtectionTest do
|> BruteForceProtection.call([])
refute conn.halted
# No before_send callback registered for authenticated users — they
# bypass the 404 tracker entirely.
assert conn.private[:before_send] in [nil, []]
# before_send callback is registered but will skip tracking for
# authenticated users (auth check moved into the callback since
# this plug runs before authentication).
assert is_list(conn.private[:before_send])
end
test "passes through immediately for agent socket paths" do