Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df88881c1 | ||
|
|
4fde0fde89 |
@@ -308,3 +308,70 @@ jobs:
|
||||
name: playwright-report-enterprise-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
|
||||
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
|
||||
multinode-e2e:
|
||||
needs: [pick, playwright-e2e-enterprise]
|
||||
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
|
||||
if: >-
|
||||
always() && needs.pick.outputs.is_fork != 'true'
|
||||
&& (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
|
||||
PREMIUM_ENABLED: "true"
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
MN_COMPOSE: docker-compose-multinode.yml
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
- name: Install behave test deps
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||
- name: Build the multi-node image
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" build
|
||||
- name: Bring up the cluster and wait for both nodes healthy
|
||||
working-directory: testing/compose
|
||||
run: |
|
||||
docker compose -f "$MN_COMPOSE" up -d
|
||||
for i in $(seq 1 90); do
|
||||
h1=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-1 2>/dev/null || echo starting)
|
||||
h2=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-2 2>/dev/null || echo starting)
|
||||
if [ "$h1" = healthy ] && [ "$h2" = healthy ]; then echo "both nodes healthy"; exit 0; fi
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::nodes did not become healthy"
|
||||
docker compose -f "$MN_COMPOSE" logs --tail=200 stirling-1 stirling-2
|
||||
exit 1
|
||||
- name: Seed the cluster (teams, users, S3 connection, policy)
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" --profile seed run --rm seed
|
||||
- name: Run multi-node regression (implemented guarantees)
|
||||
working-directory: testing/cucumber
|
||||
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
|
||||
run: python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
|
||||
- name: Run multi-node failover (destructive)
|
||||
working-directory: testing/cucumber
|
||||
run: python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
|
||||
- name: Dump node logs on failure
|
||||
if: failure()
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" logs --tail=400 stirling-1 stirling-2
|
||||
- name: Tear down
|
||||
if: always()
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f "$MN_COMPOSE" --profile seed down -v --remove-orphans
|
||||
|
||||
@@ -208,18 +208,6 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "The W3C License"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.re2j:re2j",
|
||||
"moduleLicense": "Go License"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.hubspot:algebra",
|
||||
"moduleLicense": null
|
||||
},
|
||||
{
|
||||
"moduleName": "com.hubspot.immutables:immutables-exceptions",
|
||||
"moduleLicense": null
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "UnRar License"
|
||||
|
||||
@@ -66,20 +66,6 @@ dependencies {
|
||||
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
|
||||
// jinjava/jjwt transitively request older Jackson 2 versions; declare the current
|
||||
// version directly so it is selected consistently (root build.gradle pins are the fallback).
|
||||
runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
|
||||
runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
|
||||
|
||||
implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") {
|
||||
// Compile-time-only annotation artifacts (class-retention annotations, not needed at
|
||||
// runtime) whose declared licences (LGPL / none) fail the licence compatibility check.
|
||||
exclude group: 'com.google.code.findbugs', module: 'annotations'
|
||||
exclude group: 'org.derive4j', module: 'derive4j-annotation'
|
||||
exclude group: 'com.hubspot.immutables', module: 'hubspot-style'
|
||||
exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings'
|
||||
}
|
||||
|
||||
api 'io.micrometer:micrometer-registry-prometheus'
|
||||
|
||||
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
|
||||
|
||||
+17
-38
@@ -8,14 +8,12 @@ import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
@@ -26,24 +24,18 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Dispatchable tool that converts an AI-generated document model to a PDF via WeasyPrint.
|
||||
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
|
||||
*
|
||||
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
|
||||
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as
|
||||
* structured fields; the HTML is built here from a fixed template.
|
||||
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
|
||||
* template so sanitization is intentionally skipped.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@@ -56,9 +48,6 @@ public class CreatePdfAgentController {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiDocumentHtmlRenderer htmlRenderer;
|
||||
|
||||
/**
|
||||
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
|
||||
@@ -85,42 +74,32 @@ public class CreatePdfAgentController {
|
||||
value = "/create-pdf-from-html-agent",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Convert an AI-generated document to a PDF",
|
||||
summary = "Convert AI-generated HTML to a PDF",
|
||||
description =
|
||||
"Accepts a structured document as a JSON parameter and returns a PDF. This"
|
||||
+ " endpoint is dispatched by the AI workflow orchestrator as a plan"
|
||||
+ " step; it is not intended for direct client use.")
|
||||
public ResponseEntity<Resource> createPdf(
|
||||
@RequestParam("document") String document, @RequestParam("filename") String filename)
|
||||
"Accepts an HTML document as a plain-text parameter and returns a PDF."
|
||||
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
|
||||
+ " plan step; it is not intended for direct client use.")
|
||||
public ResponseEntity<Resource> createPdfFromHtml(
|
||||
@RequestParam("htmlContent") String htmlContent,
|
||||
@RequestParam("filename") String filename)
|
||||
throws Exception {
|
||||
|
||||
if (!applicationProperties.getAiEngine().isEnabled()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
AiDocument model;
|
||||
try {
|
||||
model = objectMapper.readValue(document, AiDocument.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
String html = htmlRenderer.render(model);
|
||||
|
||||
log.info(
|
||||
"[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}",
|
||||
html.length());
|
||||
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
|
||||
htmlContent.length());
|
||||
|
||||
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
|
||||
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
|
||||
Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8);
|
||||
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getWeasyPrintPath());
|
||||
command.add("-e");
|
||||
command.add("utf-8");
|
||||
command.add("-v");
|
||||
// SSRF: the HTML is self-contained and the engine validates style colours, so no
|
||||
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
|
||||
command.add(htmlFile.getAbsolutePath());
|
||||
command.add(pdfFile.getAbsolutePath());
|
||||
|
||||
@@ -147,8 +126,8 @@ public class CreatePdfAgentController {
|
||||
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
|
||||
// which matters for large generated documents.
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
pdDocument.save(tempOut.getPath().toFile());
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
document.save(tempOut.getPath().toFile());
|
||||
} catch (Exception e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.ai.create;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AiDocument {
|
||||
|
||||
private String title;
|
||||
private String subtitle;
|
||||
private String referenceNumber;
|
||||
private Style style;
|
||||
private List<Section> sections;
|
||||
|
||||
@Data
|
||||
public static class Style {
|
||||
private String primaryColor;
|
||||
private String backgroundColor;
|
||||
private String bodyTextColor;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Section {
|
||||
private String type;
|
||||
private String heading;
|
||||
private String body;
|
||||
private List<List<String>> pairs;
|
||||
private List<String> columns;
|
||||
private List<List<String>> rows;
|
||||
private List<String> totalRow;
|
||||
private List<String> items;
|
||||
private List<String> signatories;
|
||||
}
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.hubspot.jinjava.Jinjava;
|
||||
import com.hubspot.jinjava.JinjavaConfig;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
|
||||
/** Renders an {@link AiDocument} to HTML using a Jinja template loaded from the classpath. */
|
||||
@Component
|
||||
public class AiDocumentHtmlRenderer {
|
||||
|
||||
private static final String TEMPLATE_PATH = "templates/ai/create/document.html.jinja2";
|
||||
|
||||
private static final Pattern SAFE_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$");
|
||||
|
||||
private final Jinjava jinjava;
|
||||
private final String template;
|
||||
|
||||
public AiDocumentHtmlRenderer() {
|
||||
JinjavaConfig config =
|
||||
JinjavaConfig.newBuilder().withNestedInterpretationEnabled(false).build();
|
||||
this.jinjava = new Jinjava(config);
|
||||
this.template = loadTemplate();
|
||||
}
|
||||
|
||||
public String render(AiDocument doc) {
|
||||
return jinjava.render(template, buildContext(doc));
|
||||
}
|
||||
|
||||
private static Map<String, Object> buildContext(AiDocument doc) {
|
||||
Map<String, Object> context = new LinkedHashMap<>();
|
||||
context.put("title", doc.getTitle());
|
||||
context.put("subtitle", doc.getSubtitle());
|
||||
context.put("reference_number", doc.getReferenceNumber());
|
||||
|
||||
AiDocument.Style style = doc.getStyle();
|
||||
if (style != null) {
|
||||
context.put("style_primary", safeColor(style.getPrimaryColor()));
|
||||
context.put("style_background", safeColor(style.getBackgroundColor()));
|
||||
context.put("style_body", safeColor(style.getBodyTextColor()));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> sections = new ArrayList<>();
|
||||
if (doc.getSections() != null) {
|
||||
for (AiDocument.Section section : doc.getSections()) {
|
||||
if (section != null && section.getType() != null) {
|
||||
sections.add(buildSection(section));
|
||||
}
|
||||
}
|
||||
}
|
||||
context.put("sections", sections);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static Map<String, Object> buildSection(AiDocument.Section section) {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("type", section.getType());
|
||||
node.put("heading", section.getHeading());
|
||||
switch (section.getType()) {
|
||||
case "text" -> node.put("paragraphs", paragraphs(section.getBody()));
|
||||
case "key_value" -> node.put("pairs", pairs(section.getPairs()));
|
||||
case "line_items" -> {
|
||||
node.put("columns", orEmpty(section.getColumns()));
|
||||
node.put("rows", orEmptyRows(section.getRows()));
|
||||
node.put("total_row", emptyToNull(section.getTotalRow()));
|
||||
}
|
||||
case "bullet_list" -> node.put("items", orEmpty(section.getItems()));
|
||||
case "signature" -> node.put("signatories", orEmpty(section.getSignatories()));
|
||||
default -> {}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private static List<String> paragraphs(String body) {
|
||||
String text = body == null ? "" : body;
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String paragraph : text.split("\n\n")) {
|
||||
out.add(paragraph.replace("\n", " "));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<Map<String, String>> pairs(List<List<String>> pairs) {
|
||||
List<Map<String, String>> out = new ArrayList<>();
|
||||
if (pairs != null) {
|
||||
for (List<String> pair : pairs) {
|
||||
Map<String, String> node = new LinkedHashMap<>();
|
||||
node.put("label", pair.isEmpty() ? "" : pair.get(0));
|
||||
node.put("value", pair.size() < 2 ? "" : pair.get(1));
|
||||
out.add(node);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<String> orEmpty(List<String> values) {
|
||||
return values == null ? List.of() : values;
|
||||
}
|
||||
|
||||
private static List<List<String>> orEmptyRows(List<List<String>> rows) {
|
||||
return rows == null ? List.of() : rows;
|
||||
}
|
||||
|
||||
private static List<String> emptyToNull(List<String> values) {
|
||||
return values == null || values.isEmpty() ? null : values;
|
||||
}
|
||||
|
||||
private static String safeColor(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return SAFE_COLOR.matcher(trimmed).matches() ? trimmed : null;
|
||||
}
|
||||
|
||||
private static String loadTemplate() {
|
||||
try {
|
||||
return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -166,8 +166,8 @@ class PolicyExecutorTest {
|
||||
new PipelineStep(
|
||||
createPdf,
|
||||
Map.of(
|
||||
"document",
|
||||
"{\"title\":\"PO\",\"sections\":[]}",
|
||||
"htmlContent",
|
||||
"<p>hi</p>",
|
||||
"filename",
|
||||
"purchase-order.pdf"))),
|
||||
PolicyInputs.of(List.of()),
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
|
||||
class AiDocumentHtmlRendererTest {
|
||||
|
||||
private final AiDocumentHtmlRenderer renderer = new AiDocumentHtmlRenderer();
|
||||
|
||||
private static AiDocument.Section section(String type) {
|
||||
AiDocument.Section s = new AiDocument.Section();
|
||||
s.setType(type);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static AiDocument document(String title, List<AiDocument.Section> sections) {
|
||||
AiDocument doc = new AiDocument();
|
||||
doc.setTitle(title);
|
||||
doc.setSections(sections);
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersAllSectionTypes() {
|
||||
AiDocument.Section text = section("text");
|
||||
text.setBody("Some prose text.");
|
||||
AiDocument.Section kv = section("key_value");
|
||||
kv.setPairs(List.of(List.of("Key", "Value")));
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("A", "B"));
|
||||
items.setRows(List.of(List.of("1", "2")));
|
||||
AiDocument.Section bullets = section("bullet_list");
|
||||
bullets.setItems(List.of("item one"));
|
||||
AiDocument.Section sign = section("signature");
|
||||
sign.setSignatories(List.of("Alice"));
|
||||
|
||||
String html = renderer.render(document("All", List.of(text, kv, items, bullets, sign)));
|
||||
|
||||
assertTrue(html.contains("<!DOCTYPE html>"));
|
||||
assertTrue(html.contains("Some prose text."));
|
||||
assertTrue(html.contains("Key") && html.contains("Value"));
|
||||
assertTrue(html.contains("<th>"));
|
||||
assertTrue(html.contains("item one"));
|
||||
assertTrue(html.contains("Alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersMarkupCharactersAsText() {
|
||||
AiDocument.Section text = section("text");
|
||||
text.setBody("a <b>x</b> & y");
|
||||
|
||||
String html = renderer.render(document("Doc", List.of(text)));
|
||||
|
||||
assertFalse(html.contains("<b>"));
|
||||
assertTrue(html.contains("<b>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalRowRenderedWhenPresent() {
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("Item", "Total"));
|
||||
items.setRows(List.of(List.of("Widget", "$10")));
|
||||
items.setTotalRow(List.of("Total", "$10"));
|
||||
|
||||
assertTrue(
|
||||
renderer.render(document("Table", List.of(items)))
|
||||
.contains("<tr class=\"total-row\">"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalRowAbsentWhenNotProvided() {
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("Item"));
|
||||
items.setRows(List.of(List.of("Widget")));
|
||||
|
||||
assertFalse(
|
||||
renderer.render(document("Table", List.of(items)))
|
||||
.contains("<tr class=\"total-row\">"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersSubtitleAndReference() {
|
||||
AiDocument doc = document("My Doc", List.of());
|
||||
doc.setSubtitle("Subtitle Here");
|
||||
doc.setReferenceNumber("REF-42");
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertTrue(html.contains("Subtitle Here"));
|
||||
assertTrue(html.contains("REF-42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesHexColourOverride() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("#ff00ff");
|
||||
style.setBackgroundColor("#111111");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertTrue(html.contains("--color-primary: #ff00ff"));
|
||||
assertTrue(html.contains("--color-bg: #111111"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresColourWithDisallowedCharacters() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("rgb(255, 0, 0)");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertFalse(html.contains("rgb("));
|
||||
assertTrue(html.contains("<!DOCTYPE html>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresNonHexColour() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("magenta");
|
||||
style.setBackgroundColor("#fff");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertFalse(html.contains("--color-primary: magenta"));
|
||||
assertFalse(html.contains("--color-bg: #fff;"));
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,6 @@ ext {
|
||||
okhttpBomVersion = "5.3.2"
|
||||
gsonVersion = "2.14.0"
|
||||
guavaVersion = "33.6.0-jre"
|
||||
jinjavaVersion = "2.8.3"
|
||||
jackson2Version = "2.21.2"
|
||||
bucket4jVersion = "8.19.0"
|
||||
archunitVersion = "1.4.2"
|
||||
batikVersion = "1.19"
|
||||
@@ -224,13 +222,6 @@ subprojects {
|
||||
resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}"
|
||||
// CVE-2024-47554: commons-io DoS prevention
|
||||
resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}"
|
||||
// Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request older versions);
|
||||
// pin the family to a current release and keep modules aligned.
|
||||
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jackson2Version}"
|
||||
// Keep BouncyCastle modules aligned to avoid runtime linkage errors
|
||||
resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}"
|
||||
resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}"
|
||||
|
||||
@@ -5,6 +5,7 @@ description = "AI Document Engine"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.116.0",
|
||||
"jinja2>=3.1.0",
|
||||
"pgvector>=0.3.6",
|
||||
"psycopg[binary,pool]>=3.2",
|
||||
"pydantic>=2.0.0",
|
||||
|
||||
@@ -10,7 +10,7 @@ Flow:
|
||||
4. SectionWriterAgents (smart_model) run in parallel via asyncio.gather.
|
||||
Each returns a WrittenSections with fully populated DocumentSection objects.
|
||||
5. The assembler collects sections in plan order → GeneratedDocument.
|
||||
6. The assembled document is emitted as structured fields. The LLM never writes HTML.
|
||||
6. Jinja renders the document to HTML. The LLM never writes HTML.
|
||||
|
||||
The planner is split into two calls (meta then sections) so each LLM output schema
|
||||
stays small enough for grammar compilation on all model tiers including Haiku.
|
||||
@@ -22,7 +22,9 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.output import NativeOutput
|
||||
|
||||
@@ -49,6 +51,8 @@ from stirling.services import AppRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
# ── Token budget ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Conservative per-section token estimates mapped from planner-assigned depth.
|
||||
@@ -162,13 +166,10 @@ Analyse the user's request and produce a DocumentMeta with:
|
||||
document, if the user provides one. Leave empty if the user provides no such context.
|
||||
|
||||
- style_primary_color: accent and heading colour. Set ONLY when the user explicitly names a
|
||||
colour or colour scheme (e.g. "make it red", "use navy blue"). Express it as a 6-digit hex
|
||||
code in #RRGGBB format (map any named colour to its hex value yourself, e.g. "navy" →
|
||||
"#000080"). No other format is accepted. Leave null if no colour is stated.
|
||||
- style_background_color: page background colour, same #RRGGBB format. Set only if explicitly
|
||||
requested.
|
||||
- style_body_text_color: body text colour, same #RRGGBB format. Set only if explicitly
|
||||
requested.
|
||||
colour or colour scheme (e.g. "make it red", "use navy blue"). Use CSS named colours
|
||||
(e.g. "magenta", "navy", "crimson") or hex values. Leave null if no colour is stated.
|
||||
- style_background_color: page background colour. Set only if explicitly requested.
|
||||
- style_body_text_color: body text colour. Set only if explicitly requested.
|
||||
|
||||
- cannot_do_reason: set this ONLY when the request is not asking to create a document at all
|
||||
(e.g. a question, a greeting, an edit request to an existing document). Never set it
|
||||
@@ -298,6 +299,15 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str:
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_jinja_env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||
autoescape=True,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
def _safe_filename(title: str) -> str:
|
||||
slug = re.sub(r"[^\w\s-]", "", title.lower())
|
||||
slug = re.sub(r"[\s_-]+", "-", slug).strip("-")
|
||||
@@ -310,6 +320,7 @@ def _safe_filename(title: str) -> str:
|
||||
class PdfCreateAgent:
|
||||
def __init__(self, runtime: AppRuntime) -> None:
|
||||
self.runtime = runtime
|
||||
self._jinja_env = _build_jinja_env()
|
||||
|
||||
self._meta_planner: Agent[None, DocumentMeta] = Agent(
|
||||
model=runtime.smart_model,
|
||||
@@ -390,12 +401,14 @@ class PdfCreateAgent:
|
||||
sections=all_sections,
|
||||
)
|
||||
|
||||
# ── Phase 6: emit ──────────────────────────────────────────────────────
|
||||
# ── Phase 6: render ────────────────────────────────────────────────────
|
||||
logger.info("[pdf-create] phase 6/6: rendering HTML")
|
||||
html = self._render(doc)
|
||||
filename = _safe_filename(plan.title)
|
||||
logger.info(
|
||||
"[pdf-create] done — filename=%r sections=%d",
|
||||
"[pdf-create] done — filename=%r html_bytes=%d",
|
||||
filename,
|
||||
len(all_sections),
|
||||
len(html),
|
||||
)
|
||||
|
||||
return EditPlanResponse(
|
||||
@@ -404,7 +417,7 @@ class PdfCreateAgent:
|
||||
ToolOperationStep(
|
||||
tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT,
|
||||
parameters=CreatePdfFromHtmlAgentParams(
|
||||
document=doc.model_dump_json(),
|
||||
html_content=html,
|
||||
filename=filename,
|
||||
),
|
||||
)
|
||||
@@ -424,3 +437,7 @@ class PdfCreateAgent:
|
||||
len(result.output.sections),
|
||||
)
|
||||
return result.output
|
||||
|
||||
def _render(self, doc: GeneratedDocument) -> str:
|
||||
template = self._jinja_env.get_template("document.html.jinja2")
|
||||
return template.render(doc=doc)
|
||||
|
||||
+19
-21
@@ -1,4 +1,3 @@
|
||||
{%- autoescape true -%}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -176,18 +175,18 @@
|
||||
color: var(--color-label);
|
||||
}
|
||||
</style>
|
||||
{%- if style_primary or style_background or style_body %}
|
||||
{%- if doc.style %}
|
||||
<style>
|
||||
:root {
|
||||
{%- if style_primary %}
|
||||
--color-primary: {{ style_primary }};
|
||||
{%- if doc.style.primary_color %}
|
||||
--color-primary: {{ doc.style.primary_color }};
|
||||
{%- endif %}
|
||||
{%- if style_background %}
|
||||
--color-bg: {{ style_background }};
|
||||
{%- if doc.style.background_color %}
|
||||
--color-bg: {{ doc.style.background_color }};
|
||||
{%- endif %}
|
||||
{%- if style_body %}
|
||||
--color-body: {{ style_body }};
|
||||
--color-label: {{ style_body }};
|
||||
{%- if doc.style.body_text_color %}
|
||||
--color-body: {{ doc.style.body_text_color }};
|
||||
--color-label: {{ doc.style.body_text_color }};
|
||||
{%- endif %}
|
||||
}
|
||||
</style>
|
||||
@@ -196,16 +195,16 @@
|
||||
<body>
|
||||
|
||||
<div class="doc-header">
|
||||
<div class="doc-title">{{ title }}</div>
|
||||
{%- if subtitle %}
|
||||
<div class="doc-subtitle">{{ subtitle }}</div>
|
||||
<div class="doc-title">{{ doc.title }}</div>
|
||||
{%- if doc.subtitle %}
|
||||
<div class="doc-subtitle">{{ doc.subtitle }}</div>
|
||||
{%- endif %}
|
||||
{%- if reference_number %}
|
||||
<div class="doc-reference">{{ reference_number }}</div>
|
||||
{%- if doc.reference_number %}
|
||||
<div class="doc-reference">{{ doc.reference_number }}</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
{%- for section in sections %}
|
||||
{%- for section in doc.sections %}
|
||||
|
||||
{%- if section.type == "text" %}
|
||||
<section>
|
||||
@@ -213,8 +212,8 @@
|
||||
<h2>{{ section.heading }}</h2>
|
||||
{%- endif %}
|
||||
<div class="text-body">
|
||||
{%- for para in section.paragraphs %}
|
||||
<p>{{ para }}</p>
|
||||
{%- for para in section.body.split('\n\n') %}
|
||||
<p>{{ para | replace('\n', ' ') }}</p>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -226,10 +225,10 @@
|
||||
{%- endif %}
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{%- for pair in section.pairs %}
|
||||
{%- for label, value in section.pairs %}
|
||||
<tr>
|
||||
<td class="kv-label">{{ pair.label }}</td>
|
||||
<td class="kv-value">{{ pair.value }}</td>
|
||||
<td class="kv-label">{{ label }}</td>
|
||||
<td class="kv-value">{{ value }}</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
@@ -300,4 +299,3 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{%- endautoescape %}
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Contracts for the PDF Create Agent.
|
||||
|
||||
The agent accepts a natural-language prompt and returns a single
|
||||
CREATE_PDF_FROM_HTML_AGENT plan step carrying the assembled document.
|
||||
CREATE_PDF_FROM_HTML_AGENT plan step carrying the rendered HTML.
|
||||
|
||||
Pipeline:
|
||||
1. PlannerAgent (smart_model) → DocumentPlan: structured skeleton, no body text.
|
||||
2. Python chunks the plan by token budget.
|
||||
3. SectionWriterAgents (smart_model, parallel) → WrittenSections per chunk.
|
||||
4. Assembler collects sections in plan order → GeneratedDocument.
|
||||
5. The document is emitted as structured fields. The LLM never writes HTML.
|
||||
5. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -81,12 +81,14 @@ type DocumentSection = Annotated[
|
||||
]
|
||||
|
||||
|
||||
# Colours must be a 6-digit hex code (#RRGGBB); anything else is dropped to None.
|
||||
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
# Named colour or hex only — anything else is dropped so a colour can't inject CSS into the
|
||||
# <style> block (which would let WeasyPrint fetch an attacker-controlled url() → SSRF).
|
||||
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]{1,30}$")
|
||||
|
||||
|
||||
class DocumentStyle(ApiModel):
|
||||
"""Document colours, inferred by the meta planner. Non-hex values are dropped to ``None``."""
|
||||
"""Document colours, inferred by the meta planner and rendered into the engine's Jinja
|
||||
template (never sent to Java). Unsafe colours are dropped to ``None``."""
|
||||
|
||||
primary_color: str | None = Field(default=None)
|
||||
background_color: str | None = Field(default=None)
|
||||
@@ -101,7 +103,7 @@ class DocumentStyle(ApiModel):
|
||||
|
||||
|
||||
class GeneratedDocument(ApiModel):
|
||||
"""The full document model emitted for rendering."""
|
||||
"""The full document model passed to Jinja for HTML rendering."""
|
||||
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
|
||||
@@ -29,7 +29,7 @@ class PdfCommentAgentParams(ApiModel):
|
||||
|
||||
|
||||
class CreatePdfFromHtmlAgentParams(ApiModel):
|
||||
document: str
|
||||
html_content: str
|
||||
filename: str = Field(pattern=r"^.+\.pdf$")
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Coverage:
|
||||
1. Section model validation (each section type round-trips correctly)
|
||||
2. orchestrate() emits the assembled document as structured JSON
|
||||
2. Jinja rendering (_render produces valid HTML for each section type)
|
||||
3. _safe_filename produces clean slugs
|
||||
4. _make_chunks groups sections correctly by token budget
|
||||
5. orchestrate() produces the correct EditPlanResponse via planner + writer mocks
|
||||
@@ -12,8 +12,6 @@ Coverage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from conftest import build_app_settings
|
||||
from pydantic_ai.models.test import TestModel
|
||||
@@ -66,6 +64,37 @@ def agent(runtime: AppRuntime) -> PdfCreateAgent:
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _invoice_doc() -> GeneratedDocument:
|
||||
return GeneratedDocument(
|
||||
title="Invoice",
|
||||
subtitle="Acme Corp",
|
||||
reference_number="Invoice #INV-001",
|
||||
sections=[
|
||||
KeyValueSection(
|
||||
heading="Details",
|
||||
pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06"), ("Currency", "USD")],
|
||||
),
|
||||
LineItemsSection(
|
||||
heading="Line Items",
|
||||
columns=["Description", "Qty", "Unit Price", "Total"],
|
||||
rows=[
|
||||
["Consulting services", "10", "$500.00", "$5,000.00"],
|
||||
["Expenses", "1", "$200.00", "$200.00"],
|
||||
],
|
||||
total_row=["Total", "", "", "$5,200.00"],
|
||||
),
|
||||
TextSection(
|
||||
heading="Payment Terms",
|
||||
body="Payment is due within 30 days.\n\nPlease reference the invoice number.",
|
||||
),
|
||||
SignatureSection(
|
||||
heading="Authorised By",
|
||||
signatories=["Jane Smith, CEO", "Bob Jones, CFO"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _simple_meta() -> DocumentMeta:
|
||||
return DocumentMeta(
|
||||
title="Invoice",
|
||||
@@ -170,6 +199,82 @@ def test_generated_document_optional_fields() -> None:
|
||||
assert doc.reference_number is None
|
||||
|
||||
|
||||
# ── Jinja rendering ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_render_produces_html(agent: PdfCreateAgent) -> None:
|
||||
doc = _invoice_doc()
|
||||
html = agent._render(doc)
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "Invoice" in html
|
||||
assert "INV-001" in html
|
||||
|
||||
|
||||
def test_render_includes_all_section_types(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="All Sections",
|
||||
sections=[
|
||||
TextSection(body="Some prose text."),
|
||||
KeyValueSection(pairs=[("Key", "Value")]),
|
||||
LineItemsSection(columns=["A", "B"], rows=[["1", "2"]]),
|
||||
BulletListSection(items=["item one"]),
|
||||
SignatureSection(signatories=["Alice"]),
|
||||
],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "Some prose text." in html
|
||||
assert "Key" in html and "Value" in html
|
||||
assert "<th>" in html
|
||||
assert "item one" in html
|
||||
assert "Alice" in html
|
||||
|
||||
|
||||
def test_render_escapes_html_in_content(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="XSS Test",
|
||||
sections=[TextSection(body="<script>alert('xss')</script>")],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "<script>" not in html
|
||||
assert "<script>" in html
|
||||
|
||||
|
||||
def test_render_total_row_present(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="Table",
|
||||
sections=[
|
||||
LineItemsSection(
|
||||
columns=["Item", "Total"],
|
||||
rows=[["Widget", "$10"]],
|
||||
total_row=["Total", "$10"],
|
||||
)
|
||||
],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "total-row" in html
|
||||
|
||||
|
||||
def test_render_no_total_row_skips_tfoot(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="Table",
|
||||
sections=[LineItemsSection(columns=["Item"], rows=[["Widget"]])],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "<tfoot>" not in html
|
||||
|
||||
|
||||
def test_render_subtitle_and_reference(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="My Doc",
|
||||
subtitle="Subtitle Here",
|
||||
reference_number="REF-42",
|
||||
sections=[TextSection(body="Content.")],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "Subtitle Here" in html
|
||||
assert "REF-42" in html
|
||||
|
||||
|
||||
# ── _safe_filename ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -291,9 +396,8 @@ async def test_orchestrate_returns_plan_step(agent: PdfCreateAgent) -> None:
|
||||
assert step.tool == AgentToolId.CREATE_PDF_FROM_HTML_AGENT
|
||||
assert isinstance(step.parameters, CreatePdfFromHtmlAgentParams)
|
||||
assert step.parameters.filename.endswith(".pdf")
|
||||
parsed = json.loads(step.parameters.document)
|
||||
assert parsed["title"] == "Invoice"
|
||||
assert parsed["sections"]
|
||||
assert "<!DOCTYPE html>" in step.parameters.html_content
|
||||
assert "Invoice" in step.parameters.html_content
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -364,9 +468,9 @@ async def test_orchestrate_assembles_multiple_chunks(agent: PdfCreateAgent) -> N
|
||||
result = await agent.orchestrate(_orchestrator_request("Create a multi-chunk doc"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "Introduction text." in document
|
||||
assert "Details" in document
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "Introduction text." in html
|
||||
assert "Details" in html
|
||||
|
||||
|
||||
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
|
||||
@@ -378,7 +482,7 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
|
||||
meta = DocumentMeta(
|
||||
title="Styled Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="#ff00ff",
|
||||
style_primary_color="magenta",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -395,35 +499,50 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
|
||||
result = await agent.orchestrate(_orchestrator_request("Make an invoice, magenta styling"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert json.loads(document)["style"]["primaryColor"] == "#ff00ff"
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "magenta" in html
|
||||
|
||||
|
||||
def test_document_style_keeps_only_six_digit_hex() -> None:
|
||||
"""Only #RRGGBB hex is kept; named colours and other formats drop to None."""
|
||||
safe = DocumentStyle(primary_color="#1e3a5f", background_color="#ffffff", body_text_color="#1A1A1A")
|
||||
def test_render_applies_style(agent: PdfCreateAgent) -> None:
|
||||
"""DocumentStyle fields are injected as CSS custom properties in the rendered HTML."""
|
||||
doc = GeneratedDocument(
|
||||
title="Styled",
|
||||
sections=[TextSection(body="Content.")],
|
||||
style=DocumentStyle(primary_color="magenta", background_color="#111111"),
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "--color-primary: magenta" in html
|
||||
assert "--color-bg: #111111" in html
|
||||
|
||||
|
||||
def test_document_style_drops_unsafe_colors() -> None:
|
||||
"""Unsafe colours (not named/hex) are dropped, closing the <style> url() injection."""
|
||||
safe = DocumentStyle(primary_color="navy", background_color="#1e3a5f", body_text_color="#fff")
|
||||
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
|
||||
"navy",
|
||||
"#1e3a5f",
|
||||
"#ffffff",
|
||||
"#1A1A1A",
|
||||
"#fff",
|
||||
)
|
||||
|
||||
assert DocumentStyle(primary_color="navy").primary_color is None
|
||||
assert DocumentStyle(primary_color="#fff").primary_color is None
|
||||
assert DocumentStyle(primary_color="#1e3a5f00").primary_color is None
|
||||
assert DocumentStyle(primary_color="rgb(255, 0, 0)").primary_color is None
|
||||
assert DocumentStyle(background_color="teal darken-2").background_color is None
|
||||
unsafe = DocumentStyle(
|
||||
primary_color="red; background: url(http://evil.test/steal)",
|
||||
background_color="expression(alert(1))",
|
||||
body_text_color="navy; }",
|
||||
)
|
||||
assert unsafe.primary_color is None
|
||||
assert unsafe.background_color is None
|
||||
assert unsafe.body_text_color is None
|
||||
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
|
||||
assert DocumentStyle(primary_color="#1e3a5f\n").primary_color is None
|
||||
assert DocumentStyle(primary_color="navy\n").primary_color is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_drops_non_hex_planner_colour(agent: PdfCreateAgent) -> None:
|
||||
"""A non-hex colour inferred by the meta planner is dropped before the document is emitted."""
|
||||
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
|
||||
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
|
||||
meta = DocumentMeta(
|
||||
title="Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="rgb(0, 0, 255)",
|
||||
style_primary_color="blue; background: url(http://evil.test/)",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -440,6 +559,6 @@ async def test_orchestrate_drops_non_hex_planner_colour(agent: PdfCreateAgent) -
|
||||
result = await agent.orchestrate(_orchestrator_request("make it blue"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "rgb(" not in document
|
||||
assert json.loads(document)["style"]["primaryColor"] is None
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "evil.test" not in html
|
||||
assert "url(" not in html
|
||||
|
||||
Generated
+2
@@ -604,6 +604,7 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "pgvector" },
|
||||
{ name = "posthog" },
|
||||
@@ -630,6 +631,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.116.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
|
||||
{ name = "pgvector", specifier = ">=0.3.6" },
|
||||
{ name = "posthog", specifier = ">=3.0.0" },
|
||||
|
||||
@@ -10412,9 +10412,6 @@ disableColorFilter = "Disable Color Filter"
|
||||
dualPageView = "Dual Page View"
|
||||
enableDarkFilter = "Enable Dark Filter"
|
||||
enableSepiaFilter = "Enable Sepia Filter"
|
||||
engineLoadErrorBody = "The PDF engine couldn't be loaded. If this keeps happening, make sure the app is up to date, then try again."
|
||||
engineLoadErrorRetry = "Retry"
|
||||
engineLoadErrorTitle = "Couldn't load the PDF viewer"
|
||||
firstPage = "First Page"
|
||||
lastPage = "Last Page"
|
||||
moreOptions = "More"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,101 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, act, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Controllable stand-in for the library engine hook so we can simulate the
|
||||
// three states that matter: still loading, loaded, and errored.
|
||||
let engineState: { engine: unknown; isLoading: boolean; error: Error | null } =
|
||||
{
|
||||
engine: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
vi.mock("@embedpdf/engines/react", () => ({
|
||||
usePdfiumEngine: () => engineState,
|
||||
}));
|
||||
|
||||
import { PdfEngineBoundary } from "@app/components/viewer/PdfEngineBoundary";
|
||||
|
||||
const renderBoundary = (onRetry = vi.fn(), timeoutMs = 1000) => {
|
||||
const utils = render(
|
||||
<MantineProvider>
|
||||
<PdfEngineBoundary
|
||||
wasmUrl="pdfium.wasm"
|
||||
onRetry={onRetry}
|
||||
timeoutMs={timeoutMs}
|
||||
>
|
||||
{() => <div>PDF CONTENT</div>}
|
||||
</PdfEngineBoundary>
|
||||
</MantineProvider>,
|
||||
);
|
||||
return { onRetry, ...utils };
|
||||
};
|
||||
|
||||
const wrap = (node: ReactNode) => <MantineProvider>{node}</MantineProvider>;
|
||||
|
||||
describe("PdfEngineBoundary", () => {
|
||||
beforeEach(() => {
|
||||
engineState = { engine: null, isLoading: true, error: null };
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows the loading fallback while the engine is initialising", () => {
|
||||
renderBoundary();
|
||||
expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("viewer.engineLoadErrorTitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces an error with a retry after the load times out (no infinite spinner)", () => {
|
||||
const { onRetry } = renderBoundary(vi.fn(), 1000);
|
||||
|
||||
// Still spinning before the timeout elapses.
|
||||
expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// The spinner is replaced by an actionable error state.
|
||||
expect(screen.queryByText("Loading PDF Engine...")).toBeNull();
|
||||
expect(screen.getByText("viewer.engineLoadErrorTitle")).toBeInTheDocument();
|
||||
|
||||
const retry = screen.getByText("viewer.engineLoadErrorRetry");
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces an error immediately when the engine load rejects", () => {
|
||||
engineState = {
|
||||
engine: null,
|
||||
isLoading: false,
|
||||
error: new Error("boom"),
|
||||
};
|
||||
render(
|
||||
wrap(
|
||||
<PdfEngineBoundary wasmUrl="pdfium.wasm" onRetry={vi.fn()}>
|
||||
{() => <div>PDF CONTENT</div>}
|
||||
</PdfEngineBoundary>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("viewer.engineLoadErrorTitle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders children once the engine is ready", () => {
|
||||
engineState = { engine: {}, isLoading: false, error: null };
|
||||
render(
|
||||
wrap(
|
||||
<PdfEngineBoundary wasmUrl="pdfium.wasm" onRetry={vi.fn()}>
|
||||
{() => <div>PDF CONTENT</div>}
|
||||
</PdfEngineBoundary>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("PDF CONTENT")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading PDF Engine...")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { usePdfiumEngine } from "@embedpdf/engines/react";
|
||||
import { Center, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback";
|
||||
|
||||
/** Engine instance produced by usePdfiumEngine once initialisation succeeds. */
|
||||
type PdfiumEngine = NonNullable<ReturnType<typeof usePdfiumEngine>["engine"]>;
|
||||
|
||||
/**
|
||||
* How long to wait for the PDFium WASM engine to initialise before treating the
|
||||
* load as failed. The engine normally initialises in well under a second; if it
|
||||
* has not resolved after this window it has almost certainly hung (e.g. the WASM
|
||||
* fetch stalled or the worker never reported back), so we surface an error with
|
||||
* a retry instead of spinning forever.
|
||||
*/
|
||||
const DEFAULT_ENGINE_LOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface PdfEngineBoundaryProps {
|
||||
/** Absolute URL of the pdfium.wasm binary to load. */
|
||||
wasmUrl: string;
|
||||
/** Invoked when the user asks to retry after a failed/hung load. */
|
||||
onRetry: () => void;
|
||||
/** Override the load timeout (mainly for tests). */
|
||||
timeoutMs?: number;
|
||||
/** Rendered once the engine is ready. */
|
||||
children: (engine: PdfiumEngine) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the PDFium WASM engine and gates its children on success.
|
||||
*
|
||||
* The underlying `usePdfiumEngine` hook only re-initialises when its `wasmUrl`
|
||||
* changes, so retrying is handled by the parent remounting this component via a
|
||||
* `key`. Because the boundary owns the hook, a fresh mount runs the load again
|
||||
* from scratch.
|
||||
*
|
||||
* Without this boundary a failed or hung WASM load left the viewer showing an
|
||||
* infinite "Loading PDF Engine..." spinner with no error and no way to recover.
|
||||
*/
|
||||
export function PdfEngineBoundary({
|
||||
wasmUrl,
|
||||
onRetry,
|
||||
timeoutMs = DEFAULT_ENGINE_LOAD_TIMEOUT_MS,
|
||||
children,
|
||||
}: PdfEngineBoundaryProps) {
|
||||
const { t } = useTranslation();
|
||||
const { engine, isLoading, error } = usePdfiumEngine({ wasmUrl });
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (engine || error) return;
|
||||
const timer = setTimeout(() => setTimedOut(true), timeoutMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [engine, error, isLoading, timeoutMs]);
|
||||
|
||||
if (engine) {
|
||||
return <>{children(engine)}</>;
|
||||
}
|
||||
|
||||
if (error || timedOut) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md" style={{ maxWidth: 420, padding: 16 }}>
|
||||
<div style={{ fontSize: "32px" }}>⚠️</div>
|
||||
<Text fw={600} size="md" style={{ textAlign: "center" }}>
|
||||
{t("viewer.engineLoadErrorTitle")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" style={{ textAlign: "center" }}>
|
||||
{t("viewer.engineLoadErrorBody")}
|
||||
</Text>
|
||||
<Button onClick={onRetry} variant="primary">
|
||||
{t("viewer.engineLoadErrorRetry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <ToolLoadingFallback toolName="PDF Engine" />;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
deserializeToolStep,
|
||||
getExecutableTools,
|
||||
serializeStepFromEndpoint,
|
||||
serializeToolStep,
|
||||
stepRequiresUpload,
|
||||
type WorkingToolStep,
|
||||
@@ -198,6 +199,41 @@ describe("serialize/deserialize round-trip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeStepFromEndpoint", () => {
|
||||
test("maps a wizard step's UI params to the backend contract, filling defaults", () => {
|
||||
// The shape the policy setup wizard holds: an endpoint plus UI-shaped params
|
||||
// (redact's `wordsToRedact`), with several fields left to their defaults.
|
||||
const api = serializeStepFromEndpoint(
|
||||
"/api/v1/security/auto-redact",
|
||||
{ mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] },
|
||||
dynamicRegistry,
|
||||
);
|
||||
|
||||
expect(api.operation).toBe("/api/v1/security/auto-redact");
|
||||
// wordsToRedact -> listOfText (the field the backend actually reads), and the
|
||||
// frontend-only `mode` is dropped.
|
||||
expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" });
|
||||
expect(api.parameters).not.toHaveProperty("wordsToRedact");
|
||||
expect(api.parameters).not.toHaveProperty("mode");
|
||||
// Fields the wizard never set still get their defaults so the body is complete.
|
||||
expect(api.parameters).toHaveProperty("wholeWordSearch");
|
||||
expect(api.parameters).toHaveProperty("customPadding");
|
||||
});
|
||||
|
||||
test("passes an unmapped endpoint's params through unchanged", () => {
|
||||
expect(
|
||||
serializeStepFromEndpoint(
|
||||
"/api/v1/unknown/thing",
|
||||
{ keep: true },
|
||||
dynamicRegistry,
|
||||
),
|
||||
).toEqual({
|
||||
operation: "/api/v1/unknown/thing",
|
||||
parameters: { keep: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepRequiresUpload", () => {
|
||||
const step = (params: Record<string, unknown>): WorkingToolStep => ({
|
||||
toolId: "compress" as ToolId,
|
||||
|
||||
@@ -197,6 +197,31 @@ export function serializeToolStep(
|
||||
return { operation, parameters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup
|
||||
* wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than
|
||||
* the backend contract - into the backend step contract, mapping params through the tool's
|
||||
* `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults).
|
||||
* The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config
|
||||
* endpoint is a function. Endpoints that map to no known tool pass through unchanged.
|
||||
*/
|
||||
export function serializeStepFromEndpoint(
|
||||
operation: string,
|
||||
params: ErasedToolParams,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ToolApiStep {
|
||||
const match = findToolByEndpoint({ operation, parameters: params }, registry);
|
||||
const config = match?.[1].operationConfig;
|
||||
if (!config) return { operation, parameters: params };
|
||||
const merged = { ...(config.defaultParameters ?? {}), ...params };
|
||||
return {
|
||||
operation: resolveEndpoint(config, merged) ?? operation,
|
||||
parameters: config.toApiParams
|
||||
? (config.toApiParams(merged) as Record<string, unknown>)
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
|
||||
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
|
||||
|
||||
interface Params {
|
||||
a: number;
|
||||
}
|
||||
|
||||
// A minimal config that type-checks against the flatten endpoint's model.
|
||||
const CONFIG = {
|
||||
endpoint: "/api/v1/misc/flatten" as const,
|
||||
defaultParameters: { a: 1 } satisfies Params,
|
||||
toApiParams: (p: Params) => ({ renderDpi: p.a }),
|
||||
fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }),
|
||||
};
|
||||
|
||||
describe("describeToolOperation", () => {
|
||||
test("wraps the config's mappers and endpoint into a descriptor", () => {
|
||||
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
|
||||
expect(d.endpoint).toBe("/api/v1/misc/flatten");
|
||||
expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 });
|
||||
});
|
||||
|
||||
test("fromApi merges the mapped values over the defaults", () => {
|
||||
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
|
||||
expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 });
|
||||
});
|
||||
|
||||
test("throws when the config lacks a mapper", () => {
|
||||
expect(() =>
|
||||
describeToolOperation("/api/v1/misc/flatten", {
|
||||
endpoint: "/api/v1/misc/flatten" as const,
|
||||
defaultParameters: { a: 1 },
|
||||
toApiParams: (p: Params) => ({ renderDpi: p.a }),
|
||||
}),
|
||||
).toThrow(/mappers/);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe
|
||||
* frontend<->backend parameter conversion.
|
||||
*/
|
||||
|
||||
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
|
||||
export interface ToolOperationDescriptor<E extends ToolEndpoint, TParams> {
|
||||
readonly endpoint: E;
|
||||
readonly defaultParameters: TParams;
|
||||
toApi(params: TParams): ToolApiParams[E];
|
||||
/** Backend model -> full frontend params (defaults merged under the mapped values). */
|
||||
fromApi(api: ToolApiParams[E]): TParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from
|
||||
* the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for
|
||||
* dynamic-endpoint tools (whose endpoint is a function typed against the union).
|
||||
*/
|
||||
export interface BidirectionalToolConfig<TParams, CE extends ToolEndpoint> {
|
||||
endpoint: CE | null | ((params: TParams) => CE | null);
|
||||
defaultParameters?: TParams;
|
||||
toApiParams?(params: TParams): ToolApiParams[CE];
|
||||
fromApiParams?(api: ToolApiParams[CE]): Partial<TParams>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as
|
||||
* a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while
|
||||
* allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing.
|
||||
*/
|
||||
export function describeToolOperation<
|
||||
E extends CE,
|
||||
CE extends ToolEndpoint,
|
||||
TParams,
|
||||
>(
|
||||
endpoint: E,
|
||||
config: BidirectionalToolConfig<TParams, CE>,
|
||||
): ToolOperationDescriptor<E, TParams> {
|
||||
const { toApiParams, fromApiParams, defaultParameters } = config;
|
||||
if (!toApiParams || !fromApiParams || defaultParameters === undefined) {
|
||||
throw new Error(
|
||||
`describeToolOperation: "${endpoint}" is missing mappers or defaults`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
endpoint,
|
||||
defaultParameters,
|
||||
// A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the
|
||||
// runtime mapper produces this endpoint's model).
|
||||
toApi: (params) => toApiParams(params) as ToolApiParams[E],
|
||||
fromApi: (api) =>
|
||||
({
|
||||
...defaultParameters,
|
||||
...fromApiParams(api as ToolApiParams[CE]),
|
||||
}) as TParams,
|
||||
};
|
||||
}
|
||||
@@ -28,53 +28,24 @@ export const pdfiumWasmModulePromise = new Promise<WebAssembly.Module | null>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Compile the WASM without streaming by fetching the whole binary first.
|
||||
*
|
||||
* `compileStreaming` requires the response to be served with the
|
||||
* `application/wasm` MIME type and no incompatible `Content-Encoding`. Under the
|
||||
* `tauri://` asset protocol (and behind some proxies) those headers aren't
|
||||
* guaranteed, which makes streaming compilation throw. Fetching the bytes and
|
||||
* compiling them directly sidesteps the MIME/encoding requirement entirely.
|
||||
*/
|
||||
async function compileFromArrayBuffer(): Promise<WebAssembly.Module | null> {
|
||||
try {
|
||||
const response = await fetch(pdfiumWasmUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected response ${response.status} for pdfium.wasm`);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
return await WebAssembly.compile(bytes);
|
||||
} catch (err) {
|
||||
console.warn("Eager WASM ArrayBuffer compilation failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function startEagerWasmCompilation(): void {
|
||||
if (compilationStarted) return;
|
||||
compilationStarted = true;
|
||||
|
||||
if (typeof WebAssembly !== "object") {
|
||||
resolvePromise(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer streaming compilation, but fall back to fetching the bytes and
|
||||
// compiling them directly when streaming isn't available or fails (e.g. wrong
|
||||
// MIME type / content-encoding under the tauri:// protocol). Resolving null on
|
||||
// total failure lets pdfiumService fall back to its own instantiation path.
|
||||
if (typeof WebAssembly.compileStreaming === "function") {
|
||||
if (
|
||||
typeof WebAssembly === "object" &&
|
||||
typeof WebAssembly.compileStreaming === "function"
|
||||
) {
|
||||
WebAssembly.compileStreaming(fetch(pdfiumWasmUrl))
|
||||
.then(resolvePromise)
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
"Eager WASM streaming compilation failed, falling back to ArrayBuffer:",
|
||||
"Eager WASM compilation failed or not supported in this environment:",
|
||||
err,
|
||||
);
|
||||
compileFromArrayBuffer().then(resolvePromise);
|
||||
resolvePromise(null);
|
||||
});
|
||||
} else {
|
||||
compileFromArrayBuffer().then(resolvePromise);
|
||||
resolvePromise(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { TFunction } from "i18next";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
|
||||
import { runsToActivity, runsToStats } from "@app/policies/runs";
|
||||
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
|
||||
import type { ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
import type {
|
||||
PolicyDecodedState,
|
||||
PolicyRunView,
|
||||
@@ -68,7 +66,7 @@ export interface PolicyConfigDef {
|
||||
rules: string[];
|
||||
scopeLabel: string;
|
||||
fields: PolicyField[];
|
||||
defaultOperations: PolicyToolStep[];
|
||||
defaultOperations: WirePipelineStep[];
|
||||
}
|
||||
|
||||
export interface PolicyState {
|
||||
@@ -130,11 +128,20 @@ export interface CatalogueEntry {
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoint display labels */
|
||||
/* Tool → endpoint registry */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
|
||||
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
|
||||
export const TOOL_ENDPOINTS: Record<string, string> = {
|
||||
redact: "/api/v1/security/auto-redact",
|
||||
sanitize: "/api/v1/security/sanitize-pdf",
|
||||
watermark: "/api/v1/security/add-watermark",
|
||||
ocr: "/api/v1/misc/ocr-pdf",
|
||||
flatten: "/api/v1/misc/flatten",
|
||||
compress: "/api/v1/misc/compress-pdf",
|
||||
};
|
||||
|
||||
/** Values are i18n keys — render with t(). */
|
||||
export const ENDPOINT_LABELS: Record<string, string> = {
|
||||
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
|
||||
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
|
||||
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
|
||||
@@ -147,8 +154,7 @@ export function humanizeEndpoint(
|
||||
path: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const label = ENDPOINT_LABELS[path as ToolEndpoint];
|
||||
if (label) return t(label);
|
||||
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
|
||||
const last = path.split("/").filter(Boolean).pop() ?? path;
|
||||
return last
|
||||
.replace(/-/g, " ")
|
||||
@@ -224,7 +230,10 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.ingestion.rules.3",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.ingestion.fields.minConfidence",
|
||||
@@ -251,16 +260,32 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
// Flatten to image so redactions can't be lifted off.
|
||||
policyStep("redact", {
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: DEFAULT_PII_PATTERNS,
|
||||
}),
|
||||
// JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off.
|
||||
policyStep("sanitize", { removeEmbeddedFiles: false }),
|
||||
// Bake in via image so it can't be stripped.
|
||||
policyStep("watermark", { convertPDFToImage: true }),
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.redact,
|
||||
parameters: {
|
||||
mode: "automatic",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: DEFAULT_PII_PATTERNS,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.sanitize,
|
||||
parameters: {
|
||||
removeJavaScript: true,
|
||||
removeEmbeddedFiles: false,
|
||||
removeMetadata: false,
|
||||
removeLinks: false,
|
||||
removeFonts: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.watermark,
|
||||
// convertPDFToImage bakes the watermark in so it can't be stripped
|
||||
parameters: {
|
||||
convertPDFToImage: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
@@ -272,7 +297,10 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.compliance.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.compliance.fields.frameworks",
|
||||
@@ -315,7 +343,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.routing.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("compress")],
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.routing.fields.destination",
|
||||
@@ -346,7 +374,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.retention.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("compress")],
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.retention.fields.keepFor",
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip
|
||||
// pulls in the Preferences + Sidebar contexts, which the portal does not mount
|
||||
// app-wide — so this reproduces the "usePreferences must be used within a
|
||||
// PreferencesProvider" crash unless PipelineStepSettings supplies them.
|
||||
function TooltipSettings() {
|
||||
return (
|
||||
<Tooltip content="help">
|
||||
<button type="button">field</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const step = {
|
||||
support: "editable",
|
||||
toolId: "compress",
|
||||
params: {},
|
||||
} as unknown as WorkingToolStep;
|
||||
|
||||
const registry = {
|
||||
compress: { automationSettings: TooltipSettings },
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
describe("PipelineStepSettings", () => {
|
||||
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<PipelineStepSettings
|
||||
step={step}
|
||||
registry={registry}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(screen.getByText("field")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner } from "@app/ui";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
|
||||
@@ -48,18 +46,14 @@ export function PipelineStepSettings({
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={step.params}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...step.params, [key]: value })
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</Suspense>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={step.params}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...step.params, [key]: value })
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
type CatalogueEntry,
|
||||
type DecoratedPolicy,
|
||||
type PolicySetupResult,
|
||||
type PipelineStep,
|
||||
} from "@portal/api/policies";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed
|
||||
// because the import graph pulls core/i18n.ts, which registers it as a plugin.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
// Second arg is a string fallback in some call sites and an interpolation object in others;
|
||||
// only treat a string as the fallback.
|
||||
t: (key: string, fallback?: unknown) =>
|
||||
typeof fallback === "string" ? fallback : key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: vi.fn() },
|
||||
}));
|
||||
|
||||
const fetchSources = vi.fn();
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
fetchSources: () => fetchSources(),
|
||||
}));
|
||||
|
||||
const CONTINUE = "portal.policies.wizard.actions.continue";
|
||||
const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges";
|
||||
const ENABLE = "portal.policies.wizard.actions.enablePolicy";
|
||||
|
||||
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
|
||||
const securityConfig = POLICY_CONFIG.security;
|
||||
|
||||
function editEntry(steps: PipelineStep[]): CatalogueEntry {
|
||||
const policy: DecoratedPolicy = {
|
||||
category: security,
|
||||
config: securityConfig,
|
||||
state: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
sources: ["editor"],
|
||||
scopeTypes: [],
|
||||
reviewerEmail: "",
|
||||
fieldValues: {},
|
||||
runOn: "upload",
|
||||
outputMode: "new_version",
|
||||
outputName: "",
|
||||
outputNamePosition: "suffix",
|
||||
maxRetries: 0,
|
||||
retryDelayMinutes: 0,
|
||||
backendId: "pol-1",
|
||||
isDefault: true,
|
||||
},
|
||||
steps,
|
||||
stats: { enforced: 0, dataProcessed: "-", activeFor: "-" },
|
||||
activity: [],
|
||||
};
|
||||
return { category: security, config: securityConfig, policy };
|
||||
}
|
||||
|
||||
/** Advance the wizard from the workflow tab to the settings tab and submit. */
|
||||
async function submitWizard(saveLabel: string) {
|
||||
fireEvent.click(await screen.findByRole("button", { name: CONTINUE }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: saveLabel }));
|
||||
}
|
||||
|
||||
describe("PolicySetupWizard", () => {
|
||||
beforeEach(() => {
|
||||
fetchSources.mockResolvedValue({ sources: [] });
|
||||
});
|
||||
|
||||
it("round-trips a saved step's backend params on edit", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const entry = editEntry([
|
||||
{
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: { listOfText: "foo\nbar", useRegex: true },
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
|
||||
);
|
||||
await submitWizard(SAVE_CHANGES);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
|
||||
// Only the saved tool is enabled on edit, and its patterns survive the wire -> UI -> wire trip.
|
||||
expect(result.steps).toEqual([
|
||||
expect.objectContaining({
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: expect.objectContaining({ listOfText: "foo\nbar" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("seeds the preset chain for a new policy (redact + sanitize on, watermark off)", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const entry: CatalogueEntry = {
|
||||
category: security,
|
||||
config: securityConfig,
|
||||
policy: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
|
||||
);
|
||||
await submitWizard(ENABLE);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
|
||||
const endpoints = result.steps.map((s) => s.operation);
|
||||
expect(endpoints).toEqual([
|
||||
"/api/v1/security/auto-redact",
|
||||
"/api/v1/security/sanitize-pdf",
|
||||
]);
|
||||
// Redact carries the preset PII patterns as the backend's listOfText.
|
||||
const redact = result.steps[0].parameters as { listOfText?: string };
|
||||
expect(redact.listOfText).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -13,24 +13,23 @@ import {
|
||||
} from "@app/ui";
|
||||
import { SettingsRow } from "@app/ui/SettingsRow";
|
||||
import {
|
||||
TOOL_ENDPOINTS,
|
||||
humanizeEndpoint,
|
||||
type CatalogueEntry,
|
||||
type PipelineStep,
|
||||
type PolicySetupResult,
|
||||
} from "@portal/api/policies";
|
||||
import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
policyEndpoint,
|
||||
policyStepFromWire,
|
||||
policyStepToWire,
|
||||
type PolicyParams,
|
||||
type PolicyToolId,
|
||||
type PolicyToolStep,
|
||||
} from "@app/policies/operations";
|
||||
deserializeToolStep,
|
||||
serializeStepFromEndpoint,
|
||||
} from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
|
||||
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
|
||||
import "@portal/views/Policies.css";
|
||||
@@ -48,8 +47,12 @@ interface PolicySetupWizardProps {
|
||||
|
||||
type Step = "workflow" | "settings";
|
||||
|
||||
/** A policy step plus whether it runs. */
|
||||
type ToolState = PolicyToolStep & { enabled: boolean };
|
||||
/** A configurable tool in the workflow step: whether it runs + its params. */
|
||||
interface ToolState {
|
||||
operation: string;
|
||||
enabled: boolean;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Resolve each field's effective value: saved override, else definition default. */
|
||||
function resolveFieldValues(
|
||||
@@ -66,8 +69,9 @@ function resolveFieldValues(
|
||||
* round-trips); otherwise the category preset's default chain. Each preset step
|
||||
* starts enabled — the user toggles tools off in the workflow.
|
||||
*/
|
||||
// Temporary until the catalogue carries a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
|
||||
// Temporary: tracks which tools start disabled until the tool registry lands in
|
||||
// the portal and can drive this via registry metadata or a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
|
||||
|
||||
/**
|
||||
* Policy-facing framing for each capability a policy can include. Labels and
|
||||
@@ -77,43 +81,43 @@ const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
|
||||
* the humanised endpoint name with no description.
|
||||
*/
|
||||
const CAPABILITY_META: Record<
|
||||
PolicyToolId,
|
||||
string,
|
||||
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
|
||||
> = {
|
||||
redact: {
|
||||
[TOOL_ENDPOINTS.redact]: {
|
||||
labelKey: "portal.policies.wizard.capability.redact.label",
|
||||
labelEn: "Redact sensitive information",
|
||||
descKey: "portal.policies.wizard.capability.redact.desc",
|
||||
descEn:
|
||||
"Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.",
|
||||
},
|
||||
sanitize: {
|
||||
[TOOL_ENDPOINTS.sanitize]: {
|
||||
labelKey: "portal.policies.wizard.capability.sanitize.label",
|
||||
labelEn: "Strip active content",
|
||||
descKey: "portal.policies.wizard.capability.sanitize.desc",
|
||||
descEn:
|
||||
"Removes hidden JavaScript so nothing can run automatically when the document is opened.",
|
||||
},
|
||||
watermark: {
|
||||
[TOOL_ENDPOINTS.watermark]: {
|
||||
labelKey: "portal.policies.wizard.capability.watermark.label",
|
||||
labelEn: "Apply a watermark",
|
||||
descKey: "portal.policies.wizard.capability.watermark.desc",
|
||||
descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.",
|
||||
},
|
||||
ocr: {
|
||||
[TOOL_ENDPOINTS.ocr]: {
|
||||
labelKey: "portal.policies.wizard.capability.ocr.label",
|
||||
labelEn: "Make text searchable",
|
||||
descKey: "portal.policies.wizard.capability.ocr.desc",
|
||||
descEn: "Runs OCR so scanned pages become selectable, searchable text.",
|
||||
},
|
||||
flatten: {
|
||||
[TOOL_ENDPOINTS.flatten]: {
|
||||
labelKey: "portal.policies.wizard.capability.flatten.label",
|
||||
labelEn: "Flatten the document",
|
||||
descKey: "portal.policies.wizard.capability.flatten.desc",
|
||||
descEn:
|
||||
"Merges form fields and annotations into the page so they can't be edited.",
|
||||
},
|
||||
compress: {
|
||||
[TOOL_ENDPOINTS.compress]: {
|
||||
labelKey: "portal.policies.wizard.capability.compress.label",
|
||||
labelEn: "Reduce file size",
|
||||
descKey: "portal.policies.wizard.capability.compress.desc",
|
||||
@@ -121,24 +125,29 @@ const CAPABILITY_META: Record<
|
||||
},
|
||||
};
|
||||
|
||||
function seedTools(entry: CatalogueEntry): ToolState[] {
|
||||
function seedTools(
|
||||
entry: CatalogueEntry,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ToolState[] {
|
||||
const savedSteps = entry.policy?.steps ?? [];
|
||||
const savedByTool = new Map<PolicyToolId, PolicyToolStep>();
|
||||
for (const wire of savedSteps) {
|
||||
const step = policyStepFromWire(wire);
|
||||
if (step) savedByTool.set(step.toolId, step);
|
||||
}
|
||||
// defaultOperations is the canonical list (so tools added later still show on edit); a saved
|
||||
// step's params win over the preset.
|
||||
return entry.config.defaultOperations.map((preset) => {
|
||||
const saved = savedByTool.get(preset.toolId);
|
||||
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
|
||||
// Always use defaultOperations as the canonical list so tools added after a
|
||||
// policy was first saved still appear when editing.
|
||||
return entry.config.defaultOperations.map((s) => {
|
||||
const saved = savedByOp.get(s.operation);
|
||||
return {
|
||||
...(saved ?? preset),
|
||||
operation: s.operation,
|
||||
enabled: saved
|
||||
? true
|
||||
: savedSteps.length > 0
|
||||
? false
|
||||
: !DISABLED_BY_DEFAULT.has(preset.toolId),
|
||||
: !DISABLED_BY_DEFAULT.has(s.operation),
|
||||
// Saved steps are in the backend contract shape; map them back to the UI
|
||||
// shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`).
|
||||
// Presets are already authored in the UI shape, so use them as-is.
|
||||
parameters: saved
|
||||
? deserializeToolStep(saved, registry).params
|
||||
: s.parameters,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -176,12 +185,26 @@ function PolicySetupWizardBody({
|
||||
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { allTools: toolRegistry } = useToolRegistry();
|
||||
|
||||
// Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs.
|
||||
// Build a reverse map so we can look up icons and display names by endpoint.
|
||||
const registryByEndpoint = useMemo(() => {
|
||||
const map = new Map<string, ToolRegistryEntry>();
|
||||
for (const entry of Object.values(toolRegistry)) {
|
||||
const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint;
|
||||
if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry);
|
||||
}
|
||||
return map;
|
||||
}, [toolRegistry]);
|
||||
|
||||
const { category, config, policy } = entry;
|
||||
const isEdit = policy != null;
|
||||
|
||||
const [step, setStep] = useState<Step>("workflow");
|
||||
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
|
||||
const [tools, setTools] = useState<ToolState[]>(() =>
|
||||
seedTools(entry, toolRegistry),
|
||||
);
|
||||
const [fieldValues, setFieldValues] = useState(() =>
|
||||
resolveFieldValues(entry),
|
||||
);
|
||||
@@ -190,11 +213,22 @@ function PolicySetupWizardBody({
|
||||
);
|
||||
|
||||
const sourcesAsync = useAsync(() => fetchSources(), []);
|
||||
const availableSources = useMemo(
|
||||
() =>
|
||||
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
|
||||
[sourcesAsync.data],
|
||||
);
|
||||
const availableSources = useMemo(() => {
|
||||
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
|
||||
(s) => s.status !== "disabled",
|
||||
);
|
||||
const editorSource = {
|
||||
id: "editor",
|
||||
name: t("portal.sources.types.editor.label"),
|
||||
type: "editor",
|
||||
status: "active" as const,
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [],
|
||||
docsTotal: null,
|
||||
};
|
||||
return [editorSource, ...backendSources];
|
||||
}, [sourcesAsync.data, t]);
|
||||
// Document-type scoping has no UI; preserve any saved scope on edit and
|
||||
// default new policies to all document types.
|
||||
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
|
||||
@@ -222,20 +256,9 @@ function PolicySetupWizardBody({
|
||||
|
||||
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
|
||||
|
||||
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
|
||||
function patchTool(operation: string, patch: Partial<ToolState>) {
|
||||
setTools((prev) =>
|
||||
prev.map((tl) => (tl.toolId === toolId ? { ...tl, enabled } : tl)),
|
||||
);
|
||||
}
|
||||
|
||||
function setToolParams<Id extends PolicyToolId>(
|
||||
toolId: Id,
|
||||
params: PolicyParams<Id>,
|
||||
) {
|
||||
setTools((prev) =>
|
||||
prev.map((tl) =>
|
||||
tl.toolId === toolId ? ({ ...tl, params } as ToolState) : tl,
|
||||
),
|
||||
prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,8 +277,11 @@ function PolicySetupWizardBody({
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
// Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the
|
||||
// backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the
|
||||
// UI shape verbatim would drop those fields and the step would run with none.
|
||||
const steps: PipelineStep[] = enabledTools.map((tl) =>
|
||||
policyStepToWire(tl),
|
||||
serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry),
|
||||
);
|
||||
try {
|
||||
await onSubmit(entry, {
|
||||
@@ -360,16 +386,20 @@ function PolicySetupWizardBody({
|
||||
<Card padding="none">
|
||||
<div className="portal-policies__capabilities">
|
||||
{tools.map((tl) => {
|
||||
const meta = CAPABILITY_META[tl.toolId];
|
||||
const meta = CAPABILITY_META[tl.operation];
|
||||
const label = meta
|
||||
? t(meta.labelKey, meta.labelEn)
|
||||
: humanizeEndpoint(policyEndpoint(tl.toolId), t);
|
||||
: (registryByEndpoint.get(tl.operation)?.name ??
|
||||
humanizeEndpoint(tl.operation, t));
|
||||
const description = meta
|
||||
? t(meta.descKey, meta.descEn)
|
||||
: undefined;
|
||||
const hasConfig =
|
||||
tl.operation === TOOL_ENDPOINTS.redact ||
|
||||
tl.operation === TOOL_ENDPOINTS.watermark;
|
||||
return (
|
||||
<div
|
||||
key={tl.toolId}
|
||||
key={tl.operation}
|
||||
className="portal-policies__capability"
|
||||
data-on={tl.enabled || undefined}
|
||||
>
|
||||
@@ -381,27 +411,27 @@ function PolicySetupWizardBody({
|
||||
size="sm"
|
||||
checked={tl.enabled}
|
||||
onChange={(checked) =>
|
||||
setToolEnabled(tl.toolId, checked)
|
||||
patchTool(tl.operation, { enabled: checked })
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{tl.enabled && (
|
||||
{tl.enabled && hasConfig && (
|
||||
<div className="portal-policies__capability-config">
|
||||
{tl.toolId === "redact" && (
|
||||
{tl.operation === TOOL_ENDPOINTS.redact && (
|
||||
<PolicyRedactConfig
|
||||
parameters={tl.params}
|
||||
onChange={(params) =>
|
||||
setToolParams("redact", params)
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tl.toolId === "watermark" && (
|
||||
{tl.operation === TOOL_ENDPOINTS.watermark && (
|
||||
<PolicyWatermarkConfig
|
||||
parameters={tl.params}
|
||||
onChange={(params) =>
|
||||
setToolParams("watermark", params)
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -445,8 +475,9 @@ function PolicySetupWizardBody({
|
||||
{t("portal.policies.wizard.sources.loading")}
|
||||
</p>
|
||||
) : (
|
||||
// The backend always returns the editor as a virtual source, so the
|
||||
// loaded list is never empty - no "no sources" state exists.
|
||||
// The editor is always an available source (unconditionally prepended
|
||||
// to availableSources), so the list is never empty — no "no sources"
|
||||
// state exists.
|
||||
<div className="portal-policies__sources">
|
||||
{availableSources.map((src) => (
|
||||
// A selectable multi-line tile (icon + name + type + check).
|
||||
|
||||
@@ -4,33 +4,13 @@
|
||||
* only builds seed data for the MSW handlers and tests.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PolicyRunView,
|
||||
WirePipelineStep,
|
||||
WirePolicy,
|
||||
} from "@app/policies/types";
|
||||
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
|
||||
import { POLICY_CONFIG } from "@portal/api/policies";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Seed data — real backend wire format */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Literal wire steps (not derived from the catalogue) so this fixtures module stays independent of
|
||||
// @portal/api/policies and its heavy tool-operation import graph.
|
||||
const SECURITY_STEPS: WirePipelineStep[] = [
|
||||
{
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: {
|
||||
listOfText: "",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: "/api/v1/security/sanitize-pdf",
|
||||
parameters: { removeJavaScript: true },
|
||||
},
|
||||
];
|
||||
|
||||
export function seedPolicies(): WirePolicy[] {
|
||||
return [
|
||||
{
|
||||
@@ -39,7 +19,7 @@ export function seedPolicies(): WirePolicy[] {
|
||||
owner: "security@acme.com",
|
||||
enabled: true,
|
||||
trigger: null,
|
||||
steps: SECURITY_STEPS,
|
||||
steps: POLICY_CONFIG.security.defaultOperations,
|
||||
output: {
|
||||
type: "inline",
|
||||
options: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MultiSelect } from "@app/ui/MultiSelect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PII_PRESETS } from "@app/data/policyDefinitions";
|
||||
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
|
||||
|
||||
/** The set of preset regexes — used to separate preset words from custom ones. */
|
||||
export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern));
|
||||
@@ -9,8 +8,8 @@ const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern]));
|
||||
const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value]));
|
||||
|
||||
interface PolicyPiiFieldProps {
|
||||
parameters: RedactParameters;
|
||||
onChange: (parameters: RedactParameters) => void;
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -27,7 +26,9 @@ export function PolicyPiiField({
|
||||
disabled,
|
||||
}: PolicyPiiFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const words = parameters.wordsToRedact;
|
||||
const words = Array.isArray(parameters.wordsToRedact)
|
||||
? (parameters.wordsToRedact as string[])
|
||||
: [];
|
||||
const selected = words
|
||||
.map((w) => VALUE_BY_PATTERN.get(w))
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { PolicyPiiField } from "@app/components/policies/PolicyPiiField";
|
||||
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
|
||||
|
||||
interface PolicyRedactConfigProps {
|
||||
parameters: RedactParameters;
|
||||
onChange: (parameters: RedactParameters) => void;
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/A
|
||||
import type { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
|
||||
interface PolicyWatermarkConfigProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onChange: (parameters: AddWatermarkParameters) => void;
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function PolicyWatermarkConfig({
|
||||
disabled,
|
||||
}: PolicyWatermarkConfigProps) {
|
||||
useEffect(() => {
|
||||
const patch: Partial<AddWatermarkParameters> = {};
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true;
|
||||
// Policies only support text watermarks.
|
||||
if (parameters.watermarkType !== "text") patch.watermarkType = "text";
|
||||
@@ -29,7 +29,7 @@ export function PolicyWatermarkConfig({
|
||||
|
||||
return (
|
||||
<AddWatermarkSingleStepSettings
|
||||
parameters={parameters}
|
||||
parameters={parameters as unknown as AddWatermarkParameters}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...parameters, [key]: value })
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
POLICY_OPERATIONS,
|
||||
policyEndpoint,
|
||||
policyStep,
|
||||
policyStepFromWire,
|
||||
policyStepToWire,
|
||||
policyToolIdForEndpoint,
|
||||
type PolicyToolId,
|
||||
} from "@app/policies/operations";
|
||||
|
||||
const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
|
||||
|
||||
describe("POLICY_OPERATIONS", () => {
|
||||
test("every category operation is a typed descriptor with a known endpoint", () => {
|
||||
// The catalogue uses these six across all categories; each must be wired.
|
||||
expect(ALL_TOOL_IDS.sort()).toEqual([
|
||||
"compress",
|
||||
"flatten",
|
||||
"ocr",
|
||||
"redact",
|
||||
"sanitize",
|
||||
"watermark",
|
||||
]);
|
||||
for (const id of ALL_TOOL_IDS) {
|
||||
expect(POLICY_OPERATIONS[id].endpoint).toBe(policyEndpoint(id));
|
||||
expect(typeof POLICY_OPERATIONS[id].toApi).toBe("function");
|
||||
expect(typeof POLICY_OPERATIONS[id].fromApi).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
test("policyEndpoint returns the pinned endpoint literal", () => {
|
||||
expect(policyEndpoint("redact")).toBe("/api/v1/security/auto-redact");
|
||||
expect(policyEndpoint("sanitize")).toBe("/api/v1/security/sanitize-pdf");
|
||||
expect(policyEndpoint("watermark")).toBe("/api/v1/security/add-watermark");
|
||||
expect(policyEndpoint("ocr")).toBe("/api/v1/misc/ocr-pdf");
|
||||
expect(policyEndpoint("flatten")).toBe("/api/v1/misc/flatten");
|
||||
expect(policyEndpoint("compress")).toBe("/api/v1/misc/compress-pdf");
|
||||
});
|
||||
|
||||
test("policyToolIdForEndpoint maps endpoints back, and rejects non-policy ones", () => {
|
||||
for (const id of ALL_TOOL_IDS) {
|
||||
expect(policyToolIdForEndpoint(policyEndpoint(id))).toBe(id);
|
||||
}
|
||||
expect(policyToolIdForEndpoint("/api/v1/misc/repair")).toBeNull();
|
||||
expect(policyToolIdForEndpoint("not-an-endpoint")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("policyStep", () => {
|
||||
test("merges partial params over the tool's defaults", () => {
|
||||
const step = policyStep("redact", {
|
||||
useRegex: true,
|
||||
wordsToRedact: ["ssn", "card"],
|
||||
});
|
||||
expect(step.toolId).toBe("redact");
|
||||
// Overrides applied...
|
||||
expect(step.params.useRegex).toBe(true);
|
||||
expect(step.params.wordsToRedact).toEqual(["ssn", "card"]);
|
||||
// ...and untouched fields fall back to the tool's defaults.
|
||||
expect(step.params.mode).toBe("automatic");
|
||||
expect(step.params.redactColor).toBe("#000000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wire conversion", () => {
|
||||
test("redact maps frontend params to the backend request model (wordsToRedact -> listOfText)", () => {
|
||||
const wire = policyStepToWire(
|
||||
policyStep("redact", {
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: ["ssn", "card"],
|
||||
}),
|
||||
);
|
||||
expect(wire.operation).toBe("/api/v1/security/auto-redact");
|
||||
// The backend field the endpoint actually reads, and no frontend-only `mode`/`wordsToRedact`.
|
||||
expect(wire.parameters).toMatchObject({
|
||||
listOfText: "ssn\ncard",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
});
|
||||
expect(wire.parameters).not.toHaveProperty("wordsToRedact");
|
||||
expect(wire.parameters).not.toHaveProperty("mode");
|
||||
});
|
||||
|
||||
test("every policy operation round-trips through wire and back", () => {
|
||||
for (const id of ALL_TOOL_IDS) {
|
||||
const step = policyStep(id);
|
||||
const back = policyStepFromWire(policyStepToWire(step));
|
||||
expect(back?.toolId).toBe(id);
|
||||
}
|
||||
});
|
||||
|
||||
test("redact round-trip preserves the configured patterns", () => {
|
||||
const step = policyStep("redact", {
|
||||
useRegex: true,
|
||||
wordsToRedact: ["ssn", "card"],
|
||||
});
|
||||
const back = policyStepFromWire(policyStepToWire(step));
|
||||
expect(back?.toolId).toBe("redact");
|
||||
if (back?.toolId === "redact") {
|
||||
expect(back.params.wordsToRedact).toEqual(["ssn", "card"]);
|
||||
expect(back.params.useRegex).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("a non-policy endpoint decodes to null", () => {
|
||||
expect(
|
||||
policyStepFromWire({ operation: "/api/v1/misc/repair", parameters: {} }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* The tool operations the Policies feature can run, each a typed {@link ToolOperationDescriptor}.
|
||||
* Source of truth for the catalogue, wizard, and wire conversion. Add a tool here to use it in a
|
||||
* policy - the catalogue can't reference an untyped operation.
|
||||
*/
|
||||
|
||||
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
|
||||
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
|
||||
import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation";
|
||||
import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
|
||||
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
|
||||
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
|
||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||
import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor";
|
||||
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
import type { WirePipelineStep } from "@app/policies/types";
|
||||
|
||||
export const POLICY_OPERATIONS = {
|
||||
redact: describeToolOperation(
|
||||
"/api/v1/security/auto-redact",
|
||||
redactOperationConfig,
|
||||
),
|
||||
sanitize: describeToolOperation(
|
||||
"/api/v1/security/sanitize-pdf",
|
||||
sanitizeOperationConfig,
|
||||
),
|
||||
watermark: describeToolOperation(
|
||||
"/api/v1/security/add-watermark",
|
||||
addWatermarkOperationConfig,
|
||||
),
|
||||
ocr: describeToolOperation("/api/v1/misc/ocr-pdf", ocrOperationConfig),
|
||||
flatten: describeToolOperation(
|
||||
"/api/v1/misc/flatten",
|
||||
flattenOperationConfig,
|
||||
),
|
||||
compress: describeToolOperation(
|
||||
"/api/v1/misc/compress-pdf",
|
||||
compressOperationConfig,
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type PolicyToolId = keyof typeof POLICY_OPERATIONS;
|
||||
|
||||
export type PolicyParams<Id extends PolicyToolId> =
|
||||
(typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
infer P
|
||||
>
|
||||
? P
|
||||
: never;
|
||||
|
||||
/** Discriminated on `toolId` so `params` matches the tool. */
|
||||
export type PolicyToolStep = {
|
||||
[Id in PolicyToolId]: { toolId: Id; params: PolicyParams<Id> };
|
||||
}[PolicyToolId];
|
||||
|
||||
export type PolicyToolStepOf<Id extends PolicyToolId> = Extract<
|
||||
PolicyToolStep,
|
||||
{ toolId: Id }
|
||||
>;
|
||||
|
||||
const POLICY_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
|
||||
|
||||
const TOOL_ID_BY_ENDPOINT = new Map<string, PolicyToolId>(
|
||||
POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]),
|
||||
);
|
||||
|
||||
export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint {
|
||||
return POLICY_OPERATIONS[toolId].endpoint;
|
||||
}
|
||||
|
||||
/** Tool id for an endpoint path, or null if it isn't a policy tool. */
|
||||
export function policyToolIdForEndpoint(endpoint: string): PolicyToolId | null {
|
||||
return TOOL_ID_BY_ENDPOINT.get(endpoint) ?? null;
|
||||
}
|
||||
|
||||
/** A step for `toolId`, partial params merged over the tool's defaults. */
|
||||
export function policyStep<Id extends PolicyToolId>(
|
||||
toolId: Id,
|
||||
params: Partial<PolicyParams<Id>> = {},
|
||||
): PolicyToolStepOf<Id> {
|
||||
const defaults = POLICY_OPERATIONS[toolId].defaultParameters as object;
|
||||
return {
|
||||
toolId,
|
||||
params: { ...defaults, ...(params as object) },
|
||||
} as PolicyToolStepOf<Id>;
|
||||
}
|
||||
|
||||
export function policyStepToWire(step: PolicyToolStep): WirePipelineStep {
|
||||
return serializeStep(step);
|
||||
}
|
||||
|
||||
// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through
|
||||
// the union, so `op` is widened here (a contained cast at the wire boundary).
|
||||
function serializeStep<Id extends PolicyToolId>(step: {
|
||||
toolId: Id;
|
||||
params: PolicyParams<Id>;
|
||||
}): WirePipelineStep {
|
||||
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
PolicyParams<Id>
|
||||
>;
|
||||
return {
|
||||
operation: op.endpoint,
|
||||
parameters: op.toApi(step.params) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wire step -> typed policy step, or null if the endpoint isn't a policy tool. */
|
||||
export function policyStepFromWire(
|
||||
wire: WirePipelineStep,
|
||||
): PolicyToolStep | null {
|
||||
const toolId = policyToolIdForEndpoint(wire.operation);
|
||||
if (!toolId) return null;
|
||||
return deserializeStep(toolId, wire.parameters);
|
||||
}
|
||||
|
||||
function deserializeStep<Id extends PolicyToolId>(
|
||||
toolId: Id,
|
||||
parameters: Record<string, unknown>,
|
||||
): PolicyToolStepOf<Id> {
|
||||
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
PolicyParams<Id>
|
||||
>;
|
||||
// Wire params are untyped JSON; this is the one point they enter the typed model.
|
||||
const params = op.fromApi(
|
||||
parameters as unknown as ToolApiParams[ToolEndpoint],
|
||||
);
|
||||
return { toolId, params } as unknown as PolicyToolStepOf<Id>;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
# Multi-node Stirling-PDF processor test stack: shared Postgres/MinIO/Valkey behind an nginx LB fronting N app nodes, with enterprise features unlocked via a local test licence key.
|
||||
# Bring up: ./start-multinode-test.sh Validate: ./validate-multinode-test.sh Access: http://localhost:8080 (admin / stirling)
|
||||
|
||||
x-stirling-node: &stirling-node
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/embedded/Dockerfile
|
||||
image: stirling-pdf-multinode:local
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/info/status | grep -q UP"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
start_period: 90s
|
||||
environment: &stirling-env
|
||||
# --- Licensing: unlock pro/enterprise (custom DB + cluster mode) ---
|
||||
# Cluster mode is licence-gated - set PREMIUM_KEY (locally: export PREMIUM_KEY=...; CI: the PREMIUM_KEY_ENTERPRISE secret); the default below is a non-functional placeholder, never a real key.
|
||||
PREMIUM_ENABLED: "true"
|
||||
PREMIUM_KEY: "${PREMIUM_KEY:-00000000-0000-0000-0000-000000000000}"
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
|
||||
# --- Login + teams ---
|
||||
DOCKER_ENABLE_SECURITY: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "admin"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "stirling"
|
||||
# Global API key: lets the seed/validate scripts call admin APIs without a login flow.
|
||||
SECURITY_CUSTOMGLOBALAPIKEY: "multinode-test-key"
|
||||
|
||||
# --- Shared Postgres (the single DB every node coordinates through) ---
|
||||
# enableCustomDatabase is an enterprise feature, unlocked by PREMIUM_* above.
|
||||
SYSTEM_DATASOURCE_ENABLECUSTOMDATABASE: "true"
|
||||
SYSTEM_DATASOURCE_TYPE: "postgresql"
|
||||
SYSTEM_DATASOURCE_HOSTNAME: "postgres"
|
||||
SYSTEM_DATASOURCE_PORT: "5432"
|
||||
SYSTEM_DATASOURCE_NAME: "stirling"
|
||||
SYSTEM_DATASOURCE_USERNAME: "stirling"
|
||||
SYSTEM_DATASOURCE_PASSWORD: "stirling"
|
||||
# User.settings has no @Lob annotation - with one, Postgres treats it as a large object and every login 500s ("Large Objects may not be used in auto-commit mode"); see multinode/README.md.
|
||||
|
||||
# --- Shared object storage (persistent user uploads + share/store feature) ---
|
||||
STORAGE_ENABLED: "true"
|
||||
STORAGE_PROVIDER: "s3"
|
||||
STORAGE_S3_ENDPOINT: "http://minio:9000"
|
||||
STORAGE_S3_BUCKET: "stirling-storage"
|
||||
STORAGE_S3_REGION: "us-east-1"
|
||||
STORAGE_S3_ACCESSKEY: "minioadmin"
|
||||
STORAGE_S3_SECRETKEY: "minioadmin"
|
||||
STORAGE_S3_PATHSTYLEACCESS: "true"
|
||||
STORAGE_S3_ALLOWPRIVATEENDPOINTS: "true"
|
||||
|
||||
# --- Cluster mode: Valkey backplane + shared S3 job-artifact store ---
|
||||
# artifactStore=s3 is REQUIRED for multi-node (transient job artifacts must be shared).
|
||||
CLUSTER_ENABLED: "true"
|
||||
CLUSTER_BACKPLANE: "valkey"
|
||||
CLUSTER_ARTIFACTSTORE: "s3"
|
||||
CLUSTER_VALKEY_URL: "redis://valkey:6379"
|
||||
# SPRING_DATA_REDIS_REPOSITORIES_ENABLED is not needed: DataRedisRepositoriesAutoConfiguration is excluded (see application.properties, multinode/README.md).
|
||||
|
||||
# --- Shared credential-encryption key (REQUIRED in cluster mode) ---
|
||||
# AES-256 key that encrypts stored integration/S3 secrets - must match on every node or secrets encrypted on one can't decrypt on another; boot fails if unset with cluster.enabled=true (test-only value; JWT keys persist separately in the shared DB).
|
||||
STIRLING_CREDENTIAL_ENCRYPTION_KEY: "dMobekyUEnEV7WHBah2FkbboP4Coqifd3JRXB00LiIY="
|
||||
|
||||
# --- Policy / processor subsystem (the thing under test) ---
|
||||
POLICIES_ENABLED: "true"
|
||||
# Let policy S3 sources/webhook-staging connections point at the in-cluster MinIO.
|
||||
POLICIES_ALLOWPRIVATES3ENDPOINTS: "true"
|
||||
|
||||
# --- Misc features on ---
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_DEFAULTLOCALE: "en-US"
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
UI_APPNAME: "Stirling-PDF Multi-Node"
|
||||
UI_APPNAMENAVBAR: "Stirling Multi-Node"
|
||||
networks:
|
||||
- stirling-multinode
|
||||
|
||||
services:
|
||||
# ---- Shared Postgres: the single DB all nodes coordinate through ----------
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: multinode-postgres
|
||||
environment:
|
||||
POSTGRES_USER: stirling
|
||||
POSTGRES_PASSWORD: stirling
|
||||
POSTGRES_DB: stirling
|
||||
ports:
|
||||
- "5434:5432" # host access for inspection (psql -h localhost -p 5434 -U stirling)
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U stirling -d stirling"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
networks:
|
||||
- stirling-multinode
|
||||
|
||||
# ---- Valkey: cluster backplane (shared job state / rate limiting) ---------
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: multinode-valkey
|
||||
command: ["valkey-server", "--save", "", "--appendonly", "no"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
networks:
|
||||
- stirling-multinode
|
||||
|
||||
# ---- MinIO: shared S3 (platform storage + policy connections + webhook staging) ----
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: multinode-minio
|
||||
command: ["server", "/data", "--console-address", ":9001"]
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9000:9000" # S3 API
|
||||
- "9001:9001" # web console (minioadmin / minioadmin)
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mc ready local || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
networks:
|
||||
- stirling-multinode
|
||||
|
||||
# ---- One-shot: create the buckets the stack needs -------------------------
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
container_name: multinode-minio-init
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 minioadmin minioadmin &&
|
||||
mc mb --ignore-existing local/stirling-storage &&
|
||||
mc mb --ignore-existing local/policy-data &&
|
||||
echo 'buckets ready: stirling-storage, policy-data'
|
||||
"
|
||||
networks:
|
||||
- stirling-multinode
|
||||
|
||||
# ---- App node 1 -----------------------------------------------------------
|
||||
stirling-1:
|
||||
<<: *stirling-node
|
||||
container_name: multinode-stirling-1
|
||||
environment:
|
||||
<<: *stirling-env
|
||||
SYSTEM_NODEID: "node-1"
|
||||
|
||||
# ---- App node 2 -----------------------------------------------------------
|
||||
# No ordering vs node-1 needed - each node mints its own signing key at boot and publishes the public half to the shared DB, so both verify each other's tokens.
|
||||
stirling-2:
|
||||
<<: *stirling-node
|
||||
container_name: multinode-stirling-2
|
||||
environment:
|
||||
<<: *stirling-env
|
||||
SYSTEM_NODEID: "node-2"
|
||||
|
||||
# ---- One-shot seeder: teams, ~40 users, S3 connection, policies -----------
|
||||
# Profile-gated so plain `docker compose up` skips it; the start script runs it once, using postgres:alpine for psql with curl+jq added for the HTTP calls.
|
||||
seed:
|
||||
image: postgres:17-alpine
|
||||
container_name: multinode-seed
|
||||
profiles: ["seed"]
|
||||
depends_on:
|
||||
stirling-1:
|
||||
condition: service_healthy
|
||||
stirling-2:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BASE_URL: "http://nginx:8080"
|
||||
PGHOST: "postgres"
|
||||
PGUSER: "stirling"
|
||||
PGPASSWORD: "stirling"
|
||||
PGDATABASE: "stirling"
|
||||
USER_COUNT: "40"
|
||||
volumes:
|
||||
- ./multinode/seed.sh:/seed.sh:ro
|
||||
entrypoint: ["/bin/sh", "-c", "apk add --no-cache curl jq >/dev/null && sh /seed.sh"]
|
||||
networks:
|
||||
- stirling-multinode
|
||||
|
||||
# ---- nginx load balancer: single entrypoint round-robining the nodes ------
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: multinode-nginx
|
||||
depends_on:
|
||||
stirling-1:
|
||||
condition: service_healthy
|
||||
stirling-2:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./multinode/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
networks:
|
||||
- stirling-multinode
|
||||
|
||||
networks:
|
||||
stirling-multinode:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
minio-data:
|
||||
@@ -0,0 +1,49 @@
|
||||
worker_processes 1;
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
# Round-robin across the app nodes. Add a node here to scale out.
|
||||
# max_fails=1 marks a node down after a single failure so a dead node drains fast.
|
||||
upstream stirling_nodes {
|
||||
server stirling-1:8080 max_fails=1 fail_timeout=10s;
|
||||
server stirling-2:8080 max_fails=1 fail_timeout=10s;
|
||||
}
|
||||
|
||||
# Large uploads (SYSTEM_MAXFILESIZE=100MB) plus headroom.
|
||||
client_max_body_size 200m;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
|
||||
location / {
|
||||
proxy_pass http://stirling_nodes;
|
||||
|
||||
# Graceful failover: retries the other node only on connection-level failures (unreachable/timeout), never on 5xx, so a POST a node already started is never re-sent.
|
||||
proxy_next_upstream error timeout;
|
||||
proxy_next_upstream_tries 2;
|
||||
proxy_connect_timeout 3s;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket / SSE upgrade support (policy run streaming).
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Stream server-sent events straight through, don't buffer.
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
|
||||
# Surface which app node served the request, so the validate script can prove the LB is spreading load.
|
||||
add_header X-Served-By $upstream_addr always;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/bin/sh
|
||||
# Seeds a running multi-node stack: 4 teams, ~40 users, an S3 connection, a scheduled S3 policy, and a webhook source if the build supports it.
|
||||
# Auth uses the Bearer JWT from the login response body (not a cookie) since the global API key can't create teams.
|
||||
# Idempotent-ish: re-running skips existing teams/users; each step is best-effort and logs failures without aborting.
|
||||
set -u
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:8080}"
|
||||
ADMIN_USER="${ADMIN_USER:-admin}"
|
||||
ADMIN_PASS="${ADMIN_PASS:-stirling}"
|
||||
USER_COUNT="${USER_COUNT:-40}"
|
||||
USER_PASS="${USER_PASS:-Password123!}"
|
||||
PGHOST="${PGHOST:-postgres}"
|
||||
PGUSER="${PGUSER:-stirling}"
|
||||
PGPASSWORD="${PGPASSWORD:-stirling}"
|
||||
PGDATABASE="${PGDATABASE:-stirling}"
|
||||
export PGPASSWORD
|
||||
|
||||
TEAMS="Engineering Finance Legal Operations"
|
||||
|
||||
log() { echo "[seed] $*"; }
|
||||
psqlq() { psql -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" -tAc "$1" 2>/dev/null | tr -d '[:space:]'; }
|
||||
|
||||
# --- wait for the load balancer to serve a healthy app -----------------------
|
||||
log "waiting for $BASE_URL ..."
|
||||
i=0
|
||||
until curl -fsS "$BASE_URL/api/v1/info/status" 2>/dev/null | grep -q UP; do
|
||||
i=$((i+1)); [ "$i" -gt 120 ] && { log "timed out waiting for API"; exit 1; }
|
||||
sleep 3
|
||||
done
|
||||
log "API is up"
|
||||
|
||||
# --- admin login -> Bearer token ---------------------------------------------
|
||||
code=$(curl -sS -o /tmp/login.json -w '%{http_code}' \
|
||||
-X POST "$BASE_URL/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASS\"}")
|
||||
log "admin login: HTTP $code"
|
||||
[ "$code" = "200" ] || { log "login failed: $(cat /tmp/login.json)"; exit 1; }
|
||||
TOKEN=$(jq -r '.session.access_token' </tmp/login.json)
|
||||
[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || { log "no access_token in login response"; exit 1; }
|
||||
|
||||
auth() { curl -sS -H "Authorization: Bearer $TOKEN" "$@"; }
|
||||
|
||||
# --- teams -------------------------------------------------------------------
|
||||
for t in $TEAMS; do
|
||||
code=$(auth -o /dev/null -w '%{http_code}' -X POST "$BASE_URL/api/v1/team/create" \
|
||||
--data-urlencode "name=$t")
|
||||
log "team '$t': HTTP $code"
|
||||
done
|
||||
|
||||
# Resolve team ids from the DB (no admin list endpoint self-hosted).
|
||||
seed_team_ids=""
|
||||
for t in $TEAMS; do
|
||||
id=$(psqlq "select team_id from teams where name='$t' limit 1")
|
||||
[ -n "$id" ] && seed_team_ids="$seed_team_ids $id"
|
||||
done
|
||||
set -- $seed_team_ids
|
||||
team_count=$#
|
||||
log "seedable team ids:$seed_team_ids (count=$team_count)"
|
||||
|
||||
# --- users: spread across teams, first two are admins ------------------------
|
||||
created=0; failed=0
|
||||
n=1
|
||||
while [ "$n" -le "$USER_COUNT" ]; do
|
||||
uname=$(printf "user%02d@stirling.test" "$n")
|
||||
role="ROLE_USER"; [ "$n" -le 2 ] && role="ROLE_ADMIN"
|
||||
team_id=""
|
||||
if [ "$team_count" -gt 0 ]; then
|
||||
idx=$(( (n % team_count) + 1 )); team_id=$(eval echo "\${$idx}")
|
||||
fi
|
||||
code=$(auth -o /tmp/user.json -w '%{http_code}' -X POST "$BASE_URL/api/v1/user/admin/saveUser" \
|
||||
--data-urlencode "username=$uname" \
|
||||
--data-urlencode "password=$USER_PASS" \
|
||||
--data-urlencode "role=$role" \
|
||||
${team_id:+--data-urlencode "teamId=$team_id"} \
|
||||
--data-urlencode "authType=WEB" \
|
||||
--data-urlencode "forceChange=false")
|
||||
case "$code" in
|
||||
200|201) created=$((created+1));;
|
||||
409) log "user $uname already exists";;
|
||||
*) failed=$((failed+1)); [ "$failed" -le 3 ] && log "user $uname failed HTTP $code: $(cat /tmp/user.json)";;
|
||||
esac
|
||||
n=$((n+1))
|
||||
done
|
||||
log "users created: $created (failed: $failed, requested: $USER_COUNT)"
|
||||
|
||||
# --- S3 connection -> the in-cluster MinIO 'policy-data' bucket ---------------
|
||||
conn_body=$(cat <<JSON
|
||||
{"integrationType":"S3","name":"MinIO policy bucket","scope":"SERVER","enabled":true,"locked":false,"defaultAccess":"ORG_ALL",
|
||||
"config":{"bucket":"policy-data","region":"us-east-1","endpoint":"http://minio:9000","accessKeyId":"minioadmin","secretAccessKey":"minioadmin","pathStyleAccess":true}}
|
||||
JSON
|
||||
)
|
||||
conn_id=$(auth -X POST "$BASE_URL/api/v1/integrations" -H 'Content-Type: application/json' -d "$conn_body" \
|
||||
| jq -r '.id // empty' 2>/dev/null)
|
||||
log "S3 connection id: ${conn_id:-<none>}"
|
||||
|
||||
# --- a scheduled S3 -> compress -> S3 policy ---------------------------------
|
||||
if [ -n "${conn_id:-}" ]; then
|
||||
src_body=$(cat <<JSON
|
||||
{"name":"Incoming S3","type":"s3","enabled":true,
|
||||
"options":{"connectionId":$conn_id,"prefix":"incoming/","mode":"consume"}}
|
||||
JSON
|
||||
)
|
||||
src_id=$(auth -X POST "$BASE_URL/api/v1/sources" -H 'Content-Type: application/json' -d "$src_body" \
|
||||
| jq -r '.id // empty' 2>/dev/null)
|
||||
log "S3 source id: ${src_id:-<none>}"
|
||||
|
||||
if [ -n "${src_id:-}" ]; then
|
||||
pol_body=$(cat <<JSON
|
||||
{"name":"Compress incoming PDFs","enabled":true,
|
||||
"trigger":{"type":"schedule","options":{"schedule":{"type":"every","count":5,"unit":"MINUTES"}}},
|
||||
"sourceIds":["$src_id"],
|
||||
"steps":[{"operation":"/api/v1/misc/compress-pdf","parameters":{}}],
|
||||
"output":{"type":"s3","options":{"connectionId":$conn_id,"prefix":"processed/"}}}
|
||||
JSON
|
||||
)
|
||||
code=$(auth -o /tmp/pol.json -w '%{http_code}' -X POST "$BASE_URL/api/v1/policies" \
|
||||
-H 'Content-Type: application/json' -d "$pol_body")
|
||||
log "policy create: HTTP $code $( [ "$code" != 200 ] && head -c 160 /tmp/pol.json )"
|
||||
fi
|
||||
|
||||
# --- webhook source + policy (only if this build has the webhook type) -----
|
||||
wh_body=$(cat <<JSON
|
||||
{"name":"Partner webhook","type":"webhook","enabled":true,
|
||||
"options":{"connectionId":$conn_id,"mode":"consume"}}
|
||||
JSON
|
||||
)
|
||||
wh=$(auth -o /tmp/wh.json -w '%{http_code}' -X POST "$BASE_URL/api/v1/sources" \
|
||||
-H 'Content-Type: application/json' -d "$wh_body")
|
||||
if [ "$wh" = "200" ] || [ "$wh" = "201" ]; then
|
||||
wh_url=$(jq -r '.options.webhookId // empty' </tmp/wh.json 2>/dev/null)
|
||||
log "webhook source created (deliver to /api/v1/webhooks/$wh_url)"
|
||||
else
|
||||
log "webhook source not created (HTTP $wh) - expected on builds without the webhook branch"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "seed complete."
|
||||
log " login: $ADMIN_USER / $ADMIN_PASS at $BASE_URL"
|
||||
log " users: user01..$(printf '%02d' "$USER_COUNT")@stirling.test / $USER_PASS"
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the multi-node regression suite (behave features/multinode) against the clustered stack: brings it up if needed, runs non-destructive scenarios then @destructive failover ones, and restores any killed node.
|
||||
# Usage: ./run-multinode-regression.sh [--no-failover] [--no-seed]
|
||||
# @known_gap scenarios are expected to fail - they mark work not yet done, so a non-zero exit is fine while those are open.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
COMPOSE="docker compose -f docker-compose-multinode.yml"
|
||||
CUKE_DIR="../cucumber"
|
||||
RUN_FAILOVER=1
|
||||
SEED=1
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-failover) RUN_FAILOVER=0 ;;
|
||||
--no-seed) SEED=0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "==> Ensuring the multi-node stack is up..."
|
||||
if ! docker inspect -f '{{.State.Health.Status}}' multinode-stirling-1 2>/dev/null | grep -q healthy; then
|
||||
./start-multinode-test.sh $([ "$SEED" = 0 ] && echo --no-seed) || exit 1
|
||||
elif [ "$SEED" = 1 ]; then
|
||||
echo " stack already up; seeding (idempotent)..."
|
||||
$COMPOSE --profile seed run --rm seed >/dev/null 2>&1 || echo " (seed reported issues, continuing)"
|
||||
fi
|
||||
|
||||
echo "==> Checking Python + behave..."
|
||||
PY="${PYTHON:-python}"
|
||||
command -v "$PY" >/dev/null || PY=python3
|
||||
if ! "$PY" -c "import behave" 2>/dev/null; then
|
||||
echo " installing test deps..."
|
||||
"$PY" -m pip install -q -r "$CUKE_DIR/requirements.txt" || {
|
||||
echo " could not install behave; install $CUKE_DIR/requirements.txt manually"; exit 1; }
|
||||
fi
|
||||
|
||||
REPORT_DIR="$(pwd)/multinode/regression-report"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
run_behave() { # $1=tags $2=label
|
||||
echo "==> behave features/multinode --tags='$1' ($2)"
|
||||
# behave.ini excludes features/multinode by default; -e here overrides that while still excluding the licence-gated enterprise suite.
|
||||
( cd "$CUKE_DIR" && "$PY" -m behave features/multinode -e "features/enterprise" \
|
||||
--tags="$1" --no-capture --format plain --format html --outfile "$REPORT_DIR/$2.html" )
|
||||
return $?
|
||||
}
|
||||
|
||||
rc=0
|
||||
run_behave "~@destructive" "core" || rc=1
|
||||
|
||||
if [ "$RUN_FAILOVER" = 1 ]; then
|
||||
run_behave "@destructive" "failover" || rc=1
|
||||
echo "==> Restoring any killed nodes..."
|
||||
$COMPOSE up -d >/dev/null 2>&1
|
||||
for n in multinode-stirling-1 multinode-stirling-2; do
|
||||
for i in $(seq 1 24); do
|
||||
[ "$(docker inspect -f '{{.State.Health.Status}}' "$n" 2>/dev/null)" = "healthy" ] && break
|
||||
sleep 5
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo " Regression run complete. Reports: $REPORT_DIR"
|
||||
echo " Exit $rc (non-zero = at least one scenario failed;"
|
||||
echo " @known_gap scenarios are expected to fail - see the report)."
|
||||
echo " Stack left running: http://localhost:8080 (admin / stirling)"
|
||||
echo "============================================================"
|
||||
exit $rc
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Brings up the multi-node stack (Postgres/Valkey/MinIO/2 app nodes/nginx LB), seeds teams/users/an S3 connection/policies, then leaves it running for manual testing.
|
||||
# Usage: ./start-multinode-test.sh [--no-seed | --down]
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
COMPOSE="docker compose -f docker-compose-multinode.yml"
|
||||
|
||||
if [ "${1:-}" = "--down" ]; then
|
||||
echo "Tearing down multi-node stack + volumes..."
|
||||
$COMPOSE --profile seed down -v --remove-orphans
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SEED=1
|
||||
[ "${1:-}" = "--no-seed" ] && SEED=0
|
||||
|
||||
# Cluster mode is licence-gated. Without a valid key the nodes fail the cluster licence gate at boot.
|
||||
if [ -z "${PREMIUM_KEY:-}" ]; then
|
||||
echo "WARNING: PREMIUM_KEY is not set - cluster mode needs a valid enterprise/pro licence key."
|
||||
echo " Run: export PREMIUM_KEY=<your test licence key> before starting."
|
||||
fi
|
||||
|
||||
echo "==> Building the Stirling image (first run compiles the app; be patient)..."
|
||||
$COMPOSE build
|
||||
|
||||
echo "==> Starting Postgres + Valkey + MinIO + 2 app nodes + nginx..."
|
||||
$COMPOSE up -d
|
||||
|
||||
echo "==> Waiting for both app nodes to report healthy..."
|
||||
for node in multinode-stirling-1 multinode-stirling-2; do
|
||||
for i in $(seq 1 60); do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' "$node" 2>/dev/null || echo "starting")
|
||||
[ "$status" = "healthy" ] && { echo " $node: healthy"; break; }
|
||||
[ "$i" = "60" ] && { echo " $node did not become healthy; see: $COMPOSE logs $node"; exit 1; }
|
||||
sleep 5
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$SEED" = "1" ]; then
|
||||
echo "==> Seeding teams / users / S3 connection / policies..."
|
||||
$COMPOSE --profile seed run --rm seed || echo " (seed reported issues; check output above)"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
============================================================================
|
||||
Multi-node Stirling is UP.
|
||||
|
||||
App (via load balancer): http://localhost:8080 (admin / stirling)
|
||||
MinIO console: http://localhost:9001 (minioadmin / minioadmin)
|
||||
Postgres: localhost:5434 (stirling / stirling, db 'stirling')
|
||||
|
||||
Seeded users: user01..user40@stirling.test / Password123!
|
||||
Global API key: multinode-test-key (header: X-API-KEY)
|
||||
|
||||
Try it:
|
||||
./validate-multinode-test.sh # multi-node smoke tests (optional)
|
||||
$COMPOSE logs -f stirling-1 # tail a node
|
||||
./start-multinode-test.sh --down # stop + wipe
|
||||
|
||||
Nodes are reachable directly for cross-node checks:
|
||||
docker compose -f docker-compose-multinode.yml exec stirling-1 curl -s localhost:8080/api/v1/info/status
|
||||
============================================================================
|
||||
EOF
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Multi-node smoke tests against a running stack (start-multinode-test.sh): load-balancer spread, cross-node JWT validation (signing keys persist in the shared DB), and processor state visible from every node.
|
||||
# Auth: extracts the Bearer JWT from the login body via sed (no jq needed host-side) and hits nodes directly with docker exec.
|
||||
# Non-destructive - safe to re-run against the stack at http://localhost:8080.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
LB="http://localhost:8080"
|
||||
ADMIN_USER="admin"; ADMIN_PASS="stirling"
|
||||
NODES="multinode-stirling-1 multinode-stirling-2"
|
||||
# An authed, admin-visible endpoint that returns 200 with a valid token, 401 without.
|
||||
PROBE="/api/v1/sources"
|
||||
pass=0; fail=0
|
||||
ok() { echo " PASS - $*"; pass=$((pass+1)); }
|
||||
bad() { echo " FAIL - $*"; fail=$((fail+1)); }
|
||||
|
||||
login() { # -> prints the bearer token
|
||||
curl -s -X POST "$LB/api/v1/auth/login" -H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASS\"}" \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
|
||||
}
|
||||
|
||||
echo "== 1. Load balancer spreads requests across nodes =="
|
||||
served=$(for i in $(seq 1 12); do
|
||||
curl -s -D - -o /dev/null "$LB/api/v1/info/status" | tr -d '\r' | awk -F': ' '/^X-Served-By/{print $2}'
|
||||
done | sort -u)
|
||||
distinct=$(printf '%s\n' "$served" | grep -c .)
|
||||
echo " upstreams seen: $(printf '%s' "$served" | paste -sd, -)"
|
||||
[ "$distinct" -ge 2 ] && ok "LB round-robined across $distinct nodes" \
|
||||
|| bad "only $distinct node(s) served (expected >=2; is X-Served-By enabled?)"
|
||||
|
||||
echo "== 2. A JWT from the LB is accepted by BOTH nodes directly (shared signing key) =="
|
||||
jwt=$(login)
|
||||
if [ -z "$jwt" ]; then
|
||||
bad "admin login via LB failed - cannot test cross-node JWT"
|
||||
else
|
||||
ok "logged in via LB, got a JWT (${#jwt} chars)"
|
||||
for n in $NODES; do
|
||||
hc=$(docker exec "$n" curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $jwt" "http://localhost:8080$PROBE" 2>/dev/null)
|
||||
[ "$hc" = "200" ] && ok "$n accepted the foreign-minted JWT (HTTP 200)" \
|
||||
|| bad "$n rejected the JWT (HTTP $hc) - keys not shared across nodes"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "== 3. Processor state is shared: each node sees the same sources =="
|
||||
count_of() { # $1=node -> number of sources that node reports
|
||||
docker exec "$1" curl -s -H "Authorization: Bearer $jwt" "http://localhost:8080$PROBE" 2>/dev/null \
|
||||
| grep -o '"id"' | grep -c .
|
||||
}
|
||||
a=$(count_of multinode-stirling-1); b=$(count_of multinode-stirling-2)
|
||||
echo " stirling-1 sources: $a stirling-2 sources: $b"
|
||||
if [ "$a" -gt 0 ] && [ "$a" = "$b" ]; then
|
||||
ok "both nodes report the same $a sources (shared DB)"
|
||||
else
|
||||
bad "source counts differ or zero across nodes ($a vs $b)"
|
||||
fi
|
||||
|
||||
echo "== 4. Seeded org is in the shared DB =="
|
||||
users=$(docker exec multinode-postgres psql -U stirling -d stirling -tAc "select count(*) from users" 2>/dev/null | tr -d '[:space:]')
|
||||
teams=$(docker exec multinode-postgres psql -U stirling -d stirling -tAc "select count(*) from teams" 2>/dev/null | tr -d '[:space:]')
|
||||
conns=$(docker exec multinode-postgres psql -U stirling -d stirling -tAc "select count(*) from integration_configs" 2>/dev/null | tr -d '[:space:]')
|
||||
echo " users=$users teams=$teams integration_configs=$conns"
|
||||
[ "${users:-0}" -ge 40 ] && ok "$users users present" || bad "only ${users:-0} users (did the seed run?)"
|
||||
[ "${conns:-0}" -ge 1 ] && ok "$conns S3/integration connection(s) present" || bad "no integration connections"
|
||||
|
||||
echo "== 5. Cross-node encrypted-secret read (shared credential key) =="
|
||||
# The seed's S3 secret was encrypted by whichever node handled it; fetching it via the LB (either node) and getting a masked, non-error view proves the credential key is shared, not per-node.
|
||||
lc=$(curl -s -o /tmp/mn_conns.json -w '%{http_code}' -H "Authorization: Bearer $jwt" "$LB/api/v1/integrations")
|
||||
if [ "$lc" = "200" ] && grep -q '"integrationType"' /tmp/mn_conns.json; then
|
||||
ok "integration list decrypts through the LB (HTTP 200) - credential key is shared"
|
||||
else
|
||||
bad "integration list failed (HTTP $lc) - credential key may not be shared across nodes"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo " Multi-node validation: $pass passed, $fail failed."
|
||||
echo " Stack left running: $LB (admin / stirling)"
|
||||
echo "============================================================"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -1,9 +1,5 @@
|
||||
[behave]
|
||||
# Enterprise and premium-licensed features live in features/enterprise/.
|
||||
# They are excluded from the default CI run because the test environment
|
||||
# does not have a commercial licence. To run them explicitly:
|
||||
#
|
||||
# python -m behave features/enterprise
|
||||
#
|
||||
exclude_re = features/enterprise
|
||||
# features/enterprise (needs a commercial licence) and features/multinode (needs the clustered stack in testing/compose/docker-compose-multinode.yml) are excluded by default.
|
||||
# Run either directly, e.g. `python -m behave features/enterprise`.
|
||||
exclude_re = features/(enterprise|multinode)
|
||||
tags = ~@manual
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
@multinode @health
|
||||
Feature: Multi-node cluster health
|
||||
|
||||
Every node must come up healthy and join the shared Valkey backplane - the precondition for the rest of the suite (stack: testing/compose/docker-compose-multinode.yml).
|
||||
|
||||
@smoke
|
||||
Scenario: Both application nodes are healthy
|
||||
Given the multi-node stack is running
|
||||
|
||||
@smoke
|
||||
Scenario: Both nodes joined the Valkey backplane in cluster mode
|
||||
Given the multi-node stack is running
|
||||
And both nodes are cluster members using the Valkey backplane
|
||||
|
||||
Scenario: The load balancer answers the health endpoint
|
||||
Given the multi-node stack is running
|
||||
When I request "/api/v1/info/status" 4 times through the load balancer
|
||||
Then every load-balanced response should be 200
|
||||
@@ -0,0 +1,12 @@
|
||||
@multinode @connections
|
||||
Feature: S3 connections across nodes
|
||||
|
||||
Integration connections are team-scoped, secret-encrypted rows in the shared DB - a connection created via the LB must resolve (secret masked) from every node and deletion must remove it cluster-wide.
|
||||
|
||||
Scenario: A connection created via the LB resolves everywhere, then deletes cluster-wide
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
When I create an S3 connection named "regr_conn_alpha" via the load balancer
|
||||
Then the connection "regr_conn_alpha" should resolve from every node with its secret masked
|
||||
When I delete the connection via the load balancer
|
||||
Then the connection should be gone from every node
|
||||
@@ -0,0 +1,14 @@
|
||||
@multinode @auth
|
||||
Feature: Cross-node authentication
|
||||
|
||||
JWTs are signed RS256 and verified by key id, so the signing keys (stored in the shared DB, private half encrypted) must be shared for a token minted on one node to validate on another.
|
||||
|
||||
Scenario: A token minted through the LB validates on every node directly
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
Then the current token should be accepted by every node
|
||||
|
||||
Scenario: Signing keys are persisted in the shared database, encrypted
|
||||
Given the multi-node stack is running
|
||||
Then the signing keys should be stored in the shared database
|
||||
And every stored private key should be encrypted at rest
|
||||
@@ -0,0 +1,21 @@
|
||||
@multinode @failover @destructive
|
||||
Feature: Node failover and recovery
|
||||
|
||||
Losing a node must not take the service down: the LB drains it and the survivor keeps serving, then the node rejoins healthy on restart. These scenarios kill/restart containers, so they are @destructive - pass --tags=failover to run them.
|
||||
|
||||
@destructive
|
||||
Scenario: The load balancer keeps serving when a node dies
|
||||
Given the multi-node stack is running
|
||||
When I kill node "2"
|
||||
Then the load balancer should still serve requests
|
||||
When I restart node "2"
|
||||
Then node "2" should become healthy again within 120s
|
||||
|
||||
@destructive
|
||||
Scenario: A recovered node validates tokens minted while it was down
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
When I kill node "2"
|
||||
And I restart node "2"
|
||||
Then node "2" should become healthy again within 120s
|
||||
And the current token should be accepted by every node
|
||||
@@ -0,0 +1,15 @@
|
||||
@multinode @loadbalancing
|
||||
Feature: Load balancer distributes across nodes
|
||||
|
||||
Requests must spread across nodes via round-robin with no session affinity, and a client bounced between nodes must never see a spurious failure since the app is stateless at the HTTP layer.
|
||||
|
||||
Scenario: Requests are spread across both nodes
|
||||
Given the multi-node stack is running
|
||||
When I request "/api/v1/info/status" 12 times through the load balancer
|
||||
Then the requests should be served by at least 2 distinct nodes
|
||||
|
||||
Scenario: An authenticated client bounced between nodes never gets a spurious 401
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
When I request "/api/v1/sources" 12 times through the load balancer
|
||||
Then every load-balanced response should be 200
|
||||
@@ -0,0 +1,19 @@
|
||||
@multinode @policy
|
||||
Feature: Policy management across nodes
|
||||
|
||||
A policy is durable state in the shared DB, so creating, editing, or deleting it on any node must be reflected on every other node, and both nodes must register the same trigger types.
|
||||
|
||||
Scenario: A policy created on one node is edited and deleted from the other
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
When I create a policy named "regr_pol_alpha" on node "1"
|
||||
Then the policy "regr_pol_alpha" should be visible from every node
|
||||
When I rename the policy "regr_pol_alpha" to "regr_pol_beta" via the load balancer
|
||||
Then the policy "regr_pol_beta" should be visible from every node
|
||||
When I delete the policy "regr_pol_beta" on node "2"
|
||||
Then the policy "regr_pol_beta" should be absent from every node
|
||||
|
||||
Scenario: Every node registers the same trigger types
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
Then the trigger registry should be identical across nodes
|
||||
@@ -0,0 +1,10 @@
|
||||
@multinode @processor
|
||||
Feature: Policy run visibility across nodes
|
||||
|
||||
Run state is projected into the shared job store, so the run-view endpoints (/policies/runs, /run/{runId}) show a run from any node, not only the one that executed it - no sticky-session LB required.
|
||||
|
||||
Scenario: A run executed on one node is visible from every node
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
When I run the policy "Compress incoming PDFs" on node "1"
|
||||
Then the run should be visible from every node
|
||||
@@ -0,0 +1,20 @@
|
||||
@multinode @processor
|
||||
Feature: Processor pipeline and exactly-once ledger
|
||||
|
||||
A shared processed-file ledger with an atomic (identity_hash, policy_id) primary key ensures exactly-once processing when several nodes race the same scheduled policy; depends on the seeded "Compress incoming PDFs" policy.
|
||||
|
||||
@slow
|
||||
Scenario: Files dropped in the source are processed across the cluster when both
|
||||
nodes trigger the policy at the same instant
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
And the processor workspace is clean
|
||||
When I drop 5 PDF file(s) into the S3 source under "incoming/"
|
||||
And I trigger the policy "Compress incoming PDFs" on every node simultaneously
|
||||
Then within 90s every dropped file should be processed across the cluster
|
||||
|
||||
Scenario: The ledger's atomic claim enforces exactly-once
|
||||
Given the multi-node stack is running
|
||||
Then a duplicate ledger claim for the same file and policy is rejected
|
||||
|
||||
# Note: output lands in the source's own bucket so it can be re-ingested; too flaky to assert under consume-mode pruning.
|
||||
@@ -0,0 +1,9 @@
|
||||
@multinode @ratelimit
|
||||
Feature: Shared rate limiting via the backplane
|
||||
|
||||
Rate limits must be enforced across all nodes, not per node, or a client's effective limit multiplies by the node count - cluster mode routes counters through Valkey (ValkeyRateLimitStore).
|
||||
|
||||
Scenario: Backplane counters are held in Valkey, not per node
|
||||
Given the multi-node stack is running
|
||||
And both nodes are cluster members using the Valkey backplane
|
||||
Then the rate-limit counter should be shared across nodes
|
||||
@@ -0,0 +1,20 @@
|
||||
@multinode @state
|
||||
Feature: Shared state across nodes
|
||||
|
||||
All durable state lives in the shared database, so an object created through the LB (landing on whichever node) is immediately visible from every other node - the cluster is a single logical system.
|
||||
|
||||
Scenario: A team created through the LB lands in the shared database
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
When I create a team named "regr_shared_team" through the load balancer
|
||||
Then the team "regr_shared_team" should exist in the shared database
|
||||
|
||||
Scenario: Every node reports the same sources
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
Then every node should report the same number of sources
|
||||
|
||||
Scenario: The seeded org is present in the shared database
|
||||
Given the multi-node stack is running
|
||||
Then the "users" table should contain at least 40 row(s)
|
||||
And the "integration_configs" table should contain at least 1 row(s)
|
||||
@@ -0,0 +1,25 @@
|
||||
@multinode @sources
|
||||
Feature: Source management across nodes
|
||||
|
||||
Sources are durable state. A source created on one node must be usable from
|
||||
every node, and the guard that stops a referenced source from being deleted
|
||||
must hold across nodes - even when the source and the referencing policy were
|
||||
created on different nodes.
|
||||
|
||||
Scenario: A source created on one node is visible and deletable from the other
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
When I create an S3 source named "regr_src_alpha" on node "1"
|
||||
Then the source "regr_src_alpha" should be visible from every node
|
||||
When I delete the source "regr_src_alpha" on node "2"
|
||||
Then the source "regr_src_alpha" should be absent from every node
|
||||
|
||||
Scenario: A referenced source cannot be deleted from another node
|
||||
Given the multi-node stack is running
|
||||
And I am logged in as admin
|
||||
When I create an S3 source named "regr_src_ref" on node "1"
|
||||
And I create a policy named "regr_pol_ref" referencing source "regr_src_ref" via the load balancer
|
||||
Then deleting the source "regr_src_ref" from node "2" is rejected because it is referenced
|
||||
When I delete the policy "regr_pol_ref" on node "2"
|
||||
And I delete the source "regr_src_ref" on node "1"
|
||||
Then the source "regr_src_ref" should be absent from every node
|
||||
@@ -0,0 +1,595 @@
|
||||
"""Step definitions for the multi-node regression suite: drive the stack (testing/compose/docker-compose-multinode.yml) via the LB, docker exec curl/psql on individual nodes, and a throwaway minio/mc container."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from behave import given, then, when
|
||||
|
||||
LB_URL = "http://localhost:8080"
|
||||
NODES = ["multinode-stirling-1", "multinode-stirling-2"]
|
||||
PG = "multinode-postgres"
|
||||
MINIO = "multinode-minio"
|
||||
BUCKET = "policy-data"
|
||||
SOURCE_PREFIX = "incoming/"
|
||||
OUTPUT_PREFIX = "processed/"
|
||||
ADMIN_USER = "admin"
|
||||
ADMIN_PASS = "stirling"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- helpers
|
||||
def _sh(args, stdin=None, timeout=60):
|
||||
"""Run a command, return (returncode, stdout, stderr)."""
|
||||
r = subprocess.run(
|
||||
args, input=stdin, capture_output=True, timeout=timeout,
|
||||
text=(stdin is None or isinstance(stdin, str)),
|
||||
)
|
||||
out = r.stdout if isinstance(r.stdout, str) else r.stdout.decode("utf-8", "replace")
|
||||
err = r.stderr if isinstance(r.stderr, str) else r.stderr.decode("utf-8", "replace")
|
||||
return r.returncode, out, err
|
||||
|
||||
|
||||
def _psql(query):
|
||||
"""Run a query against the shared Postgres, return the raw tab/newline output (trimmed)."""
|
||||
rc, out, err = _sh(
|
||||
["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling", "-tAc", query]
|
||||
)
|
||||
assert rc == 0, f"psql failed: {err.strip() or out.strip()}"
|
||||
return out.strip()
|
||||
|
||||
|
||||
def _psql_int(query):
|
||||
val = _psql(query)
|
||||
return int(val) if val else 0
|
||||
|
||||
|
||||
def _network():
|
||||
rc, out, _ = _sh(
|
||||
["docker", "inspect", "-f",
|
||||
"{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}", NODES[0]]
|
||||
)
|
||||
return out.strip() or "compose_stirling-multinode"
|
||||
|
||||
|
||||
def _token(context):
|
||||
tok = getattr(context, "jwt_token", None)
|
||||
assert tok, "No JWT token in context - use 'Given I am logged in as admin' first."
|
||||
return tok
|
||||
|
||||
|
||||
def _curl_on_node(node, method, path, token=None, data=None, content_type=None, timeout=30):
|
||||
"""Hit a node's own :8080 from inside the cluster (bypasses the LB). Returns (status, body)."""
|
||||
cmd = ["docker", "exec", node, "curl", "-s", "-w", "\n%{http_code}", "-X", method]
|
||||
if token:
|
||||
cmd += ["-H", f"Authorization: Bearer {token}"]
|
||||
if content_type:
|
||||
cmd += ["-H", f"Content-Type: {content_type}"]
|
||||
if data is not None:
|
||||
cmd += ["--data", data]
|
||||
cmd.append(f"http://localhost:8080{path}")
|
||||
rc, out, err = _sh(cmd, timeout=timeout)
|
||||
# Body then a newline then the status code; split the status off the end.
|
||||
text = out.rstrip("\n")
|
||||
body, _, status = text.rpartition("\n")
|
||||
return (int(status.strip()) if status.strip().isdigit() else 0), body
|
||||
|
||||
|
||||
def _node(idx):
|
||||
return NODES[int(idx) - 1]
|
||||
|
||||
|
||||
def _names_on_node(node, path, token):
|
||||
"""GET a list endpoint on a node and return the set of resource names it reports."""
|
||||
status, body = _curl_on_node(node, "GET", path, token=token)
|
||||
assert status == 200, f"{node} GET {path} returned HTTP {status}: {body[:150]}"
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
return set()
|
||||
items = data if isinstance(data, list) else data.get(path.rsplit("/", 1)[-1], []) \
|
||||
or data.get("sources", []) or data.get("policies", [])
|
||||
return {i.get("name") for i in items if isinstance(i, dict)}
|
||||
|
||||
|
||||
def _policy_body(name, source_ids=None, enabled=True):
|
||||
return json.dumps({
|
||||
"name": name, "enabled": enabled, "trigger": None,
|
||||
"sourceIds": source_ids or [],
|
||||
"steps": [{"operation": "/api/v1/misc/compress-pdf", "parameters": {}}],
|
||||
"output": {"type": "inline", "options": {}},
|
||||
})
|
||||
|
||||
|
||||
def _any_connection_id(context):
|
||||
"""Id of any usable S3 connection (the seed creates one). Cached on the context."""
|
||||
cid = getattr(context, "_seed_conn_id", None)
|
||||
if cid:
|
||||
return cid
|
||||
r = requests.get(f"{LB_URL}/api/v1/integrations",
|
||||
headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
|
||||
assert r.status_code == 200, f"list integrations failed: HTTP {r.status_code}"
|
||||
s3 = next((c for c in r.json() if c.get("integrationType") == "S3"), None)
|
||||
assert s3, "no S3 connection available (did the seed run?)"
|
||||
context._seed_conn_id = s3["id"]
|
||||
return s3["id"]
|
||||
|
||||
|
||||
def _s3_source_body(name, connection_id):
|
||||
# Folder sources are config-gated; S3 sources against the seeded connection always work.
|
||||
return json.dumps({
|
||||
"name": name, "type": "s3",
|
||||
"options": {"connectionId": connection_id, "prefix": "regr/", "mode": "snapshot"},
|
||||
"enabled": True,
|
||||
})
|
||||
|
||||
|
||||
def _source_id_by_name(context, name):
|
||||
r = requests.get(f"{LB_URL}/api/v1/sources",
|
||||
headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
|
||||
assert r.status_code == 200, f"list sources failed: HTTP {r.status_code}"
|
||||
return next((s["id"] for s in r.json().get("sources", []) if s.get("name") == name), None)
|
||||
|
||||
|
||||
def _s3_connection_body(name):
|
||||
return json.dumps({
|
||||
"integrationType": "S3", "name": name, "scope": "SERVER", "enabled": True,
|
||||
"locked": False, "defaultAccess": "ORG_ALL",
|
||||
"config": {"bucket": BUCKET, "region": "us-east-1", "endpoint": "http://minio:9000",
|
||||
"accessKeyId": "minioadmin", "secretAccessKey": "minioadmin",
|
||||
"pathStyleAccess": True},
|
||||
})
|
||||
|
||||
|
||||
def _lb_login(context):
|
||||
r = requests.post(f"{LB_URL}/api/v1/auth/login",
|
||||
json={"username": ADMIN_USER, "password": ADMIN_PASS}, timeout=15)
|
||||
assert r.status_code == 200, f"admin login via LB failed: HTTP {r.status_code}"
|
||||
context.jwt_token = r.json()["session"]["access_token"]
|
||||
|
||||
|
||||
def _pdf_bytes(marker):
|
||||
"""A minimal valid single-page PDF carrying a unique marker (so outputs are identifiable)."""
|
||||
try:
|
||||
from reportlab.pdfgen import canvas
|
||||
buf = io.BytesIO()
|
||||
c = canvas.Canvas(buf)
|
||||
c.drawString(100, 750, f"multinode-regression {marker}")
|
||||
c.showPage()
|
||||
c.save()
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
# Fallback: a hand-rolled minimal PDF if reportlab is unavailable.
|
||||
return (b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
|
||||
b"trailer<</Root 1 0 R>>\n%%EOF")
|
||||
|
||||
|
||||
def _mc(context, script, stdin=None):
|
||||
"""Run an mc script in a throwaway minio/mc container on the cluster network."""
|
||||
net = getattr(context, "_net", None) or _network()
|
||||
context._net = net
|
||||
full = f"mc alias set local http://minio:9000 minioadmin minioadmin >/dev/null 2>&1 && {script}"
|
||||
args = ["docker", "run", "-i", "--rm", "--network", net, "--entrypoint", "/bin/sh",
|
||||
"minio/mc", "-c", full]
|
||||
return _sh(args, stdin=stdin, timeout=90)
|
||||
|
||||
|
||||
def _policy_id_by_name(context, name):
|
||||
r = requests.get(f"{LB_URL}/api/v1/policies",
|
||||
headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
|
||||
assert r.status_code == 200, f"list policies failed: HTTP {r.status_code}"
|
||||
data = r.json()
|
||||
items = data if isinstance(data, list) else data.get("policies", [])
|
||||
for p in items:
|
||||
if p.get("name") == name:
|
||||
return p.get("id")
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- preconditions
|
||||
@given("the multi-node stack is running")
|
||||
def step_stack_running(context):
|
||||
for node in NODES:
|
||||
rc, out, _ = _sh(["docker", "inspect", "-f", "{{.State.Health.Status}}", node])
|
||||
assert rc == 0 and out.strip() == "healthy", f"{node} is not healthy (got '{out.strip()}')"
|
||||
context._net = _network()
|
||||
|
||||
|
||||
@given("both nodes are cluster members using the Valkey backplane")
|
||||
def step_cluster_members(context):
|
||||
for node in NODES:
|
||||
rc, out, err = _sh(["docker", "logs", node])
|
||||
logs = out + err
|
||||
assert "backplane=valkey" in logs, f"{node} did not join the Valkey backplane"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- load balancer
|
||||
@when('I request "{endpoint}" {count:d} times through the load balancer')
|
||||
def step_lb_requests(context, endpoint, count):
|
||||
context._served_by = []
|
||||
context._lb_statuses = []
|
||||
headers = {}
|
||||
if getattr(context, "jwt_token", None):
|
||||
headers["Authorization"] = f"Bearer {context.jwt_token}"
|
||||
for _ in range(count):
|
||||
r = requests.get(f"{LB_URL}{endpoint}", headers=headers, timeout=15)
|
||||
context._lb_statuses.append(r.status_code)
|
||||
node = r.headers.get("X-Served-By")
|
||||
if node:
|
||||
context._served_by.append(node)
|
||||
|
||||
|
||||
@then("the requests should be served by at least {n:d} distinct nodes")
|
||||
def step_distinct_nodes(context, n):
|
||||
distinct = set(context._served_by)
|
||||
assert len(distinct) >= n, (
|
||||
f"expected >= {n} distinct upstreams, saw {sorted(distinct)} "
|
||||
f"(is the X-Served-By header configured on the LB?)")
|
||||
|
||||
|
||||
@then("every load-balanced response should be {code:d}")
|
||||
def step_all_lb_ok(context, code):
|
||||
bad = [s for s in context._lb_statuses if s != code]
|
||||
assert not bad, f"expected all {code}, got failures: {bad}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- cross-node auth
|
||||
@then("the current token should be accepted by every node")
|
||||
def step_token_every_node(context):
|
||||
token = _token(context)
|
||||
for node in NODES:
|
||||
status, body = _curl_on_node(node, "GET", "/api/v1/sources", token=token)
|
||||
assert status == 200, f"{node} rejected the LB-minted token (HTTP {status}): {body[:200]}"
|
||||
|
||||
|
||||
@then("the signing keys should be stored in the shared database")
|
||||
def step_keys_in_db(context):
|
||||
assert _psql_int("select count(*) from jwt_signing_keys") >= 1, \
|
||||
"no rows in jwt_signing_keys - keys are not persisted in the shared DB"
|
||||
|
||||
|
||||
@then("every stored private key should be encrypted at rest")
|
||||
def step_keys_encrypted(context):
|
||||
# A plaintext PKCS#8 RSA key base64 begins with 'MII'; an encrypted blob does not.
|
||||
plaintext = _psql_int("select count(*) from jwt_signing_keys where signing_key like 'MII%'")
|
||||
assert plaintext == 0, f"{plaintext} signing key(s) look like plaintext PKCS#8 (not encrypted)"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- shared state
|
||||
@when('I create a team named "{name}" through the load balancer')
|
||||
def step_create_team(context, name):
|
||||
context._team_name = name
|
||||
r = requests.post(f"{LB_URL}/api/v1/team/create",
|
||||
headers={"Authorization": f"Bearer {_token(context)}"},
|
||||
data={"name": name}, timeout=15)
|
||||
assert r.status_code in (200, 201, 409), f"create team failed: HTTP {r.status_code}"
|
||||
|
||||
|
||||
@then('the team "{name}" should exist in the shared database')
|
||||
def step_team_in_db(context, name):
|
||||
n = _psql_int(f"select count(*) from teams where name = '{name}'")
|
||||
assert n >= 1, f"team '{name}' not found in the shared DB"
|
||||
|
||||
|
||||
@then("every node should report the same number of sources")
|
||||
def step_same_sources(context):
|
||||
token = _token(context)
|
||||
counts = {}
|
||||
for node in NODES:
|
||||
status, body = _curl_on_node(node, "GET", "/api/v1/sources", token=token)
|
||||
assert status == 200, f"{node} /sources returned HTTP {status}"
|
||||
counts[node] = body.count('"id"')
|
||||
values = set(counts.values())
|
||||
assert len(values) == 1 and values != {0}, f"source counts differ across nodes: {counts}"
|
||||
|
||||
|
||||
@then('the "{table}" table should contain at least {n:d} row(s)')
|
||||
def step_table_rows(context, table, n):
|
||||
got = _psql_int(f"select count(*) from {table}")
|
||||
assert got >= n, f"{table} has {got} rows, expected >= {n}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- processor / ledger
|
||||
@given("the processor workspace is clean")
|
||||
def step_clean_workspace(context):
|
||||
# Start from a known state so file/output/ledger counts reflect only this scenario.
|
||||
_mc(context, f"mc rm --recursive --force local/{BUCKET}/{SOURCE_PREFIX} || true")
|
||||
_mc(context, f"mc rm --recursive --force local/{BUCKET}/{OUTPUT_PREFIX} || true")
|
||||
_psql("delete from policy_processed_files")
|
||||
|
||||
|
||||
@when('I drop {count:d} PDF file(s) into the S3 source under "{prefix}"')
|
||||
def step_drop_files(context, count, prefix):
|
||||
context._dropped = []
|
||||
for _ in range(count):
|
||||
marker = uuid.uuid4().hex[:12]
|
||||
key = f"{prefix}regr-{marker}.pdf"
|
||||
rc, out, err = _mc(context, f"mc pipe local/{BUCKET}/{key}", stdin=_pdf_bytes(marker))
|
||||
assert rc == 0, f"failed to upload {key}: {err.strip() or out.strip()}"
|
||||
context._dropped.append(key)
|
||||
|
||||
|
||||
@when('I trigger the policy "{name}" on every node simultaneously')
|
||||
def step_trigger_all_nodes(context, name):
|
||||
pid = _policy_id_by_name(context, name)
|
||||
assert pid, f"policy '{name}' not found"
|
||||
context._policy_id = pid
|
||||
token = _token(context)
|
||||
# Fire the trigger on both nodes as close together as possible to race the ledger claim.
|
||||
for node in NODES:
|
||||
_curl_on_node(node, "POST", f"/api/v1/policies/{pid}/trigger", token=token, timeout=15)
|
||||
|
||||
|
||||
@then("within {seconds:d}s every dropped file should be processed across the cluster")
|
||||
def step_files_processed(context, seconds):
|
||||
# Consume-mode deletes processed files, so an empty source prefix signals every file was processed (ledger rows are pruned too, so counting them would be racy).
|
||||
deadline = time.monotonic() + seconds
|
||||
remaining = None
|
||||
while time.monotonic() < deadline:
|
||||
rc, out, _ = _mc(context, f"mc ls --recursive local/{BUCKET}/{SOURCE_PREFIX} | wc -l")
|
||||
remaining = int(out.strip() or "0") if rc == 0 else -1
|
||||
if remaining == 0:
|
||||
break
|
||||
time.sleep(3)
|
||||
assert remaining == 0, (
|
||||
f"{remaining} of {len(context._dropped)} dropped files were still unprocessed after "
|
||||
f"{seconds}s")
|
||||
for node in NODES:
|
||||
rc, out, _ = _sh(["docker", "inspect", "-f", "{{.State.Status}}", node])
|
||||
assert out.strip() == "running", f"{node} crashed during concurrent processing"
|
||||
|
||||
|
||||
@then("a duplicate ledger claim for the same file and policy is rejected")
|
||||
def step_ledger_claim_atomic(context):
|
||||
# Exactly-once relies on the (identity_hash, policy_id) primary key: two nodes claiming the same file both insert it, but only one wins; this proves the constraint rejects the second claim.
|
||||
ihash = "regr-" + uuid.uuid4().hex
|
||||
pol = "regr-policy-" + uuid.uuid4().hex[:8]
|
||||
insert = (f"insert into policy_processed_files (identity_hash, policy_id, status, attempts) "
|
||||
f"values ('{ihash}', '{pol}', 'PROCESSING', 1)")
|
||||
_psql(insert) # first claim wins
|
||||
rc, out, err = _sh(["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling",
|
||||
"-tAc", insert]) # second claim must be rejected
|
||||
_psql(f"delete from policy_processed_files where identity_hash = '{ihash}'")
|
||||
assert rc != 0 and "duplicate key" in (out + err).lower(), (
|
||||
"a second claim for the same file and policy was NOT rejected - the ledger's exactly-once "
|
||||
"guarantee is not enforced by the primary key")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- policy run coordination (gap)
|
||||
@when('I run the policy "{name}" on node "{idx}"')
|
||||
def step_run_policy_on_node(context, name, idx):
|
||||
node = _node(idx)
|
||||
context._run_node = node
|
||||
pid = _policy_id_by_name(context, name)
|
||||
assert pid, f"policy '{name}' not found"
|
||||
# Drop an input so the trigger actually produces a run (the source is otherwise empty).
|
||||
marker = uuid.uuid4().hex[:12]
|
||||
_mc(context, f"mc pipe local/{BUCKET}/{SOURCE_PREFIX}runvis-{marker}.pdf",
|
||||
stdin=_pdf_bytes(marker))
|
||||
status, _ = _curl_on_node(node, "POST", f"/api/v1/policies/{pid}/trigger", token=_token(context))
|
||||
assert status in (200, 202), f"triggering the policy on {node} failed: HTTP {status}"
|
||||
# Grab the runId that node recorded for the run it just executed.
|
||||
context._run_id = None
|
||||
for _ in range(8):
|
||||
s, body = _curl_on_node(node, "GET", "/api/v1/policies/runs", token=_token(context))
|
||||
runs = json.loads(body) if s == 200 and body.strip().startswith("[") else []
|
||||
if runs:
|
||||
context._run_id = runs[0]["runId"]
|
||||
break
|
||||
time.sleep(2)
|
||||
assert context._run_id, f"{node} recorded no run after triggering '{name}'"
|
||||
|
||||
|
||||
@then("the run should be visible from every node")
|
||||
def step_run_visible_every(context):
|
||||
for node in NODES:
|
||||
status, body = _curl_on_node(node, "GET", "/api/v1/policies/runs", token=_token(context))
|
||||
assert status == 200, f"{node} /policies/runs returned HTTP {status}"
|
||||
run_ids = [r.get("runId") for r in json.loads(body)] if body.strip().startswith("[") else []
|
||||
assert context._run_id in run_ids, (
|
||||
f"run {context._run_id} (executed on {context._run_node}) is not visible from {node} - "
|
||||
f"PolicyRunRegistry is a per-node in-JVM map, so run status and cancellation do not "
|
||||
f"cross nodes")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- rate limiting
|
||||
@then("the rate-limit counter should be shared across nodes")
|
||||
def step_ratelimit_shared(context):
|
||||
# In cluster mode the ValkeyRateLimitStore holds counters in Valkey; probe that a key exists.
|
||||
net = context._net or _network()
|
||||
rc, out, err = _sh(["docker", "run", "--rm", "--network", net, "--entrypoint", "/bin/sh",
|
||||
"valkey/valkey:8-alpine", "-c",
|
||||
"valkey-cli -h valkey keys '*'"], timeout=30)
|
||||
assert rc == 0, f"valkey probe failed: {err.strip()}"
|
||||
assert out.strip(), "no keys in Valkey - rate-limit/backplane state is not shared"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- failover
|
||||
@when('I kill node "{idx}"')
|
||||
def step_kill_node(context, idx):
|
||||
node = NODES[int(idx) - 1]
|
||||
context._killed = getattr(context, "_killed", [])
|
||||
_sh(["docker", "kill", node])
|
||||
context._killed.append(node)
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
@then("the load balancer should still serve requests")
|
||||
def step_lb_survives(context):
|
||||
ok = 0
|
||||
for _ in range(6):
|
||||
try:
|
||||
r = requests.get(f"{LB_URL}/api/v1/info/status", timeout=10)
|
||||
if r.status_code == 200:
|
||||
ok += 1
|
||||
except requests.RequestException:
|
||||
pass
|
||||
assert ok >= 4, f"LB served only {ok}/6 requests with a node down (does it drain dead nodes?)"
|
||||
|
||||
|
||||
@when('I restart node "{idx}"')
|
||||
def step_restart_node(context, idx):
|
||||
node = NODES[int(idx) - 1]
|
||||
_sh(["docker", "start", node])
|
||||
context._killed = [n for n in getattr(context, "_killed", []) if n != node]
|
||||
|
||||
|
||||
@then('node "{idx}" should become healthy again within {seconds:d}s')
|
||||
def step_node_recovers(context, idx, seconds):
|
||||
node = NODES[int(idx) - 1]
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
rc, out, _ = _sh(["docker", "inspect", "-f", "{{.State.Health.Status}}", node])
|
||||
if out.strip() == "healthy":
|
||||
return
|
||||
time.sleep(5)
|
||||
raise AssertionError(f"{node} did not become healthy within {seconds}s")
|
||||
|
||||
|
||||
# ------------------------------------------------- policy / source / connection management (CRUD)
|
||||
def _auth(context):
|
||||
return {"Authorization": f"Bearer {_token(context)}"}
|
||||
|
||||
|
||||
# --- policies ---
|
||||
@when('I create a policy named "{name}" on node "{idx}"')
|
||||
def step_create_policy_on_node(context, name, idx):
|
||||
status, body = _curl_on_node(_node(idx), "POST", "/api/v1/policies", token=_token(context),
|
||||
data=_policy_body(name), content_type="application/json")
|
||||
assert status in (200, 201), f"create policy on {_node(idx)} failed: HTTP {status}: {body[:200]}"
|
||||
|
||||
|
||||
@when('I create a policy named "{name}" referencing source "{src}" via the load balancer')
|
||||
def step_create_policy_ref(context, name, src):
|
||||
sid = _source_id_by_name(context, src)
|
||||
assert sid, f"source '{src}' not found"
|
||||
r = requests.post(f"{LB_URL}/api/v1/policies",
|
||||
headers={**_auth(context), "Content-Type": "application/json"},
|
||||
data=_policy_body(name, [sid]), timeout=15)
|
||||
assert r.status_code in (200, 201), f"create referencing policy failed: HTTP {r.status_code}"
|
||||
|
||||
|
||||
@when('I rename the policy "{old}" to "{new}" via the load balancer')
|
||||
def step_rename_policy(context, old, new):
|
||||
pid = _policy_id_by_name(context, old)
|
||||
assert pid, f"policy '{old}' not found"
|
||||
pol = requests.get(f"{LB_URL}/api/v1/policies/{pid}", headers=_auth(context), timeout=15).json()
|
||||
pol["name"] = new
|
||||
r = requests.post(f"{LB_URL}/api/v1/policies",
|
||||
headers={**_auth(context), "Content-Type": "application/json"},
|
||||
json=pol, timeout=15)
|
||||
assert r.status_code in (200, 201), f"rename policy failed: HTTP {r.status_code}"
|
||||
|
||||
|
||||
@when('I delete the policy "{name}" on node "{idx}"')
|
||||
def step_delete_policy_on_node(context, name, idx):
|
||||
pid = _policy_id_by_name(context, name)
|
||||
assert pid, f"policy '{name}' not found"
|
||||
status, body = _curl_on_node(_node(idx), "DELETE", f"/api/v1/policies/{pid}", token=_token(context))
|
||||
assert status in (200, 204), f"delete policy on {_node(idx)} failed: HTTP {status}"
|
||||
|
||||
|
||||
@then('the policy "{name}" should be visible from every node')
|
||||
def step_policy_visible(context, name):
|
||||
for node in NODES:
|
||||
names = _names_on_node(node, "/api/v1/policies", _token(context))
|
||||
assert name in names, f"{node} does not see policy '{name}' (sees {sorted(names)})"
|
||||
|
||||
|
||||
@then('the policy "{name}" should be absent from every node')
|
||||
def step_policy_absent(context, name):
|
||||
for node in NODES:
|
||||
names = _names_on_node(node, "/api/v1/policies", _token(context))
|
||||
assert name not in names, f"{node} still sees deleted policy '{name}'"
|
||||
|
||||
|
||||
@then("the trigger registry should be identical across nodes")
|
||||
def step_triggers_identical(context):
|
||||
seen = {}
|
||||
for node in NODES:
|
||||
status, body = _curl_on_node(node, "GET", "/api/v1/policies/triggers", token=_token(context))
|
||||
assert status == 200, f"{node} GET /policies/triggers returned HTTP {status}"
|
||||
items = json.loads(body)
|
||||
# Trigger descriptors are dicts; normalise to a canonical, order-independent form.
|
||||
seen[node] = sorted(json.dumps(t, sort_keys=True) for t in items)
|
||||
a, b = (seen[n] for n in NODES)
|
||||
assert a == b, f"trigger registry differs across nodes: {seen}"
|
||||
|
||||
|
||||
# --- sources ---
|
||||
@when('I create an S3 source named "{name}" on node "{idx}"')
|
||||
def step_create_source_on_node(context, name, idx):
|
||||
conn = _any_connection_id(context)
|
||||
status, body = _curl_on_node(_node(idx), "POST", "/api/v1/sources", token=_token(context),
|
||||
data=_s3_source_body(name, conn), content_type="application/json")
|
||||
assert status in (200, 201), f"create source on {_node(idx)} failed: HTTP {status}: {body[:200]}"
|
||||
|
||||
|
||||
@when('I delete the source "{name}" on node "{idx}"')
|
||||
def step_delete_source_on_node(context, name, idx):
|
||||
sid = _source_id_by_name(context, name)
|
||||
assert sid, f"source '{name}' not found"
|
||||
status, body = _curl_on_node(_node(idx), "DELETE", f"/api/v1/sources/{sid}", token=_token(context))
|
||||
assert status in (200, 204), f"delete source on {_node(idx)} failed: HTTP {status}"
|
||||
|
||||
|
||||
@then('the source "{name}" should be visible from every node')
|
||||
def step_source_visible(context, name):
|
||||
for node in NODES:
|
||||
names = _names_on_node(node, "/api/v1/sources", _token(context))
|
||||
assert name in names, f"{node} does not see source '{name}' (sees {sorted(names)})"
|
||||
|
||||
|
||||
@then('the source "{name}" should be absent from every node')
|
||||
def step_source_absent(context, name):
|
||||
for node in NODES:
|
||||
names = _names_on_node(node, "/api/v1/sources", _token(context))
|
||||
assert name not in names, f"{node} still sees deleted source '{name}'"
|
||||
|
||||
|
||||
@then('deleting the source "{name}" from node "{idx}" is rejected because it is referenced')
|
||||
def step_source_delete_guarded(context, name, idx):
|
||||
sid = _source_id_by_name(context, name)
|
||||
assert sid, f"source '{name}' not found"
|
||||
status, body = _curl_on_node(_node(idx), "DELETE", f"/api/v1/sources/{sid}", token=_token(context))
|
||||
assert status == 409, (
|
||||
f"expected 409 (source referenced by a policy created on another node), got HTTP {status}")
|
||||
|
||||
|
||||
# --- connections (integration configs) ---
|
||||
@when('I create an S3 connection named "{name}" via the load balancer')
|
||||
def step_create_conn(context, name):
|
||||
r = requests.post(f"{LB_URL}/api/v1/integrations",
|
||||
headers={**_auth(context), "Content-Type": "application/json"},
|
||||
data=_s3_connection_body(name), timeout=15)
|
||||
assert r.status_code in (200, 201), f"create connection failed: HTTP {r.status_code}: {r.text[:200]}"
|
||||
context._conn_id = r.json()["id"]
|
||||
|
||||
|
||||
@then('the connection "{name}" should resolve from every node with its secret masked')
|
||||
def step_conn_resolves(context, name):
|
||||
cid = context._conn_id
|
||||
for node in NODES:
|
||||
status, body = _curl_on_node(node, "GET", f"/api/v1/integrations/{cid}", token=_token(context))
|
||||
assert status == 200, f"{node} cannot resolve connection {cid}: HTTP {status}"
|
||||
secret = json.loads(body).get("config", {}).get("secretAccessKey")
|
||||
assert secret in (None, "", "********"), f"{node} leaked the connection secret on read"
|
||||
|
||||
|
||||
@when('I delete the connection via the load balancer')
|
||||
def step_delete_conn(context):
|
||||
r = requests.delete(f"{LB_URL}/api/v1/integrations/{context._conn_id}",
|
||||
headers=_auth(context), timeout=15)
|
||||
assert r.status_code in (200, 204), f"delete connection failed: HTTP {r.status_code}"
|
||||
|
||||
|
||||
@then("the connection should be gone from every node")
|
||||
def step_conn_absent(context):
|
||||
cid = context._conn_id
|
||||
for node in NODES:
|
||||
status, _ = _curl_on_node(node, "GET", f"/api/v1/integrations/{cid}", token=_token(context))
|
||||
assert status in (403, 404), f"{node} still resolves deleted connection {cid}: HTTP {status}"
|
||||
Reference in New Issue
Block a user