Prepare isolated Firebase push configuration for pre-production
This commit is contained in:
@@ -4,6 +4,8 @@ secrets/
|
||||
**/*service-account*.json
|
||||
**/*service_account*.json
|
||||
**/*firebase-adminsdk*.json
|
||||
**/firebase-push-*.json
|
||||
**/google-services.json
|
||||
*.pem
|
||||
*.key
|
||||
private_uploads/
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Offline credential checks inside the configured container; never sends a push."""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
|
||||
from firebase_admin import credentials
|
||||
|
||||
from notifications import enabled
|
||||
|
||||
|
||||
class PreflightError(Exception):
|
||||
"""Only fixed diagnostic codes, never credential/SDK exception contents."""
|
||||
|
||||
|
||||
def check(expected_service_account):
|
||||
if not enabled():
|
||||
raise PreflightError('push_disabled')
|
||||
project = os.environ.get('FIREBASE_PROJECT_ID', '')
|
||||
if not project:
|
||||
raise PreflightError('project_missing')
|
||||
path = Path(os.environ.get('GOOGLE_APPLICATION_CREDENTIALS', ''))
|
||||
if str(path) != '/run/secrets/firebase-service-account.json':
|
||||
raise PreflightError('container_path_mismatch')
|
||||
try:
|
||||
info = path.stat()
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise PreflightError('credential_not_a_file')
|
||||
if info.st_mode & 0o077:
|
||||
raise PreflightError('credential_permissions_too_broad')
|
||||
if not os.statvfs(path).f_flag & os.ST_RDONLY:
|
||||
raise PreflightError('credential_mount_not_read_only')
|
||||
credential = credentials.Certificate(str(path))
|
||||
except PreflightError:
|
||||
raise
|
||||
except Exception:
|
||||
raise PreflightError('credential_missing_unreadable_or_invalid') from None
|
||||
if credential.project_id != project:
|
||||
raise PreflightError('credential_project_mismatch')
|
||||
if credential.service_account_email != expected_service_account:
|
||||
raise PreflightError('credential_service_account_mismatch')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--expected-service-account', required=True,
|
||||
help='Expected service-account email, not a token or key')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
check(args.expected_service_account)
|
||||
except PreflightError as error:
|
||||
print('FAIL: ' + str(error))
|
||||
return 1
|
||||
print('PASS: push enabled; read-only credential; project and service account match. '
|
||||
'No network request; IAM and delivery still require a smoke test.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -8,7 +8,7 @@ const id = '12345678-1234-1234-1234-123456789abc';
|
||||
const tag = 'a'.repeat(64);
|
||||
|
||||
async function setup(options={}) {
|
||||
const listeners = {}, navigations = [], elements = [];
|
||||
const listeners = {}, navigations = [], elements = [], requests = [];
|
||||
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); }};
|
||||
@@ -25,11 +25,22 @@ async function setup(options={}) {
|
||||
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';}}});
|
||||
fetch:async(url,options)=>{requests.push({url,options});return {ok:true,json:async()=>session};}, localStorage:{getItem(){return 'seen';}}});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
return {listeners,navigations,elements};
|
||||
return {listeners,navigations,elements,requests};
|
||||
}
|
||||
|
||||
test('registration uses the loaded backend origin and its authenticated session',async()=>{
|
||||
const app=await setup();
|
||||
await app.listeners.registration({value:'synthetic-device-token'});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const sent=app.requests.find(request=>request.url==='/api/push/devices');
|
||||
assert.ok(sent);
|
||||
assert.equal(sent.options.credentials,'same-origin');
|
||||
assert.equal(JSON.parse(sent.options.body).session_tag,tag);
|
||||
assert.ok(app.requests.every(request=>request.url.startsWith('/api/push/')));
|
||||
});
|
||||
|
||||
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'}}});
|
||||
|
||||
@@ -40,17 +40,42 @@ class BadgeAndLanguageTests(unittest.TestCase):
|
||||
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')]:
|
||||
def test_language_dropdown_offers_both_languages_and_marks_current(self):
|
||||
for language in ('de', 'en'):
|
||||
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)
|
||||
self.assertEqual(len(re.findall(r'<a\s', html)), 2)
|
||||
for target in ('de', 'en'):
|
||||
self.assertIn('/language/' + target, html)
|
||||
self.assertRegex(html, rf'<a[^>]*lang="{language}"[^>]*aria-current="true"')
|
||||
self.assertEqual(html.count('aria-current="true"'), 1)
|
||||
|
||||
def test_all_push_kinds_share_generic_android_payload_and_unique_tags(self):
|
||||
from firebase_admin import messaging
|
||||
from notifications import TEXT
|
||||
sender = FirebaseSender()
|
||||
sender.app = object()
|
||||
tags = set()
|
||||
for kind, (title, body) in TEXT.items():
|
||||
with self.subTest(kind=kind), patch.object(messaging, 'send') as send:
|
||||
identifier = str(uuid4())
|
||||
sender.send('synthetic-token', title, body,
|
||||
{'notification_id': identifier, 'session_tag': 'a' * 64}, identifier)
|
||||
payload = send.call_args.args[0]
|
||||
self.assertEqual(payload.notification.title, title)
|
||||
self.assertEqual(payload.notification.body, body)
|
||||
self.assertEqual(set(payload.data), {'notification_id', 'session_tag'})
|
||||
self.assertEqual(payload.android.notification.tag, identifier)
|
||||
self.assertIsNone(payload.android.notification.channel_id)
|
||||
self.assertIsNone(payload.android.notification.click_action)
|
||||
self.assertEqual(payload.android.priority, 'high')
|
||||
self.assertEqual(payload.android.notification.sound, 'default')
|
||||
self.assertEqual(payload.android.notification.icon, 'ic_notification')
|
||||
tags.add(identifier)
|
||||
self.assertEqual(len(tags), 3)
|
||||
|
||||
def test_sender_missing_credentials_is_safe(self):
|
||||
with patch.dict(os.environ, {'GOOGLE_APPLICATION_CREDENTIALS':'', 'FIREBASE_PROJECT_ID':''}):
|
||||
@@ -166,6 +191,20 @@ class NotificationDatabaseTests(unittest.TestCase):
|
||||
response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
|
||||
self.assertEqual(response.headers['location'], '/messages/sender#latest')
|
||||
|
||||
def test_opening_chat_before_delivery_drops_push_and_read_tap_returns_home(self):
|
||||
self.message()
|
||||
self.assertEqual(self.clients[1].get('/messages/sender').status_code, 200)
|
||||
self.worker.deliver_one()
|
||||
self.sender.send.assert_not_called()
|
||||
self.assertEqual(self.scalar('SELECT state FROM push_notifications'), 'dropped')
|
||||
self.message()
|
||||
self.worker.deliver_one()
|
||||
self.sender.send.assert_called_once()
|
||||
identifier = self.sender.send.call_args.args[3]['notification_id']
|
||||
self.clients[1].get('/messages/sender')
|
||||
response = self.clients[1].get('/notifications/' + identifier, follow_redirects=False)
|
||||
self.assertEqual(response.headers['location'], '/')
|
||||
|
||||
def test_invitation_route_and_removed_invitation(self):
|
||||
concert = self.invitation()
|
||||
self.worker.deliver_one()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Synthetic offline checks; no key files or Firebase requests are needed."""
|
||||
import os
|
||||
import stat
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import push_preflight
|
||||
|
||||
|
||||
class PushPreflightTests(unittest.TestCase):
|
||||
account = 'metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com'
|
||||
|
||||
def setUp(self):
|
||||
env = patch.dict(os.environ, {
|
||||
'PUSH_ENABLED': 'true', 'FIREBASE_PROJECT_ID': 'metalcircle-30d9b',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS': '/run/secrets/firebase-service-account.json',
|
||||
})
|
||||
env.start()
|
||||
self.addCleanup(env.stop)
|
||||
for target, value in (
|
||||
('push_preflight.Path.stat', SimpleNamespace(st_mode=stat.S_IFREG | 0o600)),
|
||||
('push_preflight.os.statvfs', SimpleNamespace(f_flag=os.ST_RDONLY)),
|
||||
('push_preflight.credentials.Certificate', SimpleNamespace(
|
||||
project_id='metalcircle-30d9b', service_account_email=self.account)),
|
||||
):
|
||||
mock = patch(target, return_value=value)
|
||||
mock.start()
|
||||
self.addCleanup(mock.stop)
|
||||
|
||||
def test_expected_read_only_preprod_credential_passes(self):
|
||||
push_preflight.check(self.account)
|
||||
|
||||
def test_rejects_local_account_even_in_the_same_project(self):
|
||||
with patch('push_preflight.credentials.Certificate', return_value=SimpleNamespace(
|
||||
project_id='metalcircle-30d9b',
|
||||
service_account_email='metalcircle-push-local@metalcircle-30d9b.iam.gserviceaccount.com',
|
||||
)):
|
||||
with self.assertRaisesRegex(push_preflight.PreflightError, '^credential_service_account_mismatch$'):
|
||||
push_preflight.check(self.account)
|
||||
|
||||
def test_rejects_wrong_project(self):
|
||||
with patch.dict(os.environ, {'FIREBASE_PROJECT_ID': 'wrong-project'}):
|
||||
with self.assertRaisesRegex(push_preflight.PreflightError, '^credential_project_mismatch$'):
|
||||
push_preflight.check(self.account)
|
||||
|
||||
def test_rejects_missing_config_or_disabled_push(self):
|
||||
for values, code in (
|
||||
({'PUSH_ENABLED': 'false'}, 'push_disabled'),
|
||||
({'FIREBASE_PROJECT_ID': ''}, 'project_missing'),
|
||||
({'GOOGLE_APPLICATION_CREDENTIALS': '/app/key.json'}, 'container_path_mismatch'),
|
||||
):
|
||||
with self.subTest(code=code), patch.dict(os.environ, values):
|
||||
with self.assertRaisesRegex(push_preflight.PreflightError, '^' + code + '$'):
|
||||
push_preflight.check(self.account)
|
||||
|
||||
def test_rejects_broad_permissions_and_directory_mounts(self):
|
||||
for mode, code in (
|
||||
(stat.S_IFREG | 0o644, 'credential_permissions_too_broad'),
|
||||
(stat.S_IFDIR | 0o700, 'credential_not_a_file'),
|
||||
):
|
||||
with self.subTest(mode=mode), patch('push_preflight.Path.stat', return_value=SimpleNamespace(st_mode=mode)):
|
||||
with self.assertRaisesRegex(push_preflight.PreflightError, '^' + code + '$'):
|
||||
push_preflight.check(self.account)
|
||||
|
||||
def test_rejects_writable_mount(self):
|
||||
with patch('push_preflight.os.statvfs', return_value=SimpleNamespace(f_flag=0)):
|
||||
with self.assertRaisesRegex(push_preflight.PreflightError, '^credential_mount_not_read_only$'):
|
||||
push_preflight.check(self.account)
|
||||
|
||||
def test_invalid_or_unreadable_credential_error_never_exposes_details(self):
|
||||
for target in ('push_preflight.Path.stat', 'push_preflight.credentials.Certificate'):
|
||||
with self.subTest(target=target), patch(target, side_effect=ValueError('sensitive SDK details')):
|
||||
with self.assertRaisesRegex(push_preflight.PreflightError, '^credential_missing_unreadable_or_invalid$'):
|
||||
push_preflight.check(self.account)
|
||||
Reference in New Issue
Block a user