diff --git a/infra/postgres/03-webhook-permissions.sql b/infra/postgres/03-webhook-permissions.sql
new file mode 100644
index 0000000..db7e4cd
--- /dev/null
+++ b/infra/postgres/03-webhook-permissions.sql
@@ -0,0 +1,33 @@
+-- Add the webhook qbit's protected resources to the demo OIDC roles.
+-- Runs after C6's 02-auth-and-roles.sql during PostgreSQL initialization.
+INSERT INTO permission (name, object_type, object_label)
+SELECT table_name || '.' || action, 'table', table_name
+FROM (VALUES
+ ('webhook'), ('webhookSubscription'), ('webhookEvent'),
+ ('webhookEventContent'), ('webhookEventSendLog'),
+ ('scheduledJob'), ('scheduledJobParameter')
+) AS tables(table_name)
+CROSS JOIN (VALUES ('read'), ('insert'), ('edit'), ('delete')) AS actions(action)
+ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO permission (name, object_type, object_label)
+SELECT process_name || '.hasAccess', 'process', process_name
+FROM (VALUES
+ ('SendWebhookEvent'), ('SendTestEventToWebhook'), ('ManageWebhookHealth')
+) AS processes(process_name)
+ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO role_permission_int (role_id, permission_id)
+SELECT role.id, permission.id
+FROM role CROSS JOIN permission
+WHERE (role.name = 'admin' OR (role.name = 'viewer' AND permission.name LIKE '%.read'))
+ AND (permission.name LIKE 'webhook.%'
+ OR permission.name LIKE 'webhookSubscription.%'
+ OR permission.name LIKE 'webhookEvent.%'
+ OR permission.name LIKE 'webhookEventContent.%'
+ OR permission.name LIKE 'webhookEventSendLog.%'
+ OR permission.name LIKE 'scheduledJob.%'
+ OR permission.name LIKE 'scheduledJobParameter.%'
+ OR permission.name IN ('SendWebhookEvent.hasAccess',
+ 'SendTestEventToWebhook.hasAccess', 'ManageWebhookHealth.hasAccess'))
+ON CONFLICT (role_id, permission_id) DO NOTHING;
diff --git a/qqq-all-app/README.md b/qqq-all-app/README.md
index e5b5583..09a0251 100644
--- a/qqq-all-app/README.md
+++ b/qqq-all-app/README.md
@@ -9,7 +9,9 @@ java -jar qqq-all-app/target/qqq-all-app-4.1.0-SNAPSHOT.jar
The core app binds to `127.0.0.1` and serves the Next dashboard at `http://127.0.0.1:8080/` and health at `/health`. Set `QQQ_ALL_PORT` to change the port and `QQQ_ALL_DATA_DIR` to change the default `./data` directory. On first start it seeds customers and orders in H2, order lines in SQLite, and products as JSON files. Artemis runs in the same JVM; inserts and updates to `order` publish to the `orderEvents` topic and trigger `syncOrder`.
-Core uses mock authentication. The `sessionId` cookie selects the two fixed demo identities: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` for Admin and `dddddddd-dddd-4ddd-8ddd-dddddddddddd` for Demo User. Any other session ID uses Demo User. These identities are for local demonstrations; role-based permissions are wired in task C5.
+Core uses mock authentication. The `sessionId` cookie selects the two fixed demo identities: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` for Admin and `dddddddd-dddd-4ddd-8ddd-dddddddddddd` for Demo User. Any other session ID uses Demo User. Admin can read and write the customer, order, order-line, product, and address tables; Demo User can read them but cannot write them. These identities are for local demonstrations. Quick search is disabled in core because it requires OpenSearch; the customer list remains available through the normal table query API.
+
+The core demo seeds a customer table view, a shipping address linked to the geo country/state/city tables, and an order review workflow. The `RunRecordWorkflow` process can run that workflow against an order; `syncOrder` writes a process trace after an order event. It also seeds an active `orderStored` webhook subscription to its loopback receiver at `/demo/order-webhook`. Insert an order as Admin, run `SendWebhookEvent` as Admin, and inspect `/demo/order-webhook-receipts` to see the delivered payload. Demo User can read webhook records but cannot edit their configuration or invoke the delivery process. The receiver is available only in the loopback-bound core profile.
## Full profile
@@ -27,5 +29,6 @@ Configure the following environment variables before starting the same jar:
| ESB | `QQQ_ALL_ARTEMIS_URL`, `QQQ_ALL_RABBITMQ_URL`, `QQQ_ALL_RABBITMQ_USER`, `QQQ_ALL_RABBITMQ_PASSWORD` | — |
| Mailpit SMTP | `QQQ_ALL_SMTP_HOST` | `QQQ_ALL_SMTP_PORT` (1025) |
| Keycloak OIDC | `QQQ_ALL_OIDC_BASE_URL`, `QQQ_ALL_OIDC_CLIENT_ID`, `QQQ_ALL_OIDC_CLIENT_SECRET` | `QQQ_ALL_OIDC_EXTERNAL_BASE_URL` (base URL), `QQQ_ALL_OIDC_SCOPES` (`openid email profile`) |
+| OpenSearch quick search | `QQQ_ALL_OPENSEARCH_HOST` | `QQQ_ALL_OPENSEARCH_PORT` (9200), `QQQ_ALL_OPENSEARCH_INDEX` (`qqq-all-customers`), `QQQ_ALL_OPENSEARCH_USER`, `QQQ_ALL_OPENSEARCH_PASSWORD`, `QQQ_ALL_OPENSEARCH_SSL` (false) |
-The full app adds sample tables on each external backend, `orderEvents` on Artemis, and `orderSyncEvents` on RabbitMQ. Keycloak realm roles resolve by name against the PostgreSQL `role` table from `qbit-user-role-permissions`, then become QQQ session permissions. Unknown roles receive no permissions. PostgreSQL must also contain the qbit permission tables and OAuth session and redirect-state tables. External database schema, demo roles, the MinIO bucket, and the service stack are provisioned by the forthcoming compose and seed work in C6; this C4 profile expects those resources to exist.
+The full app adds sample tables on each external backend, `orderEvents` on Artemis, and `orderSyncEvents` on RabbitMQ. Quick search is configured for customer `name` and `email`; run `quickSearchFullReindex` after OpenSearch and the customer data are ready. The SFTP integration adds its import source and staging tables plus `SFTPImportFileSyncProcess`; `externalImportFile` remains the direct SFTP file sample. Keycloak realm roles resolve by name against the PostgreSQL `role` table from `qbit-user-role-permissions`, then become QQQ session permissions. Unknown roles receive no permissions. Webhook tables and delivery processes require explicit permissions: the viewer role receives read access, while the admin role can edit destinations and run delivery. The additive `infra/postgres/03-webhook-permissions.sql` grant file runs after C6's role seed. PostgreSQL must also contain the qbit permission tables and OAuth session and redirect-state tables. External database schema, demo records, demo roles, the MinIO bucket, and the service stack are provisioned by the forthcoming compose and seed work in C6; this profile expects those resources to exist.
diff --git a/qqq-all-app/pom.xml b/qqq-all-app/pom.xml
index a5c6648..056cd78 100644
--- a/qqq-all-app/pom.xml
+++ b/qqq-all-app/pom.xml
@@ -39,6 +39,13 @@
com.kingsrook.qqqqqq-esb
com.kingsrook.qqqqqq-frontend-next
com.kingsrook.qbitsqbit-user-role-permissions
+ com.kingsrook.qbitsqbit-quick-search
+ com.kingsrook.qbitsqbit-customizable-table-views
+ com.kingsrook.qbitsqbit-standard-process-trace
+ com.kingsrook.qbitsqbit-webhooks
+ com.kingsrook.qbitsqbit-workflows
+ com.kingsrook.qbitsqbit-geo-data
+ com.kingsrook.qbitsqbit-sftp-data-integration
org.postgresqlpostgresql
com.mysqlmysql-connector-j
com.h2databaseh2
diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java
index 1492c66..267204f 100644
--- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java
+++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java
@@ -9,6 +9,9 @@ package com.kingsrook.qqq.all;
import java.io.IOException;
import java.net.ServerSocket;
import java.nio.file.Path;
+import com.kingsrook.qbits.webhooks.actions.WebhookSubscriptionsHelper;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
import org.apache.activemq.artemis.core.config.Configuration;
@@ -36,6 +39,8 @@ final class CoreRuntime implements AutoCloseable
Integer brokerPort = findFreePort();
Path brokerDirectory = dataDirectory.resolve("artemis").toAbsolutePath();
String brokerUrl = "tcp://127.0.0.1:" + brokerPort;
+ DemoOrderWebhookReceiver.clear();
+ DemoQBitData.ensureSchema(dataDirectory, new QqqAllApplication(dataDirectory, brokerUrl).defineQInstance(), port);
Configuration configuration = new ConfigurationImpl()
.setPersistenceEnabled(false).setSecurityEnabled(false).setJMXManagementEnabled(false)
.setBindingsDirectory(brokerDirectory.resolve("bindings").toString())
@@ -52,6 +57,15 @@ final class CoreRuntime implements AutoCloseable
new QApplicationLauncherConfig().withRegisterShutdownHook(false)
.withServerCustomizer(server -> server.withPort(port)
.withJavalinConfigCustomizer(config -> config.jetty.host = "127.0.0.1")));
+ QContext.init(launcher.getQInstance(), new QSystemUserSession());
+ try
+ {
+ WebhookSubscriptionsHelper.clearMemoizations();
+ }
+ finally
+ {
+ QContext.clear();
+ }
return new CoreRuntime(broker, launcher);
}
catch(Exception | LinkageError e)
diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookContent.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookContent.java
new file mode 100644
index 0000000..77c54a7
--- /dev/null
+++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookContent.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ * 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
+ */
+package com.kingsrook.qqq.all;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import com.kingsrook.qbits.webhooks.actions.WebhookEventTypeCustomizerInterface;
+import com.kingsrook.qbits.webhooks.model.WebhookEventContent;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.utils.JsonUtils;
+
+/** Builds a self-contained demo payload without requiring a separate public API definition. */
+public class DemoOrderWebhookContent implements WebhookEventTypeCustomizerInterface
+{
+ @Override
+ public WebhookEventContent buildEventContent(QRecord record, String eventType, String apiName, String apiVersion)
+ {
+ Map body = new LinkedHashMap<>();
+ body.put("eventType", eventType);
+ body.put("orderNo", record.getValueString("orderNo"));
+ body.put("orderId", record.getValueInteger("id"));
+ return new WebhookEventContent().withPostBody(JsonUtils.toJson(body));
+ }
+}
diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java
new file mode 100644
index 0000000..2c56802
--- /dev/null
+++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ * 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
+ */
+package com.kingsrook.qqq.all;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
+import io.javalin.config.JavalinConfig;
+import static io.javalin.apibuilder.ApiBuilder.get;
+import static io.javalin.apibuilder.ApiBuilder.post;
+
+/** Local receiver that makes successful order webhook delivery visible in the core demo. */
+public class DemoOrderWebhookReceiver implements QJavalinRouteProviderInterface
+{
+ private static final List RECEIPTS = new CopyOnWriteArrayList<>();
+
+ static void clear()
+ {
+ RECEIPTS.clear();
+ }
+
+ @Override
+ public void setQInstance(QInstance instance)
+ {
+ }
+
+ @Override
+ public void acceptJavalinConfig(JavalinConfig config)
+ {
+ config.routes.apiBuilder(() ->
+ {
+ post("/demo/order-webhook", context ->
+ {
+ String body = context.body();
+ if(body.length() > 8192)
+ {
+ context.status(413);
+ return;
+ }
+ RECEIPTS.add(body);
+ if(RECEIPTS.size() > 20)
+ {
+ RECEIPTS.removeFirst();
+ }
+ context.status(200).result("ok");
+ });
+ get("/demo/order-webhook-receipts", context -> context.json(List.copyOf(RECEIPTS)));
+ });
+ }
+}
diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java
new file mode 100644
index 0000000..db52bc9
--- /dev/null
+++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ * 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
+ */
+package com.kingsrook.qqq.all;
+
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
+
+/** Creates repeatable local tables for the qbit metadata exposed by core. */
+final class DemoQBitData
+{
+ private DemoQBitData()
+ {
+ }
+
+ static void ensureSchema(Path dataDirectory, QInstance instance, Integer port) throws SQLException
+ {
+ try(Connection connection = DriverManager.getConnection(
+ "jdbc:h2:file:" + CoreData.h2DatabaseName(dataDirectory) + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
+ Statement statement = connection.createStatement())
+ {
+ for(QTableMetaData table : instance.getTables().values())
+ {
+ if(!"h2".equals(table.getBackendName()) || "customer".equals(table.getName()) || "order".equals(table.getName()))
+ {
+ continue;
+ }
+ String tableName = table.getBackendDetails() instanceof RDBMSTableBackendDetails details
+ ? details.getTableName() : table.getName();
+ List columns = new ArrayList<>();
+ for(QFieldMetaData field : table.getFields().values())
+ {
+ String name = field.getBackendName() == null ? field.getName() : field.getBackendName();
+ String type = sqlType(field.getType());
+ String identity = field.getName().equals(table.getPrimaryKeyField())
+ && (field.getType() == QFieldType.INTEGER || field.getType() == QFieldType.LONG)
+ ? " GENERATED BY DEFAULT AS IDENTITY" : "";
+ String primaryKey = field.getName().equals(table.getPrimaryKeyField()) ? " PRIMARY KEY" : "";
+ columns.add(identifier(name) + " " + type + identity + primaryKey);
+ }
+ if(!columns.isEmpty())
+ {
+ statement.execute("CREATE TABLE IF NOT EXISTS " + identifier(tableName)
+ + " (" + String.join(", ", columns) + ")");
+ }
+ }
+ statement.execute("MERGE INTO shipping_country (id, name, alpha2_code) KEY (id) VALUES (1, 'United States', 'US')");
+ statement.execute("MERGE INTO shipping_stateProvince (id, name, country_id) KEY (id) VALUES (1, 'Illinois', 1)");
+ statement.execute("MERGE INTO shipping_city (id, name, state_province_id, latitude, longitude) KEY (id) "
+ + "VALUES (1, 'Chicago', 1, 41.8781, -87.6298)");
+ statement.execute("MERGE INTO address (id, customer_id, line1, city_id) KEY (id) "
+ + "VALUES (1, 1, '1 Demo Street', 1)");
+ statement.execute("MERGE INTO customizable_table (id, table_name, is_active, default_table_view_id) KEY (id) "
+ + "VALUES (1, 'customer', true, 1)");
+ statement.execute("MERGE INTO table_view (id, customizable_table_id, name) KEY (id) "
+ + "VALUES (1, 1, 'Customer Directory')");
+ statement.execute("MERGE INTO table_view_field (id, table_view_id, field_name, access_level) KEY (id) VALUES "
+ + "(1, 1, 'customer.id', 'READ_ONLY'), "
+ + "(2, 1, 'customer.name', 'EDITABLE_OPTIONAL'), "
+ + "(3, 1, 'customer.email', 'EDITABLE_OPTIONAL')");
+ statement.execute("MERGE INTO workflow (id, name, workflow_type_name, table_name, current_workflow_revision_id) "
+ + "KEY (id) VALUES (1, 'Order Status Review', 'RecordWorkflow', 'order', 1)");
+ statement.execute("MERGE INTO workflow_revision (id, workflow_id, version_no, start_step_no, commit_message, author) "
+ + "KEY (id) VALUES (1, 1, 1, 1, 'Demo order review', 'qqq-all')");
+ statement.execute("MERGE INTO workflow_step (id, workflow_revision_id, step_no, workflow_step_type_name, summary, input_values_json) "
+ + "KEY (id) VALUES (1, 1, 1, 'updateInputRecordField', 'Mark order reviewed', "
+ + "'{\"fieldName\":\"status\",\"value\":\"REVIEWED\"}')");
+ statement.execute("MERGE INTO webhook (id, name, url, active_status_id, health_status_id) KEY (id) "
+ + "VALUES (1, 'Local Order Receiver', 'http://127.0.0.1:" + port + "/demo/order-webhook', 1, 1)");
+ statement.execute("MERGE INTO webhook_subscription (id, webhook_id, webhook_event_type_name, "
+ + "active_status_id, api_name, api_version, create_date) KEY (id) "
+ + "VALUES (1, 1, 'orderStored', 1, 'none', 'none', CURRENT_TIMESTAMP)");
+ }
+ }
+
+ private static String identifier(String value)
+ {
+ if(value == null || !value.matches("[A-Za-z][A-Za-z0-9_]*"))
+ {
+ throw new IllegalArgumentException("Unsupported demo schema identifier");
+ }
+ if("value".equalsIgnoreCase(value) || "key".equalsIgnoreCase(value))
+ {
+ return "`" + value + "`";
+ }
+ return value;
+ }
+
+ private static String sqlType(QFieldType type)
+ {
+ return switch(type)
+ {
+ case INTEGER -> "INTEGER";
+ case LONG -> "BIGINT";
+ case DECIMAL -> "DECIMAL(20,6)";
+ case BOOLEAN -> "BOOLEAN";
+ case DATE -> "DATE";
+ case TIME -> "TIME";
+ case DATE_TIME -> "TIMESTAMP";
+ case BLOB -> "BLOB";
+ case STRING, PASSWORD -> "VARCHAR(4000)";
+ case TEXT, HTML -> "CLOB";
+ };
+ }
+}
diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java
new file mode 100644
index 0000000..938a12f
--- /dev/null
+++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ * 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
+ */
+package com.kingsrook.qqq.all;
+
+import java.util.List;
+import com.kingsrook.qbits.customizabletableviews.CustomizableTableViewsQBitConfig;
+import com.kingsrook.qbits.customizabletableviews.CustomizableTableViewsQBitProducer;
+import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
+import com.kingsrook.qbits.quicksearch.QuickSearchQBitProducer;
+import com.kingsrook.qbits.quicksearch.SearchableFieldConfig;
+import com.kingsrook.qbits.sftpdataintegration.SFTPDataIntegrationQBitConfig;
+import com.kingsrook.qbits.sftpdataintegration.SFTPDataIntegrationQBitProducer;
+import com.kingsrook.qbits.geodata.GeoDataQBitConfig;
+import com.kingsrook.qbits.geodata.GeoDataQBitProducer;
+import com.kingsrook.qbits.standardprocesstrace.StandardProcessTraceQBitConfig;
+import com.kingsrook.qbits.standardprocesstrace.StandardProcessTraceQBitProducer;
+import com.kingsrook.qbits.standardprocesstrace.utils.StandardProcessTracer;
+import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
+import com.kingsrook.qbits.webhooks.WebhooksQBitProducer;
+import com.kingsrook.qbits.webhooks.model.WebhookEventCategory;
+import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
+import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
+import com.kingsrook.qbits.workflows.WorkflowsQBitConfig;
+import com.kingsrook.qbits.workflows.WorkflowsQBitProducer;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.qbits.ProvidedOrSuppliedTableConfig;
+import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
+import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
+import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider;
+import com.kingsrook.qqq.backend.core.model.processes.QQQProcessesMetaDataProvider;
+import com.kingsrook.qqq.backend.core.model.tables.QQQTablesMetaDataProvider;
+import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider;
+import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataProvider;
+import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobsMetaDataProvider;
+import com.kingsrook.qqq.backend.core.model.common.TimeZonePossibleValueSourceMetaDataProvider;
+import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
+import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
+
+/** Registers the locally usable qbits against the demo's relational backend. */
+final class DemoQBits
+{
+ private DemoQBits()
+ {
+ }
+
+ static void addCore(QInstance instance, String backendName) throws QException
+ {
+ new QQQTablesMetaDataProvider().defineAll(instance, backendName, backendName, null);
+ new QQQProcessesMetaDataProvider().defineAll(instance, backendName, backendName, null);
+ instance.addScheduler(new SimpleSchedulerMetaData().withName("demoScheduler"));
+ new ScheduledJobsMetaDataProvider().defineAll(instance, backendName, null);
+ instance.addPossibleValueSource(new TimeZonePossibleValueSourceMetaDataProvider().produce());
+ instance.addPossibleValueSource(TablesPossibleValueSourceMetaDataProvider.defineTablesPossibleValueSource(instance));
+ instance.addPossibleValueSource(new QPossibleValueSource().withName("apiName")
+ .withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
+ .withEnumValues(List.of(new QPossibleValue<>("none", "No API in this demo"))));
+ instance.addPossibleValueSource(new QPossibleValueSource().withName("apiVersion")
+ .withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
+ .withEnumValues(List.of(new QPossibleValue<>("none", "No API version"))));
+ if(instance.getPossibleValueSource("role") == null)
+ {
+ instance.addPossibleValueSource(new QPossibleValueSource().withName("role")
+ .withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.INTEGER)
+ .withEnumValues(List.of(new QPossibleValue<>(0, "No role"))));
+ }
+
+ MetaDataCustomizerInterface tableCustomizer =
+ (i, table) ->
+ {
+ table.setBackendName(backendName);
+ table.setBackendDetails(new RDBMSTableBackendDetails()
+ .withTableName(QInstanceEnricher.inferBackendName(table.getName())));
+ QInstanceEnricher.setInferredFieldBackendNames(table);
+ return table;
+ };
+
+ new CustomizableTableViewsQBitProducer()
+ .withQBitConfig(new CustomizableTableViewsQBitConfig()
+ .withTableMetaDataCustomizer(tableCustomizer))
+ .produce(instance).addSelfToInstance(instance);
+ CustomizableTableViewsQBitProducer.activateTableMetaDataPersonalizerInterface(instance);
+
+ StandardProcessTraceQBitConfig traceConfig = new StandardProcessTraceQBitConfig()
+ .withTableMetaDataCustomizer(tableCustomizer);
+ StandardProcessTracer.setStandardProcessTraceQBitConfig(traceConfig);
+ new StandardProcessTraceQBitProducer()
+ .withStandardProcessTraceQBitConfig(traceConfig)
+ .produce(instance).addSelfToInstance(instance);
+ instance.getProcess("syncOrder").setProcessTracerCodeReference(new QCodeReference(StandardProcessTracer.class));
+
+ new WebhooksQBitProducer()
+ .withQBitConfig(new WebhooksQBitConfig().withDefaultBackendNameForTables(backendName)
+ .withSchedulerName("demoScheduler")
+ .withTableMetaDataCustomizer(tableCustomizer))
+ .produce(instance).addSelfToInstance(instance);
+ WebhooksRegistry.ofOrWithNew(instance).registerWebhookEventType(new WebhookEventType()
+ .withName("orderStored").withLabel("Order Stored")
+ .withCategory(WebhookEventCategory.STORE).withTableName("order")
+ .withCustomizer(new QCodeReference(DemoOrderWebhookContent.class)));
+
+ new WorkflowsQBitProducer()
+ .withQBitConfig(new WorkflowsQBitConfig().withTableMetaDataCustomizer(tableCustomizer))
+ .produce(instance).addSelfToInstance(instance);
+
+ new GeoDataQBitProducer()
+ .withConfig(new GeoDataQBitConfig().withBackendName(backendName).withTableNamePrefix("shipping"))
+ .produce(instance, "shipping-geo");
+ instance.addTable(new QTableMetaData().withName("shipping_country").withLabel("Country")
+ .withBackendName(backendName).withPrimaryKeyField("id")
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER))
+ .withField(new QFieldMetaData("name", QFieldType.STRING))
+ .withField(new QFieldMetaData("alpha2Code", QFieldType.STRING).withBackendName("alpha2_code")));
+ instance.addTable(new QTableMetaData().withName("shipping_stateProvince").withLabel("State Province")
+ .withBackendName(backendName).withPrimaryKeyField("id")
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER))
+ .withField(new QFieldMetaData("name", QFieldType.STRING))
+ .withField(new QFieldMetaData("countryId", QFieldType.INTEGER).withBackendName("country_id")
+ .withPossibleValueSourceName("shipping_country")));
+ instance.addTable(new QTableMetaData().withName("shipping_city").withLabel("City")
+ .withBackendName(backendName).withPrimaryKeyField("id")
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER))
+ .withField(new QFieldMetaData("name", QFieldType.STRING))
+ .withField(new QFieldMetaData("stateProvinceId", QFieldType.INTEGER).withBackendName("state_province_id")
+ .withPossibleValueSourceName("shipping_stateProvince"))
+ .withField(new QFieldMetaData("latitude", QFieldType.DECIMAL))
+ .withField(new QFieldMetaData("longitude", QFieldType.DECIMAL)));
+ instance.addTable(new QTableMetaData().withName("address").withLabel("Address")
+ .withBackendName(backendName).withPrimaryKeyField("id")
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER))
+ .withField(new QFieldMetaData("customerId", QFieldType.INTEGER).withBackendName("customer_id"))
+ .withField(new QFieldMetaData("line1", QFieldType.STRING))
+ .withField(new QFieldMetaData("cityId", QFieldType.INTEGER).withBackendName("city_id")
+ .withPossibleValueSourceName("shipping_city")));
+ }
+
+ static void addFull(QInstance instance, FullProfileSettings settings) throws QException
+ {
+ QuickSearchQBitConfig searchConfig = new QuickSearchQBitConfig()
+ .withBackendName("postgres")
+ .withOpensearchHost(settings.get("OPENSEARCH_HOST"))
+ .withOpensearchPort(settings.port("OPENSEARCH_PORT", "9200"))
+ .withOpensearchIndexName(settings.getOrDefault("OPENSEARCH_INDEX", "qqq-all-customers"))
+ .withOpensearchUsername(settings.get("OPENSEARCH_USER"))
+ .withOpensearchPassword(settings.get("OPENSEARCH_PASSWORD"))
+ .withUseSsl(Boolean.parseBoolean(settings.getOrDefault("OPENSEARCH_SSL", "false")))
+ .withSearchableTable("customer", List.of(new SearchableFieldConfig("name"),
+ new SearchableFieldConfig("email")));
+ new QuickSearchQBitProducer().withConfig(searchConfig).produce(instance);
+ instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits")
+ .withArtifactId("quick-search").withVersion("0.2.1-SNAPSHOT").withConfig(searchConfig));
+
+ new SavedBulkLoadProfileMetaDataProvider().defineAll(instance, "postgres", null);
+ new SavedReportsMetaDataProvider().defineAll(instance, "postgres", "sftp", null);
+ new SFTPDataIntegrationQBitProducer().withSftpDataIntegrationQBitConfig(new SFTPDataIntegrationQBitConfig()
+ .withSourceFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("sftp"))
+ .withDestinationFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("sftp"))
+ .withStagingFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("files"))
+ .withSchedulerName("demoScheduler")
+ .withTableMetaDataCustomizer((i, table) ->
+ {
+ if(table.getBackendName() == null)
+ {
+ table.setBackendName("postgres");
+ table.setBackendDetails(new RDBMSTableBackendDetails()
+ .withTableName(QInstanceEnricher.inferBackendName(table.getName())));
+ QInstanceEnricher.setInferredFieldBackendNames(table);
+ }
+ return table;
+ })).produce(instance, "demo-import");
+ }
+}
diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java
index cfb90d9..93b7f6e 100644
--- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java
+++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java
@@ -7,8 +7,11 @@
package com.kingsrook.qqq.all;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
+import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
@@ -17,6 +20,35 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
{
public static final String ADMIN_SESSION = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
public static final String DEMO_SESSION = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
+ private static final List DEMO_TABLES = List.of("customer", "order", "orderLine", "product", "address");
+ private static final List WEBHOOK_TABLES = List.of("webhook", "webhookSubscription",
+ "webhookEvent", "webhookEventContent", "webhookEventSendLog", "scheduledJob", "scheduledJobParameter");
+ private static final List WEBHOOK_PROCESSES = List.of("SendWebhookEvent",
+ "SendTestEventToWebhook", "ManageWebhookHealth");
+
+ static void protectDemoTables(QInstance instance)
+ {
+ for(String tableName : DEMO_TABLES)
+ {
+ instance.getTable(tableName).setPermissionRules(new QPermissionRules()
+ .withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
+ }
+ protectWebhookResources(instance);
+ }
+
+ static void protectWebhookResources(QInstance instance)
+ {
+ for(String tableName : WEBHOOK_TABLES)
+ {
+ instance.getTable(tableName).setPermissionRules(new QPermissionRules()
+ .withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
+ }
+ for(String processName : WEBHOOK_PROCESSES)
+ {
+ instance.getProcess(processName).setPermissionRules(new QPermissionRules()
+ .withLevel(PermissionLevel.HAS_ACCESS_PERMISSION));
+ }
+ }
@Override
public void customizeSession(QInstance instance, QSession session, Map context)
@@ -26,6 +58,29 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
String userId = admin ? "core:admin" : "core:demo";
session.getUser().setIdReference(userId);
session.getUser().setFullName(name);
+ for(String tableName : DEMO_TABLES)
+ {
+ session.withPermission(tableName + ".read");
+ if(admin)
+ {
+ session.withPermissions(tableName + ".insert", tableName + ".edit", tableName + ".delete");
+ }
+ }
+ for(String tableName : WEBHOOK_TABLES)
+ {
+ session.withPermission(tableName + ".read");
+ if(admin)
+ {
+ session.withPermissions(tableName + ".insert", tableName + ".edit", tableName + ".delete");
+ }
+ }
+ if(admin)
+ {
+ for(String processName : WEBHOOK_PROCESSES)
+ {
+ session.withPermission(processName + ".hasAccess");
+ }
+ }
session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test")));
}
}
diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java
index 3e81a93..04d6a85 100644
--- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java
+++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java
@@ -58,11 +58,14 @@ final class FullProfileApplication extends AbstractQQQApplication
@Override
public QInstance defineQInstance() throws QException
{
- QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL")).defineQInstance();
+ QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL"), false).defineQInstance();
addBackends(instance);
addTables(instance);
addAuthentication(instance);
addEsb(instance);
+ DemoQBits.addCore(instance, "postgres");
+ DemoQBits.addFull(instance, settings);
+ DemoUsers.protectWebhookResources(instance);
EmailMessagingProviderMetaData mail = new EmailMessagingProviderMetaData()
.withSmtpServer(settings.get("SMTP_HOST"))
@@ -122,7 +125,7 @@ final class FullProfileApplication extends AbstractQQQApplication
.withRecordFormat(RecordFormat.JSON).withCardinality(Cardinality.MANY).withGlob("*.json"))
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withField(new QFieldMetaData("name", QFieldType.STRING)));
- instance.addTable(new QTableMetaData().withName("importFile").withLabel("Import File")
+ instance.addTable(new QTableMetaData().withName("externalImportFile").withLabel("External Import File")
.withBackendName("sftp").withPrimaryKeyField("id")
.withBackendDetails(new SFTPTableBackendDetails().withBasePath("imports")
.withRecordFormat(RecordFormat.JSON).withCardinality(Cardinality.MANY).withGlob("*.json"))
diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileSettings.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileSettings.java
index 9842732..f89b94b 100644
--- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileSettings.java
+++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileSettings.java
@@ -20,7 +20,8 @@ final class FullProfileSettings
"S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY",
"SFTP_HOST", "SFTP_USER", "SFTP_PASSWORD",
"ARTEMIS_URL", "RABBITMQ_URL", "RABBITMQ_USER", "RABBITMQ_PASSWORD",
- "SMTP_HOST", "OIDC_BASE_URL", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET");
+ "SMTP_HOST", "OIDC_BASE_URL", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET",
+ "OPENSEARCH_HOST");
private final Map values;
diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java
index 9eec1bd..f15d246 100644
--- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java
+++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java
@@ -51,11 +51,18 @@ public final class QqqAllApplication extends AbstractQQQApplication
{
private final Path dataDirectory;
private final String brokerUrl;
+ private final boolean includeDemoQbits;
public QqqAllApplication(Path dataDirectory, String brokerUrl)
+ {
+ this(dataDirectory, brokerUrl, true);
+ }
+
+ QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits)
{
this.dataDirectory = dataDirectory;
this.brokerUrl = brokerUrl;
+ this.includeDemoQbits = includeDemoQbits;
}
public static void main(String[] args) throws Exception
@@ -130,6 +137,13 @@ public final class QqqAllApplication extends AbstractQQQApplication
.withEndpointPath("/health").withIndicators(List.of(new MemoryHealthIndicator().withThreshold(99))));
QJavalinMetaData.ofOrWithNew(instance)
.withAdditionalRouteProviderReference(new QCodeReference(JavalinHealthRouteProvider.class));
+ if(includeDemoQbits)
+ {
+ DemoQBits.addCore(instance, "h2");
+ DemoUsers.protectDemoTables(instance);
+ QJavalinMetaData.ofOrWithNew(instance)
+ .withAdditionalRouteProviderReference(new QCodeReference(DemoOrderWebhookReceiver.class));
+ }
return instance;
}
diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java
index de521f7..a7d853c 100644
--- a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java
+++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java
@@ -15,8 +15,12 @@ import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
+import com.kingsrook.qbits.webhooks.model.WebhookEventStatus;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
+import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
+import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
@@ -62,6 +66,28 @@ class CoreApplicationTest
assertEquals(200, dashboard.statusCode());
assertTrue(dashboard.body().contains("html"));
+ HttpResponse demoRead = client.send(HttpRequest.newBuilder()
+ .uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/table/order/query"))
+ .header("Cookie", "sessionId=" + DemoUsers.DEMO_SESSION)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString("{}"))
+ .build(), HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, demoRead.statusCode(), demoRead.body());
+ HttpResponse demoInsert = insertOrder(client, port, DemoUsers.DEMO_SESSION, "ORD-DENIED");
+ assertEquals(403, demoInsert.statusCode());
+ assertEquals(403, updateWebhook(client, port, DemoUsers.DEMO_SESSION,
+ "webhook", "{\"url\":\"http://127.0.0.1:1/\"}").statusCode());
+ assertEquals(403, updateWebhook(client, port, DemoUsers.DEMO_SESSION,
+ "webhookSubscription", "{\"activeStatusId\":3}").statusCode());
+ assertEquals(403, runWebhookProcess(client, port, DemoUsers.DEMO_SESSION).statusCode());
+ assertEquals(200, updateWebhook(client, port, DemoUsers.ADMIN_SESSION,
+ "webhook", "{\"url\":\"http://127.0.0.1:" + port + "/demo/order-webhook\"}").statusCode());
+ assertEquals(200, updateWebhook(client, port, DemoUsers.ADMIN_SESSION,
+ "webhookSubscription", "{\"activeStatusId\":1}").statusCode());
+ HttpResponse adminInsert = insertOrder(client, port, DemoUsers.ADMIN_SESSION, "ORD-ADMIN");
+ assertEquals(200, adminInsert.statusCode(), adminInsert.body());
+ assertEquals(200, runWebhookProcess(client, port, DemoUsers.ADMIN_SESSION).statusCode());
+
QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession());
try
{
@@ -71,10 +97,14 @@ class CoreApplicationTest
assertEquals("core:demo", mockAuth.createSession(runtime.getLauncher().getQInstance(),
Map.of("sessionId", DemoUsers.DEMO_SESSION)).getUser().getIdReference());
- for(String table : List.of("customer", "order", "orderLine", "product"))
+ for(String table : List.of("customer", "order", "orderLine", "product", "address", "shipping_city"))
{
assertFalse(new QueryAction().execute(new QueryInput(table)).getRecords().isEmpty(), table);
}
+ assertEquals("Customer Directory", new QueryAction().execute(new QueryInput("TableView"))
+ .getRecords().getFirst().getValueString("name"));
+ assertEquals("Order Status Review", new QueryAction().execute(new QueryInput("workflow"))
+ .getRecords().getFirst().getValueString("name"));
waitFor(() -> QEsbRuntime.getInstance().getRunner("syncOrder.orderEvents") != null
&& QEsbRuntime.getInstance().getRunner("syncOrder.orderEvents").getState() == EsbTriggerState.RUNNING);
@@ -82,6 +112,27 @@ class CoreApplicationTest
new QRecord().withValue("orderNo", "ORD-1002")
.withValue("customerId", 2).withValue("status", "NEW"))));
waitFor(() -> QqqAllApplication.SyncOrderStep.getRunCount() > 0);
+ waitFor(() -> !new QueryAction().execute(new QueryInput("processTrace")).getRecords().isEmpty());
+ waitFor(() -> !new QueryAction().execute(new QueryInput("webhookEvent")).getRecords().isEmpty());
+
+ HttpResponse receipts = client.send(HttpRequest.newBuilder()
+ .uri(URI.create("http://127.0.0.1:" + port + "/demo/order-webhook-receipts"))
+ .build(), HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, receipts.statusCode(), receipts.body());
+ waitFor(() -> client.send(HttpRequest.newBuilder()
+ .uri(URI.create("http://127.0.0.1:" + port + "/demo/order-webhook-receipts"))
+ .build(), HttpResponse.BodyHandlers.ofString()).body().contains("ORD-ADMIN"));
+ waitFor(() -> new QueryAction().execute(new QueryInput("webhookEvent")).getRecords().stream()
+ .anyMatch(event -> WebhookEventStatus.DELIVERED.getId().equals(event.getValueInteger("eventStatusId"))));
+
+ RunProcessInput workflow = new RunProcessInput();
+ workflow.setProcessName("RunRecordWorkflow");
+ workflow.setCallback(QProcessCallbackFactory.forPrimaryKey("id", 1));
+ workflow.addValue("tableName", "order");
+ workflow.addValue("workflowId", 1);
+ workflow.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
+ new RunProcessAction().execute(workflow);
+ assertFalse(new QueryAction().execute(new QueryInput("workflowRunLog")).getRecords().isEmpty());
}
finally
{
@@ -90,13 +141,47 @@ class CoreApplicationTest
}
}
- private static void waitFor(java.util.function.BooleanSupplier condition) throws Exception
+ private static HttpResponse updateWebhook(HttpClient client, Integer port, String sessionId,
+ String tableName, String body) throws Exception
+ {
+ return client.send(HttpRequest.newBuilder()
+ .uri(URI.create("http://127.0.0.1:" + port + "/data/" + tableName + "/1"))
+ .header("Cookie", "sessionId=" + sessionId)
+ .header("Content-Type", "application/json")
+ .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
+ .build(), HttpResponse.BodyHandlers.ofString());
+ }
+
+ private static HttpResponse runWebhookProcess(HttpClient client, Integer port, String sessionId)
+ throws Exception
+ {
+ return client.send(HttpRequest.newBuilder()
+ .uri(URI.create("http://127.0.0.1:" + port + "/processes/SendWebhookEvent/run?webhookId=1"))
+ .header("Cookie", "sessionId=" + sessionId)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString("{}"))
+ .build(), HttpResponse.BodyHandlers.ofString());
+ }
+
+ private static HttpResponse insertOrder(HttpClient client, Integer port, String sessionId, String orderNo)
+ throws Exception
+ {
+ return client.send(HttpRequest.newBuilder()
+ .uri(URI.create("http://127.0.0.1:" + port + "/data/order/"))
+ .header("Cookie", "sessionId=" + sessionId)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString("{\"orderNo\":\"" + orderNo
+ + "\",\"customerId\":1,\"status\":\"NEW\"}"))
+ .build(), HttpResponse.BodyHandlers.ofString());
+ }
+
+ private static void waitFor(java.util.concurrent.Callable condition) throws Exception
{
long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos();
- while(!condition.getAsBoolean() && System.nanoTime() < deadline)
+ while(!condition.call() && System.nanoTime() < deadline)
{
Thread.sleep(50);
}
- assertTrue(condition.getAsBoolean());
+ assertTrue(condition.call());
}
}
diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreQBitsTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreQBitsTest.java
new file mode 100644
index 0000000..479bb27
--- /dev/null
+++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreQBitsTest.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ * 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
+ */
+package com.kingsrook.qqq.all;
+
+import java.nio.file.Path;
+import java.util.Set;
+import java.util.stream.Collectors;
+import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
+import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CoreQBitsTest
+{
+ @TempDir Path dataDirectory;
+
+ @Test
+ void coreExposesLocalQbitsAndOrderTrace() throws Exception
+ {
+ QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
+ new QInstanceValidator().validate(instance);
+ Set artifacts = instance.getQBits().values().stream()
+ .map(qbit -> qbit.getArtifactId()).collect(Collectors.toSet());
+ assertTrue(artifacts.containsAll(Set.of("customizable-table-views", "standard-process-trace",
+ "webhooks", "workflows", "qbit-geo-data")));
+ assertNotNull(instance.getTable("TableView"));
+ assertNotNull(instance.getTable("processTrace"));
+ assertNotNull(instance.getTable("webhook"));
+ assertNotNull(instance.getTable("workflow"));
+ assertNotNull(instance.getProcess("syncOrder").getProcessTracerCodeReference());
+ assertNotNull(WebhooksRegistry.of(instance).getWebhookEventType("orderStored"));
+ assertNotNull(WorkflowsRegistry.of(instance).getWorkflowType("RecordWorkflow"));
+ assertNotNull(instance.getTable("address").getField("cityId").getPossibleValueSourceName());
+ assertTrue(artifacts.stream().noneMatch("quick-search"::equals));
+ }
+}
diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullProfileTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullProfileTest.java
index 1519893..32fd00a 100644
--- a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullProfileTest.java
+++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullProfileTest.java
@@ -52,7 +52,7 @@ class FullProfileTest
assertEquals("http://minio.example.test:9000",
((MinioBackendMetaData) instance.getBackend("s3")).getEndpoint());
assertEquals("s3", instance.getTable("document").getBackendName());
- assertEquals("sftp", instance.getTable("importFile").getBackendName());
+ assertEquals("sftp", instance.getTable("externalImportFile").getBackendName());
EsbInstanceMetaData esb = EsbInstanceMetaData.of(instance);
assertEquals("tcp://artemis.example.test:61616", esb.getProvider("artemis").getUrl());
@@ -101,6 +101,8 @@ class FullProfileTest
values.put("QQQ_ALL_OIDC_BASE_URL", "https://keycloak.example.test/realms/demo");
values.put("QQQ_ALL_OIDC_CLIENT_ID", "qqq-all");
values.put("QQQ_ALL_OIDC_CLIENT_SECRET", "fixture");
+ values.put("QQQ_ALL_OPENSEARCH_HOST", "127.0.0.1");
+ values.put("QQQ_ALL_OPENSEARCH_PORT", "1");
return values;
}
}
diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java
new file mode 100644
index 0000000..1460427
--- /dev/null
+++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ * 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
+ */
+package com.kingsrook.qqq.all;
+
+import java.nio.file.Path;
+import java.util.Set;
+import java.util.stream.Collectors;
+import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
+import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
+import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
+import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FullQBitsTest
+{
+ @TempDir Path dataDirectory;
+
+ @Test
+ void fullAddsCustomerSearchAndSftpImportToTheDemo() throws Exception
+ {
+ FullProfileSettings settings = FullProfileSettings.from(FullProfileTest.environment());
+ QInstance instance = new FullProfileApplication(dataDirectory, settings).defineQInstance();
+ new QInstanceValidator().validate(instance);
+
+ Set artifacts = instance.getQBits().values().stream()
+ .map(qbit -> qbit.getArtifactId()).collect(Collectors.toSet());
+ assertTrue(artifacts.containsAll(Set.of("quick-search", "user-role-permissions",
+ "customizable-table-views", "standard-process-trace", "webhooks", "workflows",
+ "qbit-geo-data", "sftp-data-integration")));
+ assertNotNull(instance.getApp("quickSearchAdmin"));
+ assertNotNull(instance.getProcess("quickSearchFullReindex"));
+ assertNotNull(instance.getProcess("quickSearchReconcileIndex"));
+ assertNotNull(instance.getProcess("SFTPImportFileSyncProcess"));
+ assertNotNull(instance.getTable("SFTPImportSourceFileTable"));
+ assertEquals("sftp", instance.getTable("SFTPImportSourceFileTable").getBackendName());
+ for(String tableName : Set.of("webhook", "webhookSubscription", "webhookEvent",
+ "webhookEventContent", "webhookEventSendLog", "scheduledJob", "scheduledJobParameter"))
+ {
+ assertEquals(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS,
+ instance.getTable(tableName).getPermissionRules().getLevel(), tableName);
+ }
+ for(String processName : Set.of("SendWebhookEvent", "SendTestEventToWebhook", "ManageWebhookHealth"))
+ {
+ assertEquals(PermissionLevel.HAS_ACCESS_PERMISSION,
+ instance.getProcess(processName).getPermissionRules().getLevel(), processName);
+ }
+ QuickSearchQBitConfig search = (QuickSearchQBitConfig) instance.getQBits().values().stream()
+ .filter(qbit -> "quick-search".equals(qbit.getArtifactId())).findFirst().orElseThrow().getConfig();
+ assertEquals("customer", search.getSearchableTables().getFirst().getTableName());
+ assertEquals(Set.of("name", "email"), search.getSearchableTables().getFirst().getFields().stream()
+ .map(field -> field.getFieldName()).collect(Collectors.toSet()));
+ }
+
+ @Test
+ void viewerPermissionsCannotChangeWebhookDestinationOrSendEvent() throws Exception
+ {
+ QInstance instance = new FullProfileApplication(dataDirectory,
+ FullProfileSettings.from(FullProfileTest.environment())).defineQInstance();
+ QSession viewer = new QSession().withPermissions("webhook.read", "webhookSubscription.read");
+ QContext.init(instance, viewer);
+ try
+ {
+ assertFalse(PermissionsHelper.hasTablePermission(new QueryInput("webhook"),
+ "webhook", TablePermissionSubType.EDIT));
+ assertFalse(PermissionsHelper.hasTablePermission(new QueryInput("webhookSubscription"),
+ "webhookSubscription", TablePermissionSubType.EDIT));
+ assertThrows(QPermissionDeniedException.class,
+ () -> PermissionsHelper.checkProcessPermissionThrowing(new RunProcessInput(), "SendWebhookEvent"));
+ }
+ finally
+ {
+ QContext.clear();
+ }
+ }
+
+ @Test
+ void adminPermissionsCanChangeWebhookDestinationAndSendEvent() throws Exception
+ {
+ QInstance instance = new FullProfileApplication(dataDirectory,
+ FullProfileSettings.from(FullProfileTest.environment())).defineQInstance();
+ QSession admin = new QSession().withPermissions("webhook.edit", "webhookSubscription.edit",
+ "SendWebhookEvent.hasAccess");
+ QContext.init(instance, admin);
+ try
+ {
+ assertTrue(PermissionsHelper.hasTablePermission(new QueryInput("webhook"),
+ "webhook", TablePermissionSubType.EDIT));
+ assertTrue(PermissionsHelper.hasTablePermission(new QueryInput("webhookSubscription"),
+ "webhookSubscription", TablePermissionSubType.EDIT));
+ PermissionsHelper.checkProcessPermissionThrowing(new RunProcessInput(), "SendWebhookEvent");
+ }
+ finally
+ {
+ QContext.clear();
+ }
+ }
+}