Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
156ca565fc
|
||
|
|
7cfafd7d2f | ||
|
|
e88216de7e
|
||
|
|
0d326948fa | ||
|
|
0122fb9fc1
|
||
|
|
fea8f8d445
|
||
|
|
5be0e68ebb
|
No files matched your search
+5
-1
@@ -6,9 +6,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- Built on QQQ 4.1.0-RC.7 (default QQQ profile).
|
||||
- Built on QQQ 4.1.0-RC.8 (default QQQ profile).
|
||||
|
||||
### Added
|
||||
- Central `email.provider.<id>` and `email.readiness` health checks (DEPENDENCY, EMAIL, 300 seconds), using passive provider state and existing category readiness with deadline and secret-safety coverage. Standard Email health status widget links to central checks. Uses the QQQ 4.1.0-RC.8 health API.
|
||||
- Foreign keys between all ten tables in `docs/ddl/email-postgres.sql`, named `fk_<table>_<column>`. CASCADE: a provider's sender addresses and a message's attempts and events. RESTRICT: providers with messages, attempts, events or webhook inbox rows; a brand used by a template; a template with versions. SET NULL: a message's sender address, template, version and brand; a template's active and approved version (the template/version cycle); a suppression's source message and event, so suppressions survive message retention. Ten new indexes cover the unindexed key columns. `docs/ddl/email-foreign-keys-upgrade.sql` adds the keys to an existing database. It first records orphan rows in `qqq_fk_quarantine`: nullable references are set to NULL, and rows whose required parent is missing are deleted and kept as JSON. The script is idempotent. The provider delete check now also refuses providers with attempts, events or inbox rows. `ForeignKeyGuardrailTest` (PostgreSQL 17 via Testcontainers) fails when a join or table possible-value field between email tables has no foreign key.
|
||||
- Starter defaults changelog `db/liquibase/qbit-email/email-defaults.changelog.sql` (insert-only, guarded): one non-default, high-contrast `neutral` brand, only when no brand exists. Tested through real Liquibase on H2, including that it never changes rendering.
|
||||
- Host-neutral in-app help bundle for the Email app, tables, fields, actions and widgets, registered through QQQ's help bundle API. Replaces the temporary inline loader, preserves record-creation explanations and supports HOST overrides. The coverage gate checks owned metadata, summaries and live references.
|
||||
@@ -50,3 +51,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
- QBIT_EMAIL core messaging bridge, Review/Result actions, backend send process, dispatch/maintenance/retention schedules and host integration declarations.
|
||||
- H2 concurrency and transaction tests, GreenMail end-to-end tests, fake SES tests, metadata/permission checks and process-runner coverage.
|
||||
- Repository scaffold to the QRun qbit repository standard (Apache-2.0, CircleCI qqq-orb 0.6.8 with the `configure-package-install` pre-step, GA-only `publish_release`, the `release-rc` caller workflow).
|
||||
|
||||
### Removed
|
||||
- The Email home's standalone Provider health listing, including per-provider failure messages, last-success details and rate-headroom queries. Passive delivery health and `email.provider.unhealthy` alerts are retained.
|
||||
@@ -13,7 +13,7 @@ qbit-object-storage is wired in.
|
||||
|
||||
## Install
|
||||
|
||||
Requires Java 21, QQQ **4.1.0-RC.7**, and a transactional RDBMS with the supplied unique
|
||||
Requires Java 21, QQQ **4.1.0-RC.8**, and a transactional RDBMS with the supplied unique
|
||||
constraints. Apply [the PostgreSQL DDL](docs/ddl/email-postgres.sql) before producing the qbit. SQL timestamps store UTC values without a timezone, matching QQQ RC.5.
|
||||
The physical table names equal their metadata names; `backendName` selects the host's
|
||||
QQQ database backend, as it does in qbit-webhooks.
|
||||
@@ -181,6 +181,31 @@ and credential failures pause the provider. Tracking is explicitly controlled by
|
||||
`trackOpens` / `trackClicks`, off by default. The producer registers the phase-(c)
|
||||
receiver idempotently when the provided Javalin middleware is present.
|
||||
|
||||
## Health checks
|
||||
|
||||
The producer registers these checks with QQQ central health monitoring:
|
||||
|
||||
| Name | Kind | Interval |
|
||||
|---|---|---|
|
||||
| `email.provider.<id>` (each enabled provider) | DEPENDENCY | 300 seconds |
|
||||
| `email.readiness` | DEPENDENCY | 300 seconds |
|
||||
|
||||
Both use component `email`, category `EMAIL`, and a 5-second timeout. Provider
|
||||
checks read passive delivery state only: HEALTHY maps to UP, UNHEALTHY to DOWN,
|
||||
and missing/disabled/unknown state to UNKNOWN. The transport SPI has no
|
||||
non-sending connectivity method; these checks never contact providers or send
|
||||
mail. Readiness reuses `EmailApi.readiness` for every `EmailApiCategory`; any
|
||||
unmet prerequisite is DEGRADED, with only enum names in `failedChecks`. Existing
|
||||
passive state and `email.provider.unhealthy` alerts remain in place.
|
||||
|
||||
The Email home uses a standard status widget reading the central `health_component`
|
||||
rollup, with a link to System Health's checks filtered to `email`. It no longer
|
||||
lists provider diagnostics. The host must install central health metadata and its
|
||||
schema; central reads use the viewer's health permissions.
|
||||
|
||||
This integration uses the core health API released in QQQ `4.1.0-RC.8`.
|
||||
Verify offline with `./mvnw -o verify` (`mvnw.cmd` on Windows).
|
||||
|
||||
## Sending from another qbit
|
||||
|
||||
There are two supported routes. Both queue through the durable outbox; a successful
|
||||
@@ -458,8 +483,8 @@ return 404. The legacy token covers every To/Cc/Bcc recipient, so use one mailbo
|
||||
per opt-out message for individual preferences. Preferences UI remains deferred
|
||||
until core #1162 ships in an RC.
|
||||
|
||||
The home uses viewer-scoped counts and queries, UTC hour/day charts, provider health
|
||||
(admins), and 20 recent messages with masked recipients. No sample data or sampled
|
||||
The home uses viewer-scoped counts and queries, UTC hour/day charts, a central
|
||||
health status line, and 20 recent messages with masked recipients. No sample data or sampled
|
||||
counts are used. Delivered/bounced/complained totals count recipient events and
|
||||
use their occurred dates; sent/dead-letter totals count messages.
|
||||
|
||||
@@ -612,7 +637,7 @@ PHI canary egress, encryption, preview widgets and home data.
|
||||
|
||||
## Durable jobs
|
||||
|
||||
Built on QQQ **4.1.0-RC.7**. Enable `DurableJobsMetaData` and define the jobs tables
|
||||
Built on QQQ **4.1.0-RC.8**. Enable `DurableJobsMetaData` and define the jobs tables
|
||||
**before** producing the email qbit: `EmailQBitProducer` automatically registers
|
||||
`EmailJobEffectTarget` under target name `email`. If jobs are enabled later, call
|
||||
`EmailJobEffectTarget.register(instance)` afterward. Registration is a no-op when
|
||||
|
||||
@@ -168,6 +168,23 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
@@ -261,7 +278,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -28,6 +28,7 @@ import java.util.Map;
|
||||
import com.kingsrook.qbits.email.app.EmailApp;
|
||||
import com.kingsrook.qbits.email.bridge.EmailOutboxMessagingProviderMetaData;
|
||||
import com.kingsrook.qbits.email.delivery.EmailAlerts;
|
||||
import com.kingsrook.qbits.email.health.EmailHealthChecks;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTableCustomizers;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.processes.EmailProcesses;
|
||||
@@ -197,6 +198,7 @@ public class EmailQBitProducer implements QBitMetaDataProducer<EmailQBitConfig>
|
||||
output.add(EmailApp.app(qBitConfig, tables));
|
||||
output.add(new EmailOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
|
||||
output.addSelfToInstance(qInstance);
|
||||
EmailHealthChecks.register(qInstance);
|
||||
// Registration is metadata-only; core's startup sync guards hosts without helpContent.
|
||||
HelpBundles.register(qInstance, "help/qbit-email.yaml");
|
||||
HelpBundles.of(qInstance).getBundles().stream().filter(bundle -> "qbit-email".equals(bundle.getId()))
|
||||
|
||||
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.email.app;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.health.EmailHealthWidget;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -64,6 +65,7 @@ public final class EmailApp
|
||||
{
|
||||
return (List.of(
|
||||
widget(HOME_WIDGET, "Overview", WidgetType.HTML.getType(), HomeRenderer.class),
|
||||
widget("emailHealth", "Email health", WidgetType.STATISTICS.getType(), EmailHealthWidget.class),
|
||||
widget("emailTemplatePreview", "Preview", WidgetType.HTML.getType(), com.kingsrook.qbits.email.template.EmailPreviewWidget.class)));
|
||||
|
||||
}
|
||||
@@ -80,7 +82,7 @@ public final class EmailApp
|
||||
.withLabel(config.getAppLabel())
|
||||
.withIcon(new QIcon("mail"))
|
||||
.withPermissionRules(EmailTables.permissionRules())
|
||||
.withWidgets(List.of(HOME_WIDGET));
|
||||
.withWidgets(List.of("emailHealth", HOME_WIDGET));
|
||||
for(String name : EmailTables.MAIN)
|
||||
{
|
||||
if(tables.containsKey(name))
|
||||
|
||||
@@ -89,26 +89,13 @@ public final class EmailHome
|
||||
List<QRecord> messages = query(EmailTables.MESSAGE, new QQueryFilter().withOrderBy(new QFilterOrderBy("createDate", false))
|
||||
.withOrderBy(new QFilterOrderBy("id", false)).withLimit(20), List.of("id", "createDate", "toAddresses", "subject", "phiApproved", "status"));
|
||||
boolean admin = EmailPermissionChecker.has(EmailPermissions.ADMIN);
|
||||
List<QRecord> providers = admin ? query(EmailTables.PROVIDER, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", true)),
|
||||
List.of("id", "label", "name", "enabled", "healthStatus", "lastSuccessDate", "lastFailureMessage", "maxPerMinute")) : List.of();
|
||||
for(QRecord provider : providers)
|
||||
{
|
||||
Integer limit = provider.getValueInteger("maxPerMinute");
|
||||
if(limit != null)
|
||||
{
|
||||
int used = count(EmailTables.ATTEMPT, since("startDate", now.minusSeconds(60))
|
||||
.withCriteria("startDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
|
||||
.withCriteria("providerId", QCriteriaOperator.EQUALS, provider.getValue("id")));
|
||||
provider.setValue("headroom", Math.max(0, limit - used));
|
||||
}
|
||||
}
|
||||
List<EmailSetupStatus.Item> setup = List.of();
|
||||
if(admin)
|
||||
{
|
||||
EmailBuiltIns.ensure();
|
||||
setup = EmailSetupStatus.load();
|
||||
}
|
||||
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), providers, admin, messages, suppressions(), setup)));
|
||||
return (new RawHTML(null, EmailHomeView.render(totals, hourly(), messages, suppressions(), setup)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -42,8 +42,8 @@ public final class EmailHomeView
|
||||
private static final String WRAP = "min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;box-sizing:border-box;";
|
||||
private static final String CARD = WRAP + "padding:16px;border:1px solid rgba(127,127,127,.3);border-radius:8px;";
|
||||
private static final String ROW = WRAP + "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,12rem),1fr));gap:6px 16px;padding:10px 0;border-top:1px solid rgba(127,127,127,.2);";
|
||||
private static final String FAIL = "#d64545";
|
||||
private static final String OK = "#2e9e5b";
|
||||
private static final String FAIL = "#d64545";
|
||||
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("MMM d, HH:mm 'UTC'", Locale.ENGLISH).withZone(ZoneOffset.UTC);
|
||||
|
||||
|
||||
@@ -60,9 +60,9 @@ public final class EmailHomeView
|
||||
/*******************************************************************************
|
||||
** One ordered home with real, viewer-scoped data supplied by EmailHome.
|
||||
*******************************************************************************/
|
||||
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> providers, boolean admin, List<QRecord> messages, ChartData suppressions)
|
||||
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> messages, ChartData suppressions)
|
||||
{
|
||||
return (render(totals, hourly, providers, admin, messages, suppressions, List.of()));
|
||||
return (render(totals, hourly, messages, suppressions, List.of()));
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ public final class EmailHomeView
|
||||
/*******************************************************************************
|
||||
** The same home with the Setup card first (email admins), when there are items.
|
||||
*******************************************************************************/
|
||||
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> providers, boolean admin, List<QRecord> messages, ChartData suppressions, List<EmailSetupStatus.Item> setup)
|
||||
public static String render(List<Integer> totals, ChartData hourly, List<QRecord> messages, ChartData suppressions, List<EmailSetupStatus.Item> setup)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div style=\"" + WRAP + "display:flex;flex-direction:column;gap:16px;color:inherit;line-height:1.4\">");
|
||||
setup(html, setup);
|
||||
@@ -85,7 +85,6 @@ public final class EmailHomeView
|
||||
}
|
||||
html.append("</div>");
|
||||
bars(html, "Sends per hour, last 24 hours", hourly);
|
||||
providers(html, providers, admin);
|
||||
messages(html, messages);
|
||||
bars(html, "Suppressions added, last 30 days", suppressions);
|
||||
return (html.append("</div>").toString());
|
||||
@@ -189,32 +188,6 @@ public final class EmailHomeView
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider health is admin-only; disabled providers never appear healthy.
|
||||
*******************************************************************************/
|
||||
private static void providers(StringBuilder html, List<QRecord> providers, boolean admin)
|
||||
{
|
||||
section(html, "Provider health");
|
||||
if(!admin || providers.isEmpty())
|
||||
{
|
||||
html.append("<p style=\"margin:0;opacity:.75\">").append(admin ? "No providers configured." : "Provider health needs email.admin.").append("</p>");
|
||||
}
|
||||
for(QRecord provider : providers)
|
||||
{
|
||||
String status = Boolean.FALSE.equals(provider.getValueBoolean("enabled")) ? "Disabled"
|
||||
: "HEALTHY".equals(provider.getValueString("healthStatus")) ? "Healthy" : "UNHEALTHY".equals(provider.getValueString("healthStatus")) ? "Unhealthy" : "Unknown";
|
||||
String color = "Healthy".equals(status) ? OK : "Unhealthy".equals(status) ? FAIL : "#808080";
|
||||
html.append("<div style=\"").append(ROW).append("\">")
|
||||
.append(cell("Provider", "<strong>" + esc(value(provider, "label", value(provider, "name", "Provider"))) + "</strong><br><span style=\"color:" + color + "\">" + status + "</span>"))
|
||||
.append(cell("Last success", esc(when(provider.getValueInstant("lastSuccessDate")))))
|
||||
.append(cell("Last failure", esc(value(provider, "lastFailureMessage", "None"))))
|
||||
.append(cell("Per-minute headroom", esc(value(provider, "headroom", "No limit")))).append("</div>");
|
||||
}
|
||||
html.append("</section>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The whole row is a link. Labelled columns stack as cards on narrow screens.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -101,7 +101,7 @@ public final class EmailSetupStatus
|
||||
}
|
||||
else if(active != null && "UNHEALTHY".equals(active.getValueString("healthStatus")))
|
||||
{
|
||||
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, "The default provider is failing: " + valueOr(active.getValueString("lastFailureMessage"), "see its last failure") + ".",
|
||||
items.add(new Item("Provider", EmailTables.PROVIDER, State.TODO, "The default provider is failing. See System Health for its status.",
|
||||
"Check its settings and secret variables, then Send Test Email."));
|
||||
}
|
||||
else
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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.email.health;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiReadinessCheck;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheck;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplier;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthDetails;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central health adapters reuse readiness and passive delivery state; never send.
|
||||
*******************************************************************************/
|
||||
public class EmailHealthChecks implements HealthCheckSupplier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Register without querying data during metadata production.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
if(registry.getChecks().stream().noneMatch(check -> "email.readiness".equals(check.getName())))
|
||||
{
|
||||
registry.withCheck(definition("email.readiness", EmailHealthChecks::readiness));
|
||||
}
|
||||
if(registry.getSuppliers().stream().noneMatch(supplier -> "email.provider".equals(supplier.getNamePrefix())))
|
||||
{
|
||||
registry.withSupplier(new HealthCheckSupplierMetaData().withNamePrefix("email.provider").withComponent("email")
|
||||
.withSupplier(new QCodeReference(EmailHealthChecks.class)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider primary keys are integers, so their names are already lowercase.
|
||||
** Capture only identity; each execution rereads current state.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<HealthCheckMetaData> supply(QInstance instance) throws QException
|
||||
{
|
||||
List<HealthCheckMetaData> checks = new ArrayList<>();
|
||||
for(QRecord provider : providers(EmailStore.equal("enabled", true), Set.of("id")))
|
||||
{
|
||||
Integer id = provider.getValueInteger("id");
|
||||
checks.add(definition("email.provider." + id, input -> provider(id, input)));
|
||||
}
|
||||
return (checks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound execution cooperatively around existing reads. Core also enforces
|
||||
** the hard timeout. Exception text may contain PHI or secrets: omit it.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckMetaData definition(String name, HealthCheck check)
|
||||
{
|
||||
return (new HealthCheckMetaData().withName(name).withLabel(name).withComponent("email")
|
||||
.withCategory(HealthCategory.EMAIL).withKind(HealthCheckKind.DEPENDENCY)
|
||||
.withIntervalSeconds(300).withTimeoutMs(5000).withCheckInstance(input ->
|
||||
{
|
||||
try
|
||||
{
|
||||
requireTime(input);
|
||||
HealthCheckOutcome outcome = check.run(input);
|
||||
requireTime(input);
|
||||
return (outcome);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (HealthCheckOutcome.down("Email health check failed: " + e.getClass().getSimpleName()));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Aggregate all category preflights, exposing enum names instead of reasons.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome readiness(HealthCheckInput input) throws QException
|
||||
{
|
||||
Set<EmailApiReadinessCheck> failed = EnumSet.noneOf(EmailApiReadinessCheck.class);
|
||||
boolean ready = true;
|
||||
for(EmailApiCategory category : EmailApiCategory.values())
|
||||
{
|
||||
requireTime(input);
|
||||
var readiness = EmailApi.readiness(input.getQInstance(), category);
|
||||
ready &= readiness.ready();
|
||||
failed.addAll(readiness.failedChecks());
|
||||
}
|
||||
return ((ready ? HealthCheckOutcome.up("Email configuration ready") : HealthCheckOutcome.degraded("Email configuration incomplete"))
|
||||
.withDetails(new HealthDetails().withValue("failedChecks", failed.stream().map(Enum::name).collect(Collectors.joining(",")))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The transport SPI has no non-sending connectivity method. Passive state
|
||||
** is the only signal; never read or publish lastFailureMessage.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome provider(Integer id, HealthCheckInput input) throws QException
|
||||
{
|
||||
List<QRecord> rows = providers(EmailStore.equal("id", id).withLimit(1), Set.of("id", "enabled", "healthStatus", "lastSuccessDate"));
|
||||
requireTime(input);
|
||||
if(rows.isEmpty() || !Boolean.TRUE.equals(rows.getFirst().getValueBoolean("enabled")))
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("Provider disabled or removed"));
|
||||
}
|
||||
QRecord row = rows.getFirst();
|
||||
String state = row.getValueString("healthStatus");
|
||||
HealthCheckOutcome outcome = "HEALTHY".equals(state) ? HealthCheckOutcome.up("Passive provider state healthy")
|
||||
: "UNHEALTHY".equals(state) ? HealthCheckOutcome.down("Passive provider state unhealthy; see provider record")
|
||||
: HealthCheckOutcome.unknown("Passive provider state unavailable");
|
||||
HealthDetails details = new HealthDetails().withValue("providerId", id).withValue("passive", true);
|
||||
Instant lastSuccess = row.getValueInstant("lastSuccessDate");
|
||||
if(lastSuccess != null)
|
||||
{
|
||||
details.withValue("lastSuccessDate", lastSuccess);
|
||||
}
|
||||
return (outcome.withDetails(details));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Select only needed fields under the qbit's established system read scope.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> providers(QQueryFilter filter, Set<String> fields) throws QException
|
||||
{
|
||||
return (SystemWrites.call(() -> new QueryAction().execute(new QueryInput(EmailTables.PROVIDER).withFilter(filter)
|
||||
.withFieldNamesToInclude(fields).withShouldFetchHeavyFields(false).withShouldOmitHiddenFields(false)).getRecords()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not start further work after the runner's deadline or cancellation.
|
||||
*******************************************************************************/
|
||||
private static void requireTime(HealthCheckInput input) throws QException
|
||||
{
|
||||
if(Thread.currentThread().isInterrupted() || (input.getDeadline() != null && !Instant.now().isBefore(input.getDeadline())))
|
||||
{
|
||||
throw (new QException("Health check deadline exceeded"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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.email.health;
|
||||
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
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.health.HealthMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One standard status line linking to central checks; no provider diagnostics.
|
||||
*******************************************************************************/
|
||||
public class EmailHealthWidget extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Read the persisted rollup as the viewer, preserving health permissions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String status = HealthStatus.UNKNOWN.name();
|
||||
boolean installed = QContext.getQInstance().getTable(HealthMetaDataProvider.COMPONENT_TABLE) != null;
|
||||
boolean readable = installed && PermissionsHelper.hasTablePermission(input, HealthMetaDataProvider.COMPONENT_TABLE, TablePermissionSubType.READ);
|
||||
if(readable)
|
||||
{
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput(HealthMetaDataProvider.COMPONENT_TABLE)
|
||||
.withFilter(EmailStore.equal("name", "email").withLimit(1)).withFieldNamesToInclude(Set.of("status"))).getRecords();
|
||||
if(!rows.isEmpty())
|
||||
{
|
||||
String stored = rows.getFirst().getValueString("status");
|
||||
for(HealthStatus candidate : HealthStatus.values())
|
||||
{
|
||||
if(candidate.name().equals(stored))
|
||||
{
|
||||
status = candidate.name();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
StatisticsData data = new StatisticsData().withCount(status).withCountFontSize("1rem")
|
||||
.withCountContext(!installed ? "Email - health monitoring not configured" : readable ? "Email - System Health" : "Email - health access required");
|
||||
if(readable)
|
||||
{
|
||||
data.withCountURL("/app/health_check?filter=" + URLEncoder.encode(JsonUtils.toJson(EmailStore.equal("component", "email")), StandardCharsets.UTF_8));
|
||||
}
|
||||
return (new RenderWidgetOutput(data));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
bundle: qbit-email
|
||||
version: 0.1.0-SNAPSHOT
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:email;slot:help
|
||||
@@ -530,6 +530,8 @@ entries:
|
||||
summary: 'Sender addresses configured for this provider.'
|
||||
- key: widget:email_event_children;slot:label
|
||||
summary: 'Delivery and engagement events for this message.'
|
||||
- key: widget:emailHealth;slot:label
|
||||
summary: 'Central Email health status, with a link to its checks in System Health.'
|
||||
- key: widget:emailHome;slot:label
|
||||
summary: 'Email delivery totals, provider activity and recent messages visible to you.'
|
||||
- key: widget:emailTemplatePreview;slot:label
|
||||
|
||||
@@ -108,8 +108,8 @@ class EmailHomePolishTest
|
||||
assertThat(tiles.get(i).child(0).attr("style")).contains("font-size:1.6rem");
|
||||
assertThat(tiles.get(i).child(0).attr("style").contains("#d64545")).isEqualTo(i == 2 || i == 3);
|
||||
}
|
||||
assertThat(document.select("h3")).extracting(Element::text).containsExactly("Setup", "Sends per hour, last 24 hours", "Provider health", "Recent messages", "Suppressions added, last 30 days");
|
||||
assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHome");
|
||||
assertThat(document.select("h3")).extracting(Element::text).containsExactly("Setup", "Sends per hour, last 24 hours", "Recent messages", "Suppressions added, last 30 days");
|
||||
assertThat(QContext.getQInstance().getApp("email").getWidgets()).containsExactly("emailHealth", "emailHome");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -148,7 +148,7 @@ class EmailHomePolishTest
|
||||
** Twenty newest linked rows wrap long text, mask addresses, and hide PHI.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recentRowsAndProviderHealth() throws Exception
|
||||
void recentRowsExcludeProviderDiagnostics() throws Exception
|
||||
{
|
||||
for(int i = 0; i < 21; i++)
|
||||
{
|
||||
@@ -166,13 +166,12 @@ class EmailHomePolishTest
|
||||
assertThat(rows.first().attr("href")).isEqualTo("/app/email_message/21");
|
||||
assertThat(rows.last().attr("href")).isEqualTo("/app/email_message/2");
|
||||
assertThat(rows.first().text()).contains("When", "To", "Subject", "Status", "Waiting to retry");
|
||||
assertThat(html).contains("Secure message", "p***@", "Oct 14, 02:12 UTC", "Unhealthy", "<broken>", "min-width:0", "overflow-wrap:anywhere")
|
||||
.doesNotContain("private-canary", "Person <", "RETRY_WAIT", "2026-10-14T");
|
||||
assertThat(document.select("section").get(2).text()).contains("Last success Oct 14, 02:12 UTC", "Per-minute headroom 1");
|
||||
assertThat(html).contains("Secure message", "p***@", "min-width:0", "overflow-wrap:anywhere")
|
||||
.doesNotContain("private-canary", "Person <", "RETRY_WAIT", "2026-10-14T", "Provider health", "<broken>", "Per-minute headroom");
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", 1).withValue("enabled", false).withValue("maxPerMinute", null));
|
||||
assertThat(EmailHome.overview().getHtml()).contains("Disabled", "No limit");
|
||||
assertThat(EmailHome.overview().getHtml()).doesNotContain("Disabled", "No limit", "Provider health");
|
||||
QContext.getQSession().setPermissions(Set.of("email.view"));
|
||||
assertThat(EmailHome.overview().getHtml()).contains("Provider health needs email.admin").doesNotContain("<broken>");
|
||||
assertThat(EmailHome.overview().getHtml()).doesNotContain("Provider health", "<broken>");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -221,7 +221,7 @@ class EmailTemplatesTest
|
||||
@Test
|
||||
void previewAndHome() throws Exception
|
||||
{
|
||||
assertThat(EmailHome.overview().getHtml()).contains("No messages yet", "Relay").doesNotContain("<style", "overflow:auto");
|
||||
assertThat(EmailHome.overview().getHtml()).contains("No messages yet", "Setup", "Relay").doesNotContain("Provider health").doesNotContain("<style", "overflow:auto");
|
||||
assertThat(EmailHome.hourly().getSublabel()).contains("No completed attempts");
|
||||
assertThat(EmailHome.suppressions().getSublabel()).contains("No new suppressions");
|
||||
QRecord version = draft("preview", "HTML", "[]", "{}", "<html><body><p>Preview text</p><script>evil()</script><img src=x onerror=evil()></body></html>");
|
||||
@@ -233,7 +233,7 @@ class EmailTemplatesTest
|
||||
EmailService.send(new EmailRequest().withTemplateName("preview").withTo("Person <person@example.com>"));
|
||||
assertThat(EmailHome.overview().getHtml()).contains("p***@example.com", "/app/email_message/");
|
||||
QContext.getQSession().setPermissions(Set.of("email.view"));
|
||||
assertThat(EmailHome.overview().getHtml()).contains("needs email.admin");
|
||||
assertThat(EmailHome.overview().getHtml()).doesNotContain("Provider health", "needs email.admin");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* 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.email.health;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.H2Support;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.support.EmailStore;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheck;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the producer and real H2 reads without contacting any transport.
|
||||
*******************************************************************************/
|
||||
class EmailHealthChecksTest
|
||||
{
|
||||
private static final String SENTINEL = "S3NT1NEL-secret";
|
||||
private QInstance instance;
|
||||
private QRecord provider;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All credential values are synthetic; transport calls must never occur.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
instance = H2Support.setup(config -> config.withSecretResolver(name -> SENTINEL));
|
||||
provider = H2Support.provider(25);
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id"))
|
||||
.withValue("usernameEnvName", "EMAIL_SMTP_USERNAME").withValue("secretEnvName", "EMAIL_SMTP_PASSWORD")
|
||||
.withValue("security", "STARTTLS").withValue("lastFailureMessage", "IllegalStateException: " + SENTINEL + " recipient@example.test"));
|
||||
EmailRuntime.config().withTransportRegistryFactory(() ->
|
||||
{
|
||||
throw (new AssertionError("Health checks must not create transports"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear the thread-local instance after each isolated database.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Production registers a static check and a dynamic supplier with stable ids.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registrationAndDynamicMembership() throws Exception
|
||||
{
|
||||
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
EmailHealthChecks.register(instance);
|
||||
assertThat(registry.getChecks()).extracting(HealthCheckMetaData::getName).containsExactly("email.readiness");
|
||||
assertThat(registry.getSuppliers()).singleElement().satisfies(supplier ->
|
||||
{
|
||||
assertThat(supplier.getNamePrefix()).isEqualTo("email.provider");
|
||||
assertThat(supplier.getComponent()).isEqualTo("email");
|
||||
});
|
||||
assertThat(registry.resolveAll(instance)).hasSize(2).allSatisfy(check ->
|
||||
{
|
||||
assertThat(check.getName()).matches("[a-z0-9]+([._-][a-z0-9]+)*");
|
||||
assertThat(check.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
|
||||
assertThat(check.getCategory()).isEqualTo(HealthCategory.EMAIL);
|
||||
assertThat(check.getComponent()).isEqualTo("email");
|
||||
assertThat(check.getIntervalSeconds()).isEqualTo(300);
|
||||
assertThat(check.getTimeoutMs()).isEqualTo(5000);
|
||||
});
|
||||
HealthCheck cached = providerCheck();
|
||||
update("enabled", false);
|
||||
assertThat(registry.resolveAll(instance)).hasSize(1);
|
||||
assertThat(run(cached).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
update("enabled", true);
|
||||
assertThat(registry.resolveAll(instance)).hasSize(2);
|
||||
SystemWrites.delete(EmailTables.PROVIDER, EmailStore.equal("id", provider.getValueInteger("id")));
|
||||
assertThat(run(cached).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
assertThat(registry.resolveAll(instance)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A resolved check observes later delivery transitions, with safe details only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void passiveStatesAndNoSecrets() throws Exception
|
||||
{
|
||||
HealthCheck check = providerCheck();
|
||||
update("healthStatus", "HEALTHY");
|
||||
Instant success = Instant.parse("2026-10-14T12:00:00Z");
|
||||
update("lastSuccessDate", success);
|
||||
var outcome = run(check);
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("passive", true).containsEntry("lastSuccessDate", success);
|
||||
update("healthStatus", "UNHEALTHY");
|
||||
outcome = run(check);
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getMessage()).contains("see provider record");
|
||||
for(String state : new String[] {null, SENTINEL})
|
||||
{
|
||||
update("healthStatus", state);
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
assertThat(H2Support.all(EmailTables.MESSAGE)).isEmpty();
|
||||
assertThat(H2Support.all(EmailTables.ATTEMPT)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Marketing gaps degrade the aggregate even while other categories are ready.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readinessAcrossCategories() throws Exception
|
||||
{
|
||||
var outcome = run(readinessCheck());
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getDetails().getValues().get("failedChecks").toString().split(","))
|
||||
.containsExactly("PUBLIC_BASE_URL", "BRAND_POSTAL_ADDRESS");
|
||||
configureMarketing();
|
||||
assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
update("enabled", false);
|
||||
outcome = run(readinessCheck());
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("failedChecks", "PROVIDER_MISSING");
|
||||
update("enabled", true);
|
||||
update("host", null);
|
||||
assertThat(run(readinessCheck()).getDetails().getValues()).containsEntry("failedChecks", "PROVIDER_INVALID");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secret resolver exceptions are expected readiness gaps, never diagnostics.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readinessResolverFailureIsSecretSafe() throws Exception
|
||||
{
|
||||
configureMarketing();
|
||||
EmailRuntime.config().withSecretResolver(name ->
|
||||
{
|
||||
throw (new IllegalStateException(SENTINEL + " recipient@example.test"));
|
||||
});
|
||||
var outcome = run(readinessCheck());
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("failedChecks", "UNSUBSCRIBE_KEY");
|
||||
assertThat(run(providerCheck()).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unexpected database/customizer errors cannot leak their exception text.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void queryFailuresAreSecretSafe() throws Exception
|
||||
{
|
||||
List<HealthCheckMetaData> checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
|
||||
instance.getTable(EmailTables.PROVIDER).getCustomizers().put(TableCustomizers.POST_QUERY_RECORD.getRole(), new QCodeReference(PoisonedQuery.class));
|
||||
for(HealthCheckMetaData check : checks)
|
||||
{
|
||||
assertThat(run(check.getCheckInstance()).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expired deadlines prevent reads; expiration during readiness prevents UP.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void respectsDeadlinesAndCancellation() throws Exception
|
||||
{
|
||||
List<HealthCheckMetaData> checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
|
||||
AtomicBoolean queried = new AtomicBoolean();
|
||||
EmailRuntime.config().withSecretResolver(name ->
|
||||
{
|
||||
queried.set(true);
|
||||
return (SENTINEL);
|
||||
});
|
||||
for(HealthCheckMetaData check : checks)
|
||||
{
|
||||
var outcome = check.getCheckInstance().run(input().withDeadline(Instant.EPOCH));
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
clean(outcome);
|
||||
}
|
||||
assertThat(queried).isFalse();
|
||||
EmailRuntime.config().withSecretResolver(name ->
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
return (SENTINEL);
|
||||
});
|
||||
try
|
||||
{
|
||||
assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(run(providerCheckFrom(checks)).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The health runner has health permissions only; qbit reads use system scope.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void runsWithHealthOnlyPermissions() throws Exception
|
||||
{
|
||||
configureMarketing();
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
QContext.getQSession().setPermissions(Set.of("health.read"));
|
||||
assertThat(run(readinessCheck()).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(run(providerCheck()).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Work finishing after its deadline must not report a successful result.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deadlineExpiresDuringReadiness() throws Exception
|
||||
{
|
||||
configureMarketing();
|
||||
HealthCheckInput input = input();
|
||||
EmailRuntime.config().withSecretResolver(name ->
|
||||
{
|
||||
input.setDeadline(Instant.EPOCH);
|
||||
return (SENTINEL);
|
||||
});
|
||||
var outcome = readinessCheck().run(input);
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
clean(outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal valid marketing prerequisites use a synthetic signing secret.
|
||||
*******************************************************************************/
|
||||
private void configureMarketing() throws Exception
|
||||
{
|
||||
EmailRuntime.config().withPublicBaseUrl("https://app.example.test");
|
||||
SystemWrites.insertOne(EmailTables.BRAND, new QRecord().withValue("name", "default").withValue("label", "Default")
|
||||
.withValue("isDefault", true).withValue("postalAddress", "1 Main St"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse system mutation semantics for passive fields.
|
||||
*******************************************************************************/
|
||||
private void update(String field, java.io.Serializable value) throws Exception
|
||||
{
|
||||
SystemWrites.updateOne(EmailTables.PROVIDER, new QRecord().withValue("id", provider.getValue("id")).withValue(field, value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve dynamic definitions through the real registry.
|
||||
*******************************************************************************/
|
||||
private HealthCheck providerCheck() throws Exception
|
||||
{
|
||||
return (providerCheckFrom(HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Obtain the check for this fixture's integer primary key.
|
||||
*******************************************************************************/
|
||||
private HealthCheck providerCheckFrom(List<HealthCheckMetaData> checks)
|
||||
{
|
||||
return (checks.stream().filter(check -> check.getName().equals("email.provider." + provider.getValueInteger("id"))).findFirst().orElseThrow().getCheckInstance());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static readiness definition installed by the producer.
|
||||
*******************************************************************************/
|
||||
private HealthCheck readinessCheck()
|
||||
{
|
||||
return (HealthChecksMetaData.ofOrWithNew(instance).getChecks().getFirst().getCheckInstance());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use a real deadline rather than the fixture's delivery clock.
|
||||
*******************************************************************************/
|
||||
private HealthCheckInput input()
|
||||
{
|
||||
return (new HealthCheckInput().withQInstance(instance).withDeadline(Instant.now().plusSeconds(5)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runner has package-private sanitize; assert the raw boundary is clean.
|
||||
*******************************************************************************/
|
||||
private HealthCheckOutcome run(HealthCheck check) throws Exception
|
||||
{
|
||||
var outcome = check.run(input());
|
||||
clean(outcome);
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check both message and serialized details on every success/failure path.
|
||||
*******************************************************************************/
|
||||
private void clean(HealthCheckOutcome outcome)
|
||||
{
|
||||
assertThat(outcome.getMessage() + outcome.getDetails().toJson()).doesNotContain(SENTINEL, "recipient@example.test", "sender@example.com", "127.0.0.1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inject a failure after a real database read, like a failing table customizer.
|
||||
*******************************************************************************/
|
||||
public static class PoisonedQuery extends AbstractPostQueryCustomizer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The adapter must discard this untrusted exception message.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
throw (new IllegalStateException(SENTINEL + " recipient@example.test"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.email.health;
|
||||
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.email.H2Support;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home reads only the central rollup and honors health read permissions.
|
||||
*******************************************************************************/
|
||||
class EmailHealthWidgetTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Release thread-local state.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host without central health still has an honest empty status line.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingHealthMetadata() throws Exception
|
||||
{
|
||||
var instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
assertThat(instance.getWidget("emailHealth").getType()).isEqualTo(WidgetType.STATISTICS.getType());
|
||||
var data = render();
|
||||
assertThat(data.getCount()).isEqualTo("UNKNOWN");
|
||||
assertThat(data.getCountContext()).contains("not configured");
|
||||
assertThat(data.getCountURL()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** States come from health_component, never provider rows or free-form errors.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void centralRollupAndPermissions() throws Exception
|
||||
{
|
||||
var instance = H2Support.setup(config -> config.withSecretResolver(name -> null));
|
||||
new HealthMetaDataProvider().defineAll(instance, "rdbms", table ->
|
||||
{
|
||||
// H2 needs no additional field metadata.
|
||||
});
|
||||
try(var connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) instance.getBackend("rdbms")))
|
||||
{
|
||||
connection.createStatement().execute("CREATE TABLE health_component (id INT PRIMARY KEY, name VARCHAR(100), status VARCHAR(100))");
|
||||
QContext.getQSession().setPermissions(Set.of("email.view"));
|
||||
assertThat(render().getCountContext()).contains("access required");
|
||||
assertThat(render().getCountURL()).isNull();
|
||||
QContext.getQSession().setPermissions(Set.of("email.view", "health.read"));
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
assertThat(render().getCount()).isEqualTo("UNKNOWN");
|
||||
connection.createStatement().execute("INSERT INTO health_component VALUES (1, 'email', 'DOWN'), (2, 'other', 'UP')");
|
||||
assertThat(render().getCount()).isEqualTo("DOWN");
|
||||
String link = URLDecoder.decode(render().getCountURL(), StandardCharsets.UTF_8);
|
||||
assertThat(link).startsWith("/app/health_check?filter=").contains("component", "email");
|
||||
connection.createStatement().execute("UPDATE health_component SET status = 'UP' WHERE id = 1");
|
||||
assertThat(render().getCount()).isEqualTo("UP");
|
||||
connection.createStatement().execute("UPDATE health_component SET status = 'S3NT1NEL-secret' WHERE id = 1");
|
||||
assertThat(render().getCount()).isEqualTo("UNKNOWN");
|
||||
assertThat(render().getCountContext()).doesNotContain("S3NT1NEL-secret");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render through the same renderer installed in widget metadata.
|
||||
*******************************************************************************/
|
||||
private StatisticsData render() throws Exception
|
||||
{
|
||||
return ((StatisticsData) new EmailHealthWidget().render(new RenderWidgetInput()).getWidgetData());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user