diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c5316..1cb1fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.` 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__`. 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. diff --git a/README.md b/README.md index adb57c1..016148f 100644 --- a/README.md +++ b/README.md @@ -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.` (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 diff --git a/pom.xml b/pom.xml index 78961eb..bbd70c7 100644 --- a/pom.xml +++ b/pom.xml @@ -261,7 +261,7 @@ true - 4.1.0-RC.7 + 4.1.0-RC.8 diff --git a/src/main/java/com/kingsrook/qbits/email/EmailQBitProducer.java b/src/main/java/com/kingsrook/qbits/email/EmailQBitProducer.java index a7f8725..7d0ea4b 100644 --- a/src/main/java/com/kingsrook/qbits/email/EmailQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/email/EmailQBitProducer.java @@ -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 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())) diff --git a/src/main/java/com/kingsrook/qbits/email/app/EmailApp.java b/src/main/java/com/kingsrook/qbits/email/app/EmailApp.java index 43c4225..21075d3 100644 --- a/src/main/java/com/kingsrook/qbits/email/app/EmailApp.java +++ b/src/main/java/com/kingsrook/qbits/email/app/EmailApp.java @@ -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)) diff --git a/src/main/java/com/kingsrook/qbits/email/app/EmailHome.java b/src/main/java/com/kingsrook/qbits/email/app/EmailHome.java index db089ce..59b2cee 100644 --- a/src/main/java/com/kingsrook/qbits/email/app/EmailHome.java +++ b/src/main/java/com/kingsrook/qbits/email/app/EmailHome.java @@ -89,26 +89,13 @@ public final class EmailHome List 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 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 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))); } /******************************************************************************* diff --git a/src/main/java/com/kingsrook/qbits/email/app/EmailHomeView.java b/src/main/java/com/kingsrook/qbits/email/app/EmailHomeView.java index f5c93d1..34db94a 100644 --- a/src/main/java/com/kingsrook/qbits/email/app/EmailHomeView.java +++ b/src/main/java/com/kingsrook/qbits/email/app/EmailHomeView.java @@ -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 totals, ChartData hourly, List providers, boolean admin, List messages, ChartData suppressions) + public static String render(List totals, ChartData hourly, List 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 totals, ChartData hourly, List providers, boolean admin, List messages, ChartData suppressions, List setup) + public static String render(List totals, ChartData hourly, List messages, ChartData suppressions, List setup) { StringBuilder html = new StringBuilder("
"); setup(html, setup); @@ -85,7 +85,6 @@ public final class EmailHomeView } html.append("
"); 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("").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 providers, boolean admin) - { - section(html, "Provider health"); - if(!admin || providers.isEmpty()) - { - html.append("

").append(admin ? "No providers configured." : "Provider health needs email.admin.").append("

"); - } - 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("
") - .append(cell("Provider", "" + esc(value(provider, "label", value(provider, "name", "Provider"))) + "
" + status + "")) - .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("
"); - } - html.append(""); - } - - - /******************************************************************************* ** The whole row is a link. Labelled columns stack as cards on narrow screens. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/email/app/EmailSetupStatus.java b/src/main/java/com/kingsrook/qbits/email/app/EmailSetupStatus.java index 0294460..8c869b7 100644 --- a/src/main/java/com/kingsrook/qbits/email/app/EmailSetupStatus.java +++ b/src/main/java/com/kingsrook/qbits/email/app/EmailSetupStatus.java @@ -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 diff --git a/src/main/java/com/kingsrook/qbits/email/health/EmailHealthChecks.java b/src/main/java/com/kingsrook/qbits/email/health/EmailHealthChecks.java new file mode 100644 index 0000000..fb07f46 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/health/EmailHealthChecks.java @@ -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 supply(QInstance instance) throws QException + { + List 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 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 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 providers(QQueryFilter filter, Set 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")); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/email/health/EmailHealthWidget.java b/src/main/java/com/kingsrook/qbits/email/health/EmailHealthWidget.java new file mode 100644 index 0000000..57a7473 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/email/health/EmailHealthWidget.java @@ -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 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)); + } +} diff --git a/src/main/resources/help/qbit-email.yaml b/src/main/resources/help/qbit-email.yaml index 7556940..2eb9c48 100644 --- a/src/main/resources/help/qbit-email.yaml +++ b/src/main/resources/help/qbit-email.yaml @@ -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 diff --git a/src/test/java/com/kingsrook/qbits/email/EmailHomePolishTest.java b/src/test/java/com/kingsrook/qbits/email/EmailHomePolishTest.java index 6c0c767..313d746 100644 --- a/src/test/java/com/kingsrook/qbits/email/EmailHomePolishTest.java +++ b/src/test/java/com/kingsrook/qbits/email/EmailHomePolishTest.java @@ -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>"); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/email/EmailTemplatesTest.java b/src/test/java/com/kingsrook/qbits/email/EmailTemplatesTest.java index cc08cb9..c9c759f 100644 --- a/src/test/java/com/kingsrook/qbits/email/EmailTemplatesTest.java +++ b/src/test/java/com/kingsrook/qbits/email/EmailTemplatesTest.java @@ -221,7 +221,7 @@ class EmailTemplatesTest @Test void previewAndHome() throws Exception { - assertThat(EmailHome.overview().getHtml()).contains("No messages yet", "Relay").doesNotContain("

Preview text

"); @@ -233,7 +233,7 @@ class EmailTemplatesTest EmailService.send(new EmailRequest().withTemplateName("preview").withTo("Person ")); 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"); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/email/health/EmailHealthChecksTest.java b/src/test/java/com/kingsrook/qbits/email/health/EmailHealthChecksTest.java new file mode 100644 index 0000000..728e072 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/email/health/EmailHealthChecksTest.java @@ -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 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 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 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 apply(List records) + { + throw (new IllegalStateException(SENTINEL + " recipient@example.test")); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/email/health/EmailHealthWidgetTest.java b/src/test/java/com/kingsrook/qbits/email/health/EmailHealthWidgetTest.java new file mode 100644 index 0000000..15a81bb --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/email/health/EmailHealthWidgetTest.java @@ -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()); + } +}