From 660da8e75b4fc9dc0623af68addd357f6d48ef44 Mon Sep 17 00:00:00 2001 From: Nedelcho Delchev Date: Mon, 5 Oct 2026 12:19:08 +0300 Subject: [PATCH] The coverage IT runs an attachment upload and a snapshot mint #7568 put a drop of every system-owned column at the top of the generated repository's save(). IntentEmissionCoverageIT was green on that PR, and the change still broke every `function: Attachment` upload and every `function: Snapshot` mint: the fixture declared neither. Its own readOnly columns are written through updateProperties by jobs, notifications and task write-backs, which never reach save(), so a regression on the create path of a system writer was invisible to the one IT meant to run the whole generated surface. The fixture gains an Archive document with both file children and a process that mints twice in one run. The assertions go through HTTP against the generated controllers, because reading the rendered template proved nothing about the row the last time: - the upload answers with the five metadata columns and the STORED row carries them, with StoragePath ending in the Uuid folder of the stored file; - the file downloads byte for byte through the generated endpoint; - a plain create still stores null for all five while keeping the authored column; - the document carries two copies, Version 1 and 2, each describing the PDF it stored. The entities constant was already at the 65535-byte limit, so the two families ride in a third constant concatenated between the entities and the glue - one document, three constants, for the reason the glue was split out. components/engine/engine-intent/CLAUDE.md now names the seven writer families whose create path is save(), so a change to the generated save() or update() is checked against each one by name. Fixes #7623 Co-Authored-By: Claude Opus 5 (1M context) --- components/engine/engine-intent/CLAUDE.md | 2 +- .../tests/api/IntentEmissionCoverageIT.java | 169 +++++++++++++++++- 2 files changed, 169 insertions(+), 2 deletions(-) diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 055e696ff02..d647fe4917c 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -453,7 +453,7 @@ Semantics worth knowing: - **A roll-up's CHILD may be owned by another model (#6930), which is the n:m allocation direction.** `rollups: [{ entity: , model: , parent: , via: , field: ..., op: sum, of: ... }]` - declared by the module that owns the PARENT. The cross-model *parent* direction (a local child, `via`'s own `model:`) already existed, but the inverse was inexpressible, and it is the one an n:m pairing forces: the link entity lives with the document that owns ONE side (`SalesInvoiceCustomerPayment` belongs to `sales-invoices`, whose `invoicePaid` roll-up is local and works), while the OTHER side's total (`CustomerPayment.allocated`, and the `unapplied` figure derived from it) belongs to the module that owns the payment - so it had no declarative form at all and was answered by a register report instead of a stored, filterable number. **`parent:` is authored rather than derived** because a foreign child's relations are not in this document: nothing here can walk `via` to a target, which is also why `via` / `of` / `by` are resolved against the OWNER's `.model` at generation time (`firstUnresolvableChildProperty`, the schedules' cross-model-source rule) and a miss drops the roll-up loudly. The parent must be LOCAL - a total landing in a third model is that model's roll-up to declare, and writing it from here would invert the dependency edge. Emission-wise the child's coordinates simply come from the owner: `childProject` (the topic - this project publishes nothing about that entity, so a local topic would subscribe to silence) and `childGenFolder` (the imports), both defaulting to this project so **a local roll-up renders byte-identically**; the class name is prefixed with the owner alias and the pipeline's coalescing key gains `childModel`, because a local and a foreign child of the same name rolling up through the same relation are two handlers, and one class name for both would have the pipeline write one file over the other. Three deliberate limits: **`capacity`/`balance`/`status` are refused** (the capacity guard lives on the CHILD's DAO, which the owner model generates - a recomputed balance with no guard behind it would look like a limit and enforce nothing); **the vacated side of a re-parent is repaired only if the owner marks that relation as a grouping key**, since `-rekeyed` is published by the owner's DAO and `groupingKeys` is the union over the OWNER's own consumers (the handler is emitted regardless - it is the same store-driven recompute and converges whenever the notice does arrive; delete + re-create is always exact); and **`sensitive:`/`visibleTo:` do not propagate** from a foreign `of` field, so a restricted total must declare its own restriction. Both `EdmIntentGenerator` sites that walk `model.getRollups()` skip a cross-model child (`groupingKeys`, `buildRollupGuards`), as do the two parser propagation loops - otherwise a local entity that merely SHARES the foreign child's name would be treated as it. Covered by `GlueRollupCrossModelTest` (the emitted coordinates + class name, the local case unchanged, and every refusal). - **A roll-up counts the rows it is told to, and its ceiling is enforced at the moment it can be (`where:` / `guardAt:` / `message:`, #7542).** A capacity roll-up counted EVERY child, so a cancelled or voided row kept consuming the parent's capacity for ever and its replacement could never be issued - with every write answering 200. And the overdraw guard ran on every write of the child, which is right for a row carrying its own typed amount (an allocation's) and useless for one whose amount is a DOCUMENT TOTAL: that is recomputed from the lines after the header is written, so the guard only ever saw the 0 the header was created with. `where:` is the same `{ field, op, value }` triples a `schedules[].where` carries (`ScheduleSupport.conditions`, the shared reader), over the CHILD's own fields and to-one relations, with a status named by its seed name like every other status site (`StatusSymbolResolver.rewriteRollups`); `guardAt:` names the status the guard is enforced AT; `message:` is the refusal, with `{capacity}` / `{sum}` / `{requested}` / `{remaining}`. **The filter is ONE authored definition with TWO readers** - `GlueIntentGenerator.buildRollups` puts the clauses on the rollup descriptor, where `GlueGenerator.bindForeignKeyCriteria` appends them to the recompute's own foreign-key query, and `EdmIntentGenerator.buildRollupGuards` stamps the same clauses on the capacity guard - which is what keeps the stored balance and the enforced ceiling from disagreeing by exactly the rows the author retired. The guard still recomputes SYNCHRONOUSLY from the child's own store (never a read of the materialised balance - the documented rule for a money guard) and the filter is additionally applied to the row in hand, so a row the filter excludes is not guarded at all: cancelling an allocation must never be refused by the very ceiling the cancellation frees. **Only `eq` / `ne`** - the filter is rendered twice (a `Criteria` clause and a Java comparison) and an exact equality is the only comparison whose two renderings cannot disagree. `ModelParameterProcessor.resolveRollupGuards` renders both halves plus the authored message, because it is the pass that has resolved the property's Java shape: a numeric column is compared BY VALUE (`longValue()`), the #7237 class of guard that reads as authored and is never true. A gated guard also runs on the TARGETED write path (`updateProperties`, whose override and load gate the entity-level `hasGatedRollupGuards` flag opens), which is how a workflow setter moves a document into the gate status - and #7014/#7063 keeps that write synchronous, so the refusal reaches whoever pressed the button. Both keys are refused on a cross-model CHILD: those rows are written by the owner's repository, which is also where their guard would have to be emitted - the same reason `capacity:` is refused on that direction. Unit: `RollupMembershipIntentTest`, `EdmRollupGuardTest`, `GlueRollupFilterTest`, `ModelParameterProcessorTest`; render: `RollupGuardCrossModelTemplateIT`; runtime: `IntentEmissionCoverageIT` (a cancelled allocation frees the fund's budget and the replacement is accepted, in the authored words). - **A roll-up's `status:` is relinquished, not only set (#7016).** The `statusWhenFull` / `statusWhenPartial` branch modelled "money arrives" and forgot "money leaves": the recompute had no `else`, so deleting the only allocation of a PAID invoice left it PAID with Paid 0 / Balance = Payable - and invisible to the settlement, whose payable statuses are ISSUED/SENT/PARTIAL. Now the FIRST move into a roll-up-owned status snapshots the status it displaces into a hidden, read-only INTEGER column on the parent, `Displaced` (`IntentNaming.displacedStatusProperty`, emitted by `EdmIntentGenerator.displacedStatusProperty` for every local parent of a capacity roll-up with a status, one per status relation; `GlueIntentGenerator.buildRollups` hands it to the emitter as `statusDisplacedField`), and a sum back at zero restores it when - and only when - the parent still holds one of the two roll-up-owned statuses, then clears the snapshot (`RollupAggregates.appendStatus`; every variant, create/update/delete/rekey, since an allocation amended to 0 or re-parented away is the same situation as a deleted one). Remembering beats a declared `statusWhenEmpty:` - that is wrong for every invoice paid straight from ISSUED and never CONFIRMED - so there is no such key. A roll-up-owned status with no recorded predecessor (a deployment upgraded mid-payment) is logged and left alone, never guessed. Both writes ride the same `derived` map into ONE `updateDerived`, so the parent's listeners see one `-updated`. The column is hidden through the **`isHiddenProperty`** flag, which is now the ONE thing the Harmonia templates consult to leave bookkeeping out of forms, lists and details blocks (`ModelParameterProcessor` sets it from the model and BY NAME for `ProcessIds`, so a `.model` written before the flag existed still hides the stamps; the modeler's serializer carries unknown attributes through the generic pass, so a hand round-trip keeps it); `isReadOnlyProperty` puts it in `preservedOnUpdate`, so a full-row form save cannot null it. With a `lifecycle:` on the parent the moves back must be declared edges like the moves in. -- **A system-owned column is dropped on the REST create by the CONTROLLER; the repository's `save()` keeps whatever a system writer assigns (#7549, #7620).** `readOnly` fields, roll-up/aggregate targets, `ProcessId(s)`, a label's `Name` - the set `update()` preserves from the stored row (`preservedOnUpdate`) - were enforced on create only since #7568, which put the strip at the top of the generated repository's `save()`. That is the create path of every system writer: the `function: Attachment` upload and the `function: Snapshot` mint assign exactly those columns (the file's name, type, size, storage path, uuid; the copy's `Version`) before `save()`, so every upload stored a row pointing at nothing and `download` failed on the null path; a create-from, a `postings:`/`posts:` handler, an arrival or an `aggregates:` first row loses the same way whenever the author's mapped column is read-only or aggregate, and a posting's item cell stored null but still compared (`putSaveTimeFill` drops a readOnly/aggregate cell only when `updatedOnRewrite`) would rewrite itself on every redelivery - the #7071/#7234 loop from the other side. The strip now lives in the three generated controllers' create verbs (`dropSystemOwnedOnCreate`, power / my / partner - where `visibleTo`'s write-ignoring, the workflow-owned status, the master and period locks already are), after the controller's own checks and immediately before the save, so what a create refuses is unchanged; the number/uuid stamps are left to `save()`'s own #7548 discard, and the repository is untouched on create and preserves on update as before. The predicate is copied from `Repository.java.template`'s `preservedOnUpdate` - keep the two in step. Guards: `SystemOwnedOnCreateControllerTemplateIT` (the three controllers plus the DAO render) and `IntentAttachmentUploadIT` (an upload's row carries its file, the download streams it, a forged `POST` still stores null). +- **A system-owned column is dropped on the REST create by the CONTROLLER; the repository's `save()` keeps whatever a system writer assigns (#7549, #7620).** `readOnly` fields, roll-up/aggregate targets, `ProcessId(s)`, a label's `Name` - the set `update()` preserves from the stored row (`preservedOnUpdate`) - were enforced on create only since #7568, which put the strip at the top of the generated repository's `save()`. That is the create path of every system writer: the `function: Attachment` upload and the `function: Snapshot` mint assign exactly those columns (the file's name, type, size, storage path, uuid; the copy's `Version`) before `save()`, so every upload stored a row pointing at nothing and `download` failed on the null path; a create-from, a `postings:`/`posts:` handler, an arrival or an `aggregates:` first row loses the same way whenever the author's mapped column is read-only or aggregate, and a posting's item cell stored null but still compared (`putSaveTimeFill` drops a readOnly/aggregate cell only when `updatedOnRewrite`) would rewrite itself on every redelivery - the #7071/#7234 loop from the other side. The strip now lives in the three generated controllers' create verbs (`dropSystemOwnedOnCreate`, power / my / partner - where `visibleTo`'s write-ignoring, the workflow-owned status, the master and period locks already are), after the controller's own checks and immediately before the save, so what a create refuses is unchanged; the number/uuid stamps are left to `save()`'s own #7548 discard, and the repository is untouched on create and preserves on update as before. The predicate is copied from `Repository.java.template`'s `preservedOnUpdate` - keep the two in step. Guards: `SystemOwnedOnCreateControllerTemplateIT` (the three controllers plus the DAO render) and `IntentAttachmentUploadIT` (an upload's row carries its file, the download streams it, a forged `POST` still stores null). **The writer families whose create path is `save()` (#7623) - check a change to `Repository.java.template`'s `save()` or `update()` against each one BY NAME, never against "the coverage IT is green":** (1) the REST create of the three controllers, the only one a user drives; (2) the `function: Attachment` upload, which assigns `FileName`/`ContentType`/`FileSize`/`StoragePath`/`Uuid`; (3) the `function: Snapshot` mint, which assigns those five plus `Version`; (4) a `generates:` create-from, header and lines; (5) a `postings:` handler and its flattened sibling `posts:`; (6) an `inbound:` arrival; (7) an `aggregates:` first row. Families 2 and 3 are the ones #7568 broke while every suite stayed green, because the coverage fixture declared neither; its `readOnly` columns are all written through `updateProperties` by jobs, notifications and task write-backs, which never touch `save()`. - **`checks: kind: agree` = two relations of a JUNCTION row must agree on a shared target (#7409).** The shape no kind reached: `compare` relates two values of ONE row and `requiredWhen`/`forbidWhen` relate a child to its own parent, but nothing compared two DIFFERENT relations' targets - so a EUR `CustomerPayment` of customer B was allocated against a USD `SalesInvoice` of customer A and both writes answered 200. A fleet probe found the same shape in 7 more places across 6 modules (`PurchaseInvoicePayment`, `StockTransfer`, `EmployeeTimesheet`, `EmployeeProjectAssignment`, `Payslip`, `VacationDay`), every one closed by a hand-written `calculatedActionOnCreate`/`OnUpdate` guard class whose entire content was a rule the intent should be able to state. Authored as `{ kind: agree, relations: [, ], onProperty: , whenNull?: skip | refuse, message }`. **Both sides are ONE PATH each** - `.` through the same `ResolvePathSupport` walker every other path in the DSL uses, sharing ONE walker so each related record is loaded exactly once and a cross-model target reads through its `uses:` owner like any other hop - and the comparison is emitted into the three controllers' `validate()` as a 400, next to `exactlyOne` and `compare`. **The key is `onProperty`, not `on`:** YAML 1.1 resolves a bare `on` key to the boolean `true`, so an `on:` declaration would arrive as the key `true`, bind to nothing and generate a check with no property to agree on - `rejectCheckOn` refuses that spelling by name on the raw tree (the issue proposed it, so authors and the assistant will write it), the same treatment `lifecycle`'s `on` gets. **Refused at parse**, each because the declaration could not mean anything: fewer or more than two relations, the same relation twice (it always agrees with itself), a relation or an `onProperty` a target does not declare (the walker's own message), a `status:` gate (two relations either agree or they do not, from the first save), an unknown `whenNull`, an `onProperty` whose terminal is not an exact equality - a to-one's foreign key, a string, an integer or a boolean, the same line a `when` condition draws, so a `decimal`/`date` is refused rather than compared by an equality nobody means - and two terminals of DIFFERENT types, where the boxed comparison is silently always false. `whenNull` defaults to `skip`: a row not yet carrying both values has nothing to disagree about, and requiredness is the relation's own declaration. Unit: `IntentParserTest.agreeChecksParseAndValidate`, `EdmIntentGeneratorTest.agreeChecksEmitBothSidesAndTheirLoads`; IT: `IntentEmissionCoverageIT` (both `whenNull` readings, compiled). **The check also guards the two PARENTS (#7589).** The junction's own check runs only when the junction row is written, so a plain PUT on an allocated `CustomerPayment` changing its Customer/Currency/Company answered 200 and left the row standing in exactly the pairing the check refuses at create. `buildAgreeGuards` stamps an `agreeGuards` entry (`referencingEntity`, `fkProperty`, `property`, `message`) on each same-model target, `ModelParameterProcessor.resolveAgreeGuards` resolves the junction's repository FQN and the message literal, and the parent's generated repository refuses a CHANGE of the agreed property while a junction row references it - with the check's own message, as a `ValidationException` (400) - on all three update paths: `update`, `updateWithoutEvent` and the targeted `updateProperties` a workflow `setRelationField` reaches (which compares against the values captured BEFORE it applies its own). The parent's guard lives in the repository rather than the controllers because the issue's second case is a workflow write, which never reaches a controller. Not guarded: a `severity: warn` check (it refuses nothing, so it must not refuse the parent either) and a cross-model target (its repository belongs to the model that owns it). Unit: `EdmIntentGeneratorTest.agreeChecksGuardBothParents`, `ModelParameterProcessorTest.anAgreeGuardResolvesToTheJunctionsRepositoryAndEscapesItsMessage`; IT: `IntentEmissionCoverageIT.assertAgreeGuardsTheParentsRuntime`. - **`severity: warn` = the soft "warn and confirm" tier (#7466).** Every other check REFUSES; a warning tells the person saving and lets them go on - the billing review's three cases (a second customer with the same name, where a hard `unique:` is wrong because two companies may share a registered name; a document line at price zero; asked once per document save, never per line). Three shapes: `severity: warn` on an UNGATED row-level kind (`compare` / `requiredWhen` / `forbidWhen` / `exactlyOne` / `agree`), and the two warning-only kinds `duplicate` (`fields:` - own fields or to-ones - repeated by another record, self excluded by PK) and `itemsCompare` (on the MASTER: each item's `field` `op` a `value` literal, typed by the ITEM field through `CheckSupport.compareLiteral` exactly as a literal `compare`; `{count}` in the message = the breaking lines). `CheckIntent.isWarning()` is the one test for "is this soft". **Refused at parse** (`validateSeverity`): an unknown severity, `warn` with a `status:` gate (a gated check fires inside a transition, where nobody can answer), `warn` on a document-level kind or a `guard` (which has its own soft outcomes), `severity: error` on the warning-only kinds (that is `unique:` / a line `compare`). **Routing:** `EdmIntentGenerator.buildChecks` stamps `severity: warn` and a stable `code` (`..` - what the caller echoes back) and a warning `forbidWhen` gets NO `masterGuard` (the write stays possible, so the panel must keep offering it); `ModelParameterProcessor.splitChecks` files every warning into `warningChecks` ONLY - not `rowChecks`/`validate()`, not `deleteChecks`, not the transition. The DAO template renders them as `public List warnings(Entity)`; the three controller templates call `org.eclipse.dirigible.sdk.http.Warnings.requireConfirmed(repository.warnings(x))` after `validate()`/`validateReferences()` on create and update. **Transport:** `Warnings` throws `sdk.db.ConfirmationRequiredException` unless the request's `X-Confirm-Warnings` header names EVERY raised code (per-code, so a new warning on the repeat is asked again), passes silently with no HTTP request bound (process steps, jobs), and logs each confirmation with the user - the audit trace until the activity stream (#7472) records it. `ControllerInvoker` maps the exception to **428** `{status, error, errorType: ConfirmationRequired, message, warnings: [{code, message}]}`. **UI:** `application-core` `api.js` catches that 428 in `request()` itself, asks once through `App.services.confirmWarnings` (a Harmonia dialog mounted on the body; `window.confirm` without Alpine) and repeats the request with the codes - so every generated form, line dialog and detail panel gets it with no template change; a Cancel throws `ConfirmationDeclined`. Unit: `IntentParserTest.warningChecksParseAndValidate` / `severityWarnIsRefusedOnTheKindsNobodyIsAskedAbout`, `EdmIntentGeneratorTest.warningChecksCarryTheirSeverityCodeAndWhatTheyRead`, `ModelParameterProcessorTest.aWarningReachesOnlyTheWarningList`, `ControllerInvokerBindingTest.unconfirmed_warnings_yield_428_listing_them`, `WarningsTest`; IT: `IntentSoftWarningsIT` (compiled and run: 428 and nothing persisted, per-code confirmation, self-exclusion on update, one zero-price prompt for two zero lines). A `duplicate`'s `string`/`text` members compare through `Criteria.eqNormalized` (trim + case-fold on both sides, in the database) and its message takes `{match}` = the existing record's label (#7524) - an exact `eq` missed "ACME Ltd" against "Acme Ltd ", which is the case the rule exists for. - **`checks: kind: guard` = a precondition over a keyed `aggregates:` sum, with three outcomes.** The negative-stock / credit-limit / remaining-allowance shape: `aggregate:` names an `aggregates:` entry whose `of` is THIS entity (v1 self-referential), and the post-state is checked against `minimum:` (default 0). The sum is recomputed SYNCHRONOUSLY from the guarded entity's own store for the incoming row's key-tuple, excluding this row on update, then the incoming value is added - deliberately NOT read from the async-maintained aggregate target, so the decision cannot race the handler. Consequence worth remembering: the guard and the materialised aggregate are two independent computations of the same sum, and the guard is the authoritative one - do not "optimise" it into a target read. `enabledBy: ` wraps the whole guard in a `Configurations.get(key) == "true"` gate (a tenant-level business toggle). Emitted by `EdmIntentGenerator.buildChecks` (keys + `sumField` + `pk` + `minimum` + `enabledBy` + `outcome`) → `ModelParameterProcessor` splits `guardChecks` out → the DAO's `#aggregateGuardCheck` macro at both the save and update sites. **`outcome:` decides what a violation DOES**, and each non-default outcome carries its own companion key (parser-validated - a companion belonging to another outcome is an ERROR, since the write would look guarded and do nothing): diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index 2ae7a10d1ec..2a7f2732e42 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -19,6 +19,8 @@ import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -41,6 +43,8 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; +import io.restassured.path.json.JsonPath; + import org.eclipse.dirigible.components.api.messaging.MessagingFacade; import org.eclipse.dirigible.components.data.sources.manager.DataSourcesManager; @@ -1228,6 +1232,43 @@ class IntentEmissionCoverageIT extends IntegrationTest { - { name: Status, kind: manyToOne, to: PledgePaymentStatus, function: EntityStatus, init: ACTIVE } """; + // Still the entities list, in a constant of its own for the same 65535-byte reason as the glue + // below. The two FILE families (#7623): #7568 put a drop of every system-owned column at the top + // of the generated save(), which is the create path of the attachment upload and the snapshot + // mint - both assign exactly those columns - and this IT stayed green through it, having + // declared neither. Its own readOnly columns are written through updateProperties by jobs, + // notifications and task write-backs, which never reach save(). + private static final String INTENT_YAML_FILES = """ + - name: Archive + function: Document + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: title, type: string, length: 60, required: true, function: DocumentTitle } + - name: ArchiveLine + function: DocumentItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string, length: 60 } + relations: + - { name: Archive, kind: manyToOne, to: Archive, composition: true, required: true } + # function: Attachment - the upload stores the file and a row describing it. Caption is + # the one ordinary column, so a forged create can show an authored value kept while the + # five system-owned ones are dropped. + - name: ArchiveFile + function: Attachment + fields: + - { name: caption, type: string, length: 60 } + relations: + - { name: Archive, kind: manyToOne, to: Archive, composition: true, required: true } + # function: Snapshot - the immutable, versioned copy minted from the document's own + # print feeder. Nobody uploads one: the delegate assigns the same five columns plus + # Version and saves it. + - name: ArchiveCopy + function: Snapshot + relations: + - { name: Archive, kind: manyToOne, to: Archive, composition: true, required: true } + """; + // The rest of the same fixture. It is a SECOND constant only because a Java string constant // caps at 65535 UTF-8 bytes and the entities above reach it; `concat` keeps the joined value out // of the constant pool, which a `+` would not (the compiler folds it back into one over-long @@ -1389,6 +1430,16 @@ class IntentEmissionCoverageIT extends IntegrationTest { body: "Still open: {recordUrl}" processes: + # Two mints in ONE run (#7623): the copy's Version increments while the document it + # copies keeps its identity, and both mints go through the repository's save() with the + # five file columns and Version assigned by the delegate. + - name: ArchiveMint + trigger: { onCreate: Archive } + steps: + - { name: mintFirst, kind: serviceTask, args: { delegate: gen.events.ArchiveSnapshotGenerator, next: mintSecond } } + - { name: mintSecond, kind: serviceTask, args: { delegate: gen.events.ArchiveSnapshotGenerator, next: minted } } + - { name: minted, kind: end } + # assignee: personal - the confirm task lands in exactly the owner's Inbox (the IT # runs as admin, mapped by the Person seed below). - name: ClaimConfirm @@ -2006,7 +2057,8 @@ class IntentEmissionCoverageIT extends IntegrationTest { """; /** The whole fixture, as the two halves above spell it. */ - private static final String INTENT_YAML = INTENT_YAML_ENTITIES.concat(INTENT_YAML_GLUE); + private static final String INTENT_YAML = INTENT_YAML_ENTITIES.concat(INTENT_YAML_FILES) + .concat(INTENT_YAML_GLUE); @Autowired private IRepository repository; @@ -6612,6 +6664,7 @@ private void assertRuntimeEnforcement() { assertGeneratesStepAxisRuntime(); assertGeneratesItemsRuleRuntime(); assertStalenessSweepRuntime(); + assertFileFamiliesRuntime(); } /** @@ -7719,4 +7772,118 @@ private void removeDropFolder() { } } + + /** + * The two writer families whose create path is the repository's {@code save()} and which no other + * assertion here reaches (#7623): the {@code function: Attachment} upload and the + * {@code function: Snapshot} mint. Both assign the five system-owned file columns themselves - the + * upload from the stored file, the mint from the rendered copy - so a drop of those columns on the + * create path stores a row pointing at nothing, and the download that follows fails on the null + * path. Asserted over HTTP against the generated controllers, because reading the rendered template + * proved nothing about the row the last time (#7568 shipped green). + */ + private void assertFileFamiliesRuntime() { + AtomicInteger archive = new AtomicInteger(); + restAssuredExecutor.execute(() -> archive.set(given().contentType("application/json") + .body("{\"Title\":\"Minutes\"}") + .when() + .post(API + "/archive/ArchiveController") + .then() + .statusCode(200) + .extract() + .path("Id")), + 60); + + byte[] bytes = "every byte of this must come back".getBytes(StandardCharsets.UTF_8); + // (1) The upload answers with the metadata of the file it just wrote... + restAssuredExecutor.execute(() -> given().multiPart("file", "minutes.txt", bytes, "text/plain") + .when() + .post(API + "/archive/ArchiveFileController/upload?Archive=" + archive.get()) + .then() + .statusCode(200) + .body("", hasSize(1)) + .body("[0].FileName", equalTo("minutes.txt")) + .body("[0].ContentType", equalTo("text/plain")) + .body("[0].FileSize", equalTo(bytes.length)) + .body("[0].StoragePath", startsWith("/Attachments/ArchiveFile/")) + .body("[0].Uuid", notNullValue()), + 60); + + // ...and the STORED row carries it, read back through the master-scoped list the panel uses. + AtomicInteger file = new AtomicInteger(); + restAssuredExecutor.execute(() -> file.set(given().when() + .get(API + "/archive/ArchiveFileController?Archive=" + archive.get()) + .then() + .statusCode(200) + .body("", hasSize(1)) + .extract() + .path("[0].Id"))); + restAssuredExecutor.execute(() -> { + JsonPath stored = given().when() + .get(API + "/archive/ArchiveFileController/" + file.get()) + .then() + .statusCode(200) + .body("FileName", equalTo("minutes.txt")) + .body("FileSize", equalTo(bytes.length)) + .extract() + .jsonPath(); + // the two columns must name ONE object, not merely both be present + assertTrue(stored.getString("StoragePath") + .endsWith("/" + stored.getString("Uuid") + "/minutes.txt"), + "the stored Uuid must be the folder of the stored file, got: " + stored.getString("StoragePath")); + }); + + // (2) ...and the file downloads through the generated endpoint, byte for byte. + restAssuredExecutor.execute(() -> { + byte[] served = given().when() + .get(API + "/archive/ArchiveFileController/" + file.get() + "/download") + .then() + .statusCode(200) + .extract() + .asByteArray(); + assertArrayEquals(bytes, served, "the download must stream the uploaded bytes verbatim"); + }); + + // (3) A plain create still cannot forge the five columns, while the authored one is kept. + AtomicInteger forged = new AtomicInteger(); + restAssuredExecutor.execute(() -> forged.set(given().contentType("application/json") + .body("{\"Archive\":" + archive.get() + + ",\"Caption\":\"forged\",\"FileName\":\"forged.txt\"," + + "\"ContentType\":\"text/plain\",\"FileSize\":1," + + "\"StoragePath\":\"/Attachments/ArchiveFile/forged.txt\"," + + "\"Uuid\":\"forged\"}") + .when() + .post(API + "/archive/ArchiveFileController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + restAssuredExecutor.execute(() -> given().when() + .get(API + "/archive/ArchiveFileController/" + forged.get()) + .then() + .statusCode(200) + .body("Caption", equalTo("forged")) + .body("FileName", nullValue()) + .body("ContentType", nullValue()) + .body("FileSize", nullValue()) + .body("StoragePath", nullValue()) + .body("Uuid", nullValue())); + + // (4) The MINT: the archive's own process ran the generated delegate twice, so the document + // carries two copies - Version 1 then 2 - each describing the PDF it stored. + restAssuredExecutor.execute(() -> given().when() + .get(API + "/archive/ArchiveCopyController?Archive=" + archive.get()) + .then() + .statusCode(200) + .body("", hasSize(2)) + .body("Version", hasItem(1)) + .body("Version", hasItem(2)) + .body("FileName", everyItem(notNullValue())) + .body("ContentType", everyItem(equalTo("application/pdf"))) + .body("FileSize", everyItem(greaterThanOrEqualTo(1))) + .body("StoragePath", everyItem(startsWith("/Attachments/ArchiveCopy/"))) + .body("Uuid", everyItem(notNullValue())), + 120); + } + }