Prepare isolated Firebase push configuration for pre-production
This commit is contained in:
+4
-2
@@ -15,10 +15,12 @@ GITEA_TOKEN=
|
|||||||
GITEA_OWNER=kai
|
GITEA_OWNER=kai
|
||||||
GITEA_REPO=pingu-concerts
|
GITEA_REPO=pingu-concerts
|
||||||
|
|
||||||
# Server push defaults to off; enable only in a local/test environment with the dedicated TEST service account mounted.
|
# Server push defaults to off. Each environment needs its own service account and key.
|
||||||
|
# Pre-Production: compose.preprod.yml enables push and requires FIREBASE_PROJECT_ID.
|
||||||
|
# See docs/wiki/Firebase.md, section "Pre-Production Deployment".
|
||||||
PUSH_ENABLED=false
|
PUSH_ENABLED=false
|
||||||
FIREBASE_PROJECT_ID=
|
FIREBASE_PROJECT_ID=
|
||||||
# Only needed with: docker compose -f compose.yml -f compose.push.yml ...
|
# Required with compose.push.yml or compose.preprod.yml; absolute path OUTSIDE the checkout.
|
||||||
FIREBASE_SERVICE_ACCOUNT_FILE=
|
FIREBASE_SERVICE_ACCOUNT_FILE=
|
||||||
|
|
||||||
# Inclusive registration dates (Europe/Berlin); Early Bird starts the following day.
|
# Inclusive registration dates (Europe/Berlin); Early Bird starts the following day.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ pingu-concerts-uploads.tar.gz
|
|||||||
android/android/app/google-services.json
|
android/android/app/google-services.json
|
||||||
**/google-services.json
|
**/google-services.json
|
||||||
**/*firebase-adminsdk*.json
|
**/*firebase-adminsdk*.json
|
||||||
|
**/firebase-push-*.json
|
||||||
**/*service-account*.json
|
**/*service-account*.json
|
||||||
**/*service_account*.json
|
**/*service_account*.json
|
||||||
secrets/
|
secrets/
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ app/ FastAPI, Templates, Static Assets und Tests
|
|||||||
db/init/ Initialschema für eine neue PostgreSQL-Datenbank
|
db/init/ Initialschema für eine neue PostgreSQL-Datenbank
|
||||||
db/migrations/ Nachträgliche, reproduzierbare Schemaänderungen
|
db/migrations/ Nachträgliche, reproduzierbare Schemaänderungen
|
||||||
android/ Capacitor-Projekt und Android-App
|
android/ Capacitor-Projekt und Android-App
|
||||||
compose*.yml Lokale Containerdefinitionen
|
compose*.yml Containerdefinitionen und optionale Push-/Pre-Production-Konfiguration
|
||||||
img/ Projektgrafiken
|
img/ Projektgrafiken
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
# MetalCircle Android-App
|
# MetalCircle Android-App
|
||||||
|
|
||||||
Die App verpackt die bestehende MetalCircle-Webanwendung mit Capacitor.
|
Die App verpackt die bestehende MetalCircle-Webanwendung mit Capacitor.
|
||||||
Sie lädt die produktive HTTPS-Adresse `https://konzerte.pinguholic.de/` und
|
Sie lädt standardmäßig die aktuelle Pre-Production-Adresse `https://konzerte.pinguholic.de/` und
|
||||||
implementiert keine zweite Webanwendung.
|
implementiert keine zweite Webanwendung.
|
||||||
|
|
||||||
## Entwicklung und Build
|
## Entwicklung und Build
|
||||||
@@ -20,3 +20,5 @@ Installation auf einem verbundenen Gerät:
|
|||||||
```bash
|
```bash
|
||||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Für den separaten Pre-Production-Firebase-Zugang und die Geräteanmeldung siehe [Pre-Production Deployment](../docs/wiki/Firebase.md#pre-production-deployment). Die dortige Standard-HTTPS-Adresse erfordert keine neue APK, sofern die installierte App bereits diese Adresse lädt.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ secrets/
|
|||||||
**/*service-account*.json
|
**/*service-account*.json
|
||||||
**/*service_account*.json
|
**/*service_account*.json
|
||||||
**/*firebase-adminsdk*.json
|
**/*firebase-adminsdk*.json
|
||||||
|
**/firebase-push-*.json
|
||||||
|
**/google-services.json
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
private_uploads/
|
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);
|
const tag = 'a'.repeat(64);
|
||||||
|
|
||||||
async function setup(options={}) {
|
async function setup(options={}) {
|
||||||
const listeners = {}, navigations = [], elements = [];
|
const listeners = {}, navigations = [], elements = [], requests = [];
|
||||||
const session = {authenticated:true, session_tag:tag, ...options.session};
|
const session = {authenticated:true, session_tag:tag, ...options.session};
|
||||||
const config = {textContent:JSON.stringify({openNotification:'Open new notification'})};
|
const config = {textContent:JSON.stringify({openNotification:'Open new notification'})};
|
||||||
const main = {prepend(node) { elements.push(node); }};
|
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()=>{}};
|
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(){}},
|
vm.runInNewContext(source, {document, window:{Capacitor:{getPlatform:()=> 'android',Plugins:{PushNotifications:push,MetalCircleDevice:device}},addEventListener(){}},
|
||||||
location:{pathname:'/',assign(value){navigations.push(value);}},
|
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));
|
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()=>{
|
test('tap opens only backend-resolved destination for the matching session',async()=>{
|
||||||
const app=await setup();
|
const app=await setup();
|
||||||
await app.listeners.pushNotificationActionPerformed({notification:{data:{notification_id:id,session_tag:tag,url:'https://evil.invalid'}}});
|
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 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))
|
with self.assertRaises(ValueError): cohort(datetime(2026, 1, 1))
|
||||||
|
|
||||||
def test_one_language_link_targets_opposite_language(self):
|
def test_language_dropdown_offers_both_languages_and_marks_current(self):
|
||||||
import re
|
for language in ('de', 'en'):
|
||||||
for language, target in [('de', 'en'), ('en', 'de')]:
|
|
||||||
token = current_language.set(language)
|
token = current_language.set(language)
|
||||||
try:
|
try:
|
||||||
html = main.templates.get_template('_language_switch.html').render()
|
html = main.templates.get_template('_language_switch.html').render()
|
||||||
finally:
|
finally:
|
||||||
current_language.reset(token)
|
current_language.reset(token)
|
||||||
self.assertEqual(len(re.findall(r'<a\s', html)), 1)
|
self.assertEqual(len(re.findall(r'<a\s', html)), 2)
|
||||||
|
for target in ('de', 'en'):
|
||||||
self.assertIn('/language/' + target, html)
|
self.assertIn('/language/' + target, html)
|
||||||
self.assertIn('>' + target.upper() + '</a>', 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):
|
def test_sender_missing_credentials_is_safe(self):
|
||||||
with patch.dict(os.environ, {'GOOGLE_APPLICATION_CREDENTIALS':'', 'FIREBASE_PROJECT_ID':''}):
|
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)
|
response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
|
||||||
self.assertEqual(response.headers['location'], '/messages/sender#latest')
|
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):
|
def test_invitation_route_and_removed_invitation(self):
|
||||||
concert = self.invitation()
|
concert = self.invitation()
|
||||||
self.worker.deliver_one()
|
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)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Pre-Production: combine with compose.yml. Reuse the existing read-only mount.
|
||||||
|
# Run the documented credential preflight before starting the web service.
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
extends:
|
||||||
|
file: compose.push.yml
|
||||||
|
service: web
|
||||||
|
environment:
|
||||||
|
PUSH_ENABLED: "true"
|
||||||
|
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:?Set the Pre-Production Firebase project ID}
|
||||||
|
COOKIE_SECURE: "true"
|
||||||
+1
-1
@@ -5,7 +5,7 @@ services:
|
|||||||
GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/firebase-service-account.json
|
GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/firebase-service-account.json
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: ${FIREBASE_SERVICE_ACCOUNT_FILE:?Set an absolute path to the test Firebase service account JSON}
|
source: ${FIREBASE_SERVICE_ACCOUNT_FILE:?Set an absolute path to this environment's Firebase service account JSON}
|
||||||
target: /run/secrets/firebase-service-account.json
|
target: /run/secrets/firebase-service-account.json
|
||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ Versionen im Repository: Capacitor 6.2.1, Push Notifications 6.0.5, Android Grad
|
|||||||
Die lokale `google-services.json` ist für den Firebase-Build erforderlich, wird aber ignoriert und nie eingecheckt. FCM-Registrierung, Berechtigungsdialog, Session-Bindung und Debug-Token-Anzeige sind implementiert. Automatischer Backend-Versand ist nach [Firebase-Einrichtung](Firebase.md) aktivierbar. Die per WebView geladene JavaScript-Datei verarbeitet Antippen und Vordergrundhinweise; für diese Erweiterung ist keine neue native APK nötig.
|
Die lokale `google-services.json` ist für den Firebase-Build erforderlich, wird aber ignoriert und nie eingecheckt. FCM-Registrierung, Berechtigungsdialog, Session-Bindung und Debug-Token-Anzeige sind implementiert. Automatischer Backend-Versand ist nach [Firebase-Einrichtung](Firebase.md) aktivierbar. Die per WebView geladene JavaScript-Datei verarbeitet Antippen und Vordergrundhinweise; für diese Erweiterung ist keine neue native APK nötig.
|
||||||
|
|
||||||
Ein Release-APK muss vor Installation signiert werden. `assembleRelease` erzeugt ohne Signing-Konfiguration eine **nicht installierbare** `app-release-unsigned.apk`. Für einen Test kann der lokale Debug-Keystore signieren; dies ist keine Produktionssignatur. Mit `apksigner verify` prüfen, nie Schlüssel ins Repository übernehmen.
|
Ein Release-APK muss vor Installation signiert werden. `assembleRelease` erzeugt ohne Signing-Konfiguration eine **nicht installierbare** `app-release-unsigned.apk`. Für einen Test kann der lokale Debug-Keystore signieren; dies ist keine Produktionssignatur. Mit `apksigner verify` prüfen, nie Schlüssel ins Repository übernehmen.
|
||||||
|
|
||||||
|
Für Pre-Production beim Sync `METALCIRCLE_SERVER_URL` auf `https://konzerte.pinguholic.de/` und `METALCIRCLE_LOCAL_TEST=0` setzen. Diese Adresse ist bereits der Standardwert. Eine neue APK ist nur für eine aktuell auf localhost oder eine andere Adresse zeigende Installation notwendig. Die Package ID bleibt unverändert. Client-Firebase-Projekt bleibt `metalcircle-30d9b`; der neue Pre-Production-Privatschlüssel gehört ausschließlich in den Backend-Mount. Schritte und Prüfung der frischen Gerätebindung siehe [Pre-Production Deployment](Firebase.md#pre-production-deployment).
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
| `FIREBASE_PROJECT_ID` | tatsächliche Firebase-Projekt-ID | bei aktiviertem Versand erforderlich |
|
| `FIREBASE_PROJECT_ID` | tatsächliche Firebase-Projekt-ID | bei aktiviertem Versand erforderlich |
|
||||||
| `FIREBASE_SERVICE_ACCOUNT_FILE` | absoluter Host-Pfad zur privaten Service-Account-Datei | für `compose.push.yml` erforderlich |
|
| `FIREBASE_SERVICE_ACCOUNT_FILE` | absoluter Host-Pfad zur privaten Service-Account-Datei | für `compose.push.yml` erforderlich |
|
||||||
| `GOOGLE_APPLICATION_CREDENTIALS` | Containerpfad zur Service-Account-Datei | durch `compose.push.yml` gesetzt |
|
| `GOOGLE_APPLICATION_CREDENTIALS` | Containerpfad zur Service-Account-Datei | durch `compose.push.yml` gesetzt |
|
||||||
|
| `METALCIRCLE_SERVER_URL` | HTTPS-Adresse für die Android-WebView | beim Capacitor-Sync; Standard ist die aktuelle Pre-Production https://konzerte.pinguholic.de/ |
|
||||||
|
| `METALCIRCLE_LOCAL_TEST` | Android-HTTP-Ausnahme ausschließlich für localhost | nur lokale Tests `1`, Pre-Production `0` |
|
||||||
| `ALPHA_TESTER_UNTIL` | inklusive Alpha-Registrierungsgrenze | Standard `2026-10-31` |
|
| `ALPHA_TESTER_UNTIL` | inklusive Alpha-Registrierungsgrenze | Standard `2026-10-31` |
|
||||||
| `BETA_TESTER_UNTIL` | inklusive Beta-Registrierungsgrenze, nach Alpha | Standard `2026-12-31` |
|
| `BETA_TESTER_UNTIL` | inklusive Beta-Registrierungsgrenze, nach Alpha | Standard `2026-12-31` |
|
||||||
|
|
||||||
`.env.example` enthält nur Platzhalter. `.env` wird nie committed. `google-services.json` liegt ausschließlich lokal im Android-App-Modul und wird durch `.gitignore` ausgeschlossen.
|
`.env.example` enthält nur Platzhalter. `.env` wird nie committed. `google-services.json` liegt ausschließlich lokal im Android-App-Modul und wird durch `.gitignore` ausgeschlossen.
|
||||||
|
|
||||||
|
`compose.preprod.yml` verwendet dieselben Push-Variablen und denselben Credential-Mount, setzt `PUSH_ENABLED=true` sowie `COOKIE_SECURE=true` und verlangt eine Projekt-ID. Der Host-Pfad muss zum eigenen Pre-Production-Key zeigen. Siehe [Pre-Production Deployment](Firebase.md#pre-production-deployment), einschließlich Offline-Prüfung gegen den erwarteten Service Account und Abschaltung ohne das aktivierende Override. Die Android-Variablen sind Build-/Sync-Einstellungen und werden nicht aus der Backend-`.env` an die APK übertragen.
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
Es gibt drei getrennte Umgebungen:
|
Es gibt drei getrennte Umgebungen:
|
||||||
|
|
||||||
1. Lokale Entwicklung auf PinguCore/Codex mit Docker Compose und Testdaten.
|
1. Lokale Entwicklung auf PinguCore/Codex mit Docker Compose und Testdaten.
|
||||||
2. Cloud-Staging/Testserver für gemeinsame Integrationstests.
|
2. Aktuelle Pre-Production / Cloud-Staging unter **https://konzerte.pinguholic.de/** für gemeinsame Integrationstests.
|
||||||
3. Produktion.
|
3. Produktion.
|
||||||
|
|
||||||
Lokale Änderungen werden in Git geprüft und gepusht. Der Cloud-Testserver zieht den Stand anschließend eigenständig; Codex soll ihn nicht automatisch anmelden, verändern oder deployen. Produktion wird durch diese Dokumentation nicht verändert. Zugangsdaten und konkrete produktive Adressen gehören nicht ins Repository.
|
Lokale Änderungen werden in Git geprüft und gepusht. Der Cloud-Testserver zieht den Stand anschließend eigenständig; Codex soll ihn nicht automatisch anmelden, verändern oder deployen. Produktion wird durch diese Dokumentation nicht verändert. Zugangsdaten und konkrete produktive Adressen gehören nicht ins Repository.
|
||||||
|
|
||||||
|
## Pre-Production Push
|
||||||
|
|
||||||
|
Die vorbereitete Kombination `compose.yml` + `compose.preprod.yml` übernimmt den read-only Firebase-Mount aus `compose.push.yml`, aktiviert Push und erzwingt HTTPS-Cookies. Ein eigener Pre-Production-Service-Account ist erforderlich. Basis-/Local-Konfiguration wird damit nicht automatisch umgestellt. Die HTTPS-Domain wurde vom Betreiber bestätigt. Die Befehle werden im bestehenden Server-Checkout von `kai/pingu-concerts` ausgeführt; dessen absoluter Pfad und gegebenenfalls zusätzliche serverseitige Overrides sind im Repository nicht hinterlegt.
|
||||||
|
|
||||||
|
Der vollständige Ablauf mit IAM, Secret-Rechten, Offline-Preflight, Android-Ziel-URL, Aktivierung/Abschaltung und drei Gerätetests steht unter [Firebase → Pre-Production Deployment](Firebase.md#pre-production-deployment). Bestehenden Compose-Projektnamen und Daten-Volumes erhalten; keine Local-Datenbank auf den Server kopieren.
|
||||||
|
|||||||
@@ -41,3 +41,149 @@ Freundschaftsanfrage und Veranstaltungseinladung wurden im Android Notification
|
|||||||
Für eine Wiederholung lokale App und Backend verwenden; mit `adb reverse tcp:8080 tcp:8080` wird der Android-Testbuild an den lokalen Port 8080 weitergeleitet. Keine Cloud-Staging- oder Produktionsumgebung verwenden. In der Datenbank bedeutet `push_notifications.state='sent'`, dass der Firebase-Sendeaufruf angenommen wurde; für einen vollständigen E2E-PASS zusätzlich den tatsächlichen Android-Empfang über Notification Manager oder gleichwertige Gerätebeobachtung prüfen.
|
Für eine Wiederholung lokale App und Backend verwenden; mit `adb reverse tcp:8080 tcp:8080` wird der Android-Testbuild an den lokalen Port 8080 weitergeleitet. Keine Cloud-Staging- oder Produktionsumgebung verwenden. In der Datenbank bedeutet `push_notifications.state='sent'`, dass der Firebase-Sendeaufruf angenommen wurde; für einen vollständigen E2E-PASS zusätzlich den tatsächlichen Android-Empfang über Notification Manager oder gleichwertige Gerätebeobachtung prüfen.
|
||||||
|
|
||||||
Automatisierte Backend-Tests simulieren Firebase und belegen nicht die Cloud-IAM-Berechtigung. Die lokale Suite mit 53 Tests und der Android-Debug-Build waren erfolgreich; der oben beschriebene Gerätetest hat zusätzlich den echten FCM-Versand und Empfang bestätigt. Bei erneutem `configuration`-/403-Fehler Projekt-ID, aktiven Service Account, dessen `roles/firebasecloudmessaging.admin`-Zuweisung im richtigen Firebase-Projekt, API-Aktivierung und Secret-Mount prüfen. Logs enthalten absichtlich keine vollständigen Firebase-Fehlerantworten oder Secrets. Keine Legacy-Server-Keys einsetzen.
|
Automatisierte Backend-Tests simulieren Firebase und belegen nicht die Cloud-IAM-Berechtigung. Die lokale Suite mit 53 Tests und der Android-Debug-Build waren erfolgreich; der oben beschriebene Gerätetest hat zusätzlich den echten FCM-Versand und Empfang bestätigt. Bei erneutem `configuration`-/403-Fehler Projekt-ID, aktiven Service Account, dessen `roles/firebasecloudmessaging.admin`-Zuweisung im richtigen Firebase-Projekt, API-Aktivierung und Secret-Mount prüfen. Logs enthalten absichtlich keine vollständigen Firebase-Fehlerantworten oder Secrets. Keine Legacy-Server-Keys einsetzen.
|
||||||
|
|
||||||
|
## Pre-Production Deployment
|
||||||
|
|
||||||
|
### Bestand und Grenzen
|
||||||
|
|
||||||
|
Im Repository gibt es `compose.yml` für Web/DB, die optionale Secret-Einbindung `compose.push.yml` und eine lokale, ignorierte `compose.dev.yml` mit Reload/Quellcode-Mount. Die vom Betreiber bestätigte aktuelle Pre-Production-Adresse ist **https://konzerte.pinguholic.de/**; das Repository bleibt **`kai/pingu-concerts`**. Der absolute Server-Checkout-Pfad und zusätzliche Reverse-Proxy-/Compose-Overrides sind nicht dokumentiert. Die folgenden Befehle werden **vom Betreiber im bestehenden Pre-Production-Checkout** ausgeführt. Den vorhandenen Compose-Projektnamen und gegebenenfalls serverseitige Overrides beibehalten, damit dieselbe Pre-Production-Datenbank und dieselben Upload-Volumes verwendet werden. Keine zweite Installation mit denselben festen Containernamen auf demselben Docker-Host starten.
|
||||||
|
|
||||||
|
Neu ist `compose.preprod.yml`: Es übernimmt per `extends` die Einbindung aus `compose.push.yml`, aktiviert Push und erzwingt sichere Cookies. Start mit `compose.yml` + `compose.preprod.yml`; die dritte Datei muss nicht zusätzlich angegeben werden. Pre-Production benötigt HTTPS am vorhandenen Reverse Proxy. Die Basisdatei behält `PUSH_ENABLED=false` als Standard; lokale Konfiguration und spätere Produktion werden nicht automatisch aktiviert.
|
||||||
|
|
||||||
|
### Einmalig in Google Cloud: eigener Zugang
|
||||||
|
|
||||||
|
1. Projekt **`metalcircle-30d9b`** auswählen. Unter **APIs & Services → Enabled APIs** prüfen, dass **Firebase Cloud Messaging API** (`fcm.googleapis.com`, HTTP v1) aktiviert ist; nur falls nötig aktivieren.
|
||||||
|
2. Unter **IAM & Admin → Service Accounts → Create service account** den Namen und die ID **`metalcircle-push-preprod`** anlegen. Erwartete Adresse: `metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com`.
|
||||||
|
3. Diesem Service Account im genannten Projekt **Firebase Cloud Messaging API Admin**, exakt **`roles/firebasecloudmessaging.admin`**, zuweisen. Der Versand benötigt `cloudmessaging.messages.create`; die vordefinierte FCM-API-Rolle enthält diese Berechtigung. Keine Editor-/Owner-/Firebase-Admin-Rolle und keine zusätzliche Service Account Token Creator-Rolle für die hier verwendete JSON-Schlüssel-Authentifizierung vergeben. Nicht die ähnlich benannte Rolle `roles/firebasenotifications.admin` auswählen. [Offizielle Rollenübersicht](https://docs.cloud.google.com/iam/docs/roles-permissions/firebasecloudmessaging), [FCM HTTP v1](https://firebase.google.com/docs/cloud-messaging/send/v1-api).
|
||||||
|
4. Beim **neuen** Service Account unter **Keys → Add key → Create new key → JSON** einen Schlüssel erzeugen und über einen sicheren Übertragungsweg auf den Pre-Production-Host bringen. Der Betreiber führt diesen Schritt aus. Nicht die Admin-SDK-Schaltfläche für einen allgemeinen Standard-Service-Account verwenden und keine Organisationsrichtlinie für verbotene Schlüssel umgehen.
|
||||||
|
|
||||||
|
Der lokale Account `metalcircle-push-local@metalcircle-30d9b.iam.gserviceaccount.com` und sein Schlüssel bleiben ausschließlich lokal. Produktion bekommt später einen weiteren eigenen Account/Schlüssel. Unterschiedliche Service Accounts im selben Firebase-Projekt trennen Credentials, aber nicht die FCM-Token-Zielgruppe: Die separate Backend-Datenbank und frische Registrierung sind deshalb zwingend.
|
||||||
|
|
||||||
|
### A. Einmalig auf dem Pre-Production-Host
|
||||||
|
|
||||||
|
Es ist kein bestehendes Secret-Verzeichnis dokumentiert. **Vorschlag**, falls der Betreiber noch keine Konvention hat: `$HOME/.secrets/metalcircle/firebase-push-preprod.json` im Home-Verzeichnis des Deployment-Benutzers, außerhalb von Checkout und Docker-Buildkontext.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -d -m 700 "$HOME/.secrets/metalcircle"
|
||||||
|
# Hier ausschließlich den NEUEN Pre-Production-Schlüssel sicher ablegen.
|
||||||
|
chmod 600 "$HOME/.secrets/metalcircle/firebase-push-preprod.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Datei muss dem Deployment-Benutzer gehören. Falls die sichere Übertragung einen anderen Eigentümer gesetzt hat, korrigiert der Betreiber ihn, z. B. mit `sudo chown "$(id -u):$(id -g)" "$HOME/.secrets/metalcircle/firebase-push-preprod.json"`. Das bestehende Dockerfile läuft als Container-root; bei Rootless-Docker oder zusätzlichen `user:`-Overrides muss die UID-Abbildung berücksichtigt werden. Nicht auf `chmod 644` ausweichen.
|
||||||
|
|
||||||
|
In der **bestehenden, nicht versionierten Server-`.env`** nur diese Werte ergänzen; DB-/Admin-/Gitea-Konfiguration beibehalten:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
PUSH_ENABLED=true
|
||||||
|
FIREBASE_PROJECT_ID=metalcircle-30d9b
|
||||||
|
FIREBASE_SERVICE_ACCOUNT_FILE=/absoluter/hostpfad/zur/firebase-push-preprod.json
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Den Beispielpfad durch den tatsächlichen absoluten Pfad ersetzen, kein `~` in `.env`. `GOOGLE_APPLICATION_CREDENTIALS` wird durch Compose auf `/run/secrets/firebase-service-account.json` gesetzt. Der Bind-Mount ist `read_only: true`; `create_host_path: false` verhindert, dass eine fehlende Schlüsseldatei unbemerkt als Verzeichnis angelegt wird. Ein Host-Pfad ist kein Schlüsselinhalt und darf dokumentiert werden; JSON-Inhalt, private Schlüssel, OAuth- und FCM-Tokens niemals.
|
||||||
|
|
||||||
|
### B. Deployment im bestehenden Pre-Production-Checkout
|
||||||
|
|
||||||
|
Vor Aktivierung bestätigen: Die vorhandene DB ist die Pre-Production-DB, keine lokalen Daten oder Sessions wurden importiert. Übernommene `sessions`, `push_devices` und `push_notifications` aus einem anderen Umfeld müssen vor Aktivierung gezielt bereinigt werden; dies nicht durch Kopieren lokaler Daten lösen. Für den Smoke-Test frisch in Pre-Production anmelden und das Gerät dort registrieren.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull --ff-only
|
||||||
|
docker compose -f compose.yml -f compose.preprod.yml config --quiet
|
||||||
|
docker compose -f compose.yml -f compose.preprod.yml build web
|
||||||
|
docker compose -f compose.yml -f compose.preprod.yml run --rm --no-deps web \
|
||||||
|
python push_preflight.py \
|
||||||
|
--expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com
|
||||||
|
# Nur bei PASS fortsetzen. Das bestehende db-Service muss bereits laufen.
|
||||||
|
docker compose -f compose.yml -f compose.preprod.yml up -d --no-deps web
|
||||||
|
docker compose -f compose.yml -f compose.preprod.yml exec -T web \
|
||||||
|
python push_preflight.py \
|
||||||
|
--expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com
|
||||||
|
docker compose -f compose.yml -f compose.preprod.yml ps
|
||||||
|
docker compose -f compose.yml -f compose.preprod.yml logs --since=5m --tail=100 web
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzliche bisher verwendete Server-Overrides bei diesen Befehlen beibehalten und ihre endgültigen Werte kontrollieren. Keine neue DB anlegen, kein `down -v`, kein Entwicklungs-Reload-Mount. `config --quiet` prüft ohne Ausgabe der interpolierten Secrets; vollständige `compose config`-/`docker inspect`-Ausgaben nicht teilen. Der normale FastAPI-Start führt die vorhandene Schema-Prüfung aus; diese Push-Konfiguration benötigt keine neue Migration.
|
||||||
|
|
||||||
|
`push_preflight.py` prüft nur lokal: Push aktiv, Pfad, reguläre Datei, restriktive Rechte, read-only Mount, syntaktisch lesbares Credential, Projekt und exakte Service-Account-Adresse. Damit fällt auch ein versehentlich eingesetzter Local-Key auf. Es gibt keine Netzwerkanfrage, keinen Push und keinen IAM-Nachweis. PASS ersetzt den echten Smoke-Test nicht.
|
||||||
|
|
||||||
|
Zum Deaktivieren in der Server-`.env` `PUSH_ENABLED=false` setzen und den Webcontainer **mit `compose.yml` + `compose.push.yml`** neu erstellen. Das Pre-Production-Override setzt explizit `true` und muss für diese Abschaltung entfallen. Der Mount kann bleiben. Bei erneutem Aktivieren können noch nicht abgelaufene Aufträge verarbeitet werden; während der Abschaltung entstehen keine neuen. Schlüsselrotation: sichere neue Datei am Host ersetzen und Webcontainer neu erstellen, damit Bind-Mount und gecachtes SDK-Credential erneuert werden.
|
||||||
|
|
||||||
|
### Android mit Pre-Production verbinden
|
||||||
|
|
||||||
|
`android/capacitor.config.ts` verwendet `METALCIRCLE_SERVER_URL`. Die Standardadresse **https://konzerte.pinguholic.de/** ist bereits eure bestätigte aktuelle Pre-Production. Eine App mit dieser Zieladresse benötigt für den neuen Backend-Zugang keine neue APK. Nur einen noch auf localhost zeigenden Testbuild oder eine App mit abweichender Adresse neu synchronisieren und bauen. Die Zieladresse wird beim Sync in die APK übernommen, nicht durch eine Backend-`.env` geändert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Im Verzeichnis android/ auf dem Build-Rechner:
|
||||||
|
METALCIRCLE_SERVER_URL=https://konzerte.pinguholic.de/ METALCIRCLE_LOCAL_TEST=0 npm run sync
|
||||||
|
npm run build
|
||||||
|
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Vor dem Wechsel am alten Backend abmelden. Package ID bleibt **`de.pinguholic.concerts`**; die APK ersetzt bei passender Signatur die bestehende Installation. Bei Signaturkonflikt nicht ungefragt App-Daten löschen. Kein `adb reverse` für das HTTPS-Pre-Production-Backend verwenden. Danach auf Pre-Production als Empfänger B anmelden, Benachrichtigungen erlauben und unter „Mein Profil“ die Registrierung bestätigen lassen. Für weitere Local-/Prod-Builds den gewünschten Ziel-URL erneut ausdrücklich setzen.
|
||||||
|
|
||||||
|
`native-push.js` verwendet ausschließlich relative `/api/push/...`-URLs und dieselbe authentifizierte Origin wie die geladene Web-App. `/api/push/session` liefert die aktuelle Session-Bindung, `POST /api/push/devices` registriert/aktualisiert nur den eingeloggten Benutzer. Geräte-ID und Token sind eindeutig; dieselbe Geräte-ID aktualisiert den Token. Ein Sitzungs-/Umgebungswechsel löscht im nativen Plugin den alten Token, leert eigene Notifications und registriert neu. Logout entfernt die serverseitige Session samt Geräte-/Outbox-Verknüpfungen; Kategorie-Abwahl und verweigerte Android-Berechtigung sind zusätzlich berücksichtigt. Datenbanken und aktive Geräte-/Session-Tabellen zwischen Umgebungen nicht kopieren.
|
||||||
|
|
||||||
|
### Smoke-Test: Backend bis zum Antippen
|
||||||
|
|
||||||
|
Zwei **Pre-Production-Testkonten** A/B verwenden. B ist auf dem Android-Testgerät angemeldet, alle drei Kategorien sind aktiv. Vor jedem Ereignis die App mit Home in den Hintergrund bringen (nicht per „Stopp erzwingen“ beenden). Den Browser von B ebenfalls nicht im Chat oder in Nachrichten/Einladungen offen lassen. Uhrzeit und IDs der Testereignisse notieren. Erst nach technischer Erfassung die Benachrichtigung antippen.
|
||||||
|
|
||||||
|
| Test | Aktion A / Voraussetzungen | Backend-Nachweis | Android-Vorschau und Ziel |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Neue Freundschaftsanfrage an B, noch keine offene/bestätigte Verbindung | `friendships` mit `status='pending'`, Outbox `friend_request` | „Neue Freundschaftsanfrage“ / „New friend request“; Tap führt zu A's Profil |
|
||||||
|
| 2 | B zu einer privaten Testveranstaltung einladen | `event_invitations` mit B und `viewed_at IS NULL`, Outbox `event_invitation` | „Neue Veranstaltungseinladung“ / „New event invitation“; Tap öffnet Veranstaltung |
|
||||||
|
| 3 | Nach Annahme der Freundschaft eine neue DM an B senden | `direct_messages` mit B und `read_at IS NULL`, Outbox `direct_message` | „Neue Nachricht – Du hast eine neue Nachricht.“ / „New message – You have a new message.“; Tap öffnet Chat mit A |
|
||||||
|
|
||||||
|
Für die Backend-Prüfung können selektive SQL-Abfragen benutzt werden (IDs/Zeitpunkt eingrenzen; kein `SELECT *`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.yml -f compose.preprod.yml exec -T db \
|
||||||
|
sh -c 'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
SELECT user_id, platform, app_version, created_at, last_seen_at FROM push_devices ORDER BY last_seen_at DESC LIMIT 10;
|
||||||
|
SELECT user_id, language, friend_request, direct_message, event_invitation FROM notification_preferences ORDER BY user_id;
|
||||||
|
SELECT id, kind, object_id, state, attempts, created_at FROM push_notifications ORDER BY created_at DESC LIMIT 15;
|
||||||
|
SELECT id, requester_id, addressee_id, status FROM friendships ORDER BY id DESC LIMIT 5;
|
||||||
|
SELECT concert_id, user_id, viewed_at FROM event_invitations ORDER BY concert_id DESC LIMIT 5;
|
||||||
|
SELECT id, sender_id, recipient_id, read_at FROM direct_messages ORDER BY id DESC LIMIT 5;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Keiner dieser Befehle gibt Token, Session-Bindung, Schlüssel oder private Nachrichtentexte aus. `state='sent'` wird erst gesetzt, wenn `firebase_admin.messaging.send` erfolgreich zurückgekehrt ist. Dies belegt die FCM-Annahme, **nicht** die Zustellung. Der aktuelle Sender speichert die zurückgegebene Firebase-Message-ID nicht; stattdessen ist `push_notifications.id` als Android-Notification-Tag korrelierbar. Keine neuen SDK-Debuglogs einschalten.
|
||||||
|
|
||||||
|
Für einen technischen Nachweis direkt vor dem Tap auf dem ADB-Rechner die zur Outbox passende UUID im aktiven Notification-Manager-Bestand erfassen. Möglich ist die folgende Ausgabe mit ausschließlich UUID-Tags; keine vollständigen Notification-Dumps speichern oder teilen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb shell dumpsys notification | python3 -c '
|
||||||
|
import re, sys
|
||||||
|
active = False
|
||||||
|
for line in sys.stdin:
|
||||||
|
if line.strip() == "Notification List:":
|
||||||
|
active = True
|
||||||
|
continue
|
||||||
|
if active and line.strip() and len(line) - len(line.lstrip()) <= 2:
|
||||||
|
active = False
|
||||||
|
if active and "NotificationRecord(" in line and "pkg=de.pinguholic.concerts " in line:
|
||||||
|
tag = re.search(r"\btag=([a-f0-9-]{36})\b", line)
|
||||||
|
if tag:
|
||||||
|
print("active MetalCircle notification_id=" + tag.group(1))
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Hersteller/Android-Versionen können den Dump anders gliedern. Leere Ausgabe ist deshalb kein alleiniger Nachweis fehlender Zustellung. Alternativ über die verbundene WebView nur `notifications.map(n => ({tag: n.tag, id: n.id}))` des Capacitor-Aufrufs `PushNotifications.getDeliveredNotifications()` betrachten, **nie** dessen komplettes Objekt (`data` enthält Session-Metadaten). Notification-Tag mit der Outbox-ID vergleichen, generische Vorschau am Gerät prüfen, dann Tap/Ziel prüfen. Ohne belastbaren Gerätebeleg bleibt der Android-Empfang **nicht verifiziert**.
|
||||||
|
|
||||||
|
Alle drei Tests zusätzlich mit B in EN wiederholen; ursprüngliche Sprache/Präferenzen danach wiederherstellen. Eine Kategorie deaktivieren: Ereignis muss entstehen, ein neuer Versandauftrag darf nicht entstehen. Im Vordergrund separat prüfen: lokale Banner-Aktion und native Anzeige; Verhalten siehe [Push Notifications](Push-Notifications.md). Testveranstaltung/DM/Anfragen anschließend gezielt entfernen bzw. ursprüngliche Verbindung wiederherstellen; keine fremden Daten pauschal löschen.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
- **Preflight abgebrochen:** `credential_service_account_mismatch` bedeutet falscher Account (z. B. Local-Key), `credential_project_mismatch` ein anderes Projekt. `credential_permissions_too_broad` verlangt engere Host-Rechte; `credential_mount_not_read_only` eine korrigierte Compose-Einbindung. Bei `credential_missing_unreadable_or_invalid` Dateiexistenz, Leserechte/UID-Mapping und Gültigkeit des neu erzeugten Schlüssels prüfen, keinen Inhalt ausgeben.
|
||||||
|
- **`configuration`/403 nach Preflight-PASS:** Preflight prüft kein IAM. Exakte Account-Zuweisung von `roles/firebasecloudmessaging.admin` auf `metalcircle-30d9b`, API-Aktivierung, nicht widerrufenen Schlüssel und Netzwerkzugang zu Google prüfen. IAM-Propagation abwarten. Der Worker loggt bewusst nur Fehlerkategorien, keine volle Google-Antwort. Für die drei aktuellen Push-Arten nicht `roles/firebasenotifications.admin` mit der HTTP-v1-Rolle verwechseln.
|
||||||
|
- **Kein Outbox-Eintrag:** `PUSH_ENABLED`, korrektes Backend in der APK, frische Registrierung von B, Kategorien, Selbstbenachrichtigung und tatsächliches neues Ereignis prüfen. Ein reines Backend-Update ändert die APK-Ziel-URL nicht.
|
||||||
|
- **`dropped`:** Auftrag kann gelesen, abgelaufen, abgewählt, blockiert oder durch Session-/Tokenwechsel ungültig sein. Direktnachrichten vor dem Versand nicht öffnen; der Chat-GET setzt `read_at`. Bereits erledigte Ziele führen beim Tap absichtlich zur Übersicht.
|
||||||
|
- **`sent`, aber nichts sichtbar:** FCM-Annahme getrennt vom Android-Empfang bewerten. Vor dem Tap erfassen; Android-App-/Kanalberechtigung, Benachrichtigungskanal, Energiesparen/Netzwerk, Force-Stop und Gerätewechsel prüfen. Identische Wiederholungs-Tags ersetzen dieselbe Meldung; neue Outbox-IDs sind verschieden. Die App löscht eigene Notifications bei Session-Wechsel.
|
||||||
|
- **Chat-Push im Vordergrund:** Capacitor 6.0.5 erhält den Payload im nativen Callback; `presentationOptions: ['sound', 'alert']` erlaubt eine native Anzeige, zusätzlich kommt der Web-Banner. Systemanzeige hängt weiterhin vom Android-Kanal ab. Es gibt keinen DM-spezifischen Kanal und keinen belegten DM-Payload-Fehler.
|
||||||
|
- **Keine Freigabe für Pre-Production ableiten:** Lokale historische Gerätetests und simulierte Tests ersetzen die drei echten Pre-Production-Smoke-Tests nicht. Aktuell liegt noch kein solcher Pre-Production-Nachweis vor.
|
||||||
|
|
||||||
|
### Lokal geprüfte Pre-Production-Vorbereitung
|
||||||
|
|
||||||
|
Die Repository-Vorbereitung wurde mit 64 erfolgreichen Backend-Tests (isolierte lokale PostgreSQL-Schemas, simuliertes Firebase) und 11 JavaScript-Tests für Push-Registrierung/-Navigation und Android-Zurück-Verhalten geprüft. Neue Prüfungen decken insbesondere falsche Service Accounts, Projekt-/Mount-/Dateirechte, relative authentifizierte Registrierungs-URLs, die gemeinsamen Payload-Eigenschaften aller drei Push-Arten und den Lesestatus von Direktnachrichten ab. Ein veralteter Sprach-Test wurde an das bereits bestehende DE/EN-Dropdown angepasst; keine Oberflächenänderung.
|
||||||
|
|
||||||
|
Compose-Konfigurationen für Basis, Local-Push und Pre-Production wurden mit synthetischen Konfigurationswerten geprüft, einschließlich Abbruch bei fehlendem Projekt/Secret-Pfad. Das Backend-Image wurde lokal gebaut und auf Credential-Dateien/Schlüssel geprüft; der einzige Schlüssel-Marker war ein kurzer synthetischer Text im bestehenden Gitea-Redaktionstest. `.env`, Android-Firebase-Konfiguration und die vorgesehenen Schlüsseldateinamen sind Git-ignoriert. Diese Prüfungen verwenden keinen Pre-Production-Key und belegen weder Server-IAM noch Zustellung auf dem Pre-Production-Gerät. Native Android-Dateien wurden nicht geändert; ein neuer APK-Build war dafür nicht erforderlich.
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ Automatische Android-Pushs gibt es für neue Freundschaftsanfragen, Direktnachri
|
|||||||
|
|
||||||
`PUSH_ENABLED=false` ist der Standard. Erst nach [Firebase-Einrichtung](Firebase.md) wird der Versand aktiviert; während er deaktiviert ist, entstehen keine Versandaufträge für historische Aktivitäten.
|
`PUSH_ENABLED=false` ist der Standard. Erst nach [Firebase-Einrichtung](Firebase.md) wird der Versand aktiviert; während er deaktiviert ist, entstehen keine Versandaufträge für historische Aktivitäten.
|
||||||
|
|
||||||
Im eigenen Profil lassen sich die drei Kategorien getrennt einstellen. Die Einstellungen gelten für alle angemeldeten Android-Geräte des Kontos; Android muss zusätzlich Benachrichtigungen erlauben. Ausschalten verwirft ausstehende Meldungen dieser Kategorie. Die zuletzt bei Anmeldung, Geräteanmeldung oder Sprachwechsel gewählte Sprache wird pro Konto in `notification_preferences.language` gespeichert. Der einzelne Sprachbutton bietet jeweils die andere Sprache DE/EN an.
|
Im eigenen Profil lassen sich die drei Kategorien getrennt einstellen. Die Einstellungen gelten für alle angemeldeten Android-Geräte des Kontos; Android muss zusätzlich Benachrichtigungen erlauben. Ausschalten verwirft ausstehende Meldungen dieser Kategorie. Die zuletzt bei Anmeldung, Geräteanmeldung oder Sprachwechsel gewählte Sprache wird pro Konto in `notification_preferences.language` gespeichert. Das Sprach-Dropdown bietet Deutsch und Englisch an und markiert die aktuelle Sprache.
|
||||||
|
|
||||||
Pushs enthalten generische Texte, etwa „Neue Nachricht – Du hast eine neue Nachricht.“ Namen, Nachrichtentext und Veranstaltungstitel werden nicht auf den Sperrbildschirm übertragen. Android erhält eine zufällige Versandkennung und die Bindung an die aktuelle Sitzung. Im Vordergrund zeigt die WebView einen Hinweis zum Öffnen an; im Hintergrund übernimmt Android die Systembenachrichtigung.
|
Pushs enthalten generische Texte, etwa „Neue Nachricht – Du hast eine neue Nachricht.“ Namen, Nachrichtentext und Veranstaltungstitel werden nicht auf den Sperrbildschirm übertragen. Android erhält eine zufällige Versandkennung und die Bindung an die aktuelle Sitzung. Im Vordergrund zeigt die WebView einen Hinweis zum Öffnen an; Capacitor Push Notifications 6.0.5 erzeugt mit dem konfigurierten `presentationOptions: ['sound', 'alert']` zusätzlich eine native Notification. Im Hintergrund übernimmt Firebase/Android die Systembenachrichtigung. Android-Berechtigung und Kanal-Einstellungen gelten in beiden Fällen.
|
||||||
|
|
||||||
|
Alle drei Arten verwenden denselben kombinierten `notification`-/`data`-Payload: generischer Titel/Text, `notification_id` und `session_tag`, hohe Android-Priorität, fünf Minuten TTL, `ic_notification`, Standardton und private Sichtbarkeit. Der Tag entspricht der Outbox-UUID; Wiederholungen ersetzen dieselbe Meldung, verschiedene Aufträge haben unterschiedliche Tags. Es gibt weder einen eigenen Kanal noch eine besondere `click_action` für Direktnachrichten. Da Payload und Manifest keinen Channel angeben, wird der Firebase-Fallback-Kanal verwendet. Die Capacitor-Tap-Verarbeitung löst das Ziel über `/notifications/{id}` auf.
|
||||||
|
|
||||||
|
Ein Chat-Aufruf setzt eingegangene Nachrichten sofort auf gelesen. Geschieht dies vor dem Worker-Versand, wird die Meldung absichtlich verworfen; nach dem Versand kann ein späterer Tap wegen bereits gelesenem Ziel zur Übersicht führen. Deshalb beim Smoke-Test den Chat von B bis zur Erfassung der Notification geschlossen halten. Der historische DM-Test wurde durch Nutzerbeobachtung/Tap bestätigt, aber nicht ebenso zuverlässig im Notification Manager erfasst wie die anderen Arten. Daraus lässt sich kein belegter DM-spezifischer Versandfehler ableiten; in Pre-Production muss der technische Nachweis für alle drei Arten erneut erfolgen.
|
||||||
|
|
||||||
Beim Antippen prüft die App die Sitzung. `/notifications/{id}` prüft erneut Empfänger, Sitzung und Berechtigung und leitet zur Unterhaltung, Anfrage oder Veranstaltung weiter. Bereits gelesene Nachrichten, erledigte Anfragen, entfernte Einladungen oder blockierte Kontakte führen zur Übersicht.
|
Beim Antippen prüft die App die Sitzung. `/notifications/{id}` prüft erneut Empfänger, Sitzung und Berechtigung und leitet zur Unterhaltung, Anfrage oder Veranstaltung weiter. Bereits gelesene Nachrichten, erledigte Anfragen, entfernte Einladungen oder blockierte Kontakte führen zur Übersicht.
|
||||||
|
|
||||||
@@ -31,3 +35,7 @@ Automatisierte Tests nutzen isolierte lokale PostgreSQL-Schemas und simuliertes
|
|||||||
Der echte lokale FCM-E2E-Test wurde am **15.09.2026** mit dem Android-Gerät und dem lokalen Backend erfolgreich abgeschlossen. Freundschaftsanfrage, Direktnachricht und Veranstaltungseinladung erreichten das Gerät. Freundschaftsanfrage und Einladung wurden im Android Notification Manager bestätigt; bei der Direktnachricht öffnete Antippen der Benachrichtigung den Chat. Die Push-Vorschauen blieben generisch. Für IAM, Firebase-Projekt und Secret-Mount siehe [Firebase](Firebase.md). Der Test fand lokal statt; Cloud-Staging und Produktion waren nicht betroffen.
|
Der echte lokale FCM-E2E-Test wurde am **15.09.2026** mit dem Android-Gerät und dem lokalen Backend erfolgreich abgeschlossen. Freundschaftsanfrage, Direktnachricht und Veranstaltungseinladung erreichten das Gerät. Freundschaftsanfrage und Einladung wurden im Android Notification Manager bestätigt; bei der Direktnachricht öffnete Antippen der Benachrichtigung den Chat. Die Push-Vorschauen blieben generisch. Für IAM, Firebase-Projekt und Secret-Mount siehe [Firebase](Firebase.md). Der Test fand lokal statt; Cloud-Staging und Produktion waren nicht betroffen.
|
||||||
|
|
||||||
Ein [fertiger ChatGPT-Prompt](Firebase-Setup-Prompt.md) begleitet die Einrichtung.
|
Ein [fertiger ChatGPT-Prompt](Firebase-Setup-Prompt.md) begleitet die Einrichtung.
|
||||||
|
|
||||||
|
## Pre-Production Deployment
|
||||||
|
|
||||||
|
[Firebase → Pre-Production Deployment](Firebase.md#pre-production-deployment) beschreibt die vorbereitete Compose-Konfiguration, den ausschließlich dort verwendeten Service Account, Secret-Mount und Offline-Preflight sowie den Smoke-Test einschließlich Direktnachrichten und sicherer Gerätebeobachtung. Ein erfolgreicher SDK-Aufruf allein ist kein Android-Empfangsnachweis.
|
||||||
|
|||||||
Reference in New Issue
Block a user