diff --git a/.circleci/config.yml b/.circleci/config.yml index a3572cd..995869f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2.1 orbs: - qqq-orb: kingsrook/qqq-orb@0.6.5 + qqq-orb: kingsrook/qqq-orb@0.6.8 commands: configure-package-install: diff --git a/infra/postgres/05-runtime-demo-permissions.sql b/infra/postgres/05-runtime-demo-permissions.sql new file mode 100644 index 0000000..de7d300 --- /dev/null +++ b/infra/postgres/05-runtime-demo-permissions.sql @@ -0,0 +1,18 @@ +-- Read-only API catalog, transient notes, and the fixed JavaScript calculation. +INSERT INTO permission (name, object_type, object_label) +VALUES + ('apiCatalog.read', 'table', 'API Catalog'), + ('demoNote.read', 'table', 'Demo Note (Transient)'), + ('demoNote.insert', 'table', 'Demo Note (Transient)'), + ('demoNote.edit', 'table', 'Demo Note (Transient)'), + ('demoNote.delete', 'table', 'Demo Note (Transient)'), + ('calculateOrderTotal.hasAccess', 'process', 'Calculate Order Total (JavaScript)') +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 permission.name IN ('apiCatalog.read', 'demoNote.read', 'demoNote.insert', + 'demoNote.edit', 'demoNote.delete', 'calculateOrderTotal.hasAccess') + AND (role.name = 'admin' OR (role.name = 'viewer' AND permission.name IN ('apiCatalog.read', 'demoNote.read'))) +ON CONFLICT (role_id, permission_id) DO NOTHING; diff --git a/qqq-all-app/README.md b/qqq-all-app/README.md index 0d29309..5315e1d 100644 --- a/qqq-all-app/README.md +++ b/qqq-all-app/README.md @@ -55,3 +55,64 @@ QQQ_ALL_COMPOSE_BROKERS=true mvn -pl qqq-all-app -am -Dtest=FullProfileBrokerInt ``` The test uses the loopback management ports to read and pause/resume the Artemis subscription queue, then creates, reads, purges, and deletes a temporary RabbitMQ queue. For a customized `.env`, source that file instead. It is skipped during ordinary `mvn verify`. + +## Local runtime demonstrations (#21) + +Both profiles include the BOM-managed API backend and JavaScript module. The shaded +JAR retains Nashorn's `ScriptEngineFactory` service registration. No extra service, +API key, script editor, or user-supplied code is needed. + +| Demo | Behavior | Access | +| --- | --- | --- | +| `apiCatalog` | QQQ API-backed table reads three synthetic products from this app's `/demo/catalog` HTTP endpoint; native queries page through the endpoint and get-by-ID uses `/demo/catalog/{id}`. | Admin and Demo/Viewer can read; neither receives write grants. | +| `calculateOrderTotal` | Executes fixed JavaScript through `ExecuteCodeAction`: quantity × unit price in cents → `totalCents`. It changes no records. | Admin only. | +| `demoNote` | Memory-backed table, seeded with `Transient demo note` on every start and cleared on shutdown. | Admin can edit; Demo/Viewer can read. | + +The internal catalog URL follows `QQQ_ALL_BIND_HOST` and the configured HTTP port. +IPv4/IPv6 wildcard listeners use their corresponding loopback address; literal +IPv6 hosts are bracketed correctly in the URL. + +The catalog endpoint exposes **only synthetic public demo data**. It is not an +outbound proxy and does not forward session credentials. The adapter supports +ascending ID order, limit/skip pagination, and one exact ID filter; unsupported +filters and sorts fail explicitly. Native get of an absent ID returns no record. +The endpoint rejects invalid or excessive page sizes (maximum 1000). + +With the core JAR running on port 8080, exercise the real QQQ routes: + +```sh +curl -fsS -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:8080/qqq/v1/table/apiCatalog/query +curl -fsS http://127.0.0.1:8080/data/apiCatalog/103 +curl -fsS -H 'Cookie: sessionId=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' -H 'Content-Type: application/json' -d '{}' 'http://127.0.0.1:8080/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250' +``` + +The last response contains `values.totalCents: 3750`. Quantity must be an integer +in 1..1000; unit price must be an integer in 0..1000000 cents. The process does not +accept code from the caller. This demonstration does not alter stored-script or +Test-mode behavior. + +Memory notes are deliberately **not persisted** with the data directory or a +Compose volume. Restarting discards edits and restores the seed. Like the existing +QQQ memory and ESB runtime singletons, this demo assumes one application per JVM. +The memory lifecycle touches only `demoNote`; it never resets the global store. + +Full-profile PostgreSQL now uses QQQ's first-party `PostgreSQLBackendMetaData` and +strategy, including driver/URL selection, quoted identifiers, default-value +inserts, generated IDs, null binding, and UTC timestamp handling. Existing qbit +schema provisioning and compatibility checks remain in place. + +Fresh full-profile volumes apply `infra/postgres/05-runtime-demo-permissions.sql`. +For an existing volume, apply the idempotent grants and then sign in again: + +```sh +COMPOSE_PROFILES=full docker compose exec -T postgres sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < infra/postgres/05-runtime-demo-permissions.sql +``` + +`mvn -pl qqq-all-app -am verify` includes native local API, calculation/permission, +memory restart, and packaged-JAR checks. `scripts/run_core_smoke.py` and +`scripts/run_full_smoke.py` verify these routes with actual Admin and restricted +sessions; full smoke also retains the OIDC, qbit, and broker checks. The opt-in +`PostgresCompatibilityTest` uses `QQQ_ALL_TEST_POSTGRES_PORT` and +`QQQ_ALL_TEST_POSTGRES_PASSWORD` against a local `qqq` database/user; it creates and +drops only a uniquely named test table. It is skipped during ordinary verify. +All eight included qbits, their explicit deferrals, and release gates are unchanged. diff --git a/qqq-all-app/pom.xml b/qqq-all-app/pom.xml index 79aa9d3..0fc3f52 100644 --- a/qqq-all-app/pom.xml +++ b/qqq-all-app/pom.xml @@ -30,7 +30,10 @@ com.kingsrook.qqqqqq-backend-core + com.kingsrook.qqqqqq-backend-module-api + com.kingsrook.qqqqqq-language-support-javascript com.kingsrook.qqqqqq-backend-module-rdbms + com.kingsrook.qqqqqq-backend-module-postgres com.kingsrook.qqqqqq-backend-module-mongodb com.kingsrook.qqqqqq-backend-module-sqlite com.kingsrook.qqqqqq-backend-module-filesystem diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CalculateOrderTotalStep.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CalculateOrderTotalStep.java new file mode 100644 index 0000000..ae3920c --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CalculateOrderTotalStep.java @@ -0,0 +1,40 @@ +/* + * 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.Map; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.scripts.ExecuteCodeAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.scripts.ExecuteCodeInput; +import com.kingsrook.qqq.backend.core.model.actions.scripts.ExecuteCodeOutput; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeType; + +/** Execute a fixed, side-effect-free JavaScript calculation through QQQ. */ +public class CalculateOrderTotalStep implements BackendStep +{ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + Integer quantity = input.getValueInteger("quantity"); + Integer unitPriceCents = input.getValueInteger("unitPriceCents"); + if(quantity == null || quantity < 1 || quantity > 1000 + || unitPriceCents == null || unitPriceCents < 0 || unitPriceCents > 1_000_000) + { + throw new QException("Supply quantity 1..1000 and unitPriceCents 0..1000000"); + } + ExecuteCodeOutput result = new ExecuteCodeOutput(); + new ExecuteCodeAction().run(new ExecuteCodeInput() + .withCodeReference(new QCodeReference().withCodeType(QCodeType.JAVA_SCRIPT) + .withInlineCode("return quantity * unitPriceCents;")) + .withInput(Map.of("quantity", quantity, "unitPriceCents", unitPriceCents)), result); + output.addValue("totalCents", ((Number) result.getOutput()).intValue()); + } +} 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 b4afbd5..21b2de5 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 @@ -45,7 +45,7 @@ final class CoreRuntime implements AutoCloseable 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); + DemoQBitData.ensureSchema(dataDirectory, new QqqAllApplication(dataDirectory, brokerUrl, true, port, bindHost).defineQInstance(), port); Configuration configuration = new ConfigurationImpl() .setPersistenceEnabled(false).setSecurityEnabled(false).setJMXManagementEnabled(false) .setBindingsDirectory(brokerDirectory.resolve("bindings").toString()) @@ -58,7 +58,7 @@ final class CoreRuntime implements AutoCloseable try { QApplicationLauncher launcher = QApplicationLauncher.run( - new QqqAllApplication(dataDirectory, brokerUrl), + new QqqAllApplication(dataDirectory, brokerUrl, true, port, bindHost), new QApplicationLauncherConfig().withRegisterShutdownHook(false) .withServerCustomizer(server -> server.withPort(port) .withJavalinConfigCustomizer(config -> config.jetty.host = bindHost))); diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoCatalogApi.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoCatalogApi.java new file mode 100644 index 0000000..d218448 --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoCatalogApi.java @@ -0,0 +1,82 @@ +/* + * 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.io.Serializable; +import java.util.Map; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil; + +/** The local catalog supports ascending ID pages and optional exact ID selection. */ +public class DemoCatalogApi extends BaseAPIActionUtil +{ + @Override + public String buildUrlSuffixForSingleRecordGet(Serializable primaryKey) throws QException + { + try + { + return "/" + Integer.parseInt(String.valueOf(primaryKey)); + } + catch(NumberFormatException e) + { + throw new QException("Invalid catalog ID", e); + } + } + + @Override + protected Integer getApiStandardLimit() + { + return 2; + } + + @Override + protected String buildQueryStringForGet(QQueryFilter filter, Integer limit, Integer skip, Map fields) throws QException + { + String query = "?limit=" + (limit == null ? 2 : limit) + "&skip=" + (skip == null ? 0 : skip); + if(filter != null) + { + if(!filter.getSubFilters().isEmpty() || filter.getBooleanOperator() != QQueryFilter.BooleanOperator.AND + || filter.getCriteria().size() > 1 || filter.getOrderBys().stream().anyMatch(order -> !"id".equals(order.getFieldName()) || !order.getIsAscending())) + { + throw new QException("Demo catalog supports ascending ID order and one exact ID filter only"); + } + if(!filter.getCriteria().isEmpty()) + { + var criterion = filter.getCriteria().getFirst(); + if(!"id".equals(criterion.getFieldName()) || criterion.getOperator() != QCriteriaOperator.EQUALS || criterion.getValues().size() != 1) + { + throw new QException("Demo catalog supports one exact ID filter only"); + } + try + { + query += "&id=" + Integer.parseInt(String.valueOf(criterion.getValues().getFirst())); + } + catch(NumberFormatException e) + { + throw new QException("Invalid catalog ID", e); + } + } + } + return query; + } + + @Override + public CountOutput doCount(QTableMetaData table, CountInput input) throws QException + { + QQueryFilter filter = input.getFilter() == null ? new QQueryFilter() : input.getFilter().clone(); + filter.setLimit(null); + filter.setSkip(null); + return new CountOutput().withCount(doQuery(table, new QueryInput(table.getName()).withFilter(filter)).getRecords().size()); + } +} diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoCatalogRoutes.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoCatalogRoutes.java new file mode 100644 index 0000000..da8a5b6 --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoCatalogRoutes.java @@ -0,0 +1,76 @@ +/* + * 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.Map; +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; + +/** Synthetic public supplier data, owned by the same server as the reference app. */ +public class DemoCatalogRoutes implements QJavalinRouteProviderInterface +{ + private static final List> CATALOG = List.of( + Map.of("id", 101, "name", "Starter Kit", "unitPriceCents", 2500), + Map.of("id", 102, "name", "Refill Pack", "unitPriceCents", 1250), + Map.of("id", 103, "name", "Shipping Box", "unitPriceCents", 300)); + + @Override + public void setQInstance(QInstance instance) + { + } + + @Override + public void acceptJavalinConfig(JavalinConfig config) + { + config.routes.apiBuilder(() -> + { + get("/demo/catalog/{id}", context -> + { + try + { + int id = Integer.parseInt(context.pathParam("id")); + var row = CATALOG.stream().filter(item -> Integer.valueOf(id).equals(item.get("id"))).findFirst(); + if(row.isPresent()) + { + context.json(row.get()); + } + else + { + context.status(404); + } + } + catch(NumberFormatException e) + { + context.status(400); + } + }); + get("/demo/catalog", context -> + { + try + { + int skip = Integer.parseInt(context.queryParamAsClass("skip", String.class).getOrDefault("0")); + int limit = Integer.parseInt(context.queryParamAsClass("limit", String.class).getOrDefault("2")); + Integer id = context.queryParam("id") == null ? null : Integer.valueOf(context.queryParam("id")); + if(skip < 0 || limit < 1 || limit > 1000) + { + context.status(400); + return; + } + context.json(CATALOG.stream().filter(row -> id == null || id.equals(row.get("id"))) + .skip(skip).limit(limit).toList()); + } + catch(NumberFormatException e) + { + context.status(400); + } + }); + }); + } +} diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoRuntimeFeatures.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoRuntimeFeatures.java new file mode 100644 index 0000000..dbc212e --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoRuntimeFeatures.java @@ -0,0 +1,90 @@ +/* + * 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.net.Inet6Address; +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.List; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +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.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionOutputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.module.api.model.AuthorizationType; +import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData; +import com.kingsrook.qqq.backend.module.api.model.metadata.APITableBackendDetails; +import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData; + +/** Shared, local-only demonstrations included in both profiles. */ +final class DemoRuntimeFeatures +{ + private DemoRuntimeFeatures() + { + } + + static void add(QInstance instance, int port, String bindHost) throws QException + { + instance.addBackend(new APIBackendMetaData().withName("demoApi") + .withBaseUrl(catalogBaseUrl(bindHost, port)) + .withAuthorizationType(AuthorizationType.NONE).withContentType("application/json") + .withActionUtil(new QCodeReference(DemoCatalogApi.class))); + instance.addTable(new QTableMetaData().withName("apiCatalog").withLabel("API Catalog") + .withBackendName("demoApi").withPrimaryKeyField("id") + .withBackendDetails(new APITableBackendDetails().withTablePath("/catalog")) + .withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)) + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("unitPriceCents", QFieldType.INTEGER))); + instance.addBackend(new QBackendMetaData().withName("transientMemory").withBackendType(MemoryBackendModule.class)); + instance.addTable(new QTableMetaData().withName("demoNote").withLabel("Demo Note (Transient)") + .withBackendName("transientMemory").withPrimaryKeyField("id") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)) + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("text", QFieldType.STRING))); + instance.withRuntimeService(new QCodeReference(TransientNotesService.class)); + instance.addProcess(new QProcessMetaData().withName("calculateOrderTotal").withLabel("Calculate Order Total (JavaScript)") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)) + .withStep(new QBackendStepMetaData().withName("calculate") + .withCode(new QCodeReference(CalculateOrderTotalStep.class)) + .withInputData(new QFunctionInputMetaData().withFieldList(List.of( + new QFieldMetaData("quantity", QFieldType.INTEGER), + new QFieldMetaData("unitPriceCents", QFieldType.INTEGER)))) + .withOutputMetaData(new QFunctionOutputMetaData().withFieldList(List.of( + new QFieldMetaData("totalCents", QFieldType.INTEGER)))))); + QJavalinMetaData.ofOrWithNew(instance).withAdditionalRouteProviderReference(new QCodeReference(DemoCatalogRoutes.class)); + } + + private static String catalogBaseUrl(String bindHost, int port) throws QException + { + try + { + InetAddress address = InetAddress.getByName(bindHost); + String host = address.isAnyLocalAddress() + ? (address instanceof Inet6Address ? "::1" : "127.0.0.1") : bindHost; + return new URI("http", null, host, port, "/demo", null, null).toASCIIString(); + } + catch(UnknownHostException | URISyntaxException e) + { + throw new QException("Could not derive demo catalog URL from HTTP bind host", e); + } + } + +} 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 204c0a5..e1d77c9 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 @@ -53,6 +53,7 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface @Override public void customizeSession(QInstance instance, QSession session, Map context) { + session.withPermissions("apiCatalog.read", "demoNote.read"); Boolean admin = ADMIN_SESSION.equals(session.getUuid()); String name = admin ? "Admin" : "Demo User"; String userId = admin ? "core:admin" : "core:demo"; @@ -76,6 +77,7 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface } if(admin) { + session.withPermissions("calculateOrderTotal.hasAccess", "demoNote.insert", "demoNote.edit", "demoNote.delete"); session.withPermissions("esbView.hasAccess", "esbOperate.hasAccess", "esbDelete.hasAccess"); for(String processName : WEBHOOK_PROCESSES) { 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 65b6659..0e199b7 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 @@ -34,6 +34,7 @@ import com.kingsrook.qqq.backend.module.filesystem.sftp.model.metadata.SFTPTable import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMetaData; import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBTableBackendDetails; import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData; import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; import com.kingsrook.qqq.esb.model.EsbDestinationType; import com.kingsrook.qqq.esb.model.EsbInstanceMetaData; @@ -66,7 +67,7 @@ final class FullProfileApplication extends AbstractQQQApplication @Override public QInstance defineQInstance() throws QException { - QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL"), false).defineQInstance(); + QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL"), false, settings.port("PORT", "8080"), settings.get("BIND_HOST")).defineQInstance(); addBackends(instance); addTables(instance); addAuthentication(instance); @@ -111,13 +112,9 @@ final class FullProfileApplication extends AbstractQQQApplication private void addBackends(QInstance instance) { - instance.addBackend(new RDBMSBackendMetaData().withName("postgres").withVendor("postgres") + instance.addBackend(new PostgreSQLBackendMetaData().withName("postgres") .withHostName(settings.get("POSTGRES_HOST")).withPort(settings.port("POSTGRES_PORT", "5432")) .withDatabaseName(settings.get("POSTGRES_DATABASE")) - .withJdbcUrl("jdbc:postgresql://" + settings.get("POSTGRES_HOST") + ":" - + settings.port("POSTGRES_PORT", "5432") + "/" + settings.get("POSTGRES_DATABASE")) - .withJdbcDriverClassName("org.postgresql.Driver") - .withActionStrategyCodeReference(new QCodeReference(PostgresActionStrategy.class)) .withUsername(settings.get("POSTGRES_USER")).withPassword(settings.get("POSTGRES_PASSWORD"))); instance.addBackend(new RDBMSBackendMetaData().withName("mysql").withVendor("mysql") .withHostName(settings.get("MYSQL_HOST")).withPort(settings.port("MYSQL_PORT", "3306")) diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/PostgresActionStrategy.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/PostgresActionStrategy.java deleted file mode 100644 index c9bce8c..0000000 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/PostgresActionStrategy.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.sql.PreparedStatement; -import java.sql.SQLException; -import java.sql.Types; -import com.kingsrook.qqq.backend.module.rdbms.strategy.BaseRDBMSActionStrategy; - -/** PostgreSQL syntax for the shared QQQ RDBMS actions. */ -public class PostgresActionStrategy extends BaseRDBMSActionStrategy -{ - @Override - public String getIdentifierQuoteString() - { - return "\""; - } - - @Override - public String getInsertDefaultValuesClause() - { - return "DEFAULT VALUES"; - } - - @Override - protected int bindParamObject(PreparedStatement statement, int index, Object value) throws SQLException - { - if(value == null) - { - statement.setNull(index, Types.NULL); - return 1; - } - return super.bindParamObject(statement, index, value); - } -} 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 9328db2..a562760 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 @@ -58,6 +58,8 @@ public final class QqqAllApplication extends AbstractQQQApplication private final Path dataDirectory; private final String brokerUrl; private final boolean includeDemoQbits; + private final int httpPort; + private final String bindHost; public QqqAllApplication(Path dataDirectory, String brokerUrl) { @@ -66,6 +68,18 @@ public final class QqqAllApplication extends AbstractQQQApplication QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits) { + this(dataDirectory, brokerUrl, includeDemoQbits, 8080); + } + + QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits, int httpPort) + { + this(dataDirectory, brokerUrl, includeDemoQbits, httpPort, "127.0.0.1"); + } + + QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits, int httpPort, String bindHost) + { + this.bindHost = bindHost; + this.httpPort = httpPort; this.dataDirectory = dataDirectory; this.brokerUrl = brokerUrl; this.includeDemoQbits = includeDemoQbits; @@ -152,6 +166,7 @@ public final class QqqAllApplication extends AbstractQQQApplication QJavalinMetaData.ofOrWithNew(instance) .withAdditionalRouteProviderReference(new QCodeReference(DemoOrderWebhookReceiver.class)); } + DemoRuntimeFeatures.add(instance, httpPort, bindHost); return instance; } diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/TransientNotesService.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/TransientNotesService.java new file mode 100644 index 0000000..90fd15e --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/TransientNotesService.java @@ -0,0 +1,88 @@ +/* + * 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.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.QRuntimeServiceInterface; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; + +/** Seed and clear only the application's transient memory table. */ +public class TransientNotesService implements QRuntimeServiceInterface +{ + private static final QLogger LOG = QLogger.getLogger(TransientNotesService.class); + private QInstance instance; + + @Override + public String getName() + { + return "transientDemoNotes"; + } + + @Override + public void start(QInstance instance) throws QException + { + this.instance = instance; + var previous = QContext.capture(); + try + { + QContext.init(instance, new QSystemUserSession()); + clearNotes(); + new InsertAction().execute(new InsertInput("demoNote").withRecords(List.of( + new QRecord().withValue("id", 1).withValue("text", "Transient demo note")))); + } + catch(QException | RuntimeException failure) + { + try + { + clearNotes(); + } + catch(Exception cleanup) + { + failure.addSuppressed(cleanup); + } + throw failure; + } + finally + { + QContext.init(previous); + } + } + + @Override + public void stop() + { + var previous = QContext.capture(); + try + { + QContext.init(instance, new QSystemUserSession()); + clearNotes(); + } + catch(Exception failure) + { + LOG.warn("Could not clear transient demo notes", failure); + } + finally + { + QContext.init(previous); + } + } + + private void clearNotes() throws QException + { + new DeleteAction().execute(new DeleteInput("demoNote").withQueryFilter(new QQueryFilter())); + } +} diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreJarIT.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreJarIT.java index 1724904..d7bea7e 100644 --- a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreJarIT.java +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreJarIT.java @@ -16,6 +16,8 @@ import java.nio.file.Path; import java.time.Duration; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -39,28 +41,56 @@ class CoreJarIT Path log = dataDirectory.resolve("app.log"); ProcessBuilder builder = new ProcessBuilder( Path.of(System.getProperty("java.home"), "bin", "java").toString(), "-jar", jar.toString()); + builder.environment().put("QQQ_ALL_PROFILE", "core"); builder.environment().put("QQQ_ALL_PORT", port.toString()); builder.environment().put("QQQ_ALL_DATA_DIR", dataDirectory.resolve("data").toString()); builder.redirectErrorStream(true).redirectOutput(log.toFile()); Process process = builder.start(); try { - HttpClient client = HttpClient.newHttpClient(); - URI healthUri = URI.create("http://127.0.0.1:" + port + "/health"); - HttpResponse health = awaitHealth(client, healthUri, process, log); - assertEquals(200, health.statusCode()); - assertTrue(health.body().contains("\"UP\"")); - assertTrue(Files.readString(log).contains("{127.0.0.1:" + port + "}"), - "Core profile must bind only to loopback"); + try(HttpClient client = HttpClient.newHttpClient()) + { + URI healthUri = URI.create("http://127.0.0.1:" + port + "/health"); + HttpResponse health = awaitHealth(client, healthUri, process, log); + assertEquals(200, health.statusCode()); + assertTrue(health.body().contains("\"UP\"")); + assertTrue(Files.readString(log).contains("{127.0.0.1:" + port + "}"), + "Core profile must bind only to loopback"); - HttpRequest query = HttpRequest.newBuilder() - .uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/table/customer/query")) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString("{}")) - .build(); - HttpResponse customers = client.send(query, HttpResponse.BodyHandlers.ofString()); - assertEquals(200, customers.statusCode(), customers.body()); - assertTrue(customers.body().contains("Ada Lovelace")); + HttpRequest query = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/table/customer/query")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(); + HttpResponse customers = client.send(query, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, customers.statusCode(), customers.body()); + assertTrue(customers.body().contains("Ada Lovelace")); + for(String table : java.util.List.of("apiCatalog", "demoNote")) + { + var result = client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + + "/qqq/v1/table/" + table + "/query")).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, result.statusCode(), result.body()); + var records = new org.json.JSONObject(result.body()).getJSONArray("records"); + assertEquals(table.equals("apiCatalog") ? 3 : 1, records.length()); + } + var calculation = client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + + "/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250")) + .header("Cookie", "sessionId=" + DemoUsers.ADMIN_SESSION).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, calculation.statusCode(), calculation.body()); + assertEquals(3750, new org.json.JSONObject(calculation.body()).getJSONObject("values").getInt("totalCents")); + } + try(var packaged = new java.util.jar.JarFile(jar.toFile())) + { + var service = packaged.getJarEntry("META-INF/services/javax.script.ScriptEngineFactory"); + org.junit.jupiter.api.Assertions.assertNotNull(service); + try(var input = packaged.getInputStream(service)) + { + assertTrue(new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8) + .contains("org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory")); + } + } } finally { @@ -73,6 +103,69 @@ class CoreJarIT } } + @ParameterizedTest + @ValueSource(strings = {"::1", "::", "0:0:0:0:0:0:0:0", "0.0.0.0"}) + void packagedCatalogUsesConfiguredListener(String bindHost) throws Exception + { + String clientHost = bindHost.contains(":") ? "::1" : "127.0.0.1"; + int port; + try(ServerSocket socket = new ServerSocket(0, 50, java.net.InetAddress.getByName(clientHost))) + { + port = socket.getLocalPort(); + } + URI base = new URI("http", null, clientHost, port, "/", null, null); + Path log = dataDirectory.resolve("bound-app.log"); + ProcessBuilder builder = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-jar", System.getProperty("qqqAllJar")); + builder.environment().put("QQQ_ALL_PROFILE", "core"); + builder.environment().put("QQQ_ALL_BIND_HOST", bindHost); + builder.environment().put("QQQ_ALL_PORT", Integer.toString(port)); + builder.environment().put("QQQ_ALL_DATA_DIR", dataDirectory.resolve("bound-data").toString()); + builder.redirectErrorStream(true).redirectOutput(log.toFile()); + Process process = builder.start(); + try(HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()) + { + assertEquals(200, awaitHealth(client, base.resolve("health"), process, log).statusCode()); + var direct = client.send(HttpRequest.newBuilder(base.resolve("demo/catalog")) + .timeout(Duration.ofSeconds(10)).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, direct.statusCode(), "Configured listener must serve its catalog before testing the backend"); + assertEquals(2, new org.json.JSONArray(direct.body()).length()); + + var all = catalogQuery(client, base, "{}"); + assertEquals(java.util.List.of(101, 102, 103), all.toList().stream() + .map(row -> ((java.util.Map) ((java.util.Map) row).get("values")).get("id")).toList()); + for(int skip = 0; skip < 3; skip++) + { + var page = catalogQuery(client, base, "{\"filter\":{\"skip\":" + skip + ",\"limit\":1}}"); + assertEquals(1, page.length()); + assertEquals(java.util.List.of(101, 102, 103).get(skip), page.getJSONObject(0).getJSONObject("values").getInt("id")); + } + assertEquals(0, catalogQuery(client, base, "{\"filter\":{\"skip\":3,\"limit\":1}}").length()); + var get = client.send(HttpRequest.newBuilder(base.resolve("data/apiCatalog/103")) + .timeout(Duration.ofSeconds(10)).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, get.statusCode(), get.body()); + assertEquals("Shipping Box", new org.json.JSONObject(get.body()).getJSONObject("values").getString("name")); + } + finally + { + process.destroy(); + if(!process.waitFor(5, TimeUnit.SECONDS)) + { + process.destroyForcibly(); + assertTrue(process.waitFor(5, TimeUnit.SECONDS), "Owned packaged process must stop"); + } + } + } + + private static org.json.JSONArray catalogQuery(HttpClient client, URI base, String body) throws Exception + { + var response = client.send(HttpRequest.newBuilder(base.resolve("qqq/v1/table/apiCatalog/query")) + .timeout(Duration.ofSeconds(10)).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode(), response.body()); + return new org.json.JSONObject(response.body()).getJSONArray("records"); + } + private static HttpResponse awaitHealth(HttpClient client, URI uri, Process process, Path log) throws Exception { long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos(); 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 f45b85b..ce2fe57 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 @@ -45,6 +45,7 @@ class FullProfileTest new QInstanceValidator().validate(instance); RDBMSBackendMetaData postgres = (RDBMSBackendMetaData) instance.getBackend("postgres"); + assertEquals("com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData", postgres.getClass().getName()); assertEquals("postgres.example.test", postgres.getHostName()); assertEquals("jdbc:postgresql://postgres.example.test:5432/qqq", ConnectionManager.getJdbcUrl(postgres)); assertEquals("org.postgresql.Driver", ConnectionManager.getJdbcDriverClassName(postgres)); diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/PostgresCompatibilityTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/PostgresCompatibilityTest.java new file mode 100644 index 0000000..374308e --- /dev/null +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/PostgresCompatibilityTest.java @@ -0,0 +1,101 @@ +/* + * 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.DriverManager; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +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.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +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.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Opt-in native PostgreSQL oracle; owns only its uniquely named table. */ +@EnabledIfEnvironmentVariable(named = "QQQ_ALL_TEST_POSTGRES_PORT", matches = "[0-9]+") +@Timeout(30) +class PostgresCompatibilityTest +{ + @TempDir + Path directory; + + @Test + void nativeStrategyPreservesGeneratedIdsNullsAndUtcTimestamps() throws Exception + { + var environment = FullProfileTest.environment(); + environment.put("QQQ_ALL_POSTGRES_HOST", "127.0.0.1"); + environment.put("QQQ_ALL_POSTGRES_PORT", System.getenv("QQQ_ALL_TEST_POSTGRES_PORT")); + environment.put("QQQ_ALL_POSTGRES_DATABASE", "qqq"); + environment.put("QQQ_ALL_POSTGRES_USER", "qqq"); + environment.put("QQQ_ALL_POSTGRES_PASSWORD", System.getenv("QQQ_ALL_TEST_POSTGRES_PASSWORD")); + var instance = new FullProfileApplication(directory, FullProfileSettings.from(environment)).defineQInstance(); + var backend = (RDBMSBackendMetaData) instance.getBackend("postgres"); + String table = "compat_" + UUID.randomUUID().toString().replace("-", ""); + instance.addTable(new QTableMetaData().withName("compatibility").withBackendName("postgres") + .withBackendDetails(new RDBMSTableBackendDetails().withTableName(table)).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("value", QFieldType.STRING)) + .withField(new QFieldMetaData("created", QFieldType.DATE_TIME))); + instance.addTable(new QTableMetaData().withName("compatibilityDefault").withBackendName("postgres") + .withBackendDetails(new RDBMSTableBackendDetails().withTableName(table)).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER))); + var previous = QContext.capture(); + try(var connection = DriverManager.getConnection(ConnectionManager.getJdbcUrl(backend), backend.getUsername(), backend.getPassword()); + var statement = connection.createStatement()) + { + statement.execute("CREATE TABLE " + table + " (id SERIAL PRIMARY KEY, value TEXT, created TIMESTAMP)"); + try + { + QContext.init(instance, new QSystemUserSession()); + var empty = new InsertAction().execute(new InsertInput("compatibilityDefault").withRecords(List.of(new QRecord()))).getRecords().getFirst(); + assertNotNull(empty.getValueInteger("id")); + Instant timestamp = Instant.parse("2026-09-25T12:34:56Z"); + var row = new InsertAction().execute(new InsertInput("compatibility").withRecords(List.of( + new QRecord().withValue("value", "demo").withValue("created", timestamp)))).getRecords().getFirst(); + assertTrue(row.getValueInteger("id") > empty.getValueInteger("id")); + new UpdateAction().execute(new UpdateInput("compatibility").withRecords(List.of( + new QRecord().withValue("id", row.getValueInteger("id")).withValue("value", null)))); + assertNull(GetAction.execute("compatibility", row.getValueInteger("id")).getValue("value")); + try(var result = statement.executeQuery("SELECT value, created FROM " + table + " WHERE id = " + row.getValueInteger("id"))) + { + assertTrue(result.next()); + assertNull(result.getString(1)); + assertEquals(timestamp, result.getTimestamp(2, java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))).toInstant()); + assertFalse(result.next()); + } + } + finally + { + statement.execute("DROP TABLE " + table); + } + } + finally + { + QContext.init(previous); + } + } +} diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/RuntimeAssemblyTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/RuntimeAssemblyTest.java new file mode 100644 index 0000000..abb7333 --- /dev/null +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/RuntimeAssemblyTest.java @@ -0,0 +1,24 @@ +/* + * 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 javax.script.ScriptEngineManager; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** The reference application must ship its advertised backend and language. */ +class RuntimeAssemblyTest +{ + @Test + void includesApiBackendAndJavaScriptEngine() + { + assertDoesNotThrow(() -> Class.forName("com.kingsrook.qqq.backend.module.api.APIBackendModule")); + assertDoesNotThrow(() -> Class.forName("com.kingsrook.qqq.languages.javascript.QJavaScriptExecutor")); + assertNotNull(new ScriptEngineManager().getEngineByName("JavaScript")); + } +} diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/RuntimeFeaturesTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/RuntimeFeaturesTest.java new file mode 100644 index 0000000..6e1daf9 --- /dev/null +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/RuntimeFeaturesTest.java @@ -0,0 +1,157 @@ +/* + * 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.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.esb.connection.EsbConnectionManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** Exercise the assembled demo against its real local HTTP backend. */ +@Timeout(45) +class RuntimeFeaturesTest +{ + @TempDir + Path directory; + + @AfterEach + void closeOwnedBrokerConnections() + { + EsbConnectionManager.getInstance().closeAll(); + } + + @Test + void apiCatalogQueriesGetsAndPaginates() throws Exception + { + try(CoreRuntime runtime = CoreRuntime.launch(directory, freePort())) + { + assertNotNull(runtime.getLauncher().getQInstance().getTable("apiCatalog")); + QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession()); + try + { + assertEquals(List.of(101, 102, 103), new QueryAction().execute(new QueryInput("apiCatalog")).getRecords() + .stream().map(record -> record.getValueInteger("id")).toList()); + assertEquals(102, new QueryAction().execute(new QueryInput("apiCatalog") + .withFilter(new QQueryFilter().withSkip(1).withLimit(1))).getRecords().getFirst().getValueInteger("id")); + assertEquals("Shipping Box", GetAction.execute("apiCatalog", 103).getValueString("name")); + assertNull(GetAction.execute("apiCatalog", 999)); + assertEquals(3, new CountAction().execute(new CountInput("apiCatalog")).getCount()); + var exactId = new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, 103)); + assertEquals(1, new CountAction().execute(new CountInput("apiCatalog").withFilter(exactId)).getCount()); + assertEquals(List.of(103), new QueryAction().execute(new QueryInput("apiCatalog").withFilter(exactId)).getRecords() + .stream().map(record -> record.getValueInteger("id")).toList()); + assertThrows(QException.class, () -> new QueryAction().execute(new QueryInput("apiCatalog") + .withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Starter Kit"))))); + } + finally + { + QContext.clear(); + } + } + } + + @Test + void calculationUsesRealJavaScriptAndRequiresPermission() throws Exception + { + int port = freePort(); + try(CoreRuntime runtime = CoreRuntime.launch(directory, port); HttpClient client = HttpClient.newHttpClient()) + { + assertNotNull(runtime.getLauncher().getQInstance().getProcess("calculateOrderTotal")); + HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + + "/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250")) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}")); + assertEquals(403, client.send(request.header("Cookie", "sessionId=" + DemoUsers.DEMO_SESSION).build(), HttpResponse.BodyHandlers.ofString()).statusCode()); + var allowed = client.send(request.setHeader("Cookie", "sessionId=" + DemoUsers.ADMIN_SESSION).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, allowed.statusCode(), allowed.body()); + assertEquals(3750, new org.json.JSONObject(allowed.body()).getJSONObject("values").getInt("totalCents")); + } + } + + @Test + void calculationRejectsMissingNegativeAndExcessiveInputs() + { + for(Integer quantity : java.util.Arrays.asList(null, 0, -1, 1001)) + { + var input = new RunBackendStepInput().addValue("quantity", quantity).addValue("unitPriceCents", 1250); + assertThrows(QException.class, () -> new CalculateOrderTotalStep().run(input, new RunBackendStepOutput())); + } + for(Integer price : java.util.Arrays.asList(null, -1, 1_000_001)) + { + var input = new RunBackendStepInput().addValue("quantity", 3).addValue("unitPriceCents", price); + assertThrows(QException.class, () -> new CalculateOrderTotalStep().run(input, new RunBackendStepOutput())); + } + } + + @Test + void memoryNotesResetAcrossRuntimeRestart() throws Exception + { + try(CoreRuntime runtime = CoreRuntime.launch(directory, freePort())) + { + assertNotNull(runtime.getLauncher().getQInstance().getTable("demoNote")); + QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession()); + try + { + assertEquals("Transient demo note", GetAction.execute("demoNote", 1).getValueString("text")); + new UpdateAction().execute(new UpdateInput("demoNote").withRecords(List.of(new QRecord().withValue("id", 1).withValue("text", "Only this run")))); + assertEquals("Only this run", GetAction.execute("demoNote", 1).getValueString("text")); + } + finally + { + QContext.clear(); + } + } + closeOwnedBrokerConnections(); + try(CoreRuntime runtime = CoreRuntime.launch(directory, freePort())) + { + QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession()); + try + { + assertEquals("Transient demo note", GetAction.execute("demoNote", 1).getValueString("text")); + } + finally + { + QContext.clear(); + } + } + } + + static int freePort() throws Exception + { + try(ServerSocket socket = new ServerSocket(0)) + { + return socket.getLocalPort(); + } + } +} diff --git a/scripts/smoke.py b/scripts/smoke.py index f19ac3d..c3f89c8 100644 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -81,7 +81,7 @@ class SmokeClient: self.cookies = http.cookiejar.CookieJar() self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookies)) - def request(self, path, body=None, method=None): + def request(self, path, body=None, method=None, expected_status=200): data = None if body is None else json.dumps(body).encode() headers = {"Content-Type": "application/json"} if data is not None else {} if self.session_id: @@ -92,12 +92,16 @@ class SmokeClient: ) try: with self.opener.open(request, timeout=5) as response: - if response.status != 200: + if response.status != expected_status: raise AssertionError(f"{path}: HTTP {response.status}") return response.read() except urllib.error.HTTPError as error: status = error.code - error.close() + try: + if status == expected_status: + return error.read() + finally: + error.close() raise AssertionError(f"{path}: HTTP {status}") from None def json(self, path, body=None): @@ -197,9 +201,31 @@ def check_artemis_round_trip(client, timeout): return process_uuid +def check_runtime_features(client, can_calculate=True): + records = client.records("apiCatalog") + if [record.get("values", {}).get("id") for record in records] != [101, 102, 103]: + raise AssertionError("API catalog did not traverse all pages") + record = client.json("/data/apiCatalog/103") + if record.get("values", {}).get("name") != "Shipping Box": + raise AssertionError("API catalog get returned incorrect record") + client.require_record("demoNote", "text", "Transient demo note") + path = "/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250" + if can_calculate: + result = client.json(path, {}) + if result.get("values", {}).get("totalCents") != 3750: + raise AssertionError("JavaScript calculation returned incorrect total") + else: + client.request(path, {}, expected_status=403) + client.request("/data/demoNote/1", {"text": "forbidden"}, method="PATCH", expected_status=403) + client.request("/data/apiCatalog/", {"id": 999, "name": "forbidden"}, expected_status=403) + print("API catalog, transient memory, JavaScript permissions: OK") + + def check_core(client, timeout): check_health_and_dashboard(client, timeout) check_tables(client, CORE_TABLES) + check_runtime_features(client) + check_runtime_features(SmokeClient(client.base_url, "dddddddd-dddd-4ddd-8ddd-dddddddddddd"), False) check_artemis_round_trip(client, timeout) @@ -312,9 +338,11 @@ def check_full(base_url, keycloak_url, query_index, timeout): check_health_and_dashboard(viewer, timeout) oidc_login(viewer, keycloak_url, "demo-user", os.environ["DEMO_USER_PASSWORD"]) viewer.require_record("customer", "name", "Ada Lovelace") + check_runtime_features(viewer, False) admin = SmokeClient(base_url) oidc_login(admin, keycloak_url, "demo-admin", os.environ["DEMO_ADMIN_PASSWORD"]) check_tables(admin, FULL_TABLES, require_seed=False) + check_runtime_features(admin) check_quick_search(admin, query_index, os.environ.get("QQQ_ALL_OPENSEARCH_INDEX", "qqq-all-customers"), timeout) process_uuid = check_artemis_round_trip(admin, timeout) check_rabbitmq(admin, timeout, process_uuid) diff --git a/scripts/test_smoke.py b/scripts/test_smoke.py index 0177d47..b17069b 100644 --- a/scripts/test_smoke.py +++ b/scripts/test_smoke.py @@ -19,7 +19,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from unittest import mock -from smoke import SmokeClient, check_artemis_round_trip, check_core, check_rabbitmq, check_tables, CORE_TABLES +from smoke import SmokeClient, check_artemis_round_trip, check_core, check_rabbitmq, check_tables, check_runtime_features, CORE_TABLES import run_full_smoke from run_full_smoke import ROOT @@ -42,6 +42,8 @@ class DemoHandler(BaseHTTPRequestHandler): pass def do_GET(self): + if self.path == "/data/apiCatalog/103": + return self.send_json({"values": {"id": 103, "name": "Shipping Box"}}) if self.path == "/health": return self.send_json({"status": "UP"}) if self.path == "/": @@ -62,6 +64,12 @@ class DemoHandler(BaseHTTPRequestHandler): def do_POST(self): body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + if self.path == "/data/apiCatalog/": + return self.send_error(403) + if self.path.startswith("/processes/calculateOrderTotal/run"): + if "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" not in self.headers.get("Cookie", ""): + return self.send_error(403) + return self.send_json({"values": {"totalCents": 3750}}) if self.path == "/data/order/": assert body["orderNo"].startswith("SMOKE-") type(self).orders += 1 @@ -70,6 +78,8 @@ class DemoHandler(BaseHTTPRequestHandler): return self.send_json({"records": [{"values": {"id": 100 + self.orders}}]}) if self.path.startswith("/qqq/v1/table/") and self.path.endswith("/query"): table = self.path.split("/")[4] + if table == "apiCatalog": + return self.send_json({"records": [{"values": {"id": i}} for i in (101, 102, 103)]}) if table == "processTrace": records = [{"recordLabel": self.trace_label, "values": {"id": i, "processUUID": f"trace-{i}", @@ -77,6 +87,7 @@ class DemoHandler(BaseHTTPRequestHandler): for i in range(self.orders)] else: values = { + "demoNote": {"id": 1, "text": "Transient demo note"}, "customer": {"id": 1, "name": "Ada Lovelace"}, "order": {"id": 1, "orderNo": "ORD-1001"}, "orderLine": {"id": 1, "quantity": 2}, @@ -89,6 +100,11 @@ class DemoHandler(BaseHTTPRequestHandler): return self.send_json({"records": records}) self.send_error(404) + def do_PATCH(self): + if self.path == "/data/demoNote/1": + return self.send_error(403) + self.send_error(404) + def send_json(self, value): data = json.dumps(value).encode() self.send_response(200) @@ -104,6 +120,19 @@ class SmokeTest(unittest.TestCase): DemoHandler.trace_order_id = None DemoHandler.inserted_marker = None + def test_catalog_missing_final_page_fails(self): + client = mock.Mock() + client.records.return_value = [{"values": {"id": value}} for value in (101, 102)] + with self.assertRaisesRegex(AssertionError, "all pages"): + check_runtime_features(client) + + def test_wrong_javascript_result_fails(self): + client = mock.Mock() + client.records.return_value = [{"values": {"id": value}} for value in (101, 102, 103)] + client.json.side_effect = [{"values": {"name": "Shipping Box"}}, {"values": {"totalCents": 3749}}] + with self.assertRaisesRegex(AssertionError, "incorrect total"): + check_runtime_features(client) + def test_core_runner_fails_promptly_when_jar_exits(self): with tempfile.TemporaryDirectory() as directory: jar = Path(directory) / "broken.jar" @@ -294,6 +323,7 @@ class SmokeTest(unittest.TestCase): thread.start() try: client = SmokeClient(f"http://127.0.0.1:{server.server_port}") + client.session_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" check_core(client, timeout=2) finally: server.shutdown()