Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88eb6cf6cd | ||
|
|
0570c4c4d9 | ||
|
|
776749277c | ||
|
|
41b1b89fcb | ||
|
|
4d4e994562 | ||
|
|
2b118556f3 | ||
|
|
fbaff56d1c | ||
|
|
38ccea074c | ||
|
|
a7307ff393 | ||
|
|
72729e99c1 | ||
|
|
5fba2720f0 | ||
|
|
01a1ef8c44 | ||
|
|
8535c7e9ac | ||
|
|
cca3f42623 | ||
|
|
1b7ffcdbac | ||
|
|
67a0ca6110 | ||
|
|
8e4b2e2fc6 | ||
|
|
3c93457021 |
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.1
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.1
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -510,6 +510,7 @@ jobs:
|
||||
# cargo output unsigned, so checking it produces false negatives.
|
||||
- name: Verify Windows Code Signature
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
run: |
|
||||
$allSigned = $true
|
||||
@@ -531,11 +532,26 @@ jobs:
|
||||
|
||||
# Extract MSI and verify the inner exe (the file that actually gets installed).
|
||||
# This is the critical check - AV flags the installed exe at runtime.
|
||||
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
|
||||
# _MSIExecute mutex and hangs forever on hosted runners when another
|
||||
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
|
||||
$msi = $msiFiles[0].FullName
|
||||
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
|
||||
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
|
||||
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||
|
||||
choco install lessmsi -y --no-progress --limit-output | Out-Null
|
||||
|
||||
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
|
||||
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
|
||||
if (-not $proc.WaitForExit(120000)) {
|
||||
try { $proc.Kill() } catch {}
|
||||
Write-Host "[ERROR] MSI extraction timed out after 120s"
|
||||
$allSigned = $false
|
||||
} elseif ($proc.ExitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
|
||||
if ($innerExe) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
|
||||
@@ -548,9 +564,6 @@ jobs:
|
||||
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
|
||||
$allSigned = $false
|
||||
}
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
}
|
||||
|
||||
if (-not $allSigned) {
|
||||
@@ -800,7 +813,11 @@ jobs:
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
# Don't regenerate/append notes on re-runs, and don't force this into the
|
||||
# "Latest" slot - leave the release body and latest marker as they are.
|
||||
generate_release_notes: false
|
||||
append_body: false
|
||||
make_latest: false
|
||||
fail_on_unmatched_files: true
|
||||
# Installers + updater payloads + manifest. .sig contents are embedded
|
||||
# in latest.json so the .sig files themselves are not uploaded.
|
||||
|
||||
@@ -208,6 +208,18 @@
|
||||
"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"
|
||||
|
||||
@@ -254,9 +254,6 @@ public class ApplicationProperties {
|
||||
* in-network object store.
|
||||
*/
|
||||
private boolean allowPrivateS3Endpoints = false;
|
||||
|
||||
/** Max inbound webhook body (bytes); over it is rejected 413. Default 100 MB. */
|
||||
private long webhookMaxBytes = 104857600L;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -198,8 +198,6 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith("/readiness")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
// Webhook receiver: authenticated per-request by HMAC signature, not a session.
|
||||
|| trimmedUri.startsWith("/api/v1/webhooks/")
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
// Workflow participant endpoints - access controlled by share tokens, not login
|
||||
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
|
||||
|
||||
@@ -176,13 +176,6 @@ class RequestUriUtilsTest {
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/convert", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_webhookReceiver() {
|
||||
// The webhook source receiver authenticates each delivery by HMAC signature, not a session.
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/whk_abc123", ""));
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/api/v1/webhooks/whk_abc123", "/app"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_withContextPath() {
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/login", "/app"));
|
||||
|
||||
@@ -66,6 +66,20 @@ 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}"
|
||||
|
||||
+38
-17
@@ -8,12 +8,14 @@ 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;
|
||||
@@ -24,18 +26,24 @@ 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 HTML string to a PDF via WeasyPrint.
|
||||
* Dispatchable tool that converts an AI-generated document model 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 HTML comes from a trusted Jinja
|
||||
* template so sanitization is intentionally skipped.
|
||||
* 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.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@@ -48,6 +56,9 @@ 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
|
||||
@@ -74,32 +85,42 @@ public class CreatePdfAgentController {
|
||||
value = "/create-pdf-from-html-agent",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Convert AI-generated HTML to a PDF",
|
||||
summary = "Convert an AI-generated document to a PDF",
|
||||
description =
|
||||
"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)
|
||||
"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)
|
||||
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 HTML to PDF via WeasyPrint — html_bytes={}",
|
||||
htmlContent.length());
|
||||
"[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}",
|
||||
html.length());
|
||||
|
||||
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
|
||||
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
|
||||
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
|
||||
Files.writeString(htmlFile.getPath(), html, 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());
|
||||
|
||||
@@ -126,8 +147,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 document = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
document.save(tempOut.getPath().toFile());
|
||||
try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
pdDocument.save(tempOut.getPath().toFile());
|
||||
} catch (Exception e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
-7
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.input;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
|
||||
@@ -23,12 +22,6 @@ public interface InputSource {
|
||||
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
|
||||
default void validate(InputSpec spec) {}
|
||||
|
||||
/** Normalise a source's options before persistence (default no-op); must not mutate the arg. */
|
||||
default Map<String, Object> prepareOptionsForSave(
|
||||
Map<String, Object> options, boolean isCreate) {
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the spec into zero or more units of work, each carrying one run's files and a
|
||||
* completion hook. Empty list means nothing to run right now. Discovery is read-only - files
|
||||
|
||||
-191
@@ -1,191 +0,0 @@
|
||||
package stirling.software.proprietary.policy.input;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.FileReadinessChecker;
|
||||
import stirling.software.proprietary.policy.ledger.FolderIdentities;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookConfig;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookIds;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookSpool;
|
||||
|
||||
/** Push source: deliveries staged to an S3 connection or the local spool, read via the ledger. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class WebhookInputSource implements InputSource {
|
||||
|
||||
static final String TYPE = "webhook";
|
||||
|
||||
private final WebhookSpool spool;
|
||||
private final FileReadinessChecker readinessChecker;
|
||||
// Connection-backed staging delegates to the S3 source; else the local spool.
|
||||
private final S3InputSource s3InputSource;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(InputSpec spec) {
|
||||
return spec != null && TYPE.equals(spec.type());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(InputSpec spec) {
|
||||
WebhookConfig config = WebhookConfig.from(spec.options());
|
||||
if (config.usesConnection()) {
|
||||
// Resolve with the caller present so save fails if they can't use the connection.
|
||||
s3InputSource.validate(stagingSpec(config));
|
||||
}
|
||||
}
|
||||
|
||||
/** The webhook's durable staging as an {@code s3} input spec for {@link S3InputSource}. */
|
||||
private static InputSpec stagingSpec(WebhookConfig config) {
|
||||
return new InputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
WebhookConfig.CONNECTION_ID_OPTION,
|
||||
config.connectionId(),
|
||||
"prefix",
|
||||
config.stagingPrefix(),
|
||||
"mode",
|
||||
config.mode()));
|
||||
}
|
||||
|
||||
/** Mint the routing id + signing secret on create; an existing webhook is left untouched. */
|
||||
@Override
|
||||
public Map<String, Object> prepareOptionsForSave(
|
||||
Map<String, Object> options, boolean isCreate) {
|
||||
boolean hasId =
|
||||
options.get(WebhookConfig.WEBHOOK_ID_OPTION) != null
|
||||
&& !options.get(WebhookConfig.WEBHOOK_ID_OPTION).toString().isBlank();
|
||||
if (!isCreate && hasId) {
|
||||
return options;
|
||||
}
|
||||
Map<String, Object> prepared = new LinkedHashMap<>(options);
|
||||
if (!hasId) {
|
||||
prepared.put(WebhookConfig.WEBHOOK_ID_OPTION, WebhookIds.newWebhookId());
|
||||
}
|
||||
Object secret = prepared.get(WebhookConfig.SIGNING_SECRET_OPTION);
|
||||
if (secret == null || secret.toString().isBlank()) {
|
||||
prepared.put(WebhookConfig.SIGNING_SECRET_OPTION, WebhookIds.newSigningSecret());
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
|
||||
WebhookConfig config = WebhookConfig.from(spec.options());
|
||||
if (config.usesConnection()) {
|
||||
// Durable staging read by the S3 source (no principal; the save-time check is trusted).
|
||||
return s3InputSource.resolve(stagingSpec(config), ctx);
|
||||
}
|
||||
Path dir = spool.dirFor(config.webhookId());
|
||||
if (!Files.isDirectory(dir)) {
|
||||
// No deliveries yet: verifiably empty (a missing dir is normal here, not an error).
|
||||
ctx.reportPresent(List.of());
|
||||
return List.of();
|
||||
}
|
||||
Path canonicalDir = FolderIdentities.canonicalDir(dir);
|
||||
List<Path> present = listFiles(dir);
|
||||
|
||||
if (config.snapshot()) {
|
||||
List<ResolvedInput> work = new ArrayList<>();
|
||||
for (Path file : present) {
|
||||
if (readinessChecker.isReady(file)) {
|
||||
work.add(ResolvedInput.of(PolicyInputs.of(List.of(fileResource(file)))));
|
||||
}
|
||||
}
|
||||
return work;
|
||||
}
|
||||
|
||||
ctx.reportPresent(
|
||||
present.stream()
|
||||
.map(file -> FolderIdentities.identity(canonicalDir, dir, file))
|
||||
.toList());
|
||||
|
||||
List<ResolvedInput> work = new ArrayList<>();
|
||||
for (Path file : present) {
|
||||
if (!readinessChecker.isReady(file)) {
|
||||
continue;
|
||||
}
|
||||
String identity = FolderIdentities.identity(canonicalDir, dir, file);
|
||||
String gate;
|
||||
boolean claimed;
|
||||
try {
|
||||
gate = FolderIdentities.statGate(file);
|
||||
claimed = ctx.claim(identity, gate, null);
|
||||
} catch (IOException | UncheckedIOException e) {
|
||||
log.debug("Could not read {} for its version: {}", file, e.getMessage());
|
||||
continue; // vanished or unreadable mid-sweep; the next sweep sees the truth
|
||||
}
|
||||
if (!claimed) {
|
||||
continue;
|
||||
}
|
||||
work.add(
|
||||
new ResolvedInput(
|
||||
PolicyInputs.of(List.of(fileResource(file))),
|
||||
success -> completeConsumed(ctx, identity, file, gate, success)));
|
||||
}
|
||||
return work;
|
||||
}
|
||||
|
||||
/** Settle, then delete when unchanged and every claimant settled DONE (consensus). */
|
||||
private static void completeConsumed(
|
||||
ResolveContext ctx, String identity, Path file, String claimGate, boolean success) {
|
||||
ctx.settle(identity, claimGate, null, success);
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (FolderIdentities.statGate(file).equals(claimGate) && ctx.allSettledDone(identity)) {
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
} catch (java.nio.file.NoSuchFileException alreadyGone) {
|
||||
// Removed by the user or a co-watching policy's own consensus delete: nothing to do.
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not remove consumed webhook delivery {}: {}", file, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Every non-hidden regular file currently spooled for the webhook. */
|
||||
private static List<Path> listFiles(Path dir) throws IOException {
|
||||
List<Path> files = new ArrayList<>();
|
||||
try (Stream<Path> entries = Files.list(dir)) {
|
||||
entries.filter(Files::isRegularFile)
|
||||
.filter(file -> !file.getFileName().toString().startsWith("."))
|
||||
.forEach(files::add);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private static Resource fileResource(Path path) {
|
||||
String name = WebhookSpool.displayName(path.getFileName().toString());
|
||||
return new FileSystemResource(path.toFile()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return name;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+5
-31
@@ -2,7 +2,6 @@ package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -47,8 +46,6 @@ import stirling.software.proprietary.util.SecretMasker;
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class SourceController {
|
||||
|
||||
private static final String WEBHOOK_TYPE = "webhook";
|
||||
|
||||
private final SourceStore sourceStore;
|
||||
private final SourceAccessGuard sourceAccessGuard;
|
||||
private final SourceOverviewService overviewService;
|
||||
@@ -112,8 +109,7 @@ public class SourceController {
|
||||
public ResponseEntity<Source> save(@RequestBody Source source) {
|
||||
requireSourceEditingAllowed();
|
||||
requireNotEditor(source.id(), source.type());
|
||||
boolean isCreate = source.id() == null || source.id().isBlank();
|
||||
Source owned = withPreparedOptions(withStoredSecrets(resolveOwnership(source)), isCreate);
|
||||
Source owned = withStoredSecrets(resolveOwnership(source));
|
||||
try {
|
||||
validateConfig(owned);
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -123,7 +119,7 @@ public class SourceController {
|
||||
// An edited folder source can change which directory needs watching, so re-sync trigger
|
||||
// registrations now instead of waiting for the next reconcile.
|
||||
policyTriggerManager.notifyPoliciesChanged();
|
||||
return ResponseEntity.ok(revealOnCreate(saved, isCreate));
|
||||
return ResponseEntity.ok(withMaskedSecrets(saved));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{sourceId}")
|
||||
@@ -225,36 +221,14 @@ public class SourceController {
|
||||
/** Validate the config against the bean that handles the source's type, as the engine will. */
|
||||
private void validateConfig(Source source) {
|
||||
InputSpec spec = source.toInputSpec();
|
||||
inputSourceFor(spec)
|
||||
inputSources.stream()
|
||||
.filter(inputSource -> inputSource.supports(spec))
|
||||
.findFirst()
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("unknown source type: " + source.type()))
|
||||
.validate(spec);
|
||||
}
|
||||
|
||||
/** Let the source type populate server-owned config before persistence. */
|
||||
private Source withPreparedOptions(Source source, boolean isCreate) {
|
||||
InputSpec spec = source.toInputSpec();
|
||||
InputSource input = inputSourceFor(spec).orElse(null);
|
||||
if (input == null) {
|
||||
return source;
|
||||
}
|
||||
Map<String, Object> prepared = input.prepareOptionsForSave(source.options(), isCreate);
|
||||
// Treat a null return as "unchanged" (else the record wipes config to empty).
|
||||
return prepared == null ? source : withOptions(source, prepared);
|
||||
}
|
||||
|
||||
/** Reveal a webhook's minted secret once on the create response; other reads mask. */
|
||||
private static Source revealOnCreate(Source saved, boolean isCreate) {
|
||||
if (isCreate && WEBHOOK_TYPE.equals(saved.type())) {
|
||||
return saved;
|
||||
}
|
||||
return withMaskedSecrets(saved);
|
||||
}
|
||||
|
||||
private Optional<InputSource> inputSourceFor(InputSpec spec) {
|
||||
return inputSources.stream().filter(input -> input.supports(spec)).findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing sources requires the editor role for the caller's team (a team leader on SaaS), the
|
||||
* same rule as policies. Single-user deployments (login disabled) trust the local operator.
|
||||
|
||||
+2
-13
@@ -102,8 +102,7 @@ public class SourceOverviewService {
|
||||
List.of(),
|
||||
docs.total(),
|
||||
docs.last24h(),
|
||||
docs.last30d(),
|
||||
null);
|
||||
docs.last30d());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,17 +143,7 @@ public class SourceOverviewService {
|
||||
configRows(source),
|
||||
docs.total(),
|
||||
docs.last24h(),
|
||||
docs.last30d(),
|
||||
webhookPath(source));
|
||||
}
|
||||
|
||||
/** The server-relative delivery path for a webhook source, else null. Never a secret. */
|
||||
private static String webhookPath(Source source) {
|
||||
if (!"webhook".equals(source.type())) {
|
||||
return null;
|
||||
}
|
||||
Object webhookId = source.options().get("webhookId");
|
||||
return webhookId == null ? null : "/api/v1/webhooks/" + webhookId;
|
||||
docs.last30d());
|
||||
}
|
||||
|
||||
/** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */
|
||||
|
||||
+6
-3
@@ -2,7 +2,11 @@ package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** One Sources-overview row; {@code webhookPath} is a webhook's delivery path, else null. */
|
||||
/**
|
||||
* One row in the Sources overview: a persisted input connection shown exactly once, with how many
|
||||
* policies reference it (and which) and how many documents it has fed into runs ({@code docsTotal}
|
||||
* lifetime plus the trailing 24-hour and 30-day windows).
|
||||
*/
|
||||
public record SourceView(
|
||||
String id,
|
||||
String name,
|
||||
@@ -13,8 +17,7 @@ public record SourceView(
|
||||
List<DetailRow> config,
|
||||
long docsTotal,
|
||||
long docs24h,
|
||||
long docs30d,
|
||||
String webhookPath) {
|
||||
long docs30d) {
|
||||
|
||||
/** A policy that references this source. */
|
||||
public record PolicyRef(String id, String name) {}
|
||||
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
package stirling.software.proprietary.policy.trigger;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
||||
import stirling.software.proprietary.policy.engine.SweepKind;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookConfig;
|
||||
|
||||
/** Fires webhook policies on delivery plus a periodic reconcile safety net. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class WebhookTrigger implements PolicyTrigger {
|
||||
|
||||
static final String TYPE = "webhook";
|
||||
// The webhook source type (equals the trigger type but a distinct concept).
|
||||
private static final String WEBHOOK_SOURCE_TYPE = "webhook";
|
||||
|
||||
private final PolicyStore policyStore;
|
||||
private final PolicyRunner policyRunner;
|
||||
private final SourceStore sourceStore;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private volatile ScheduledExecutorService reconciler;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresSource() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> supportedSourceTypes() {
|
||||
return Set.of(WEBHOOK_SOURCE_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Policy policy) {
|
||||
boolean hasWebhookSource =
|
||||
policy.sourceIds().stream()
|
||||
.map(sourceStore::get)
|
||||
.flatMap(java.util.Optional::stream)
|
||||
.anyMatch(source -> WEBHOOK_SOURCE_TYPE.equals(source.type()));
|
||||
if (!hasWebhookSource) {
|
||||
throw new IllegalArgumentException(
|
||||
"webhook trigger requires at least one webhook input source");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void start() {
|
||||
if (reconciler != null) {
|
||||
return;
|
||||
}
|
||||
long reconcileSeconds = applicationProperties.getPolicies().getWatchReconcileSeconds();
|
||||
reconciler =
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("policy-webhook-reconcile-", 0).factory());
|
||||
// First reconcile runs immediately so deliveries spooled before startup are picked up.
|
||||
reconciler.scheduleAtFixedRate(this::safeReconcile, 0, reconcileSeconds, TimeUnit.SECONDS);
|
||||
log.info("Webhook trigger started (reconcile every {}s)", reconcileSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void stop() {
|
||||
if (reconciler != null) {
|
||||
reconciler.shutdownNow();
|
||||
reconciler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run every webhook policy referencing the delivered-to source (LIGHT, best-effort). */
|
||||
public void fireForWebhook(String webhookId) {
|
||||
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
|
||||
if (!referencesWebhook(policy, webhookId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
log.debug("Webhook policy {} ({}) saw a delivery", policy.id(), policy.name());
|
||||
policyRunner.run(policy, SweepKind.LIGHT);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Webhook run failed for policy {}: {}", policy.id(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void safeReconcile() {
|
||||
try {
|
||||
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
|
||||
try {
|
||||
policyRunner.run(policy);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn(
|
||||
"Webhook reconcile run failed for policy {}: {}",
|
||||
policy.id(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.error("Webhook reconcile failed: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the policy references a webhook source whose routing id is {@code webhookId}. */
|
||||
private boolean referencesWebhook(Policy policy, String webhookId) {
|
||||
for (String sourceId : policy.sourceIds()) {
|
||||
Source source = sourceStore.get(sourceId).orElse(null);
|
||||
if (source == null || !WEBHOOK_SOURCE_TYPE.equals(source.type())) {
|
||||
continue;
|
||||
}
|
||||
Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION);
|
||||
if (configured != null && configured.toString().equals(webhookId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
package stirling.software.proprietary.policy.webhook;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Webhook source config: server-minted ids, staging via an S3 connection or the local spool. */
|
||||
public record WebhookConfig(
|
||||
String webhookId, String signingSecret, Long connectionId, boolean snapshot) {
|
||||
|
||||
public static final String WEBHOOK_ID_OPTION = "webhookId";
|
||||
public static final String SIGNING_SECRET_OPTION = "signingSecret";
|
||||
public static final String CONNECTION_ID_OPTION = "connectionId";
|
||||
private static final String MODE_OPTION = "mode";
|
||||
private static final String MODE_CONSUME = "consume";
|
||||
private static final String MODE_SNAPSHOT = "snapshot";
|
||||
|
||||
/** Reserved key namespace webhook deliveries are staged under in a connection's bucket. */
|
||||
private static final String STAGING_ROOT = "stirling-webhook";
|
||||
|
||||
public static WebhookConfig from(Map<String, Object> options) {
|
||||
String webhookId = trimmed(options.get(WEBHOOK_ID_OPTION));
|
||||
if (webhookId == null) {
|
||||
throw new IllegalArgumentException("webhook config requires a 'webhookId' option");
|
||||
}
|
||||
if (!WebhookIds.isValidId(webhookId)) {
|
||||
throw new IllegalArgumentException("webhook config 'webhookId' has an invalid format");
|
||||
}
|
||||
String signingSecret = trimmed(options.get(SIGNING_SECRET_OPTION));
|
||||
if (signingSecret == null) {
|
||||
throw new IllegalArgumentException("webhook config requires a 'signingSecret' option");
|
||||
}
|
||||
String mode = trimmed(options.get(MODE_OPTION));
|
||||
if (mode != null && !MODE_CONSUME.equals(mode) && !MODE_SNAPSHOT.equals(mode)) {
|
||||
throw new IllegalArgumentException(
|
||||
"webhook config 'mode' must be 'consume' or 'snapshot'");
|
||||
}
|
||||
return new WebhookConfig(
|
||||
webhookId, signingSecret, connectionId(options), MODE_SNAPSHOT.equals(mode));
|
||||
}
|
||||
|
||||
/** Whether deliveries are staged to a durable S3 connection rather than the local spool. */
|
||||
public boolean usesConnection() {
|
||||
return connectionId != null;
|
||||
}
|
||||
|
||||
/** Reserved per-webhook staging prefix inside the connection's bucket. */
|
||||
public String stagingPrefix() {
|
||||
return STAGING_ROOT + "/" + webhookId;
|
||||
}
|
||||
|
||||
public String mode() {
|
||||
return snapshot ? MODE_SNAPSHOT : MODE_CONSUME;
|
||||
}
|
||||
|
||||
private static Long connectionId(Map<String, Object> options) {
|
||||
Object reference = options.get(CONNECTION_ID_OPTION);
|
||||
if (reference == null || (reference instanceof String s && s.isBlank())) {
|
||||
return null;
|
||||
}
|
||||
if (reference instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(reference.toString().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"webhook 'connectionId' is not a valid connection reference: " + reference);
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimmed(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString().trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
/** Never prints the signing secret, so an accidental log line cannot leak it. */
|
||||
@Override
|
||||
public String toString() {
|
||||
return "WebhookConfig[webhookId="
|
||||
+ webhookId
|
||||
+ ", connectionId="
|
||||
+ connectionId
|
||||
+ ", snapshot="
|
||||
+ snapshot
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
package stirling.software.proprietary.policy.webhook;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Generates and validates a webhook's routing id and signing secret. */
|
||||
public final class WebhookIds {
|
||||
|
||||
/** URL-safe base64 without padding: the characters a single path segment allows. */
|
||||
private static final Pattern VALID_ID = Pattern.compile("^[A-Za-z0-9_-]{16,128}$");
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
|
||||
|
||||
private WebhookIds() {}
|
||||
|
||||
/** A fresh routing token (~24 chars), unguessable so it cannot be enumerated. */
|
||||
public static String newWebhookId() {
|
||||
return randomToken(18);
|
||||
}
|
||||
|
||||
/** A fresh HMAC signing key (~43 chars) revealed to the operator once at creation. */
|
||||
public static String newSigningSecret() {
|
||||
return randomToken(32);
|
||||
}
|
||||
|
||||
/** Whether {@code id} is well-formed and safe as a path segment / directory name. */
|
||||
public static boolean isValidId(String id) {
|
||||
return id != null && VALID_ID.matcher(id).matches();
|
||||
}
|
||||
|
||||
private static String randomToken(int bytes) {
|
||||
byte[] buffer = new byte[bytes];
|
||||
RANDOM.nextBytes(buffer);
|
||||
return ENCODER.encodeToString(buffer);
|
||||
}
|
||||
}
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
package stirling.software.proprietary.policy.webhook;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.s3.S3Config;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
/** Public receiver: HMAC-verifies a signed delivery, stages it, and fires the policies. */
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/webhooks")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Webhooks", description = "Inbound webhook source receiver")
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class WebhookReceiverController {
|
||||
|
||||
static final String SIGNATURE_HEADER = "X-Stirling-Signature";
|
||||
static final String FILENAME_HEADER = "X-Stirling-Filename";
|
||||
private static final String WEBHOOK_TYPE = "webhook";
|
||||
|
||||
private final SourceStore sourceStore;
|
||||
private final WebhookSpool spool;
|
||||
private final WebhookTrigger webhookTrigger;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final S3ConnectionResolver connectionResolver;
|
||||
private final S3ConnectionPool connectionPool;
|
||||
|
||||
@PostMapping("/{webhookId}")
|
||||
@Operation(
|
||||
summary = "Deliver a document to a webhook source",
|
||||
description =
|
||||
"The body is the raw document; sign it with the source's secret and present"
|
||||
+ " 'sha256=<hex>' in the X-Stirling-Signature header. Returns 202 once"
|
||||
+ " the document is spooled for the referencing policies.")
|
||||
public ResponseEntity<WebhookDeliveryResponse> receive(
|
||||
@PathVariable String webhookId,
|
||||
@RequestHeader(value = SIGNATURE_HEADER, required = false) String signature,
|
||||
@RequestHeader(value = FILENAME_HEADER, required = false) String filename,
|
||||
HttpServletRequest request) {
|
||||
if (!WebhookIds.isValidId(webhookId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook");
|
||||
}
|
||||
Source source = findWebhookSource(webhookId);
|
||||
if (source == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook");
|
||||
}
|
||||
|
||||
WebhookConfig config = WebhookConfig.from(source.options());
|
||||
byte[] body = readBoundedBody(request);
|
||||
if (!WebhookSignatures.verify(config.signingSecret(), body, signature)) {
|
||||
// Same 401 whether the header was absent or wrong: never confirm a guess.
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature");
|
||||
}
|
||||
if (!source.enabled()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "Webhook source is paused; deliveries are not accepted");
|
||||
}
|
||||
if (body.length == 0) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Empty request body");
|
||||
}
|
||||
|
||||
String storedName =
|
||||
config.usesConnection()
|
||||
? stageToConnection(config, filename, body)
|
||||
: stageToSpool(webhookId, filename, body);
|
||||
|
||||
// Fire the referencing policies now; the trigger's reconcile is the safety net.
|
||||
webhookTrigger.fireForWebhook(webhookId);
|
||||
log.info(
|
||||
"Accepted webhook delivery '{}' ({} bytes) for {}",
|
||||
storedName,
|
||||
body.length,
|
||||
webhookId);
|
||||
return ResponseEntity.accepted()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new WebhookDeliveryResponse(true, storedName, body.length));
|
||||
}
|
||||
|
||||
/** The enabled-or-not webhook source whose routing id matches, or null if there is none. */
|
||||
private Source findWebhookSource(String webhookId) {
|
||||
for (Source source : sourceStore.all()) {
|
||||
if (!WEBHOOK_TYPE.equals(source.type())) {
|
||||
continue;
|
||||
}
|
||||
Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION);
|
||||
if (configured != null && configured.toString().equals(webhookId)) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Stage a delivery to the node-local spool; returns its display (original) name. */
|
||||
private String stageToSpool(String webhookId, String filename, byte[] body) {
|
||||
try {
|
||||
return WebhookSpool.displayName(
|
||||
spool.store(webhookId, filename, body).getFileName().toString());
|
||||
} catch (IOException e) {
|
||||
log.error("Could not spool webhook delivery for {}: {}", webhookId, e.getMessage());
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR, "Could not store delivery");
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage a delivery to the S3 connection (save-time access check trusted). */
|
||||
private String stageToConnection(WebhookConfig config, String filename, byte[] body) {
|
||||
S3Config s3 =
|
||||
connectionResolver.resolve(
|
||||
Map.of(
|
||||
WebhookConfig.CONNECTION_ID_OPTION,
|
||||
config.connectionId(),
|
||||
"prefix",
|
||||
config.stagingPrefix()));
|
||||
S3Client client = connectionPool.clientFor(s3);
|
||||
String key = keyPrefix(s3.prefix()) + WebhookSpool.objectKeySuffix(filename);
|
||||
try {
|
||||
client.putObject(
|
||||
PutObjectRequest.builder().bucket(s3.bucket()).key(key).build(),
|
||||
RequestBody.fromBytes(body));
|
||||
} catch (SdkException e) {
|
||||
log.error(
|
||||
"Could not stage webhook delivery for {} to s3://{}/{}: {}",
|
||||
config.webhookId(),
|
||||
s3.bucket(),
|
||||
key,
|
||||
e.getMessage());
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR, "Could not store delivery");
|
||||
}
|
||||
return WebhookSpool.objectDisplayName(filename);
|
||||
}
|
||||
|
||||
/** The configured prefix as a key-path prefix ("inbox" and "inbox/" mean the same). */
|
||||
private static String keyPrefix(String prefix) {
|
||||
if (prefix == null || prefix.isEmpty() || prefix.endsWith("/")) {
|
||||
return prefix == null ? "" : prefix;
|
||||
}
|
||||
return prefix + "/";
|
||||
}
|
||||
|
||||
/** Read the body into memory, capped at {@code policies.webhookMaxBytes}. */
|
||||
private byte[] readBoundedBody(HttpServletRequest request) {
|
||||
long maxBytes = applicationProperties.getPolicies().getWebhookMaxBytes();
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[8192];
|
||||
long total = 0;
|
||||
try (InputStream in = request.getInputStream()) {
|
||||
int read;
|
||||
while ((read = in.read(chunk)) != -1) {
|
||||
total += read;
|
||||
if (total > maxBytes) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
"Delivery exceeds the " + maxBytes + "-byte limit");
|
||||
}
|
||||
buffer.write(chunk, 0, read);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Could not read request body");
|
||||
}
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
/** The 202 body: the stored (display) name and byte count of an accepted delivery. */
|
||||
public record WebhookDeliveryResponse(boolean accepted, String filename, int bytes) {}
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
package stirling.software.proprietary.policy.webhook;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/** HMAC-SHA256 signing of a webhook delivery's raw body (header sha256=<hex>). */
|
||||
public final class WebhookSignatures {
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
private static final String PREFIX = "sha256=";
|
||||
|
||||
private WebhookSignatures() {}
|
||||
|
||||
/** The header a sender presents for {@code body}: {@code sha256=<lowercase hex>}. */
|
||||
public static String sign(String signingSecret, byte[] body) {
|
||||
return PREFIX + HexFormat.of().formatHex(hmac(signingSecret, body));
|
||||
}
|
||||
|
||||
/** Whether the presented signature is valid for {@code body}; false on bad input. */
|
||||
public static boolean verify(String signingSecret, byte[] body, String presented) {
|
||||
if (signingSecret == null || presented == null || body == null) {
|
||||
return false;
|
||||
}
|
||||
String hex = presented.trim();
|
||||
if (hex.regionMatches(true, 0, PREFIX, 0, PREFIX.length())) {
|
||||
hex = hex.substring(PREFIX.length());
|
||||
}
|
||||
byte[] presentedBytes;
|
||||
try {
|
||||
presentedBytes = HexFormat.of().parseHex(hex);
|
||||
} catch (IllegalArgumentException notHex) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(hmac(signingSecret, body), presentedBytes);
|
||||
}
|
||||
|
||||
private static byte[] hmac(String signingSecret, byte[] body) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(ALGORITHM);
|
||||
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), ALGORITHM));
|
||||
return mac.doFinal(body);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
// HmacSHA256 is a required JCE algorithm and the key is always non-empty here.
|
||||
throw new IllegalStateException("HMAC-SHA256 unavailable", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
package stirling.software.proprietary.policy.webhook;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
/** Server-owned per-webhook staging directory under the install path. */
|
||||
@Component
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class WebhookSpool {
|
||||
|
||||
private static final String SPOOL_DIR = "policy-webhook-spool";
|
||||
private static final String TEMP_SUFFIX = ".part";
|
||||
private static final String DEFAULT_NAME = "document.pdf";
|
||||
private static final int UNIQUE_LEN = 32; // UUID hex without dashes
|
||||
|
||||
private final Path spoolRoot;
|
||||
|
||||
public WebhookSpool() {
|
||||
this(Path.of(InstallationPathConfig.getPath(), SPOOL_DIR));
|
||||
}
|
||||
|
||||
// Lets a caller (and tests) root the spool at a chosen directory; Spring uses the no-arg one.
|
||||
public WebhookSpool(Path spoolRoot) {
|
||||
this.spoolRoot = spoolRoot.toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
/** The staging directory for one webhook. Never escapes the spool root; may not yet exist. */
|
||||
public Path dirFor(String webhookId) {
|
||||
if (!WebhookIds.isValidId(webhookId)) {
|
||||
throw new IllegalArgumentException("invalid webhookId");
|
||||
}
|
||||
Path dir = spoolRoot.resolve(webhookId).normalize();
|
||||
if (!dir.getParent().equals(spoolRoot)) {
|
||||
// A validated id is a single safe segment; this only trips on a bug, never user input.
|
||||
throw new IllegalArgumentException("invalid webhookId");
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Write a delivery into the spool atomically (staged .part, then moved into place). */
|
||||
public Path store(String webhookId, String filename, byte[] content) throws IOException {
|
||||
Path dir = dirFor(webhookId);
|
||||
Files.createDirectories(dir);
|
||||
String finalName = spoolName(filename);
|
||||
Path target = dir.resolve(finalName);
|
||||
Path temp = dir.resolve("." + finalName + TEMP_SUFFIX);
|
||||
Files.write(temp, content);
|
||||
try {
|
||||
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException atomicUnsupported) {
|
||||
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** The spool file name for a delivery: a unique prefix plus the sanitised original name. */
|
||||
static String spoolName(String filename) {
|
||||
return UUID.randomUUID().toString().replace("-", "") + "-" + sanitize(filename);
|
||||
}
|
||||
|
||||
/** The original name recovered from a spool file name, for the resolved input's filename. */
|
||||
public static String displayName(String spoolFileName) {
|
||||
int dash = spoolFileName.indexOf('-');
|
||||
// The unique prefix is fixed-length hex with no dashes, so the first dash is the separator.
|
||||
if (dash == UNIQUE_LEN && dash + 1 < spoolFileName.length()) {
|
||||
return spoolFileName.substring(dash + 1);
|
||||
}
|
||||
return spoolFileName;
|
||||
}
|
||||
|
||||
/** S3 object-key suffix {@code <unique>/<name>}; the subfolder keeps the basename clean. */
|
||||
public static String objectKeySuffix(String filename) {
|
||||
return UUID.randomUUID().toString().replace("-", "") + "/" + sanitize(filename);
|
||||
}
|
||||
|
||||
/** The staged delivery's display name (basename of {@link #objectKeySuffix}). */
|
||||
public static String objectDisplayName(String filename) {
|
||||
return sanitize(filename);
|
||||
}
|
||||
|
||||
/** Reduce a client-supplied filename to a safe bare basename; fall back to a default. */
|
||||
private static String sanitize(String filename) {
|
||||
if (filename == null) {
|
||||
return DEFAULT_NAME;
|
||||
}
|
||||
String base = filename.replace('\\', '/');
|
||||
int slash = base.lastIndexOf('/');
|
||||
if (slash >= 0) {
|
||||
base = base.substring(slash + 1);
|
||||
}
|
||||
base = base.replaceAll("[^A-Za-z0-9._-]", "_").trim();
|
||||
// Strip leading dots so the result is never hidden (which resolve would skip) or empty.
|
||||
while (base.startsWith(".")) {
|
||||
base = base.substring(1);
|
||||
}
|
||||
return base.isEmpty() ? DEFAULT_NAME : base;
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,6 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ElementCollection
|
||||
@MapKeyColumn(name = "setting_key")
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,9 @@ public final class SecretMasker {
|
||||
private static final Pattern SENSITIVE =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getPattern(
|
||||
// specific keys precede plain "secret" so camelCase still matches.
|
||||
"(?i)\\b(password|token|secret[_-]?access[_-]?key|signing[_-]?secret|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
|
||||
// secret[_-]?access[_-]?key precedes plain secret so camelCase keys
|
||||
// like secretAccessKey (no word boundary after "secret") still match.
|
||||
"(?i)\\b(password|token|secret[_-]?access[_-]?key|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
|
||||
|
||||
private SecretMasker() {}
|
||||
|
||||
|
||||
+21
-19
@@ -1,3 +1,4 @@
|
||||
{%- autoescape true -%}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -175,18 +176,18 @@
|
||||
color: var(--color-label);
|
||||
}
|
||||
</style>
|
||||
{%- if doc.style %}
|
||||
{%- if style_primary or style_background or style_body %}
|
||||
<style>
|
||||
:root {
|
||||
{%- if doc.style.primary_color %}
|
||||
--color-primary: {{ doc.style.primary_color }};
|
||||
{%- if style_primary %}
|
||||
--color-primary: {{ style_primary }};
|
||||
{%- endif %}
|
||||
{%- if doc.style.background_color %}
|
||||
--color-bg: {{ doc.style.background_color }};
|
||||
{%- if style_background %}
|
||||
--color-bg: {{ style_background }};
|
||||
{%- endif %}
|
||||
{%- if doc.style.body_text_color %}
|
||||
--color-body: {{ doc.style.body_text_color }};
|
||||
--color-label: {{ doc.style.body_text_color }};
|
||||
{%- if style_body %}
|
||||
--color-body: {{ style_body }};
|
||||
--color-label: {{ style_body }};
|
||||
{%- endif %}
|
||||
}
|
||||
</style>
|
||||
@@ -195,16 +196,16 @@
|
||||
<body>
|
||||
|
||||
<div class="doc-header">
|
||||
<div class="doc-title">{{ doc.title }}</div>
|
||||
{%- if doc.subtitle %}
|
||||
<div class="doc-subtitle">{{ doc.subtitle }}</div>
|
||||
<div class="doc-title">{{ title }}</div>
|
||||
{%- if subtitle %}
|
||||
<div class="doc-subtitle">{{ subtitle }}</div>
|
||||
{%- endif %}
|
||||
{%- if doc.reference_number %}
|
||||
<div class="doc-reference">{{ doc.reference_number }}</div>
|
||||
{%- if reference_number %}
|
||||
<div class="doc-reference">{{ reference_number }}</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
{%- for section in doc.sections %}
|
||||
{%- for section in sections %}
|
||||
|
||||
{%- if section.type == "text" %}
|
||||
<section>
|
||||
@@ -212,8 +213,8 @@
|
||||
<h2>{{ section.heading }}</h2>
|
||||
{%- endif %}
|
||||
<div class="text-body">
|
||||
{%- for para in section.body.split('\n\n') %}
|
||||
<p>{{ para | replace('\n', ' ') }}</p>
|
||||
{%- for para in section.paragraphs %}
|
||||
<p>{{ para }}</p>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -225,10 +226,10 @@
|
||||
{%- endif %}
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{%- for label, value in section.pairs %}
|
||||
{%- for pair in section.pairs %}
|
||||
<tr>
|
||||
<td class="kv-label">{{ label }}</td>
|
||||
<td class="kv-value">{{ value }}</td>
|
||||
<td class="kv-label">{{ pair.label }}</td>
|
||||
<td class="kv-value">{{ pair.value }}</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
@@ -299,3 +300,4 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{%- endautoescape %}
|
||||
+2
-2
@@ -166,8 +166,8 @@ class PolicyExecutorTest {
|
||||
new PipelineStep(
|
||||
createPdf,
|
||||
Map.of(
|
||||
"htmlContent",
|
||||
"<p>hi</p>",
|
||||
"document",
|
||||
"{\"title\":\"PO\",\"sections\":[]}",
|
||||
"filename",
|
||||
"purchase-order.pdf"))),
|
||||
PolicyInputs.of(List.of()),
|
||||
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
package stirling.software.proprietary.policy.input;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.util.FileReadinessChecker;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookConfig;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookSpool;
|
||||
|
||||
/** Tests for {@link WebhookInputSource}: ledger-backed read/consume, and id/secret minting. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WebhookInputSourceTest {
|
||||
|
||||
private static final String POLICY = "p1";
|
||||
private static final String WEBHOOK_ID = "testwebhookid1234";
|
||||
|
||||
@Mock private FileReadinessChecker readinessChecker;
|
||||
@Mock private S3InputSource s3InputSource;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private WebhookSpool spool;
|
||||
private WebhookInputSource source;
|
||||
private InProcessProcessedLedger ledger;
|
||||
private RecordingContext ctx;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
spool = new WebhookSpool(tempDir.resolve("spool"));
|
||||
source = new WebhookInputSource(spool, readinessChecker, s3InputSource);
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
lenient().when(readinessChecker.isReady(any())).thenReturn(true);
|
||||
}
|
||||
|
||||
private static InputSpec spec(String mode) {
|
||||
return new InputSpec(
|
||||
"webhook",
|
||||
Map.of("webhookId", WEBHOOK_ID, "signingSecret", "secret", "mode", mode));
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumeRemovesTheDeliveryOnceProcessed() throws IOException {
|
||||
Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes());
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec("consume"), ctx);
|
||||
|
||||
assertEquals(1, work.size());
|
||||
assertEquals("doc.pdf", work.get(0).inputs().primary().get(0).getFilename());
|
||||
// In flight: still spooled, but a second sweep does not pick it up again.
|
||||
assertTrue(Files.exists(delivered));
|
||||
assertTrue(source.resolve(spec("consume"), ctx).isEmpty());
|
||||
|
||||
work.get(0).onComplete().accept(true);
|
||||
assertTrue(Files.notExists(delivered));
|
||||
assertTrue(source.resolve(spec("consume"), ctx).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRunLeavesTheDeliveryInPlace() throws IOException {
|
||||
Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes());
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec("consume"), ctx);
|
||||
work.get(0).onComplete().accept(false);
|
||||
|
||||
assertTrue(Files.exists(delivered));
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotReReadsEveryRunAndNeverDeletes() throws IOException {
|
||||
Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes());
|
||||
|
||||
assertEquals(1, source.resolve(spec("snapshot"), ctx).size());
|
||||
List<ResolvedInput> second = source.resolve(spec("snapshot"), ctx);
|
||||
assertEquals(1, second.size());
|
||||
second.get(0).onComplete().accept(true);
|
||||
assertTrue(Files.exists(delivered));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nothingDeliveredIsAnEmptySourceNotAnError() throws IOException {
|
||||
List<ResolvedInput> work = source.resolve(spec("consume"), ctx);
|
||||
assertTrue(work.isEmpty());
|
||||
assertTrue(ctx.present.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateRejectsMissingIdOrSecret() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> source.validate(new InputSpec("webhook", Map.of("signingSecret", "s"))));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> source.validate(new InputSpec("webhook", Map.of("webhookId", WEBHOOK_ID))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prepareMintsIdAndSecretOnCreate() {
|
||||
Map<String, Object> prepared =
|
||||
source.prepareOptionsForSave(Map.of("mode", "consume"), true);
|
||||
|
||||
String id = prepared.get(WebhookConfig.WEBHOOK_ID_OPTION).toString();
|
||||
String secret = prepared.get(WebhookConfig.SIGNING_SECRET_OPTION).toString();
|
||||
assertFalse(id.isBlank());
|
||||
assertFalse(secret.isBlank());
|
||||
assertEquals("consume", prepared.get("mode"));
|
||||
Map<String, Object> other = source.prepareOptionsForSave(Map.of(), true);
|
||||
assertNotEquals(id, other.get(WebhookConfig.WEBHOOK_ID_OPTION).toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prepareLeavesAnExistingWebhookUntouchedOnEdit() {
|
||||
Map<String, Object> existing =
|
||||
Map.of("webhookId", WEBHOOK_ID, "signingSecret", "keepme", "mode", "snapshot");
|
||||
|
||||
Map<String, Object> prepared = source.prepareOptionsForSave(existing, false);
|
||||
|
||||
assertEquals(WEBHOOK_ID, prepared.get("webhookId"));
|
||||
assertEquals("keepme", prepared.get("signingSecret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aConnectionBackedWebhookDelegatesToTheS3SourceUnderItsReservedPrefix() throws IOException {
|
||||
when(s3InputSource.resolve(any(), any())).thenReturn(List.of());
|
||||
InputSpec spec =
|
||||
new InputSpec(
|
||||
"webhook",
|
||||
Map.of(
|
||||
"webhookId",
|
||||
WEBHOOK_ID,
|
||||
"signingSecret",
|
||||
"secret",
|
||||
"mode",
|
||||
"consume",
|
||||
"connectionId",
|
||||
7));
|
||||
|
||||
source.resolve(spec, ctx);
|
||||
|
||||
ArgumentCaptor<InputSpec> delegated = ArgumentCaptor.forClass(InputSpec.class);
|
||||
verify(s3InputSource).resolve(delegated.capture(), eq(ctx));
|
||||
InputSpec s3 = delegated.getValue();
|
||||
assertEquals("s3", s3.type());
|
||||
assertEquals(7L, ((Number) s3.options().get("connectionId")).longValue());
|
||||
assertEquals("stirling-webhook/" + WEBHOOK_ID, s3.options().get("prefix"));
|
||||
assertEquals("consume", s3.options().get("mode"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aConnectionBackedWebhookValidatesTheConnectionThroughTheS3Source() {
|
||||
InputSpec spec =
|
||||
new InputSpec(
|
||||
"webhook",
|
||||
Map.of(
|
||||
"webhookId",
|
||||
WEBHOOK_ID,
|
||||
"signingSecret",
|
||||
"secret",
|
||||
"connectionId",
|
||||
7));
|
||||
|
||||
source.validate(spec);
|
||||
|
||||
// Save-time validation defers to the S3 source, which ownership-checks the connection.
|
||||
verify(s3InputSource).validate(any());
|
||||
}
|
||||
|
||||
/** Policy-scoped context backed by the in-process ledger, recording presence reports. */
|
||||
private class RecordingContext implements ResolveContext {
|
||||
|
||||
private final List<String> present = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
|
||||
return ledger.claim(POLICY, identity, gate, contentHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void settle(
|
||||
String identity, String finalGate, String finalContentHash, boolean success) {
|
||||
ledger.settle(POLICY, identity, finalGate, finalContentHash, success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allSettledDone(String identity) {
|
||||
return ledger.allSettledDone(identity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportPresent(Collection<String> identities) {
|
||||
present.addAll(identities);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-57
@@ -1,42 +1,33 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.FileReadinessChecker;
|
||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.policy.input.InputSource;
|
||||
import stirling.software.proprietary.policy.input.S3InputSource;
|
||||
import stirling.software.proprietary.policy.input.WebhookInputSource;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookSpool;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
/**
|
||||
@@ -50,9 +41,6 @@ class SourceControllerTest {
|
||||
private final PolicyStore policyStore = new InProcessPolicyStore();
|
||||
private PolicyTriggerManager triggerManager;
|
||||
private SourceController controller;
|
||||
private SourceController webhookController;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
@@ -70,11 +58,9 @@ class SourceControllerTest {
|
||||
policyGuard,
|
||||
new InProcessSourceDocCounter());
|
||||
triggerManager = mock(PolicyTriggerManager.class);
|
||||
// A permissive input source; stub prepareOptionsForSave as a pass-through.
|
||||
// A permissive input source so config validation passes and save can be exercised.
|
||||
InputSource folderInput = mock(InputSource.class);
|
||||
when(folderInput.supports(any())).thenReturn(true);
|
||||
when(folderInput.prepareOptionsForSave(any(), anyBoolean()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
controller =
|
||||
new SourceController(
|
||||
sourceStore,
|
||||
@@ -86,48 +72,6 @@ class SourceControllerTest {
|
||||
triggerManager,
|
||||
properties,
|
||||
List.of(folderInput));
|
||||
WebhookInputSource webhookInput =
|
||||
new WebhookInputSource(
|
||||
new WebhookSpool(tempDir),
|
||||
mock(FileReadinessChecker.class),
|
||||
mock(S3InputSource.class));
|
||||
webhookController =
|
||||
new SourceController(
|
||||
sourceStore,
|
||||
sourceGuard,
|
||||
overviewService,
|
||||
policyStore,
|
||||
policyGuard,
|
||||
authority,
|
||||
triggerManager,
|
||||
properties,
|
||||
List.of(webhookInput));
|
||||
}
|
||||
|
||||
@Test
|
||||
void creatingAWebhookRevealsItsSecretOnceThenMasks() {
|
||||
Source created =
|
||||
webhookController
|
||||
.save(
|
||||
new Source(
|
||||
null,
|
||||
"Partner uploads",
|
||||
"webhook",
|
||||
Map.of("mode", "consume"),
|
||||
true,
|
||||
null,
|
||||
null))
|
||||
.getBody();
|
||||
|
||||
String secret = String.valueOf(created.options().get("signingSecret"));
|
||||
String webhookId = String.valueOf(created.options().get("webhookId"));
|
||||
assertNotEquals(SecretMasker.REDACTED, secret);
|
||||
assertFalse(secret.isBlank());
|
||||
assertFalse(webhookId.isBlank());
|
||||
|
||||
Source read = webhookController.get(created.id()).getBody();
|
||||
assertEquals(SecretMasker.REDACTED, read.options().get("signingSecret"));
|
||||
assertEquals(webhookId, read.options().get("webhookId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
package stirling.software.proprietary.policy.trigger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
||||
import stirling.software.proprietary.policy.engine.SweepKind;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.TriggerConfig;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
|
||||
/** Tests for {@link WebhookTrigger}: fires only policies referencing the delivered-to webhook. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WebhookTriggerTest {
|
||||
|
||||
private static final String TYPE = "webhook";
|
||||
|
||||
@Mock private PolicyStore policyStore;
|
||||
@Mock private PolicyRunner policyRunner;
|
||||
|
||||
private final SourceStore sourceStore = new InProcessSourceStore();
|
||||
private WebhookTrigger trigger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
trigger =
|
||||
new WebhookTrigger(
|
||||
policyStore, policyRunner, sourceStore, new ApplicationProperties());
|
||||
}
|
||||
|
||||
@Test
|
||||
void firesOnlyPoliciesReferencingTheDeliveredWebhook() {
|
||||
Policy matching = webhookPolicy("a", "whkA");
|
||||
Policy other = webhookPolicy("b", "whkB");
|
||||
when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(matching, other));
|
||||
|
||||
trigger.fireForWebhook("whkA");
|
||||
|
||||
verify(policyRunner).run(matching, SweepKind.LIGHT);
|
||||
verify(policyRunner, never()).run(other, SweepKind.LIGHT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresADeliveryForAnUnknownWebhookId() {
|
||||
Policy policy = webhookPolicy("a", "whkA");
|
||||
when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(policy));
|
||||
|
||||
trigger.fireForWebhook("whkZ");
|
||||
|
||||
verify(policyRunner, never()).run(any(), any(SweepKind.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateRequiresAWebhookSource() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> trigger.validate(policy("p", webhookTriggerConfig(), List.of())));
|
||||
trigger.validate(webhookPolicy("p", "whkA"));
|
||||
}
|
||||
|
||||
private static TriggerConfig webhookTriggerConfig() {
|
||||
return new TriggerConfig(TYPE, Map.of());
|
||||
}
|
||||
|
||||
/** Persist a webhook source with the given routing id and return a policy referencing it. */
|
||||
private Policy webhookPolicy(String id, String webhookId) {
|
||||
String sourceId =
|
||||
sourceStore
|
||||
.save(
|
||||
new Source(
|
||||
null,
|
||||
"hook",
|
||||
"webhook",
|
||||
Map.of(
|
||||
"webhookId",
|
||||
webhookId,
|
||||
"signingSecret",
|
||||
"s",
|
||||
"mode",
|
||||
"consume"),
|
||||
true,
|
||||
"owner",
|
||||
null))
|
||||
.id();
|
||||
return policy(id, webhookTriggerConfig(), List.of(sourceId));
|
||||
}
|
||||
|
||||
private static Policy policy(String id, TriggerConfig trigger, List<String> sourceIds) {
|
||||
return new Policy(
|
||||
id,
|
||||
"hook",
|
||||
"owner",
|
||||
true,
|
||||
trigger,
|
||||
sourceIds,
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
OutputSpec.inline());
|
||||
}
|
||||
}
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
package stirling.software.proprietary.policy.webhook;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookReceiverController.WebhookDeliveryResponse;
|
||||
|
||||
/** Tests for the public webhook receiver: valid delivery spools + fires; bad requests rejected. */
|
||||
class WebhookReceiverControllerTest {
|
||||
|
||||
private static final String WEBHOOK_ID = "receivertestid12";
|
||||
private static final String SECRET = "topsecret";
|
||||
private static final byte[] BODY = "a pdf".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private SourceStore sourceStore;
|
||||
private WebhookSpool spool;
|
||||
private WebhookTrigger trigger;
|
||||
private ApplicationProperties properties;
|
||||
private WebhookReceiverController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sourceStore = new InProcessSourceStore();
|
||||
sourceStore.save(webhookSource(true));
|
||||
spool = new WebhookSpool(tempDir.resolve("spool"));
|
||||
trigger = mock(WebhookTrigger.class);
|
||||
properties = new ApplicationProperties();
|
||||
// The local-disk tests never touch a connection; mocks stand in for the S3 collaborators.
|
||||
controller =
|
||||
new WebhookReceiverController(
|
||||
sourceStore,
|
||||
spool,
|
||||
trigger,
|
||||
properties,
|
||||
mock(S3ConnectionResolver.class),
|
||||
mock(S3ConnectionPool.class));
|
||||
}
|
||||
|
||||
private static Source webhookSource(boolean enabled) {
|
||||
return new Source(
|
||||
"s1",
|
||||
"Partner uploads",
|
||||
"webhook",
|
||||
Map.of("webhookId", WEBHOOK_ID, "signingSecret", SECRET, "mode", "consume"),
|
||||
enabled,
|
||||
"owner",
|
||||
null);
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest request(byte[] body) {
|
||||
MockHttpServletRequest req =
|
||||
new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID);
|
||||
req.setContent(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValidDeliveryIsSpooledAndFiresTheTrigger() throws IOException {
|
||||
String signature = WebhookSignatures.sign(SECRET, BODY);
|
||||
|
||||
ResponseEntity<WebhookDeliveryResponse> response =
|
||||
controller.receive(WEBHOOK_ID, signature, "invoice.pdf", request(BODY));
|
||||
|
||||
assertEquals(202, response.getStatusCode().value());
|
||||
assertTrue(response.getBody().accepted());
|
||||
assertEquals("invoice.pdf", response.getBody().filename());
|
||||
assertEquals(1, spooledFiles().size());
|
||||
verify(trigger).fireForWebhook(WEBHOOK_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWrongSignatureIsRejectedAndStoresNothing() {
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() ->
|
||||
controller.receive(
|
||||
WEBHOOK_ID, "sha256=deadbeef", "x.pdf", request(BODY)));
|
||||
|
||||
assertEquals(401, ex.getStatusCode().value());
|
||||
assertTrue(spooledFiles().isEmpty());
|
||||
verify(trigger, never()).fireForWebhook(WEBHOOK_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownWebhookIsNotFound() {
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() ->
|
||||
controller.receive(
|
||||
"unknownwebhookid",
|
||||
WebhookSignatures.sign(SECRET, BODY),
|
||||
"x.pdf",
|
||||
request(BODY)));
|
||||
|
||||
assertEquals(404, ex.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPausedSourceRejectsDeliveries() {
|
||||
sourceStore.save(webhookSource(false));
|
||||
String signature = WebhookSignatures.sign(SECRET, BODY);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> controller.receive(WEBHOOK_ID, signature, "x.pdf", request(BODY)));
|
||||
|
||||
assertEquals(403, ex.getStatusCode().value());
|
||||
assertTrue(spooledFiles().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEmptyBodyIsRejected() {
|
||||
byte[] empty = new byte[0];
|
||||
String signature = WebhookSignatures.sign(SECRET, empty);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> controller.receive(WEBHOOK_ID, signature, null, request(empty)));
|
||||
|
||||
assertEquals(400, ex.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOversizeDeliveryIsRejectedBeforeStoring() {
|
||||
properties.getPolicies().setWebhookMaxBytes(2);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() ->
|
||||
controller.receive(
|
||||
WEBHOOK_ID,
|
||||
WebhookSignatures.sign(SECRET, BODY),
|
||||
"x.pdf",
|
||||
request(BODY)));
|
||||
|
||||
assertEquals(413, ex.getStatusCode().value());
|
||||
assertTrue(spooledFiles().isEmpty());
|
||||
}
|
||||
|
||||
private List<Path> spooledFiles() {
|
||||
Path dir = spool.dirFor(WEBHOOK_ID);
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return List.of();
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(dir)) {
|
||||
return entries.filter(Files::isRegularFile)
|
||||
.filter(p -> !p.getFileName().toString().startsWith("."))
|
||||
.toList();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
-263
@@ -1,263 +0,0 @@
|
||||
package stirling.software.proprietary.policy.webhook;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.FileReadinessChecker;
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.policy.input.ResolveContext;
|
||||
import stirling.software.proprietary.policy.input.ResolvedInput;
|
||||
import stirling.software.proprietary.policy.input.S3InputSource;
|
||||
import stirling.software.proprietary.policy.input.WebhookInputSource;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
|
||||
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** End-to-end connection-backed webhook against MinIO: stage, read via S3 delegation, consume. */
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class WebhookS3ConnectionMinioTest {
|
||||
|
||||
private static final String POLICY = "p1";
|
||||
private static final String ACCESS_KEY = "minioadmin";
|
||||
private static final String SECRET_KEY = "minioadmin";
|
||||
private static final String WEBHOOK_ID = "miniowebhookid12";
|
||||
private static final String SECRET = "topsecret";
|
||||
private static final long CONNECTION_ID = 7L;
|
||||
|
||||
@Container
|
||||
static MinIOContainer minio =
|
||||
new MinIOContainer("minio/minio:latest")
|
||||
.withUserName(ACCESS_KEY)
|
||||
.withPassword(SECRET_KEY);
|
||||
|
||||
private static S3Client adminClient;
|
||||
private static int bucketCounter;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private String bucket;
|
||||
private String stagingPrefix;
|
||||
private WebhookReceiverController receiver;
|
||||
private WebhookInputSource inputSource;
|
||||
private InProcessProcessedLedger ledger;
|
||||
private RecordingContext ctx;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
if (adminClient == null) {
|
||||
adminClient =
|
||||
S3Client.builder()
|
||||
.endpointOverride(URI.create(minio.getS3URL()))
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.US_EAST_1)
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
|
||||
.serviceConfiguration(
|
||||
S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
||||
.build();
|
||||
}
|
||||
bucket = "webhook-inbox-" + ++bucketCounter;
|
||||
adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
stagingPrefix = "stirling-webhook/" + WEBHOOK_ID + "/";
|
||||
|
||||
// The MinIO endpoint resolves to loopback, so the operator opt-in must be on.
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
|
||||
// A resolver over a MinIO S3 connection; no principal here, so ownership is skipped.
|
||||
S3ConnectionResolver resolver = resolverFor(minioConnection());
|
||||
S3ConnectionPool pool = new S3ConnectionPool(properties);
|
||||
S3InputSource s3 = new S3InputSource(pool, resolver);
|
||||
|
||||
inputSource =
|
||||
new WebhookInputSource(
|
||||
new WebhookSpool(tempDir.resolve("spool")),
|
||||
mock(FileReadinessChecker.class),
|
||||
s3);
|
||||
|
||||
InProcessSourceStore sourceStore = new InProcessSourceStore();
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
"s1",
|
||||
"Partner uploads",
|
||||
"webhook",
|
||||
Map.of(
|
||||
"webhookId", WEBHOOK_ID,
|
||||
"signingSecret", SECRET,
|
||||
"mode", "consume",
|
||||
"connectionId", CONNECTION_ID),
|
||||
true,
|
||||
"owner",
|
||||
null));
|
||||
receiver =
|
||||
new WebhookReceiverController(
|
||||
sourceStore,
|
||||
new WebhookSpool(tempDir.resolve("spool")),
|
||||
mock(WebhookTrigger.class),
|
||||
properties,
|
||||
resolver,
|
||||
pool);
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeliveryIsStagedToTheConnectionThenReadAndConsumed() throws IOException {
|
||||
byte[] body = "a pdf".getBytes(StandardCharsets.UTF_8);
|
||||
String signature = WebhookSignatures.sign(SECRET, body);
|
||||
|
||||
var response = receiver.receive(WEBHOOK_ID, signature, "invoice.pdf", request(body));
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(202);
|
||||
|
||||
List<S3Object> staged = listUnder(stagingPrefix);
|
||||
assertThat(staged).hasSize(1);
|
||||
// Staged under a unique subfolder so the object basename stays the clean original name.
|
||||
assertThat(staged.get(0).key()).endsWith("/invoice.pdf");
|
||||
|
||||
List<ResolvedInput> work = inputSource.resolve(webhookSpec(), ctx);
|
||||
assertThat(work).hasSize(1);
|
||||
assertThat(work.get(0).inputs().primary().get(0).getFilename()).isEqualTo("invoice.pdf");
|
||||
assertThat(read(work.get(0))).isEqualTo("a pdf");
|
||||
// In flight: a second sweep claims nothing.
|
||||
assertThat(inputSource.resolve(webhookSpec(), ctx)).isEmpty();
|
||||
|
||||
work.get(0).onComplete().accept(true);
|
||||
assertThat(listUnder(stagingPrefix)).isEmpty();
|
||||
assertThat(inputSource.resolve(webhookSpec(), ctx)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRejectedDeliveryStagesNothing() {
|
||||
byte[] body = "a pdf".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
try {
|
||||
receiver.receive(WEBHOOK_ID, "sha256=deadbeef", "x.pdf", request(body));
|
||||
} catch (RuntimeException expected) {
|
||||
// 401 invalid signature
|
||||
}
|
||||
|
||||
assertThat(listUnder(stagingPrefix)).isEmpty();
|
||||
}
|
||||
|
||||
private IntegrationConfig minioConnection() {
|
||||
IntegrationConfig connection = new IntegrationConfig();
|
||||
connection.setId(CONNECTION_ID);
|
||||
connection.setIntegrationType(IntegrationType.S3);
|
||||
connection.setName("minio");
|
||||
connection.setEnabled(true);
|
||||
connection.setConfig(
|
||||
new ObjectMapper()
|
||||
.writeValueAsString(
|
||||
Map.of(
|
||||
"bucket", bucket,
|
||||
"region", "us-east-1",
|
||||
"endpoint", minio.getS3URL(),
|
||||
"accessKeyId", ACCESS_KEY,
|
||||
"secretAccessKey", SECRET_KEY)));
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static S3ConnectionResolver resolverFor(IntegrationConfig connection) {
|
||||
IntegrationConfigRepository connections = mock(IntegrationConfigRepository.class);
|
||||
when(connections.findById(connection.getId())).thenReturn(Optional.of(connection));
|
||||
return new S3ConnectionResolver(
|
||||
connections, mock(OwnershipService.class), mock(UserService.class));
|
||||
}
|
||||
|
||||
private static InputSpec webhookSpec() {
|
||||
return new InputSpec(
|
||||
"webhook",
|
||||
Map.of(
|
||||
"webhookId", WEBHOOK_ID,
|
||||
"signingSecret", SECRET,
|
||||
"mode", "consume",
|
||||
"connectionId", CONNECTION_ID));
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest request(byte[] body) {
|
||||
MockHttpServletRequest req =
|
||||
new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID);
|
||||
req.setContent(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
private List<S3Object> listUnder(String prefix) {
|
||||
return adminClient
|
||||
.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).prefix(prefix).build())
|
||||
.contents();
|
||||
}
|
||||
|
||||
private static String read(ResolvedInput unit) throws IOException {
|
||||
try (InputStream stream = unit.inputs().primary().get(0).getInputStream()) {
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingContext implements ResolveContext {
|
||||
|
||||
private final List<String> present = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
|
||||
return ledger.claim(POLICY, identity, gate, contentHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void settle(
|
||||
String identity, String finalGate, String finalContentHash, boolean success) {
|
||||
ledger.settle(POLICY, identity, finalGate, finalContentHash, success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allSettledDone(String identity) {
|
||||
return ledger.allSettledDone(identity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportPresent(Collection<String> identities) {
|
||||
present.addAll(identities);
|
||||
}
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
package stirling.software.proprietary.policy.webhook;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** The HMAC-SHA256 webhook signing scheme: correct signatures verify, tampered ones do not. */
|
||||
class WebhookSignaturesTest {
|
||||
|
||||
private static final String SECRET = "whsec_test_secret";
|
||||
private static final byte[] BODY = "the document bytes".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
void aFreshlySignedBodyVerifies() {
|
||||
String header = WebhookSignatures.sign(SECRET, BODY);
|
||||
assertTrue(header.startsWith("sha256="));
|
||||
assertTrue(WebhookSignatures.verify(SECRET, BODY, header));
|
||||
}
|
||||
|
||||
@Test
|
||||
void abarehexSignatureVerifiesToo() {
|
||||
String header = WebhookSignatures.sign(SECRET, BODY);
|
||||
String bareHex = header.substring("sha256=".length());
|
||||
assertTrue(WebhookSignatures.verify(SECRET, BODY, bareHex));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWrongSecretDoesNotVerify() {
|
||||
String header = WebhookSignatures.sign(SECRET, BODY);
|
||||
assertFalse(WebhookSignatures.verify("other-secret", BODY, header));
|
||||
}
|
||||
|
||||
@Test
|
||||
void atamperedBodyDoesNotVerify() {
|
||||
String header = WebhookSignatures.sign(SECRET, BODY);
|
||||
byte[] tampered = "the document byteS".getBytes(StandardCharsets.UTF_8);
|
||||
assertFalse(WebhookSignatures.verify(SECRET, tampered, header));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingOrMalformedHeaderIsFalseNotAnError() {
|
||||
assertFalse(WebhookSignatures.verify(SECRET, BODY, null));
|
||||
assertFalse(WebhookSignatures.verify(SECRET, BODY, "sha256=not-hex"));
|
||||
assertFalse(WebhookSignatures.verify(SECRET, BODY, ""));
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
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;"));
|
||||
}
|
||||
}
|
||||
-12
@@ -103,18 +103,6 @@ class SecretMaskerTest {
|
||||
assertEquals("AKIAEXAMPLE", result.get("accessKeyId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should mask camelCase signingSecret despite no word boundary")
|
||||
void shouldMaskCamelCaseSigningSecret() {
|
||||
Map<String, Object> input = Map.of("signingSecret", "shh", "webhookId", "whk_abc");
|
||||
|
||||
Map<String, Object> result = SecretMasker.mask(input);
|
||||
|
||||
assertEquals(SecretMasker.REDACTED, result.get("signingSecret"));
|
||||
// The routing id is a public URL token, not a secret.
|
||||
assertEquals("whk_abc", result.get("webhookId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should mask nested map sensitive keys")
|
||||
void shouldMaskNestedMapSensitiveKeys() {
|
||||
|
||||
+10
-1
@@ -36,6 +36,8 @@ 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"
|
||||
@@ -91,7 +93,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.14.1'
|
||||
version = '2.14.2'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
||||
@@ -222,6 +224,13 @@ 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,7 +5,6 @@ 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. Jinja renders the document to HTML. The LLM never writes HTML.
|
||||
6. The assembled document is emitted as structured fields. 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,9 +22,7 @@ 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
|
||||
|
||||
@@ -51,8 +49,6 @@ 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.
|
||||
@@ -166,10 +162,13 @@ 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"). 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.
|
||||
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.
|
||||
|
||||
- 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
|
||||
@@ -299,15 +298,6 @@ 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("-")
|
||||
@@ -320,7 +310,6 @@ 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,
|
||||
@@ -401,14 +390,12 @@ class PdfCreateAgent:
|
||||
sections=all_sections,
|
||||
)
|
||||
|
||||
# ── Phase 6: render ────────────────────────────────────────────────────
|
||||
logger.info("[pdf-create] phase 6/6: rendering HTML")
|
||||
html = self._render(doc)
|
||||
# ── Phase 6: emit ──────────────────────────────────────────────────────
|
||||
filename = _safe_filename(plan.title)
|
||||
logger.info(
|
||||
"[pdf-create] done — filename=%r html_bytes=%d",
|
||||
"[pdf-create] done — filename=%r sections=%d",
|
||||
filename,
|
||||
len(html),
|
||||
len(all_sections),
|
||||
)
|
||||
|
||||
return EditPlanResponse(
|
||||
@@ -417,7 +404,7 @@ class PdfCreateAgent:
|
||||
ToolOperationStep(
|
||||
tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT,
|
||||
parameters=CreatePdfFromHtmlAgentParams(
|
||||
html_content=html,
|
||||
document=doc.model_dump_json(),
|
||||
filename=filename,
|
||||
),
|
||||
)
|
||||
@@ -437,7 +424,3 @@ 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)
|
||||
|
||||
@@ -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 rendered HTML.
|
||||
CREATE_PDF_FROM_HTML_AGENT plan step carrying the assembled document.
|
||||
|
||||
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. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML.
|
||||
5. The document is emitted as structured fields. The LLM never writes HTML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -81,14 +81,12 @@ type DocumentSection = Annotated[
|
||||
]
|
||||
|
||||
|
||||
# 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}$")
|
||||
# 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}$")
|
||||
|
||||
|
||||
class DocumentStyle(ApiModel):
|
||||
"""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``."""
|
||||
"""Document colours, inferred by the meta planner. Non-hex values are dropped to ``None``."""
|
||||
|
||||
primary_color: str | None = Field(default=None)
|
||||
background_color: str | None = Field(default=None)
|
||||
@@ -103,7 +101,7 @@ class DocumentStyle(ApiModel):
|
||||
|
||||
|
||||
class GeneratedDocument(ApiModel):
|
||||
"""The full document model passed to Jinja for HTML rendering."""
|
||||
"""The full document model emitted for rendering."""
|
||||
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
|
||||
@@ -29,7 +29,7 @@ class PdfCommentAgentParams(ApiModel):
|
||||
|
||||
|
||||
class CreatePdfFromHtmlAgentParams(ApiModel):
|
||||
html_content: str
|
||||
document: str
|
||||
filename: str = Field(pattern=r"^.+\.pdf$")
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Coverage:
|
||||
1. Section model validation (each section type round-trips correctly)
|
||||
2. Jinja rendering (_render produces valid HTML for each section type)
|
||||
2. orchestrate() emits the assembled document as structured JSON
|
||||
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,6 +12,8 @@ Coverage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from conftest import build_app_settings
|
||||
from pydantic_ai.models.test import TestModel
|
||||
@@ -64,37 +66,6 @@ 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",
|
||||
@@ -199,82 +170,6 @@ 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 ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -396,8 +291,9 @@ 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")
|
||||
assert "<!DOCTYPE html>" in step.parameters.html_content
|
||||
assert "Invoice" in step.parameters.html_content
|
||||
parsed = json.loads(step.parameters.document)
|
||||
assert parsed["title"] == "Invoice"
|
||||
assert parsed["sections"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -468,9 +364,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)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "Introduction text." in html
|
||||
assert "Details" in html
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "Introduction text." in document
|
||||
assert "Details" in document
|
||||
|
||||
|
||||
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
|
||||
@@ -482,7 +378,7 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
|
||||
meta = DocumentMeta(
|
||||
title="Styled Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="magenta",
|
||||
style_primary_color="#ff00ff",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -499,50 +395,35 @@ 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)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "magenta" in html
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert json.loads(document)["style"]["primaryColor"] == "#ff00ff"
|
||||
|
||||
|
||||
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")
|
||||
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")
|
||||
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
|
||||
"navy",
|
||||
"#1e3a5f",
|
||||
"#fff",
|
||||
"#ffffff",
|
||||
"#1A1A1A",
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
|
||||
assert DocumentStyle(primary_color="navy\n").primary_color is None
|
||||
assert DocumentStyle(primary_color="#1e3a5f\n").primary_color is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
|
||||
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
|
||||
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."""
|
||||
meta = DocumentMeta(
|
||||
title="Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="blue; background: url(http://evil.test/)",
|
||||
style_primary_color="rgb(0, 0, 255)",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -559,6 +440,6 @@ async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) ->
|
||||
result = await agent.orchestrate(_orchestrator_request("make it blue"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "evil.test" not in html
|
||||
assert "url(" not in html
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "rgb(" not in document
|
||||
assert json.loads(document)["style"]["primaryColor"] is None
|
||||
|
||||
Generated
-2
@@ -604,7 +604,6 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "pgvector" },
|
||||
{ name = "posthog" },
|
||||
@@ -631,7 +630,6 @@ 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" },
|
||||
|
||||
@@ -8139,36 +8139,6 @@ placeholder = "us-east-1"
|
||||
[portal.sources.types.s3.fields.secretAccessKey]
|
||||
label = "Secret access key"
|
||||
|
||||
[portal.sources.types.webhook]
|
||||
description = "Receive documents by signed HTTP POST from any external system."
|
||||
label = "Webhook"
|
||||
|
||||
[portal.sources.types.webhook.detail]
|
||||
deliveryUrl = "Delivery URL"
|
||||
secretNote = "The signing secret is shown once, when the webhook is created. Recreate the source to roll it."
|
||||
|
||||
[portal.sources.types.webhook.fields.connection]
|
||||
helperText = "Choose an S3 connection to stage deliveries durably (required in hosted). Leave blank on a self-hosted server to stage them on local disk."
|
||||
label = "Storage connection"
|
||||
|
||||
[portal.sources.types.webhook.fields.mode]
|
||||
helperText = "Consume removes each delivered document once every policy has processed it."
|
||||
label = "Read mode"
|
||||
|
||||
[portal.sources.types.webhook.fields.mode.options]
|
||||
consume = "Consume: process each delivery once"
|
||||
snapshot = "Snapshot: re-read the spool every run"
|
||||
|
||||
[portal.sources.types.webhook.reveal]
|
||||
copy = "Copy"
|
||||
done = "Done"
|
||||
secret = "Signing secret"
|
||||
secretHelp = "Sign each delivery's raw body with this key (HMAC-SHA256) and send it as the X-Stirling-Signature header."
|
||||
secretWarning = "Copy the signing secret now. For your security it is shown only once and cannot be retrieved later."
|
||||
title = "Webhook created"
|
||||
url = "Delivery URL"
|
||||
usage = "POST each document as the raw request body to the delivery URL with a binary content type (application/pdf or application/octet-stream). Referencing policies run automatically on arrival."
|
||||
|
||||
[portal.sources.types.unknown]
|
||||
label = "Source"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling PDF",
|
||||
"mainBinaryName": "Stirling-PDF",
|
||||
"version": "2.14.1",
|
||||
"version": "2.14.2",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import {
|
||||
deserializeToolStep,
|
||||
getExecutableTools,
|
||||
serializeStepFromEndpoint,
|
||||
serializeToolStep,
|
||||
stepRequiresUpload,
|
||||
type WorkingToolStep,
|
||||
@@ -199,41 +198,6 @@ 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,31 +197,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import type { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
|
||||
import type { BaseParametersHook } from "@app/hooks/tools/shared/useBaseParameters";
|
||||
import type { StirlingFile } from "@app/types/fileContext";
|
||||
|
||||
// Isolate useBaseTool from the file/viewer/endpoint contexts so the test can
|
||||
// focus on the unmount lifecycle wiring.
|
||||
const scopedFiles: { current: StirlingFile[] } = { current: [] };
|
||||
vi.mock("@app/hooks/tools/shared/useViewScopedFiles", () => ({
|
||||
useViewScopedFiles: () => scopedFiles.current,
|
||||
}));
|
||||
vi.mock("@app/hooks/useEndpointConfig", () => ({
|
||||
useEndpointEnabled: () => ({ enabled: true, loading: false }),
|
||||
}));
|
||||
|
||||
const cancelOperation = vi.fn();
|
||||
|
||||
const makeOperation = (isLoading: boolean): ToolOperationHook<unknown> =>
|
||||
({
|
||||
files: [],
|
||||
thumbnails: [],
|
||||
isGeneratingThumbnails: false,
|
||||
downloadUrl: null,
|
||||
downloadFilename: null,
|
||||
downloadLocalPath: null,
|
||||
outputFileIds: [],
|
||||
isLoading,
|
||||
status: "",
|
||||
errorMessage: null,
|
||||
progress: null,
|
||||
willUseCloud: false,
|
||||
executeOperation: vi.fn(),
|
||||
resetResults: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
cancelOperation,
|
||||
undoOperation: vi.fn(),
|
||||
}) as unknown as ToolOperationHook<unknown>;
|
||||
|
||||
const makeParams = (): BaseParametersHook<unknown> =>
|
||||
({
|
||||
parameters: {},
|
||||
updateParameter: vi.fn(),
|
||||
setParameters: vi.fn(),
|
||||
resetParameters: vi.fn(),
|
||||
validateParameters: () => true,
|
||||
getEndpointName: () => "compress-pdf",
|
||||
}) as unknown as BaseParametersHook<unknown>;
|
||||
|
||||
const props = {
|
||||
onPreviewFile: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
};
|
||||
|
||||
describe("useBaseTool unmount behaviour", () => {
|
||||
beforeEach(() => {
|
||||
cancelOperation.mockClear();
|
||||
scopedFiles.current = [];
|
||||
});
|
||||
|
||||
test("aborts the in-flight operation when the tool unmounts mid-run", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useBaseTool("compress", makeParams, () => makeOperation(true), props),
|
||||
);
|
||||
|
||||
expect(cancelOperation).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(cancelOperation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does not cancel on unmount when no operation is running", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useBaseTool("compress", makeParams, () => makeOperation(false), props),
|
||||
);
|
||||
|
||||
unmount();
|
||||
expect(cancelOperation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,22 @@ export function useBaseTool<
|
||||
const params = useParams();
|
||||
const operation = useOperation();
|
||||
|
||||
// Abort any in-flight backend request when the tool unmounts — e.g. the user
|
||||
// navigates away or switches tools mid-operation. Without this the request
|
||||
// keeps running and the UI has no way to stop it. A ref keeps the cleanup
|
||||
// pointed at the latest operation without re-subscribing on every render, and
|
||||
// we only cancel while something is actually in flight so a normal unmount
|
||||
// (and React strict-mode's dev remount) stays a no-op.
|
||||
const operationRef = useRef(operation);
|
||||
operationRef.current = operation;
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (operationRef.current.isLoading) {
|
||||
operationRef.current.cancelOperation();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Endpoint validation using parameters hook
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } =
|
||||
useEndpointEnabled(params.getEndpointName());
|
||||
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: "2.14.1",
|
||||
appVersion: "2.14.2",
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -7,15 +7,6 @@ describe("creatableSourceTypes (SaaS)", () => {
|
||||
expect(creatableSourceTypes().map((t) => t.type)).not.toContain("folder");
|
||||
});
|
||||
|
||||
it("offers webhook with a required S3 connection (durable staging in hosted)", () => {
|
||||
const webhook = creatableSourceTypes().find((t) => t.type === "webhook");
|
||||
expect(webhook).toBeDefined();
|
||||
const connection = webhook?.fields.find(
|
||||
(f) => f.control === "s3Connection",
|
||||
);
|
||||
expect(connection?.required).toBe(true);
|
||||
});
|
||||
|
||||
it("still offers the cloud source types", () => {
|
||||
expect(creatableSourceTypes().map((t) => t.type)).toContain("s3");
|
||||
});
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
import {
|
||||
CREATABLE_SOURCE_TYPES,
|
||||
WEBHOOK_SOURCE_TYPE,
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
|
||||
/** Hosted drops folder (no server FS) and requires webhook's S3 connection (node-local isn't durable). */
|
||||
/**
|
||||
* Hosted deployments never read the server's filesystem (the backend's
|
||||
* FolderAccessGuard denies it outright), so folder connections are not offered
|
||||
* in the connect wizard.
|
||||
*/
|
||||
export function creatableSourceTypes(): CreatableSourceType[] {
|
||||
return CREATABLE_SOURCE_TYPES.filter((type) => type.type !== "folder").map(
|
||||
(type) =>
|
||||
type.type === WEBHOOK_SOURCE_TYPE ? withRequiredConnection(type) : type,
|
||||
);
|
||||
}
|
||||
|
||||
/** The type with its S3-connection field marked required (durable staging is mandatory). */
|
||||
function withRequiredConnection(
|
||||
type: CreatableSourceType,
|
||||
): CreatableSourceType {
|
||||
return {
|
||||
...type,
|
||||
fields: type.fields.map((field) =>
|
||||
field.control === "s3Connection" ? { ...field, required: true } : field,
|
||||
),
|
||||
};
|
||||
return CREATABLE_SOURCE_TYPES.filter((type) => type.type !== "folder");
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ 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,
|
||||
@@ -66,7 +68,7 @@ export interface PolicyConfigDef {
|
||||
rules: string[];
|
||||
scopeLabel: string;
|
||||
fields: PolicyField[];
|
||||
defaultOperations: WirePipelineStep[];
|
||||
defaultOperations: PolicyToolStep[];
|
||||
}
|
||||
|
||||
export interface PolicyState {
|
||||
@@ -128,20 +130,11 @@ export interface CatalogueEntry {
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Tool → endpoint registry */
|
||||
/* Endpoint display labels */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
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> = {
|
||||
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
|
||||
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, 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",
|
||||
@@ -154,7 +147,8 @@ export function humanizeEndpoint(
|
||||
path: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
|
||||
const label = ENDPOINT_LABELS[path as ToolEndpoint];
|
||||
if (label) return t(label);
|
||||
const last = path.split("/").filter(Boolean).pop() ?? path;
|
||||
return last
|
||||
.replace(/-/g, " ")
|
||||
@@ -230,10 +224,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.ingestion.rules.3",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.ingestion.fields.minConfidence",
|
||||
@@ -260,32 +251,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
// 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 }),
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
@@ -297,10 +272,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.compliance.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.compliance.fields.frameworks",
|
||||
@@ -343,7 +315,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.routing.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
defaultOperations: [policyStep("compress")],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.routing.fields.destination",
|
||||
@@ -374,7 +346,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.retention.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
defaultOperations: [policyStep("compress")],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.retention.fields.keepFor",
|
||||
|
||||
@@ -32,8 +32,6 @@ export interface SourceView {
|
||||
docsTotal: number;
|
||||
docs24h: number;
|
||||
docs30d: number;
|
||||
/** Webhook sources only: the server-relative delivery path senders POST to. Null otherwise. */
|
||||
webhookPath?: string | null;
|
||||
}
|
||||
|
||||
export interface SourceKpi {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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,6 +1,8 @@
|
||||
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";
|
||||
@@ -46,14 +48,18 @@ export function PipelineStepSettings({
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={step.params}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...step.params, [key]: value })
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</Suspense>
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={step.params}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...step.params, [key]: value })
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</Suspense>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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,23 +13,24 @@ 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 {
|
||||
deserializeToolStep,
|
||||
serializeStepFromEndpoint,
|
||||
} from "@app/hooks/tools/shared/toolAutomation";
|
||||
policyEndpoint,
|
||||
policyStepFromWire,
|
||||
policyStepToWire,
|
||||
type PolicyParams,
|
||||
type PolicyToolId,
|
||||
type PolicyToolStep,
|
||||
} from "@app/policies/operations";
|
||||
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";
|
||||
@@ -47,12 +48,8 @@ interface PolicySetupWizardProps {
|
||||
|
||||
type Step = "workflow" | "settings";
|
||||
|
||||
/** A configurable tool in the workflow step: whether it runs + its params. */
|
||||
interface ToolState {
|
||||
operation: string;
|
||||
enabled: boolean;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
/** A policy step plus whether it runs. */
|
||||
type ToolState = PolicyToolStep & { enabled: boolean };
|
||||
|
||||
/** Resolve each field's effective value: saved override, else definition default. */
|
||||
function resolveFieldValues(
|
||||
@@ -69,9 +66,8 @@ 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: 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"]);
|
||||
// Temporary until the catalogue carries a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
|
||||
|
||||
/**
|
||||
* Policy-facing framing for each capability a policy can include. Labels and
|
||||
@@ -81,43 +77,43 @@ const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
|
||||
* the humanised endpoint name with no description.
|
||||
*/
|
||||
const CAPABILITY_META: Record<
|
||||
string,
|
||||
PolicyToolId,
|
||||
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
|
||||
> = {
|
||||
[TOOL_ENDPOINTS.redact]: {
|
||||
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.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.sanitize]: {
|
||||
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.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.watermark]: {
|
||||
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.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.ocr]: {
|
||||
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.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.flatten]: {
|
||||
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.",
|
||||
},
|
||||
[TOOL_ENDPOINTS.compress]: {
|
||||
compress: {
|
||||
labelKey: "portal.policies.wizard.capability.compress.label",
|
||||
labelEn: "Reduce file size",
|
||||
descKey: "portal.policies.wizard.capability.compress.desc",
|
||||
@@ -125,29 +121,24 @@ const CAPABILITY_META: Record<
|
||||
},
|
||||
};
|
||||
|
||||
function seedTools(
|
||||
entry: CatalogueEntry,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ToolState[] {
|
||||
function seedTools(entry: CatalogueEntry): ToolState[] {
|
||||
const savedSteps = entry.policy?.steps ?? [];
|
||||
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);
|
||||
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);
|
||||
return {
|
||||
operation: s.operation,
|
||||
...(saved ?? preset),
|
||||
enabled: saved
|
||||
? true
|
||||
: savedSteps.length > 0
|
||||
? false
|
||||
: !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,
|
||||
: !DISABLED_BY_DEFAULT.has(preset.toolId),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -185,26 +176,12 @@ 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, toolRegistry),
|
||||
);
|
||||
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
|
||||
const [fieldValues, setFieldValues] = useState(() =>
|
||||
resolveFieldValues(entry),
|
||||
);
|
||||
@@ -213,22 +190,11 @@ function PolicySetupWizardBody({
|
||||
);
|
||||
|
||||
const sourcesAsync = useAsync(() => fetchSources(), []);
|
||||
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]);
|
||||
const availableSources = useMemo(
|
||||
() =>
|
||||
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
|
||||
[sourcesAsync.data],
|
||||
);
|
||||
// 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 ?? []);
|
||||
@@ -256,9 +222,20 @@ function PolicySetupWizardBody({
|
||||
|
||||
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
|
||||
|
||||
function patchTool(operation: string, patch: Partial<ToolState>) {
|
||||
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
|
||||
setTools((prev) =>
|
||||
prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,11 +254,8 @@ 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) =>
|
||||
serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry),
|
||||
policyStepToWire(tl),
|
||||
);
|
||||
try {
|
||||
await onSubmit(entry, {
|
||||
@@ -386,20 +360,16 @@ function PolicySetupWizardBody({
|
||||
<Card padding="none">
|
||||
<div className="portal-policies__capabilities">
|
||||
{tools.map((tl) => {
|
||||
const meta = CAPABILITY_META[tl.operation];
|
||||
const meta = CAPABILITY_META[tl.toolId];
|
||||
const label = meta
|
||||
? t(meta.labelKey, meta.labelEn)
|
||||
: (registryByEndpoint.get(tl.operation)?.name ??
|
||||
humanizeEndpoint(tl.operation, t));
|
||||
: humanizeEndpoint(policyEndpoint(tl.toolId), 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.operation}
|
||||
key={tl.toolId}
|
||||
className="portal-policies__capability"
|
||||
data-on={tl.enabled || undefined}
|
||||
>
|
||||
@@ -411,27 +381,27 @@ function PolicySetupWizardBody({
|
||||
size="sm"
|
||||
checked={tl.enabled}
|
||||
onChange={(checked) =>
|
||||
patchTool(tl.operation, { enabled: checked })
|
||||
setToolEnabled(tl.toolId, checked)
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{tl.enabled && hasConfig && (
|
||||
{tl.enabled && (
|
||||
<div className="portal-policies__capability-config">
|
||||
{tl.operation === TOOL_ENDPOINTS.redact && (
|
||||
{tl.toolId === "redact" && (
|
||||
<PolicyRedactConfig
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
parameters={tl.params}
|
||||
onChange={(params) =>
|
||||
setToolParams("redact", params)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tl.operation === TOOL_ENDPOINTS.watermark && (
|
||||
{tl.toolId === "watermark" && (
|
||||
<PolicyWatermarkConfig
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
parameters={tl.params}
|
||||
onChange={(params) =>
|
||||
setToolParams("watermark", params)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -475,9 +445,8 @@ function PolicySetupWizardBody({
|
||||
{t("portal.policies.wizard.sources.loading")}
|
||||
</p>
|
||||
) : (
|
||||
// The editor is always an available source (unconditionally prepended
|
||||
// to availableSources), so the list is never empty — no "no sources"
|
||||
// state exists.
|
||||
// The backend always returns the editor as a virtual source, so the
|
||||
// loaded 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).
|
||||
|
||||
@@ -22,9 +22,6 @@ export interface SourceTypeMeta {
|
||||
*/
|
||||
export const EDITOR_SOURCE_TYPE = "editor";
|
||||
|
||||
/** The webhook source type. Its delivery URL + signing secret are minted server-side on create. */
|
||||
export const WEBHOOK_SOURCE_TYPE = "webhook";
|
||||
|
||||
const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
|
||||
folder: {
|
||||
labelKey: "portal.sources.types.folder.label",
|
||||
@@ -41,11 +38,6 @@ const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
|
||||
icon: "☁",
|
||||
accent: "brand",
|
||||
},
|
||||
webhook: {
|
||||
labelKey: "portal.sources.types.webhook.label",
|
||||
icon: "↯",
|
||||
accent: "warning",
|
||||
},
|
||||
};
|
||||
|
||||
const UNKNOWN_TYPE_META: SourceTypeMeta = {
|
||||
@@ -188,40 +180,6 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// URL + secret minted server-side (revealed once); an S3 connection makes staging durable, else local disk.
|
||||
type: WEBHOOK_SOURCE_TYPE,
|
||||
labelKey: "portal.sources.types.webhook.label",
|
||||
descriptionKey: "portal.sources.types.webhook.description",
|
||||
fields: [
|
||||
{
|
||||
key: "connectionId",
|
||||
labelKey: "portal.sources.types.webhook.fields.connection.label",
|
||||
control: "s3Connection",
|
||||
helperTextKey:
|
||||
"portal.sources.types.webhook.fields.connection.helperText",
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
labelKey: "portal.sources.types.webhook.fields.mode.label",
|
||||
control: "select",
|
||||
defaultValue: "consume",
|
||||
helperTextKey: "portal.sources.types.webhook.fields.mode.helperText",
|
||||
options: [
|
||||
{
|
||||
value: "consume",
|
||||
labelKey:
|
||||
"portal.sources.types.webhook.fields.mode.options.consume",
|
||||
},
|
||||
{
|
||||
value: "snapshot",
|
||||
labelKey:
|
||||
"portal.sources.types.webhook.fields.mode.options.snapshot",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Default option values for a type's create form. */
|
||||
|
||||
@@ -55,18 +55,6 @@ function seedSources(): StoredSource[] {
|
||||
enabled: false,
|
||||
owner: "data-eng@acme.com",
|
||||
},
|
||||
{
|
||||
id: "src-webhook",
|
||||
name: "Partner uploads",
|
||||
type: "webhook",
|
||||
options: {
|
||||
webhookId: "whk_demo_5f3a9c21b7",
|
||||
signingSecret: "whsec_demo_2b8e1d47a9f60c35",
|
||||
mode: "consume",
|
||||
},
|
||||
enabled: true,
|
||||
owner: "you@acme.com",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -88,7 +76,6 @@ const docCounts: Record<
|
||||
"src-contracts": { total: 12840, last24h: 96, last30d: 2310 },
|
||||
"src-archive": { total: 1180, last24h: 0, last30d: 0 },
|
||||
"src-legacy": { total: 48600, last24h: 0, last30d: 0 },
|
||||
"src-webhook": { total: 3120, last24h: 24, last30d: 640 },
|
||||
};
|
||||
|
||||
function docsFor(id: string): {
|
||||
@@ -109,23 +96,14 @@ function nextId(): string {
|
||||
return `src_${Date.now().toString(36)}_${idCounter}`;
|
||||
}
|
||||
|
||||
/** A demo token for a newly created webhook's server-generated id/secret (mock only). */
|
||||
function randomToken(): string {
|
||||
return (
|
||||
Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
|
||||
);
|
||||
}
|
||||
|
||||
function refsFor(id: string): SourcePolicyRef[] {
|
||||
return references[id] ?? [];
|
||||
}
|
||||
|
||||
/** Mirror the backend's secret redaction so config rows never surface a secret in the overview. */
|
||||
function configRows(options: Record<string, unknown>) {
|
||||
const secret = /secret|password|token/i;
|
||||
return Object.entries(options).map(([key, value]) => ({
|
||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
value: secret.test(key) ? "********" : String(value),
|
||||
value: String(value),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -142,7 +120,6 @@ function toSourceView(
|
||||
refs: SourcePolicyRef[],
|
||||
): SourceView {
|
||||
const docs = docsFor(source.id);
|
||||
const webhookId = source.options.webhookId;
|
||||
return {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
@@ -154,10 +131,6 @@ function toSourceView(
|
||||
docsTotal: docs.total,
|
||||
docs24h: docs.last24h,
|
||||
docs30d: docs.last30d,
|
||||
webhookPath:
|
||||
source.type === "webhook" && typeof webhookId === "string"
|
||||
? `/api/v1/webhooks/${webhookId}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -234,18 +207,8 @@ export const sourcesHandlers = [
|
||||
? store.find((s) => s.id === incoming.id)
|
||||
: undefined;
|
||||
const id = existing?.id ?? nextId();
|
||||
// A new webhook's id + secret are minted server-side and revealed once on this create response.
|
||||
let options = incoming.options ?? {};
|
||||
if (incoming.type === "webhook" && !existing && !options.webhookId) {
|
||||
options = {
|
||||
...options,
|
||||
webhookId: `whk_${randomToken()}`,
|
||||
signingSecret: `whsec_${randomToken()}`,
|
||||
};
|
||||
}
|
||||
const saved: StoredSource = {
|
||||
...incoming,
|
||||
options,
|
||||
id,
|
||||
owner: existing?.owner ?? "you@acme.com",
|
||||
};
|
||||
|
||||
@@ -4,13 +4,33 @@
|
||||
* only builds seed data for the MSW handlers and tests.
|
||||
*/
|
||||
|
||||
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
|
||||
import { POLICY_CONFIG } from "@portal/api/policies";
|
||||
import type {
|
||||
PolicyRunView,
|
||||
WirePipelineStep,
|
||||
WirePolicy,
|
||||
} from "@app/policies/types";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* 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 [
|
||||
{
|
||||
@@ -19,7 +39,7 @@ export function seedPolicies(): WirePolicy[] {
|
||||
owner: "security@acme.com",
|
||||
enabled: true,
|
||||
trigger: null,
|
||||
steps: POLICY_CONFIG.security.defaultOperations,
|
||||
steps: SECURITY_STEPS,
|
||||
output: {
|
||||
type: "inline",
|
||||
options: {
|
||||
|
||||
@@ -85,27 +85,3 @@
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Webhook delivery URL / signing secret: read-only value + copy button. */
|
||||
.portal-source-builder__copy-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portal-source-builder__copy-row > :first-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-source-builder__reveal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-source-builder__muted {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
@@ -102,39 +102,6 @@ describe("SourceBuilder", () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("reveals the delivery URL and signing secret once after creating a webhook", async () => {
|
||||
createSource.mockResolvedValue({
|
||||
id: "wh-1",
|
||||
options: { webhookId: "whk_abc123", signingSecret: "whsec_topsecret" },
|
||||
});
|
||||
renderBuilder("/processor/sources/new");
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
|
||||
target: { value: "Partner uploads" },
|
||||
});
|
||||
// Webhook's connection is optional (self-hosted local-disk), so a name is enough to create.
|
||||
fireEvent.click(screen.getByText("portal.sources.types.webhook.label"));
|
||||
fireEvent.click(screen.getByText("portal.sources.builder.create"));
|
||||
|
||||
await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1));
|
||||
expect(createSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "webhook", name: "Partner uploads" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByDisplayValue("whsec_topsecret"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue(/\/api\/v1\/webhooks\/whk_abc123$/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("sources list")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByText("portal.sources.types.webhook.reveal.done"),
|
||||
);
|
||||
expect(await screen.findByText("sources list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks create until required fields are filled", async () => {
|
||||
renderBuilder("/processor/sources/new");
|
||||
// Name given but directory (required) still blank -> Create disabled.
|
||||
|
||||
@@ -27,17 +27,11 @@ import {
|
||||
CREATABLE_SOURCE_TYPES,
|
||||
defaultOptions,
|
||||
sourceTypeMeta,
|
||||
WEBHOOK_SOURCE_TYPE,
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker";
|
||||
import "@portal/views/SourceBuilder.css";
|
||||
|
||||
/** Absolute delivery URL a sender POSTs to for a webhook's routing id. */
|
||||
function webhookUrl(webhookId: string): string {
|
||||
return `${window.location.origin}/api/v1/webhooks/${webhookId}`;
|
||||
}
|
||||
|
||||
const OFFERED_TYPES = creatableSourceTypes();
|
||||
|
||||
/** A source's stored type resolved to its create-form metadata (edit falls back to any type). */
|
||||
@@ -90,11 +84,6 @@ export function SourceBuilder() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
// Set after a webhook is created: its one-time delivery id + signing secret, shown before leaving.
|
||||
const [reveal, setReveal] = useState<{
|
||||
webhookId: string;
|
||||
secret: string;
|
||||
} | null>(null);
|
||||
|
||||
// Seed once: immediately for a new source, or after the record loads for edit.
|
||||
useEffect(() => {
|
||||
@@ -123,40 +112,18 @@ export function SourceBuilder() {
|
||||
);
|
||||
const canSave = name.trim() !== "" && requiredComplete && !submitting;
|
||||
|
||||
// An existing webhook's delivery URL, read-only in edit (the secret is revealed only at creation).
|
||||
const editingWebhookId =
|
||||
isEdit && sourceState.data?.type === WEBHOOK_SOURCE_TYPE
|
||||
? String(sourceState.data.options?.webhookId ?? "")
|
||||
: "";
|
||||
const revealUrl = reveal ? webhookUrl(reveal.webhookId) : "";
|
||||
const revealSecret = reveal ? reveal.secret : "";
|
||||
|
||||
function dismissReveal() {
|
||||
setReveal(null);
|
||||
navigate(listPath);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await createSource({
|
||||
await createSource({
|
||||
id: isEdit ? id : undefined,
|
||||
name: name.trim(),
|
||||
type: type.type,
|
||||
options,
|
||||
enabled,
|
||||
});
|
||||
// A new webhook returns its minted id + secret once; reveal them (with the URL) before leaving.
|
||||
if (!isEdit && type.type === WEBHOOK_SOURCE_TYPE) {
|
||||
const webhookId = String(saved.options?.webhookId ?? "");
|
||||
const secret = String(saved.options?.signingSecret ?? "");
|
||||
if (webhookId && secret) {
|
||||
setReveal({ webhookId, secret });
|
||||
return;
|
||||
}
|
||||
}
|
||||
navigate(listPath);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
@@ -164,10 +131,6 @@ export function SourceBuilder() {
|
||||
}
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
void navigator.clipboard?.writeText(text);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!id || deleting) return;
|
||||
setDeleting(true);
|
||||
@@ -327,28 +290,6 @@ export function SourceBuilder() {
|
||||
</FormField>
|
||||
))}
|
||||
|
||||
{editingWebhookId && (
|
||||
<FormField
|
||||
label={t("portal.sources.types.webhook.detail.deliveryUrl")}
|
||||
helperText={t("portal.sources.types.webhook.detail.secretNote")}
|
||||
>
|
||||
<div className="portal-source-builder__copy-row">
|
||||
<Input
|
||||
value={webhookUrl(editingWebhookId)}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => copy(webhookUrl(editingWebhookId))}
|
||||
>
|
||||
{t("portal.sources.types.webhook.reveal.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
</div>
|
||||
|
||||
@@ -380,69 +321,6 @@ export function SourceBuilder() {
|
||||
>
|
||||
<p>{t("portal.sources.delete.body", { name })}</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={reveal !== null}
|
||||
onClose={dismissReveal}
|
||||
width="md"
|
||||
title={t("portal.sources.types.webhook.reveal.title")}
|
||||
footer={
|
||||
<div className="portal-source-builder__delete-actions">
|
||||
<Button size="sm" onClick={dismissReveal}>
|
||||
{t("portal.sources.types.webhook.reveal.done")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{reveal && (
|
||||
<div className="portal-source-builder__reveal">
|
||||
<Banner
|
||||
tone="warning"
|
||||
description={t(
|
||||
"portal.sources.types.webhook.reveal.secretWarning",
|
||||
)}
|
||||
/>
|
||||
<FormField label={t("portal.sources.types.webhook.reveal.url")}>
|
||||
<div className="portal-source-builder__copy-row">
|
||||
<Input
|
||||
value={revealUrl}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => copy(revealUrl)}
|
||||
>
|
||||
{t("portal.sources.types.webhook.reveal.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.webhook.reveal.secret")}
|
||||
helperText={t("portal.sources.types.webhook.reveal.secretHelp")}
|
||||
>
|
||||
<div className="portal-source-builder__copy-row">
|
||||
<Input
|
||||
value={revealSecret}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => copy(revealSecret)}
|
||||
>
|
||||
{t("portal.sources.types.webhook.reveal.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
<p className="portal-source-builder__muted">
|
||||
{t("portal.sources.types.webhook.reveal.usage")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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));
|
||||
@@ -8,8 +9,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: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
parameters: RedactParameters;
|
||||
onChange: (parameters: RedactParameters) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -26,9 +27,7 @@ export function PolicyPiiField({
|
||||
disabled,
|
||||
}: PolicyPiiFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const words = Array.isArray(parameters.wordsToRedact)
|
||||
? (parameters.wordsToRedact as string[])
|
||||
: [];
|
||||
const words = parameters.wordsToRedact;
|
||||
const selected = words
|
||||
.map((w) => VALUE_BY_PATTERN.get(w))
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { PolicyPiiField } from "@app/components/policies/PolicyPiiField";
|
||||
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
|
||||
|
||||
interface PolicyRedactConfigProps {
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
parameters: RedactParameters;
|
||||
onChange: (parameters: RedactParameters) => 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: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
parameters: AddWatermarkParameters;
|
||||
onChange: (parameters: AddWatermarkParameters) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function PolicyWatermarkConfig({
|
||||
disabled,
|
||||
}: PolicyWatermarkConfigProps) {
|
||||
useEffect(() => {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const patch: Partial<AddWatermarkParameters> = {};
|
||||
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 as unknown as AddWatermarkParameters}
|
||||
parameters={parameters}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...parameters, [key]: value })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: "2.14.1",
|
||||
appVersion: "2.14.2",
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
enableDesktopInstallSlide: true,
|
||||
|
||||
Reference in New Issue
Block a user