diff --git a/CHANGELOG.md b/CHANGELOG.md index 7651621..1e238a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] +### Added +- Every table explains itself: a help bundle (`help/qbit-webhooks.yaml`, QQQ help-bundle format) says what each `webhooks_*` table is for and how its rows get created, including where event types come from. It shows on the table screen and when the table is empty. Applied by `WebhooksHelp` (STAND-IN for QRun-IO/qqq#1198); a host's own help is kept. +- A Setup card on the Webhooks home lists event types, encryption key, endpoints, signing secrets and subscriptions with their state and the next step. +- `WebhooksTablesGuardrailTest` (each table is used at runtime, seeded or explained, and has help) and a test that a previous secret stops signing at its Signs Until date even before the maintenance job retires it. +- Nothing is seeded, deliberately: the tables hold the owner's receivers and secrets, or logs. + ### Changed - Require QQQ 4.1.0-RC.5 and use its webhook signer, verifier, secret and verification exception (QRun-IO/qqq#1160, F11). Removed the internal signing package; Java consumers must import `com.kingsrook.qqq.backend.core.utils.webhooks` instead. - Signing wire format, timestamp tolerance, rotation, endpoint management, encrypted secret storage and delivery behavior are unchanged. diff --git a/README.md b/README.md index 8e17069..c67475d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,12 @@ Set `WEBHOOKS_SECRET_KEY` to the base64 of 32 random bytes, so the qbit can gene Deliveries and attempts are written only by the qbit. People act on them through the actions below. +## Setup and empty states + +Nothing in this qbit is seeded: endpoints, secrets and subscriptions are your choices, and deliveries and attempts are logs. Instead the app home has a **Setup** card (event types, encryption key, endpoints, signing secrets, subscriptions, each with its state and next step), and every table has help (`help/qbit-webhooks.yaml`) that explains how its rows get created and shows when the table is empty. + +The event types you can subscribe to come from code, not from a table: the host's `tableEventSources`, `processEventSources` and ESB settings, custom events it fires with `Webhooks.fire`, and a built-in Test event, all held in the `WebhooksRegistry`. + ## Permissions | Permission | Grants | diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..708193d --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,5 @@ +# Upgrading + +## Unreleased + +No action is needed. The qbit adds help to its tables and a Setup card to its app home when it is produced; it adds no tables, columns or data, and a host's own table or app help is kept. Nothing is seeded. diff --git a/src/main/java/com/kingsrook/qbits/webhooks/WebhooksQBitProducer.java b/src/main/java/com/kingsrook/qbits/webhooks/WebhooksQBitProducer.java index 93bcdad..344bc97 100644 --- a/src/main/java/com/kingsrook/qbits/webhooks/WebhooksQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/webhooks/WebhooksQBitProducer.java @@ -28,6 +28,7 @@ import java.util.Map; import com.kingsrook.qbits.webhooks.app.WebhooksApp; import com.kingsrook.qbits.webhooks.events.WebhooksProcessListener; import com.kingsrook.qbits.webhooks.events.WebhooksRecordChangeListener; +import com.kingsrook.qbits.webhooks.metadata.WebhooksHelp; import com.kingsrook.qbits.webhooks.metadata.WebhooksTableCustomizers; import com.kingsrook.qbits.webhooks.metadata.WebhooksTables; import com.kingsrook.qbits.webhooks.processes.WebhooksProcesses; @@ -209,6 +210,7 @@ public class WebhooksQBitProducer implements QBitMetaDataProducer attention = new ArrayList<>(); private final List attentionWhy = new ArrayList<>(); private final List recentDeadLetters = new ArrayList<>(); + private List setup = new ArrayList<>(); @@ -150,6 +151,7 @@ public final class WebhooksHomeData data.recentDeadLetters.addAll(query(WebhooksTables.DELIVERY, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, DeliveryStatus.DEAD_LETTER.name())) .withOrderBy(new QFilterOrderBy("id", false)).withLimit(5))); + data.setup = WebhooksSetupStatus.load(); return (data); } @@ -346,4 +348,15 @@ public final class WebhooksHomeData { return (recentDeadLetters); } + + + + + /******************************************************************************* + ** The setup steps (state and next step of each thing an admin configures). + *******************************************************************************/ + public List getSetup() + { + return (setup); + } } diff --git a/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeView.java b/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeView.java index e4dda57..a3316f2 100644 --- a/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeView.java +++ b/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeView.java @@ -85,6 +85,7 @@ public final class WebhooksHomeView public static String render(WebhooksHomeData data, String tablePrefix) { StringBuilder html = new StringBuilder("
"); + setup(html, data); kpis(html, data, tablePrefix); volume(html, data); attention(html, data, tablePrefix); @@ -94,6 +95,24 @@ public final class WebhooksHomeView + /******************************************************************************* + ** The Setup card: each thing to configure, its state and the next step. + *******************************************************************************/ + private static void setup(StringBuilder html, WebhooksHomeData data) + { + html.append("

Setup

"); + for(WebhooksSetupStatus.Step step : data.getSetup()) + { + String label = step.href() == null ? esc(step.label()) : "" + esc(step.label()) + ""; + String state = WebhooksSetupStatus.NEEDS_SETUP.equals(step.state()) ? "" + esc(step.state()) + "" + : WebhooksSetupStatus.SET_UP.equals(step.state()) ? "" + esc(step.state()) + "" : esc(step.state()); + html.append("
").append(cell("Step", label)).append(cell("State", state)).append(cell("Next step", esc(step.next()))).append("
"); + } + html.append("
"); + } + + + /******************************************************************************* ** The KPI tiles. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksSetupStatus.java b/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksSetupStatus.java new file mode 100644 index 0000000..42711ea --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/webhooks/app/WebhooksSetupStatus.java @@ -0,0 +1,146 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.webhooks.app; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.webhooks.WebhooksQBitConfig; +import com.kingsrook.qbits.webhooks.WebhooksRuntime; +import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.ActiveStatus; +import com.kingsrook.qbits.webhooks.metadata.WebhooksTables; +import com.kingsrook.qbits.webhooks.processes.WebhooksProcesses; +import com.kingsrook.qbits.webhooks.registry.WebhookEventType; +import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry; +import com.kingsrook.qbits.webhooks.secrets.EndpointSecrets; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** The Setup card on the Webhooks home: each thing an administrator configures + ** (event types, encryption key, endpoints, secrets, subscriptions), its state + ** and the next step. Nothing here is seeded: every step is the owner's choice. + *******************************************************************************/ +public final class WebhooksSetupStatus +{ + public static final String SET_UP = "Set up"; + public static final String NEEDS_SETUP = "Needs setup"; + public static final String OPTIONAL = "Optional"; + + + + /******************************************************************************* + ** One line of the card. + *******************************************************************************/ + public record Step(String label, String state, String next, String href) + { + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private WebhooksSetupStatus() + { + } + + + + /******************************************************************************* + ** Read the current state, as the viewer. + *******************************************************************************/ + public static List load() throws QException + { + WebhooksQBitConfig config = WebhooksRuntime.config(); + int eventTypes = 0; + for(WebhookEventType type : WebhooksRegistry.of(QContext.getQInstance()).getAll()) + { + if(!WebhooksProcesses.TEST_EVENT_TYPE.equals(type.getName())) + { + eventTypes++; + } + } + + int endpoints = count(WebhooksTables.ENDPOINT); + int subscriptions = count(WebhooksTables.SUBSCRIPTION); + int blocked = 0; + for(QRecord endpoint : new QueryAction().execute(new QueryInput(WebhooksTables.ENDPOINT)).getRecords()) + { + /////////////////////////////////////////////////////////////////////////////////// + // exactly DeliveryEngine's test: active, signing required, and no usable secret // + /////////////////////////////////////////////////////////////////////////////////// + if(ActiveStatus.ACTIVE.name().equals(endpoint.getValueString("activeStatus")) && !Boolean.TRUE.equals(endpoint.getValueBoolean("allowUnsigned")) + && EndpointSecrets.signingKeys(endpoint.getValueInteger("id")).isEmpty()) + { + blocked++; + } + } + return (steps(config.getTablePathPrefix(), config.getEncryptionKeyEnvName(), eventTypes, EndpointSecrets.canGenerate(), endpoints, blocked, subscriptions)); + } + + + + /******************************************************************************* + ** The steps, from the numbers (pure, for tests). + *******************************************************************************/ + static List steps(String prefix, String keyEnvName, int eventTypes, boolean canGenerate, int endpoints, int blockedEndpoints, int subscriptions) + { + String endpointHref = prefix + "/" + WebhooksTables.ENDPOINT; + String subscriptionHref = prefix + "/" + WebhooksTables.SUBSCRIPTION; + List steps = new ArrayList<>(); + steps.add(eventTypes > 0 + ? new Step("Event types", SET_UP, eventTypes + " available to subscribe to (from the code-side registry)", subscriptionHref) + : new Step("Event types", NEEDS_SETUP, "The host lists tables or processes that raise events in the qbit config (tableEventSources, processEventSources), or fires custom events", null)); + steps.add(canGenerate + ? new Step("Encryption key", SET_UP, "Generated signing secrets can be stored encrypted", null) + : new Step("Encryption key", OPTIONAL, "Set " + keyEnvName + " to generate secrets here, or use environment-variable secrets", null)); + steps.add(endpoints > 0 + ? new Step("Endpoints", SET_UP, endpoints + (endpoints == 1 ? " endpoint" : " endpoints"), endpointHref) + : new Step("Endpoints", NEEDS_SETUP, "Add an endpoint: the URL that receives events", endpointHref)); + steps.add(endpoints > 0 && blockedEndpoints == 0 + ? new Step("Signing secrets", SET_UP, "Every endpoint signs its deliveries", endpointHref) + : new Step("Signing secrets", NEEDS_SETUP, endpoints == 0 ? "Add an endpoint first, then give it a secret" + : blockedEndpoints + " active endpoint(s) have no usable signing secret, so their deliveries wait: open the endpoint and add one", endpointHref)); + steps.add(subscriptions > 0 + ? new Step("Subscriptions", SET_UP, subscriptions + (subscriptions == 1 ? " subscription" : " subscriptions"), subscriptionHref) + : new Step("Subscriptions", NEEDS_SETUP, "Subscribe an endpoint to an event type, or nothing is ever sent", subscriptionHref)); + return (steps); + } + + + + /******************************************************************************* + ** Count as the viewer. + *******************************************************************************/ + private static int count(String table) throws QException + { + Integer count = new CountAction().execute(new CountInput(table)).getCount(); + return (count == null ? 0 : count); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/webhooks/metadata/WebhooksHelp.java b/src/main/java/com/kingsrook/qbits/webhooks/metadata/WebhooksHelp.java new file mode 100644 index 0000000..73cd507 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/webhooks/metadata/WebhooksHelp.java @@ -0,0 +1,132 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.webhooks.metadata; + + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.kingsrook.qbits.webhooks.WebhooksQBitConfig; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** Applies help/qbit-webhooks.yaml (the QQQ help-bundle format) to the + ** Webhooks app and its webhooks_* tables: what each table is for and how its + ** rows get created, shown on the table screen and when it is empty. + ** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned + ** QQQ release includes it; the bundle file stays the same. + *******************************************************************************/ +public final class WebhooksHelp +{ + public static final String RESOURCE = "/help/qbit-webhooks.yaml"; + public static final String SLOT = "help"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private WebhooksHelp() + { + } + + + + /******************************************************************************* + ** The bundle's entries by key, validated (fails startup if malformed). + *******************************************************************************/ + public static Map entries() throws QException + { + try(InputStream stream = WebhooksHelp.class.getResourceAsStream(RESOURCE)) + { + if(stream == null) + { + throw (new IOException("missing resource")); + } + JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream); + if(!"qbit-webhooks".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText())) + { + throw (new IOException("invalid bundle header")); + } + Map entries = new LinkedHashMap<>(); + for(JsonNode entry : bundle.path("entries")) + { + String key = entry.path("key").asText(); + String summary = entry.path("summary").asText(); + if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null) + { + throw (new IOException("invalid or duplicate entry: " + key)); + } + } + return (entries); + } + catch(IOException | RuntimeException e) + { + throw (new QException("Could not load " + RESOURCE, e)); + } + } + + + + /******************************************************************************* + ** Apply the help to the app and every webhooks_* table in the instance. + ** A host's own help on a table or the app is kept (the bundle only fills + ** the slot). + *******************************************************************************/ + public static void apply(QInstance qInstance, WebhooksQBitConfig config) throws QException + { + Map entries = entries(); + for(QTableMetaData table : qInstance.getTables().values()) + { + JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT); + if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT))) + { + table.withHelpContent(SLOT, content(entry)); + } + } + QAppMetaData app = qInstance.getApp(config.getAppName()); + JsonNode appEntry = entries.get("app:webhooks;slot:" + SLOT); + if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT))) + { + app.withHelpContent(SLOT, content(appEntry)); + } + } + + + + /******************************************************************************* + ** Summary as the first paragraph, then the content, as markdown. + *******************************************************************************/ + static QHelpContent content(JsonNode entry) + { + return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip())); + } + +} diff --git a/src/main/resources/help/qbit-webhooks.yaml b/src/main/resources/help/qbit-webhooks.yaml new file mode 100644 index 0000000..6ad9423 --- /dev/null +++ b/src/main/resources/help/qbit-webhooks.yaml @@ -0,0 +1,38 @@ +bundle: qbit-webhooks +version: 1 +layer: QBIT +# Help for the Webhooks app and every webhooks_* table, in the QQQ help-bundle +# format (qqq#1198). Each table entry says what the table is for and how its rows +# get created, so an empty table explains itself. WebhooksHelp applies it until +# the core HelpBundles registry ships in a QQQ release (a stand-in until that ships). +# Summaries stay under 120 characters (WebhooksTablesGuardrailTest). +entries: + - key: app:webhooks;slot:help + summary: "Send signed HTTP notifications to other systems when things happen in this application." + content: | + The Setup card on the home lists each step with its state and the next step. Nothing is seeded: you choose where events go. Add an endpoint (a URL), give it a signing secret, then subscribe it to event types. The event types you can pick come from the code: the host lists the tables and processes that raise events in the qbit's configuration (`tableEventSources`, `processEventSources`, ESB topics) and can fire custom ones, and the qbit adds a Test event. + + - key: table:webhooks_endpoint;slot:help + summary: "A receiver URL that gets webhooks: its status, health, rate limit, timeout and unsigned or internal-network switches." + content: | + **How rows get created:** an administrator adds one with New (a name and an `https` URL). Then add a signing secret and a subscription. Send Test Event checks it end to end. Health moves on its own (Healthy, Probation, Unhealthy) as deliveries succeed or fail, and an endpoint that keeps failing on probation is disabled. + + - key: table:webhooks_endpoint_secret;slot:help + summary: "The signing secrets for an endpoint: generated and encrypted, or the name of an environment variable." + content: | + **How rows get created:** an administrator adds one on the endpoint, either a generated secret (needs the encryption key variable, `WEBHOOKS_SECRET_KEY` by default) or the name of an environment variable that holds it. The Rotate Signing Secret action adds a new one and keeps the old one signing until Signs Until, so receivers can switch over. Secrets past Signs Until stop signing and are retired by the maintenance job. Secret values are never shown here, only fingerprints. + + - key: table:webhooks_subscription;slot:help + summary: "Which events an endpoint receives: event type, optional record filter, detail level and field limits." + content: | + **How rows get created:** an administrator adds one on the endpoint or with New: pick the endpoint and an event type. The event types listed come from the code-side registry: `.inserted|updated|deleted` for each table in `tableEventSources`, `process..completed|failed` for `processEventSources`, ESB topics, custom events the host fires, and the Test event. A record filter limits which records fire it. + + - key: table:webhooks_delivery;slot:help + summary: "One row per event per endpoint: the stored payload, status, attempts, next attempt and last error." + content: | + **How rows get created:** written only by the qbit, when an event matches a subscription. Empty means no subscribed event has happened yet. Use Send Now, Cancel Delivery or Replay on dead letters to act on them; the Deliver Webhooks job sends them and retries on a schedule. + + - key: table:webhooks_delivery_attempt;slot:help + summary: "One row per HTTP attempt to an endpoint: outcome, status code, duration, response excerpt and error." + content: | + **How rows get created:** written only by the qbit each time it tries to send a delivery (the claim that stops two replicas sending the same attempt). Empty means nothing has been sent yet. Old rows are purged by the retention job. diff --git a/src/test/java/com/kingsrook/qbits/webhooks/WebhooksTablesGuardrailTest.java b/src/test/java/com/kingsrook/qbits/webhooks/WebhooksTablesGuardrailTest.java new file mode 100644 index 0000000..9ef5a30 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/webhooks/WebhooksTablesGuardrailTest.java @@ -0,0 +1,170 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.webhooks; + + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import com.kingsrook.qbits.webhooks.metadata.WebhooksHelp; +import com.kingsrook.qbits.webhooks.metadata.WebhooksTables; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Guardrail (owner requirement 2026-10-14: "all tables should be populated and + ** the qbit should be fully using them"). For every webhooks_* table: + ** - runtime code (not just metadata or the home screen) reads or writes it; + ** - it is either seeded by shipped defaults, or listed here with why it starts + ** empty (nothing in this qbit is seeded: endpoints, secrets and + ** subscriptions are the owner's choices, deliveries and attempts are logs); + ** - it has bundle help that says how its rows get created (shown on the table + ** screen and when it is empty). + ** A new table fails this test until it is wired, seeded or explained. + *******************************************************************************/ +class WebhooksTablesGuardrailTest +{ + private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "webhooks"); + + /******************************************************************************* + ** Tables that legitimately start empty, and where their rows come from. + *******************************************************************************/ + static final Map STARTS_EMPTY = Map.of( + WebhooksTables.ENDPOINT, "host-specific: an administrator adds each receiver URL", + WebhooksTables.SECRET, "secrets are never seeded: an administrator generates one or names an environment variable", + WebhooksTables.SUBSCRIPTION, "an administrator picks the event types an endpoint receives", + WebhooksTables.DELIVERY, "log: written when an event matches a subscription", + WebhooksTables.ATTEMPT, "log: written on every HTTP attempt"); + + + + /******************************************************************************* + ** Every table is read or written by runtime code (delivery, events, secrets, + ** processes), not just declared in metadata or shown on the home. + *******************************************************************************/ + @Test + void everyTableIsUsedAtRuntime() throws Exception + { + List runtimeSource; + try(Stream files = Files.walk(MAIN)) + { + runtimeSource = files.filter(p -> p.toString().endsWith(".java")) + .filter(p -> !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app"))) + .map(WebhooksTablesGuardrailTest::read).toList(); + } + for(String table : WebhooksTables.ALL) + { + String constant = "WebhooksTables." + constantFor(table); + assertThat(runtimeSource.stream().anyMatch(source -> source.contains(constant))) + .as(table + " must be read or written by runtime code, not only declared").isTrue(); + } + } + + + + /******************************************************************************* + ** Every table is seeded (none are) or explained. + *******************************************************************************/ + @Test + void everyTableIsSeededOrExplained() + { + assertThat(STARTS_EMPTY.keySet()).containsExactlyInAnyOrderElementsOf(WebhooksTables.ALL); + } + + + + /******************************************************************************* + ** Every table and the app have bundle help that says how rows get created. + *******************************************************************************/ + @Test + void everyTableHasEmptyStateHelp() throws Exception + { + QInstance instance = WebhooksTestSupport.setUp(); + assertThat(WebhooksHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120)); + for(String table : WebhooksTables.ALL) + { + List help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(WebhooksHelp.SLOT); + assertThat(help).as(table + " table help").isNotEmpty(); + assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created"); + } + assertThat(instance.getApp("webhooks").getHelpContent()).containsKey(WebhooksHelp.SLOT); + assertThat(WebhooksHelp.entries().get("table:webhooks_subscription;slot:help").path("content").asText()).contains("registry"); + assertThat(WebhooksHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null, + "help names only real tables"); + } + + + + /******************************************************************************* + ** The WebhooksTables constant name of a table. + *******************************************************************************/ + private static String constantFor(String table) + { + return (switch(table) + { + case WebhooksTables.ENDPOINT -> "ENDPOINT"; + case WebhooksTables.SECRET -> "SECRET"; + case WebhooksTables.SUBSCRIPTION -> "SUBSCRIPTION"; + case WebhooksTables.DELIVERY -> "DELIVERY"; + case WebhooksTables.ATTEMPT -> "ATTEMPT"; + default -> throw new IllegalStateException("no constant for " + table); + }); + } + + + + /******************************************************************************* + ** Read a file. + *******************************************************************************/ + private static String read(Path path) + { + try + { + return (Files.readString(path, StandardCharsets.UTF_8)); + } + catch(Exception e) + { + throw (new IllegalStateException(e)); + } + } + + + /******************************************************************************* + ** A host's own help in the slot is kept; the bundle only fills empty slots. + *******************************************************************************/ + @Test + void hostHelpIsKept() throws Exception + { + QInstance instance = WebhooksTestSupport.setUp(); + instance.getTable(WebhooksTables.ENDPOINT).withHelpContent(WebhooksHelp.SLOT, new QHelpContent().withContentAsMarkdown("host text")); + instance.getApp("webhooks").withHelpContent(WebhooksHelp.SLOT, new QHelpContent().withContentAsMarkdown("host app text")); + WebhooksHelp.apply(instance, WebhooksRuntime.config(instance)); + assertThat(instance.getTable(WebhooksTables.ENDPOINT).getHelpContent().get(WebhooksHelp.SLOT)).singleElement().extracting(QHelpContent::getContent).isEqualTo("host text"); + assertThat(instance.getApp("webhooks").getHelpContent().get(WebhooksHelp.SLOT)).singleElement().extracting(QHelpContent::getContent).isEqualTo("host app text"); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeTest.java b/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeTest.java index 45e62f1..1777a03 100644 --- a/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeTest.java +++ b/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksHomeTest.java @@ -90,7 +90,7 @@ class WebhooksHomeTest void emptyHome() throws Exception { String html = home(); - assertThat(html).contains("No endpoints yet").contains("/admin/webhooks/webhooks_endpoint").contains("No delivery attempts in the last 24 hours") + assertThat(html).contains("Setup").contains("Needs setup").contains("No endpoints yet").contains("/admin/webhooks/webhooks_endpoint").contains("No delivery attempts in the last 24 hours") .contains("No dead letters").contains("None yet"); } diff --git a/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksSetupStatusTest.java b/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksSetupStatusTest.java new file mode 100644 index 0000000..5b671eb --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/webhooks/app/WebhooksSetupStatusTest.java @@ -0,0 +1,132 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.webhooks.app; + + +import java.time.Duration; +import java.util.List; +import com.kingsrook.qbits.webhooks.WebhooksTestSupport; +import com.kingsrook.qbits.webhooks.app.WebhooksSetupStatus.Step; +import com.kingsrook.qbits.webhooks.metadata.WebhooksTables; +import com.kingsrook.qbits.webhooks.secrets.EndpointSecrets; +import com.kingsrook.qbits.webhooks.support.SystemWrites; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The Setup card: states follow what is configured, and it shows on the home. + *******************************************************************************/ +class WebhooksSetupStatusTest +{ + /******************************************************************************* + ** A fresh install: everything the administrator chooses needs setup, each + ** with a next step. + *******************************************************************************/ + @Test + void freshInstallNeedsSetup() + { + List steps = WebhooksSetupStatus.steps("/admin/webhooks", "WEBHOOKS_SECRET_KEY", 0, false, 0, 0, 0); + assertThat(steps).extracting(Step::label).containsExactly("Event types", "Encryption key", "Endpoints", "Signing secrets", "Subscriptions"); + assertThat(steps).extracting(Step::state).containsExactly("Needs setup", "Optional", "Needs setup", "Needs setup", "Needs setup"); + assertThat(steps).allSatisfy(step -> assertThat(step.next()).isNotBlank()); + assertThat(steps.get(1).next()).contains("WEBHOOKS_SECRET_KEY"); + } + + + + /******************************************************************************* + ** A secretless endpoint is called out; a complete setup is all set up. + *******************************************************************************/ + @Test + void progressIsReflected() + { + List partial = WebhooksSetupStatus.steps("/p", "K", 3, true, 2, 1, 0); + assertThat(partial).extracting(Step::state).containsExactly("Set up", "Set up", "Set up", "Needs setup", "Needs setup"); + assertThat(partial.get(3).next()).contains("1 active endpoint(s) have no usable signing secret"); + + List done = WebhooksSetupStatus.steps("/p", "K", 3, true, 2, 0, 4); + assertThat(done).extracting(Step::state).containsOnly("Set up"); + } + + + + /******************************************************************************* + ** The live card: an empty install needs endpoints, and an endpoint moves it + ** forward. + *******************************************************************************/ + @Test + void liveStatusFollowsTheTables() throws Exception + { + WebhooksTestSupport.setUp(); + List empty = WebhooksSetupStatus.load(); + assertThat(empty).filteredOn(step -> step.label().equals("Endpoints")).extracting(Step::state).containsExactly("Needs setup"); + assertThat(empty).filteredOn(step -> step.label().equals("Event types")).extracting(Step::state).containsExactly("Set up"); + + WebhooksTestSupport.endpoint("crm", "https://example.com/hook"); + assertThat(WebhooksSetupStatus.load()).filteredOn(step -> step.label().equals("Endpoints")).extracting(Step::state).containsExactly("Set up"); + } + + + + /******************************************************************************* + ** The secrets step counts blocked endpoints exactly as DeliveryEngine does: + ** active, signing required, no usable secret. An allowUnsigned endpoint, a + ** paused endpoint and one whose only secret has expired are judged by the + ** runtime's rules. + *******************************************************************************/ + @Test + void secretsStepMatchesTheRuntime() throws Exception + { + WebhooksTestSupport.setUp(); + Integer unsigned = WebhooksTestSupport.insert(WebhooksTables.ENDPOINT, new QRecord().withValue("name", "u").withValue("url", "https://example.com/u").withValue("allowUnsigned", true)).getValueInteger("id"); + assertThat(secretsState()).as("allowUnsigned with no secret is not blocked").isEqualTo("Set up"); + + Integer paused = WebhooksTestSupport.insert(WebhooksTables.ENDPOINT, new QRecord().withValue("name", "p").withValue("url", "https://example.com/p").withValue("activeStatus", "PAUSED")).getValueInteger("id"); + assertThat(paused).isNotNull(); + assertThat(secretsState()).as("a paused endpoint with no secret is not blocked").isEqualTo("Set up"); + + Integer rotating = WebhooksTestSupport.endpoint("r", "https://example.com/r"); + assertThat(secretsState()).as("a secret signs").isEqualTo("Set up"); + WebhooksTestSupport.CLOCK.advance(Duration.ofHours(25)); + EndpointSecrets.rotate(rotating, "again"); + WebhooksTestSupport.CLOCK.advance(Duration.ofHours(25)); + assertThat(unsigned).isNotNull(); + assertThat(EndpointSecrets.signingKeys(rotating)).hasSize(1); + + Integer onlyExpired = WebhooksTestSupport.insert(WebhooksTables.ENDPOINT, new QRecord().withValue("name", "e").withValue("url", "https://example.com/e")).getValueInteger("id"); + SystemWrites.insertOne(WebhooksTables.SECRET, new QRecord().withValue("endpointId", onlyExpired).withValue("status", "PREVIOUS").withValue("source", "REFERENCE") + .withValue("secretRef", "WEBHOOKS_TEST_NOPE").withValue("expiresDate", WebhooksTestSupport.CLOCK.instant().minus(Duration.ofHours(1)))); + assertThat(EndpointSecrets.signingKeys(onlyExpired)).isEmpty(); + assertThat(secretsState()).as("an endpoint whose only secret expired is blocked").isEqualTo("Needs setup"); + } + + + + /******************************************************************************* + ** The secrets step state. + *******************************************************************************/ + private static String secretsState() throws Exception + { + return (WebhooksSetupStatus.load().stream().filter(step -> step.label().equals("Signing secrets")).findFirst().orElseThrow().state()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/webhooks/delivery/SignedDeliveryTest.java b/src/test/java/com/kingsrook/qbits/webhooks/delivery/SignedDeliveryTest.java index 9bbea51..77ec74c 100644 --- a/src/test/java/com/kingsrook/qbits/webhooks/delivery/SignedDeliveryTest.java +++ b/src/test/java/com/kingsrook/qbits/webhooks/delivery/SignedDeliveryTest.java @@ -175,6 +175,25 @@ class SignedDeliveryTest + /******************************************************************************* + ** A previous secret stops signing when its Signs Until date passes, even + ** before the maintenance job retires it (expiresDate is enforced when + ** signing, not only when retiring). + *******************************************************************************/ + @Test + void expiredSecretStopsSigningBeforeItIsRetired() throws Exception + { + Integer endpointId = WebhooksTestSupport.endpoint("crm", receiver.url("/hook")); + EndpointSecrets.rotate(endpointId, "expiry test"); + assertThat(EndpointSecrets.signingKeys(endpointId)).hasSize(2); + + WebhooksTestSupport.CLOCK.advance(Duration.ofHours(25)); + assertThat(WebhooksTestSupport.all(WebhooksTables.SECRET)).extracting(r -> r.getValueString("status")).contains("PREVIOUS"); + assertThat(EndpointSecrets.signingKeys(endpointId)).hasSize(1); + } + + + /******************************************************************************* ** A retry keeps the same webhook-id (the idempotency key) and payload. *******************************************************************************/