Add activity push notifications and registration patches

This commit is contained in:
2026-09-15 08:31:45 +02:00
parent 7c801dfddd
commit 55174684af
38 changed files with 1096 additions and 42 deletions
+64
View File
@@ -0,0 +1,64 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '../static/js/native-push.js'), 'utf8');
const id = '12345678-1234-1234-1234-123456789abc';
const tag = 'a'.repeat(64);
async function setup(options={}) {
const listeners = {}, navigations = [], elements = [];
const session = {authenticated:true, session_tag:tag, ...options.session};
const config = {textContent:JSON.stringify({openNotification:'Open new notification'})};
const main = {prepend(node) { elements.push(node); }};
const document = {
getElementById(name) { return name === 'native-push-config' ? config : null; },
querySelector(name) { return name === 'main' ? main : null; },
createElement(type) { return {type,children:[],append(node){this.children.push(node);},setAttribute(){}}; },
addEventListener() {}, body:main,
};
const push = {
addListener(name, callback) { listeners[name]=callback; return Promise.resolve(); },
checkPermissions:async()=>({receive:'granted'}), register:async()=>{},
};
const device = {getInfo:async()=>({deviceId:id,appVersion:'1.1.0',binding:options.binding ?? tag}), prepareSession:async()=>{}};
vm.runInNewContext(source, {document, window:{Capacitor:{getPlatform:()=> 'android',Plugins:{PushNotifications:push,MetalCircleDevice:device}},addEventListener(){}},
location:{pathname:'/',assign(value){navigations.push(value);}},
fetch:async()=>({ok:true,json:async()=>session}), localStorage:{getItem(){return 'seen';}}});
await new Promise(resolve=>setImmediate(resolve));
return {listeners,navigations,elements};
}
test('tap opens only backend-resolved destination for the matching session',async()=>{
const app=await setup();
await app.listeners.pushNotificationActionPerformed({notification:{data:{notification_id:id,session_tag:tag,url:'https://evil.invalid'}}});
assert.deepEqual(app.navigations,['/notifications/'+id]);
});
test('old-account push cannot navigate after user switch',async()=>{
const app=await setup({session:{session_tag:'b'.repeat(64)}});
await app.listeners.pushNotificationActionPerformed({notification:{data:{notification_id:id,session_tag:tag}}});
assert.deepEqual(app.navigations,[]);
});
test('logout and native binding mismatch cannot open a notification',async()=>{
for(const options of [{session:{authenticated:false}}, {binding:''}]) {
const app=await setup(options);
await app.listeners.pushNotificationActionPerformed({notification:{data:{notification_id:id,session_tag:tag}}});
assert.deepEqual(app.navigations,[]);
}
});
test('arbitrary URL and malformed identifier are ignored',async()=>{
const app=await setup();
for(const data of [{url:'https://evil.invalid'}, {notification_id:'../../profile',session_tag:tag}])
await app.listeners.pushNotificationActionPerformed({notification:{data}});
assert.deepEqual(app.navigations,[]);
});
test('foreground hint uses local text and never remote HTML',async()=>{
const app=await setup();
await app.listeners.pushNotificationReceived({body:'<script>private message</script>',data:{notification_id:id,session_tag:tag}});
assert.equal(app.elements.length,1);
const link=app.elements[0].children[0];
assert.equal(link.textContent,'Open new notification');
assert.equal(link.href,'/notifications/'+id);
assert.equal(link.innerHTML,undefined);
});
+7 -2
View File
@@ -34,12 +34,16 @@ class FeatureApiTests(unittest.TestCase):
with main.get_db_connection() as db:
db.execute('''
CREATE TABLE users(id SERIAL PRIMARY KEY, username TEXT UNIQUE, email TEXT UNIQUE,
display_name TEXT, password_hash TEXT, is_admin BOOLEAN DEFAULT FALSE);
display_name TEXT, password_hash TEXT, is_admin BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE sessions(id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT UNIQUE, expires_at TIMESTAMP);
CREATE TABLE friendships(addressee_id INTEGER, status TEXT);
CREATE TABLE direct_messages(recipient_id INTEGER, read_at TIMESTAMP);
CREATE TABLE event_invitations(user_id INTEGER, viewed_at TIMESTAMP);
CREATE TABLE user_badges(user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
badge_code TEXT, awarded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(user_id,badge_code));
''')
for statement in FEATURE_SCHEMA: db.execute(statement)
cls.password_hash = bcrypt.hashpw(b'Test-only-password-123', bcrypt.gensalt()).decode()
@@ -81,7 +85,8 @@ class FeatureApiTests(unittest.TestCase):
def test_migrations_repeat_and_match_startup_schema(self):
with main.get_db_connection() as db:
for name, runtime in zip(('19_push_devices.sql', '20_bug_report_submissions.sql'), FEATURE_SCHEMA):
for name, runtime in zip(('19_push_devices.sql', '20_bug_report_submissions.sql',
'21_push_notifications.sql', '22_registration_badges.sql'), FEATURE_SCHEMA):
source = Path('/test-migrations', name).read_text()
normalize = lambda s: re.sub(r'\s+', '', re.sub(r'--[^\n]*', '', s))
self.assertEqual(normalize(source), normalize(runtime))
+3 -2
View File
@@ -69,7 +69,7 @@ class TranslationTests(unittest.TestCase):
for path, title in [('/datenschutz', 'Privacy policy'), ('/impressum', 'Legal notice')]:
page = client.get(path)
self.assertIn(title, page.text)
self.assertIn('aria-label="English" aria-current="true"', page.text)
self.assertIn('aria-label="Switch to German"', page.text)
response = client.get('/language/de?next=/login')
self.assertIn('Anmelden', response.text)
self.assertIn('<html lang="de">', response.text)
@@ -136,7 +136,8 @@ class TranslationTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'Instagram') as error:
main.normalize_instagram_url('https://evil.example/test')
self.assertIn('Please enter', str(error.exception))
response = main.change_language('invalid')
from starlette.requests import Request
response = main.change_language(Request({'type': 'http', 'headers': []}), 'invalid')
self.assertEqual(response.body, b'Invalid language.')
+334
View File
@@ -0,0 +1,334 @@
"""Local PostgreSQL and simulated Firebase tests. Never contacts Firebase."""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
import hashlib
import os
from pathlib import Path
import unittest
from unittest.mock import Mock, patch
from uuid import uuid4
from zoneinfo import ZoneInfo
import bcrypt
import psycopg
from psycopg import sql
from psycopg.conninfo import make_conninfo
from fastapi.testclient import TestClient
import main
from community_badges import cohort, reconcile
from i18n import current_language
from notifications import DeliveryError, FirebaseSender, PushWorker, enqueue, save_language
class BadgeAndLanguageTests(unittest.TestCase):
def test_registration_boundaries_and_timezone(self):
with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-10-31', 'BETA_TESTER_UNTIL':'2026-12-31'}):
for value, expected in (
(datetime(2026, 10, 31, 23, 59, 59), 'alpha_tester'),
(datetime(2026, 11, 1), 'beta_tester'),
(datetime(2026, 12, 31, 23, 59, 59), 'beta_tester'),
(datetime(2027, 1, 1), 'early_bird'),
(datetime(2026, 10, 31, 23, tzinfo=ZoneInfo('UTC')), 'beta_tester')):
self.assertEqual(cohort(value), expected)
def test_configurable_dates_and_invalid_order(self):
with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-11-30', 'BETA_TESTER_UNTIL':'2027-01-31'}):
self.assertEqual(cohort(datetime(2026, 11, 15)), 'alpha_tester')
self.assertEqual(cohort(datetime(2027, 1, 1)), 'beta_tester')
with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2027-02-01', 'BETA_TESTER_UNTIL':'2027-01-31'}):
with self.assertRaises(ValueError): cohort(datetime(2026, 1, 1))
def test_one_language_link_targets_opposite_language(self):
import re
for language, target in [('de', 'en'), ('en', 'de')]:
token = current_language.set(language)
try:
html = main.templates.get_template('_language_switch.html').render()
finally:
current_language.reset(token)
self.assertEqual(len(re.findall(r'<a\s', html)), 1)
self.assertIn('/language/' + target, html)
self.assertIn('>' + target.upper() + '</a>', html)
def test_sender_missing_credentials_is_safe(self):
with patch.dict(os.environ, {'GOOGLE_APPLICATION_CREDENTIALS':'', 'FIREBASE_PROJECT_ID':''}):
with self.assertRaisesRegex(DeliveryError, '^configuration$'):
FirebaseSender().send('secret-token', 'title', 'body', {}, 'tag')
def test_sdk_payload_errors_and_no_credentials_in_payload(self):
from firebase_admin import messaging, exceptions
sender = FirebaseSender()
sender.app = object()
with patch.object(messaging, 'send') as send:
sender.send('synthetic-token', 'New message', 'You have a new message.',
{'notification_id':str(uuid4()), 'session_tag':'a'*64}, 'tag')
payload = send.call_args.args[0]
self.assertEqual(payload.android.notification.visibility, 'private')
self.assertEqual(payload.android.ttl.total_seconds(), 300)
self.assertEqual(payload.notification.body, 'You have a new message.')
for failure, expected in ((messaging.UnregisteredError('sensitive'), 'unregistered'),
(exceptions.UnavailableError('sensitive'), 'transient'),
(exceptions.PermissionDeniedError('sensitive'), 'configuration')):
with patch.object(messaging, 'send', side_effect=failure):
with self.assertRaisesRegex(DeliveryError, '^' + expected + '$'):
sender.send('secret-token', 'title', 'body', {}, 'tag')
@unittest.skipUnless(os.environ.get('METALCIRCLE_TEST_DATABASE') == '1', 'explicit local DB opt-in required')
class NotificationDatabaseTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.original_dsn = main.DATABASE_URL
cls.schema = 'metalcircle_push_test_' + uuid4().hex
with psycopg.connect(cls.original_dsn) as db:
db.execute(sql.SQL('CREATE SCHEMA {}').format(sql.Identifier(cls.schema)))
main.DATABASE_URL = make_conninfo(cls.original_dsn, options='-csearch_path='+cls.schema)
with main.get_db_connection() as db:
db.execute(Path('/test-init/01_initial.sql').read_text())
with patch.object(main, 'INITIAL_ADMIN_USERNAME', None): main.ensure_schema()
cls.password_hash = bcrypt.hashpw(b'Push-local-test-123', bcrypt.gensalt()).decode()
@classmethod
def tearDownClass(cls):
main.DATABASE_URL = cls.original_dsn
with psycopg.connect(cls.original_dsn) as db:
db.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(cls.schema)))
def setUp(self):
self.flags = patch.dict(os.environ, {'PUSH_ENABLED':'true', 'ALPHA_TESTER_UNTIL':'2026-10-31', 'BETA_TESTER_UNTIL':'2026-12-31'})
self.flags.start()
self.secure = patch.object(main, 'COOKIE_SECURE', False)
self.secure.start()
main.rate_limit_buckets.clear()
with main.get_db_connection() as db:
db.execute('TRUNCATE users,concerts RESTART IDENTITY CASCADE')
for name in ('sender', 'recipient', 'outsider'):
db.execute('INSERT INTO users(username,email,password_hash,created_at) VALUES (%s,%s,%s,%s)',
(name, name+'@example.invalid', self.password_hash, datetime(2026, 9, 1)))
self.clients = []
for name in ('sender', 'recipient', 'outsider'):
client = TestClient(main.app)
client.headers['Origin'] = 'http://testserver'
self.assertEqual(client.post('/login', data={'username':name, 'password':'Push-local-test-123'}, follow_redirects=False).status_code, 303)
self.clients.append(client)
self.device = dict(device_id=str(uuid4()), token='synthetic-fcm-token-'+'x'*120, platform='android',
session_tag=self.clients[1].get('/api/push/session').json()['session_tag'])
self.assertEqual(self.clients[1].post('/api/push/devices', json=self.device).status_code, 200)
self.sender = Mock()
self.worker = PushWorker(main.get_db_connection, self.sender)
def tearDown(self):
for client in self.clients: client.close()
self.secure.stop()
self.flags.stop()
def scalar(self, query, args=()):
with main.get_db_connection() as db: return db.execute(query, args).fetchone()[0]
def friendship(self):
result = self.clients[0].post('/users/recipient/friend-request', follow_redirects=False)
self.assertEqual(result.status_code, 303)
def message(self):
with main.get_db_connection() as db:
db.execute("INSERT INTO friendships(requester_id,addressee_id,status) VALUES (1,2,'accepted') ON CONFLICT DO NOTHING")
result = self.clients[0].post('/messages/recipient', data={'body':'PRIVATE message content'}, follow_redirects=False)
self.assertEqual(result.status_code, 303)
def invitation(self):
result = self.clients[0].post('/concerts', data={'artist':'Private test event', 'start_datetime':'2027-04-01T20:00',
'event_type':'other','visibility':'private','invited_user_ids':'2'}, follow_redirects=False)
self.assertEqual(result.status_code, 303)
return int(result.headers['location'].rsplit('/', 1)[1])
def test_friend_event_deduplicated_and_english_recipient(self):
self.clients[1].get('/language/en', follow_redirects=False)
self.friendship()
self.friendship()
self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 1)
self.assertTrue(self.worker.deliver_one())
args = self.sender.send.call_args.args
self.assertEqual(args[1], 'New friend request')
self.assertNotIn('PRIVATE', str(args))
target = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
self.assertEqual(target.headers['location'], '/users/sender')
denied = self.clients[2].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
self.assertEqual(denied.headers['location'], '/')
def test_message_route_and_private_content(self):
self.message()
self.worker.deliver_one()
args = self.sender.send.call_args.args
self.assertEqual(args[1:3], ('Neue Nachricht', 'Du hast eine neue Nachricht.'))
self.assertNotIn('PRIVATE message content', str(args))
response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
self.assertEqual(response.headers['location'], '/messages/sender#latest')
def test_invitation_route_and_removed_invitation(self):
concert = self.invitation()
self.worker.deliver_one()
args = self.sender.send.call_args.args
response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
self.assertEqual(response.headers['location'], '/concerts/'+str(concert))
with main.get_db_connection() as db: db.execute('DELETE FROM event_invitations')
response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
self.assertEqual(response.headers['location'], '/')
def test_disabled_sender_has_no_backlog(self):
with patch.dict(os.environ, {'PUSH_ENABLED':'false'}): self.friendship()
self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0)
def test_invitation_edit_only_notifies_new_invitees(self):
concert = self.invitation()
form = {'artist':'Private test event','start_datetime':'2027-04-01T20:00',
'event_type':'other','visibility':'private','invited_user_ids':'2'}
result = self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False)
self.assertEqual(result.status_code, 303)
self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='pending'"), 1)
form.pop('invited_user_ids')
self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False)
form['invited_user_ids'] = '2'
self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False)
self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='pending'"), 1)
def test_profile_preferences_and_alpha_render_in_both_languages(self):
for language, label in [('de', 'Push-Benachrichtigungen'), ('en', 'Push notifications')]:
self.clients[1].get('/language/'+language, follow_redirects=False)
response = self.clients[1].get('/profile')
self.assertEqual(response.status_code, 200)
self.assertIn(label, response.text)
self.assertIn('patch-alpha-tester.svg', response.text)
self.assertNotIn('id="patch-beta_tester"', response.text)
exported = self.clients[1].get('/profile/export')
self.assertEqual(exported.status_code, 200)
self.assertEqual(exported.json()['notification_preferences']['language'], language)
def test_enqueue_rolls_back_with_domain_transaction(self):
try:
with main.get_db_connection() as db:
request_id = db.execute('INSERT INTO friendships(requester_id,addressee_id) VALUES (1,2) RETURNING id').fetchone()[0]
enqueue(db.cursor(), 'friend_request', 1, 2, request_id)
raise RuntimeError('simulated rollback')
except RuntimeError:
pass
self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0)
self.assertEqual(self.scalar('SELECT count(*) FROM friendships'), 0)
def test_preference_opt_out_and_csrf_auth(self):
unauth = TestClient(main.app)
self.assertEqual(unauth.post('/profile/notifications', follow_redirects=False).status_code, 303)
self.assertEqual(self.clients[1].post('/profile/notifications', headers={'Origin':'http://evil.invalid'}).status_code, 403)
self.friendship()
result = self.clients[1].post('/profile/notifications', data={'direct_message':'true'}, follow_redirects=False)
self.assertEqual(result.status_code, 303)
self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='dropped'"), 1)
self.assertFalse(self.worker.deliver_one())
self.sender.send.assert_not_called()
def test_disabled_category_never_enqueues(self):
self.clients[1].post('/profile/notifications', data={}, follow_redirects=False)
self.friendship()
self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0)
def test_logout_cancels_queue_and_switch_does_not_receive_old_push(self):
self.friendship()
self.clients[1].post('/logout', follow_redirects=False)
self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0)
self.assertFalse(self.worker.deliver_one())
self.sender.send.assert_not_called()
def test_token_rotation_invalidates_old_delivery(self):
self.friendship()
self.device['token'] = 'synthetic-fcm-token-'+'y'*120
self.clients[1].post('/api/push/devices', json=self.device)
self.worker.deliver_one()
self.sender.send.assert_not_called()
self.assertEqual(self.scalar('SELECT state FROM push_notifications'), 'dropped')
def test_multiple_devices_each_receive_once(self):
another = dict(self.device, device_id=str(uuid4()), token='synthetic-fcm-token-'+'z'*120)
self.clients[1].post('/api/push/devices', json=another)
self.friendship()
self.worker.deliver_one()
self.worker.deliver_one()
self.assertEqual(self.sender.send.call_count, 2)
self.assertFalse(self.worker.deliver_one())
def test_read_message_block_and_removed_friendship_drop_pending(self):
for operation in ('read', 'block', 'unfriend'):
with self.subTest(operation=operation):
self.message()
with main.get_db_connection() as db:
if operation == 'read': db.execute('UPDATE direct_messages SET read_at=CURRENT_TIMESTAMP')
elif operation == 'block': db.execute('INSERT INTO user_blocks(blocker_id,blocked_id) VALUES (2,1)')
else: db.execute('DELETE FROM friendships')
self.worker.deliver_one()
self.sender.send.assert_not_called()
with main.get_db_connection() as db: db.execute('DELETE FROM user_blocks')
def test_retry_limit_and_safe_logging(self):
self.friendship()
self.sender.send.side_effect = DeliveryError('transient')
with self.assertLogs('notifications', 'WARNING') as logs:
for attempt in range(4):
self.worker.deliver_one()
with main.get_db_connection() as db:
db.execute('UPDATE push_notifications SET available_at=CURRENT_TIMESTAMP')
self.assertNotIn(self.device['token'], str(logs.output))
self.assertEqual(self.scalar('SELECT attempts FROM push_notifications'), 4)
self.assertEqual(self.scalar('SELECT state FROM push_notifications'), 'failed')
self.assertEqual(self.clients[1].get('/impressum').status_code, 200)
def test_unregistered_removes_device_but_configuration_error_does_not(self):
self.friendship()
self.sender.send.side_effect = DeliveryError('configuration')
self.worker.deliver_one()
self.assertEqual(self.scalar('SELECT count(*) FROM push_devices'), 1)
with main.get_db_connection() as db: db.execute('UPDATE push_notifications SET available_at=CURRENT_TIMESTAMP')
self.sender.send.side_effect = DeliveryError('unregistered')
self.worker.deliver_one()
self.assertEqual(self.scalar('SELECT count(*) FROM push_devices'), 0)
def test_expired_job_and_expired_session_are_dropped(self):
self.friendship()
with main.get_db_connection() as db: db.execute("UPDATE push_notifications SET expires_at=CURRENT_TIMESTAMP-INTERVAL '1 second'")
self.worker.deliver_one()
self.sender.send.assert_not_called()
with main.get_db_connection() as db:
db.execute("UPDATE push_notifications SET expires_at=CURRENT_TIMESTAMP+INTERVAL '1 hour',state='pending'")
db.execute("UPDATE sessions SET expires_at=CURRENT_TIMESTAMP-INTERVAL '1 second' WHERE user_id=2")
self.worker.deliver_one()
self.sender.send.assert_not_called()
def test_two_workers_do_not_send_same_job_twice(self):
self.friendship()
workers = [PushWorker(main.get_db_connection, self.sender) for _ in range(2)]
with ThreadPoolExecutor(max_workers=2) as pool:
list(pool.map(lambda worker: worker.deliver_one(), workers))
self.assertEqual(self.sender.send.call_count, 1)
def test_backfill_is_exclusive_idempotent_and_reconfigurable(self):
with main.get_db_connection() as db:
db.execute("INSERT INTO user_badges(user_id,badge_code) VALUES (1,'beta_tester')")
db.execute("UPDATE users SET created_at='2026-11-01' WHERE id=2")
db.execute("UPDATE users SET created_at='2027-01-01' WHERE id=3")
reconcile(db.cursor())
reconcile(db.cursor())
rows = db.execute('SELECT user_id,badge_code FROM user_badges ORDER BY user_id').fetchall()
self.assertEqual(rows, [(1,'alpha_tester'),(2,'beta_tester'),(3,'early_bird')])
with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-11-30'}): reconcile(db.cursor())
self.assertEqual(db.execute('SELECT badge_code FROM user_badges WHERE user_id=2').fetchone()[0], 'alpha_tester')
def test_db_utc_registration_respects_berlin_midnight(self):
with main.get_db_connection() as db:
db.execute("SET LOCAL TIME ZONE 'UTC'")
db.execute("UPDATE users SET created_at='2026-10-31 22:59:59' WHERE id=1")
db.execute("UPDATE users SET created_at='2026-10-31 23:00:00' WHERE id=2")
db.execute("UPDATE users SET created_at='2026-12-31 23:00:00' WHERE id=3")
reconcile(db.cursor())
rows = db.execute('SELECT user_id,badge_code FROM user_badges ORDER BY user_id').fetchall()
self.assertEqual(rows, [(1,'alpha_tester'),(2,'beta_tester'),(3,'early_bird')])
if __name__ == '__main__': unittest.main()