feat(qbits): wire demo qbits into core and full profiles (#6) #16

Merged
KofTwentyTwo merged 4 commits from feature/GH-6-qbits into develop 2026-09-26 15:05:42 +00:00
16 changed files with 770 additions and 10 deletions

No files matched your search

+33
View File
@@ -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;
+5 -2
View File
@@ -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.
+7
View File
@@ -39,6 +39,13 @@
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-esb</artifactId></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-user-role-permissions</artifactId></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-quick-search</artifactId></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-customizable-table-views</artifactId></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-standard-process-trace</artifactId></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-webhooks</artifactId></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-workflows</artifactId></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-geo-data</artifactId></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-sftp-data-integration</artifactId></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId></dependency>
@@ -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)
@@ -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<String, Object> 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));
}
}
@@ -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<String> 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)));
});
}
}
@@ -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<String> 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";
};
}
}
@@ -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<QTableMetaData> 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");
}
}
@@ -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<String> DEMO_TABLES = List.of("customer", "order", "orderLine", "product", "address");
private static final List<String> WEBHOOK_TABLES = List.of("webhook", "webhookSubscription",
"webhookEvent", "webhookEventContent", "webhookEventSendLog", "scheduledJob", "scheduledJobParameter");
private static final List<String> 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<String, Object> 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")));
}
}
@@ -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"))
@@ -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<String, String> values;
@@ -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;
}
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<Boolean> 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());
}
}
@@ -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<String> 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));
}
}
@@ -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;
}
}
@@ -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<String> 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();
}
}
}