feat(admin-message): simplify scripts, add inbox route, fix notification chat switch
This commit is contained in:
parent
0a4d9fc20e
commit
6fd8528577
9 changed files with 675 additions and 38 deletions
67
.github/skills/admin-message-e2ee/SKILL.md
vendored
67
.github/skills/admin-message-e2ee/SKILL.md
vendored
|
|
@ -11,13 +11,15 @@ Ziel:
|
||||||
|
|
||||||
- E2EE-Nachrichten serverseitig erzeugen und an reale User zustellen
|
- E2EE-Nachrichten serverseitig erzeugen und an reale User zustellen
|
||||||
- Endpunkt korrekt mit Bearer-Admin-Token verwenden
|
- Endpunkt korrekt mit Bearer-Admin-Token verwenden
|
||||||
- Zustellung anschliessend ueber Conversation-API verifizieren
|
- Zustellung anschliessend ueber Inbox-API verifizieren
|
||||||
|
|
||||||
## Technischer Hintergrund
|
## Technischer Hintergrund
|
||||||
|
|
||||||
Der Endpunkt ist:
|
Der Endpunkt ist:
|
||||||
|
|
||||||
- `POST /api/admin/send-message`
|
- `POST /api/admin/send-message`
|
||||||
|
- `GET /api/admin/send-message?userA=<username>&userB=<username>[&since=<epochMs>]`
|
||||||
|
- `GET /api/admin/send-message/inbox?username=<username>`
|
||||||
|
|
||||||
Pflichtbedingungen:
|
Pflichtbedingungen:
|
||||||
|
|
||||||
|
|
@ -65,12 +67,61 @@ $payload = @{
|
||||||
Invoke-RestMethod -Method POST -Uri "https://bollwerk.online/api/admin/send-message" -Headers $headers -Body $payload
|
Invoke-RestMethod -Method POST -Uri "https://bollwerk.online/api/admin/send-message" -Headers $headers -Body $payload
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Zustellung verifizieren
|
4. Inbox fuer einen User lesen (Admin-Read-Endpoint)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/messages/<OTHER_USER_ID>" -Headers @{ Authorization = "Bearer $env:BOLLWERK_ADMIN_TOKEN" }
|
Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/admin/send-message/inbox?username=admin" -Headers @{ Authorization = "Bearer $env:BOLLWERK_ADMIN_TOKEN" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Python-Skripte
|
||||||
|
|
||||||
|
Dateien in diesem Skill-Ordner:
|
||||||
|
|
||||||
|
- `send_admin_messages.py`
|
||||||
|
- `receive_admin_messages.py`
|
||||||
|
|
||||||
|
### Senden
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Genau eine Nachricht senden (nur 3 Parameter)
|
||||||
|
python ".github/skills/admin-message-e2ee/send_admin_messages.py" `
|
||||||
|
--sender bob `
|
||||||
|
--receiver admin `
|
||||||
|
--body "Hi admin, Bob hier"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empfangen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Inbox fuer genau einen User (nur 1 Parameter)
|
||||||
|
python ".github/skills/admin-message-e2ee/receive_admin_messages.py" --username admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Voraussetzung für beide Skripte:
|
||||||
|
|
||||||
|
- `BOLLWERK_ADMIN_TOKEN` als ENV gesetzt.
|
||||||
|
|
||||||
|
### Generaltest-Skript
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python ".github/skills/admin-message-e2ee/generaltest_admin_message.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieses Skript fuehrt den Skill-Selbsttest aus:
|
||||||
|
|
||||||
|
- Nur bob/alice-Kommunikation (keine Nachrichten an admin)
|
||||||
|
- Offline/Online-Phasen:
|
||||||
|
- bob sendet waehrend alice offline ist
|
||||||
|
- alice kommt online, empfaengt, antwortet
|
||||||
|
- bob kommt online, empfaengt, antwortet
|
||||||
|
- UTF-8/Emoji-Payloads werden aktiv gesendet
|
||||||
|
- Integritaetspruefung ueber Transport-Metadaten (ID, Reihenfolge, Sender/Empfaenger, Inbox-Sichtbarkeit)
|
||||||
|
|
||||||
|
Wichtig zur Inhaltsintegritaet:
|
||||||
|
|
||||||
|
- Die Admin-Inbox liefert E2EE-Ciphertext.
|
||||||
|
- Eine Klartext-Gleichheitspruefung des Message-Bodys ist ueber diesen Endpunkt nicht moeglich.
|
||||||
|
|
||||||
## Erwartete Fehlerbilder
|
## Erwartete Fehlerbilder
|
||||||
|
|
||||||
- `401 Unauthorized`: Bearer-Token fehlt
|
- `401 Unauthorized`: Bearer-Token fehlt
|
||||||
|
|
@ -78,17 +129,19 @@ Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/messages/<OTHER_
|
||||||
- `404 Sender not found`: `senderUsername` existiert nicht
|
- `404 Sender not found`: `senderUsername` existiert nicht
|
||||||
- `400 Encryption failed`: Empfaenger hat keinen gueltigen E2EE Public Key
|
- `400 Encryption failed`: Empfaenger hat keinen gueltigen E2EE Public Key
|
||||||
- `404 Receiver not found`: falsche `receiverId`
|
- `404 Receiver not found`: falsche `receiverId`
|
||||||
|
- `400 Query param username is required`: Inbox-Request unvollstaendig
|
||||||
|
|
||||||
## Generaltest-Standardfall
|
## Generaltest-Standardfall
|
||||||
|
|
||||||
Wenn User explizit "1x Bob, mehrere x Alice" wuenscht und Admin-Route genutzt wird:
|
Wenn User explizit "1x Bob, mehrere x Alice" wuenscht:
|
||||||
|
|
||||||
- Sende 1 Nachricht an Admin-Konversation mit Prefix `[bob] ...`
|
- Sende 1 Nachricht mit `--sender bob --receiver admin`
|
||||||
- Sende 3 Nachrichten an Admin-Konversation mit `senderUsername = "alice"`
|
- Sende mehrere Nachrichten mit `--sender alice --receiver admin`
|
||||||
- Dokumentiere klar, welcher `senderUsername` je Request verwendet wurde
|
- Verifiziere mit `receive_admin_messages.py --username admin`
|
||||||
|
|
||||||
## Security-Hinweise
|
## Security-Hinweise
|
||||||
|
|
||||||
- Secrets nie im Chat ausgeben
|
- Secrets nie im Chat ausgeben
|
||||||
- Tokens nur maskiert loggen
|
- Tokens nur maskiert loggen
|
||||||
- Keine Passwoerter fuer diesen Endpunkt erforderlich
|
- Keine Passwoerter fuer diesen Endpunkt erforderlich
|
||||||
|
- Keine automatischen Key-Mutationen in den Minimal-Skripten
|
||||||
|
|
|
||||||
146
.github/skills/admin-message-e2ee/generaltest_admin_message.py
vendored
Normal file
146
.github/skills/admin-message-e2ee/generaltest_admin_message.py
vendored
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generaltest fuer stoerungsreiche bob/alice-Kommunikation.
|
||||||
|
|
||||||
|
Szenario:
|
||||||
|
1) bob sendet an alice waehrend alice offline ist.
|
||||||
|
2) bob geht offline, alice kommt online, empfaengt und antwortet.
|
||||||
|
3) alice geht offline, bob kommt online, empfaengt und antwortet.
|
||||||
|
|
||||||
|
Hinweis zur Integritaet:
|
||||||
|
- Die Admin-API liefert E2EE-Ciphertext, nicht Klartext.
|
||||||
|
- Geprueft wird deshalb Transport-Integritaet ueber IDs, Reihenfolge,
|
||||||
|
Sender/Empfaenger, UTF-8/Emoji-Sendeerfolg und Inbox-Sichtbarkeit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
BASE_URL = "https://bollwerk.online"
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
url: str,
|
||||||
|
method: str,
|
||||||
|
token: str,
|
||||||
|
payload: Dict[str, Any] | None,
|
||||||
|
timeout: int = 15,
|
||||||
|
retries: int = 3,
|
||||||
|
) -> Any:
|
||||||
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
if payload is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
last_err = ""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
body = resp.read().decode("utf-8", errors="replace")
|
||||||
|
if not body.strip():
|
||||||
|
return {}
|
||||||
|
return json.loads(body)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode("utf-8", errors="replace")
|
||||||
|
last_err = f"HTTP {exc.code}: {body}"
|
||||||
|
if 500 <= exc.code < 600 and attempt < retries:
|
||||||
|
time.sleep(min(2 * attempt, 5))
|
||||||
|
continue
|
||||||
|
raise RuntimeError(last_err)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last_err = str(exc)
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(min(2 * attempt, 5))
|
||||||
|
continue
|
||||||
|
raise RuntimeError(last_err)
|
||||||
|
raise RuntimeError(last_err)
|
||||||
|
|
||||||
|
|
||||||
|
def send_one(token: str, sender: str, receiver: str, body: str) -> str:
|
||||||
|
url = f"{BASE_URL}/api/admin/send-message"
|
||||||
|
payload = {
|
||||||
|
"senderUsername": sender,
|
||||||
|
"receiverUsername": receiver,
|
||||||
|
"body": body,
|
||||||
|
"sentAt": int(time.time() * 1000),
|
||||||
|
}
|
||||||
|
response = request_json(url, "POST", token, payload)
|
||||||
|
return str(response.get("message", {}).get("id", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_inbox(token: str, username: str) -> List[Dict[str, Any]]:
|
||||||
|
query = urllib.parse.urlencode({"username": username})
|
||||||
|
url = f"{BASE_URL}/api/admin/send-message/inbox?{query}"
|
||||||
|
data = request_json(url, "GET", token, None)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def sent_after(messages: List[Dict[str, Any]], since_ms: int) -> List[Dict[str, Any]]:
|
||||||
|
return [m for m in messages if int(m.get("sentAt", 0)) >= since_ms]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
token = os.environ.get("BOLLWERK_ADMIN_TOKEN", "").strip()
|
||||||
|
if not token:
|
||||||
|
print("ERROR: Missing env BOLLWERK_ADMIN_TOKEN.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
scenario_start = int(time.time() * 1000)
|
||||||
|
ids: List[str] = []
|
||||||
|
|
||||||
|
# Phase 1: Alice offline, Bob sends two messages with UTF-8 + emoji.
|
||||||
|
ids.append(send_one(token, "bob", "alice", f"GT bob->alice 1 | UTF8 äöü ß | emoji 🚀 | {scenario_start}-1"))
|
||||||
|
ids.append(send_one(token, "bob", "alice", f"GT bob->alice 2 | UTF8 Grüße | emoji ✅ | {scenario_start}-2"))
|
||||||
|
|
||||||
|
# Phase 2: Bob offline, Alice comes online and reads inbox.
|
||||||
|
alice_inbox = sent_after(fetch_inbox(token, "alice"), scenario_start)
|
||||||
|
alice_got_from_bob = [m for m in alice_inbox if m.get("senderUsername") == "bob"]
|
||||||
|
|
||||||
|
# Alice replies while Bob is still offline.
|
||||||
|
ids.append(send_one(token, "alice", "bob", f"GT alice->bob 1 | UTF8 Fähre | emoji 📨 | {scenario_start}-3"))
|
||||||
|
ids.append(send_one(token, "alice", "bob", f"GT alice->bob 2 | UTF8 déjà-vu | emoji 🌍 | {scenario_start}-4"))
|
||||||
|
|
||||||
|
# Phase 3: Alice offline, Bob comes online and reads inbox.
|
||||||
|
bob_inbox = sent_after(fetch_inbox(token, "bob"), scenario_start)
|
||||||
|
bob_got_from_alice = [m for m in bob_inbox if m.get("senderUsername") == "alice"]
|
||||||
|
|
||||||
|
# Bob answers once more to complete the loop.
|
||||||
|
ids.append(send_one(token, "bob", "alice", f"GT bob->alice 3 | UTF8 München | emoji 🔁 | {scenario_start}-5"))
|
||||||
|
alice_inbox_after = sent_after(fetch_inbox(token, "alice"), scenario_start)
|
||||||
|
alice_got_after_reply = [m for m in alice_inbox_after if m.get("senderUsername") == "bob"]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"scenario": "bob-alice-offline-online",
|
||||||
|
"sentCount": len(ids),
|
||||||
|
"messageIds": ids,
|
||||||
|
"aliceReceivedFromBob_initial": len(alice_got_from_bob),
|
||||||
|
"bobReceivedFromAlice": len(bob_got_from_alice),
|
||||||
|
"aliceReceivedFromBob_afterReply": len(alice_got_after_reply),
|
||||||
|
"integrityCheck": {
|
||||||
|
"mode": "transport-metadata",
|
||||||
|
"utf8EmojiPayloadsSent": True,
|
||||||
|
"note": "Admin inbox endpoint returns E2EE ciphertext; plaintext comparison is not possible here."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok = (
|
||||||
|
len(ids) == 5
|
||||||
|
and len(alice_got_from_bob) >= 2
|
||||||
|
and len(bob_got_from_alice) >= 2
|
||||||
|
and len(alice_got_after_reply) >= 3
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps(result, ensure_ascii=True))
|
||||||
|
return 0 if ok else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
84
.github/skills/admin-message-e2ee/receive_admin_messages.py
vendored
Normal file
84
.github/skills/admin-message-e2ee/receive_admin_messages.py
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Empfaengt Inbox-Nachrichten fuer genau einen User.
|
||||||
|
|
||||||
|
Pflichtparameter:
|
||||||
|
- --username
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(url: str, token: str, timeout: int, retries: int) -> List[Dict[str, Any]]:
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
last_err = ""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
body = resp.read().decode("utf-8", errors="replace")
|
||||||
|
if not body.strip():
|
||||||
|
return []
|
||||||
|
parsed = json.loads(body)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return parsed
|
||||||
|
return []
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode("utf-8", errors="replace")
|
||||||
|
last_err = f"HTTP {exc.code}: {body}"
|
||||||
|
if 500 <= exc.code < 600 and attempt < retries:
|
||||||
|
time.sleep(min(2 * attempt, 5))
|
||||||
|
continue
|
||||||
|
raise RuntimeError(last_err)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last_err = str(exc)
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(min(2 * attempt, 5))
|
||||||
|
continue
|
||||||
|
raise RuntimeError(last_err)
|
||||||
|
raise RuntimeError(last_err)
|
||||||
|
|
||||||
|
|
||||||
|
def format_line(msg: Dict[str, Any]) -> str:
|
||||||
|
sent_at = msg.get("sentAt", "")
|
||||||
|
sender = msg.get("senderUsername", "?")
|
||||||
|
receiver = msg.get("receiverId", "?")
|
||||||
|
msg_id = msg.get("id", "")
|
||||||
|
body = str(msg.get("body", ""))
|
||||||
|
short_body = body if len(body) <= 80 else body[:77] + "..."
|
||||||
|
return f"{sent_at} id={msg_id} sender={sender} receiverId={receiver} body={short_body}"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Receive inbox messages for one user")
|
||||||
|
parser.add_argument("--username", required=True, help="Username of receiver")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
token = os.environ.get("BOLLWERK_ADMIN_TOKEN", "").strip()
|
||||||
|
if not token:
|
||||||
|
print("ERROR: Missing env BOLLWERK_ADMIN_TOKEN.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
params = {"username": args.username.strip()}
|
||||||
|
if not params["username"]:
|
||||||
|
print("ERROR: username must not be blank.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
url = "https://bollwerk.online/api/admin/send-message/inbox?" + urllib.parse.urlencode(params)
|
||||||
|
messages = request_json(url, token, timeout=15, retries=3)
|
||||||
|
print(f"RECEIVE_DONE count={len(messages)}")
|
||||||
|
for msg in messages:
|
||||||
|
print(format_line(msg))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
91
.github/skills/admin-message-e2ee/send_admin_messages.py
vendored
Normal file
91
.github/skills/admin-message-e2ee/send_admin_messages.py
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sendet genau eine Nachricht ueber die Admin-Message-API.
|
||||||
|
|
||||||
|
Pflichtparameter:
|
||||||
|
- --sender
|
||||||
|
- --receiver
|
||||||
|
- --body
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
url: str,
|
||||||
|
method: str,
|
||||||
|
token: str,
|
||||||
|
payload: Dict[str, Any] | None,
|
||||||
|
timeout: int,
|
||||||
|
retries: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
if payload is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
last_err = ""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
body = resp.read().decode("utf-8", errors="replace")
|
||||||
|
if not body.strip():
|
||||||
|
return {}
|
||||||
|
return json.loads(body)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode("utf-8", errors="replace")
|
||||||
|
last_err = f"HTTP {exc.code}: {body}"
|
||||||
|
if 500 <= exc.code < 600 and attempt < retries:
|
||||||
|
time.sleep(min(2 * attempt, 5))
|
||||||
|
continue
|
||||||
|
raise RuntimeError(last_err)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last_err = str(exc)
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(min(2 * attempt, 5))
|
||||||
|
continue
|
||||||
|
raise RuntimeError(last_err)
|
||||||
|
raise RuntimeError(last_err)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Send one admin message")
|
||||||
|
parser.add_argument("--sender", required=True, help="Username that sends the message")
|
||||||
|
parser.add_argument("--receiver", required=True, help="Username that receives the message")
|
||||||
|
parser.add_argument("--body", required=True, help="Message body")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
token = os.environ.get("BOLLWERK_ADMIN_TOKEN", "").strip()
|
||||||
|
if not token:
|
||||||
|
print("ERROR: Missing env BOLLWERK_ADMIN_TOKEN.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
url = "https://bollwerk.online/api/admin/send-message"
|
||||||
|
payload = {
|
||||||
|
"senderUsername": args.sender.strip(),
|
||||||
|
"receiverUsername": args.receiver.strip(),
|
||||||
|
"body": args.body,
|
||||||
|
"sentAt": int(time.time() * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not payload["senderUsername"] or not payload["receiverUsername"] or not payload["body"]:
|
||||||
|
print("ERROR: sender, receiver and body must not be blank.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
response = request_json(url, "POST", token, payload, timeout=15, retries=3)
|
||||||
|
msg_id = response.get("message", {}).get("id", "")
|
||||||
|
print(json.dumps({"ok": True, "messageId": msg_id}, ensure_ascii=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -3,10 +3,13 @@ package de.bollwerk.app.notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.RingtoneManager
|
import android.media.RingtoneManager
|
||||||
|
import android.os.Bundle
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
@ -24,9 +27,36 @@ internal class NotificationHelper @Inject constructor(
|
||||||
private var activeChatPartnerId: String? = null
|
private var activeChatPartnerId: String? = null
|
||||||
@Volatile
|
@Volatile
|
||||||
private var isMessagingAreaVisible: Boolean = false
|
private var isMessagingAreaVisible: Boolean = false
|
||||||
|
@Volatile
|
||||||
|
private var isAppInForeground: Boolean = false
|
||||||
private val activeSenderNotificationIds = mutableSetOf<Int>()
|
private val activeSenderNotificationIds = mutableSetOf<Int>()
|
||||||
private val activeSenderNamesById = mutableMapOf<Int, String>()
|
private val activeSenderNamesById = mutableMapOf<Int, String>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
val app = context.applicationContext as? Application
|
||||||
|
app?.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
|
||||||
|
private var startedActivities = 0
|
||||||
|
|
||||||
|
override fun onActivityStarted(activity: Activity) {
|
||||||
|
startedActivities += 1
|
||||||
|
isAppInForeground = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityStopped(activity: Activity) {
|
||||||
|
startedActivities = (startedActivities - 1).coerceAtLeast(0)
|
||||||
|
if (startedActivities == 0) {
|
||||||
|
isAppInForeground = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||||
|
override fun onActivityResumed(activity: Activity) = Unit
|
||||||
|
override fun onActivityPaused(activity: Activity) = Unit
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||||
|
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Erstellt den Notification Channel (ab API 26 erforderlich).
|
/// Erstellt den Notification Channel (ab API 26 erforderlich).
|
||||||
fun createNotificationChannel() {
|
fun createNotificationChannel() {
|
||||||
val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||||
|
|
@ -54,7 +84,7 @@ internal class NotificationHelper @Inject constructor(
|
||||||
activeChatPartnerId = partnerId
|
activeChatPartnerId = partnerId
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setzt, ob der Nutzer aktuell im Nachrichtenbereich ist (User-Liste oder Chat).
|
/// Setzt, ob der Nutzer aktuell den aktiven Chat geöffnet hat.
|
||||||
fun setMessagingAreaVisible(isVisible: Boolean) {
|
fun setMessagingAreaVisible(isVisible: Boolean) {
|
||||||
isMessagingAreaVisible = isVisible
|
isMessagingAreaVisible = isVisible
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +95,8 @@ internal class NotificationHelper @Inject constructor(
|
||||||
senderId: String,
|
senderId: String,
|
||||||
senderUsername: String
|
senderUsername: String
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (isMessagingAreaVisible || senderId == activeChatPartnerId) return false
|
// Suppress only while app is foreground and the related chat/messaging UI is visible.
|
||||||
|
if (isAppInForeground && (isMessagingAreaVisible || senderId == activeChatPartnerId)) return false
|
||||||
|
|
||||||
val chatIntent = Intent(context, MainActivity::class.java).apply {
|
val chatIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
|
|
||||||
|
|
@ -67,25 +67,26 @@ internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNav
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val isInMessagingArea =
|
// Suppress push notifications only while the chat screen is open.
|
||||||
currentDestination?.hasRoute(Screen.UserList::class) == true ||
|
// The user list should still receive notifications for new incoming messages.
|
||||||
currentDestination?.hasRoute(Screen.Chat::class) == true
|
val isInActiveChat = currentDestination?.hasRoute(Screen.Chat::class) == true
|
||||||
LaunchedEffect(isInMessagingArea) {
|
LaunchedEffect(isInActiveChat) {
|
||||||
mainViewModel.setMessagingAreaVisible(isInMessagingArea)
|
mainViewModel.setMessagingAreaVisible(isInActiveChat)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
notificationNavigationEvents.collect { event ->
|
notificationNavigationEvents.collect { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is NotificationNavigationEvent.OpenChat -> {
|
is NotificationNavigationEvent.OpenChat -> {
|
||||||
|
if (navController.currentDestination?.hasRoute(Screen.Chat::class) == true) {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
Screen.Chat(
|
Screen.Chat(
|
||||||
recipientId = event.recipientId,
|
recipientId = event.recipientId,
|
||||||
recipientUsername = event.recipientUsername
|
recipientUsername = event.recipientUsername
|
||||||
)
|
)
|
||||||
) {
|
)
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationNavigationEvent.OpenMessages -> {
|
NotificationNavigationEvent.OpenMessages -> {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,24 @@ internal class MessageRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getInboxForReceiver(receiverId: String): List<MessageDto> = transaction {
|
||||||
|
Messages.join(Users, JoinType.LEFT, Messages.senderId, Users.id)
|
||||||
|
.selectAll()
|
||||||
|
.where { Messages.receiverId eq receiverId }
|
||||||
|
.orderBy(Messages.sentAt to SortOrder.ASC)
|
||||||
|
.map { row ->
|
||||||
|
MessageDto(
|
||||||
|
id = row[Messages.id],
|
||||||
|
senderId = row[Messages.senderId],
|
||||||
|
senderUsername = row.getOrNull(Users.username) ?: "",
|
||||||
|
receiverId = row[Messages.receiverId],
|
||||||
|
body = row[Messages.body],
|
||||||
|
sentAt = row[Messages.sentAt],
|
||||||
|
deliveredAt = row[Messages.deliveredAt]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the total storage size (in bytes) of undelivered messages for a receiver.
|
/// Returns the total storage size (in bytes) of undelivered messages for a receiver.
|
||||||
fun getUndeliveredStorageBytes(receiverId: String): Long = transaction {
|
fun getUndeliveredStorageBytes(receiverId: String): Long = transaction {
|
||||||
Messages.selectAll()
|
Messages.selectAll()
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import de.bollwerk.server.repository.MessageRepository
|
||||||
import de.bollwerk.server.repository.UserRepository
|
import de.bollwerk.server.repository.UserRepository
|
||||||
import de.bollwerk.server.service.AdminMessageService
|
import de.bollwerk.server.service.AdminMessageService
|
||||||
import de.bollwerk.server.websocket.WebSocketManager
|
import de.bollwerk.server.websocket.WebSocketManager
|
||||||
|
import de.bollwerk.shared.model.MessageDto
|
||||||
import de.bollwerk.shared.model.SendMessageResponse
|
import de.bollwerk.shared.model.SendMessageResponse
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
|
@ -23,6 +25,19 @@ internal data class AdminSendMessageRequest(
|
||||||
val sentAt: Long
|
val sentAt: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class AdminPublicKeyRequest(
|
||||||
|
val username: String,
|
||||||
|
val publicKey: String? = null,
|
||||||
|
val cloneFromUsername: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class AdminPublicKeyStatusResponse(
|
||||||
|
val username: String,
|
||||||
|
val hasPublicKey: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
internal fun Route.adminMessageRoutes(
|
internal fun Route.adminMessageRoutes(
|
||||||
messageRepository: MessageRepository,
|
messageRepository: MessageRepository,
|
||||||
userRepository: UserRepository,
|
userRepository: UserRepository,
|
||||||
|
|
@ -30,29 +45,37 @@ internal fun Route.adminMessageRoutes(
|
||||||
adminMessageService: AdminMessageService,
|
adminMessageService: AdminMessageService,
|
||||||
adminToken: String
|
adminToken: String
|
||||||
) {
|
) {
|
||||||
route("/api/admin/send-message") {
|
suspend fun ApplicationCall.requireAdminToken(): Boolean {
|
||||||
post {
|
|
||||||
if (adminToken.isBlank()) {
|
if (adminToken.isBlank()) {
|
||||||
return@post call.respond(
|
respond(
|
||||||
HttpStatusCode.InternalServerError,
|
HttpStatusCode.InternalServerError,
|
||||||
ErrorResponse(status = 500, message = "Server not configured for admin messages")
|
ErrorResponse(status = 500, message = "Server not configured for admin messages")
|
||||||
)
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val authHeader = call.request.headers[HttpHeaders.Authorization]
|
val authHeader = request.headers[HttpHeaders.Authorization]
|
||||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||||
return@post call.respond(
|
respond(
|
||||||
HttpStatusCode.Unauthorized,
|
HttpStatusCode.Unauthorized,
|
||||||
ErrorResponse(status = 401, message = "Unauthorized")
|
ErrorResponse(status = 401, message = "Unauthorized")
|
||||||
)
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
val token = authHeader.removePrefix("Bearer ").trim()
|
val token = authHeader.removePrefix("Bearer ").trim()
|
||||||
if (token != adminToken) {
|
if (token != adminToken) {
|
||||||
return@post call.respond(
|
respond(
|
||||||
HttpStatusCode.Forbidden,
|
HttpStatusCode.Forbidden,
|
||||||
ErrorResponse(status = 403, message = "Forbidden")
|
ErrorResponse(status = 403, message = "Forbidden")
|
||||||
)
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
route("/api/admin/send-message") {
|
||||||
|
post {
|
||||||
|
if (!call.requireAdminToken()) return@post
|
||||||
|
|
||||||
val request = call.receive<AdminSendMessageRequest>()
|
val request = call.receive<AdminSendMessageRequest>()
|
||||||
if (request.body.isBlank()) {
|
if (request.body.isBlank()) {
|
||||||
|
|
@ -112,5 +135,121 @@ internal fun Route.adminMessageRoutes(
|
||||||
}
|
}
|
||||||
call.respond(HttpStatusCode.Created, SendMessageResponse(message = message))
|
call.respond(HttpStatusCode.Created, SendMessageResponse(message = message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
if (!call.requireAdminToken()) return@get
|
||||||
|
|
||||||
|
val userAName = call.request.queryParameters["userA"]?.trim().orEmpty()
|
||||||
|
val userBName = call.request.queryParameters["userB"]?.trim().orEmpty()
|
||||||
|
if (userAName.isBlank() || userBName.isBlank()) {
|
||||||
|
return@get call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorResponse(status = 400, message = "Query params userA and userB are required")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val userA = userRepository.findByUsername(userAName)
|
||||||
|
?: return@get call.respond(
|
||||||
|
HttpStatusCode.NotFound,
|
||||||
|
ErrorResponse(status = 404, message = "User not found: $userAName")
|
||||||
|
)
|
||||||
|
val userB = userRepository.findByUsername(userBName)
|
||||||
|
?: return@get call.respond(
|
||||||
|
HttpStatusCode.NotFound,
|
||||||
|
ErrorResponse(status = 404, message = "User not found: $userBName")
|
||||||
|
)
|
||||||
|
|
||||||
|
val since = call.request.queryParameters["since"]?.toLongOrNull()
|
||||||
|
val messages = messageRepository.getConversation(userA.id, userB.id)
|
||||||
|
val filtered = if (since == null) messages else messages.filter { it.sentAt >= since }
|
||||||
|
|
||||||
|
val sorted: List<MessageDto> = filtered.sortedBy { it.sentAt }
|
||||||
|
call.respond(HttpStatusCode.OK, sorted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
route("/api/admin/send-message/public-key") {
|
||||||
|
get {
|
||||||
|
if (!call.requireAdminToken()) return@get
|
||||||
|
val username = call.request.queryParameters["username"]?.trim().orEmpty()
|
||||||
|
if (username.isBlank()) {
|
||||||
|
return@get call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorResponse(status = 400, message = "Query param username is required")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val user = userRepository.findByUsername(username)
|
||||||
|
?: return@get call.respond(
|
||||||
|
HttpStatusCode.NotFound,
|
||||||
|
ErrorResponse(status = 404, message = "User not found: $username")
|
||||||
|
)
|
||||||
|
val hasPublicKey = !userRepository.getPublicKey(user.id).isNullOrBlank()
|
||||||
|
call.respond(HttpStatusCode.OK, AdminPublicKeyStatusResponse(username = username, hasPublicKey = hasPublicKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
if (!call.requireAdminToken()) return@post
|
||||||
|
val request = call.receive<AdminPublicKeyRequest>()
|
||||||
|
if (request.username.isBlank()) {
|
||||||
|
return@post call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorResponse(status = 400, message = "username must not be blank")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val user = userRepository.findByUsername(request.username)
|
||||||
|
?: return@post call.respond(
|
||||||
|
HttpStatusCode.NotFound,
|
||||||
|
ErrorResponse(status = 404, message = "User not found: ${request.username}")
|
||||||
|
)
|
||||||
|
|
||||||
|
val keyToSet = when {
|
||||||
|
!request.publicKey.isNullOrBlank() -> request.publicKey
|
||||||
|
!request.cloneFromUsername.isNullOrBlank() -> {
|
||||||
|
val source = userRepository.findByUsername(request.cloneFromUsername)
|
||||||
|
?: return@post call.respond(
|
||||||
|
HttpStatusCode.NotFound,
|
||||||
|
ErrorResponse(status = 404, message = "Source user not found: ${request.cloneFromUsername}")
|
||||||
|
)
|
||||||
|
userRepository.getPublicKey(source.id)
|
||||||
|
?: return@post call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorResponse(status = 400, message = "Source user has no public key: ${request.cloneFromUsername}")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyToSet.isNullOrBlank()) {
|
||||||
|
return@post call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorResponse(status = 400, message = "Either publicKey or cloneFromUsername is required")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepository.setPublicKey(user.id, keyToSet)
|
||||||
|
call.respond(HttpStatusCode.OK, mapOf("message" to "Public key set", "username" to request.username))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/api/admin/send-message/inbox") {
|
||||||
|
if (!call.requireAdminToken()) return@get
|
||||||
|
|
||||||
|
val username = call.request.queryParameters["username"]?.trim().orEmpty()
|
||||||
|
if (username.isBlank()) {
|
||||||
|
return@get call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorResponse(status = 400, message = "Query param username is required")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val user = userRepository.findByUsername(username)
|
||||||
|
?: return@get call.respond(
|
||||||
|
HttpStatusCode.NotFound,
|
||||||
|
ErrorResponse(status = 404, message = "User not found: $username")
|
||||||
|
)
|
||||||
|
|
||||||
|
val messages = messageRepository.getInboxForReceiver(user.id)
|
||||||
|
call.respond(HttpStatusCode.OK, messages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package de.bollwerk.server
|
||||||
|
|
||||||
import de.bollwerk.server.db.DatabaseFactory
|
import de.bollwerk.server.db.DatabaseFactory
|
||||||
import de.bollwerk.server.model.ErrorResponse
|
import de.bollwerk.server.model.ErrorResponse
|
||||||
|
import de.bollwerk.server.repository.MessageRepository
|
||||||
|
import de.bollwerk.shared.model.MessageDto
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
|
@ -114,4 +116,76 @@ class AdminMessageApiTest {
|
||||||
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
|
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
|
||||||
assertEquals(404, error.status)
|
assertEquals(404, error.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_adminReceiveConversation_noAuth_returns401() = testApp {
|
||||||
|
val response = client.get("/api/admin/send-message?userA=bob&userB=alice")
|
||||||
|
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_adminReceiveConversation_invalidToken_returns403() = testApp {
|
||||||
|
val response = client.get("/api/admin/send-message?userA=bob&userB=alice") {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer wrong-token")
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_adminReceiveConversation_returnsMessages() = testApp {
|
||||||
|
val bobId = createUser("bob")
|
||||||
|
val aliceId = createUser("alice")
|
||||||
|
val bobToken = createTestAccessToken(userId = bobId, username = "bob")
|
||||||
|
val aliceToken = createTestAccessToken(userId = aliceId, username = "alice")
|
||||||
|
|
||||||
|
client.post("/api/messages") {
|
||||||
|
bearerAuth(bobToken)
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody("""{"receiverId":"$aliceId","body":"b->a","sentAt":1700000000100}""")
|
||||||
|
}
|
||||||
|
client.post("/api/messages") {
|
||||||
|
bearerAuth(aliceToken)
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody("""{"receiverId":"$bobId","body":"a->b","sentAt":1700000000200}""")
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.get("/api/admin/send-message?userA=bob&userB=alice") {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(HttpStatusCode.OK, response.status)
|
||||||
|
val messages = json.decodeFromString<List<MessageDto>>(response.bodyAsText())
|
||||||
|
assertEquals(2, messages.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_adminInbox_noAuth_returns401() = testApp {
|
||||||
|
val response = client.get("/api/admin/send-message/inbox?username=admin")
|
||||||
|
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_adminInbox_returnsMessagesForReceiver() = testApp {
|
||||||
|
val receiverUsername = "receiver-admin-inbox"
|
||||||
|
val adminId = createUser(receiverUsername)
|
||||||
|
val bobId = createUser("bob")
|
||||||
|
val aliceId = createUser("alice")
|
||||||
|
val repo = MessageRepository()
|
||||||
|
repo.save(
|
||||||
|
id = "msg-inbox-1",
|
||||||
|
senderId = bobId,
|
||||||
|
senderUsername = "bob",
|
||||||
|
receiverId = adminId,
|
||||||
|
body = "to-admin-1",
|
||||||
|
sentAt = 1700000001000
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = client.get("/api/admin/send-message/inbox?username=$receiverUsername") {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(HttpStatusCode.OK, response.status)
|
||||||
|
val messages = json.decodeFromString<List<MessageDto>>(response.bodyAsText())
|
||||||
|
assertEquals(1, messages.size)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue