Adressdaten-Bereich, Mock hinter einem Profil, Webhook
Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css, README, application.properties) und deshalb nicht getrennt committet werden können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen: Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon- buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen. Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen fest. Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an und reicht es über Server-Sent Events an die offenen Browser weiter, wo es der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten 50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token) ist vorgesehen, aber nicht voreingestellt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import de.appcreation.swyxweb.config.WebSocketProperties;
|
||||
import de.appcreation.swyxweb.config.WebhookProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties(WebSocketProperties.class)
|
||||
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
|
||||
public class BackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.appcreation.swyxweb.config;
|
||||
|
||||
/**
|
||||
* Name des Spring-Profils, unter dem der eingebaute SwyxTray-Mock läuft.
|
||||
*
|
||||
* <p>Der Mock ist bewusst <b>abgeschaltet, solange dieses Profil nicht gesetzt
|
||||
* ist</b> – so kann er nicht versehentlich in einer Produktivumgebung
|
||||
* mitlaufen. Eingeschaltet wird er über
|
||||
* {@code --spring.profiles.active=mock} bzw. {@code SPRING_PROFILES_ACTIVE=mock}.
|
||||
*
|
||||
* <p>Betroffen sind {@code SwyxTrayMockHandler} (der WebSocket-Endpunkt unter
|
||||
* {@code /ws}), {@code WebSocketConfig} (seine Registrierung) und
|
||||
* {@code MockController} ({@code /api/mock/**}). Ohne das Profil gibt es weder
|
||||
* den einen noch die anderen; beide antworten dann mit 404.
|
||||
*/
|
||||
public final class MockProfile {
|
||||
|
||||
/** Name des Profils, siehe Klassenkommentar. */
|
||||
public static final String NAME = "mock";
|
||||
|
||||
private MockProfile() {
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.appcreation.swyxweb.config;
|
||||
|
||||
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
@@ -9,7 +10,10 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
|
||||
/**
|
||||
* Stellt den SwyxTray-Mock unter dem konfigurierten Pfad bereit, damit sich die
|
||||
* Startseite auch ohne die echte SwyxTray-App testen lässt.
|
||||
*
|
||||
* <p>Nur mit dem Profil {@link MockProfile#NAME} – siehe dort.
|
||||
*/
|
||||
@Profile(MockProfile.NAME)
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
public class WebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.appcreation.swyxweb.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
/**
|
||||
* Einstellungen des Webhooks, über den ein fremdes System Adressdaten an die
|
||||
* Startseite schickt.
|
||||
*
|
||||
* @param token Gemeinsames Geheimnis. Ist es gesetzt, muss der Aufrufer es im
|
||||
* Kopf {@code X-Webhook-Token} mitschicken; ist es leer, nimmt
|
||||
* der Webhook <b>jeden</b> Aufruf an.
|
||||
* @param history So viele Nachrichten hält das Backend vor, damit ein später
|
||||
* geöffneter Bereich die vorigen noch sieht.
|
||||
* @param maxSize Größte erlaubte Nutzlast in Byte.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "app.webhook")
|
||||
public record WebhookProperties(
|
||||
@DefaultValue("") String token,
|
||||
@DefaultValue("50") int history,
|
||||
@DefaultValue("262144") int maxSize) {
|
||||
|
||||
/** Ob der Webhook ein Geheimnis verlangt. */
|
||||
public boolean isSecured() {
|
||||
return !token.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.appcreation.swyxweb.web;
|
||||
|
||||
import de.appcreation.swyxweb.config.MockProfile;
|
||||
import de.appcreation.swyxweb.websocket.LineState;
|
||||
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -9,10 +11,16 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Auslöser für den SwyxTray-Mock: simuliert einen eingehenden Anruf, damit sich
|
||||
* die Anrufmeldung der Startseite ohne echte Telefonanlage prüfen lässt.
|
||||
* die Anrufmeldung der Startseite ohne echte Telefonanlage prüfen lässt, und
|
||||
* schaltet den Adress-Cache um.
|
||||
*
|
||||
* <pre>curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"</pre>
|
||||
* <pre>curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
|
||||
* curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"</pre>
|
||||
*
|
||||
* <p>Nur mit dem Profil {@link MockProfile#NAME} vorhanden – im Normalbetrieb
|
||||
* gibt es {@code /api/mock/**} nicht.
|
||||
*/
|
||||
@Profile(MockProfile.NAME)
|
||||
@RestController
|
||||
@RequestMapping("/api/mock")
|
||||
public class MockController {
|
||||
@@ -29,4 +37,18 @@ public class MockController {
|
||||
@RequestParam(required = false) String name) {
|
||||
return mock.simulateIncomingCall(number, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Füllt oder leert den Adress-Cache des Mocks. Mit leerem Cache verhält er
|
||||
* sich wie die zurzeit laufende Anlage, und der Bereich „Adressdaten" muss
|
||||
* auf die Einzelabfragen ausweichen.
|
||||
*
|
||||
* <pre>curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"</pre>
|
||||
*
|
||||
* @return Anzahl der Einträge im Cache danach
|
||||
*/
|
||||
@PostMapping("/address-cache")
|
||||
public int addressCache(@RequestParam(defaultValue = "true") boolean filled) {
|
||||
return mock.setAddressCacheFilled(filled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.appcreation.swyxweb.web;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import de.appcreation.swyxweb.config.WebhookProperties;
|
||||
import de.appcreation.swyxweb.webhook.WebhookEvent;
|
||||
import de.appcreation.swyxweb.webhook.WebhookService;
|
||||
|
||||
/**
|
||||
* Webhook für Adressdaten aus einem fremden System.
|
||||
*
|
||||
* <pre>curl -X POST https://swyxweb.appcreation.de/api/webhook \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -H "X-Webhook-Token: …" \
|
||||
* -d '{"name":"Muster GmbH","number":"+493012345"}'</pre>
|
||||
*
|
||||
* <p>Angenommen wird <b>beliebiges</b> JSON – Objekt wie Liste. Die Startseite
|
||||
* zeigt es im Bereich „Webhook" unverändert an; das aufrufende System muss sich
|
||||
* also an kein festes Schema halten.
|
||||
*
|
||||
* <p>Der Weg zum Browser läuft über {@code GET /api/webhook/events}
|
||||
* (Server-Sent Events), siehe {@link WebhookService}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/webhook")
|
||||
public class WebhookController {
|
||||
|
||||
private final WebhookService service;
|
||||
private final WebhookProperties properties;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public WebhookController(WebhookService service, WebhookProperties properties, ObjectMapper mapper) {
|
||||
this.service = service;
|
||||
this.properties = properties;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** Quittung an das aufrufende System. */
|
||||
public record Receipt(long id, Instant receivedAt) {
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Receipt receive(
|
||||
@RequestBody JsonNode payload,
|
||||
@RequestHeader(name = "X-Webhook-Token", required = false) String token) {
|
||||
|
||||
requireToken(token);
|
||||
|
||||
if (payload == null || payload.isNull()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Leere Nutzlast.");
|
||||
}
|
||||
|
||||
// Die Größe wird erst nach dem Parsen geprüft; das Feld begrenzt vor allem,
|
||||
// was sich im Verlauf ansammeln und im Browser anzeigen lässt.
|
||||
int size = mapper.writeValueAsString(payload).getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
|
||||
if (size > properties.maxSize()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
"Nutzlast ist zu groß (%d Byte, erlaubt sind %d).".formatted(size, properties.maxSize()));
|
||||
}
|
||||
|
||||
WebhookEvent event = service.record(payload);
|
||||
return new Receipt(event.id(), event.receivedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Strom der eingehenden Nachrichten für die Startseite. Der Browser schickt
|
||||
* beim Wiederverbinden von selbst {@code Last-Event-ID} mit; alles Jüngere
|
||||
* wird dann nachgeliefert.
|
||||
*/
|
||||
@GetMapping(path = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public ResponseEntity<SseEmitter> events(
|
||||
@RequestHeader(name = "Last-Event-ID", required = false) String lastEventId) {
|
||||
|
||||
Long since = null;
|
||||
try {
|
||||
if (lastEventId != null && !lastEventId.isBlank()) since = Long.parseLong(lastEventId.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
// Unbrauchbare Id: dann eben ohne Nachlieferung, statt den Aufruf abzulehnen.
|
||||
since = null;
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||
.cacheControl(org.springframework.http.CacheControl.noCache().mustRevalidate())
|
||||
// Ohne diesen Kopf sammelt nginx die Ereignisse im Puffer und gibt sie erst
|
||||
// aus, wenn genug beisammen ist – die Nachricht erschiene dann verspätet
|
||||
// oder gar nicht. nginx wertet ihn aus, andere Proxys ignorieren ihn.
|
||||
.header("X-Accel-Buffering", "no")
|
||||
.header(HttpHeaders.CONNECTION, "keep-alive")
|
||||
.body(service.subscribe(since));
|
||||
}
|
||||
|
||||
/** Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten zeigt. */
|
||||
@GetMapping("/history")
|
||||
public List<WebhookEvent> history() {
|
||||
return service.history();
|
||||
}
|
||||
|
||||
/** Leert den Verlauf; die Startseite bietet das als „Leeren" an. */
|
||||
@DeleteMapping("/history")
|
||||
public int clear() {
|
||||
return service.clear();
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
if (!properties.isSecured()) return;
|
||||
// Konstante Laufzeit ist hier zweitrangig; der Vergleich läuft gegen ein
|
||||
// Geheimnis fester Länge und hinter einer Netzgrenze.
|
||||
if (token == null || !token.equals(properties.token())) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Ungültiges oder fehlendes X-Webhook-Token.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.appcreation.swyxweb.webhook;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Eine über den Webhook eingegangene Nachricht.
|
||||
*
|
||||
* @param id fortlaufend ab 1; der Browser meldet sie beim Wiederverbinden
|
||||
* als {@code Last-Event-ID} zurück, damit nichts verloren geht
|
||||
* @param receivedAt Eingangszeit im Backend
|
||||
* @param payload das empfangene JSON, unverändert
|
||||
*/
|
||||
public record WebhookEvent(long id, Instant receivedAt, JsonNode payload) {
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package de.appcreation.swyxweb.webhook;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import de.appcreation.swyxweb.config.WebhookProperties;
|
||||
|
||||
/**
|
||||
* Nimmt die Nachrichten des Webhooks entgegen und verteilt sie an die offenen
|
||||
* Browser.
|
||||
*
|
||||
* <p>Der Weg zum Browser sind <b>Server-Sent Events</b>: Die Startseite hält
|
||||
* eine {@code EventSource} auf {@code /api/webhook/events} offen. Das ist
|
||||
* einseitig – genau das, was hier gebraucht wird –, läuft über dieselbe
|
||||
* HTTPS-Verbindung wie die Seite und wird vom Browser nach einem Abbruch von
|
||||
* selbst wieder aufgebaut. Die WebSocket-Verbindung der Startseite gehört
|
||||
* dagegen der SwyxTray-App und steht dafür nicht zur Verfügung.
|
||||
*
|
||||
* <p>Die letzten {@link WebhookProperties#history()} Nachrichten bleiben im
|
||||
* Speicher. Das hat zwei Gründe: Ein erst später geöffneter Bereich zeigt
|
||||
* trotzdem, was vorher ankam, und nach einem Verbindungsabbruch lässt sich
|
||||
* anhand der {@code Last-Event-ID} nachliefern, was in der Zwischenzeit
|
||||
* eingegangen ist. Über einen Neustart hinaus wird nichts aufgehoben.
|
||||
*/
|
||||
@Service
|
||||
public class WebhookService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebhookService.class);
|
||||
|
||||
/** Ein Doppelpunkt-Kommentar hält die Verbindung durch Proxys hindurch offen. */
|
||||
private static final long HEARTBEAT_SECONDS = 25;
|
||||
|
||||
private final WebhookProperties properties;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
private final AtomicLong nextId = new AtomicLong(1);
|
||||
/** Jüngste Nachricht zuletzt. Zugriff immer unter dem Monitor dieses Feldes. */
|
||||
private final Deque<WebhookEvent> events = new ArrayDeque<>();
|
||||
private final List<SseEmitter> subscribers = new CopyOnWriteArrayList<>();
|
||||
|
||||
private final ScheduledExecutorService heartbeat =
|
||||
Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "webhook-heartbeat");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
public WebhookService(WebhookProperties properties, ObjectMapper mapper) {
|
||||
this.properties = properties;
|
||||
this.mapper = mapper;
|
||||
heartbeat.scheduleWithFixedDelay(
|
||||
this::sendHeartbeat, HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
if (properties.isSecured()) {
|
||||
log.info("Webhook: Aufrufe müssen den Kopf X-Webhook-Token mitschicken.");
|
||||
} else {
|
||||
log.warn("Webhook: app.webhook.token ist nicht gesetzt – POST /api/webhook nimmt "
|
||||
+ "jeden Aufruf an. Für eine öffentlich erreichbare Instanz ein Token setzen.");
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdown() {
|
||||
heartbeat.shutdownNow();
|
||||
subscribers.forEach(SseEmitter::complete);
|
||||
subscribers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine Nachricht an, hängt sie an den Verlauf und schickt sie sofort
|
||||
* an alle offenen Browser.
|
||||
*
|
||||
* @return das angelegte Ereignis, damit der Aufrufer die Id quittieren kann
|
||||
*/
|
||||
public WebhookEvent record(JsonNode payload) {
|
||||
WebhookEvent event = new WebhookEvent(nextId.getAndIncrement(), Instant.now(), payload);
|
||||
|
||||
synchronized (events) {
|
||||
events.addLast(event);
|
||||
while (events.size() > Math.max(1, properties.history())) {
|
||||
events.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Webhook: Nachricht {} angenommen ({} Empfänger).", event.id(), subscribers.size());
|
||||
broadcast(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/** Verlauf, älteste zuerst. */
|
||||
public List<WebhookEvent> history() {
|
||||
synchronized (events) {
|
||||
return List.copyOf(events);
|
||||
}
|
||||
}
|
||||
|
||||
/** Leert den Verlauf. Die offenen Browser behalten, was sie schon zeigen. */
|
||||
public int clear() {
|
||||
synchronized (events) {
|
||||
int removed = events.size();
|
||||
events.clear();
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet einen Browser an.
|
||||
*
|
||||
* @param lastEventId Id der zuletzt beim Browser angekommenen Nachricht oder
|
||||
* {@code null}. Alles Jüngere wird sofort nachgeliefert –
|
||||
* so überbrückt ein Wiederverbinden die Lücke.
|
||||
*/
|
||||
public SseEmitter subscribe(Long lastEventId) {
|
||||
// Kein Zeitlimit: Der Browser hält die Verbindung, der Heartbeat hält sie am Leben.
|
||||
SseEmitter emitter = new SseEmitter(0L);
|
||||
emitter.onCompletion(() -> subscribers.remove(emitter));
|
||||
emitter.onTimeout(() -> {
|
||||
subscribers.remove(emitter);
|
||||
emitter.complete();
|
||||
});
|
||||
emitter.onError(e -> subscribers.remove(emitter));
|
||||
|
||||
List<WebhookEvent> missed = new ArrayList<>();
|
||||
if (lastEventId != null) {
|
||||
for (WebhookEvent event : history()) {
|
||||
if (event.id() > lastEventId) missed.add(event);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Ohne erste Nachricht bliebe die Antwort ohne Kopfzeilen hängen.
|
||||
emitter.send(SseEmitter.event().comment("verbunden"));
|
||||
for (WebhookEvent event : missed) {
|
||||
emitter.send(toSse(event));
|
||||
}
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
emitter.completeWithError(e);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
subscribers.add(emitter);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/** Anzahl der offenen Browser-Verbindungen – für Diagnose und Test. */
|
||||
public int subscriberCount() {
|
||||
return subscribers.size();
|
||||
}
|
||||
|
||||
private void broadcast(WebhookEvent event) {
|
||||
SseEmitter.SseEventBuilder message = toSse(event);
|
||||
for (SseEmitter emitter : subscribers) {
|
||||
try {
|
||||
emitter.send(message);
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
// Browser weg oder Verbindung tot: abräumen, der Rest bekommt trotzdem alles.
|
||||
subscribers.remove(emitter);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendHeartbeat() {
|
||||
for (SseEmitter emitter : subscribers) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().comment("ping"));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
subscribers.remove(emitter);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Nutzlast wird hier selbst zu Text gemacht und als solcher gesendet –
|
||||
* über {@code MediaType.APPLICATION_JSON} würde Spring den String ein
|
||||
* zweites Mal in Anführungszeichen setzen.
|
||||
*/
|
||||
private SseEmitter.SseEventBuilder toSse(WebhookEvent event) {
|
||||
return SseEmitter.event()
|
||||
.id(Long.toString(event.id()))
|
||||
.name("webhook")
|
||||
.data(mapper.writeValueAsString(event));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.appcreation.swyxweb.websocket;
|
||||
|
||||
/**
|
||||
* Adresseintrag im Mock – Feldnamen wie in der Quittung auf {@code contacts}:
|
||||
* {@code {"name":"Abt, Bettina","number":"7587","description":"S-SB"}}.
|
||||
*
|
||||
* <p>Die echte SwyxTray-App liest diese Einträge aus dem Telefonbuch des
|
||||
* Swyx-Clients; leere Felder schickt sie als {@code ""}, nicht als {@code null}.
|
||||
*/
|
||||
public record MockContact(String name, String number, String description) {
|
||||
|
||||
/** Trifft der Suchbegriff? Teilzeichenkette in Name und Rufnummer, Schreibweise egal. */
|
||||
public boolean matches(String lowerCaseQuery) {
|
||||
return name.toLowerCase().contains(lowerCaseQuery)
|
||||
|| number.toLowerCase().contains(lowerCaseQuery);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -13,8 +14,11 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import de.appcreation.swyxweb.config.MockProfile;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
@@ -38,7 +42,24 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
* <p>Seit Protokoll 6 beantwortet der Mock zusätzlich {@code tabs}, {@code opentab} und
|
||||
* {@code closetab}. Die echte App reicht diese Kommandos an das Firefox-Plugin durch;
|
||||
* der Mock spielt das Plugin und führt eine Liste vorgetäuschter Tabs im Speicher.
|
||||
*
|
||||
* <p>Seit Protokoll 7 beantwortet er {@code contacts} aus einem fest eingebauten
|
||||
* Telefonbuch – mit denselben Regeln wie die echte App: Teilzeichenkette in Name und
|
||||
* Rufnummer, Schreibweise egal, nach Namen sortiert und bei
|
||||
* {@link #CONTACT_RESULT_LIMIT} Treffern stillschweigend gekürzt.
|
||||
*
|
||||
* <p>Seit Protokoll 8 gibt er dasselbe Telefonbuch als <b>Adress-Cache</b> am Stück
|
||||
* heraus: auf {@code addresses} und zusätzlich unaufgefordert als
|
||||
* {@code {"type":"addresses","addresses":[…]}} direkt nach dem ersten Snapshot.
|
||||
* Über {@code POST /api/mock/address-cache?filled=false} lässt sich der Cache leeren –
|
||||
* dann verhält sich der Mock wie die zurzeit laufende Anlage, deren Cache leer ist,
|
||||
* und die Startseite muss auf die Einzelabfragen ausweichen.
|
||||
*
|
||||
* <p><b>Nur mit dem Profil {@link MockProfile#NAME}.</b> Ohne dieses Profil gibt es
|
||||
* die Bean nicht, {@code /ws} ist dann nicht belegt – der Mock kann also nicht
|
||||
* versehentlich in einer Produktivumgebung mitlaufen.
|
||||
*/
|
||||
@Profile(MockProfile.NAME)
|
||||
@Component
|
||||
public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
|
||||
@@ -51,8 +72,17 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
private static final List<String> MOCK_WINDOWS = List.of(
|
||||
"SwyxIt!", "SwyxWeb – Google Chrome", "Kundenakte Muster GmbH");
|
||||
|
||||
/** Protokollstand der echten App seit der Tab-Verwaltung. */
|
||||
private static final int PROTOCOL_VERSION = 6;
|
||||
/** Protokollstand der echten App seit dem Adress-Cache. */
|
||||
private static final int PROTOCOL_VERSION = 8;
|
||||
|
||||
/** So viele Treffer meldet die echte App höchstens; gekürzt wird ohne Hinweis. */
|
||||
private static final int CONTACT_RESULT_LIMIT = 100;
|
||||
|
||||
/** Längere Suchbegriffe weist die echte App mit einem Fehler zurück. */
|
||||
private static final int CONTACT_QUERY_MAX_LENGTH = 128;
|
||||
|
||||
/** Vorgetäuschtes Telefonbuch; siehe {@link #buildDirectory()}. */
|
||||
private static final List<MockContact> MOCK_CONTACTS = buildDirectory();
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
|
||||
@@ -61,6 +91,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
private final Map<Integer, MockTab> tabs = Collections.synchronizedMap(new LinkedHashMap<>());
|
||||
private final AtomicInteger nextTabId = new AtomicInteger(41);
|
||||
private final AtomicInteger nextSession = new AtomicInteger();
|
||||
// Ist der Adress-Cache gefüllt? Zum Umschalten auf die Rückfallebene.
|
||||
private volatile boolean addressCacheFilled = true;
|
||||
// Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist.
|
||||
private final Object sendLock = new Object();
|
||||
|
||||
@@ -81,6 +113,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
synchronized (sendLock) {
|
||||
sendText(session, toJson(hello()));
|
||||
sendText(session, toJson(snapshot()));
|
||||
// Der Cache kommt unaufgefordert hinterher – wie bei der echten App.
|
||||
sendText(session, toJson(addressesPush()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +168,15 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
sendText(session, toJson(result(id, true, null, null)));
|
||||
return;
|
||||
}
|
||||
// Adressdaten des Swyx-Clients; auch sie rühren keine Leitung an.
|
||||
case "contacts" -> {
|
||||
sendText(session, toJson(contactsResult(id, searchContacts(request.path("query")))));
|
||||
return;
|
||||
}
|
||||
case "addresses" -> {
|
||||
sendText(session, toJson(addressesResult(id, addressCache())));
|
||||
return;
|
||||
}
|
||||
default -> { /* weiter unten */ }
|
||||
}
|
||||
|
||||
@@ -262,6 +305,89 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
return tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das vorgetäuschte Telefonbuch. Es ist bewusst groß genug, dass der
|
||||
* Bereich „Adressdaten" wie gegen die echte Anlage arbeiten muss: einstellige
|
||||
* Abfragen hängen am Deckel und werden verfeinert, zweistellige nicht.
|
||||
*
|
||||
* <p>Drei Sonderfälle sind absichtlich dabei:
|
||||
* <ul>
|
||||
* <li>dieselbe Person mit <b>zwei Durchwahlen</b> – Namen sind nicht eindeutig,</li>
|
||||
* <li>Einträge <b>ohne Beschreibung</b> (die echte App schickt dafür {@code ""}),</li>
|
||||
* <li>eine <b>einstellige</b> Rufnummer, die in keinem Ziffernpaar vorkommt und
|
||||
* deshalb nur über die einstelligen Abfragen gefunden wird.</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static List<MockContact> buildDirectory() {
|
||||
List<String> surnames = List.of(
|
||||
"Abt", "Ahrens", "Bauer", "Becker", "Beyer", "Cordes", "Dahme", "Diehl",
|
||||
"Erbert", "Franke", "Gast", "Geier", "Grote", "Hansper", "Heymann", "Hill",
|
||||
"Kaiser", "Lorenz", "Meyer", "Neumann", "Otto", "Peters", "Richter", "Schulz",
|
||||
"Thiel", "Ulrich", "Vogel", "Weber", "Zeller", "Zimmer");
|
||||
List<String> given = List.of("Anna", "Bernd", "Claudia", "Dirk", "Erika");
|
||||
List<String> sites = List.of("HH-MT", "B-SB", "HB-GT", "L-SB", "S-MT");
|
||||
|
||||
List<MockContact> entries = new ArrayList<>();
|
||||
int number = 4100;
|
||||
for (String surname : surnames) {
|
||||
for (String first : given) {
|
||||
entries.add(new MockContact(surname + ", " + first, String.valueOf(number),
|
||||
sites.get(entries.size() % sites.size())));
|
||||
// Lücken wie in einer gewachsenen Anlage – sonst lägen die
|
||||
// Nummern so dicht, dass auch Ziffernpaare am Deckel hingen.
|
||||
number += 7;
|
||||
}
|
||||
}
|
||||
entries.add(new MockContact("Hotline_HH-MT_Verwaltung", "41240", "HH-MT"));
|
||||
entries.add(new MockContact("Muster, Max", "4711", ""));
|
||||
entries.add(new MockContact("Muster, Max", "53119", "B-GT"));
|
||||
entries.add(new MockContact("Zentrale", "0", ""));
|
||||
return List.copyOf(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simuliert {@code contacts}. Die echte App verlangt einen nicht leeren
|
||||
* Suchbegriff von höchstens {@value #CONTACT_QUERY_MAX_LENGTH} Zeichen und
|
||||
* beantwortet beide Verstöße mit derselben Meldung.
|
||||
*/
|
||||
private List<MockContact> searchContacts(JsonNode query) {
|
||||
String wanted = query.isString() ? query.stringValue().trim() : "";
|
||||
if (wanted.isEmpty() || wanted.length() > CONTACT_QUERY_MAX_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
"Feld 'query' fehlt oder ist zu lang (max. " + CONTACT_QUERY_MAX_LENGTH + " Zeichen).");
|
||||
}
|
||||
String needle = wanted.toLowerCase();
|
||||
List<MockContact> hits = MOCK_CONTACTS.stream()
|
||||
.filter(contact -> contact.matches(needle))
|
||||
.sorted(Comparator.comparing(MockContact::name).thenComparing(MockContact::number))
|
||||
.limit(CONTACT_RESULT_LIMIT)
|
||||
.toList();
|
||||
log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted);
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** Der Adress-Cache – leer, solange er abgeschaltet ist. */
|
||||
private List<MockContact> addressCache() {
|
||||
return addressCacheFilled ? MOCK_CONTACTS : List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schaltet den Adress-Cache um und schickt ihn allen Verbundenen neu – so
|
||||
* lässt sich beides prüfen: der Cache-Weg und die Rückfallebene über
|
||||
* {@code contacts}.
|
||||
*
|
||||
* @return Anzahl der Einträge im Cache danach
|
||||
*/
|
||||
public int setAddressCacheFilled(boolean filled) {
|
||||
addressCacheFilled = filled;
|
||||
log.info("SwyxTray-Mock: Adress-Cache {}", filled ? "gefüllt" : "geleert");
|
||||
String json = toJson(addressesPush());
|
||||
synchronized (sendLock) {
|
||||
sessions.values().forEach(session -> sendText(session, json));
|
||||
}
|
||||
return addressCache().size();
|
||||
}
|
||||
|
||||
/** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */
|
||||
public LineState simulateIncomingCall(String number, String name) {
|
||||
int line = freeLine();
|
||||
@@ -364,6 +490,34 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Quittung auf {@code contacts}; trägt die gefundenen Adressdaten. */
|
||||
private Map<String, Object> contactsResult(int id, List<MockContact> entries) {
|
||||
Map<String, Object> message = new LinkedHashMap<>();
|
||||
message.put("id", id);
|
||||
message.put("ok", true);
|
||||
message.put("contacts", entries);
|
||||
message.put("type", "result");
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Quittung auf {@code addresses}; trägt den gesamten Cache. */
|
||||
private Map<String, Object> addressesResult(int id, List<MockContact> entries) {
|
||||
Map<String, Object> message = new LinkedHashMap<>();
|
||||
message.put("id", id);
|
||||
message.put("ok", true);
|
||||
message.put("addresses", entries);
|
||||
message.put("type", "result");
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Unaufgeforderte Cache-Nachricht – ohne {@code id}, ohne {@code ok}. */
|
||||
private Map<String, Object> addressesPush() {
|
||||
Map<String, Object> message = new LinkedHashMap<>();
|
||||
message.put("addresses", addressCache());
|
||||
message.put("type", "addresses");
|
||||
return message;
|
||||
}
|
||||
|
||||
private Map<String, Object> result(int id, boolean ok, Integer line, String error) {
|
||||
Map<String, Object> message = new LinkedHashMap<>();
|
||||
message.put("id", id);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
spring.application.name=swyxweb-backend
|
||||
|
||||
# Port dieser Anwendung (REST-API und der eingebaute Test-Endpunkt /ws).
|
||||
# Der eingebaute SwyxTray-Mock (/ws und /api/mock/**) läuft nur mit dem Profil
|
||||
# "mock" – bewusst nicht voreingestellt, damit er nicht in einer Produktiv-
|
||||
# umgebung mitläuft. Einschalten zum Testen ohne Telefonanlage:
|
||||
# ./mvnw spring-boot:run -Dspring-boot.run.profiles=mock
|
||||
# java -jar app.jar --spring.profiles.active=mock
|
||||
# SPRING_PROFILES_ACTIVE=mock
|
||||
|
||||
# Port dieser Anwendung (REST-API und, mit Profil "mock", der Endpunkt /ws).
|
||||
server.address=0.0.0.0
|
||||
server.port=8080
|
||||
|
||||
@@ -11,4 +18,16 @@ app.websocket.port=17654
|
||||
app.websocket.path=/ws
|
||||
app.websocket.secure=false
|
||||
|
||||
# Webhook, über den ein fremdes System Adressdaten an die Startseite schickt:
|
||||
# POST /api/webhook. Ist ein Token gesetzt, muss der Aufrufer es im Kopf
|
||||
# X-Webhook-Token mitschicken; leer heißt, dass jeder Aufruf angenommen wird.
|
||||
# Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein
|
||||
# (oder von außen: APP_WEBHOOK_TOKEN).
|
||||
app.webhook.token=
|
||||
# So viele Nachrichten hält das Backend vor, damit ein später geöffneter
|
||||
# Bereich die vorigen noch sieht. Nach einem Neustart ist der Verlauf leer.
|
||||
app.webhook.history=50
|
||||
# Größte erlaubte Nutzlast in Byte.
|
||||
app.webhook.max-size=262144
|
||||
|
||||
logging.level.de.appcreation.swyxweb=DEBUG
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import de.appcreation.swyxweb.web.MockController;
|
||||
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
@SpringBootTest
|
||||
class BackendApplicationTests {
|
||||
|
||||
@Autowired
|
||||
ApplicationContext context;
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ohne das Profil „mock" – also so, wie die Anwendung im Container läuft –
|
||||
* darf der SwyxTray-Mock nicht im Kontext stehen.
|
||||
*/
|
||||
@Test
|
||||
void mockIsAbsentWithoutItsProfile() {
|
||||
assertThat(context.getBeansOfType(SwyxTrayMockHandler.class)).isEmpty();
|
||||
assertThat(context.getBeansOfType(MockController.class)).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import de.appcreation.swyxweb.web.MockController;
|
||||
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* Gegenstück zu {@link BackendApplicationTests#mockIsAbsentWithoutItsProfile()}:
|
||||
* mit dem Profil „mock" muss der SwyxTray-Mock vollständig da sein.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("mock")
|
||||
class MockProfileTests {
|
||||
|
||||
@Autowired
|
||||
ApplicationContext context;
|
||||
|
||||
@Test
|
||||
void mockIsPresentWithItsProfile() {
|
||||
assertThat(context.getBeansOfType(SwyxTrayMockHandler.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(MockController.class)).isNotEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import de.appcreation.swyxweb.webhook.WebhookService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
/**
|
||||
* Der Webhook nimmt beliebiges JSON an und hält es im Verlauf vor. Ohne
|
||||
* gesetztes Token ist er offen – so läuft er ohne Zutun des aufrufenden Systems.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class WebhookControllerTests {
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@Autowired
|
||||
WebhookService service;
|
||||
|
||||
@BeforeEach
|
||||
void emptyHistory() {
|
||||
service.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsJsonAndReturnsReceipt() throws Exception {
|
||||
mvc.perform(post("/api/webhook")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"Muster GmbH\",\"number\":\"+493012345\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").isNumber())
|
||||
.andExpect(jsonPath("$.receivedAt").exists());
|
||||
|
||||
assertThat(service.history()).hasSize(1);
|
||||
}
|
||||
|
||||
/** Kein festes Schema: Auch eine Liste ist gültige Nutzlast. */
|
||||
@Test
|
||||
void acceptsAnyJsonShape() throws Exception {
|
||||
mvc.perform(post("/api/webhook")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("[{\"name\":\"Abt, Bettina\",\"number\":\"7587\"}]"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(service.history()).hasSize(1);
|
||||
assertThat(service.history().getFirst().payload().isArray()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyKeepsOrderAndCanBeCleared() throws Exception {
|
||||
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{\"n\":1}"))
|
||||
.andExpect(status().isOk());
|
||||
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{\"n\":2}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mvc.perform(get("/api/webhook/history"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(2))
|
||||
.andExpect(jsonPath("$[0].payload.n").value(1))
|
||||
.andExpect(jsonPath("$[1].payload.n").value(2));
|
||||
|
||||
mvc.perform(delete("/api/webhook/history"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").value(2));
|
||||
|
||||
assertThat(service.history()).isEmpty();
|
||||
}
|
||||
|
||||
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
|
||||
@Test
|
||||
void tokenIsNotRequiredWhenUnset() throws Exception {
|
||||
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPayloadAboveTheLimit() throws Exception {
|
||||
// max-size steht auf 256 KiB; ein Wert deutlich darüber muss abgelehnt werden.
|
||||
String big = "{\"v\":\"" + "x".repeat(300_000) + "\"}";
|
||||
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content(big))
|
||||
.andExpect(status().isPayloadTooLarge());
|
||||
|
||||
assertThat(service.history()).isEmpty();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@TestPropertySource(properties = "app.webhook.token=geheim")
|
||||
class WithToken {
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@Test
|
||||
void rejectsCallWithoutToken() throws Exception {
|
||||
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongToken() throws Exception {
|
||||
mvc.perform(post("/api/webhook")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-Webhook-Token", "falsch")
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsCorrectToken() throws Exception {
|
||||
mvc.perform(post("/api/webhook")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-Webhook-Token", "geheim")
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user