Skip to content
Open
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9ac98ba
Initial work
dgboss Mar 2, 2026
d9471c5
Tests and tweaks
dgboss Mar 2, 2026
7edd3ef
formatting
dgboss Mar 2, 2026
189df46
minor updates and router tests
dgboss Mar 3, 2026
14990e6
ios
dgboss Mar 3, 2026
8fecac0
more ios
dgboss Mar 3, 2026
5fb287e
Remove unused file
dgboss Mar 3, 2026
6f91c6f
lint
dgboss Mar 3, 2026
833ec10
code quality
dgboss Mar 3, 2026
31da761
Clean up
dgboss Mar 3, 2026
bcae514
Remove android http
dgboss Mar 3, 2026
8997d37
Limit channel to android
dgboss Mar 3, 2026
24709f4
use idir
dgboss Mar 3, 2026
3a3033b
test fixes
dgboss Mar 3, 2026
949f1ac
Merge branch 'main' into task/fcm-tokens/4906
dgboss Mar 3, 2026
1c67610
PR feedback
dgboss Mar 4, 2026
b0222b3
Use enum, fix get_utc_now default
dgboss Mar 4, 2026
2459551
feedback
dgboss Mar 4, 2026
086862e
fcm crud tests
dgboss Mar 4, 2026
669b1c9
Guard service re-init
dgboss Mar 4, 2026
bcae983
fix cap config
dgboss Mar 4, 2026
9508c8e
fix cap config 2
dgboss Mar 4, 2026
16d0f99
Add auth to fcm routes
dgboss Mar 4, 2026
f484ac6
Remove unused interfaces
dgboss Mar 4, 2026
f668fe0
Additional tests
dgboss Mar 4, 2026
e51eb3e
Merge branch 'main' into task/fcm-tokens/4906
dgboss Mar 4, 2026
de523dd
Update gitignore
dgboss Mar 4, 2026
709be15
post instead of delete, secure endpoint tests
dgboss Mar 4, 2026
2664f47
Include device id
dgboss Mar 5, 2026
2fc8548
Test fixes
dgboss Mar 5, 2026
aec4720
Code smell
dgboss Mar 5, 2026
fcb8a26
Frontend device id
dgboss Mar 5, 2026
9b30978
feedback
dgboss Mar 5, 2026
d9ff808
Merge branch 'main' into task/fcm-tokens/4906
dgboss Mar 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,8 @@ Pods
**/local.bru

# hypothesis directories
.hypothesis/
.hypothesis/

# Push notification config
google-services.json
GoogleService-Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Push notification tokens
Revision ID: 7d2194c5051e
Revises: 0b46effaf3a1
Create Date: 2026-03-02 10:48:11.523814
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from wps_shared.db.models.common import TZTimeStamp

# revision identifiers, used by Alembic.
revision = '7d2194c5051e'
down_revision = '0b46effaf3a1'
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
"device_token",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("device_id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column(
"platform", postgresql.ENUM("android", "ios", name="platformenum"), nullable=False
),
sa.Column("token", sa.String(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", TZTimeStamp(), nullable=False),
sa.Column("updated_at", TZTimeStamp(), nullable=False),
sa.PrimaryKeyConstraint("id"),
comment="Device token management.",
)
op.create_index(op.f('ix_device_token_id'), 'device_token', ['id'], unique=False)
op.create_index(op.f("ix_device_token_device_id"), "device_token", ["device_id"], unique=False)
op.create_index(op.f('ix_device_token_platform'), 'device_token', ['platform'], unique=False)
op.create_index(op.f('ix_device_token_token'), 'device_token', ['token'], unique=True)


def downgrade():
op.drop_index(op.f('ix_device_token_token'), table_name='device_token')
op.drop_index(op.f('ix_device_token_platform'), table_name='device_token')
op.drop_index(op.f('ix_device_token_id'), table_name='device_token')
op.drop_table('device_token')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: downgrade also should drop the platformenum I think

17 changes: 17 additions & 0 deletions backend/packages/wps-api/src/app/fcm/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from typing import Optional

from pydantic import BaseModel, Field


class RegisterDeviceRequest(BaseModel):
user_id: Optional[str] = None
device_id: str
token: str = Field(..., min_length=10)
platform: str = Field(..., pattern="^(ios|android)$")

class UnregisterDeviceRequest(BaseModel):
token: str = Field(..., min_length=10)

class DeviceRequestResponse(BaseModel):
success: bool

4 changes: 3 additions & 1 deletion backend/packages/wps-api/src/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
morecast_v2,
snow,
fire_watch,
fcm,
)
from app.fire_behaviour.cffdrs import CFFDRS

Expand Down Expand Up @@ -122,7 +123,7 @@ async def catch_exception_middleware(request: Request, call_next):
CORSMiddleware,
allow_origins=ORIGINS,
allow_credentials=True,
allow_methods=["GET", "HEAD", "POST", "PATCH"],
allow_methods=["GET", "HEAD", "POST", "PATCH", "DELETE"],
allow_headers=["*"],
)
api.middleware("http")(catch_exception_middleware)
Expand All @@ -139,6 +140,7 @@ async def catch_exception_middleware(request: Request, call_next):
api.include_router(snow.router, tags=["SFMS Insights"])
api.include_router(fire_watch.router, tags=["Fire Watch"])
api.include_router(object_store_proxy.router, tags=["Object Store Proxy"])
api.include_router(fcm.router, tags=["Firebase Cloud Messaging"])


@api.get("/ready")
Expand Down
61 changes: 61 additions & 0 deletions backend/packages/wps-api/src/app/routers/fcm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import logging

from fastapi import APIRouter, Depends, HTTPException
from wps_shared.auth import asa_authentication_required, audit_asa
from wps_shared.db.crud.fcm import (
get_device_by_device_id,
save_device_token,
update_device_token_is_active,
)
from wps_shared.db.database import get_async_write_session_scope
from wps_shared.db.models.fcm import DeviceToken
from wps_shared.utils.time import get_utc_now

from app.fcm.schema import DeviceRequestResponse, RegisterDeviceRequest, UnregisterDeviceRequest

logger = logging.getLogger(__name__)

router = APIRouter(
prefix="/device",
dependencies=[Depends(asa_authentication_required), Depends(audit_asa)],
)


@router.post("/register")
async def register_device(request: RegisterDeviceRequest):
"""
Upsert a device token for a device_id.
"""
logger.info("/device/register")
async with get_async_write_session_scope() as session:
existing = await get_device_by_device_id(session, request.device_id)
if existing:
existing.is_active = True
existing.token = request.token
existing.updated_at = get_utc_now()
Comment on lines +32 to +35
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just thinking about work phones, and on the assumption that the device_id is always stable. Do we also want to update the user_id, in case the device gets passed on to a different person?

Copy link
Collaborator

@brettedw brettedw Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After doing a bit of reading I don't think we need to do this. If I understand Apple's page properly there would be a new device id if the phone was wiped and given to someone else
https://capacitorjs.com/docs/apis/device#deviceid

logger.info(f"Updated existing DeviceInfo record for token: {request.token}")
else:
device_token = DeviceToken(
user_id=request.user_id,
device_id=request.device_id,
token=request.token,
platform=request.platform,
is_active=True,
)
save_device_token(session, device_token)
logger.info("Successfully created new DeviceToken record.")
return DeviceRequestResponse(success=True)


@router.post("/unregister", responses={404: {"description": "Token not found."}})
async def unregister_device(request: UnregisterDeviceRequest):
"""
Mark a token inactive (e.g., user logged out or uninstalled).
"""
logger.info("/device/unregister")
async with get_async_write_session_scope() as session:
success = await update_device_token_is_active(session, request.token, False)
if not success:
logger.error(f"Could not find a record matching the provided token: {request.token}")
raise HTTPException(status_code=404, detail=f"Token not found: {request.token}")
return DeviceRequestResponse(success=True)
232 changes: 232 additions & 0 deletions backend/packages/wps-api/src/app/tests/fcm/test_fcm_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
"""Unit tests for FCM endpoints."""

from datetime import datetime
from unittest.mock import patch

import app.main
import pytest
from app.fcm.schema import RegisterDeviceRequest
from fastapi.testclient import TestClient
from wps_shared.db.models.fcm import PlatformEnum

DB_SESSION = "app.routers.fcm.get_async_write_session_scope"
GET_DEVICE_TOKEN = "app.routers.fcm.get_device_by_device_id"
SAVE_DEVICE_TOKEN = "app.routers.fcm.save_device_token"
API_DEVICE_REGISTER = "/api/device/register"
API_DEVICE_UNREGISTER = "/api/device/unregister"
MOCK_DEVICE_TOKEN = "abcdefghijklmonp"
TEST_REGISTER_DEVICE_REQUEST = {
"user_id": "test_idir",
"device_id": "test_device_id",
"token": MOCK_DEVICE_TOKEN,
"platform": PlatformEnum.android.value,
}
TEST_UNREGISTER_DEVICE_REQUEST = {"token": MOCK_DEVICE_TOKEN}


@pytest.fixture()
def client():
from app.main import app as test_app

with TestClient(test_app) as test_client:
yield test_client


@pytest.mark.usefixtures("mock_client_session")
@pytest.mark.parametrize(
"endpoint, payload",
[
(API_DEVICE_REGISTER, TEST_REGISTER_DEVICE_REQUEST),
(API_DEVICE_UNREGISTER, TEST_UNREGISTER_DEVICE_REQUEST),
],
)
def test_get_endpoints_unauthorized(endpoint: str, payload, client: TestClient):
"""Forbidden to get fire zone areas when unauthorized"""

response = client.post(endpoint, json=payload)
assert response.status_code == 401


@pytest.mark.usefixtures("mock_jwt_decode")
def test_register_device_success():
"""Test that device registration returns 200/OK."""
client = TestClient(app.main.app)

# Test data
request_data = {
"user_id": "test-user-123",
"device_id": "test_device_id",
"token": "test-fcm-token-456",
"platform": "android",
}

with patch(DB_SESSION) as mock_session_scope:
mock_session_scope.return_value.__aenter__.return_value
with (
patch(GET_DEVICE_TOKEN, return_value=None),
patch(SAVE_DEVICE_TOKEN),
):
response = client.post(API_DEVICE_REGISTER, json=request_data)

assert response.status_code == 200
assert response.json()["success"] == True
assert response.headers["content-type"] == "application/json"

@pytest.mark.usefixtures("mock_jwt_decode")
def test_register_device_already_exists():
"""Test that existing device registration updates successfully."""
client = TestClient(app.main.app)

request_data = {
"user_id": "test-user-123",
"device_id": "test_device_id",
"token": "existing-fcm-token",
"platform": "ios",
}

with patch(DB_SESSION) as mock_session_scope:
mock_session_scope.return_value.__aenter__.return_value

existing_device = type(
"",
(object,),
{
"is_active": False,
"device_id": "test_device_id",
"token": "existing-fcm-token",
"updated_at": datetime(2026, 1, 1),
},
)()

with (
patch(GET_DEVICE_TOKEN, return_value=existing_device),
patch(SAVE_DEVICE_TOKEN) as mock_save,
):
response = client.post(API_DEVICE_REGISTER, json=request_data)

assert response.status_code == 200
assert response.json()["success"] == True
assert existing_device.is_active == True # Should be updated
mock_save.assert_not_called() # Should not call save for existing device

@pytest.mark.usefixtures("mock_jwt_decode")
def test_register_device_missing_fields():
"""Test that missing fields in registration request returns 422."""
client = TestClient(app.main.app)

# Missing 'token' field which is required
request_data = {"user_id": "test-user-123", "platform": "android"}

with patch(DB_SESSION) as mock_session_scope:
mock_session_scope.return_value.__aenter__.return_value

response = client.post(API_DEVICE_REGISTER, json=request_data)

assert response.status_code == 422

@pytest.mark.usefixtures("mock_jwt_decode")
def test_register_device_invalid_platform():
"""Test that invalid platform returns 422."""
client = TestClient(app.main.app)

request_data = {
"user_id": "test-user-123",
"token": "test-fcm-token",
"platform": "invalid-platform",
}

with patch(DB_SESSION) as mock_session_scope:
mock_session_scope.return_value.__aenter__.return_value

response = client.post(API_DEVICE_REGISTER, json=request_data)

assert response.status_code == 422

@pytest.mark.usefixtures("mock_jwt_decode")
def test_register_device_short_token():
"""Test that short token returns 422."""
client = TestClient(app.main.app)

request_data = {
"user_id": "test-user-123",
"token": "short", # Less than 10 characters
"platform": "android",
"device_id": "test_device_id",
}

with patch(DB_SESSION) as mock_session_scope:
mock_session_scope.return_value.__aenter__.return_value

response = client.post(API_DEVICE_REGISTER, json=request_data)

assert response.status_code == 422


@pytest.mark.usefixtures("mock_jwt_decode")
def test_missing_device_id_returns_422():
"""Test that a missing device_id returns 422."""
client = TestClient(app.main.app)

request_data = {
"user_id": "test-user-123",
"token": "short", # Less than 10 characters
"platform": "android",
}

with patch(DB_SESSION) as mock_session_scope:
mock_session_scope.return_value.__aenter__.return_value

response = client.post(API_DEVICE_REGISTER, json=request_data)

assert response.status_code == 422

@pytest.mark.usefixtures("mock_jwt_decode")
def test_unregister_device_success():
"""Test that device unregistration returns 200/OK."""
client = TestClient(app.main.app)

request_data = {"token": "test-fcm-token-456"}

with patch("app.routers.fcm.get_async_write_session_scope") as mock_session_scope:
mock_session_scope.return_value.__aenter__.return_value
with patch("app.routers.fcm.update_device_token_is_active"):
response = client.post("/api/device/unregister", json=request_data)

assert response.status_code == 200
assert response.json()["success"] == True

@pytest.mark.usefixtures("mock_jwt_decode")
def test_unregister_device_missing_token():
"""Test that missing token field returns 422."""
client = TestClient(app.main.app)

request_data = {}

with patch(DB_SESSION) as mock_session_scope:
mock_session_scope.return_value.__aenter__.return_value

response = client.post("/api/device/unregister", json=request_data)

assert response.status_code == 422

@pytest.mark.usefixtures("mock_jwt_decode")
def test_register_device_without_user_id():
"""Test that device registration without user_id is allowed (null user)."""
client = TestClient(app.main.app)

request_data = {
"token": "test-fcm-token-789",
"platform": "android",
"device_id": "test_device_id",
}

with patch(DB_SESSION) as mock_session_scope:
mock_session_scope.return_value.__aenter__.return_value
with (
patch(GET_DEVICE_TOKEN, return_value=None),
patch(SAVE_DEVICE_TOKEN),
):
response = client.post(API_DEVICE_REGISTER, json=request_data)

assert response.status_code == 200
assert response.json()["success"] == True
Loading
Loading