Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fcc958460 | ||
|
|
a835e68ac2 | ||
|
|
0570c4c4d9 | ||
|
|
776749277c | ||
|
|
41b1b89fcb | ||
|
|
4d4e994562 | ||
|
|
2b118556f3 | ||
|
|
fbaff56d1c | ||
|
|
a1b15e0570 | ||
|
|
76549288a9 | ||
|
|
b5d0c4a5ed | ||
|
|
8bfcf6eb7e | ||
|
|
b019f9b570 | ||
|
|
a84b375f5d | ||
|
|
52358c5bf9 | ||
|
|
c1e68c27c5 | ||
|
|
d4edff9059 | ||
|
|
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"
|
||||
|
||||
+18
-1
@@ -144,8 +144,10 @@ public class TempFileCleanupService {
|
||||
int directoriesDeletedCount = 0;
|
||||
for (Path directory : registry.getTempDirectories()) {
|
||||
try {
|
||||
if (Files.exists(directory)) {
|
||||
if (Files.exists(directory)
|
||||
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
directoriesDeletedCount++;
|
||||
log.debug("Cleaned up temporary directory: {}", directory);
|
||||
}
|
||||
@@ -275,6 +277,21 @@ public class TempFileCleanupService {
|
||||
return totalDeletedCount.get();
|
||||
}
|
||||
|
||||
private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) {
|
||||
if (maxAgeMillis <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long lastModified = Files.getLastModifiedTime(directory).toMillis();
|
||||
return (currentTime - lastModified) > maxAgeMillis;
|
||||
} catch (IOException e) {
|
||||
log.debug("Could not check directory age, skipping cleanup: {}", directory, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the system temp directory path based on configuration or system property. */
|
||||
private Path getSystemTempPath() {
|
||||
String systemTempDir =
|
||||
|
||||
@@ -155,6 +155,7 @@ public class TempFileManager {
|
||||
if (directory != null && Files.isDirectory(directory)) {
|
||||
try {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
log.debug("Deleted temp directory: {}", directory.toString());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
|
||||
|
||||
@@ -85,6 +85,18 @@ public class TempFileRegistry {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a temporary directory from the registry.
|
||||
*
|
||||
* @param directory The directory to unregister
|
||||
*/
|
||||
public void unregisterDirectory(Path directory) {
|
||||
if (directory != null) {
|
||||
tempDirectories.remove(directory);
|
||||
log.debug("Unregistered temp directory: {}", directory.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a third-party temporary file that requires special handling.
|
||||
*
|
||||
|
||||
+19
-1
@@ -176,11 +176,13 @@ class TempFileCleanupServiceMoreTest {
|
||||
class ScheduledCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes registered temp directories and reports counts")
|
||||
@DisplayName("deletes stale registered temp directories and reports counts")
|
||||
void deletesRegisteredDirectories() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Files.setLastModifiedTime(
|
||||
regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
@@ -193,6 +195,22 @@ class TempFileCleanupServiceMoreTest {
|
||||
verify(tempFileManager).cleanupOldTempFiles(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps a fresh registered temp directory")
|
||||
void keepsFreshRegisteredDirectory() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
withIsolatedUserHome(cleanupService::scheduledCleanup);
|
||||
|
||||
assertThat(Files.exists(regDir)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips a registered directory that no longer exists")
|
||||
void skipsMissingRegisteredDirectory() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+40
-4
@@ -43,6 +43,10 @@ public class IntegrationConfigService {
|
||||
private final OwnershipService ownership;
|
||||
private final SecretMasker secretMasker;
|
||||
private final ResourceGrantRepository grantRepository;
|
||||
// Bean-discovered extension points: features that understand a type contribute its config
|
||||
// schema and report what still references a config, without this module depending on them.
|
||||
private final List<IntegrationConfigValidator> validators;
|
||||
private final List<IntegrationConfigUsageCheck> usageChecks;
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
@@ -66,13 +70,21 @@ public class IntegrationConfigService {
|
||||
? DefaultAccessPolicy.EXPLICIT_ONLY
|
||||
: request.defaultAccess());
|
||||
|
||||
// TEAM scope may omit the team id: default to the caller's own team so clients (the
|
||||
// portal) need not know it. assignOwnership still enforces admin-or-leader of that team.
|
||||
Long ownerTeamId = request.ownerTeamId();
|
||||
if (ownerTeamId == null && scope == OwnerScope.TEAM && currentUser.getTeam() != null) {
|
||||
ownerTeamId = currentUser.getTeam().getId();
|
||||
}
|
||||
ownership.assignOwnership(
|
||||
cfg,
|
||||
scope,
|
||||
request.ownerTeamId(),
|
||||
ownerTeamId,
|
||||
currentUser,
|
||||
() -> lockedServerExists(cfg.getIntegrationType()));
|
||||
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
|
||||
Map<String, Object> config = secretMasker.sanitize(request.config());
|
||||
validateConfig(cfg.getIntegrationType(), config);
|
||||
cfg.setConfig(writeJson(config));
|
||||
return repository.save(cfg);
|
||||
}
|
||||
|
||||
@@ -101,8 +113,10 @@ public class IntegrationConfigService {
|
||||
cfg.setDefaultAccess(request.defaultAccess());
|
||||
}
|
||||
if (request.config() != null) {
|
||||
cfg.setConfig(
|
||||
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
|
||||
Map<String, Object> merged =
|
||||
secretMasker.merge(readJson(cfg.getConfig()), request.config());
|
||||
validateConfig(cfg.getIntegrationType(), merged);
|
||||
cfg.setConfig(writeJson(merged));
|
||||
}
|
||||
return repository.save(cfg);
|
||||
}
|
||||
@@ -113,6 +127,15 @@ public class IntegrationConfigService {
|
||||
if (!ownership.canManage(TYPE, cfg, currentUser)) {
|
||||
throw forbidden("You cannot manage this integration");
|
||||
}
|
||||
// Refuse to pull a connection out from under whatever still references it.
|
||||
List<String> usages =
|
||||
usageChecks.stream()
|
||||
.flatMap(check -> check.usagesOf(cfg.getId()).stream())
|
||||
.toList();
|
||||
if (!usages.isEmpty()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages));
|
||||
}
|
||||
// Drop grants sharing this config so they do not dangle as dead rows.
|
||||
grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId()));
|
||||
repository.delete(cfg);
|
||||
@@ -188,6 +211,19 @@ public class IntegrationConfigService {
|
||||
|
||||
// ---- integration-specific glue ----
|
||||
|
||||
/** Runs every registered validator for the type; unknown types save free-form. */
|
||||
private void validateConfig(IntegrationType type, Map<String, Object> config) {
|
||||
for (IntegrationConfigValidator validator : validators) {
|
||||
if (validator.type() == type) {
|
||||
try {
|
||||
validator.validate(config == null ? Map.of() : config);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-admin can't create a personal config of a type an admin has locked at server scope. */
|
||||
private boolean lockedServerExists(IntegrationType type) {
|
||||
return repository.findByScope(OwnerScope.SERVER).stream()
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.integration.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Reports what still references an integration config, so deletion can be refused instead of
|
||||
* pulling a connection out from under a live consumer. Implementations are beans discovered by
|
||||
* {@link IntegrationConfigService} (e.g. the policy subsystem reporting sources and pipelines that
|
||||
* reference a connection).
|
||||
*/
|
||||
public interface IntegrationConfigUsageCheck {
|
||||
|
||||
/** Human-readable labels of everything still using the config; empty when unreferenced. */
|
||||
List<String> usagesOf(long configId);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package stirling.software.proprietary.integration.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/**
|
||||
* Validates one integration type's config map at save time. Implementations are beans discovered by
|
||||
* {@link IntegrationConfigService}, so the feature that understands a type (e.g. the policy S3
|
||||
* backend) owns its schema without the integration module depending on it. Types with no registered
|
||||
* validator save free-form.
|
||||
*/
|
||||
public interface IntegrationConfigValidator {
|
||||
|
||||
/** The type this validator understands. */
|
||||
IntegrationType type();
|
||||
|
||||
/**
|
||||
* Validates the config as it will be stored (secrets already sanitized/merged, so values are
|
||||
* real, never the redaction mask). Throws {@link IllegalArgumentException} on bad config.
|
||||
*/
|
||||
void validate(Map<String, Object> config);
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+20
@@ -120,6 +120,7 @@ public class PolicyController {
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
PolicyRunHandle handle =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
|
||||
@@ -140,6 +141,7 @@ public class PolicyController {
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
|
||||
SseEmitter emitter =
|
||||
@@ -530,6 +532,24 @@ public class PolicyController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization-check an ad-hoc run's output while the caller's principal is present (this
|
||||
* request thread). The worker thread that later delivers carries no security context, so an S3
|
||||
* output's connection-access check would be skipped there; without this gate a caller could
|
||||
* reference another tenant's connection by id and write to it (confused deputy). Stored
|
||||
* policies are covered by save-time {@link PolicyValidator#validate} instead.
|
||||
*/
|
||||
private void validateAdHocOutput(PipelineDefinition definition) {
|
||||
if (definition.output() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
policyValidator.validateOutput(definition.output());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents
|
||||
* feed the same virtual editor source as stored editor policies, counted against the caller's
|
||||
|
||||
+13
-1
@@ -52,7 +52,19 @@ public class PolicyValidator {
|
||||
InputSpec spec = source.toInputSpec();
|
||||
inputSourceFor(spec).validate(spec);
|
||||
}
|
||||
outputSinkFor(policy.output()).validate(policy.output());
|
||||
validateOutput(policy.output());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an output spec against its sink. Must be called on a request thread (caller's
|
||||
* principal present) so an S3 output's connection is authorization-checked against the caller -
|
||||
* ad-hoc runs are never persisted and so never hit {@link #validate(Policy)}, and the worker
|
||||
* thread that later delivers has no principal, so this is their only access gate.
|
||||
*
|
||||
* @throws IllegalArgumentException if the type is unknown or the config is invalid/inaccessible
|
||||
*/
|
||||
public void validateOutput(OutputSpec output) {
|
||||
outputSinkFor(output).validate(output);
|
||||
}
|
||||
|
||||
private PolicyTrigger triggerFor(TriggerConfig config) {
|
||||
|
||||
+14
-13
@@ -18,6 +18,7 @@ import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
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.s3.S3Identities;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
@@ -36,15 +37,14 @@ import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
* Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit
|
||||
* of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and
|
||||
* version gate come from {@link S3Identities}, so the steady-state sweep never downloads content.
|
||||
* Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only
|
||||
* keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style
|
||||
* addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are
|
||||
* never signed with the server's own AWS identity), and "mode" which is "consume" (default: a
|
||||
* processed object is deleted once every policy that claimed it has settled successfully and it is
|
||||
* still the version that ran; failures stay in place and are not retried until they change) or
|
||||
* "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and
|
||||
* keys with a dot-prefixed path segment are never picked up, mirroring the folder source's
|
||||
* hidden-file rule.
|
||||
* Options: "connectionId" references the stored S3 connection (an {@code IntegrationConfig} owning
|
||||
* bucket, region, endpoint, and credentials - resolved by {@link S3ConnectionResolver}); "prefix"
|
||||
* (only keys starting with it are read) and "mode" are per-source, where mode is "consume"
|
||||
* (default: a processed object is deleted once every policy that claimed it has settled
|
||||
* successfully and it is still the version that ran; failures stay in place and are not retried
|
||||
* until they change) or "snapshot" (stateless, every run sees the full set). Keys ending in "/"
|
||||
* (folder placeholders) and keys with a dot-prefixed path segment are never picked up, mirroring
|
||||
* the folder source's hidden-file rule.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -55,6 +55,7 @@ public class S3InputSource implements InputSource {
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
private final S3ConnectionResolver connectionResolver;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
@@ -67,12 +68,12 @@ public class S3InputSource implements InputSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or
|
||||
* a bucket the supplied credentials cannot list.
|
||||
* Fails fast at save time: an unknown/disabled/unusable connection, bad config shape, a private
|
||||
* endpoint without the operator opt-in, or a bucket the connection cannot list.
|
||||
*/
|
||||
@Override
|
||||
public void validate(InputSpec spec) {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
try {
|
||||
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
|
||||
} catch (SdkException e) {
|
||||
@@ -89,7 +90,7 @@ public class S3InputSource implements InputSource {
|
||||
|
||||
@Override
|
||||
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
// A listing failure propagates so the sweep reads it as "could not list" (which vetoes
|
||||
// presence cleanup), never as "verifiably no objects".
|
||||
|
||||
+7
-5
@@ -27,6 +27,7 @@ import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
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.s3.S3Identities;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
@@ -59,6 +60,7 @@ public class S3OutputSink implements PolicyOutputSink {
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
private final S3ConnectionResolver connectionResolver;
|
||||
private final ProcessedLedger processedLedger;
|
||||
|
||||
@Override
|
||||
@@ -72,19 +74,19 @@ public class S3OutputSink implements PolicyOutputSink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Config shape and endpoint guard only - no network probe, since write-only credentials
|
||||
* (s3:PutObject without s3:ListBucket) are a legitimate setup for an output bucket and a
|
||||
* listing probe would wrongly reject them.
|
||||
* Connection resolution (including the saving user's right to use it) and endpoint guard only -
|
||||
* no network probe, since write-only credentials (s3:PutObject without s3:ListBucket) are a
|
||||
* legitimate setup for an output bucket and a listing probe would wrongly reject them.
|
||||
*/
|
||||
@Override
|
||||
public void validate(OutputSpec spec) {
|
||||
connectionPool.clientFor(S3Config.from(spec.options()));
|
||||
connectionPool.clientFor(connectionResolver.resolve(spec.options()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResultFile> deliver(
|
||||
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
|
||||
List<ResultFile> results = new ArrayList<>();
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
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.model.Team;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
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.security.repository.TeamRepository;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* One-time, idempotent extraction of legacy embedded S3 credentials into stored connections:
|
||||
* sources and policy outputs written before connections shipped carry bucket/credentials in their
|
||||
* own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and
|
||||
* keeps only per-use options (prefix, mode). MUST be programmatic - the option JSON is encrypted at
|
||||
* the application layer, so no SQL migration can read it.
|
||||
*
|
||||
* <p>Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find
|
||||
* nothing to do. Connections are deduplicated against both this run's extractions and existing S3
|
||||
* connections; a concurrent multi-node boot can at worst create a redundant connection row, never
|
||||
* corrupt a source. Ownership follows the owning row: team-scoped when the source/policy has a
|
||||
* team, server-scoped otherwise (single-operator self-hosted).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class EmbeddedS3CredentialMigration {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final List<String> CONNECTION_OPTIONS =
|
||||
List.of("bucket", "region", "endpoint", "accessKeyId", "secretAccessKey");
|
||||
// Field separator for the dedup key: a unit-separator control char that cannot appear in a
|
||||
// bucket/region/endpoint/credential, so distinct field sets can never collide.
|
||||
private static final char DELIMITER = '\u001f';
|
||||
|
||||
private final SourceStore sourceStore;
|
||||
private final PolicyStore policyStore;
|
||||
private final IntegrationConfigRepository connections;
|
||||
private final TeamRepository teamRepository;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@Transactional
|
||||
public void migrate() {
|
||||
Map<String, IntegrationConfig> byCredentialKey = indexExistingConnections();
|
||||
int migrated = 0;
|
||||
for (Source source : sourceStore.all()) {
|
||||
if (!"s3".equals(source.type()) || !embedsCredentials(source.options())) {
|
||||
continue;
|
||||
}
|
||||
IntegrationConfig connection =
|
||||
connectionFor(source.options(), source.teamId(), byCredentialKey);
|
||||
sourceStore.save(withOptions(source, referencing(connection, source.options(), true)));
|
||||
migrated++;
|
||||
}
|
||||
for (Policy policy : policyStore.all()) {
|
||||
OutputSpec output = policy.output();
|
||||
if (!"s3".equals(output.type()) || !embedsCredentials(output.options())) {
|
||||
continue;
|
||||
}
|
||||
IntegrationConfig connection =
|
||||
connectionFor(output.options(), policy.teamId(), byCredentialKey);
|
||||
policyStore.save(
|
||||
withOutput(
|
||||
policy,
|
||||
new OutputSpec(
|
||||
output.type(),
|
||||
referencing(connection, output.options(), false))));
|
||||
migrated++;
|
||||
}
|
||||
if (migrated > 0) {
|
||||
log.info("Extracted embedded S3 credentials from {} row(s) into connections", migrated);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean embedsCredentials(Map<String, Object> options) {
|
||||
return options.get("accessKeyId") != null;
|
||||
}
|
||||
|
||||
/** Reuses an existing connection with identical coordinates+credentials, else creates one. */
|
||||
private IntegrationConfig connectionFor(
|
||||
Map<String, Object> options, Long teamId, Map<String, IntegrationConfig> byKey) {
|
||||
String key = credentialKey(options);
|
||||
IntegrationConfig existing = byKey.get(key);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
IntegrationConfig connection = new IntegrationConfig();
|
||||
connection.setIntegrationType(IntegrationType.S3);
|
||||
connection.setName(connectionName(options, byKey));
|
||||
connection.setEnabled(true);
|
||||
connection.setLocked(false);
|
||||
connection.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY);
|
||||
Team team = teamId == null ? null : teamRepository.findById(teamId).orElse(null);
|
||||
if (team != null) {
|
||||
connection.setScope(OwnerScope.TEAM);
|
||||
connection.setOwnerTeam(team);
|
||||
} else {
|
||||
// No team (teamless self-hosted, or a source whose team was since deleted): server
|
||||
// scope, i.e. admin-owned. An orphaned-team source's non-admin editor would then need
|
||||
// an admin to re-share the connection - acceptable for the narrow orphaned case.
|
||||
connection.setScope(OwnerScope.SERVER);
|
||||
}
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
for (String option : CONNECTION_OPTIONS) {
|
||||
Object value = options.get(option);
|
||||
if (value != null && !value.toString().isBlank()) {
|
||||
config.put(option, value);
|
||||
}
|
||||
}
|
||||
connection.setConfig(OBJECT_MAPPER.writeValueAsString(config));
|
||||
IntegrationConfig saved = connections.save(connection);
|
||||
byKey.put(key, saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** The rewritten options: the connection reference plus per-use settings only. */
|
||||
private static Map<String, Object> referencing(
|
||||
IntegrationConfig connection, Map<String, Object> legacy, boolean keepMode) {
|
||||
Map<String, Object> options = new LinkedHashMap<>();
|
||||
options.put(S3ConnectionResolver.CONNECTION_ID_OPTION, connection.getId());
|
||||
Object prefix = legacy.get("prefix");
|
||||
if (prefix != null && !prefix.toString().isBlank()) {
|
||||
options.put("prefix", prefix);
|
||||
}
|
||||
Object mode = legacy.get("mode");
|
||||
if (keepMode && mode != null && !mode.toString().isBlank()) {
|
||||
options.put("mode", mode);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private Map<String, IntegrationConfig> indexExistingConnections() {
|
||||
Map<String, IntegrationConfig> byKey = new LinkedHashMap<>();
|
||||
for (IntegrationConfig connection : connections.findAll()) {
|
||||
if (connection.getIntegrationType() != IntegrationType.S3) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> config =
|
||||
OBJECT_MAPPER.readValue(connection.getConfig(), Map.class);
|
||||
byKey.putIfAbsent(credentialKey(config), connection);
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Skipping unreadable S3 connection {} while indexing: {}",
|
||||
connection.getId(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
return byKey;
|
||||
}
|
||||
|
||||
private static String credentialKey(Map<String, Object> options) {
|
||||
StringBuilder key = new StringBuilder();
|
||||
for (String option : CONNECTION_OPTIONS) {
|
||||
Object value = options.get(option);
|
||||
key.append(value == null ? "" : value.toString().trim()).append(DELIMITER);
|
||||
}
|
||||
return key.toString();
|
||||
}
|
||||
|
||||
private static String connectionName(
|
||||
Map<String, Object> options, Map<String, IntegrationConfig> byKey) {
|
||||
String base = "S3: " + options.getOrDefault("bucket", "bucket");
|
||||
long sameName = byKey.values().stream().filter(c -> c.getName().startsWith(base)).count();
|
||||
return sameName == 0 ? base : base + " (" + (sameName + 1) + ")";
|
||||
}
|
||||
|
||||
private static Source withOptions(Source source, Map<String, Object> options) {
|
||||
return new Source(
|
||||
source.id(),
|
||||
source.name(),
|
||||
source.type(),
|
||||
options,
|
||||
source.enabled(),
|
||||
source.owner(),
|
||||
source.teamId());
|
||||
}
|
||||
|
||||
private static Policy withOutput(Policy policy, OutputSpec output) {
|
||||
return new Policy(
|
||||
policy.id(),
|
||||
policy.name(),
|
||||
policy.owner(),
|
||||
policy.enabled(),
|
||||
policy.trigger(),
|
||||
policy.sourceIds(),
|
||||
policy.steps(),
|
||||
output,
|
||||
policy.teamId());
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.integration.service.IntegrationConfigUsageCheck;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Reports the policy sources and pipeline outputs referencing an S3 connection, so the connection
|
||||
* cannot be deleted out from under them (mirrors {@code SourceController}'s referenced-source
|
||||
* delete guard). Scans in memory - fine at admin-dashboard scale, always consistent with the live
|
||||
* stores.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck {
|
||||
|
||||
private final SourceStore sourceStore;
|
||||
private final PolicyStore policyStore;
|
||||
|
||||
@Override
|
||||
public List<String> usagesOf(long configId) {
|
||||
List<String> usages = new ArrayList<>();
|
||||
for (Source source : sourceStore.all()) {
|
||||
if (references(source.options(), configId)) {
|
||||
usages.add("source '" + source.name() + "'");
|
||||
}
|
||||
}
|
||||
for (Policy policy : policyStore.all()) {
|
||||
if (references(policy.output().options(), configId)) {
|
||||
usages.add("pipeline '" + policy.name() + "'");
|
||||
}
|
||||
}
|
||||
return usages;
|
||||
}
|
||||
|
||||
private static boolean references(Map<String, Object> options, long configId) {
|
||||
try {
|
||||
Long reference = S3ConnectionResolver.connectionId(options);
|
||||
return reference != null && reference == configId;
|
||||
} catch (IllegalArgumentException unparseable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import java.net.URISyntaxException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Connection settings shared by the S3 input source and output sink, parsed from a spec's options
|
||||
* map. Credentials are required: there is deliberately no fallback to the server's own AWS
|
||||
* credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot}
|
||||
* is input-only and ignored by the sink.
|
||||
* The fully resolved connection settings the S3 input source and output sink run with - normally
|
||||
* produced by {@link S3ConnectionResolver} merging a stored connection (bucket, region, endpoint,
|
||||
* credentials) with per-use options (prefix, mode), or parsed directly from legacy options that
|
||||
* still embed credentials. Credentials are required: there is deliberately no fallback to the
|
||||
* server's own AWS credential chain, so user-supplied config can never borrow the host's identity.
|
||||
* {@code snapshot} is input-only and ignored by the sink.
|
||||
*/
|
||||
public record S3Config(
|
||||
String bucket,
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
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.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Turns a source's or output's options into a full {@link S3Config} by dereferencing its {@code
|
||||
* connectionId} to a stored S3 {@link IntegrationConfig} (the connection owns bucket, region,
|
||||
* endpoint, and credentials; the options own per-use settings such as prefix and mode). Options
|
||||
* with no {@code connectionId} fall back to legacy embedded credentials, so rows written before
|
||||
* connections shipped keep working until {@link EmbeddedS3CredentialMigration} rewrites them.
|
||||
*
|
||||
* <p>When an authenticated caller is present (save-time validation), they must be allowed to use
|
||||
* the connection. Background sweeps and deliveries run with no caller and skip that check: the
|
||||
* referencing source or policy was access-checked when it was saved.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3ConnectionResolver {
|
||||
|
||||
static final String CONNECTION_ID_OPTION = "connectionId";
|
||||
private static final String PREFIX_OPTION = "prefix";
|
||||
private static final String MODE_OPTION = "mode";
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final IntegrationConfigRepository connections;
|
||||
private final OwnershipService ownership;
|
||||
private final UserService userService;
|
||||
|
||||
public S3Config resolve(Map<String, Object> options) {
|
||||
Long connectionId = connectionId(options);
|
||||
if (connectionId == null) {
|
||||
// Legacy embedded credentials, pending migration.
|
||||
return S3Config.from(options);
|
||||
}
|
||||
IntegrationConfig connection =
|
||||
connections
|
||||
.findById(connectionId)
|
||||
.filter(cfg -> cfg.getIntegrationType() == IntegrationType.S3)
|
||||
.filter(this::usableByCurrentUser)
|
||||
// Existence and access collapse into one error: a caller must not be able
|
||||
// to tell "no such connection" from "someone else's connection" and
|
||||
// enumerate ids. The id/name are never echoed.
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"unknown or inaccessible s3 connection"));
|
||||
if (!connection.isEnabled()) {
|
||||
throw new IllegalArgumentException("s3 connection is disabled");
|
||||
}
|
||||
Map<String, Object> merged = new LinkedHashMap<>(connectionConfig(connection));
|
||||
copyPerUseOption(options, merged, PREFIX_OPTION);
|
||||
copyPerUseOption(options, merged, MODE_OPTION);
|
||||
return S3Config.from(merged);
|
||||
}
|
||||
|
||||
/** The {@code connectionId} option as a long, or null when the options are legacy-embedded. */
|
||||
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(
|
||||
"s3 'connectionId' is not a valid connection reference: " + reference);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current caller may use this connection. With no principal - a background sweep or
|
||||
* delivery on a worker thread that carries no {@code SecurityContext} - access is treated as
|
||||
* already established: stored policies are validated with the caller present at save time, and
|
||||
* ad-hoc runs are validated on the request thread before dispatch (see {@code
|
||||
* PolicyValidator#validateOutput}). A missing principal must therefore never be the ONLY thing
|
||||
* standing between a caller and a connection, or the check becomes a confused deputy.
|
||||
*/
|
||||
private boolean usableByCurrentUser(IntegrationConfig connection) {
|
||||
User user = currentUser();
|
||||
return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user);
|
||||
}
|
||||
|
||||
// Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated.
|
||||
private User currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
Object principal = auth.getPrincipal();
|
||||
if (principal instanceof User user) {
|
||||
return user;
|
||||
}
|
||||
if (principal instanceof UserDetails userDetails) {
|
||||
return userService.findByUsername(userDetails.getUsername()).orElse(null);
|
||||
}
|
||||
if (principal instanceof String username && !"anonymousUser".equals(username)) {
|
||||
return userService.findByUsername(username).orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, Object> connectionConfig(IntegrationConfig connection) {
|
||||
String json = connection.getConfig();
|
||||
if (json == null || json.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(
|
||||
json, new TypeReference<LinkedHashMap<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 connection '" + connection.getName() + "' has unreadable config", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyPerUseOption(
|
||||
Map<String, Object> options, Map<String, Object> merged, String key) {
|
||||
Object value = options.get(key);
|
||||
if (value != null && !value.toString().isBlank()) {
|
||||
merged.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.cluster.s3.S3Clients;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.service.IntegrationConfigValidator;
|
||||
|
||||
/**
|
||||
* The S3 connection schema, enforced when an S3 {@link IntegrationType} config is saved: bucket and
|
||||
* credentials required, endpoint an http(s) URL that must not reach private addresses without the
|
||||
* operator opt-in - the same rules {@link S3ConnectionPool} enforces before signing, moved to save
|
||||
* time so a bad connection fails in the form rather than in a sweep.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3IntegrationValidator implements IntegrationConfigValidator {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
public IntegrationType type() {
|
||||
return IntegrationType.S3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Map<String, Object> config) {
|
||||
S3Config parsed = S3Config.from(config);
|
||||
if (parsed.endpoint() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create(parsed.endpoint()),
|
||||
applicationProperties.getPolicies().isAllowPrivateS3Endpoints(),
|
||||
"S3 connection endpoint",
|
||||
"set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local"
|
||||
+ " MinIO).");
|
||||
} catch (IllegalStateException e) {
|
||||
throw new IllegalArgumentException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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 %}
|
||||
+53
-2
@@ -13,9 +13,9 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -52,7 +52,58 @@ class IntegrationConfigServiceTest {
|
||||
@Mock
|
||||
private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository;
|
||||
|
||||
@InjectMocks private IntegrationConfigService service;
|
||||
@Mock private IntegrationConfigValidator validator;
|
||||
@Mock private IntegrationConfigUsageCheck usageCheck;
|
||||
|
||||
private IntegrationConfigService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service =
|
||||
new IntegrationConfigService(
|
||||
repository,
|
||||
ownership,
|
||||
secretMasker,
|
||||
grantRepository,
|
||||
List.of(validator),
|
||||
List.of(usageCheck));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRejectsAConfigItsTypeValidatorRefuses() {
|
||||
when(secretMasker.sanitize(any())).thenReturn(Map.of());
|
||||
when(validator.type()).thenReturn(IntegrationType.API);
|
||||
org.mockito.Mockito.doThrow(new IllegalArgumentException("api config needs a 'url'"))
|
||||
.when(validator)
|
||||
.validate(any());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
service.create(
|
||||
request(IntegrationType.API, OwnerScope.USER, null),
|
||||
user(7)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRefusedWhileAnythingStillReferencesTheConfig() {
|
||||
IntegrationConfig cfg = config(9L);
|
||||
when(repository.findById(9L)).thenReturn(Optional.of(cfg));
|
||||
when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true);
|
||||
when(usageCheck.usagesOf(9L)).thenReturn(List.of("source 'Claims intake'"));
|
||||
|
||||
assertThatThrownBy(() -> service.delete(9L, user(7)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.CONFLICT));
|
||||
verify(repository, org.mockito.Mockito.never()).delete(any(IntegrationConfig.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDelegatesOwnershipAndSanitizesConfig() {
|
||||
|
||||
+24
@@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -194,6 +195,29 @@ class PolicyControllerTest {
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an ad-hoc output the caller cannot use, on the request thread")
|
||||
void rejectsUnauthorizedAdHocOutput() {
|
||||
// The confused-deputy guard: an S3 output referencing a connection the caller may not
|
||||
// use is validated here (principal present) and refused before any worker dispatch.
|
||||
PipelineDefinition definition =
|
||||
new PipelineDefinition(
|
||||
"pipe",
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)),
|
||||
new OutputSpec("s3", Map.of("connectionId", 999)));
|
||||
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
|
||||
.when(policyValidator)
|
||||
.validateOutput(any());
|
||||
|
||||
assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles()))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
verify(policyRunner, never()).runAdHoc(any(), any(), any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
+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()),
|
||||
|
||||
+22
@@ -82,6 +82,28 @@ class PolicyValidatorTest {
|
||||
assertTrue(ex.getMessage().contains("schedule"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateOutputDelegatesToTheSink() {
|
||||
when(outputSink.supports(any())).thenReturn(true);
|
||||
OutputSpec output = new OutputSpec("s3", Map.of("connectionId", 1));
|
||||
|
||||
validator.validateOutput(output);
|
||||
|
||||
verify(outputSink).validate(output);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateOutputSurfacesAnInaccessibleConnection() {
|
||||
when(outputSink.supports(any())).thenReturn(true);
|
||||
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
|
||||
.when(outputSink)
|
||||
.validate(any());
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownTriggerType() {
|
||||
when(trigger.type()).thenReturn("schedule");
|
||||
|
||||
+7
-2
@@ -23,6 +23,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
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.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
@@ -82,7 +83,9 @@ class S3InputSourceMinioTest {
|
||||
// The MinIO endpoint resolves to loopback, so the operator opt-in must be on.
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
source = new S3InputSource(new S3ConnectionPool(properties));
|
||||
source =
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(properties), S3TestConnections.legacyResolver());
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
@@ -161,7 +164,9 @@ class S3InputSourceMinioTest {
|
||||
@Test
|
||||
void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() {
|
||||
S3InputSource guarded =
|
||||
new S3InputSource(new S3ConnectionPool(new ApplicationProperties()));
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(new ApplicationProperties()),
|
||||
S3TestConnections.legacyResolver());
|
||||
|
||||
assertThatThrownBy(() -> guarded.validate(spec(Map.of())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
|
||||
+3
-1
@@ -29,6 +29,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
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.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.core.ResponseInputStream;
|
||||
import software.amazon.awssdk.core.exception.SdkClientException;
|
||||
@@ -64,7 +65,8 @@ class S3InputSourceTest {
|
||||
void setUp() {
|
||||
source =
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client));
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
|
||||
S3TestConnections.legacyResolver());
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
|
||||
+3
-2
@@ -28,6 +28,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
@@ -90,8 +91,8 @@ class S3OutputSinkMinioTest {
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
S3ConnectionPool pool = new S3ConnectionPool(properties);
|
||||
ledger = new InProcessProcessedLedger();
|
||||
sink = new S3OutputSink(pool, ledger);
|
||||
source = new S3InputSource(pool);
|
||||
sink = new S3OutputSink(pool, S3TestConnections.legacyResolver(), ledger);
|
||||
source = new S3InputSource(pool, S3TestConnections.legacyResolver());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
@@ -32,6 +32,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedFileStatus;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.awscore.exception.AwsServiceException;
|
||||
import software.amazon.awssdk.core.exception.SdkClientException;
|
||||
@@ -66,6 +67,7 @@ class S3OutputSinkTest {
|
||||
sink =
|
||||
new S3OutputSink(
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
|
||||
S3TestConnections.legacyResolver(),
|
||||
ledger);
|
||||
}
|
||||
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
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.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
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.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
/**
|
||||
* Tests for {@link EmbeddedS3CredentialMigration}: legacy embedded credentials become deduplicated
|
||||
* team-scoped connections, rewritten rows keep only per-use options, and re-runs are no-ops.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EmbeddedS3CredentialMigrationTest {
|
||||
|
||||
@Mock private IntegrationConfigRepository connections;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
|
||||
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
|
||||
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
|
||||
private EmbeddedS3CredentialMigration migration;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
migration =
|
||||
new EmbeddedS3CredentialMigration(
|
||||
sourceStore, policyStore, connections, teamRepository);
|
||||
AtomicLong ids = new AtomicLong(100);
|
||||
// Lenient: the nothing-to-migrate cases never create a connection.
|
||||
lenient().when(connections.findAll()).thenReturn(List.of());
|
||||
lenient()
|
||||
.when(connections.save(any()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
IntegrationConfig saved = invocation.getArgument(0);
|
||||
if (saved.getId() == null) {
|
||||
saved.setId(ids.incrementAndGet());
|
||||
}
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsSharedCredentialsIntoOneTeamScopedConnection() {
|
||||
Team team = new Team();
|
||||
team.setId(7L);
|
||||
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
|
||||
Source source =
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Claims intake",
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"prefix", "incoming/",
|
||||
"mode", "snapshot",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh"),
|
||||
true,
|
||||
"alice",
|
||||
7L));
|
||||
Policy policy =
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Rotate",
|
||||
"alice",
|
||||
true,
|
||||
null,
|
||||
List.of(source.id()),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
new OutputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"prefix", "processed/",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh")),
|
||||
7L));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
// Same bucket + credentials on both rows: exactly one connection extracted.
|
||||
verify(connections, times(1)).save(any());
|
||||
Map<String, Object> sourceOptions = sourceStore.get(source.id()).orElseThrow().options();
|
||||
assertEquals(101L, sourceOptions.get("connectionId"));
|
||||
assertEquals("incoming/", sourceOptions.get("prefix"));
|
||||
assertEquals("snapshot", sourceOptions.get("mode"));
|
||||
assertNull(sourceOptions.get("accessKeyId"));
|
||||
assertNull(sourceOptions.get("secretAccessKey"));
|
||||
assertNull(sourceOptions.get("bucket"));
|
||||
|
||||
Map<String, Object> outputOptions =
|
||||
policyStore.get(policy.id()).orElseThrow().output().options();
|
||||
assertEquals(101L, outputOptions.get("connectionId"));
|
||||
assertEquals("processed/", outputOptions.get("prefix"));
|
||||
assertNull(outputOptions.get("secretAccessKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectionOwnershipFollowsTheSourceTeam() {
|
||||
Team team = new Team();
|
||||
team.setId(7L);
|
||||
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
|
||||
sourceStore.save(s3Source("teamed", 7L));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
verify(connections)
|
||||
.save(
|
||||
org.mockito.ArgumentMatchers.argThat(
|
||||
connection ->
|
||||
connection.getScope() == OwnerScope.TEAM
|
||||
&& connection.getOwnerTeam() == team));
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamlessRowsBecomeServerScopedConnections() {
|
||||
sourceStore.save(s3Source("solo", null));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
verify(connections)
|
||||
.save(
|
||||
org.mockito.ArgumentMatchers.argThat(
|
||||
connection -> connection.getScope() == OwnerScope.SERVER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSecondRunFindsNothingToDo() {
|
||||
sourceStore.save(s3Source("once", null));
|
||||
|
||||
migration.migrate();
|
||||
migration.migrate();
|
||||
|
||||
// One connection from the first run; the rewritten source no longer embeds credentials.
|
||||
verify(connections, times(1)).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonS3AndAlreadyMigratedRowsAreUntouched() {
|
||||
Source folder =
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Folder",
|
||||
"folder",
|
||||
Map.of("directory", "/in"),
|
||||
true,
|
||||
"alice",
|
||||
null));
|
||||
Source migrated =
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Done already",
|
||||
"s3",
|
||||
Map.of("connectionId", 55L, "prefix", "in/"),
|
||||
true,
|
||||
"alice",
|
||||
null));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
verify(connections, times(0)).save(any());
|
||||
assertEquals(
|
||||
Map.of("directory", "/in"), sourceStore.get(folder.id()).orElseThrow().options());
|
||||
assertEquals(
|
||||
Map.of("connectionId", 55L, "prefix", "in/"),
|
||||
sourceStore.get(migrated.id()).orElseThrow().options());
|
||||
}
|
||||
|
||||
private static Source s3Source(String name, Long teamId) {
|
||||
return new Source(
|
||||
null,
|
||||
name,
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh"),
|
||||
true,
|
||||
"alice",
|
||||
teamId);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
|
||||
|
||||
/** Tests for {@link PolicyS3ConnectionUsageCheck}'s reference scan across sources and outputs. */
|
||||
class PolicyS3ConnectionUsageCheckTest {
|
||||
|
||||
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
|
||||
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
|
||||
private final PolicyS3ConnectionUsageCheck check =
|
||||
new PolicyS3ConnectionUsageCheck(sourceStore, policyStore);
|
||||
|
||||
@Test
|
||||
void reportsSourcesAndOutputsReferencingTheConnection() {
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Claims intake",
|
||||
"s3",
|
||||
Map.of("connectionId", 5L, "prefix", "in/"),
|
||||
true,
|
||||
"alice",
|
||||
null));
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Rotate",
|
||||
"alice",
|
||||
true,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
new OutputSpec("s3", Map.of("connectionId", "5")),
|
||||
null));
|
||||
|
||||
assertThat(check.usagesOf(5))
|
||||
.containsExactlyInAnyOrder("source 'Claims intake'", "pipeline 'Rotate'");
|
||||
assertThat(check.usagesOf(6)).isEmpty();
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
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.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
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.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* Tests for {@link S3ConnectionResolver}: connection dereferencing with per-use overrides, the
|
||||
* legacy embedded fallback, and the save-time access check that background sweeps skip.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class S3ConnectionResolverTest {
|
||||
|
||||
@Mock private IntegrationConfigRepository connections;
|
||||
@Mock private OwnershipService ownership;
|
||||
@Mock private UserService userService;
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesAConnectionAndMergesPerUseOptions() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
|
||||
S3Config config =
|
||||
resolver()
|
||||
.resolve(
|
||||
Map.of(
|
||||
"connectionId", 9L,
|
||||
"prefix", "incoming/",
|
||||
"mode", "snapshot"));
|
||||
|
||||
assertEquals("inbox", config.bucket());
|
||||
assertEquals("AKIAEXAMPLE", config.accessKeyId());
|
||||
assertEquals("incoming/", config.prefix());
|
||||
assertTrue(config.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAStringConnectionReference() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
|
||||
assertEquals("inbox", resolver().resolve(Map.of("connectionId", "9")).bucket());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToLegacyEmbeddedCredentials() {
|
||||
S3Config config =
|
||||
resolver()
|
||||
.resolve(
|
||||
Map.of(
|
||||
"bucket", "legacy",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh"));
|
||||
|
||||
assertEquals("legacy", config.bucket());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownDisabledOrWrongTypeConnections() {
|
||||
when(connections.findById(1L)).thenReturn(Optional.empty());
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 1L)));
|
||||
|
||||
when(connections.findById(2L)).thenReturn(Optional.of(s3Connection(2L, false)));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 2L)));
|
||||
|
||||
IntegrationConfig mcp = s3Connection(3L, true);
|
||||
mcp.setIntegrationType(IntegrationType.MCP);
|
||||
when(connections.findById(3L)).thenReturn(Optional.of(mcp));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 3L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAuthenticatedSaverMustBeAllowedToUseTheConnection() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
User saver = new User();
|
||||
saver.setUsername("alice");
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(saver, null, java.util.List.of()));
|
||||
when(ownership.canUse(any(), any(IntegrationConfig.class), eq(saver))).thenReturn(false);
|
||||
|
||||
// Denied reads the same as unknown and never echoes the connection name, so ids can't be
|
||||
// enumerated by probing.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 9L)));
|
||||
try {
|
||||
resolver().resolve(Map.of("connectionId", 9L));
|
||||
} catch (IllegalArgumentException e) {
|
||||
org.junit.jupiter.api.Assertions.assertFalse(
|
||||
e.getMessage().contains("Claims bucket"),
|
||||
"access-denied error must not leak the connection name");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void backgroundSweepsWithNoUserSkipTheAccessCheck() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
|
||||
// No authentication in the context: resolution succeeds without consulting ownership.
|
||||
assertEquals("inbox", resolver().resolve(Map.of("connectionId", 9L)).bucket());
|
||||
}
|
||||
|
||||
private S3ConnectionResolver resolver() {
|
||||
return new S3ConnectionResolver(connections, ownership, userService);
|
||||
}
|
||||
|
||||
private static IntegrationConfig s3Connection(long id, boolean enabled) {
|
||||
IntegrationConfig connection = new IntegrationConfig();
|
||||
connection.setId(id);
|
||||
connection.setIntegrationType(IntegrationType.S3);
|
||||
connection.setName("Claims bucket");
|
||||
connection.setEnabled(enabled);
|
||||
connection.setConfig(
|
||||
"{\"bucket\":\"inbox\",\"accessKeyId\":\"AKIAEXAMPLE\","
|
||||
+ "\"secretAccessKey\":\"shh\"}");
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/**
|
||||
* Tests for {@link S3IntegrationValidator}: the S3 connection schema fails at save time - missing
|
||||
* credentials, bad endpoints, and private endpoints without the operator opt-in.
|
||||
*/
|
||||
class S3IntegrationValidatorTest {
|
||||
|
||||
@Test
|
||||
void acceptsACompleteConnection() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
validator(false)
|
||||
.validate(
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh")))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingCredentialsOrBucket() {
|
||||
assertThatThrownBy(() -> validator(false).validate(Map.of("bucket", "inbox")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
validator(false)
|
||||
.validate(
|
||||
Map.of(
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAPrivateEndpointWithoutTheOperatorOptIn() {
|
||||
Map<String, Object> config =
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh",
|
||||
"endpoint", "http://localhost:9000");
|
||||
|
||||
assertThatThrownBy(() -> validator(false).validate(config))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("allowPrivateS3Endpoints");
|
||||
assertThatCode(() -> validator(true).validate(config)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void itOnlyClaimsTheS3Type() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(IntegrationType.S3, validator(false).type());
|
||||
}
|
||||
|
||||
private static S3IntegrationValidator validator(boolean allowPrivateEndpoints) {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(allowPrivateEndpoints);
|
||||
return new S3IntegrationValidator(properties);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** Test fixtures for S3 connection plumbing shared across the policy S3 tests. */
|
||||
public final class S3TestConnections {
|
||||
|
||||
private S3TestConnections() {}
|
||||
|
||||
/**
|
||||
* A resolver for tests whose options embed credentials directly (the legacy pass-through path),
|
||||
* so its collaborators are never touched.
|
||||
*/
|
||||
public static S3ConnectionResolver legacyResolver() {
|
||||
return new S3ConnectionResolver(
|
||||
mock(IntegrationConfigRepository.class),
|
||||
mock(OwnershipService.class),
|
||||
mock(UserService.class));
|
||||
}
|
||||
}
|
||||
+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;"));
|
||||
}
|
||||
}
|
||||
+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" },
|
||||
|
||||
@@ -6240,6 +6240,10 @@ muted = "muted"
|
||||
nameLabel = "Name"
|
||||
namePlaceholder = "e.g. Compliance escalation"
|
||||
|
||||
[portal.agentBuilder.status]
|
||||
draft = "Draft"
|
||||
published = "Published"
|
||||
|
||||
[portal.agentBuilder.tabs]
|
||||
evals = "Evals"
|
||||
scenarios = "Scenarios"
|
||||
@@ -6463,7 +6467,7 @@ install = "Install"
|
||||
usage = "Usage"
|
||||
|
||||
[portal.catalogue.detail.locked]
|
||||
description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it."
|
||||
description = "{{name}} is included from the {{plan}}. Upgrade to embed it."
|
||||
title = "Not available on your plan"
|
||||
|
||||
[portal.catalogue.detail.preview]
|
||||
@@ -6530,6 +6534,35 @@ title = "No components available"
|
||||
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
|
||||
title = "Some components need a paid plan"
|
||||
|
||||
[portal.connections]
|
||||
createTitle = "New S3 connection"
|
||||
delete = "Delete"
|
||||
edit = "Edit"
|
||||
editTitle = "Edit S3 connection"
|
||||
subtitle = "Reusable S3 credentials that sources and pipeline outputs connect to."
|
||||
|
||||
[portal.connections.actions]
|
||||
new = "New connection"
|
||||
|
||||
[portal.connections.empty]
|
||||
description = "Add an S3 connection to reuse the same bucket and credentials across sources and pipeline outputs."
|
||||
title = "No connections yet"
|
||||
|
||||
[portal.connections.picker]
|
||||
cancel = "Cancel"
|
||||
createNew = "New connection..."
|
||||
placeholder = "Select a connection"
|
||||
save = "Save connection"
|
||||
|
||||
[portal.connections.s3.fields]
|
||||
name = "Connection name"
|
||||
namePlaceholder = "e.g. Claims bucket"
|
||||
|
||||
[portal.connections.table]
|
||||
bucket = "Bucket"
|
||||
name = "Name"
|
||||
region = "Region"
|
||||
|
||||
[portal.docs.authentication]
|
||||
codeCaption = "every request"
|
||||
eyebrow = "GETTING STARTED"
|
||||
@@ -7341,10 +7374,6 @@ operations_one = "Operation ({{count}})"
|
||||
operations_other = "Operations ({{count}})"
|
||||
output = "Output"
|
||||
removeStep = "Remove operation"
|
||||
s3Configure = "Configure"
|
||||
s3Done = "Done"
|
||||
s3ModalTitle = "Amazon S3 output"
|
||||
s3NotConfigured = "Not configured"
|
||||
s3PrefixHelp = "Outputs are uploaded under this key prefix."
|
||||
save = "Save changes"
|
||||
scheduleEvery = "Run every"
|
||||
@@ -7415,6 +7444,7 @@ manual = "Manual"
|
||||
schedule = "Scheduled"
|
||||
|
||||
[portal.policies]
|
||||
defaultName = "{{category}} Policy"
|
||||
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
|
||||
title = "Policies"
|
||||
|
||||
@@ -7986,34 +8016,29 @@ primaryNav = "Primary navigation"
|
||||
switchApp = "Switch app"
|
||||
|
||||
[portal.sources]
|
||||
subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it."
|
||||
subtitle = "Reusable input connections that feed documents into Stirling. Configure a source once, then reference it from any number of pipelines."
|
||||
title = "Sources"
|
||||
|
||||
[portal.sources.actions]
|
||||
agentBuilder = "Agent Builder"
|
||||
connectSource = "Connect source"
|
||||
|
||||
[portal.sources.builder]
|
||||
back = "Back to sources"
|
||||
cancel = "Cancel"
|
||||
create = "Create source"
|
||||
createTitle = "Connect a source"
|
||||
delete = "Delete"
|
||||
editTitle = "Edit source"
|
||||
enabled = "Enabled"
|
||||
save = "Save changes"
|
||||
|
||||
[portal.sources.delete]
|
||||
body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated."
|
||||
cancel = "Cancel"
|
||||
confirm = "Delete"
|
||||
title = "Delete source?"
|
||||
|
||||
[portal.sources.detail]
|
||||
closeAriaLabel = "Close detail"
|
||||
delete = "Delete source"
|
||||
docs24h = "Last 24h"
|
||||
docs30d = "Last 30 days"
|
||||
docsTotal = "Total seen"
|
||||
docsTrend = "Documents over the last 30 days"
|
||||
documents = "Documents"
|
||||
edit = "Edit"
|
||||
notReferenced = "Not referenced by any policy, so it's safe to delete."
|
||||
pause = "Pause"
|
||||
resume = "Resume"
|
||||
subtitle = "{{type}} · {{status}}"
|
||||
usedBy = "Used by"
|
||||
|
||||
[portal.sources.empty]
|
||||
description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from."
|
||||
title = "No sources connected yet"
|
||||
@@ -8029,10 +8054,15 @@ disabled = "Disabled"
|
||||
unused = "Unused"
|
||||
|
||||
[portal.sources.table]
|
||||
documents = "Documents"
|
||||
source = "Source"
|
||||
status = "Status"
|
||||
usedBy = "Policies"
|
||||
|
||||
[portal.sources.tabs]
|
||||
connections = "Connections"
|
||||
sources = "Sources"
|
||||
|
||||
[portal.sources.types.editor]
|
||||
description = "Documents your team has processed in the editor, across policy and AI runs."
|
||||
label = "Editor"
|
||||
@@ -8080,6 +8110,10 @@ label = "Access key ID"
|
||||
label = "Bucket"
|
||||
placeholder = "my-company-inbox"
|
||||
|
||||
[portal.sources.types.s3.fields.connection]
|
||||
helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it."
|
||||
label = "Connection"
|
||||
|
||||
[portal.sources.types.s3.fields.endpoint]
|
||||
helperText = "Leave blank for Amazon S3. Set to use an S3-compatible service such as MinIO."
|
||||
label = "Custom endpoint"
|
||||
@@ -8109,21 +8143,14 @@ label = "Secret access key"
|
||||
label = "Source"
|
||||
|
||||
[portal.sources.wizard]
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
continue = "Continue"
|
||||
editTitle = "Edit source"
|
||||
name = "Name"
|
||||
namePlaceholder = "e.g. Claims intake"
|
||||
save = "Save changes"
|
||||
subtitle = "Step {{current}} of {{total}} · {{label}}"
|
||||
title = "Connect a source"
|
||||
type = "Type"
|
||||
|
||||
[portal.sources.wizard.steps]
|
||||
chooseType = "Choose type"
|
||||
configure = "Configure"
|
||||
review = "Review & connect"
|
||||
[portal.tier]
|
||||
enterprise = "Enterprise plan"
|
||||
free = "Editor plan"
|
||||
pro = "Processor plan"
|
||||
|
||||
[portal.usage]
|
||||
managePayment = "Manage Payment"
|
||||
@@ -10139,6 +10166,19 @@ resetPw = "Reset password"
|
||||
suspend = "Suspend"
|
||||
unlock = "Unlock account"
|
||||
|
||||
[users.activity]
|
||||
daysAgo = "{{count}}d ago"
|
||||
hoursAgo = "{{count}}h ago"
|
||||
justNow = "Just now"
|
||||
minutesAgo = "{{count}}m ago"
|
||||
monthsAgo_one = "{{count}} month ago"
|
||||
monthsAgo_other = "{{count}} months ago"
|
||||
never = "Never"
|
||||
weeksAgo_one = "{{count}} week ago"
|
||||
weeksAgo_other = "{{count}} weeks ago"
|
||||
yearsAgo_one = "{{count}} year ago"
|
||||
yearsAgo_other = "{{count}} years ago"
|
||||
|
||||
[users.cap]
|
||||
addProcessor = "+ Processor"
|
||||
approver = "Approves policy"
|
||||
@@ -10209,7 +10249,9 @@ by = "Invited by {{who}}"
|
||||
cancel = "Cancel"
|
||||
count = "{{count}} pending"
|
||||
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
|
||||
expires = "Expires"
|
||||
expiresInDays_one = "Expires in {{count}} day"
|
||||
expiresInDays_other = "Expires in {{count}} days"
|
||||
expiresToday = "Expires today"
|
||||
title = "Pending invitations"
|
||||
|
||||
[users.loadError]
|
||||
@@ -10370,6 +10412,9 @@ disableColorFilter = "Disable Color Filter"
|
||||
dualPageView = "Dual Page View"
|
||||
enableDarkFilter = "Enable Dark Filter"
|
||||
enableSepiaFilter = "Enable Sepia Filter"
|
||||
engineLoadErrorBody = "The PDF engine couldn't be loaded. If this keeps happening, make sure the app is up to date, then try again."
|
||||
engineLoadErrorRetry = "Retry"
|
||||
engineLoadErrorTitle = "Couldn't load the PDF viewer"
|
||||
firstPage = "First Page"
|
||||
lastPage = "Last Page"
|
||||
moreOptions = "More"
|
||||
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, act, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Controllable stand-in for the library engine hook so we can simulate the
|
||||
// three states that matter: still loading, loaded, and errored.
|
||||
let engineState: { engine: unknown; isLoading: boolean; error: Error | null } =
|
||||
{
|
||||
engine: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
vi.mock("@embedpdf/engines/react", () => ({
|
||||
usePdfiumEngine: () => engineState,
|
||||
}));
|
||||
|
||||
import { PdfEngineBoundary } from "@app/components/viewer/PdfEngineBoundary";
|
||||
|
||||
const renderBoundary = (onRetry = vi.fn(), timeoutMs = 1000) => {
|
||||
const utils = render(
|
||||
<MantineProvider>
|
||||
<PdfEngineBoundary
|
||||
wasmUrl="pdfium.wasm"
|
||||
onRetry={onRetry}
|
||||
timeoutMs={timeoutMs}
|
||||
>
|
||||
{() => <div>PDF CONTENT</div>}
|
||||
</PdfEngineBoundary>
|
||||
</MantineProvider>,
|
||||
);
|
||||
return { onRetry, ...utils };
|
||||
};
|
||||
|
||||
const wrap = (node: ReactNode) => <MantineProvider>{node}</MantineProvider>;
|
||||
|
||||
describe("PdfEngineBoundary", () => {
|
||||
beforeEach(() => {
|
||||
engineState = { engine: null, isLoading: true, error: null };
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows the loading fallback while the engine is initialising", () => {
|
||||
renderBoundary();
|
||||
expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("viewer.engineLoadErrorTitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces an error with a retry after the load times out (no infinite spinner)", () => {
|
||||
const { onRetry } = renderBoundary(vi.fn(), 1000);
|
||||
|
||||
// Still spinning before the timeout elapses.
|
||||
expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// The spinner is replaced by an actionable error state.
|
||||
expect(screen.queryByText("Loading PDF Engine...")).toBeNull();
|
||||
expect(screen.getByText("viewer.engineLoadErrorTitle")).toBeInTheDocument();
|
||||
|
||||
const retry = screen.getByText("viewer.engineLoadErrorRetry");
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces an error immediately when the engine load rejects", () => {
|
||||
engineState = {
|
||||
engine: null,
|
||||
isLoading: false,
|
||||
error: new Error("boom"),
|
||||
};
|
||||
render(
|
||||
wrap(
|
||||
<PdfEngineBoundary wasmUrl="pdfium.wasm" onRetry={vi.fn()}>
|
||||
{() => <div>PDF CONTENT</div>}
|
||||
</PdfEngineBoundary>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("viewer.engineLoadErrorTitle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders children once the engine is ready", () => {
|
||||
engineState = { engine: {}, isLoading: false, error: null };
|
||||
render(
|
||||
wrap(
|
||||
<PdfEngineBoundary wasmUrl="pdfium.wasm" onRetry={vi.fn()}>
|
||||
{() => <div>PDF CONTENT</div>}
|
||||
</PdfEngineBoundary>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("PDF CONTENT")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading PDF Engine...")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { usePdfiumEngine } from "@embedpdf/engines/react";
|
||||
import { Center, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback";
|
||||
|
||||
/** Engine instance produced by usePdfiumEngine once initialisation succeeds. */
|
||||
type PdfiumEngine = NonNullable<ReturnType<typeof usePdfiumEngine>["engine"]>;
|
||||
|
||||
/**
|
||||
* How long to wait for the PDFium WASM engine to initialise before treating the
|
||||
* load as failed. The engine normally initialises in well under a second; if it
|
||||
* has not resolved after this window it has almost certainly hung (e.g. the WASM
|
||||
* fetch stalled or the worker never reported back), so we surface an error with
|
||||
* a retry instead of spinning forever.
|
||||
*/
|
||||
const DEFAULT_ENGINE_LOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface PdfEngineBoundaryProps {
|
||||
/** Absolute URL of the pdfium.wasm binary to load. */
|
||||
wasmUrl: string;
|
||||
/** Invoked when the user asks to retry after a failed/hung load. */
|
||||
onRetry: () => void;
|
||||
/** Override the load timeout (mainly for tests). */
|
||||
timeoutMs?: number;
|
||||
/** Rendered once the engine is ready. */
|
||||
children: (engine: PdfiumEngine) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the PDFium WASM engine and gates its children on success.
|
||||
*
|
||||
* The underlying `usePdfiumEngine` hook only re-initialises when its `wasmUrl`
|
||||
* changes, so retrying is handled by the parent remounting this component via a
|
||||
* `key`. Because the boundary owns the hook, a fresh mount runs the load again
|
||||
* from scratch.
|
||||
*
|
||||
* Without this boundary a failed or hung WASM load left the viewer showing an
|
||||
* infinite "Loading PDF Engine..." spinner with no error and no way to recover.
|
||||
*/
|
||||
export function PdfEngineBoundary({
|
||||
wasmUrl,
|
||||
onRetry,
|
||||
timeoutMs = DEFAULT_ENGINE_LOAD_TIMEOUT_MS,
|
||||
children,
|
||||
}: PdfEngineBoundaryProps) {
|
||||
const { t } = useTranslation();
|
||||
const { engine, isLoading, error } = usePdfiumEngine({ wasmUrl });
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (engine || error) return;
|
||||
const timer = setTimeout(() => setTimedOut(true), timeoutMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [engine, error, isLoading, timeoutMs]);
|
||||
|
||||
if (engine) {
|
||||
return <>{children(engine)}</>;
|
||||
}
|
||||
|
||||
if (error || timedOut) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md" style={{ maxWidth: 420, padding: 16 }}>
|
||||
<div style={{ fontSize: "32px" }}>⚠️</div>
|
||||
<Text fw={600} size="md" style={{ textAlign: "center" }}>
|
||||
{t("viewer.engineLoadErrorTitle")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" style={{ textAlign: "center" }}>
|
||||
{t("viewer.engineLoadErrorBody")}
|
||||
</Text>
|
||||
<Button onClick={onRetry} variant="primary">
|
||||
{t("viewer.engineLoadErrorRetry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <ToolLoadingFallback toolName="PDF Engine" />;
|
||||
}
|
||||
@@ -13,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,
|
||||
};
|
||||
}
|
||||
@@ -28,24 +28,53 @@ export const pdfiumWasmModulePromise = new Promise<WebAssembly.Module | null>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Compile the WASM without streaming by fetching the whole binary first.
|
||||
*
|
||||
* `compileStreaming` requires the response to be served with the
|
||||
* `application/wasm` MIME type and no incompatible `Content-Encoding`. Under the
|
||||
* `tauri://` asset protocol (and behind some proxies) those headers aren't
|
||||
* guaranteed, which makes streaming compilation throw. Fetching the bytes and
|
||||
* compiling them directly sidesteps the MIME/encoding requirement entirely.
|
||||
*/
|
||||
async function compileFromArrayBuffer(): Promise<WebAssembly.Module | null> {
|
||||
try {
|
||||
const response = await fetch(pdfiumWasmUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected response ${response.status} for pdfium.wasm`);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
return await WebAssembly.compile(bytes);
|
||||
} catch (err) {
|
||||
console.warn("Eager WASM ArrayBuffer compilation failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function startEagerWasmCompilation(): void {
|
||||
if (compilationStarted) return;
|
||||
compilationStarted = true;
|
||||
|
||||
if (
|
||||
typeof WebAssembly === "object" &&
|
||||
typeof WebAssembly.compileStreaming === "function"
|
||||
) {
|
||||
if (typeof WebAssembly !== "object") {
|
||||
resolvePromise(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer streaming compilation, but fall back to fetching the bytes and
|
||||
// compiling them directly when streaming isn't available or fails (e.g. wrong
|
||||
// MIME type / content-encoding under the tauri:// protocol). Resolving null on
|
||||
// total failure lets pdfiumService fall back to its own instantiation path.
|
||||
if (typeof WebAssembly.compileStreaming === "function") {
|
||||
WebAssembly.compileStreaming(fetch(pdfiumWasmUrl))
|
||||
.then(resolvePromise)
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
"Eager WASM compilation failed or not supported in this environment:",
|
||||
"Eager WASM streaming compilation failed, falling back to ArrayBuffer:",
|
||||
err,
|
||||
);
|
||||
resolvePromise(null);
|
||||
compileFromArrayBuffer().then(resolvePromise);
|
||||
});
|
||||
} else {
|
||||
resolvePromise(null);
|
||||
compileFromArrayBuffer().then(resolvePromise);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -270,8 +270,29 @@
|
||||
--grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%);
|
||||
}
|
||||
|
||||
/* Always-dark code palette. Not theme-switched. */
|
||||
:root {
|
||||
/* Code palette — theme-aware: a light GitHub-style box in light mode, the dark
|
||||
terminal palette in dark mode. (CodeBlock renders plain text, so only the
|
||||
base colours are load-bearing; the syntax slots are kept for future Shiki. */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--code-bg: #f6f8fa;
|
||||
--code-bg-alt: #eef1f4;
|
||||
--code-bg-header: #eaeef2;
|
||||
--code-text: #1f2328;
|
||||
--code-dim: #656d76;
|
||||
--code-muted: #8c959f;
|
||||
--code-keyword: #cf222e;
|
||||
--code-string: #0a3069;
|
||||
--code-number: #0550ae;
|
||||
--code-fn: #8250df;
|
||||
--code-type: #953800;
|
||||
--code-property: #0550ae;
|
||||
--code-comment: #6e7781;
|
||||
--code-border: #d0d7de;
|
||||
/* Window-chrome traffic-light dots in the code-block header. */
|
||||
--code-dot: #d0d7de;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--code-bg: #0f172a;
|
||||
--code-bg-alt: #1e293b;
|
||||
--code-bg-header: #1a2332;
|
||||
@@ -286,12 +307,16 @@
|
||||
--code-property: #93c5fd;
|
||||
--code-comment: #475569;
|
||||
--code-border: #1e293b;
|
||||
/* Window-chrome traffic-light dots in the code-block header. */
|
||||
--code-dot: #475569;
|
||||
}
|
||||
|
||||
/* Radii / typography / motion / spacing / z-index — theme-stable */
|
||||
:root {
|
||||
/* Home hero strip navy. Theme-stable by design — the hero keeps this deep
|
||||
navy in both light and dark (it's a branded surface, like the assistant
|
||||
header), so it's defined once here rather than in the light/dark blocks. */
|
||||
--color-hero-navy: #16213e;
|
||||
|
||||
--radius-xs: 0.1875rem;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
|
||||
@@ -61,11 +61,14 @@
|
||||
background: var(--button-bg, transparent) !important;
|
||||
}
|
||||
|
||||
/* Disabled buttons in dark read as a muted surface (not a dimmed accent that
|
||||
still looks clickable, and not an invisible transparent pill). Covers every
|
||||
variant so a disabled primary and a disabled secondary look alike. */
|
||||
[data-theme="dark"] .sui-btn.mantine-Button-root:disabled:not([data-loading]),
|
||||
[data-theme="dark"]
|
||||
.sui-btn--primary.mantine-Button-root:disabled:not([data-loading]),
|
||||
[data-theme="dark"]
|
||||
.sui-btn--primary.mantine-Button-root[data-disabled]:not([data-loading]) {
|
||||
background: var(--button-bg);
|
||||
color: var(--button-color);
|
||||
opacity: 0.55;
|
||||
.sui-btn.mantine-Button-root[data-disabled]:not([data-loading]) {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-5);
|
||||
border-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-code__copy:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
/* Subtle tint that reads on both the light and dark code surfaces. */
|
||||
background: color-mix(in srgb, var(--code-text) 8%, transparent);
|
||||
}
|
||||
.sui-code__pre {
|
||||
margin: 0;
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface TableProps<T> {
|
||||
rowKey: (row: T) => string;
|
||||
/** Makes rows interactive (hover + click + keyboard). */
|
||||
onRowClick?: (row: T) => void;
|
||||
/**
|
||||
* Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which
|
||||
* this returns false is inert: no click/keyboard, and not announced as a button. Defaults to
|
||||
* all rows interactive.
|
||||
*/
|
||||
isRowInteractive?: (row: T) => boolean;
|
||||
/** Rendered in place of the body when there are no rows. */
|
||||
empty?: ReactNode;
|
||||
className?: string;
|
||||
@@ -35,6 +41,7 @@ export function Table<T>({
|
||||
rows,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
isRowInteractive,
|
||||
empty,
|
||||
className,
|
||||
}: TableProps<T>) {
|
||||
@@ -66,38 +73,42 @@ export function Table<T>({
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={
|
||||
interactive
|
||||
? "sui-table__row sui-table__row--interactive"
|
||||
: "sui-table__row"
|
||||
}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
role={interactive ? "button" : undefined}
|
||||
onKeyDown={
|
||||
interactive
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick?.(row);
|
||||
rows.map((row) => {
|
||||
const rowInteractive =
|
||||
interactive && (isRowInteractive?.(row) ?? true);
|
||||
return (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={
|
||||
rowInteractive
|
||||
? "sui-table__row sui-table__row--interactive"
|
||||
: "sui-table__row"
|
||||
}
|
||||
onClick={rowInteractive ? () => onRowClick?.(row) : undefined}
|
||||
tabIndex={rowInteractive ? 0 : undefined}
|
||||
role={rowInteractive ? "button" : undefined}
|
||||
onKeyDown={
|
||||
rowInteractive
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick?.(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
|
||||
>
|
||||
{c.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
|
||||
>
|
||||
{c.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Documents } from "@portal/views/Documents";
|
||||
import { Pipelines } from "@portal/views/Pipelines";
|
||||
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
|
||||
import { Sources } from "@portal/views/Sources";
|
||||
import { SourceBuilder } from "@portal/views/SourceBuilder";
|
||||
import { AgentBuilder } from "@portal/views/AgentBuilder";
|
||||
import { Policies } from "@portal/views/Policies";
|
||||
import { Components } from "@portal/views/Components";
|
||||
@@ -36,6 +37,14 @@ export function ViewRouter() {
|
||||
element={<PipelineBuilder />}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.sources)} element={<Sources />} />
|
||||
<Route
|
||||
path={`${rel(VIEW_PATHS.sources)}/new`}
|
||||
element={<SourceBuilder />}
|
||||
/>
|
||||
<Route
|
||||
path={`${rel(VIEW_PATHS.sources)}/:id`}
|
||||
element={<SourceBuilder />}
|
||||
/>
|
||||
<Route
|
||||
path={rel(VIEW_PATHS["agent-builder"])}
|
||||
element={<AgentBuilder />}
|
||||
|
||||
@@ -99,6 +99,11 @@ export const AGENT_STATUS_TONE: Record<AgentStatus, "success" | "neutral"> = {
|
||||
draft: "neutral",
|
||||
};
|
||||
|
||||
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
|
||||
published: "portal.agentBuilder.status.published",
|
||||
draft: "portal.agentBuilder.status.draft",
|
||||
};
|
||||
|
||||
/**
|
||||
* Catalogue of tools an agent can be granted or denied. Surfaced as the chip
|
||||
* palette in restricted mode so the deny list is picked from a known set
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Integrations service layer: stored connections (S3 today; MCP/API later) that
|
||||
* policy sources and pipeline outputs reference by id instead of embedding
|
||||
* credentials. Secrets are write-only - reads return them masked, and sending
|
||||
* the mask back on update keeps the stored value.
|
||||
*/
|
||||
import { apiClient } from "@portal/api/http";
|
||||
|
||||
export type IntegrationType = "S3" | "MCP" | "API";
|
||||
export type OwnerScope = "USER" | "TEAM" | "SERVER";
|
||||
|
||||
/** Mirrors the backend IntegrationConfigResponse; `config` values are masked. */
|
||||
export interface IntegrationConfig {
|
||||
id: number;
|
||||
integrationType: IntegrationType;
|
||||
name: string;
|
||||
scope: OwnerScope;
|
||||
ownerUserId: number | null;
|
||||
ownerTeamId: number | null;
|
||||
enabled: boolean;
|
||||
locked: boolean;
|
||||
defaultAccess: string;
|
||||
config: Record<string, unknown>;
|
||||
canManage: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Create/update body; omitted fields keep their stored values on update. */
|
||||
export interface IntegrationConfigRequest {
|
||||
integrationType?: IntegrationType;
|
||||
name?: string;
|
||||
scope?: OwnerScope;
|
||||
ownerTeamId?: number | null;
|
||||
enabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchIntegrations(): Promise<IntegrationConfig[]> {
|
||||
return apiClient.local.json<IntegrationConfig[]>("/api/v1/integrations");
|
||||
}
|
||||
|
||||
/** The S3 connections the caller may use, for source/output pickers. */
|
||||
export async function fetchS3Connections(): Promise<IntegrationConfig[]> {
|
||||
return (await fetchIntegrations()).filter(
|
||||
(integration) => integration.integrationType === "S3",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createIntegration(
|
||||
body: IntegrationConfigRequest,
|
||||
): Promise<IntegrationConfig> {
|
||||
return apiClient.local.json<IntegrationConfig>("/api/v1/integrations", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateIntegration(
|
||||
id: number,
|
||||
body: IntegrationConfigRequest,
|
||||
): Promise<IntegrationConfig> {
|
||||
return apiClient.local.json<IntegrationConfig>(
|
||||
`/api/v1/integrations/${encodeURIComponent(id)}`,
|
||||
{ method: "PUT", body },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteIntegration(id: number): Promise<void> {
|
||||
await apiClient.local.json<void>(
|
||||
`/api/v1/integrations/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,12 @@
|
||||
* approach the editor uses for its own catalogue view.
|
||||
*/
|
||||
|
||||
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,
|
||||
@@ -65,7 +68,7 @@ export interface PolicyConfigDef {
|
||||
rules: string[];
|
||||
scopeLabel: string;
|
||||
fields: PolicyField[];
|
||||
defaultOperations: WirePipelineStep[];
|
||||
defaultOperations: PolicyToolStep[];
|
||||
}
|
||||
|
||||
export interface PolicyState {
|
||||
@@ -127,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",
|
||||
@@ -153,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, " ")
|
||||
@@ -229,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",
|
||||
@@ -259,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: [],
|
||||
},
|
||||
@@ -296,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",
|
||||
@@ -342,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",
|
||||
@@ -373,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",
|
||||
@@ -556,17 +529,30 @@ const DEFAULT_RETRY_DELAY = 5;
|
||||
// POST /api/v1/policies endpoint. The real backend ignores unknown fields.
|
||||
type CatalogueWireBody = WirePolicy & { categoryId: string };
|
||||
|
||||
/**
|
||||
* The persisted policy name derived from its category, e.g. "Security Policy".
|
||||
* `category.label` is an i18n key, so translate it before building the name;
|
||||
* otherwise the raw key is persisted and surfaces in the UI (e.g. the Sources
|
||||
* "Used by" pill).
|
||||
*/
|
||||
function policyDisplayName(entry: CatalogueEntry, t: TFunction): string {
|
||||
return t("portal.policies.defaultName", {
|
||||
category: t(entry.category.label),
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a wire policy from a setup wizard result. */
|
||||
export function buildWireFromSetup(
|
||||
entry: CatalogueEntry,
|
||||
result: PolicySetupResult,
|
||||
t: TFunction,
|
||||
enabled = true,
|
||||
): CatalogueWireBody {
|
||||
return {
|
||||
categoryId: entry.category.id,
|
||||
...toWirePolicy({
|
||||
id: entry.policy?.state.backendId ?? "",
|
||||
name: `${entry.category.label} Policy`,
|
||||
name: policyDisplayName(entry, t),
|
||||
enabled,
|
||||
categoryId: entry.category.id,
|
||||
sources: result.sources,
|
||||
@@ -589,13 +575,14 @@ export function buildWireFromState(
|
||||
entry: CatalogueEntry,
|
||||
policy: DecoratedPolicy,
|
||||
enabled: boolean,
|
||||
t: TFunction,
|
||||
): CatalogueWireBody {
|
||||
const s = policy.state;
|
||||
return {
|
||||
categoryId: entry.category.id,
|
||||
...toWirePolicy({
|
||||
id: s.backendId ?? "",
|
||||
name: `${entry.category.label} Policy`,
|
||||
name: policyDisplayName(entry, t),
|
||||
enabled,
|
||||
categoryId: entry.category.id,
|
||||
sources: s.sources,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// The bare i18next singleton (the same instance @app/i18n initializes at
|
||||
// startup), imported directly so this data module doesn't pull i18n's
|
||||
// init side effects into unit tests that mock react-i18next.
|
||||
import i18n from "i18next";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
@@ -269,22 +273,23 @@ function roleIdFor(u: AdminUserSummaryDto): RoleId {
|
||||
|
||||
/** A member's last-seen time as plain language; "Never" when no session is tracked. */
|
||||
function relativeTime(value: number | string | undefined): string {
|
||||
if (value === undefined || value === null) return "Never";
|
||||
if (value === undefined || value === null)
|
||||
return i18n.t("users.activity.never");
|
||||
const ts = typeof value === "string" ? Date.parse(value) : value;
|
||||
if (!Number.isFinite(ts) || ts <= 0) return "Never";
|
||||
if (!Number.isFinite(ts) || ts <= 0) return i18n.t("users.activity.never");
|
||||
const mins = Math.max(0, Math.round((Date.now() - ts) / 60000));
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
if (mins < 1) return i18n.t("users.activity.justNow");
|
||||
if (mins < 60) return i18n.t("users.activity.minutesAgo", { count: mins });
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (hours < 24) return i18n.t("users.activity.hoursAgo", { count: hours });
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (days < 7) return i18n.t("users.activity.daysAgo", { count: days });
|
||||
const weeks = Math.round(days / 7);
|
||||
if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`;
|
||||
if (weeks < 5) return i18n.t("users.activity.weeksAgo", { count: weeks });
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`;
|
||||
if (months < 12) return i18n.t("users.activity.monthsAgo", { count: months });
|
||||
const years = Math.round(days / 365);
|
||||
return years <= 1 ? "1 year ago" : `${years} years ago`;
|
||||
return i18n.t("users.activity.yearsAgo", { count: years });
|
||||
}
|
||||
|
||||
/** 0 / huge sentinel license values mean "no seat limit". */
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #16213e;
|
||||
background: var(--color-hero-navy);
|
||||
}
|
||||
|
||||
.portal-editor-hero__logo {
|
||||
@@ -123,7 +123,7 @@
|
||||
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn {
|
||||
background: #ffffff;
|
||||
border-color: #ffffff;
|
||||
color: #16213e;
|
||||
color: var(--color-hero-navy);
|
||||
}
|
||||
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ProcessingStatusStrip() {
|
||||
style={{ background: TIER_INFO[tier].dotColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
{TIER_INFO[tier].label}
|
||||
{t(TIER_INFO[tier].labelKey)}
|
||||
</span>
|
||||
<span className="portal-statusstrip__sep" aria-hidden>
|
||||
·
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: #16213e;
|
||||
background: var(--color-hero-navy);
|
||||
}
|
||||
|
||||
.portal-welcome__brand {
|
||||
@@ -90,7 +90,7 @@
|
||||
.portal-welcome__header .portal-welcome__cta.sui-btn {
|
||||
background: #ffffff;
|
||||
border-color: #ffffff;
|
||||
color: #16213e;
|
||||
color: var(--color-hero-navy);
|
||||
}
|
||||
.portal-welcome__header .portal-welcome__cta.sui-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatusBadge, Tabs, type TabItem } from "@app/ui";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
|
||||
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
|
||||
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
|
||||
@@ -52,7 +56,7 @@ export function AgentBuilderPanel({
|
||||
</div>
|
||||
<div className="portal-agents__builder-meta">
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[agent.status]} size="sm">
|
||||
{agent.status}
|
||||
{t(AGENT_STATUS_LABEL[agent.status])}
|
||||
</StatusBadge>
|
||||
<code className="portal-agents__builder-version">
|
||||
{agent.version}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import { Button, StatusBadge } from "@app/ui";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
@@ -40,7 +44,7 @@ export function AgentSelector({
|
||||
</span>
|
||||
<span className="portal-agents__selector-meta">
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[a.status]} size="sm">
|
||||
{a.status}
|
||||
{t(AGENT_STATUS_LABEL[a.status])}
|
||||
</StatusBadge>
|
||||
<code className="portal-agents__selector-version">{a.version}</code>
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileUploadRounded from "@mui/icons-material/FileUploadRounded";
|
||||
import { Button, Modal } from "@app/ui";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
@@ -58,7 +59,7 @@ export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) {
|
||||
onChange={(e) => setFileName(e.target.files?.[0]?.name ?? null)}
|
||||
/>
|
||||
<span className="portal-agents__dropzone-icon" aria-hidden>
|
||||
⇪
|
||||
<FileUploadRounded style={{ fontSize: "1.5rem" }} />
|
||||
</span>
|
||||
<span className="portal-agents__dropzone-text">
|
||||
{fileName ?? t("portal.agentBuilder.bootstrap.dropzoneText")}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, StatusBadge } from "@app/ui";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface VersionsPanelProps {
|
||||
@@ -52,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
|
||||
{v.version}
|
||||
</code>
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[v.status]} size="sm">
|
||||
{v.status}
|
||||
{t(AGENT_STATUS_LABEL[v.status])}
|
||||
</StatusBadge>
|
||||
{isCurrent && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
} from "@app/ui";
|
||||
import {
|
||||
type SdkComponent,
|
||||
BILLING_UNIT_LABEL,
|
||||
MATURITY_META,
|
||||
formatPrice,
|
||||
} from "@portal/api/sdkComponents";
|
||||
import { TIER_INFO } from "@portal/contexts/TierContext";
|
||||
import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
@@ -113,7 +115,7 @@ export function ComponentDetailModal({
|
||||
title={t("portal.catalogue.detail.locked.title")}
|
||||
description={t("portal.catalogue.detail.locked.description", {
|
||||
name: component.name,
|
||||
tier: component.minTier,
|
||||
plan: t(TIER_INFO[component.minTier].labelKey),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
@@ -205,7 +207,7 @@ export function ComponentDetailModal({
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.billedOn")}
|
||||
value={component.pricing.unit}
|
||||
value={t(BILLING_UNIT_LABEL[component.pricing.unit])}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.freeQuota")}
|
||||
@@ -220,7 +222,7 @@ export function ComponentDetailModal({
|
||||
</div>
|
||||
<p className="portal-components__pricing-note">
|
||||
{t("portal.catalogue.detail.pricing.note", {
|
||||
unit: component.pricing.unit,
|
||||
unit: t(BILLING_UNIT_LABEL[component.pricing.unit]),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LockRounded from "@mui/icons-material/LockRounded";
|
||||
import { StatusBadge, Table, type TableColumn } from "@app/ui";
|
||||
import { type Extraction, type ReviewDocument } from "@portal/api/documents";
|
||||
import {
|
||||
@@ -55,7 +56,7 @@ export function DocumentExtractions({
|
||||
return (
|
||||
<div className="portal-documents__masked">
|
||||
<span className="portal-documents__masked-icon" aria-hidden>
|
||||
🔒
|
||||
<LockRounded style={{ fontSize: "1.5rem" }} />
|
||||
</span>
|
||||
<p className="portal-documents__masked-text">
|
||||
{t("portal.documents.extractions.masked")}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ScheduleRounded from "@mui/icons-material/ScheduleRounded";
|
||||
import LockRounded from "@mui/icons-material/LockRounded";
|
||||
import { Banner, Button } from "@app/ui";
|
||||
import { formatCountdown } from "@portal/components/documents/format";
|
||||
|
||||
@@ -26,7 +28,7 @@ export function ElevationBanner({
|
||||
return (
|
||||
<Banner
|
||||
tone="success"
|
||||
icon={<span aria-hidden>⏱</span>}
|
||||
icon={<ScheduleRounded style={{ fontSize: "1.1rem" }} />}
|
||||
title={t("portal.documents.elevation.active.title", {
|
||||
time: formatCountdown(secondsLeft),
|
||||
})}
|
||||
@@ -42,7 +44,7 @@ export function ElevationBanner({
|
||||
return (
|
||||
<Banner
|
||||
tone="warning"
|
||||
icon={<span aria-hidden>🔒</span>}
|
||||
icon={<LockRounded style={{ fontSize: "1.1rem" }} />}
|
||||
title={t("portal.documents.elevation.gated.title")}
|
||||
description={
|
||||
fourEyes
|
||||
|
||||
@@ -157,7 +157,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`)
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`)
|
||||
}
|
||||
>
|
||||
{t("portal.documents.queue.empty.connectSource")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LockRounded from "@mui/icons-material/LockRounded";
|
||||
import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui";
|
||||
import {
|
||||
classificationTone,
|
||||
@@ -75,7 +76,7 @@ export function ReviewQueueTable({
|
||||
title={t("portal.documents.table.sensitiveTitle")}
|
||||
aria-label={t("portal.documents.table.sensitiveLabel")}
|
||||
>
|
||||
🔒
|
||||
<LockRounded style={{ fontSize: "0.95rem" }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
rightSection={
|
||||
<span className="portal-infra__key-head-right">
|
||||
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
|
||||
{KEY_LABEL[apiKey.status]}
|
||||
{t(KEY_LABEL[apiKey.status])}
|
||||
</StatusBadge>
|
||||
<span
|
||||
className={"portal-infra__chevron" + (open ? " is-open" : "")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AccountTreeRounded from "@mui/icons-material/AccountTreeRounded";
|
||||
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
|
||||
import {
|
||||
Chip,
|
||||
@@ -31,7 +32,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) {
|
||||
render: (p) => (
|
||||
<div className="portal-pipelines__name-cell">
|
||||
<span className="portal-pipelines__pipe-dot" aria-hidden>
|
||||
⛓
|
||||
<AccountTreeRounded style={{ fontSize: "1.2rem" }} />
|
||||
</span>
|
||||
<div className="portal-pipelines__name-text">
|
||||
<strong>{p.name}</strong>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DocumentLedger({
|
||||
className="portal-proc__stage-label"
|
||||
data-current={cur || undefined}
|
||||
>
|
||||
{group.label}
|
||||
{t(group.label)}
|
||||
</span>
|
||||
{blurb && (
|
||||
<span className="portal-proc__stage-hint">
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
|
||||
|
||||
const meta: Meta<typeof ConnectWizard> = {
|
||||
title: "Portal/Sources/ConnectWizard",
|
||||
component: ConnectWizard,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { open: true, onClose: () => {}, onCreated: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ConnectWizard>;
|
||||
|
||||
/** Opens on the type-picker step; Continue/Back walk through the three steps. */
|
||||
export const Open: Story = {};
|
||||
|
||||
export const Closed: Story = { args: { open: false } };
|
||||
@@ -1,192 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { HttpError } from "@portal/api/http";
|
||||
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
|
||||
|
||||
function renderWithMantine(ui: React.ReactElement) {
|
||||
return render(<MantineProvider>{ui}</MantineProvider>);
|
||||
}
|
||||
|
||||
// Deterministic i18n: keys come back verbatim so the test never waits on the
|
||||
// async TOML backend.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the service layer so no real fetch is issued.
|
||||
const createSource = vi.fn();
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
createSource: (...args: unknown[]) => createSource(...args),
|
||||
}));
|
||||
|
||||
/** Step through the folder-source flow: choose type -> configure -> review. */
|
||||
function stepToReview() {
|
||||
// Step 0: folder is the default-selected type. Continue.
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
|
||||
// Step 1: fill the required name + directory fields.
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
// First textbox is the name field, second is the folder's directory field.
|
||||
fireEvent.change(inputs[0], { target: { value: "Claims intake" } });
|
||||
fireEvent.change(inputs[1], { target: { value: "/data/incoming" } });
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
}
|
||||
|
||||
describe("ConnectWizard", () => {
|
||||
beforeEach(() => {
|
||||
createSource.mockReset();
|
||||
});
|
||||
|
||||
it("creates a folder source with the configured options", async () => {
|
||||
createSource.mockResolvedValue({ id: "src-1" });
|
||||
const onCreated = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard open onClose={onClose} onCreated={onCreated} />,
|
||||
);
|
||||
|
||||
stepToReview();
|
||||
|
||||
// Step 2: submit via the final "connect source" action.
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(createSource).toHaveBeenCalledWith({
|
||||
name: "Claims intake",
|
||||
type: "folder",
|
||||
options: {
|
||||
directory: "/data/incoming",
|
||||
mode: "consume",
|
||||
recursive: "false",
|
||||
identity: "stat",
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an s3 source with a masked secret in review", async () => {
|
||||
createSource.mockResolvedValue({ id: "src-2" });
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Step 0: pick the S3 type card.
|
||||
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
|
||||
// Step 1: name, bucket, credentials. The secret renders as a password
|
||||
// input, so it is not part of the textbox roles.
|
||||
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
|
||||
fireEvent.change(inputs[0], { target: { value: "Claims bucket" } });
|
||||
fireEvent.change(inputs[1], { target: { value: "claims-inbox" } });
|
||||
fireEvent.change(inputs[4], { target: { value: "AKIAEXAMPLE" } });
|
||||
const secret = document.querySelector(
|
||||
'input[type="password"]',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(secret, { target: { value: "shh-secret" } });
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
|
||||
// Step 2: the secret is masked in review, never echoed.
|
||||
expect(screen.queryByText("shh-secret")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("********")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(createSource).toHaveBeenCalledWith({
|
||||
name: "Claims bucket",
|
||||
type: "s3",
|
||||
options: {
|
||||
bucket: "claims-inbox",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
accessKeyId: "AKIAEXAMPLE",
|
||||
secretAccessKey: "shh-secret",
|
||||
endpoint: "",
|
||||
mode: "consume",
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("edits an existing source: prefilled, skips type, submits with its id", async () => {
|
||||
createSource.mockResolvedValue({ id: "s1" });
|
||||
const onCreated = vi.fn();
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard
|
||||
open
|
||||
source={{
|
||||
id: "s1",
|
||||
name: "James",
|
||||
type: "folder",
|
||||
options: { directory: "'/data/in'", mode: "consume" },
|
||||
enabled: true,
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onCreated={onCreated}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Edit opens on the configure step with name + directory prefilled.
|
||||
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
|
||||
expect(inputs[0].value).toBe("James");
|
||||
expect(inputs[1].value).toBe("'/data/in'");
|
||||
|
||||
// Fix the stray quotes, continue to review, save.
|
||||
fireEvent.change(inputs[1], { target: { value: "/data/in" } });
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Options absent from the stored source are submitted at their defaults.
|
||||
expect(createSource).toHaveBeenCalledWith({
|
||||
id: "s1",
|
||||
name: "James",
|
||||
type: "folder",
|
||||
options: {
|
||||
directory: "/data/in",
|
||||
mode: "consume",
|
||||
recursive: "false",
|
||||
identity: "stat",
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the inline error message when create fails", async () => {
|
||||
createSource.mockRejectedValue(
|
||||
new HttpError(400, "Bad Request", {
|
||||
detail: "Directory is not readable",
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
|
||||
);
|
||||
|
||||
stepToReview();
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Directory is not readable"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
StatTile,
|
||||
} from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import { createSource, type Source } from "@portal/api/sources";
|
||||
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
|
||||
import {
|
||||
defaultOptions,
|
||||
sourceTypeMeta,
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
const OFFERED_TYPES = creatableSourceTypes();
|
||||
const DEFAULT_TYPE = OFFERED_TYPES[0];
|
||||
|
||||
/** Wizard steps. Editing skips type selection (the type is fixed once created). */
|
||||
type StepId = "type" | "configure" | "review";
|
||||
const CREATE_STEPS: StepId[] = ["type", "configure", "review"];
|
||||
const EDIT_STEPS: StepId[] = ["configure", "review"];
|
||||
|
||||
interface ConnectWizardProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a source is created or updated so the page can refetch. */
|
||||
onCreated: () => void;
|
||||
/** When set, the wizard edits this existing source instead of creating one. */
|
||||
source?: Source;
|
||||
}
|
||||
|
||||
/** The creatable-type metadata for a source's stored type, falling back to the first offered. */
|
||||
function typeFor(type: string | undefined): CreatableSourceType {
|
||||
return OFFERED_TYPES.find((t) => t.type === type) ?? DEFAULT_TYPE;
|
||||
}
|
||||
|
||||
/** Source options coerced to strings for the form, defaulted from the type's fields. */
|
||||
function optionsFor(
|
||||
type: CreatableSourceType,
|
||||
options: Record<string, unknown> | undefined,
|
||||
): Record<string, string> {
|
||||
const out = defaultOptions(type);
|
||||
for (const [key, value] of Object.entries(options ?? {})) {
|
||||
out[key] = value == null ? "" : String(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guided flow for connecting a source (choose type -> configure -> review) or
|
||||
* editing an existing one (configure -> review, type fixed). On submit a blank
|
||||
* id creates and a set id updates, matching the backend's POST contract.
|
||||
*/
|
||||
export function ConnectWizard({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
source,
|
||||
}: ConnectWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = source !== undefined;
|
||||
const steps = isEdit ? EDIT_STEPS : CREATE_STEPS;
|
||||
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [type, setType] = useState<CreatableSourceType>(() =>
|
||||
typeFor(source?.type),
|
||||
);
|
||||
const [name, setName] = useState(source?.name ?? "");
|
||||
const [options, setOptions] = useState<Record<string, string>>(() =>
|
||||
optionsFor(typeFor(source?.type), source?.options),
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Re-seed the form whenever the wizard opens (or its target source changes) so
|
||||
// editing prefills the current config and a reopened create starts clean.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const ct = typeFor(source?.type);
|
||||
setStepIndex(0);
|
||||
setType(ct);
|
||||
setName(source?.name ?? "");
|
||||
setOptions(optionsFor(ct, source?.options));
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [open, source]);
|
||||
|
||||
const stepId = steps[stepIndex];
|
||||
const isLast = stepIndex === steps.length - 1;
|
||||
|
||||
function chooseType(next: CreatableSourceType) {
|
||||
setType(next);
|
||||
setOptions(defaultOptions(next));
|
||||
}
|
||||
|
||||
const requiredFilled = type.fields.every(
|
||||
(f) => !f.required || (options[f.key] ?? "").trim() !== "",
|
||||
);
|
||||
const canContinue =
|
||||
stepId === "configure" ? name.trim() !== "" && requiredFilled : true;
|
||||
|
||||
async function advance() {
|
||||
if (!isLast) {
|
||||
setStepIndex((i) => i + 1);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fields = {
|
||||
name: name.trim(),
|
||||
type: type.type,
|
||||
options,
|
||||
enabled: source?.enabled ?? true,
|
||||
};
|
||||
await createSource(isEdit ? { ...fields, id: source.id } : fields);
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const stepLabels: Record<StepId, string> = {
|
||||
type: t("portal.sources.wizard.steps.chooseType"),
|
||||
configure: t("portal.sources.wizard.steps.configure"),
|
||||
review: t("portal.sources.wizard.steps.review"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={
|
||||
isEdit
|
||||
? t("portal.sources.wizard.editTitle")
|
||||
: t("portal.sources.wizard.title")
|
||||
}
|
||||
subtitle={t("portal.sources.wizard.subtitle", {
|
||||
current: stepIndex + 1,
|
||||
total: steps.length,
|
||||
label: stepLabels[stepId],
|
||||
})}
|
||||
footer={
|
||||
<div className="portal-sources__wizard-footer">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
stepIndex === 0 ? onClose() : setStepIndex((i) => i - 1)
|
||||
}
|
||||
>
|
||||
{stepIndex === 0
|
||||
? t("portal.sources.wizard.cancel")
|
||||
: t("portal.sources.wizard.back")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={advance}
|
||||
loading={submitting}
|
||||
disabled={!canContinue}
|
||||
rightSection={!isLast ? <span aria-hidden>→</span> : undefined}
|
||||
>
|
||||
{!isLast
|
||||
? t("portal.sources.wizard.continue")
|
||||
: isEdit
|
||||
? t("portal.sources.wizard.save")
|
||||
: t("portal.sources.actions.connectSource")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ol className="portal-sources__steps" aria-hidden>
|
||||
{steps.map((id, i) => (
|
||||
<li
|
||||
key={id}
|
||||
className={
|
||||
"portal-sources__step" +
|
||||
(i === stepIndex ? " is-active" : i < stepIndex ? " is-done" : "")
|
||||
}
|
||||
>
|
||||
<span className="portal-sources__step-mark">
|
||||
{i < stepIndex ? "✓" : i + 1}
|
||||
</span>
|
||||
{stepLabels[id]}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{stepId === "type" && (
|
||||
<div className="portal-sources__type-grid">
|
||||
{OFFERED_TYPES.map((ct) => (
|
||||
<Button
|
||||
key={ct.type}
|
||||
variant="tertiary"
|
||||
className={
|
||||
"portal-sources__type-card" +
|
||||
(type.type === ct.type ? " is-selected" : "")
|
||||
}
|
||||
onClick={() => chooseType(ct)}
|
||||
>
|
||||
<span className="portal-sources__type-icon" aria-hidden>
|
||||
{sourceTypeMeta(ct.type).icon}
|
||||
</span>
|
||||
<span className="portal-sources__type-name">
|
||||
{t(ct.labelKey)}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepId === "configure" && (
|
||||
<div className="portal-sources__wizard-body">
|
||||
<FormField label={t("portal.sources.wizard.name")} required>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder={t("portal.sources.wizard.namePlaceholder")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
{type.fields.map((field) => (
|
||||
<FormField
|
||||
key={field.key}
|
||||
label={t(field.labelKey)}
|
||||
helperText={
|
||||
field.helperTextKey ? t(field.helperTextKey) : undefined
|
||||
}
|
||||
required={field.required}
|
||||
>
|
||||
{field.control === "select" ? (
|
||||
<Select
|
||||
value={options[field.key] ?? ""}
|
||||
options={(field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: t(o.labelKey),
|
||||
}))}
|
||||
onChange={(value) =>
|
||||
setOptions((o) => ({ ...o, [field.key]: value ?? "" }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.control === "password" ? "password" : undefined}
|
||||
value={options[field.key] ?? ""}
|
||||
placeholder={
|
||||
field.placeholderKey ? t(field.placeholderKey) : undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
setOptions((o) => ({ ...o, [field.key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepId === "review" && (
|
||||
<div className="portal-sources__wizard-body">
|
||||
<div className="portal-sources__stat-grid">
|
||||
<StatTile
|
||||
label={t("portal.sources.wizard.name")}
|
||||
value={name || "—"}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.sources.wizard.type")}
|
||||
value={t(type.labelKey)}
|
||||
/>
|
||||
{type.fields.map((field) => (
|
||||
<StatTile
|
||||
key={field.key}
|
||||
label={t(field.labelKey)}
|
||||
value={
|
||||
field.control === "password" && options[field.key]
|
||||
? "********"
|
||||
: options[field.key] || "—"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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 { HttpError } from "@portal/api/http";
|
||||
import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab";
|
||||
import type { IntegrationConfig } from "@portal/api/integrations";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const fetchS3Connections = vi.fn();
|
||||
const deleteIntegration = vi.fn();
|
||||
vi.mock("@portal/api/integrations", () => ({
|
||||
fetchS3Connections: () => fetchS3Connections(),
|
||||
deleteIntegration: (id: number) => deleteIntegration(id),
|
||||
createIntegration: vi.fn(),
|
||||
updateIntegration: vi.fn(),
|
||||
}));
|
||||
|
||||
const CONNECTION = {
|
||||
id: 5,
|
||||
integrationType: "S3",
|
||||
name: "Claims bucket",
|
||||
config: { bucket: "inbox", region: "us-east-1" },
|
||||
canManage: true,
|
||||
} as unknown as IntegrationConfig;
|
||||
|
||||
describe("ConnectionsTab", () => {
|
||||
beforeEach(() => {
|
||||
fetchS3Connections.mockReset();
|
||||
deleteIntegration.mockReset();
|
||||
deleteIntegration.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no connections", async () => {
|
||||
fetchS3Connections.mockResolvedValue([]);
|
||||
render(<ConnectionsTab />);
|
||||
expect(
|
||||
await screen.findByText("portal.connections.empty.title"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists connections and deletes one", async () => {
|
||||
fetchS3Connections.mockResolvedValueOnce([CONNECTION]);
|
||||
fetchS3Connections.mockResolvedValueOnce([]);
|
||||
render(<ConnectionsTab />);
|
||||
|
||||
expect(await screen.findByText("Claims bucket")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("portal.connections.delete"));
|
||||
await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5));
|
||||
});
|
||||
|
||||
it("surfaces the 409 when deleting a connection still in use", async () => {
|
||||
fetchS3Connections.mockResolvedValue([CONNECTION]);
|
||||
deleteIntegration.mockRejectedValue(
|
||||
new HttpError(409, "Conflict", {
|
||||
detail: "Integration is in use by: source 'Claims intake'",
|
||||
}),
|
||||
);
|
||||
render(<ConnectionsTab />);
|
||||
|
||||
await screen.findByText("Claims bucket");
|
||||
fireEvent.click(screen.getByText("portal.connections.delete"));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Integration is in use by: source 'Claims intake'",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
deleteIntegration,
|
||||
fetchS3Connections,
|
||||
type IntegrationConfig,
|
||||
} from "@portal/api/integrations";
|
||||
import { SourcesIcon } from "@portal/components/icons";
|
||||
import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal";
|
||||
|
||||
/**
|
||||
* The Connections tab of the Sources page: stored S3 connections that sources
|
||||
* and pipeline outputs reference by id. Create/edit go through the shared
|
||||
* {@link S3ConnectionModal}; deleting one the backend still references returns a
|
||||
* 409, surfaced inline.
|
||||
*/
|
||||
export function ConnectionsTab() {
|
||||
const { t } = useTranslation();
|
||||
const [connections, setConnections] = useState<IntegrationConfig[] | null>(
|
||||
null,
|
||||
);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<IntegrationConfig | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setConnections(await fetchS3Connections());
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(connection: IntegrationConfig) {
|
||||
setEditing(connection);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function remove(connection: IntegrationConfig) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteIntegration(connection.id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<TableColumn<IntegrationConfig>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: t("portal.connections.table.name"),
|
||||
render: (c) => <strong>{c.name}</strong>,
|
||||
},
|
||||
{
|
||||
key: "bucket",
|
||||
header: t("portal.connections.table.bucket"),
|
||||
render: (c) => (
|
||||
<span className="portal-sources__connections-bucket">
|
||||
{String(c.config?.bucket ?? "")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
header: t("portal.connections.table.region"),
|
||||
render: (c) => String(c.config?.region ?? ""),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
render: (c) =>
|
||||
c.canManage ? (
|
||||
<span className="portal-sources__connections-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => openEdit(c)}
|
||||
>
|
||||
{t("portal.connections.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
accent="danger"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(c)}
|
||||
>
|
||||
{t("portal.connections.delete")}
|
||||
</Button>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
// remove/openEdit are stable enough for this admin surface; busy gates them.
|
||||
[t, busy],
|
||||
);
|
||||
|
||||
const isLoading = connections === null;
|
||||
const isEmpty = connections !== null && connections.length === 0;
|
||||
|
||||
return (
|
||||
<section className="portal-sources__connections">
|
||||
<div className="portal-sources__connections-head">
|
||||
<p className="portal-sources__connections-sub">
|
||||
{t("portal.connections.subtitle")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.connections.actions.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-sources__table-skeleton" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
icon={<SourcesIcon size={28} />}
|
||||
title={t("portal.connections.empty.title")}
|
||||
description={t("portal.connections.empty.description")}
|
||||
actions={
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.connections.actions.new")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connections !== null && connections.length > 0 && (
|
||||
<Table<IntegrationConfig>
|
||||
className="portal-sources__connections-table"
|
||||
columns={columns}
|
||||
rows={connections}
|
||||
rowKey={(c) => String(c.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<S3ConnectionModal
|
||||
open={modalOpen}
|
||||
connection={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={() => void refresh()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormField, Input } from "@app/ui";
|
||||
|
||||
/**
|
||||
* The connection-level S3 fields (per-use settings like prefix/mode live on the
|
||||
* source or output referencing the connection). Secrets are write-only: when
|
||||
* editing, the backend returns them masked and keeps the stored value if the
|
||||
* mask is sent back unchanged.
|
||||
*/
|
||||
export interface S3ConnectionFormValues {
|
||||
name: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
}
|
||||
|
||||
export const EMPTY_S3_CONNECTION: S3ConnectionFormValues = {
|
||||
name: "",
|
||||
bucket: "",
|
||||
region: "us-east-1",
|
||||
endpoint: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
};
|
||||
|
||||
export function s3ConnectionRequestConfig(
|
||||
values: S3ConnectionFormValues,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
bucket: values.bucket.trim(),
|
||||
region: values.region.trim(),
|
||||
endpoint: values.endpoint.trim(),
|
||||
accessKeyId: values.accessKeyId.trim(),
|
||||
secretAccessKey: values.secretAccessKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function s3ConnectionFormValid(values: S3ConnectionFormValues): boolean {
|
||||
return (
|
||||
values.name.trim() !== "" &&
|
||||
values.bucket.trim() !== "" &&
|
||||
values.accessKeyId.trim() !== "" &&
|
||||
values.secretAccessKey.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
interface S3ConnectionFormProps {
|
||||
values: S3ConnectionFormValues;
|
||||
onChange: (values: S3ConnectionFormValues) => void;
|
||||
}
|
||||
|
||||
export function S3ConnectionForm({ values, onChange }: S3ConnectionFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const set = (key: keyof S3ConnectionFormValues, value: string) =>
|
||||
onChange({ ...values, [key]: value });
|
||||
|
||||
return (
|
||||
<div className="portal-sources__connection-form">
|
||||
<FormField label={t("portal.connections.s3.fields.name")} required>
|
||||
<Input
|
||||
value={values.name}
|
||||
placeholder={t("portal.connections.s3.fields.namePlaceholder")}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.bucket.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={values.bucket}
|
||||
placeholder="my-company-inbox"
|
||||
onChange={(e) => set("bucket", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("portal.sources.types.s3.fields.region.label")}>
|
||||
<Input
|
||||
value={values.region}
|
||||
placeholder="us-east-1"
|
||||
onChange={(e) => set("region", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.accessKeyId.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={values.accessKeyId}
|
||||
onChange={(e) => set("accessKeyId", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.secretAccessKey.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={values.secretAccessKey}
|
||||
onChange={(e) => set("secretAccessKey", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.endpoint.label")}
|
||||
helperText={t("portal.sources.types.s3.fields.endpoint.helperText")}
|
||||
>
|
||||
<Input
|
||||
value={values.endpoint}
|
||||
placeholder="https://s3.example.com"
|
||||
onChange={(e) => set("endpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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 { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal";
|
||||
import type { IntegrationConfig } from "@portal/api/integrations";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const createIntegration = vi.fn();
|
||||
const updateIntegration = vi.fn();
|
||||
vi.mock("@portal/api/integrations", () => ({
|
||||
createIntegration: (...a: unknown[]) => createIntegration(...a),
|
||||
updateIntegration: (...a: unknown[]) => updateIntegration(...a),
|
||||
}));
|
||||
|
||||
function setField(labelPattern: RegExp, value: string) {
|
||||
fireEvent.change(screen.getByLabelText(labelPattern), { target: { value } });
|
||||
}
|
||||
|
||||
describe("S3ConnectionModal", () => {
|
||||
beforeEach(() => {
|
||||
createIntegration.mockReset();
|
||||
updateIntegration.mockReset();
|
||||
});
|
||||
|
||||
it("creates a team-scoped connection from the entered fields", async () => {
|
||||
createIntegration.mockResolvedValue({ id: 5, name: "Claims bucket" });
|
||||
const onSaved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<S3ConnectionModal open onClose={onClose} onSaved={onSaved} />);
|
||||
|
||||
setField(/portal\.connections\.s3\.fields\.name/, "Claims bucket");
|
||||
setField(/portal\.sources\.types\.s3\.fields\.bucket\.label/, "inbox");
|
||||
setField(/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, "AKIA");
|
||||
setField(
|
||||
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
|
||||
"shh",
|
||||
);
|
||||
fireEvent.click(screen.getByText("portal.connections.picker.save"));
|
||||
|
||||
await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1));
|
||||
expect(createIntegration).toHaveBeenCalledWith({
|
||||
integrationType: "S3",
|
||||
name: "Claims bucket",
|
||||
scope: "TEAM",
|
||||
config: {
|
||||
bucket: "inbox",
|
||||
region: "us-east-1",
|
||||
endpoint: "",
|
||||
accessKeyId: "AKIA",
|
||||
secretAccessKey: "shh",
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("round-trips a masked secret unchanged on edit (keeps the stored value)", async () => {
|
||||
updateIntegration.mockResolvedValue({ id: 5, name: "Claims bucket" });
|
||||
// The API returns secrets masked; the modal must resend the sentinel verbatim
|
||||
// so the backend keeps the stored secret rather than overwriting it.
|
||||
const connection = {
|
||||
id: 5,
|
||||
integrationType: "S3",
|
||||
name: "Claims bucket",
|
||||
config: {
|
||||
bucket: "inbox",
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA",
|
||||
secretAccessKey: "********",
|
||||
},
|
||||
canManage: true,
|
||||
} as unknown as IntegrationConfig;
|
||||
|
||||
render(
|
||||
<S3ConnectionModal
|
||||
open
|
||||
connection={connection}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const secret = screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
|
||||
) as HTMLInputElement;
|
||||
expect(secret.value).toBe("********");
|
||||
// Change only the name; leave the masked secret untouched.
|
||||
setField(/portal\.connections\.s3\.fields\.name/, "Renamed bucket");
|
||||
fireEvent.click(screen.getByText("portal.connections.picker.save"));
|
||||
|
||||
await waitFor(() => expect(updateIntegration).toHaveBeenCalledTimes(1));
|
||||
expect(updateIntegration).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.objectContaining({
|
||||
name: "Renamed bucket",
|
||||
config: expect.objectContaining({ secretAccessKey: "********" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps save disabled until the required fields are present", () => {
|
||||
render(<S3ConnectionModal open onClose={vi.fn()} onSaved={vi.fn()} />);
|
||||
const save = () =>
|
||||
screen.getByText("portal.connections.picker.save").closest("button");
|
||||
|
||||
expect(save()).toBeDisabled();
|
||||
setField(/portal\.connections\.s3\.fields\.name/, "Only a name");
|
||||
expect(save()).toBeDisabled();
|
||||
setField(/portal\.sources\.types\.s3\.fields\.bucket\.label/, "inbox");
|
||||
setField(/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, "AKIA");
|
||||
setField(
|
||||
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
|
||||
"shh",
|
||||
);
|
||||
expect(save()).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Modal } from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
createIntegration,
|
||||
updateIntegration,
|
||||
type IntegrationConfig,
|
||||
} from "@portal/api/integrations";
|
||||
import {
|
||||
EMPTY_S3_CONNECTION,
|
||||
S3ConnectionForm,
|
||||
s3ConnectionFormValid,
|
||||
s3ConnectionRequestConfig,
|
||||
type S3ConnectionFormValues,
|
||||
} from "@portal/components/sources/S3ConnectionForm";
|
||||
|
||||
/**
|
||||
* The one place S3 connections are created and edited. Launched from the
|
||||
* Connections tab, the source builder's connection picker, and the pipeline
|
||||
* builder output - so connection setup is always a modal, never inline splat.
|
||||
* Saving validates backend-side (schema, SSRF, credentials); on edit the secret
|
||||
* arrives masked and round-trips unchanged to keep the stored value.
|
||||
*/
|
||||
interface S3ConnectionModalProps {
|
||||
open: boolean;
|
||||
/** When set, edit this connection; otherwise create a new one. */
|
||||
connection?: IntegrationConfig | null;
|
||||
onClose: () => void;
|
||||
/** The saved connection, so callers can select or refresh it. */
|
||||
onSaved: (connection: IntegrationConfig) => void;
|
||||
}
|
||||
|
||||
export function S3ConnectionModal({
|
||||
open,
|
||||
connection,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: S3ConnectionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<S3ConnectionFormValues>(EMPTY_S3_CONNECTION);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isEdit = Boolean(connection);
|
||||
|
||||
// Seed the form each time the modal opens (or its target changes).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (connection) {
|
||||
const config = connection.config ?? {};
|
||||
setForm({
|
||||
name: connection.name,
|
||||
bucket: String(config.bucket ?? ""),
|
||||
region: String(config.region ?? "us-east-1"),
|
||||
endpoint: String(config.endpoint ?? ""),
|
||||
accessKeyId: String(config.accessKeyId ?? ""),
|
||||
secretAccessKey: String(config.secretAccessKey ?? ""),
|
||||
});
|
||||
} else {
|
||||
setForm(EMPTY_S3_CONNECTION);
|
||||
}
|
||||
setError(null);
|
||||
}, [open, connection]);
|
||||
|
||||
async function save() {
|
||||
if (saving || !s3ConnectionFormValid(form)) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = connection
|
||||
? await updateIntegration(connection.id, {
|
||||
name: form.name.trim(),
|
||||
config: s3ConnectionRequestConfig(form),
|
||||
})
|
||||
: // TEAM scope suits the team-based portal (the backend defaults the team to the
|
||||
// caller's own). A teamless single-operator self-hosted deployment would need a
|
||||
// USER/SERVER scope choice here - follow-up if the portal ships there.
|
||||
await createIntegration({
|
||||
integrationType: "S3",
|
||||
name: form.name.trim(),
|
||||
scope: "TEAM",
|
||||
config: s3ConnectionRequestConfig(form),
|
||||
});
|
||||
onSaved(saved);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t(
|
||||
isEdit
|
||||
? "portal.connections.editTitle"
|
||||
: "portal.connections.createTitle",
|
||||
)}
|
||||
footer={
|
||||
<div className="portal-sources__connection-create-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("portal.connections.picker.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={saving}
|
||||
disabled={!s3ConnectionFormValid(form)}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("portal.connections.picker.save")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<S3ConnectionForm values={form} onChange={setForm} />
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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 { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const fetchS3Connections = vi.fn();
|
||||
const createIntegration = vi.fn();
|
||||
vi.mock("@portal/api/integrations", () => ({
|
||||
fetchS3Connections: () => fetchS3Connections(),
|
||||
createIntegration: (...a: unknown[]) => createIntegration(...a),
|
||||
updateIntegration: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("S3ConnectionPicker", () => {
|
||||
beforeEach(() => {
|
||||
fetchS3Connections.mockReset();
|
||||
fetchS3Connections.mockResolvedValue([]);
|
||||
createIntegration.mockReset();
|
||||
});
|
||||
|
||||
it("creates a connection inline and selects it", async () => {
|
||||
createIntegration.mockResolvedValue({ id: 7, name: "New bucket" });
|
||||
const onChange = vi.fn();
|
||||
render(<S3ConnectionPicker value="" onChange={onChange} />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByText("portal.connections.picker.createNew"),
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(/portal\.connections\.s3\.fields\.name/),
|
||||
{ target: { value: "New bucket" } },
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.bucket\.label/,
|
||||
),
|
||||
{ target: { value: "inbox" } },
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/,
|
||||
),
|
||||
{ target: { value: "AKIA" } },
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
|
||||
),
|
||||
{ target: { value: "shh" } },
|
||||
);
|
||||
fireEvent.click(screen.getByText("portal.connections.picker.save"));
|
||||
|
||||
await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1));
|
||||
// The newly created connection's id is selected in the parent.
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("7"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Select } from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
fetchS3Connections,
|
||||
type IntegrationConfig,
|
||||
} from "@portal/api/integrations";
|
||||
import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal";
|
||||
|
||||
/**
|
||||
* Selects a stored S3 connection by id. Creating a new one opens the shared
|
||||
* connection modal (saved immediately and validated backend-side), so the
|
||||
* parent only ever sees a real connection id.
|
||||
*/
|
||||
interface S3ConnectionPickerProps {
|
||||
value: string;
|
||||
onChange: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
export function S3ConnectionPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: S3ConnectionPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [connections, setConnections] = useState<IntegrationConfig[] | null>(
|
||||
null,
|
||||
);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
fetchS3Connections()
|
||||
.then((list) => {
|
||||
if (mounted) setConnections(list);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (mounted) setError(errorMessage(e));
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="portal-sources__connection-picker">
|
||||
<Select
|
||||
value={value || null}
|
||||
placeholder={t("portal.connections.picker.placeholder")}
|
||||
options={(connections ?? []).map((connection) => ({
|
||||
value: String(connection.id),
|
||||
label: connection.name,
|
||||
}))}
|
||||
onChange={(selected) => onChange(selected ?? "")}
|
||||
/>
|
||||
<Button variant="tertiary" size="sm" onClick={() => setModalOpen(true)}>
|
||||
{t("portal.connections.picker.createNew")}
|
||||
</Button>
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
<S3ConnectionModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={(created) => {
|
||||
setConnections((list) => [...(list ?? []), created]);
|
||||
onChange(String(created.id));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard";
|
||||
import { sampleDailySeries } from "@portal/mocks/sampleDailySeries";
|
||||
|
||||
const SAMPLE_SERIES = sampleDailySeries(330);
|
||||
|
||||
const IN_USE: SourceView = {
|
||||
id: "src-claims",
|
||||
name: "Claims intake",
|
||||
type: "folder",
|
||||
status: "active",
|
||||
referenceCount: 2,
|
||||
referencingPolicies: [
|
||||
{ id: "a", name: "Security Policy" },
|
||||
{ id: "b", name: "Redaction Policy" },
|
||||
],
|
||||
config: [
|
||||
{ label: "Directory", value: "/data/claims-intake" },
|
||||
{ label: "Mode", value: "consume" },
|
||||
],
|
||||
docsTotal: 45230,
|
||||
docs24h: 312,
|
||||
docs30d: 9870,
|
||||
};
|
||||
|
||||
const ORPHANED: SourceView = {
|
||||
id: "src-archive",
|
||||
name: "Archive reprocess",
|
||||
type: "folder",
|
||||
status: "unused",
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [{ label: "Directory", value: "/data/archive" }],
|
||||
docsTotal: 45230,
|
||||
docs24h: 312,
|
||||
docs30d: 9870,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SourceDetailCard> = {
|
||||
title: "Portal/Sources/SourceDetailCard",
|
||||
component: SourceDetailCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
docSeries: SAMPLE_SERIES,
|
||||
onClose: () => {},
|
||||
onEdit: () => {},
|
||||
onTogglePause: () => {},
|
||||
onDelete: () => {},
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "56rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SourceDetailCard>;
|
||||
|
||||
export const InUse: Story = { args: { source: IN_USE } };
|
||||
export const Orphaned: Story = { args: { source: ORPHANED } };
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Button } from "@app/ui";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
|
||||
import {
|
||||
EDITOR_SOURCE_TYPE,
|
||||
sourceTypeMeta,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import "@portal/views/Sources.css";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
interface SourceDetailCardProps {
|
||||
source: SourceView;
|
||||
docSeries: number[];
|
||||
onClose: () => void;
|
||||
onEdit: (source: SourceView) => void;
|
||||
onTogglePause: (source: SourceView) => void;
|
||||
onDelete: (source: SourceView) => void;
|
||||
/** Disables the actions while a mutation is in flight. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
/** Expanded detail for the selected source row, with edit/pause/delete actions. */
|
||||
export function SourceDetailCard({
|
||||
source,
|
||||
docSeries,
|
||||
onClose,
|
||||
onEdit,
|
||||
onTogglePause,
|
||||
onDelete,
|
||||
busy = false,
|
||||
}: SourceDetailCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const meta = sourceTypeMeta(source.type);
|
||||
const paused = source.status === "disabled";
|
||||
// The editor is a built-in source: it has no instance name and can't be edited/paused/deleted.
|
||||
const isEditor = source.type === EDITOR_SOURCE_TYPE;
|
||||
return (
|
||||
<section className="portal-sources__expanded">
|
||||
<header className="portal-sources__expanded-head">
|
||||
<span
|
||||
className={`portal-sources__type-dot portal-sources__type-dot--${meta.accent}`}
|
||||
aria-hidden
|
||||
>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="portal-sources__expanded-title">
|
||||
{isEditor ? t(meta.labelKey) : source.name}
|
||||
</h2>
|
||||
<span className="portal-sources__expanded-sub">
|
||||
{t("portal.sources.detail.subtitle", {
|
||||
type: t(meta.labelKey),
|
||||
status: t(`portal.sources.status.${source.status}`),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
className="portal-sources__expanded-close"
|
||||
onClick={onClose}
|
||||
aria-label={t("portal.sources.detail.closeAriaLabel")}
|
||||
>
|
||||
<CloseIcon />
|
||||
</ActionIcon>
|
||||
</header>
|
||||
|
||||
<SourceDetailPanel source={source} docSeries={docSeries} />
|
||||
|
||||
{!isEditor && (
|
||||
<div className="portal-sources__detail-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => onEdit(source)}
|
||||
>
|
||||
{t("portal.sources.detail.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => onTogglePause(source)}
|
||||
>
|
||||
{paused
|
||||
? t("portal.sources.detail.resume")
|
||||
: t("portal.sources.detail.pause")}
|
||||
</Button>
|
||||
<Button
|
||||
accent="danger"
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(source)}
|
||||
>
|
||||
{t("portal.sources.detail.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
|
||||
import { sampleDailySeries } from "@portal/mocks/sampleDailySeries";
|
||||
|
||||
const SAMPLE_SERIES = sampleDailySeries(330);
|
||||
|
||||
const IN_USE: SourceView = {
|
||||
id: "src-claims",
|
||||
name: "Claims intake",
|
||||
type: "folder",
|
||||
status: "active",
|
||||
referenceCount: 2,
|
||||
referencingPolicies: [
|
||||
{ id: "a", name: "Security Policy" },
|
||||
{ id: "b", name: "Redaction Policy" },
|
||||
],
|
||||
config: [
|
||||
{ label: "Directory", value: "/data/claims-intake" },
|
||||
{ label: "Mode", value: "consume" },
|
||||
],
|
||||
docsTotal: 45230,
|
||||
docs24h: 312,
|
||||
docs30d: 9870,
|
||||
};
|
||||
|
||||
const ORPHANED: SourceView = {
|
||||
id: "src-archive",
|
||||
name: "Archive reprocess",
|
||||
type: "folder",
|
||||
status: "unused",
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [{ label: "Directory", value: "/data/archive" }],
|
||||
docsTotal: 1180,
|
||||
docs24h: 0,
|
||||
docs30d: 0,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SourceDetailPanel> = {
|
||||
title: "Portal/Sources/SourceDetailPanel",
|
||||
component: SourceDetailPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "48rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SourceDetailPanel>;
|
||||
|
||||
export const InUse: Story = {
|
||||
args: { source: IN_USE, docSeries: SAMPLE_SERIES },
|
||||
};
|
||||
/** A source no policy references is called out as safe to delete. */
|
||||
export const Orphaned: Story = { args: { source: ORPHANED, docSeries: [] } };
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip, StatTile } from "@app/ui";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { Sparkline } from "@portal/components/sources/Sparkline";
|
||||
import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
interface SourceDetailPanelProps {
|
||||
source: SourceView;
|
||||
/** The 30-day daily series for the sparkline, fetched per source when expanded. */
|
||||
docSeries: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded detail for a source row: its config (key/value), the documents it has
|
||||
* fed into runs, and which policies reference it (a 0-reference source is called
|
||||
* out as safe to delete).
|
||||
*/
|
||||
export function SourceDetailPanel({
|
||||
source,
|
||||
docSeries,
|
||||
}: SourceDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEditor = source.type === EDITOR_SOURCE_TYPE;
|
||||
return (
|
||||
<div className="portal-sources__detail">
|
||||
{isEditor && (
|
||||
<p className="portal-sources__muted">
|
||||
{t("portal.sources.types.editor.description")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{source.config.length > 0 && (
|
||||
<div className="portal-sources__stat-grid">
|
||||
{source.config.map((row) => (
|
||||
<StatTile key={row.label} label={row.label} value={row.value} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="portal-sources__detail-section">
|
||||
<span className="portal-sources__detail-heading">
|
||||
{t("portal.sources.detail.usedBy")}
|
||||
</span>
|
||||
{source.referencingPolicies.length === 0 ? (
|
||||
<p className="portal-sources__muted">
|
||||
{t(
|
||||
isEditor
|
||||
? "portal.sources.types.editor.noPolicies"
|
||||
: "portal.sources.detail.notReferenced",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="portal-sources__chips">
|
||||
{source.referencingPolicies.map((policy) => (
|
||||
<Chip key={policy.id} size="sm">
|
||||
{policy.name}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="portal-sources__detail-section">
|
||||
<span className="portal-sources__detail-heading">
|
||||
{t("portal.sources.detail.documents")}
|
||||
</span>
|
||||
<div className="portal-sources__stat-grid">
|
||||
<StatTile
|
||||
label={t("portal.sources.detail.docsTotal")}
|
||||
value={source.docsTotal.toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.sources.detail.docs24h")}
|
||||
value={source.docs24h.toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.sources.detail.docs30d")}
|
||||
value={source.docs30d.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
{docSeries.length > 0 && (
|
||||
<Sparkline
|
||||
data={docSeries}
|
||||
ariaLabel={t("portal.sources.detail.docsTrend")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user