QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Failing after 9s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 11s
Add DocumentDefinitionService.rename and the native authoring actions
document_generation_{type,stock,template}_author_rename. The form loads
the current name and saves only the trimmed name; names must be nonblank,
at most 200 characters and free of control characters. The configure
permission is checked at discovery and again at save, and the native
UpdateAction keeps the host's field-level audit. GenerationStore.update
now admits an id+name-only write for type and stock rows, so geometry,
bindings, drafts, published revisions and queued pins stay immutable.
No schema change.
Carried locally by GG Forge as definition-rename.patch (pin
0.1.0-forge.ee782b7d6ea8-rename1); upstreamed so a published release can
replace that pin. The prepare-step selection message is generalised to
cover type and stock rows. Docs: AUTHORING.md describes the action,
README and RELEASE-STATUS record the addition and its verification
(./mvnw -pl foundation -am verify: 232 tests, Checkstyle and coverage
gates met, including the Docker MySQL and PostgreSQL suites).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
1324 lines
85 KiB
Java
1324 lines
85 KiB
Java
/*
|
|
* QQQ - Low-code Application Framework for Engineers.
|
|
* Copyright (C) 2021-2025. Kingsrook, LLC
|
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
|
* contact@kingsrook.com
|
|
* https://github.com/Kingsrook/
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* https://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package com.kingsrook.qbits.documentgeneration;
|
|
|
|
import java.time.Instant;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
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.QInstanceEnricher;
|
|
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.insert.InsertInput;
|
|
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
|
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.authentication.QAuthenticationMetaData;
|
|
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.tables.QTableMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
|
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
|
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
|
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
|
|
/*******************************************************************************
|
|
** Native QQQ metadata/step execution with current permissions and pure fixtures.
|
|
** The owner download adapter has separate real-owner integration coverage.
|
|
*******************************************************************************/
|
|
class GenerationProcessesTest
|
|
{
|
|
private QInstance instance;
|
|
private DocumentGenerationQBitConfig config;
|
|
private Integer template;
|
|
private final Map<String,GenerationPorts.Receipt> receipts=new HashMap<>();
|
|
private Map<String,Object> rendered;
|
|
private String downloadUrl="https://storage.example.test/exact-version";
|
|
private boolean wrongVersion;
|
|
private boolean contextDenied;
|
|
private String selectedContextId = "7";
|
|
private boolean assetAccessDenied;
|
|
|
|
/***************************************************************************
|
|
** Standard clients receive usable sections and references without changing pins.
|
|
***************************************************************************/
|
|
@Test
|
|
void templateRecordSeparatesAuthoringFromAdvancedIdentities()
|
|
{
|
|
var table = instance.getTable(GenerationTables.TEMPLATE);
|
|
assertThat(table.getSections()).extracting(section -> section.getName()).containsExactly("overview", "layout", "context", "advanced");
|
|
assertThat(table.getSections().getFirst().getFieldNames()).contains("typeId", "stockId", "publishedRevisionId")
|
|
.doesNotContain("pdfSource", "zplSource", "scriptArtifact", "assetIds");
|
|
assertThat(table.getSections().get(1).getCollapsible().getInitiallyOpen()).isFalse();
|
|
assertThat(table.getSections().get(3).getCollapsible().getInitiallyOpen()).isFalse();
|
|
assertThat(table.getSections().stream().flatMap(section -> section.getFieldNames().stream()).toList())
|
|
.containsExactlyInAnyOrderElementsOf(table.getFields().keySet());
|
|
assertThat(table.getField("typeId").getPossibleValueSourceName()).isEqualTo(GenerationTables.TYPE);
|
|
assertThat(table.getField("stockId").getPossibleValueSourceName()).isEqualTo(GenerationTables.STOCK);
|
|
assertThat(table.getField("publishedRevisionId").getPossibleValueSourceName()).isEqualTo(GenerationTables.REVISION);
|
|
assertThat(table.getField("scriptArtifact").getPossibleValueSourceName()).isNull();
|
|
var process = instance.getProcess(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "preview"));
|
|
assertThat(process.getLabel()).isEqualTo("Preview draft");
|
|
assertThat(process.getFrontendStep("result").getLabel()).contains("open or download");
|
|
assertThat(process.getFrontendStep("result").getViewFields()).anyMatch(field -> "downloadUrl".equals(field.getName()));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** The form selects its persisted source, and search/save both recheck access.
|
|
***************************************************************************/
|
|
@Test
|
|
void previewSourceSelectorUsesNativeReadAuthorityAndKeepsPublishedRevision() throws Exception
|
|
{
|
|
var table = instance.getTable("orders");
|
|
table.withRecordLabelFormat("%s").withRecordLabelFields("name");
|
|
var input = new RunBackendStepInput().withProcessName(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "preview"))
|
|
.withRecords(List.of(new QRecord().withValue("id", template).withValue("tableName", "forged")));
|
|
var output = new RunBackendStepOutput();
|
|
new GenerationAuthoringProcesses.Prepare().run(input, output);
|
|
var field = output.getProcessMetaDataAdjustment().getUpdatedFields().get("sourceRecordId");
|
|
assertThat(field.getType()).isEqualTo(QFieldType.INTEGER);
|
|
assertThat(field.getPossibleValueSourceName()).isEqualTo(GenerationAuthoringProcesses.sourceRecordChoices("orders"));
|
|
assertThat(instance.getPossibleValueSource(field.getPossibleValueSourceName()).getTableName()).isNull();
|
|
var search = new com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput()
|
|
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withSearchTerm("canon");
|
|
var action = new com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction();
|
|
assertThat(action.execute(search).getResults()).extracting(value -> value.getLabel()).containsExactly("canonical");
|
|
table.getField("name").setIsHidden(true);
|
|
search.setSearchTerm("");
|
|
assertThat(action.execute(search).getResults()).extracting(value -> value.getLabel()).containsExactly("1");
|
|
Integer published = GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("publishedRevisionId");
|
|
var preview = new RunBackendStepInput().withProcessName(input.getProcessName()).withValues(output.getValues());
|
|
preview.addValue("sourceRecordId", "1");
|
|
assertThat(saveAuthor(preview).getValueString("downloadUrl")).isEqualTo(downloadUrl);
|
|
assertThat(GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("publishedRevisionId")).isEqualTo(published);
|
|
QContext.getQSession().getPermissions().remove("orders.read");
|
|
assertThatThrownBy(() -> action.execute(search)).isInstanceOf(QException.class);
|
|
assertThatThrownBy(() -> saveAuthor(preview)).isInstanceOf(QException.class);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** The standard process possible-values route reads the process's own static input field, so
|
|
** the record selector must be declared there and resolve its table from the form value.
|
|
***************************************************************************/
|
|
@Test
|
|
void previewRecordSelectorIsDeclaredOnTheProcessFieldAndFollowsTheFormSourceTable() throws Exception
|
|
{
|
|
instance.getTable("orders").withRecordLabelFormat("%s").withRecordLabelFields("name");
|
|
var process = instance.getProcess(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "preview"));
|
|
var field = process.getInputFields().stream().filter(candidate -> "sourceRecordId".equals(candidate.getName())).findFirst().orElseThrow();
|
|
assertThat(field.getPossibleValueSourceName()).isEqualTo(GenerationAuthoringProcesses.PREVIEW_SOURCE);
|
|
var shown = process.getInputFields().stream().filter(candidate -> "sourceTable".equals(candidate.getName())).findFirst().orElseThrow();
|
|
assertThat(shown.getIsEditable()).isFalse();
|
|
var prepare = new RunBackendStepInput().withProcessName(process.getName()).withRecords(List.of(new QRecord().withValue("id", template)));
|
|
var output = new RunBackendStepOutput();
|
|
new GenerationAuthoringProcesses.Prepare().run(prepare, output);
|
|
assertThat(output.getValues().get("sourceTable")).isEqualTo("orders");
|
|
var action = new com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction();
|
|
var search = new com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput()
|
|
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withSearchTerm("canon").withOtherValues(java.util.Map.of("sourceTable", "orders"));
|
|
assertThat(action.execute(search).getResults()).extracting(value -> value.getLabel()).containsExactly("canonical");
|
|
for(var other : List.of(java.util.Map.<String, java.io.Serializable>of(), java.util.Map.<String, java.io.Serializable>of("sourceTable", "unconfigured_table"),
|
|
java.util.Map.<String, java.io.Serializable>of("sourceTable", 7)))
|
|
{
|
|
var rejected = new com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput()
|
|
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withSearchTerm("canon").withOtherValues(other);
|
|
assertThatThrownBy(() -> action.execute(rejected)).isInstanceOf(QException.class);
|
|
}
|
|
QContext.getQSession().getPermissions().remove("orders.read");
|
|
assertThatThrownBy(() -> action.execute(search)).isInstanceOf(QException.class);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Picker callers cannot broaden the registered source or request unbounded work.
|
|
***************************************************************************/
|
|
@Test
|
|
void previewSourceSelectorRejectsUnscopedAndOversizedRequests() throws Exception
|
|
{
|
|
var provider = new GenerationPreviewSources();
|
|
var input = new com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput()
|
|
.withPossibleValueSourceName(GenerationTables.TEMPLATE);
|
|
assertThatThrownBy(() -> provider.search(input)).hasMessageContaining("Unknown preview source");
|
|
assertThatThrownBy(() -> provider.getPossibleValue(1)).hasMessageContaining("source-specific");
|
|
input.setPossibleValueSourceName(GenerationAuthoringProcesses.sourceRecordChoices("orders"));
|
|
input.setSearchTerm("x".repeat(129));
|
|
assertThatThrownBy(() -> provider.search(input)).hasMessageContaining("Narrow");
|
|
input.setSearchTerm("");
|
|
input.setSkip(10001);
|
|
assertThatThrownBy(() -> provider.search(input)).hasMessageContaining("Narrow");
|
|
input.setSkip(0);
|
|
input.setIdList(java.util.Collections.nCopies(51, 1));
|
|
assertThatThrownBy(() -> provider.search(input)).hasMessageContaining("Narrow");
|
|
input.setIdList(List.of(1));
|
|
assertThat(provider.search(input)).extracting(value -> value.getId()).containsExactly(1);
|
|
input.setIdList(null);
|
|
input.setSearchTerm("not-a-record-key");
|
|
assertThat(provider.search(input)).isEmpty();
|
|
QContext.getQSession().getPermissions().remove("document-generation.generate");
|
|
assertThatThrownBy(() -> provider.search(input)).isInstanceOf(QException.class);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Public retained-owner fixture; replacement must resolve current metadata.
|
|
***************************************************************************/
|
|
private void assetChoices()
|
|
{
|
|
var choices = Map.of("image1", new GenerationPorts.AssetChoice("image1", "Original logo", "a".repeat(64), "image/png", 20L),
|
|
"image2", new GenerationPorts.AssetChoice("image2", "New logo", "b".repeat(64), "image/jpeg", 30L),
|
|
"font1", new GenerationPorts.AssetChoice("font1", "Original font", "c".repeat(64), "font/ttf", 40L),
|
|
"font2", new GenerationPorts.AssetChoice("font2", "New font", "d".repeat(64), "font/ttf", 50L));
|
|
config.withAssetSelection(new GenerationPorts.AssetSelection()
|
|
{
|
|
/***************************************************************************
|
|
** Offer the fixed asset choices up to the requested limit, as the retained-owner search would for the current actor.
|
|
***************************************************************************/
|
|
public List<GenerationPorts.AssetChoice> search(String actor, String term, int limit)
|
|
{
|
|
return (choices.values().stream().limit(limit).toList());
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Resolve a choice or refuse once access is revoked, modelling the owner's current-authority check at use time.
|
|
***************************************************************************/
|
|
public GenerationPorts.AssetChoice resolve(String actor, String version) throws QException
|
|
{
|
|
if(assetAccessDenied || !choices.containsKey(version))
|
|
{
|
|
throw new QException("Retained asset access was revoked.");
|
|
}
|
|
return (choices.get(version));
|
|
}
|
|
});
|
|
}
|
|
|
|
/***************************************************************************
|
|
** A single native form creates an unpublished usable draft with no ID assembly.
|
|
***************************************************************************/
|
|
@Test
|
|
void packingSlipWizardCreatesBoundDraftAndKeepsInternalTablesOffNavigation() throws Exception
|
|
{
|
|
var form = author(GenerationTables.TEMPLATE, "packingSlip", null);
|
|
assertThat(form.getValueInteger("widthMm")).isEqualTo(210);
|
|
form.addValue("tableName", "orders");
|
|
var result = saveAuthor(form);
|
|
var created = new DocumentDefinitionService(config).loadDraft(result.getValueInteger("resultId"));
|
|
assertThat(created.sourceTable()).isEqualTo("orders");
|
|
assertThat(created.draft().pdfSource()).isEqualTo(DocumentDefinitionService.packingSlipStarter());
|
|
assertThat(created.draft().pdfSource()).contains("Packing slip", "lines?is_sequence", "shipTo", "sender", "notes")
|
|
.doesNotContain("<#list record as field, value>", "tenant");
|
|
assertThat(created.draft().scriptArtifact()).isNull();
|
|
assertThat(GenerationStore.get(GenerationTables.TEMPLATE, created.templateId()).getValue("publishedRevisionId")).isNull();
|
|
assertThat(GenerationStore.get(GenerationTables.STOCK, created.stockId()).getValueInteger("widthMicrons")).isEqualTo(210000);
|
|
var app = instance.getApp("documentGeneration");
|
|
assertThat(app.getLabel()).isEqualTo("Document Generation");
|
|
assertThat(app.getSections().getFirst().getTables()).containsExactly(GenerationTables.TEMPLATE);
|
|
assertThat(app.getSections().stream().flatMap(section -> section.getTables().stream()).toList())
|
|
.doesNotContain(GenerationTables.REQUEST, GenerationTables.ASSET, GenerationTables.REVISION, GenerationTables.EVENT);
|
|
assertThat(rendered).isNull();
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Native decimal fields preserve four-by-six inch and Letter sizes exactly.
|
|
***************************************************************************/
|
|
@Test
|
|
void packingSlipWizardPreservesMicronPrecisionWithoutRounding() throws Exception
|
|
{
|
|
var fields = instance.getProcess(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "packingSlip")).getFrontendStep("edit").getFormFields();
|
|
assertThat(fields.stream().filter(field -> field.getName().endsWith("Mm"))).allMatch(field -> field.getType() == QFieldType.DECIMAL);
|
|
for(String[] size : List.of(new String[] {"101.6", "152.4", "101600", "152400"}, new String[] {"215.9", "279.4", "215900", "279400"}))
|
|
{
|
|
var form = author(GenerationTables.TEMPLATE, "packingSlip", null);
|
|
form.addValue("tableName", "orders");
|
|
form.addValue("widthMm", new java.math.BigDecimal(size[0]));
|
|
form.addValue("heightMm", new java.math.BigDecimal(size[1]));
|
|
var created = new DocumentDefinitionService(config).loadDraft(saveAuthor(form).getValueInteger("resultId"));
|
|
var stock = GenerationStore.get(GenerationTables.STOCK, created.stockId());
|
|
assertThat(stock.getValueInteger("widthMicrons")).isEqualTo(Integer.valueOf(size[2]));
|
|
assertThat(stock.getValueInteger("heightMicrons")).isEqualTo(Integer.valueOf(size[3]));
|
|
}
|
|
var invalid = author(GenerationTables.TEMPLATE, "packingSlip", null);
|
|
invalid.addValue("tableName", "orders");
|
|
invalid.addValue("widthMm", new java.math.BigDecimal("101.6001"));
|
|
assertThatThrownBy(() -> saveAuthor(invalid)).hasMessageContaining("whole microns");
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Invalid dimensions, unknown source and revoked authority create no setup rows.
|
|
***************************************************************************/
|
|
@Test
|
|
void packingSlipWizardRejectsInvalidAndUnauthorizedRequests() throws Exception
|
|
{
|
|
int count = GenerationStore.query(GenerationTables.TYPE, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()).size();
|
|
var form = author(GenerationTables.TEMPLATE, "packingSlip", null);
|
|
form.addValue("tableName", "orders");
|
|
form.addValue("widthMm", Integer.MAX_VALUE);
|
|
assertThatThrownBy(() -> saveAuthor(form)).hasMessageContaining("dimensions");
|
|
form.addValue("widthMm", 210);
|
|
form.addValue("tableName", "notConfigured");
|
|
assertThatThrownBy(() -> saveAuthor(form)).hasMessageContaining("configured source");
|
|
assertThat(GenerationStore.query(GenerationTables.TYPE, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).hasSize(count);
|
|
form.addValue("tableName", "orders");
|
|
QContext.getQSession().getPermissions().remove("document-generation.configure");
|
|
assertThatThrownBy(() -> saveAuthor(form)).isInstanceOf(QException.class);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Native replacement/removal preserves old publications and named references.
|
|
***************************************************************************/
|
|
@Test
|
|
void nativeNamingEditsPreserveDraftAndRequireFreshAuthorizedFingerprint() throws Exception
|
|
{
|
|
var definitions = new DocumentDefinitionService(config);
|
|
int oldRevision = definitions.publish(template);
|
|
var form = author(GenerationTables.TEMPLATE, "naming", template);
|
|
var stale = author(GenerationTables.TEMPLATE, "naming", template);
|
|
var fields = instance.getProcess(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "naming")).getFrontendStep("edit").getFormFields();
|
|
assertThat(fields).extracting(field -> field.getName()).contains("outputBaseName", "outputNameContextKey");
|
|
form.addValue("outputBaseName", "Incoming PO");
|
|
assertThat(saveAuthor(form).getValueString("resultState")).isEqualTo("OUTPUT_NAMING_SAVED");
|
|
assertThatThrownBy(() -> saveAuthor(stale)).hasMessageContaining("changed");
|
|
var edit = author(GenerationTables.TEMPLATE, "edit", template);
|
|
edit.addValue("pdfSource", "edited source");
|
|
saveAuthor(edit);
|
|
assertThat(definitions.loadDraft(template).draft().outputBaseName()).isEqualTo("Incoming PO");
|
|
assertThat(definitions.revision(oldRevision).outputBaseName()).isNull();
|
|
assertThatThrownBy(() -> definitions.saveOutputNaming(template, "name", "result", definitions.loadDraft(template).fingerprint())).hasMessageContaining("script");
|
|
QContext.getQSession().getPermissions().remove("document-generation.configure");
|
|
assertThatThrownBy(() -> definitions.saveOutputNaming(template, "Denied", null, definitions.loadDraft(template).fingerprint())).isInstanceOf(QException.class);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Replace or remove current draft asset membership while preserving immutable published assets.
|
|
***************************************************************************/
|
|
@Test
|
|
void friendlyAssetsReplaceAndRemoveOnlyCurrentDraftMembership() throws Exception
|
|
{
|
|
assetChoices();
|
|
var definitions = new DocumentDefinitionService(config);
|
|
int original = definitions.addSelectedAsset(template, "logo", "image1", null, null, null, definitions.loadDraft(template).fingerprint());
|
|
int badge = definitions.addSelectedAsset(template, "badge", "image1", null, null, null, definitions.loadDraft(template).fingerprint());
|
|
int revision = definitions.publish(template);
|
|
var replace = author(GenerationTables.TEMPLATE, "replaceAsset", template);
|
|
replace.addValue("selectedIncludedAsset", original);
|
|
replace.addValue("selectedVersion", "image2");
|
|
assertThat(saveAuthor(replace).getValueString("resultState")).isEqualTo("ASSET_REPLACED_IN_DRAFT");
|
|
var included = definitions.includedAssets(template);
|
|
assertThat(included).hasSize(2);
|
|
assertThat(included.getFirst().id()).isNotEqualTo(original);
|
|
assertThat(included.getFirst().asset().name()).isEqualTo("logo");
|
|
assertThat(included.getFirst().asset().fileVersionId()).isEqualTo("image2");
|
|
assertThat(included.get(1).id()).isEqualTo(badge);
|
|
assertThat(definitions.revision(revision).assets().getFirst().fileVersionId()).isEqualTo("image1");
|
|
assertThat(GenerationStore.get(GenerationTables.ASSET, original).getValueString("fileVersionId")).isEqualTo("image1");
|
|
var edit = author(GenerationTables.TEMPLATE, "edit", template);
|
|
assertThat(edit.getValueString("includedAssetNames")).isEqualTo("logo, badge");
|
|
edit.addValue("assetIds", "");
|
|
edit.addValue("pdfSource", "edited source");
|
|
saveAuthor(edit);
|
|
assertThat(definitions.includedAssets(template)).isEqualTo(included);
|
|
var remove = author(GenerationTables.TEMPLATE, "removeAsset", template);
|
|
remove.addValue("selectedIncludedAsset", badge);
|
|
assertThat(saveAuthor(remove).getValueString("resultState")).isEqualTo("ASSET_REMOVED_FROM_DRAFT");
|
|
assertThat(definitions.includedAssets(template)).containsExactly(included.getFirst());
|
|
assertThat(definitions.revision(revision).assets()).hasSize(2);
|
|
assertThat(GenerationStore.get(GenerationTables.ASSET, badge)).isNotNull();
|
|
assertThatThrownBy(() -> saveAuthor(remove)).hasMessageContaining("changed");
|
|
var removed = author(GenerationTables.TEMPLATE, "removeAsset", template);
|
|
removed.addValue("selectedIncludedAsset", badge);
|
|
assertThatThrownBy(() -> saveAuthor(removed)).hasMessageContaining("currently included");
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Font replacement preserves CSS face semantics and refuses role substitution.
|
|
***************************************************************************/
|
|
@Test
|
|
void fontReplacementPreservesFaceAndRequiresCurrentRetainedAuthority() throws Exception
|
|
{
|
|
assetChoices();
|
|
var definitions = new DocumentDefinitionService(config);
|
|
int font = definitions.addSelectedAsset(template, "heading", "font1", "Warehouse", 700, "italic", definitions.loadDraft(template).fingerprint());
|
|
var replace = author(GenerationTables.TEMPLATE, "replaceAsset", template);
|
|
replace.addValue("selectedIncludedAsset", font);
|
|
replace.addValue("selectedVersion", "font2");
|
|
assetAccessDenied = true;
|
|
assertThatThrownBy(() -> saveAuthor(replace)).hasMessageContaining("revoked");
|
|
assetAccessDenied = false;
|
|
replace.addValue("selectedVersion", "image2");
|
|
assertThatThrownBy(() -> saveAuthor(replace)).hasMessageContaining("TrueType");
|
|
replace.addValue("selectedVersion", "font2");
|
|
saveAuthor(replace);
|
|
assertThat(definitions.includedAssets(template).getFirst().asset()).isEqualTo(
|
|
new GenerationPorts.Asset("heading", "font2", "d".repeat(64), "font/ttf", "Warehouse", 700, "italic"));
|
|
var old = author(GenerationTables.TEMPLATE, "replaceAsset", template);
|
|
old.addValue("selectedIncludedAsset", font);
|
|
old.addValue("selectedVersion", "font1");
|
|
assertThatThrownBy(() -> saveAuthor(old)).hasMessageContaining("currently included");
|
|
var denied = author(GenerationTables.TEMPLATE, "removeAsset", template);
|
|
denied.addValue("selectedIncludedAsset", definitions.includedAssets(template).getFirst().id());
|
|
QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope().withReadOnly(true));
|
|
assertThatThrownBy(() -> saveAuthor(denied)).isInstanceOf(QException.class);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Per-request choices contain only current membership and never mutate global
|
|
** process metadata. Wrong-template and stale submissions remain server-denied.
|
|
***************************************************************************/
|
|
@Test
|
|
void assetFormsScopeReadableChoicesAndRecheckMembershipUnderDraftLock() throws Exception
|
|
{
|
|
assetChoices();
|
|
var definitions = new DocumentDefinitionService(config);
|
|
assertThatThrownBy(() -> author(GenerationTables.TEMPLATE, "removeAsset", template)).hasMessageContaining("no included assets");
|
|
int included = definitions.addSelectedAsset(template, "logo", "image1", null, null, null, definitions.loadDraft(template).fingerprint());
|
|
QRecord current = GenerationStore.get(GenerationTables.TEMPLATE, template);
|
|
int other = definitions.createTemplate("Other", current.getValueInteger("typeId"), current.getValueInteger("stockId"), "orders");
|
|
definitions.saveDraft(other, current.getValueInteger("stockId"), new DocumentDefinitionService.Draft("other", null, null, List.of()));
|
|
int foreign = definitions.addSelectedAsset(other, "foreign-logo", "image2", null, null, null, definitions.loadDraft(other).fingerprint());
|
|
var input = new RunBackendStepInput().withProcessName(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "removeAsset"))
|
|
.withRecords(List.of(new QRecord().withValue("id", template)));
|
|
var output = new RunBackendStepOutput();
|
|
new GenerationAuthoringProcesses.Prepare().run(input, output);
|
|
var values = output.getProcessMetaDataAdjustment().getUpdatedFields().get("selectedIncludedAsset").getInlinePossibleValueSource().getEnumValues();
|
|
assertThat(values).hasSize(1);
|
|
assertThat(values.getFirst().getId()).isEqualTo(included);
|
|
assertThat(values.getFirst().getLabel()).contains("logo", "image/png").doesNotContain("foreign");
|
|
var global = input.getProcess().getFrontendStep("edit").getFormFields().getFirst();
|
|
assertThat(global.getInlinePossibleValueSource()).isNull();
|
|
var attack = new RunBackendStepInput().withProcessName(input.getProcessName()).withValues(output.getValues());
|
|
attack.addValue("selectedIncludedAsset", foreign);
|
|
assertThatThrownBy(() -> saveAuthor(attack)).hasMessageContaining("currently included");
|
|
var stale = author(GenerationTables.TEMPLATE, "replaceAsset", template);
|
|
stale.addValue("selectedIncludedAsset", included);
|
|
stale.addValue("selectedVersion", "image2");
|
|
var edit = author(GenerationTables.TEMPLATE, "edit", template);
|
|
edit.addValue("pdfSource", "newer source");
|
|
saveAuthor(edit);
|
|
assertThatThrownBy(() -> saveAuthor(stale)).hasMessageContaining("changed");
|
|
assertThat(definitions.includedAssets(template).getFirst().id()).isEqualTo(included);
|
|
assertThat(instance.getProcess(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "edit")).getFrontendStep("edit").getFormFields())
|
|
.noneMatch(field -> "assetIds".equals(field.getName()));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Deterministic public source-owner fixture; never executes script content.
|
|
***************************************************************************/
|
|
private void contextChooser()
|
|
{
|
|
config.withContextSelection(new GenerationPorts.ContextSelection()
|
|
{
|
|
/***************************************************************************
|
|
** List the single context choice, which resolves under the same denial switch as the execution seam.
|
|
***************************************************************************/
|
|
public List<GenerationPorts.ContextChoice> search(String actor, String term, int limit) throws QException
|
|
{
|
|
return (List.of(resolve(actor, "7")));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Refuse when the context owner has denied access, otherwise return the pinned publication choice.
|
|
***************************************************************************/
|
|
public GenerationPorts.ContextChoice resolve(String actor, String id) throws QException
|
|
{
|
|
if(contextDenied)
|
|
{
|
|
throw new QException("Context owner denied current access.");
|
|
}
|
|
return (new GenerationPorts.ContextChoice(selectedContextId, "Packing — version 1", "publication:7:8:" + "a".repeat(64)));
|
|
}
|
|
});
|
|
config.withContextExecution(new GenerationPorts.ContextExecution()
|
|
{
|
|
/***************************************************************************
|
|
** Refuse a published context pin when the owner has denied access, as a revoked publication would.
|
|
***************************************************************************/
|
|
public void validatePublished(String actor, String pin) throws QException
|
|
{
|
|
if(contextDenied)
|
|
{
|
|
throw new QException("Context owner denied current access.");
|
|
}
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Return an empty context because this scenario tests selection and pin authority, not rendered values.
|
|
***************************************************************************/
|
|
public Map<String, Object> prepare(GenerationPorts.ContextRequest input)
|
|
{
|
|
return (Map.of());
|
|
}
|
|
});
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Native forms resolve an exact owner pin and preserve it through source edits.
|
|
***************************************************************************/
|
|
@Test
|
|
void nativeContextChooserPinsPublicationAndClearsOnlyExplicitly() throws Exception
|
|
{
|
|
contextChooser();
|
|
var choices = new GenerationContextChoices();
|
|
var form = instance.getProcess(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "context")).getFrontendStep("edit");
|
|
assertThat(form.getFormFields()).anySatisfy(field ->
|
|
{
|
|
assertThat(field.getName()).isEqualTo("selectedPublication");
|
|
assertThat(field.getPossibleValueSourceName()).isEqualTo(GenerationContextChoices.NAME);
|
|
});
|
|
assertThat(instance.getProcess(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "edit")).getFrontendStep("edit").getFormFields())
|
|
.noneMatch(field -> "scriptArtifact".equals(field.getName()));
|
|
var search = new com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput().withSearchTerm("Packing").withLimit(10);
|
|
assertThat(choices.search(search)).hasSize(1);
|
|
assertThat(choices.getPossibleValue("7").getLabel()).contains("Packing");
|
|
var select = author(GenerationTables.TEMPLATE, "context", template);
|
|
select.addValue("selectedPublication", "7");
|
|
select.addValue("scriptArtifact", "forged");
|
|
assertThat(saveAuthor(select).getValueString("resultState")).isEqualTo("CONTEXT_SELECTION_SAVED");
|
|
var definitions = new DocumentDefinitionService(config);
|
|
String pin = definitions.loadDraft(template).draft().scriptArtifact();
|
|
assertThat(pin).isEqualTo("publication:7:8:" + "a".repeat(64));
|
|
var edit = author(GenerationTables.TEMPLATE, "edit", template);
|
|
edit.addValue("scriptArtifact", "injected");
|
|
edit.addValue("pdfSource", "new source");
|
|
saveAuthor(edit);
|
|
assertThat(definitions.loadDraft(template).draft().scriptArtifact()).isEqualTo(pin);
|
|
int revision = saveAuthor(author(GenerationTables.TEMPLATE, "publish", template)).getValueInteger("resultId");
|
|
var clear = author(GenerationTables.TEMPLATE, "context", template);
|
|
clear.addValue("clearContext", true);
|
|
saveAuthor(clear);
|
|
assertThat(definitions.loadDraft(template).draft().scriptArtifact()).isNull();
|
|
assertThat(definitions.revision(revision).scriptArtifact()).isEqualTo(pin);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Missing, revoked or mismatched source authority never changes the draft.
|
|
***************************************************************************/
|
|
@Test
|
|
void contextChooserRefusesWrongOwnerIdentityRevocationAndStaleDrafts() throws Exception
|
|
{
|
|
var select = author(GenerationTables.TEMPLATE, "context", template);
|
|
select.addValue("selectedPublication", "7");
|
|
assertThatThrownBy(() -> saveAuthor(select)).hasMessageContaining("unavailable");
|
|
contextChooser();
|
|
selectedContextId = "8";
|
|
assertThatThrownBy(() -> saveAuthor(select)).hasMessageContaining("changed the selected");
|
|
selectedContextId = "7";
|
|
contextDenied = true;
|
|
assertThatThrownBy(() -> saveAuthor(select)).hasMessageContaining("denied");
|
|
contextDenied = false;
|
|
var edit = author(GenerationTables.TEMPLATE, "edit", template);
|
|
edit.addValue("pdfSource", "newer draft");
|
|
saveAuthor(edit);
|
|
assertThatThrownBy(() -> saveAuthor(select)).hasMessageContaining("changed");
|
|
assertThat(new DocumentDefinitionService(config).loadDraft(template).draft().scriptArtifact()).isNull();
|
|
var fresh = author(GenerationTables.TEMPLATE, "context", template);
|
|
assertThatThrownBy(() -> saveAuthor(fresh)).hasMessageContaining("Select");
|
|
fresh.addValue("selectedPublication", "7");
|
|
fresh.addValue("clearContext", true);
|
|
assertThatThrownBy(() -> saveAuthor(fresh)).hasMessageContaining("Select");
|
|
fresh.addValue("clearContext", false);
|
|
QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope().withReadOnly(true));
|
|
assertThatThrownBy(() -> saveAuthor(fresh)).isInstanceOf(QException.class);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Publication rechecks source rights after a successful draft selection.
|
|
***************************************************************************/
|
|
@Test
|
|
void contextSelectionDoesNotGrantFuturePublicationAuthority() throws Exception
|
|
{
|
|
contextChooser();
|
|
var selected = author(GenerationTables.TEMPLATE, "context", template);
|
|
selected.addValue("selectedPublication", "7");
|
|
saveAuthor(selected);
|
|
contextDenied = true;
|
|
assertThatThrownBy(() -> saveAuthor(author(GenerationTables.TEMPLATE, "publish", template))).hasMessageContaining("denied");
|
|
var provider = new GenerationContextChoices();
|
|
assertThatThrownBy(() -> provider.getPossibleValue("7")).hasMessageContaining("denied");
|
|
assertThatThrownBy(() -> provider.search(new com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput().withSearchTerm("x".repeat(129))))
|
|
.hasMessageContaining("Narrow");
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Exact-label batches and discovery are bounded, with no mutable actor switch.
|
|
***************************************************************************/
|
|
@Test
|
|
void contextSelectorBoundsAndAdapterIdentityFailClosed() throws Exception
|
|
{
|
|
contextChooser();
|
|
var provider = new GenerationContextChoices();
|
|
var input = new com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput().withIdList(List.of("7"));
|
|
assertThat(provider.search(input)).hasSize(1);
|
|
input.setIdList(java.util.Collections.nCopies(51, "7"));
|
|
assertThatThrownBy(() -> provider.search(input)).hasMessageContaining("Too many");
|
|
input.setIdList(null);
|
|
input.setSkip(1);
|
|
assertThatThrownBy(() -> provider.search(input)).hasMessageContaining("Narrow");
|
|
assertThatThrownBy(() -> provider.getPossibleValue(" ")).hasMessageContaining("Select");
|
|
var choice = new GenerationPorts.ContextChoice("7", "Context", "pin");
|
|
config.withContextSelection(new GenerationPorts.ContextSelection()
|
|
{
|
|
/***************************************************************************
|
|
** Return a duplicate choice to prove the process de-duplicates and bounds a context page.
|
|
***************************************************************************/
|
|
public List<GenerationPorts.ContextChoice> search(String actor, String term, int limit)
|
|
{
|
|
return (List.of(choice, choice));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Replace the session with a different user during resolution, so the process must detect the actor change and refuse.
|
|
***************************************************************************/
|
|
public GenerationPorts.ContextChoice resolve(String actor, String id)
|
|
{
|
|
QContext.setQSession(new QSession().withUser(new QUser().withIdReference("other")));
|
|
return (choice);
|
|
}
|
|
});
|
|
input.setSkip(0);
|
|
input.setLimit(1);
|
|
assertThatThrownBy(() -> provider.search(input)).hasMessageContaining("result limit");
|
|
input.setLimit(10);
|
|
assertThatThrownBy(() -> provider.search(input)).hasMessageContaining("duplicate");
|
|
var selected = author(GenerationTables.TEMPLATE, "context", template);
|
|
selected.addValue("selectedPublication", "7");
|
|
assertThatThrownBy(() -> saveAuthor(selected)).hasMessageContaining("context changed");
|
|
login(true);
|
|
assertThat(new DocumentDefinitionService(config).loadDraft(template).draft().scriptArtifact()).isNull();
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Register native authoring processes with permission-enforced source records and observable ports.
|
|
***************************************************************************/
|
|
@BeforeEach
|
|
void setup() throws Exception
|
|
{
|
|
MemoryRecordStore.getInstance().reset();
|
|
instance=new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
|
instance.setEnforcePermissionsInCoreActions(true);
|
|
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
|
instance.addTable(new QTableMetaData().withName("orders").withBackendName("memory").withPrimaryKeyField("id")
|
|
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))
|
|
.withField(new QFieldMetaData("id",QFieldType.INTEGER)).withField(new QFieldMetaData("name",QFieldType.STRING)));
|
|
config=new DocumentGenerationQBitConfig().withBackendName("memory").withSourceTables(List.of("orders")).withAudit(event -> {})
|
|
.withRenderer(new GenerationPorts.IsolatedRenderer()
|
|
{
|
|
/***************************************************************************
|
|
** Accept every revision; this fixture inspects only the context delivered to render.
|
|
***************************************************************************/
|
|
public void validate(String actor,GenerationPorts.Revision revision) {}
|
|
|
|
/***************************************************************************
|
|
** Capture the render context and return fixed bytes, so tests can assert exactly what values reached the renderer.
|
|
***************************************************************************/
|
|
public GenerationPorts.Content render(GenerationPorts.RenderRequest request)
|
|
{
|
|
rendered=request.context();
|
|
return (new GenerationPorts.Content(new byte[]{1,2,3}));
|
|
}
|
|
}).withIntake(new GenerationPorts.RetainedOutputIntake()
|
|
{
|
|
/***************************************************************************
|
|
** Retain fixed bytes and remember the receipt so authorize can answer for the same version.
|
|
***************************************************************************/
|
|
public GenerationPorts.Receipt retain(GenerationPorts.IntakeRequest request)
|
|
{
|
|
var receipt=new GenerationPorts.Receipt("file","version",request.sha256(),3L,true,true);
|
|
receipts.put("version",receipt);
|
|
return (receipt);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Return the receipt remembered at retention, modelling an owner that still authorizes access.
|
|
***************************************************************************/
|
|
public GenerationPorts.Receipt authorize(String actor,GenerationPorts.Source source,String version)
|
|
{
|
|
return (receipts.get(version));
|
|
}
|
|
}).withPrinting(request -> "job")
|
|
.withDownloadAccess((actor,source,receipt,correlation) -> new GenerationPorts.DownloadGrant(wrongVersion?"other":receipt.versionId(),downloadUrl,Instant.now()));
|
|
// Trusted test authority: production installs the exact retained-owner adapter.
|
|
config.withRetainedAccess(reference ->
|
|
{
|
|
GenerationSecurity.source(reference.source());
|
|
return (reference);
|
|
});
|
|
new DocumentGenerationQBitProducer().withQBitConfig(config).produce(instance);
|
|
new QInstanceEnricher(instance).enrich();
|
|
login(true);
|
|
new InsertAction().execute(new InsertInput("orders").withRecords(List.of(new QRecord().withValue("id",1).withValue("name","canonical"))));
|
|
var definitions=new DocumentDefinitionService(config);
|
|
int type=definitions.createType("packing");
|
|
int stock=definitions.createStock("stock",new GenerationPorts.Stock(100000,150000,0,0,0,0,203));
|
|
definitions.bind(type,"orders",true,true);
|
|
template=definitions.createTemplate("packing",type,stock,"orders");
|
|
definitions.saveDraft(template,stock,new DocumentDefinitionService.Draft("fixture",null,null,List.of()));
|
|
definitions.publish(template);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Release native context and in-memory records after each process conversation.
|
|
***************************************************************************/
|
|
@AfterEach
|
|
void clear()
|
|
{
|
|
QContext.clear();
|
|
MemoryRecordStore.getInstance().reset();
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Select the fixture actor with an explicit choice of current source-read authority.
|
|
***************************************************************************/
|
|
private void login(boolean sourceRead)
|
|
{
|
|
var session=new QSession().withUser(new QUser().withIdReference("alice")).withPermissions("document-generation.view","document-generation.configure","document-generation.publish","document-generation.generate","document-generation.print","orders.write");
|
|
if(sourceRead)
|
|
{
|
|
session.withPermissions("orders.read");
|
|
}
|
|
QContext.init(instance,session);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Prepare a native process from a selected ID, carrying forward only server-prepared values.
|
|
***************************************************************************/
|
|
private RunBackendStepInput prepared(String table,String operation,Integer id) throws Exception
|
|
{
|
|
var input=new RunBackendStepInput().withProcessName(GenerationProcesses.name(table,operation)).withRecords(List.of(new QRecord().withValue("id",id).withValue("name","forged")));
|
|
var output=new RunBackendStepOutput();
|
|
new GenerationProcesses.Prepare().run(input,output);
|
|
return (new RunBackendStepInput().withProcessName(input.getProcessName()).withValues(output.getValues()));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Execute the prepared native process and return its serialized result.
|
|
***************************************************************************/
|
|
private String execute(RunBackendStepInput input) throws Exception
|
|
{
|
|
var output=new RunBackendStepOutput();
|
|
new GenerationProcesses.Execute().run(input,output);
|
|
return (output.getValueString("resultJson"));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Generate the first order through native process steps and return the durable request ID.
|
|
***************************************************************************/
|
|
private int generate() throws Exception
|
|
{
|
|
var input=prepared("orders","generate",1);
|
|
input.addValue("templateId",template);
|
|
return (new ObjectMapper().readTree(execute(input)).get("requestId").intValue());
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Explicit owner authority, not a failed live lookup, permits exact retained
|
|
** history after source deletion. This provider is a contract fixture only.
|
|
*******************************************************************************/
|
|
@Test
|
|
void retainedOwnerAuthorizesHistoryDownloadAndReprintAfterDeletion() throws Exception
|
|
{
|
|
int id = generate();
|
|
QRecord original = GenerationStore.get(GenerationTables.REQUEST, id);
|
|
var expected = new GenerationPorts.RetainedReference("alice", new GenerationPorts.Source("orders", "1"), "file", "version", original.getValueString("sha256"), 3L);
|
|
java.util.concurrent.atomic.AtomicBoolean allowed = new java.util.concurrent.atomic.AtomicBoolean(true);
|
|
config.withRetainedAccess(reference ->
|
|
{
|
|
assertThat(reference).isEqualTo(expected);
|
|
if(!allowed.get())
|
|
{
|
|
throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("Owner access revoked");
|
|
}
|
|
return (reference);
|
|
});
|
|
new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(
|
|
new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput("orders").withPrimaryKeys(List.of(1)));
|
|
var query = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction();
|
|
assertThat(query.execute(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.REQUEST)).getRecords()).hasSize(1);
|
|
assertThat(new GenerationDownloadService(config).issue(id).versionId()).isEqualTo("version");
|
|
assertThat(new DocumentGenerationService(config).reprint(id, "archived-reprint", "printer").state()).isEqualTo("QUEUED");
|
|
assertThat(new DocumentGenerationService(config).reconcile(id).state()).isEqualTo("GENERATED");
|
|
assertThatThrownBy(this::generate).isInstanceOf(QException.class);
|
|
allowed.set(false);
|
|
assertThatThrownBy(() -> new GenerationDownloadService(config).issue(id)).isInstanceOf(QException.class);
|
|
assertThatThrownBy(() -> query.execute(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.REQUEST))).isInstanceOf(QException.class);
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** A metadata owner cannot substitute another version or waive mutation ceilings.
|
|
*******************************************************************************/
|
|
@Test
|
|
void retainedAuthorityCannotSubstituteIdentityOrBypassReadOnlyScope() throws Exception
|
|
{
|
|
int id = generate();
|
|
config.withRetainedAccess(reference -> new GenerationPorts.RetainedReference(reference.actorId(), reference.source(), reference.fileId(), "other", reference.sha256(), reference.length()));
|
|
assertThatThrownBy(() -> new GenerationDownloadService(config).issue(id)).hasMessageContaining("changed its identity");
|
|
config.withRetainedAccess(reference -> reference);
|
|
QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope().withReadOnly(true));
|
|
assertThatThrownBy(() -> new DocumentGenerationService(config).reprint(id, "read-only", "printer"))
|
|
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Exercise the same native prepare/save seam used by standard QQQ forms.
|
|
*******************************************************************************/
|
|
private RunBackendStepInput author(String table, String operation, Integer id) throws Exception
|
|
{
|
|
var input = new RunBackendStepInput().withProcessName(GenerationAuthoringProcesses.name(table, operation))
|
|
.withRecords(id == null ? List.of() : List.of(new QRecord().withValue("id", id)));
|
|
var values = new RunBackendStepOutput();
|
|
new GenerationAuthoringProcesses.Prepare().run(input, values);
|
|
return (new RunBackendStepInput().withProcessName(input.getProcessName()).withValues(values.getValues()));
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Read actual native process output rather than calling an implementation helper.
|
|
*******************************************************************************/
|
|
private RunBackendStepOutput saveAuthor(RunBackendStepInput input) throws Exception
|
|
{
|
|
var output = new RunBackendStepOutput();
|
|
new GenerationAuthoringProcesses.Save().run(input, output);
|
|
return (output);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Rename forms change only the display name and preserve published bytes/pins.
|
|
***************************************************************************/
|
|
@Test
|
|
void renameDefinitionsPreservesPublishedRevisionAndStockGeometry() throws Exception
|
|
{
|
|
QRecord original = GenerationStore.get(GenerationTables.TEMPLATE, template);
|
|
int revisionId = original.getValueInteger("publishedRevisionId");
|
|
Map<String, java.io.Serializable> revision = new HashMap<>(GenerationStore.get(GenerationTables.REVISION, revisionId).getValues());
|
|
for(String table : List.of(GenerationTables.TYPE, GenerationTables.STOCK, GenerationTables.TEMPLATE))
|
|
{
|
|
int id = table.equals(GenerationTables.TYPE) ? original.getValueInteger("typeId")
|
|
: table.equals(GenerationTables.STOCK) ? original.getValueInteger("stockId") : template;
|
|
Map<String, java.io.Serializable> before = new HashMap<>(GenerationStore.get(table, id).getValues());
|
|
var process = instance.getProcess(GenerationAuthoringProcesses.name(table, "rename"));
|
|
assertThat(process.getFrontendStep("edit").getFormFields()).extracting(field -> field.getName()).containsExactly("name");
|
|
var input = author(table, "rename", id);
|
|
assertThat(input.getValueString("name")).isEqualTo(before.get("name"));
|
|
input.addValue("name", " Readable label ");
|
|
saveAuthor(input);
|
|
before.put("name", "Readable label");
|
|
assertThat(GenerationStore.get(table, id).getValues()).isEqualTo(before);
|
|
}
|
|
assertThat(GenerationStore.get(GenerationTables.REVISION, revisionId).getValues()).isEqualTo(revision);
|
|
}
|
|
|
|
|
|
/***************************************************************************
|
|
** Renames use ordinary QQQ field auditing rather than an untracked SQL write.
|
|
***************************************************************************/
|
|
@Test
|
|
void renameEmitsConfiguredNativeFieldAudit() throws Exception
|
|
{
|
|
QContext.getQSession().getUser().setFullName("Synthetic Alice");
|
|
new com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider()
|
|
.defineAll(instance, instance.getTable(GenerationTables.TEMPLATE).getBackendName(), null);
|
|
instance.getTable(GenerationTables.TEMPLATE).setAuditRules(
|
|
new com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules()
|
|
.withAuditLevel(com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel.FIELD));
|
|
new QInstanceEnricher(instance).enrich();
|
|
new DocumentDefinitionService(config).rename(GenerationTables.TEMPLATE, template, "Readable document");
|
|
var query = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction();
|
|
var auditQuery = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput("auditDetail");
|
|
auditQuery.withPermissionBypass("Test fixture inspects native audit output after the authorized rename.");
|
|
var details = query.execute(auditQuery).getRecords();
|
|
assertThat(details).anyMatch(row -> "name".equals(row.getValueString("fieldName"))
|
|
&& row.getValueString("message").contains("Readable document"));
|
|
}
|
|
|
|
|
|
/***************************************************************************
|
|
** Invalid names and unowned immutable rows cannot be changed through rename.
|
|
***************************************************************************/
|
|
@Test
|
|
void renameRejectsInvalidNamesAndCannotChangeStockDimensions() throws Exception
|
|
{
|
|
var service = new DocumentDefinitionService(config);
|
|
for(String name : List.of(" ", "x".repeat(201), "bad\nname"))
|
|
{
|
|
assertThatThrownBy(() -> service.rename(GenerationTables.TEMPLATE, template, name)).isInstanceOf(QException.class);
|
|
}
|
|
assertThatThrownBy(() -> service.rename(GenerationTables.TEMPLATE, template, null)).isInstanceOf(QException.class);
|
|
assertThatThrownBy(() -> service.rename(GenerationTables.REVISION, 1, "changed")).isInstanceOf(QException.class);
|
|
assertThatThrownBy(() -> service.rename(null, 1, "changed")).isInstanceOf(QException.class);
|
|
int stockId = GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("stockId");
|
|
assertThatThrownBy(() -> GenerationStore.update(GenerationTables.STOCK,
|
|
new QRecord().withValue("id", stockId).withValue("name", "bad").withValue("widthMicrons", 1)))
|
|
.isInstanceOf(QException.class).hasMessageContaining("Immutable");
|
|
}
|
|
|
|
|
|
/***************************************************************************
|
|
** Configure permission and read-only credential ceilings apply at save time.
|
|
***************************************************************************/
|
|
@Test
|
|
void renameRechecksConfigureAndCredentialCeiling() throws Exception
|
|
{
|
|
var input = author(GenerationTables.TEMPLATE, "rename", template);
|
|
input.addValue("name", "Blocked");
|
|
QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope().withReadOnly(true));
|
|
assertThatThrownBy(() -> saveAuthor(input)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
QContext.getQSession().setCredentialScope(null);
|
|
QContext.getQSession().getPermissions().remove("document-generation.configure");
|
|
assertThatThrownBy(() -> saveAuthor(input)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
}
|
|
|
|
|
|
/*******************************************************************************
|
|
** Users can create types/stock/bindings/templates and edit source entirely in QQQ.
|
|
*******************************************************************************/
|
|
@Test
|
|
void authoringFormsCreateDefinitionsAndRejectStaleSourceEdits() throws Exception
|
|
{
|
|
var type = author(GenerationTables.TYPE, "create", null);
|
|
type.addValue("name", "Warehouse labels");
|
|
int typeId = saveAuthor(type).getValueInteger("resultId");
|
|
var stock = author(GenerationTables.STOCK, "create", null);
|
|
stock.getValues().putAll(Map.of("name", "User stock", "widthMicrons", 100000, "heightMicrons", 150000, "topMicrons", 0,
|
|
"rightMicrons", 0, "bottomMicrons", 0, "leftMicrons", 0, "dpi", 203));
|
|
int stockId = saveAuthor(stock).getValueInteger("resultId");
|
|
var binding = author(GenerationTables.BINDING, "create", null);
|
|
binding.getValues().putAll(Map.of("typeId", typeId, "tableName", "orders", "allowPdf", true, "allowZpl", true));
|
|
assertThat(saveAuthor(binding).getValueInteger("resultId")).isPositive();
|
|
var create = author(GenerationTables.TEMPLATE, "create", null);
|
|
create.getValues().putAll(Map.of("name", "User template", "typeId", typeId, "stockId", stockId, "tableName", "orders"));
|
|
int created = saveAuthor(create).getValueInteger("resultId");
|
|
var edit = author(GenerationTables.TEMPLATE, "edit", created);
|
|
var stale = author(GenerationTables.TEMPLATE, "edit", created);
|
|
edit.addValue("pdfSource", "<html><body>${record.name}</body></html>");
|
|
assertThat(saveAuthor(edit).getValueString("resultState")).isEqualTo("DRAFT_SAVED");
|
|
stale.addValue("pdfSource", "stale overwrite");
|
|
assertThatThrownBy(() -> saveAuthor(stale)).hasMessageContaining("draft changed");
|
|
assertThat(new DocumentDefinitionService(config).loadDraft(created).draft().pdfSource()).contains("record.name");
|
|
assertThat(GenerationStore.get(GenerationTables.TEMPLATE, created).getValueInteger("publishedRevisionId")).isNull();
|
|
config.withAssetSelection(new GenerationPorts.AssetSelection()
|
|
{
|
|
/***************************************************************************
|
|
** Return one authorized logo choice for the asset-selection step.
|
|
***************************************************************************/
|
|
public List<GenerationPorts.AssetChoice> search(String actor, String term, int limit)
|
|
{
|
|
return (List.of(new GenerationPorts.AssetChoice("41", "Logo", "a".repeat(64), "image/png", 20L)));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Resolve through search so the selected asset and its digest match the owner's single answer.
|
|
***************************************************************************/
|
|
public GenerationPorts.AssetChoice resolve(String actor, String version)
|
|
{
|
|
return (search(actor, "", 1).getFirst());
|
|
}
|
|
});
|
|
var asset = author(GenerationTables.TEMPLATE, "asset", created);
|
|
asset.getValues().putAll(Map.of("name", "logo", "selectedVersion", "41"));
|
|
int assetId = saveAuthor(asset).getValueInteger("resultId");
|
|
var include = author(GenerationTables.TEMPLATE, "edit", created);
|
|
include.addValue("assetIds", Integer.toString(assetId));
|
|
saveAuthor(include);
|
|
assertThat(new DocumentDefinitionService(config).loadDraft(created).draft().assetIds()).containsExactly(assetId);
|
|
var invalid = author(GenerationTables.TEMPLATE, "edit", created);
|
|
invalid.addValue("assetIds", "not-an-id");
|
|
saveAuthor(invalid);
|
|
assertThat(new DocumentDefinitionService(config).loadDraft(created).draft().assetIds()).containsExactly(assetId);
|
|
assertThat(instance.getApp("documentGeneration")).isNotNull();
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Preview preserves publication while retaining and auditing the frozen draft.
|
|
*******************************************************************************/
|
|
@Test
|
|
void retainedPreviewDoesNotPublishAndUsesStableRequestIdentity() throws Exception
|
|
{
|
|
Integer active = GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("publishedRevisionId");
|
|
var edit = author(GenerationTables.TEMPLATE, "edit", template);
|
|
edit.addValue("pdfSource", "changed draft");
|
|
saveAuthor(edit);
|
|
List<String> events = new java.util.ArrayList<>();
|
|
config.withAudit(event -> events.add(event.outcome()));
|
|
var preview = author(GenerationTables.TEMPLATE, "preview", template);
|
|
preview.addValue("sourceRecordId", "1");
|
|
var previewOutput = saveAuthor(preview);
|
|
int requestId = previewOutput.getValueInteger("resultId");
|
|
assertThat(previewOutput.getValueInteger(GenerationTables.REQUEST + "Result")).isEqualTo(requestId);
|
|
assertThat(previewOutput.getValueString("downloadUrl")).isEqualTo(downloadUrl);
|
|
assertThat(previewOutput.getValueString("delivery")).isEqualTo("SIGNED_URL");
|
|
assertThat(previewOutput.getValueString("fileName")).isEqualTo("generated-revision-" + GenerationStore.get(GenerationTables.REQUEST, requestId).getValueInteger("revisionId") + ".pdf");
|
|
assertThat(saveAuthor(preview).getValueInteger("resultId")).isEqualTo(requestId);
|
|
QRecord request = GenerationStore.get(GenerationTables.REQUEST, requestId);
|
|
assertThat(request.getValueString("versionId")).isEqualTo("version");
|
|
assertThat(GenerationStore.get(GenerationTables.REVISION, request.getValueInteger("revisionId")).getValueString("purpose")).isEqualTo("PREVIEW");
|
|
assertThat(GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("publishedRevisionId")).isEqualTo(active);
|
|
assertThat(events).containsExactly("GENERATION_REQUESTED", "GENERATION_RETAINED");
|
|
var publish = author(GenerationTables.TEMPLATE, "publish", template);
|
|
int published = saveAuthor(publish).getValueInteger("resultId");
|
|
assertThat(published).isNotEqualTo(active).isNotEqualTo(request.getValueInteger("revisionId"));
|
|
assertThat(GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("publishedRevisionId")).isEqualTo(published);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** An unsupported barcode option reported by the isolated renderer reaches both
|
|
** preview and publication as its closed code; neither publishes a revision.
|
|
***************************************************************************/
|
|
@Test
|
|
void rendererUnsupportedBarcodeOptionRefusesPreviewAndPublishWithClosedCode() throws Exception
|
|
{
|
|
Integer active = GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("publishedRevisionId");
|
|
config.withRenderer(new GenerationPorts.IsolatedRenderer()
|
|
{
|
|
/***************************************************************************
|
|
** The renderer refuses the template's explicit barcode option.
|
|
***************************************************************************/
|
|
public void validate(String actor, GenerationPorts.Revision revision) throws QException
|
|
{
|
|
throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.UNSUPPORTED_BARCODE_OPTION);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** The renderer refuses the template's explicit barcode option.
|
|
***************************************************************************/
|
|
public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException
|
|
{
|
|
throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.UNSUPPORTED_BARCODE_OPTION);
|
|
}
|
|
});
|
|
var preview = author(GenerationTables.TEMPLATE, "preview", template);
|
|
preview.addValue("sourceRecordId", "1");
|
|
assertThatThrownBy(() -> saveAuthor(preview)).isInstanceOf(GenerationPorts.RenderException.class).hasMessage("UNSUPPORTED_BARCODE_OPTION");
|
|
var publish = author(GenerationTables.TEMPLATE, "publish", template);
|
|
assertThatThrownBy(() -> saveAuthor(publish)).isInstanceOf(GenerationPorts.RenderException.class).hasMessage("UNSUPPORTED_BARCODE_OPTION");
|
|
assertThat(GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("publishedRevisionId")).isEqualTo(active);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Native download results preserve transport mode and show owner navigation.
|
|
***************************************************************************/
|
|
@Test
|
|
void downloadResultUsesAuthenticatedDeliveryAndNativeRecordNavigation() throws Exception
|
|
{
|
|
int id = generate();
|
|
config.withDownloadAccess((actor, source, receipt, correlation) -> new GenerationPorts.DownloadGrant(receipt.versionId(), downloadUrl, Instant.now(), true));
|
|
config.withRetainedAccess(new GenerationPorts.RetainedAccess()
|
|
{
|
|
/***************************************************************************
|
|
** Authorize every retained reference, representing an owner whose current-authority check passes.
|
|
***************************************************************************/
|
|
public GenerationPorts.RetainedReference authorize(GenerationPorts.RetainedReference reference)
|
|
{
|
|
return (reference);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Map the retained reference to a fixed source record, so the download scenario can navigate back to it.
|
|
***************************************************************************/
|
|
public GenerationPorts.RecordLocation recordLocation(GenerationPorts.RetainedReference reference)
|
|
{
|
|
return (new GenerationPorts.RecordLocation("orders", "1"));
|
|
}
|
|
});
|
|
var output = new RunBackendStepOutput();
|
|
new GenerationProcesses.Execute().run(prepared(GenerationTables.REQUEST, "download", id), output);
|
|
assertThat(output.getValueInteger(GenerationTables.REQUEST + "Result")).isEqualTo(id);
|
|
assertThat(output.getValueString("delivery")).isEqualTo("AUTHENTICATED_FETCH");
|
|
assertThat(output.getValueString("retainedRecordId")).isEqualTo("1");
|
|
assertThat(output.getValueString("retainedRecordTable")).isEqualTo("orders");
|
|
var resultStep = (com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData) instance.getProcess(GenerationProcesses.name(GenerationTables.REQUEST, "download")).getStep("result");
|
|
assertThat(resultStep.getViewFields().stream().filter(field -> field.getName().equals("downloadUrl")).findFirst().orElseThrow().getAdornments())
|
|
.anyMatch(adornment -> adornment.getType() == com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType.FILE_DOWNLOAD && "delivery".equals(adornment.getValues().get("deliveryModeField")));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Barcode help is bounded trusted documentation, never a render invocation.
|
|
***************************************************************************/
|
|
@Test
|
|
void barcodeReferenceIsDiscoverableBoundedAndPermissionChecked() throws Exception
|
|
{
|
|
var input = author(GenerationTables.TEMPLATE, "catalog", null);
|
|
assertThatThrownBy(() -> saveAuthor(input)).hasMessageContaining("unavailable");
|
|
config.withRenderer(new GenerationPorts.IsolatedRenderer()
|
|
{
|
|
/***************************************************************************
|
|
** Report a stale catalog identity, so the authoring process must refuse a reference whose version it does not support.
|
|
***************************************************************************/
|
|
public String barcodeReference()
|
|
{
|
|
return ("qqq-barcodes-1: qr, code128; typed options");
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Fail the test if validation runs, since catalog reference refusal must occur before any render work.
|
|
***************************************************************************/
|
|
public void validate(String actor, GenerationPorts.Revision revision)
|
|
{
|
|
throw new AssertionError();
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Fail the test if rendering runs, since catalog reference refusal must occur before any render work.
|
|
***************************************************************************/
|
|
public GenerationPorts.Content render(GenerationPorts.RenderRequest request)
|
|
{
|
|
throw new AssertionError();
|
|
}
|
|
});
|
|
var output = saveAuthor(input);
|
|
assertThat(output.getValueString("barcodeReference")).contains("qqq-barcodes-1");
|
|
assertThat(output.getValueString("pdfExample")).contains("<qqq-barcode", "</qqq-barcode>");
|
|
assertThat(new ObjectMapper().readTree(output.getValueString("zplExample")).get("schema").asText()).isEqualTo("qqq-zpl-scene/1");
|
|
QContext.getQSession().getPermissions().remove("document-generation.configure");
|
|
assertThatThrownBy(() -> saveAuthor(input)).isInstanceOf(QException.class);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Published examples address the actual canonical selected-record context,
|
|
** including when the native action receives forged client record values.
|
|
***************************************************************************/
|
|
@Test
|
|
void barcodeExamplesResolveAgainstNativeGenerationContext() throws Exception
|
|
{
|
|
var renderer = config.getRenderer();
|
|
config.withRenderer(new GenerationPorts.IsolatedRenderer()
|
|
{
|
|
/***************************************************************************
|
|
** Report a supported but different catalog identity for the scenario that checks the example documents.
|
|
***************************************************************************/
|
|
public String barcodeReference()
|
|
{
|
|
return ("qqq-barcodes-4: qr");
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Delegate to the real renderer so example generation exercises actual validation.
|
|
***************************************************************************/
|
|
public void validate(String actor, GenerationPorts.Revision revision) throws QException
|
|
{
|
|
renderer.validate(actor, revision);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Delegate to the real renderer so example generation produces genuine output.
|
|
***************************************************************************/
|
|
public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException
|
|
{
|
|
return (renderer.render(request));
|
|
}
|
|
});
|
|
var examples = saveAuthor(author(GenerationTables.TEMPLATE, "catalog", null));
|
|
String pdf = examples.getValueString("pdfExample");
|
|
String zpl = examples.getValueString("zplExample");
|
|
var definitions = new DocumentDefinitionService(config);
|
|
var draft = definitions.loadDraft(template);
|
|
definitions.saveSources(template, draft.stockId(), pdf, zpl, draft.fingerprint());
|
|
definitions.publish(template);
|
|
for(String format : List.of("PDF", "ZPL"))
|
|
{
|
|
var input = prepared("orders", "generate", 1);
|
|
input.addValue("templateId", template);
|
|
input.addValue("format", format);
|
|
execute(input);
|
|
var context = new ObjectMapper().valueToTree(rendered);
|
|
assertThat(context.has("id")).isFalse();
|
|
assertThat(context.at("/record/name").asText()).isEqualTo("canonical");
|
|
String zplPath = new ObjectMapper().readTree(zpl).at("/elements/0/valuePath").asText();
|
|
assertThat(context.at(zplPath).intValue()).isEqualTo(1);
|
|
var interpolation = java.util.regex.Pattern.compile("value='\\$\\{([a-z.]+)}'").matcher(pdf);
|
|
assertThat(interpolation.find()).isTrue();
|
|
assertThat(context.at("/" + interpolation.group(1).replace('.', '/')).intValue()).isEqualTo(1);
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Changing source during isolated validation cannot publish the stale candidate.
|
|
*******************************************************************************/
|
|
@Test
|
|
void publicationDetectsDraftChangeDuringExternalValidation() throws Exception
|
|
{
|
|
var service = new DocumentDefinitionService(config);
|
|
Integer active = GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("publishedRevisionId");
|
|
var before = service.loadDraft(template);
|
|
config.withRenderer(new GenerationPorts.IsolatedRenderer()
|
|
{
|
|
/***************************************************************************
|
|
** Edit the draft during validation, simulating a concurrent change that publication must detect and refuse.
|
|
***************************************************************************/
|
|
public void validate(String actor, GenerationPorts.Revision revision) throws QException
|
|
{
|
|
service.saveDraft(template, before.stockId(), new DocumentDefinitionService.Draft("concurrent edit", null, null, List.of()));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Fail the test if rendering runs, since this publication scenario must stop at the changed-draft refusal.
|
|
***************************************************************************/
|
|
public GenerationPorts.Content render(GenerationPorts.RenderRequest request)
|
|
{
|
|
throw new AssertionError("No rendering in publication fixture");
|
|
}
|
|
});
|
|
assertThatThrownBy(() -> service.publish(template, before.fingerprint())).hasMessageContaining("draft changed");
|
|
assertThat(GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("publishedRevisionId")).isEqualTo(active);
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Native query predicates cannot turn private context into a row-count oracle.
|
|
*******************************************************************************/
|
|
@Test
|
|
void nativeItemReadsRejectHiddenFiltersOrderingOperandsAndProjection() throws Exception
|
|
{
|
|
GenerationStore.insert(GenerationTables.ITEM, new QRecord().withValue("queueId",1).withValue("ordinal",0).withValue("actorId","alice")
|
|
.withValue("tableName","orders").withValue("recordId","1").withValue("contextJson","PRIVATE-CONTEXT-SENTINEL").withValue("leaseToken","PRIVATE-LEASE"));
|
|
var action=new com.kingsrook.qqq.backend.core.actions.tables.QueryAction();
|
|
for(String field:List.of("contextJson","leaseToken",GenerationTables.ITEM+".contextJson"))
|
|
{
|
|
var filtered=new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.ITEM)
|
|
.withFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter().withCriteria(field,com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.CONTAINS,"PRIVATE"));
|
|
assertThatThrownBy(() -> action.execute(filtered)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
var ordered=new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.ITEM)
|
|
.withFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter().withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy(field)));
|
|
assertThatThrownBy(() -> action.execute(ordered)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
}
|
|
var other=new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.ITEM)
|
|
.withFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter().withCriteria(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria("state",com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS).withOtherFieldName("contextJson")));
|
|
assertThatThrownBy(() -> action.execute(other)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
var explicit=new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.ITEM).withShouldOmitHiddenFields(false);
|
|
assertThatThrownBy(() -> action.execute(explicit)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
var projection=new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.ITEM).withFieldNamesToInclude(java.util.Set.of("id","contextJson"));
|
|
assertThatThrownBy(() -> action.execute(projection)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
var nested=new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.ITEM).withFilter(
|
|
new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter().withSubFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()
|
|
.withCriteria("contextJson",com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS,"PRIVATE-CONTEXT-SENTINEL")));
|
|
assertThatThrownBy(() -> action.execute(nested)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
var joined=new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.ITEM)
|
|
.withQueryJoins(List.of(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin("orders")));
|
|
assertThatThrownBy(() -> action.execute(joined)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
var allowed=action.execute(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(GenerationTables.ITEM)).getRecords();
|
|
assertThat(allowed).hasSize(1);
|
|
assertThat(allowed.getFirst().getValues()).doesNotContainKeys("contextJson","leaseToken");
|
|
var byPrivateKey = new com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput(GenerationTables.ITEM)
|
|
.withUniqueKey(Map.of("contextJson", "PRIVATE-CONTEXT-SENTINEL"));
|
|
assertThatThrownBy(() -> new com.kingsrook.qqq.backend.core.actions.tables.GetAction().execute(byPrivateKey))
|
|
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Domain generate permission cannot override read-only credential/channel caps.
|
|
*******************************************************************************/
|
|
@Test
|
|
void semanticGenerationAndConfigurationRespectMutationCeilings() throws Exception
|
|
{
|
|
QContext.getQSession().getPermissions().remove("orders.write");
|
|
assertThat(generate()).isPositive();
|
|
QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope().withReadOnly(true));
|
|
assertThatThrownBy(this::generate).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
assertThatThrownBy(() -> new DocumentDefinitionService(config).createType("blocked")).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
QContext.getQSession().setCredentialScope(null);
|
|
com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData.ofOrWithNew(instance).withDeniedWritePattern("api","orders");
|
|
QContext.getQSession().withClientChannel("api");
|
|
assertThatThrownBy(this::generate).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Use the current source record and expose exact retained-version status, download and reprint results.
|
|
***************************************************************************/
|
|
@Test
|
|
void nativeActionsReadCurrentRecordAndReturnExactVersion() throws Exception
|
|
{
|
|
int request=generate();
|
|
assertThat(rendered.get("record")).isEqualTo(Map.of("id",1,"name","canonical"));
|
|
assertThat(execute(prepared(GenerationTables.REQUEST,"status",request))).contains("GENERATED","version");
|
|
assertThat(execute(prepared(GenerationTables.REQUEST,"download",request))).contains("LINK_ISSUED",downloadUrl).doesNotContain("DOWNLOADED");
|
|
var reprint=prepared(GenerationTables.REQUEST,"reprint",request);
|
|
reprint.addValue("destination","printer");
|
|
assertThat(execute(reprint)).contains("QUEUED","job");
|
|
assertThat(execute(prepared(GenerationTables.REQUEST,"reconcile",request))).contains("GENERATED");
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Recheck source permission at execution and reject forged selections after preparation.
|
|
***************************************************************************/
|
|
@Test
|
|
void executionRechecksSourceRightsAndRejectsSpoofedSelections() throws Exception
|
|
{
|
|
var input=prepared("orders","generate",1);
|
|
input.addValue("templateId",template);
|
|
login(false);
|
|
assertThatThrownBy(() -> execute(input)).isInstanceOf(QException.class);
|
|
login(true);
|
|
input.addValue("selectionJson","[\"1\",\"2\"]");
|
|
assertThatThrownBy(() -> execute(input)).isInstanceOf(QException.class);
|
|
input.addValue("selectionJson","[{\"id\":1}]");
|
|
assertThatThrownBy(() -> execute(input)).isInstanceOf(QException.class);
|
|
input.addValue("selectionJson","[\"1\"] trailing");
|
|
assertThatThrownBy(() -> execute(input)).isInstanceOf(QException.class);
|
|
input.addValue("selectionJson","[\"1\"]");
|
|
input.addValue("format","EXEC");
|
|
assertThatThrownBy(() -> execute(input)).hasMessageContaining("PDF or ZPL");
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Reject substituted retained versions and unsafe download links before exposing delivery metadata.
|
|
***************************************************************************/
|
|
@Test
|
|
void downloadCannotSubstituteVersionsOrEmitUnsafeLinks() throws Exception
|
|
{
|
|
int request=generate();
|
|
var service=new GenerationDownloadService(config);
|
|
wrongVersion=true;
|
|
assertThatThrownBy(() -> service.issue(request)).hasMessageContaining("Invalid retained-file");
|
|
wrongVersion=false;
|
|
downloadUrl="javascript:alert(1)";
|
|
assertThatThrownBy(() -> service.issue(request)).hasMessageContaining("HTTPS");
|
|
downloadUrl="https://user:password@example.test/file";
|
|
assertThatThrownBy(() -> service.issue(request)).hasMessageContaining("HTTPS");
|
|
config.withDownloadAccess(new DocumentGenerationQBitConfig().getDownloadAccess());
|
|
assertThatThrownBy(() -> service.issue(request)).hasMessageContaining("unavailable");
|
|
receipts.put("version",new GenerationPorts.Receipt("file","version","0".repeat(64),3L,true,true));
|
|
assertThatThrownBy(() -> service.issue(request)).hasMessageContaining("exact retained");
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Advertise bounded native selections and refuse queue operations on an unsupported memory backend.
|
|
***************************************************************************/
|
|
@Test
|
|
void metadataLimitsAndUnsupportedQueueBackendFailClosed() throws Exception
|
|
{
|
|
assertThat(instance.getProcess(GenerationProcesses.name("orders","generate")).getMaxInputRecords()).isEqualTo(1);
|
|
assertThat(instance.getProcess(GenerationProcesses.name("orders","enqueue")).getMaxInputRecords()).isEqualTo(config.getMaximumBatch());
|
|
var input=prepared("orders","enqueue",1);
|
|
input.addValue("templateId",template);
|
|
assertThatThrownBy(() -> execute(input)).hasMessageContaining("file-backed H2");
|
|
assertThatThrownBy(() -> GenerationProcesses.register(instance,List.of("missing"))).isInstanceOf(QException.class);
|
|
assertThatThrownBy(() -> new DocumentGenerationQBitConfig().withSourceTables(List.of(""))).isInstanceOf(IllegalArgumentException.class);
|
|
var empty=new RunBackendStepInput().withProcessName(GenerationProcesses.name("orders","generate")).withRecords(List.of());
|
|
assertThatThrownBy(() -> new GenerationProcesses.Prepare().run(empty,new RunBackendStepOutput())).isInstanceOf(QException.class);
|
|
}
|
|
}
|