From eded87fee75338a6486e6cb17fde72418df277e2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCveyda?=
Date: Mon, 12 Oct 2026 21:28:11 +0200
Subject: [PATCH 1/5] Central management for otter
---
.env.example | 4 +
AGENTS.md | 42 +-
README.md | 59 +-
docker-compose.yml | 2 +
otserver-otter | 2 +-
otter_management.md | 355 ++++
package.json | 2 +
pnpm-lock.yaml | 3 +
src/access/authorization.ts | 74 +-
src/app/(payload)/admin/importMap.js | 12 +
src/app/(payload)/custom.scss | 82 +
src/application/queuedImports.ts | 94 +-
src/collections/AssetImports.ts | 130 +-
src/collections/AuditLogs.ts | 18 +-
src/collections/Otter.ts | 700 ++++++++
src/collections/Sites.ts | 16 +-
src/collections/UserRoles.ts | 10 +
src/collections/Users/index.ts | 3 +-
src/components/BeforeDashboard/index.tsx | 8 +-
.../OtterCredentialsField/index.tsx | 307 ++++
src/components/OtterEnrollment/index.scss | 22 +
src/components/OtterEnrollment/index.tsx | 194 +++
src/components/OtterInterfaceField/index.tsx | 120 ++
.../OtterManagedSettingsField/index.tsx | 80 +
src/components/OtterManagementView/index.scss | 138 ++
src/components/OtterManagementView/index.tsx | 8 +
src/components/OtterManagementView/view.tsx | 827 +++++++++
src/components/OtterNavLink/index.tsx | 27 +
src/components/OtterScannerActions/index.tsx | 117 ++
src/components/TopologyNavLink/index.scss | 3 +-
src/jobs/worker.ts | 28 +-
.../20261010_164200_otter_indexes.ts | 43 +
src/migrations/index.ts | 6 +
src/otter/controller.ts | 1172 +++++++++++++
src/otter/scheduling.ts | 83 +
src/otter/security.ts | 315 ++++
src/payload-types.ts | 355 +++-
src/payload.config.ts | 11 +
tests/int/asset-import-hooks.int.spec.ts | 4 +-
tests/int/components.int.spec.ts | 748 +++++++-
tests/int/coverage.int.spec.ts | 4 +
tests/int/jobs-runtime.int.spec.ts | 33 +
tests/int/migrations.int.spec.ts | 40 +
tests/int/otter-client.int.spec.ts | 340 ++++
tests/int/otter-management.int.spec.ts | 1510 +++++++++++++++++
tests/int/otter-security.int.spec.ts | 866 ++++++++++
tests/int/queued-import-runtime.int.spec.ts | 23 +-
tests/int/topology-view.int.spec.ts | 1 +
48 files changed, 8963 insertions(+), 78 deletions(-)
create mode 100644 otter_management.md
create mode 100644 src/collections/Otter.ts
create mode 100644 src/components/OtterCredentialsField/index.tsx
create mode 100644 src/components/OtterEnrollment/index.scss
create mode 100644 src/components/OtterEnrollment/index.tsx
create mode 100644 src/components/OtterInterfaceField/index.tsx
create mode 100644 src/components/OtterManagedSettingsField/index.tsx
create mode 100644 src/components/OtterManagementView/index.scss
create mode 100644 src/components/OtterManagementView/index.tsx
create mode 100644 src/components/OtterManagementView/view.tsx
create mode 100644 src/components/OtterNavLink/index.tsx
create mode 100644 src/components/OtterScannerActions/index.tsx
create mode 100644 src/migrations/20261010_164200_otter_indexes.ts
create mode 100644 src/otter/controller.ts
create mode 100644 src/otter/scheduling.ts
create mode 100644 src/otter/security.ts
create mode 100644 tests/int/otter-client.int.spec.ts
create mode 100644 tests/int/otter-management.int.spec.ts
create mode 100644 tests/int/otter-security.int.spec.ts
diff --git a/.env.example b/.env.example
index 109b14c..2405774 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,9 @@
DATABASE_URL=mongodb://127.0.0.1:27017/otserver?replicaSet=rs0&directConnection=true
OTSERVER_SECRET=replace-with-a-long-random-secret
+# Required for managed Otter jobs; use HTTPS remotely or http://localhost:3000 for same-host use.
+OTSERVER_PUBLIC_URL=https://otserver.example
+# Generate with `openssl rand -hex 32`. Keep stable and back it up separately from OTSERVER_SECRET.
+OTSERVER_OTTER_ENCRYPTION_KEY=replace-with-64-hex-characters
# OTSERVER_VULNERABILITY_FEEDS=off
# OTSERVER_QUEUED_IMPORTS=on
# Set to external only when dedicated worker processes are deployed.
diff --git a/AGENTS.md b/AGENTS.md
index 9bc0026..3e7c9e4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,6 +34,8 @@ Main locations:
- `src/components/`: custom Payload admin views and fields.
- `src/domain/`, `src/application/`, `src/integrations/`: pure rules, use cases, and Payload/MongoDB boundaries.
- `src/jobs/`, `src/worker.ts`: fenced import and maintenance queues using Payload jobs.
+- `src/otter/`: site-scoped Otter enrollment, machine authentication, encrypted profiles, schedules, and controller API.
+- `otter_management.md`: management protocol and scanner-side Otter implementation checklist.
- `otserver-otter/contracts/otserver-scan-v2.schema.json`: pinned canonical Otter wire contract.
- `tests/int/`: application and importer integration tests.
@@ -103,6 +105,38 @@ The scanner-source fallback is low quality. User-entered import overrides are hu
access to both sites; import site selection never silently moves an existing physical asset.
- Identity relationships must belong to the same site. Graph/identifier changes lock the affected
assets (including ancestry) to prevent snapshot-isolation races. Preserve history site scope.
+- Otter Config is an additional role capability, not a substitute for site permissions. Scanner,
+ profile, schedule, run, and managed import data stay bound to one exact site. Scanner credentials
+ are machine principals restricted to `/api/otter/v1` and their own `/api/asset-imports` records;
+ never give them generic Payload or descendant-site access.
+- Otter enrollment tokens are single-use, short-lived digests; machine credentials are hashed,
+ expiring, and revocable. SNMP/OPC UA credentials are encrypted with
+ `OTSERVER_OTTER_ENCRYPTION_KEY` and must never be returned to ordinary profile reads or logged.
+- Otter enrollment and polling inventory reports include the computer name and at most 32 adapters,
+ each with technical/friendly names, description, MAC, and at most 16 IPv4/IPv6 addresses. Validate
+ reports and keep them bound to the exact scanner; never include credentials in adapter inventory.
+- Manage enrollment from the Otter Scanners collection popup. All scan options and human import
+ overrides belong to Otter Configurations (`otter-profiles`); manual runs select a configuration
+ explicitly and schedules reference one. Snapshot scan options, encrypted credentials, and import
+ overrides together for every run. Never layer scanner-level network overrides onto configurations.
+ Generic scanner writes may change only the name; controller-owned inventory and credentials remain
+ protected. Internal controller updates use the `otterScannerUpdate` request context.
+ Manual configuration runs require a Yes/No authorization confirmation. Schedules derive their
+ scanner and exact site from the selected configuration on save, ignoring supplied scope fields.
+ New configurations store an encrypted SNMP v2c `public` community by default; later credential
+ replacement or clearing must survive configuration edits.
+- Managed import display names include Otter, scanner name, site path/name, and a UTC timestamp.
+ Scanner-supplied overrides are discarded in favor of the server-owned run snapshot.
+- Otter Runs expose terminal failure reasons in `message`. Lease expiry records the missed-heartbeat
+ reason, and older clients reporting failure without a message get an explicit missing-details
+ fallback. Scanner-generated reasons must remain credential-safe and survive finish retries.
+- Otter controller URLs require HTTPS except for HTTP on `localhost`, `127.0.0.1`, or `[::1]`,
+ regardless of `NODE_ENV`. Browser management accepts loopback aliases only on the same scheme and
+ port as an allowed origin; retain origin checks for external requests.
+- Create due Otter schedule occurrences and claim runs transactionally during scanner polling.
+ Preserve the schedule-occurrence partial unique index, one active assignment per scanner, lease
+ checks, and latest-only offline catch-up. Scanner-side changes belong in `otserver-otter` and must
+ coordinate with `otter_management.md`.
### Auditability
@@ -115,6 +149,8 @@ The scanner-source fallback is low quality. User-entered import overrides are hu
asset moves. Merged/split views may resolve aliases and endpoint evidence without rewriting logs.
- New collection mutations are covered by `withAudit`; custom actions outside collection hooks must
call `writeAudit` explicitly with the original request and target context.
+- Treat scanner operations as the explicit `otter` audit actor and redact credential/community fields
+ when extending enrollment or controller configuration.
- Never bypass auditing merely to simplify bulk operations or imports.
### Vulnerability matching
@@ -251,7 +287,11 @@ application.
Vitest runs integration files serially because they share database state. New behavior affecting
authorization, imports, quality precedence, audit logs, parsing, or bulk operations needs a focused
integration test. Otter implementation and protocol tests belong in the `otserver-otter`
-repository.
+repository. Run `pnpm test:otter-client` against the test replica set to build the headless executable
+and verify real-client REST enrollment, import, and credential reuse after restart. To test a specific
+build, use `OTTER_CLIENT_BIN=otserver-otter/target/debug/otserver-otter pnpm test:int
+tests/int/otter-client.int.spec.ts`. Local-file CLI/GUI operation must
+remain independent of the managed `controller` command and its private state directory.
The development server permits HMR only from `192.168.50.*` in `next.config.ts`. Keep production
origin policy separate from this home-network development exception.
diff --git a/README.md b/README.md
index fc159c4..d4c616a 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,8 @@ field merging, flexible hierarchies, role-based access, search, and a complete a
protected Admin role retains unrestricted access.
- **Discovery imports** — Ingest Siemens PRONETA XML, Nmap XML, and OTserver Otter JSON into a
selected site.
+- **Otter management** — Enroll site-bound scanners, configure encrypted scan profiles, schedule
+ jobs, and inspect run history from the management interface.
- **Physical device identity** — Correlate qualified hardware identities across multiple network
interfaces; retain site-scoped MAC bindings, module installations, and replacement history.
- **Evidence-aware merging** — Higher-quality discoveries can improve lower-quality data while
@@ -76,6 +78,8 @@ services:
environment:
DATABASE_URL: mongodb://mongo:27017/otserver?replicaSet=rs0
OTSERVER_SECRET: ${OTSERVER_SECRET:?Set OTSERVER_SECRET in .env}
+ OTSERVER_PUBLIC_URL: ${OTSERVER_PUBLIC_URL:-}
+ OTSERVER_OTTER_ENCRYPTION_KEY: ${OTSERVER_OTTER_ENCRYPTION_KEY:-}
OTSERVER_QUEUED_IMPORTS: ${OTSERVER_QUEUED_IMPORTS:-off}
OTSERVER_VULNERABILITY_FEEDS: ${OTSERVER_VULNERABILITY_FEEDS:-on}
OTSERVER_WORKER_MODE: ${OTSERVER_WORKER_MODE:-standalone}
@@ -142,6 +146,10 @@ Create a `.env` file beside it with a long random secret, and keep that secret a
```dotenv
OTSERVER_SECRET=replace-with-a-long-random-secret
+# Required for managed Otter jobs and encrypted SNMP/OPC UA profile credentials.
+OTSERVER_PUBLIC_URL=https://otserver.example
+# Generate with: openssl rand -hex 32. Keep this key stable and backed up.
+OTSERVER_OTTER_ENCRYPTION_KEY=replace-with-64-hex-characters
```
Pull the images and start the application and MongoDB:
@@ -189,6 +197,10 @@ pnpm dev
Then open . The first account receives the protected Admin role.
+For local Otter management, set `OTSERVER_PUBLIC_URL=http://localhost:3000` in `.env` and restart
+OTserver. HTTP works on `localhost`, `127.0.0.1`, and `[::1]`, including production-mode builds;
+Otter must run on the same host when using these loopback URLs. Remote controllers require HTTPS.
+
The repository's `docker-compose.yml` is the production-style standalone deployment. Run
`docker compose up -d mongo` when only a local development database is needed, or
`docker compose up -d --build` to test the complete containerized application.
@@ -317,10 +329,33 @@ Only scan networks you own or are authorized to assess. Otter does not perform c
writes, SNMP SET, DCP Set, DNP3 writes, operates, class assignment, freezes, or restarts, brute
force, exploits, vulnerability scripts, or Modbus requests.
-Users can enable a Payload API key on their account. With an OTserver URL, site ID, and that key in
-the Otter environment or executable-adjacent `otter.json`, Otter can send its completed
-JSON directly to the existing REST importer with the user's current site permissions. The local
-scan file is retained.
+For standalone CLI uploads, users can enable a Payload API key on their account. With an OTserver
+URL, site ID, and that key in the Otter environment or executable-adjacent `otter.json`, Otter can
+send completed JSON to the existing REST importer using the user's current site permissions. The
+local scan file is retained. Managed scanner uploads use site-bound Otter credentials instead;
+see [Otter management](otter_management.md).
+
+### Central Otter management
+
+OTserver provides site-scoped Payload collections for Otter registrations, scan profiles,
+encrypted SNMP/OPC UA credentials, schedules, manual runs, and run history. Enrolled agents report
+their computer name and network adapters; the Payload profile editor uses that inventory for interface
+selection and exposes all managed scan options. Grant the **Otter Config**
+capability to a role; users still need the corresponding site read/write permissions. Enrollment
+tokens are single-use, and scanner credentials are restricted to that scanner's bound site.
+Open **Otter → Otter Scanners** and select **Create New** for the enrollment popup. Use
+**Otter → Otter Configurations** to select the scanner and site, set all scan options, configure
+import overrides, and queue a scan with **Run now**. Schedules reference these same configurations.
+Each run snapshots its options, credentials, and import overrides. Managed import names include
+the scanner name, site, and UTC date/time. Existing scanner-level settings are no longer applied;
+copy any previously used network overrides into the appropriate configuration before running it.
+
+Use Otter's `controller --server-url URL --enroll --ack-authorized` command and paste the token
+from the enrollment popup at the prompt. For same-host development, `--server-url 127.0.0.1:3000`
+uses HTTP; remote controllers require HTTPS. Restart without `--enroll` to reuse the saved
+credential. Setup and the management protocol are documented in [`otter_management.md`](otter_management.md). Set
+`OTSERVER_PUBLIC_URL` and a stable `OTSERVER_OTTER_ENCRYPTION_KEY` before configuring managed
+profiles. Apply the Otter management migration during the normal versioned upgrade procedure.
OTserver Otter, its platform guide, contract, tests, and interoperability lab are maintained in the
separate [`otserver-otter` repository](https://github.com/ruveydac/otserver-otter).
@@ -334,8 +369,8 @@ separate [`otserver-otter` repository](https://github.com/ruveydac/otserver-otte
- All registered collections are audited for creates, updates, deletes, and authentication events.
- Audit entries are immutable and redact fields resembling passwords, secrets, tokens, hashes, or
sessions.
-- Otter SNMP and OPC UA settings, including credentials, live in executable-adjacent `otter.json`
- and are never included in logs or scan exports.
+- Standalone Otter SNMP and OPC UA credentials remain in executable-adjacent `otter.json`. Managed
+ profile credentials are encrypted at rest. Neither form is included in logs or scan exports.
## Technology
@@ -357,6 +392,7 @@ src/importers/ PRONETA, Nmap, Otter parsers, and quality merging
src/search/ Lucene query translation and graphical-filter integration
src/vulnerabilities/ Feed synchronization, CPE parsing, and passive matching
src/components/ OTserver admin views, branding, and fields
+src/otter/ Site-bound scanner enrollment, controller API, encrypted profiles, and schedules
otserver-otter/ Pinned scanner repository and canonical export contract
tests/int/ Application and importer integration tests
```
@@ -379,6 +415,17 @@ pnpm test:container
Set `CONTAINER_RUNTIME=docker` or `CONTAINER_RUNTIME=podman` to select one explicitly.
+Verify controller interoperability with the real Rust executable and Payload REST API against a
+disposable MongoDB replica-set database (requires the Rust toolchain and initialized Otter submodule):
+
+```bash
+TEST_DATABASE_URL='mongodb://127.0.0.1:27018/otserver-test?replicaSet=rs0&directConnection=true' \
+ pnpm test:otter-client
+```
+
+This builds the headless client and tests enrollment using bare loopback and explicit HTTP URLs,
+assignment execution, schema-v2 upload/import, and restart using saved credentials.
+
Coverage is enforced at 90% for the application:
```bash
diff --git a/docker-compose.yml b/docker-compose.yml
index 7ae4535..560bcc9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,6 +11,8 @@ services:
environment:
DATABASE_URL: mongodb://mongo:27017/otserver?replicaSet=rs0
OTSERVER_SECRET: ${OTSERVER_SECRET:?Set OTSERVER_SECRET in .env}
+ OTSERVER_PUBLIC_URL: ${OTSERVER_PUBLIC_URL:-}
+ OTSERVER_OTTER_ENCRYPTION_KEY: ${OTSERVER_OTTER_ENCRYPTION_KEY:-}
OTSERVER_QUEUED_IMPORTS: ${OTSERVER_QUEUED_IMPORTS:-off}
OTSERVER_VULNERABILITY_FEEDS: ${OTSERVER_VULNERABILITY_FEEDS:-on}
OTSERVER_WORKER_MODE: ${OTSERVER_WORKER_MODE:-standalone}
diff --git a/otserver-otter b/otserver-otter
index 2607b4a..d64f6bf 160000
--- a/otserver-otter
+++ b/otserver-otter
@@ -1 +1 @@
-Subproject commit 2607b4ad17f2efb132b60b00061b487161971172
+Subproject commit d64f6bf31b9fa68a8789a6aeaeb974a55b2efdf5
diff --git a/otter_management.md b/otter_management.md
new file mode 100644
index 0000000..3f29bdf
--- /dev/null
+++ b/otter_management.md
@@ -0,0 +1,355 @@
+# OTserver Otter management
+
+OTserver now owns site-scoped scanner registrations, profiles, encrypted protocol credentials,
+schedules, scan assignments, and run history. Scanner communication is outbound polling over HTTPS;
+no inbound listener is required on an Otter host.
+
+The Otter executable implements this protocol through the separate `controller` command. Its existing
+local `otter.json`, CLI/GUI scans, and user API-key upload flow continue to work independently. Managed
+mode enrolls, polls, executes assigned snapshots, and uploads with a machine credential. The scan
+result remains the existing `otserver-scan` schema-version-2 document. Scanner setup, private state
+storage, recovery, and verification commands are documented in `otserver-otter/README.md`.
+
+## Operator setup
+
+Set these values on every OTserver web process that manages or serves Otter requests:
+
+```dotenv
+OTSERVER_PUBLIC_URL=https://otserver.example
+OTSERVER_OTTER_ENCRYPTION_KEY=64-hexadecimal-characters-from-openssl-rand-hex-32
+```
+
+Generate the encryption key with `openssl rand -hex 32`. Keep it separate from `OTSERVER_SECRET`,
+back it up securely, and use the same value on all web replicas. Losing it makes stored SNMP and OPC
+UA credentials unavailable. Changing it requires decrypting and re-encrypting those credentials and
+active run snapshots before replacing the old key. Keep `OTSERVER_PUBLIC_URL` set to the externally
+reachable HTTPS base URL; jobs use it to tell Otter where to upload scan results. Same-host setups
+may use `OTSERVER_PUBLIC_URL=http://localhost:3000` (also `127.0.0.1` or `[::1]`), including
+production-mode builds. Restart OTserver after changing `.env`. Browser management requests accept
+these loopback aliases on the same scheme and port; external cross-origin requests remain rejected.
+
+For a manager on the same host, Otter also accepts `--server-url 127.0.0.1:3000` (equivalent to
+`http://127.0.0.1:3000`). With `--enroll`, paste the token from **Manage Otters** at the prompt and
+press Enter. Use the same controller URL as `OTSERVER_PUBLIC_URL`; subsequent starts omit `--enroll`.
+
+The controller endpoints are registered with Payload under `/api/otter/v1`. Apply the versioned
+database migrations as part of normal production upgrades; they establish partial unique indexes for
+scanner credentials and scheduled occurrence deduplication.
+
+## Roles and site scope
+
+Enable **Otter Config** on a user role. Admin has this capability implicitly. The capability does
+not grant inventory or site permissions by itself:
+
+- Reading scanner management data requires Otter Config and read permission for the scanner's site.
+- Creating enrollment tokens, editing profiles, scheduling runs, starting a manual run, revoking, and
+ re-enrolling require Otter Config and write permission for that exact site.
+- Existing site permissions continue to inherit through descendants.
+- Each scanner is bound to one mandatory site. Its machine credential can only create/read its own
+ Otter imports for that exact site; it does not inherit descendant access.
+- Moving a scanner is not an edit. Revoke/re-enroll it for the new site so historical runs/imports
+ keep their original site scope.
+
+Open **Otter → Otter Scanners** and select **Create New**. The enrollment popup provides the
+download link, creates a site-bound scanner, and shows its one-time token and enrollment commands.
+Use **Otter → Otter Configurations** to select the scanner and site, edit network and protocol options,
+configure import overrides, and queue the first scan with **Run now**. Save the configuration before
+running. Schedules select a configuration bound to that same scanner and exact site. Scanner documents
+manage enrollment and inventory only. Legacy scanner network overrides are no longer applied; copy
+them into the appropriate configuration before running it. The old `/admin/otters` URL redirects to
+the scanner list. The API collection slug remains `otter-profiles`.
+
+Otter binds its private state directory to the enrolled controller URL. To reuse that directory for
+a different controller, generate a fresh enrollment token and start with `--enroll --overwrite-state-dir`.
+The scanner replaces the saved enrollment and run state only after successful enrollment, retaining
+existing local scan exports. Subsequent starts omit both flags.
+Enrollment and polling report the computer name and network adapter inventory (technical and friendly
+names, MAC, and IP addresses). OTserver stores this with the scanner and refreshes it while the
+controller is running. The **Otter** Payload collections expose scanner details and the native profile
+editor, including selectable adapters, all scan options, encrypted credentials, schedules, and run
+history. Reports are bounded to 32 adapters and 16 IP addresses per adapter. Enrollment credentials
+are single-use and only shown in the response that creates or re-enrolls a scanner. Copy the token at
+that time.
+
+## Enrollment and credentials
+
+1. A permitted operator creates a named scanner at a writable site using **Otter Scanners → Create New**.
+2. OTserver creates a 256-bit random enrollment token. Only its SHA-256 digest is stored; it expires
+ after 15 minutes.
+3. On the Otter host, run the enrollment command shown by the management page and paste the token
+ when prompted. Otter redeems it once with `POST /api/otter/v1/enroll` over HTTPS.
+4. OTserver consumes the token transactionally and returns a distinct 256-bit scanner credential,
+ valid for one year. Only the credential digest is stored.
+5. Otter authenticates with `Authorization: Otter `.
+
+The machine strategy is accepted only on `/api/otter/v1/*` and `/api/asset-imports*`. It does not
+authenticate the scanner to general Payload REST, GraphQL, or the admin UI. Each request checks that
+the credential remains enabled and unexpired. Revoke takes effect immediately. Re-enrollment revokes
+the old credential and issues a new single-use token; it also terminates an active assignment.
+
+The scanner can rotate its credential with `POST /api/otter/v1/credentials/rotate`. The returned
+credential is only shown once, and the previous one stops working immediately. It must be saved
+durably by Otter before the old token is discarded. Operators can always recover a lost local
+credential by re-enrolling the scanner.
+
+Use a reverse proxy/WAF to rate-limit `/api/otter/v1/enroll`, cap request sizes, and require HTTPS.
+Enrollment tokens have 256 bits of entropy and short expiry, but rate-limiting still protects the
+public endpoint from avoidable traffic. Do not log `Authorization` headers, enrollment tokens,
+credential responses, configuration responses, or request bodies containing credentials.
+
+## Profiles and secrets
+
+Profiles are named, site/scanner-bound scan settings. The Payload profile editor exposes these
+non-secret `ScannerConfig` options as native fields:
+
+```json
+{
+ "targets": ["192.168.1.0/24"],
+ "interface": "eth0",
+ "sourceMac": "00:11:22:33:44:55",
+ "noProtocols": false,
+ "allowDcpSource": false,
+ "noArp": false,
+ "noProfinet": false,
+ "noS7": false,
+ "noEnip": false,
+ "noBacnet": false,
+ "noFins": false,
+ "noFox": false,
+ "noDnp3": false,
+ "noIec61850": false,
+ "noNetbios": false,
+ "noSmb": false,
+ "noRpc": false,
+ "noOpcua": false,
+ "opcuaPorts": [4840, 4841, 48400],
+ "noSnmp": false,
+ "noLldp": false
+}
+```
+
+`targets` must be IPv4 addresses or CIDRs; unknown options, non-IPv4 targets, excessive target
+lists, and invalid ports are rejected. `noProtocols` disables native IP protocols (including SMB
+and RPC), but does not disable ARP, PROFINET, SNMP, or LLDP; those have separate switches. The
+interface selector displays each reported adapter's friendly name, MAC, and addresses and stores its
+technical name. `sourceMac` is optional; Otter refreshes the selected adapter's actual MAC at scan
+start. The controller supplies the scanner's fixed site, OTserver URL, profile name, and upload
+destination. Profiles cannot specify `site`, `serverUrl`, `apiKey`, or an arbitrary local output path.
+
+Enter SNMP/OPC UA secrets through the profile's **SNMP and OPC UA credentials** controls. They are
+validated and encrypted at rest with AES-256-GCM using `OTSERVER_OTTER_ENCRYPTION_KEY`. The regular
+profile API, edit form, and reads never return credential values or ciphertext. The GUI exposes SNMP
+version/community/user/context/authentication/privacy settings, OPC UA credential entries, and the
+single-credential fallback. An operator can replace or clear the credential set; the form does not
+preload it. Audit entries redact both credential settings and key/token-shaped field names.
+
+Configurations also expose **Apply to imported assets** and **Custom field overrides**, using the same
+fields and validation as manual imports. These are human-quality values in the shared import merger.
+When a run is queued, OTserver snapshots the validated profile, encrypted credentials, and import overrides. Later
+profile changes affect future runs only. The assigned scanner receives the snapshot over its
+authenticated HTTPS polling connection. Store neither the returned credentials nor scan results in
+logs.
+
+## Schedules and run lifecycle
+
+Schedules are owned by the management server. Supported types are:
+
+- **Run once** at a selected timestamp.
+- **Interval** from 5 minutes through 7 days.
+- **Calendar schedule** with a five-field cron expression and IANA time zone, for example
+ `0 2 * * 1-5` in `Europe/Berlin`.
+
+Timestamps are stored in UTC. Croner handles time-zone rules and DST; nonexistent local times are
+skipped and repeated times run once. When an Otter is offline, each due schedule creates at most its
+latest missed occurrence. Older missed occurrences are coalesced rather than sent in a burst.
+
+Every run has an immutable configuration snapshot and progresses through `queued`, `assigned`,
+`running`, and a terminal state (`completed`, `partial`, `failed`, `cancelled`, or `expired`). Only
+one assigned/running run is active per scanner. Polling returns the existing assignment again until
+it is finished, which lets a scanner recover a lost poll response. Assigned and running jobs use a
+two-minute lease. If the lease expires, OTserver records `expired`; it does not silently repeat an
+uncertain scan. Human cancellation is immediate while queued and becomes `cancel-requested` after
+assignment; Otter observes it on its next poll/heartbeat.
+
+The dispatcher calculates due schedules during polling. It does not run scans on OTserver and does
+not need a separate scheduled worker process. MongoDB transactions plus partial unique indexes
+prevent duplicate schedule occurrences and ensure concurrent polls do not create duplicate runs.
+
+## Controller API v1
+
+All request/response JSON is UTF-8. Responses carrying tokens or credentials use
+`Cache-Control: no-store`. Scanner calls require the `Otter` authorization scheme; browser
+management mutations require the logged-in user, same-origin `Origin`, Otter Config, and site write
+access.
+
+### Enroll
+
+```http
+POST /api/otter/v1/enroll
+Content-Type: application/json
+
+{
+ "token":"",
+ "version":"0.9.0",
+ "capabilities":["scan-v2","controller-v1"],
+ "interfaces":["eth0"],
+ "computerName":"otter-host",
+ "networkAdapters":[{
+ "name":"eth0",
+ "friendlyName":"Ethernet",
+ "description":"Ethernet adapter",
+ "macAddress":"00:11:22:33:44:55",
+ "ipAddresses":["192.168.1.20"]
+ }]
+}
+```
+
+Success returns `{ "credential": "...", "credentialExpiresAt": "...", "scanner": { "id":
+"...", "name": "...", "site": "..." } }`. Invalid, expired, or consumed tokens return an
+authentication error.
+
+### Poll
+
+```http
+POST /api/otter/v1/poll
+Authorization: Otter
+Content-Type: application/json
+
+{
+ "version":"0.9.0",
+ "capabilities":["scan-v2","controller-v1"],
+ "interfaces":["eth0"],
+ "computerName":"otter-host",
+ "networkAdapters":[{
+ "name":"eth0",
+ "friendlyName":"Ethernet",
+ "description":"Ethernet adapter",
+ "macAddress":"00:11:22:33:44:55",
+ "ipAddresses":["192.168.1.20"]
+ }]
+}
+```
+
+Idle scanners receive `{ "action": "idle", "retryAfterSeconds": 30 }`. A job response has this
+shape:
+
+```json
+{
+ "action": "run",
+ "run": {
+ "id": "RUN_ID",
+ "configuration": {
+ "name": "Line A",
+ "targets": ["192.168.1.0/24"],
+ "serverUrl": "https://otserver.example",
+ "site": "SITE_ID"
+ },
+ "credentials": { "snmp": [{ "version": "3", "username": "inventory" }] },
+ "uploadPath": "/api/asset-imports",
+ "leaseExpiresAt": "2026-10-10T12:02:00.000Z"
+ }
+}
+```
+
+The credential header is used for the import upload too. OTserver injects the site and
+`otserver-otter` source on scanner uploads; supplied conflicting sites and non-Otter sources are
+rejected. A cancellation response is `{ "action": "cancel", "runID": "RUN_ID" }`.
+
+### Run actions
+
+```text
+POST /api/otter/v1/runs/:id/start
+POST /api/otter/v1/runs/:id/heartbeat {"progress": 50}
+POST /api/otter/v1/runs/:id/finish {"status":"completed","importID":"..."}
+```
+
+Heartbeats extend the lease. A heartbeat returns `cancelRequested`; the scanner should stop safely
+and finish with `cancelled` when cancellation is acknowledged. Finish accepts `completed`, `partial`,
+`failed`, or `cancelled`, an optional bounded diagnostic message, and an optional import ID. An import
+ID must be an upload by this scanner associated with this active run. Scan completion and import
+processing are separate outcomes: a queued import can still be pending when the scan run finishes.
+
+Failed runs include a credential-safe `message` explaining setup/discovery failures or an interrupted
+scan. Otter persists this reason before finishing so retries and restarts retain it. Arbitrary protocol
+errors and credential values are not included. The native **Otter Runs** list and document show the
+message. Lease expiry records a missed-heartbeat reason; older clients that omit failure details get
+an explicit missing-diagnostics message.
+
+The import API remains Payload's existing multipart endpoint. The scanner sends `_payload` metadata
+with its run association and the scan JSON as `file`, plus
+`Authorization: Otter `. Example metadata:
+
+```json
+{
+ "source": "otserver-otter",
+ "sourceVersion": "0.9.0",
+ "otterRun": "RUN_ID",
+ "executionMode": "queued"
+}
+```
+
+The manager pins `site` and `source`, discards scanner-supplied human overrides, applies the run's
+server-owned import override snapshot, and names imports `Otter — scanner name — site — UTC datetime`.
+It preserves normal
+parser validation, identity reconciliation, quality merging, auditing, upload limits, and the
+2,000-record transaction cap. Exact retries use the existing import digest/idempotency logic. Queued
+scanner uploads require `OTSERVER_QUEUED_IMPORTS=on`; workers reload the scanner and fail closed if
+it was disabled, revoked, or expired before processing.
+
+### Management actions
+
+```text
+GET /api/otter/v1/overview
+POST /api/otter/v1/scanners
+POST /api/otter/v1/scanners/:id/reenroll
+POST /api/otter/v1/scanners/:id/revoke
+POST /api/otter/v1/profiles/:id/credentials
+POST /api/otter/v1/runs {"scannerID":"...","profileID":"..."}
+POST /api/otter/v1/runs/:id/cancel
+```
+
+Create scanner accepts `{ "name": "Line A Otter", "site": "SITE_ID" }` and responds with a
+single-use `enrollmentToken` and expiry. Credential management accepts
+`{ "credentials": { "snmp": [...], "opcuaCredentials": [...] } }`; `{ "clear": true }` clears
+stored credentials. Re-enrollment revokes the current scanner credential and terminates its active
+assignment before returning a new token.
+
+## Client implementation checklist in `otserver-otter`
+
+These requirements are implemented in the separate `otserver-otter` repository. The client uses
+`controller --server-url URL --enroll --ack-authorized` with the token on stdin, then restarts without
+`--enroll`. Imports default to synchronous execution; `--queued-imports` opts into the example queued
+metadata below. This does not change OTserver's TypeScript scanner parser or schema-version-2 result
+contract. The Rust tests, HTTPS lab, and management `pnpm test:otter-client` (real Rust client, REST API,
+and test replica set) cover:
+
+1. Add a `controller`/managed-agent mode that accepts the OTserver URL and one-time enrollment token
+ without requiring a scanner-side inbound listener.
+2. Redeem the enrollment token over HTTPS, then persist the returned credential in an
+ OS-appropriate secret store where available; support a permission-restricted file fallback.
+3. Poll `/api/otter/v1/poll` on a configurable interval with bounded exponential backoff and jitter.
+ Send version, supported capabilities, locally available capture interfaces, computer name, and
+ adapter technical/friendly names, description, optional MAC, and IP addresses.
+4. Decode the supported server configuration fields and credential snapshot. Apply profile options
+ without weakening the existing `--ack-authorized`, read-only protocol behavior, interface/source
+ MAC checks, response validation, or process-wide traffic pacing.
+5. Persist run IDs and assignment state across restarts. Repeated poll responses for the same run must
+ resume/reconcile that run rather than launching a duplicate scan.
+6. Send start, bounded progress/lease heartbeats, and finish status. Stop promptly on a cancellation
+ request or lost lease. A scanner must not continue uploading stale work after a terminal response.
+7. Upload the existing schema-v2 JSON to `/api/asset-imports` with
+ `Authorization: Otter `, `site` omitted or matching the bound site,
+ `source: otserver-otter`, and the run ID in `otterRun`. Keep the local result file on upload
+ failure.
+8. Retry imports using the returned import ID/status where appropriate, and report scanner completion
+ separately from import completion/failure.
+9. Rotate credentials before expiry, atomically persist the returned credential before replacing the
+ old one, and stop/re-enroll on revocation or authentication failure.
+10. Add Rust tests for enrollment responses, config mapping/validation, persisted assignment recovery,
+ cancellation, upload auth/metadata, credential redaction, and retry/backoff. Add protocol
+ interoperability cases to the Otter lab. Update its README/AGENTS safety rules and the
+ interoperability tests in this repository in the coordinated change.
+
+The controller protocol is versioned independently (`/otter/v1`). A future breaking change requires
+a new controller protocol version; it does not by itself require changing
+`otserver-scan` schema version 2.
diff --git a/package.json b/package.json
index 3b4d33c..a1680b9 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"check:generated": "node scripts/check-generated.mjs",
"test": "pnpm run test:int",
"test:container": "sh tests/container-bootstrap.sh",
+ "test:otter-client": "cargo build --locked --no-default-features --manifest-path otserver-otter/Cargo.toml && cross-env OTTER_CLIENT_BIN=otserver-otter/target/debug/otserver-otter OTSERVER_VULNERABILITY_FEEDS=off pnpm test:int tests/int/otter-client.int.spec.ts",
"test:coverage": "pnpm run test:coverage:app",
"test:coverage:app": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config=vitest.config.mts --coverage",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config=vitest.config.mts"
@@ -31,6 +32,7 @@
"@payloadcms/next": "3.90.1",
"@payloadcms/ui": "3.90.1",
"@xyflow/react": "^12.12.0",
+ "croner": "10.0.1",
"cross-env": "^7.0.3",
"graphql": "^16.14.2",
"lucene-query-parser": "^1.2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1a10bca..b38e058 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -53,6 +53,9 @@ importers:
'@xyflow/react':
specifier: ^12.12.0
version: 12.12.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ croner:
+ specifier: 10.0.1
+ version: 10.0.1
cross-env:
specifier: ^7.0.3
version: 7.0.3
diff --git a/src/access/authorization.ts b/src/access/authorization.ts
index 4874d41..4aba346 100644
--- a/src/access/authorization.ts
+++ b/src/access/authorization.ts
@@ -9,12 +9,14 @@ import {
} from 'payload'
type Authorization = {
+ canConfigureOtter: boolean
isAdmin: boolean
readableSiteIDs: string[]
writableSiteIDs: string[]
}
const noAuthorization: Authorization = {
+ canConfigureOtter: false,
isAdmin: false,
readableSiteIDs: [],
writableSiteIDs: [],
@@ -75,7 +77,7 @@ export const getAuthorization = async (req: PayloadRequest): Promise()
const writableRoots = new Set()
@@ -88,7 +90,12 @@ export const getAuthorization = async (req: PayloadRequest): Promise {
}
export const canReadSiteDocuments: Access = async ({ req }) => {
+ const scannerID = req.context.otterScannerID
+ if (typeof scannerID === 'string')
+ return req.context.assetImport === true
+ ? { site: { equals: String(req.context.otterScannerSiteID) } }
+ : false
+ const scannerSiteID = req.context.otterScannerSiteID
+ if (typeof scannerSiteID === 'string') return { site: { equals: scannerSiteID } }
const authorization = await getAuthorization(req)
return authorization.isAdmin ? true : { site: { in: authorization.readableSiteIDs } }
}
export const canWriteSiteDocuments: Access = async ({ req }) => {
+ const scannerSiteID = req.context.otterScannerSiteID
+ if (typeof scannerSiteID === 'string')
+ return req.context.assetImport === true ? { site: { equals: scannerSiteID } } : false
const authorization = await getAuthorization(req)
return authorization.isAdmin ? true : { site: { in: authorization.writableSiteIDs } }
}
export const canCreateSiteDocument: Access = async ({ data, req }) => {
+ const scannerSiteID = req.context.otterScannerSiteID
+ if (typeof scannerSiteID === 'string')
+ return (
+ req.context.assetImport === true &&
+ String(relationshipID(data?.site)) === String(scannerSiteID)
+ )
const authorization = await getAuthorization(req)
if (authorization.isAdmin) return true
if (!data) return authorization.writableSiteIDs.length > 0
@@ -147,17 +170,46 @@ export const canCreateSiteDocument: Access = async ({ data, req }) => {
return Boolean(siteID && authorization.writableSiteIDs.includes(String(siteID)))
}
+export const canCreateOtterImport: Access = async ({ data, req }) => {
+ const scannerID = req.context.otterScannerID
+ const siteID = req.context.otterScannerSiteID
+ if (typeof scannerID === 'string' && typeof siteID === 'string') {
+ const requestedSite = relationshipID(data?.site)
+ return (
+ Boolean(data) &&
+ (data?.source === undefined || data.source === 'otserver-otter') &&
+ (!requestedSite || String(requestedSite) === siteID)
+ )
+ }
+ const authorization = await getAuthorization(req)
+ if (authorization.isAdmin) return true
+ if (!data) return authorization.writableSiteIDs.length > 0
+ const requestedSite = relationshipID(data.site)
+ return Boolean(requestedSite && authorization.writableSiteIDs.includes(String(requestedSite)))
+}
+
+export const canReadOtterImports: Access = async (args) => {
+ const { req } = args
+ const scannerID = req.context.otterScannerID
+ if (typeof scannerID === 'string') return { scanner: { equals: scannerID } }
+ return canReadSiteDocuments(args)
+}
+
export const canReadSites: Access = async ({ req }) => {
+ const scannerSiteID = req.context.otterScannerSiteID
+ if (typeof scannerSiteID === 'string') return { id: { equals: scannerSiteID } }
const authorization = await getAuthorization(req)
return authorization.isAdmin ? true : { id: { in: authorization.readableSiteIDs } }
}
export const canWriteSites: Access = async ({ req }) => {
+ if (typeof req.context.otterScannerSiteID === 'string') return false
const authorization = await getAuthorization(req)
return authorization.isAdmin ? true : { id: { in: authorization.writableSiteIDs } }
}
export const canCreateSite: Access = async ({ data, req }) => {
+ if (typeof req.context.otterScannerSiteID === 'string') return false
const authorization = await getAuthorization(req)
if (authorization.isAdmin) return true
if (!data) return authorization.writableSiteIDs.length > 0
@@ -168,17 +220,35 @@ export const canCreateSite: Access = async ({ data, req }) => {
export const filterWritableSites = async ({
req,
}: Pick): Promise => {
+ const scannerSiteID = req.context.otterScannerSiteID
+ if (typeof scannerSiteID === 'string') return { id: { equals: scannerSiteID } }
if (!req.user) return true
const authorization = await getAuthorization(req)
return authorization.isAdmin ? true : { id: { in: authorization.writableSiteIDs } }
}
export const enforceWritableSite: CollectionBeforeChangeHook = async ({
+ collection,
data,
+ operation,
originalDoc,
req,
}) => {
if (!req.user) return data
+ const scannerSiteID = req.context.otterScannerSiteID
+ if (typeof scannerSiteID === 'string') {
+ if (
+ (req.context.assetImport === true ||
+ req.context.otterImportAuthorized === true ||
+ (collection.slug === 'asset-imports' &&
+ operation === 'create' &&
+ data.source === 'otserver-otter')) &&
+ String(relationshipID(Object.hasOwn(data, 'site') ? data.site : originalDoc?.site)) ===
+ scannerSiteID
+ )
+ return data
+ throw new APIError('Otter scanners may only import into their bound site.', 403)
+ }
const authorization = await getAuthorization(req)
if (authorization.isAdmin) return data
diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js
index 23c5b45..cac8356 100644
--- a/src/app/(payload)/admin/importMap.js
+++ b/src/app/(payload)/admin/importMap.js
@@ -8,12 +8,18 @@ import { DefaultEditView as DefaultEditView_3817bf644402e67bfe6577f60ef982de } f
import { default as default_d749ea13566f53f6012b380234b84c90 } from '@/components/AssetVulnerabilitiesView'
import { default as default_8712540e86c8ec1b8842171d5455844e } from '@/components/ImportStatus'
import { default as default_269ec8e6f70d6b068e7aa5ffa27b8110 } from '@/components/ImportInstructions'
+import { default as default_fc33955082dea31f4b0ec6d0cb077a17 } from '@/components/OtterScannerActions'
+import { default as default_c1520f8119081daafc04e2023c3ef070 } from '@/components/OtterEnrollment'
+import { default as default_1cf71388346ca687235889e34b2c8889 } from '@/components/OtterInterfaceField'
+import { default as default_75fff05379f2361cc5722c257b69b4b9 } from '@/components/OtterManagedSettingsField'
+import { default as default_d5e71aff688bb616206a98b246bfeca0 } from '@/components/OtterCredentialsField'
import { default as default_db9ac62598c46d0f1db201f6af05442e } from '@/components/LogoutButton'
import { Icon as Icon_ea112afe978e228c434ec2fb66c71b02 } from '@/components/Brand'
import { Logo as Logo_ea112afe978e228c434ec2fb66c71b02 } from '@/components/Brand'
import { default as default_c7b00627a26fa7f67bc5572c9be5c49c } from '@/components/TopologyNavLink'
import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/components/BeforeDashboard'
import { default as default_10de5814a66cadb96e2a15909b461a8f } from '@/components/TopologyView'
+import { default as default_a625382a3e6c10d385fca20fc0376c7e } from '@/components/OtterManagementView'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
@@ -28,11 +34,17 @@ export const importMap = {
'@/components/AssetVulnerabilitiesView#default': default_d749ea13566f53f6012b380234b84c90,
'@/components/ImportStatus#default': default_8712540e86c8ec1b8842171d5455844e,
'@/components/ImportInstructions#default': default_269ec8e6f70d6b068e7aa5ffa27b8110,
+ '@/components/OtterScannerActions#default': default_fc33955082dea31f4b0ec6d0cb077a17,
+ '@/components/OtterEnrollment#default': default_c1520f8119081daafc04e2023c3ef070,
+ '@/components/OtterInterfaceField#default': default_1cf71388346ca687235889e34b2c8889,
+ '@/components/OtterManagedSettingsField#default': default_75fff05379f2361cc5722c257b69b4b9,
+ '@/components/OtterCredentialsField#default': default_d5e71aff688bb616206a98b246bfeca0,
'@/components/LogoutButton#default': default_db9ac62598c46d0f1db201f6af05442e,
'@/components/Brand#Icon': Icon_ea112afe978e228c434ec2fb66c71b02,
'@/components/Brand#Logo': Logo_ea112afe978e228c434ec2fb66c71b02,
'@/components/TopologyNavLink#default': default_c7b00627a26fa7f67bc5572c9be5c49c,
'@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6,
'@/components/TopologyView#default': default_10de5814a66cadb96e2a15909b461a8f,
+ '@/components/OtterManagementView#default': default_a625382a3e6c10d385fca20fc0376c7e,
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
}
diff --git a/src/app/(payload)/custom.scss b/src/app/(payload)/custom.scss
index 9c00be0..43f66bb 100644
--- a/src/app/(payload)/custom.scss
+++ b/src/app/(payload)/custom.scss
@@ -7,3 +7,85 @@
font-size: 0.85rem;
margin: 0 0 var(--base);
}
+
+.otter-credentials {
+ border: 1px solid var(--theme-elevation-300);
+ display: grid;
+ gap: var(--base);
+ margin: var(--base) 0;
+ padding: var(--base);
+}
+
+.otter-credentials p {
+ margin: 0;
+}
+.otter-credentials__section {
+ border-top: 1px solid var(--theme-elevation-200);
+ display: grid;
+ gap: var(--base);
+ padding-top: var(--base);
+}
+.otter-credentials__entry {
+ align-items: end;
+ border-bottom: 1px solid var(--theme-elevation-200);
+ display: grid;
+ gap: var(--base);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ padding: var(--base) 0;
+}
+.otter-credentials__field {
+ display: grid;
+ gap: calc(var(--base) / 4);
+}
+.otter-credentials input,
+.otter-credentials select {
+ background: var(--theme-input-bg);
+ border: 1px solid var(--theme-elevation-400);
+ border-radius: var(--style-radius-s);
+ color: var(--theme-text);
+ min-height: 40px;
+ padding: calc(var(--base) / 2);
+}
+.otter-credentials__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--base);
+}
+
+.otter-managed-settings {
+ border: 1px solid var(--theme-elevation-200);
+ border-radius: var(--style-radius-s);
+ display: grid;
+ gap: var(--base);
+ padding: var(--base);
+}
+.otter-managed-settings h3 {
+ margin: 0;
+}
+.otter-managed-settings p {
+ color: var(--theme-elevation-600);
+ margin: 0;
+}
+.otter-managed-settings dl {
+ display: grid;
+ gap: calc(var(--base) / 2);
+ margin: 0;
+}
+.otter-managed-settings dl > div {
+ display: grid;
+ gap: var(--base);
+ grid-template-columns: minmax(120px, 0.25fr) 1fr;
+}
+.otter-managed-settings dt {
+ color: var(--theme-elevation-600);
+}
+.otter-managed-settings dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 900px) {
+ .otter-credentials__entry {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/application/queuedImports.ts b/src/application/queuedImports.ts
index 6394093..f89c541 100644
--- a/src/application/queuedImports.ts
+++ b/src/application/queuedImports.ts
@@ -1,7 +1,14 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
-import { APIError, type Payload, type PayloadHandler, type PayloadRequest } from 'payload'
+import {
+ APIError,
+ createLocalReq,
+ type Payload,
+ type PayloadHandler,
+ type PayloadRequest,
+ type TypedUser,
+} from 'payload'
import { getAuthorization, relationshipID } from '../access/authorization'
import { inTransaction } from '../integrations/payload/transactions'
import { withRequestContext } from '../integrations/payload/context'
@@ -15,6 +22,7 @@ export const fileDigest = (data: Buffer) => createHash('sha256').update(data).di
const internalImportFields = [
'jobID',
'submittedBy',
+ 'executorType',
'executorID',
'retriedBy',
'fileDigest',
@@ -28,6 +36,11 @@ const publicImport = (doc: AssetImport): AssetImport => {
}
export const requireSiteWriter = async (site: unknown, req: PayloadRequest) => {
+ const scannerSiteID = req.context.otterScannerSiteID
+ if (typeof scannerSiteID === 'string') {
+ if (scannerSiteID === String(relationshipID(site))) return
+ throw new APIError('Site write permission is required.', 403)
+ }
const access = await getAuthorization(req)
if (
!req.user ||
@@ -79,8 +92,9 @@ export const retryImport: PayloadHandler = async (req) => {
req,
data: {
status: 'pending',
- executorID: String(req.user!.id),
- retriedBy: String(req.user!.id),
+ executorType: req.context.otterScannerID ? 'otter' : 'user',
+ executorID: String(req.context.otterScannerID || req.user!.id),
+ retriedBy: String(req.context.otterScannerID || req.user!.id),
error: null,
completedAt: null,
queuedAt: new Date().toISOString(),
@@ -110,27 +124,67 @@ export const executeQueuedImport = async (
const start = Date.now()
try {
if (doc.executionMode !== 'queued') throw new APIError('Invalid processing mode.', 400)
- const user = await payload.findByID({
- collection: 'users',
- id: doc.executorID || '',
- depth: 0,
- disableErrors: true,
- })
- if (!user) throw new APIError('Executing user is missing.', 403)
- const req = await principalRequest(
- payload,
- { ...user, collection: 'users' },
- {
- importWrite: true,
- skipAssetImport: true,
- },
- )
+ let req: PayloadRequest
+ if (doc.executorType === 'otter') {
+ const scanner = await payload.findByID({
+ collection: 'otter-scanners',
+ id: doc.executorID || '',
+ depth: 0,
+ disableErrors: true,
+ overrideAccess: true,
+ })
+ const credentialExpiresAt = Date.parse(scanner?.credentialExpiresAt || '')
+ if (
+ !scanner?.enabled ||
+ !scanner.credentialHash ||
+ !Number.isFinite(credentialExpiresAt) ||
+ credentialExpiresAt <= Date.now()
+ )
+ throw new APIError('Executing Otter scanner is missing, disabled, or expired.', 403)
+ const siteID = String(relationshipID(scanner.site) || '')
+ if (!siteID) throw new APIError('Otter scanner site is missing.', 403)
+ req = await createLocalReq(
+ {
+ context: {
+ importWrite: true,
+ otterImportAuthorized: true,
+ otterScannerID: String(scanner.id),
+ otterScannerSiteID: siteID,
+ skipAssetImport: true,
+ },
+ user: {
+ collection: 'users',
+ email: `otter-${scanner.id}@otter.invalid`,
+ id: `otter:${scanner.id}`,
+ otterScannerID: String(scanner.id),
+ role: null,
+ } as unknown as TypedUser,
+ },
+ payload,
+ )
+ } else {
+ const user = await payload.findByID({
+ collection: 'users',
+ id: doc.executorID || '',
+ depth: 0,
+ disableErrors: true,
+ })
+ if (!user) throw new APIError('Executing user is missing.', 403)
+ req = await principalRequest(
+ payload,
+ { ...user, collection: 'users' },
+ {
+ importWrite: true,
+ skipAssetImport: true,
+ },
+ )
+ }
await requireSiteWriter(doc.site, req)
await payload.update({
collection: 'asset-imports',
id: doc.id,
req,
- overrideAccess: false,
+ overrideAccess: doc.executorType === 'otter',
data: {
status: 'running',
startedAt: new Date().toISOString(),
@@ -170,7 +224,7 @@ export const executeQueuedImport = async (
collection: 'asset-imports',
id: doc.id,
req,
- overrideAccess: false,
+ overrideAccess: doc.executorType === 'otter',
data: { completedAt: new Date().toISOString() },
})
})
diff --git a/src/collections/AssetImports.ts b/src/collections/AssetImports.ts
index 4224340..baa1d17 100644
--- a/src/collections/AssetImports.ts
+++ b/src/collections/AssetImports.ts
@@ -8,11 +8,12 @@ import {
} from 'payload'
import { isDeepStrictEqual } from 'node:util'
import {
- canCreateSiteDocument,
- canReadSiteDocuments,
+ canCreateOtterImport,
+ canReadOtterImports,
canWriteSiteDocuments,
enforceWritableSite,
filterWritableSites,
+ relationshipID,
} from '../access/authorization'
import { userSuppliedAssetFields } from '../domain/assetFields'
import { sanitizeCustomFieldValues } from './AssetFields'
@@ -34,6 +35,20 @@ const normalizeLegacyOtterSource: CollectionBeforeValidateHook = ({ data }) => {
if (data?.source === 'otserver-scanner') data.source = 'otserver-otter'
return data
}
+const bindOtterUpload: CollectionBeforeValidateHook = ({ data, operation, req }) => {
+ const scannerID =
+ typeof req.context.otterScannerID === 'string' ? req.context.otterScannerID : undefined
+ if (!scannerID || req.context.importWrite || !data) return data
+ if (operation !== 'create')
+ throw new APIError('Otter uploads cannot modify existing imports.', 403)
+ const siteID = String(req.context.otterScannerSiteID || '')
+ const requestedSite = relationshipID(data.site)
+ if (!siteID || (requestedSite && String(requestedSite) !== siteID))
+ throw new APIError('Otter uploads are restricted to the scanner’s bound site.', 403)
+ data.site = siteID
+ data.source = 'otserver-otter'
+ return data
+}
const runImport: CollectionAfterChangeHook = async ({ context, doc, req, operation }) => {
if (context.skipAssetImport || !req.file) return doc
if (doc.executionMode === 'queued') return operation === 'create' ? queueImport(doc, req) : doc
@@ -46,6 +61,7 @@ const managed = [
'status',
'jobID',
'submittedBy',
+ 'executorType',
'executorID',
'retriedBy',
'fileDigest',
@@ -65,6 +81,8 @@ const managed = [
'warnings',
'scanMetadata',
'unresolved',
+ 'scanner',
+ 'otterRun',
]
const immutable = [
'executionMode',
@@ -73,6 +91,8 @@ const immutable = [
'assetOverrides',
'customFieldOverrides',
'sourceVersion',
+ 'scanner',
+ 'otterRun',
'filename',
]
const internalFieldAccess = { create: () => false, read: () => false, update: () => false }
@@ -84,7 +104,10 @@ const protectProcessing: CollectionBeforeChangeHook = async ({
context,
}) => {
if (context.importWrite) return data
+ const scannerID =
+ typeof req.context.otterScannerID === 'string' ? req.context.otterScannerID : undefined
if (operation === 'update') {
+ if (scannerID) throw new APIError('Otter uploads cannot modify existing imports.', 403)
for (const field of managed)
if (Object.hasOwn(data, field) && !isDeepStrictEqual(data[field], originalDoc?.[field]))
throw new APIError('Processing state is managed by the server.', 400)
@@ -97,14 +120,70 @@ const protectProcessing: CollectionBeforeChangeHook = async ({
return data
}
// Discard caller-supplied processing state, including through generic REST/GraphQL APIs.
+ const scannerRunID = scannerID ? relationshipID(data.otterRun) : undefined
for (const field of managed) delete data[field]
+ if (scannerID) {
+ const siteID = String(req.context.otterScannerSiteID || '')
+ if (!siteID || (Object.hasOwn(data, 'site') && String(relationshipID(data.site)) !== siteID))
+ throw new APIError('Otter uploads are restricted to the scanner’s bound site.', 403)
+ data.site = siteID
+ data.source = 'otserver-otter'
+ data.scanner = scannerID
+ delete data.assetOverrides
+ delete data.customFieldOverrides
+ const runID = scannerRunID
+ if (runID) {
+ const run = await req.payload.findByID({
+ collection: 'otter-runs',
+ depth: 0,
+ id: String(runID),
+ overrideAccess: true,
+ req,
+ })
+ if (
+ String(relationshipID(run.scanner)) !== scannerID ||
+ String(relationshipID(run.site)) !== siteID ||
+ !['assigned', 'running', 'cancel-requested'].includes(run.status)
+ )
+ throw new APIError(
+ 'The scan run does not belong to this scanner or is no longer active.',
+ 409,
+ )
+ data.otterRun = String(runID)
+ const snapshot = run.importOverridesSnapshot
+ if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) {
+ data.assetOverrides = snapshot.assetOverrides || {}
+ data.customFieldOverrides = snapshot.customFieldOverrides || {}
+ }
+ } else {
+ delete data.otterRun
+ }
+ const scanner = await req.payload.findByID({
+ collection: 'otter-scanners',
+ id: scannerID,
+ depth: 0,
+ overrideAccess: true,
+ req,
+ })
+ const site = await req.payload.findByID({
+ collection: 'sites',
+ id: siteID,
+ depth: 0,
+ overrideAccess: true,
+ req,
+ })
+ data.name = `Otter — ${scanner.name} — ${site.path || site.name} — ${new Date().toISOString()}`
+ req.context.otterImportAuthorized = true
+ }
+ if (!data.name) data.name = data.filename || req.file?.name
data.status = 'pending'
data.attemptCount = 0
if (data.executionMode === 'queued') {
if (!queuedImportsEnabled()) throw new APIError('Queued imports are not enabled.', 503)
await requireSiteWriter(data.site, req)
if (!req.file) throw new APIError('A file is required for a queued import.', 400)
- data.submittedBy = data.executorID = String(req.user!.id)
+ data.submittedBy = data.executorID = scannerID || String(req.user!.id)
+ data.executorType = scannerID ? 'otter' : 'user'
data.fileDigest = fileDigest(req.file.data)
data.queuedAt = new Date().toISOString()
data.processingInput = {
@@ -152,14 +231,14 @@ export const AssetImports: CollectionConfig = {
singular: 'Import',
},
access: {
- create: canCreateSiteDocument,
+ create: canCreateOtterImport,
delete: canWriteSiteDocuments,
- read: canReadSiteDocuments,
+ read: canReadOtterImports,
update: canWriteSiteDocuments,
},
admin: {
defaultColumns: [
- 'filename',
+ 'name',
'site',
'source',
'sourceVersion',
@@ -170,9 +249,17 @@ export const AssetImports: CollectionConfig = {
],
description: 'Import physical identity and network evidence into a selected site.',
group: 'OT Inventory',
- useAsTitle: 'filename',
+ useAsTitle: 'name',
},
fields: [
+ {
+ name: 'name',
+ type: 'text',
+ admin: { readOnly: true },
+ hooks: {
+ afterRead: [({ value, originalDoc }) => value || originalDoc?.filename],
+ },
+ },
{
name: 'executionMode',
type: 'select',
@@ -190,12 +277,14 @@ export const AssetImports: CollectionConfig = {
type: 'ui',
admin: { components: { Field: '@/components/ImportStatus' } },
},
- ...['jobID', 'submittedBy', 'executorID', 'retriedBy', 'fileDigest'].map((name) => ({
- name,
- type: 'text' as const,
- access: internalFieldAccess,
- admin: { readOnly: true, hidden: true },
- })),
+ ...['jobID', 'submittedBy', 'executorType', 'executorID', 'retriedBy', 'fileDigest'].map(
+ (name) => ({
+ name,
+ type: 'text' as const,
+ access: internalFieldAccess,
+ admin: { readOnly: true, hidden: true },
+ }),
+ ),
{
name: 'processingInput',
type: 'json',
@@ -235,6 +324,17 @@ export const AssetImports: CollectionConfig = {
relationTo: 'sites',
required: true,
},
+ {
+ name: 'scanner',
+ type: 'text',
+ admin: { hidden: true, readOnly: true },
+ index: true,
+ },
+ {
+ name: 'otterRun',
+ type: 'text',
+ admin: { hidden: true, readOnly: true },
+ },
{
name: 'source',
type: 'select',
@@ -355,11 +455,11 @@ export const AssetImports: CollectionConfig = {
hooks: {
beforeOperation: [protectUpload],
afterChange: [runImport],
- beforeValidate: [normalizeLegacyOtterSource],
+ beforeValidate: [normalizeLegacyOtterSource, bindOtterUpload],
beforeChange: [
enforceWritableSite,
- sanitizeCustomFieldValues,
protectProcessing,
+ sanitizeCustomFieldValues,
({ data, context }) => {
if (context.appliedImportKey) data.appliedKey = context.appliedImportKey
return data
diff --git a/src/collections/AuditLogs.ts b/src/collections/AuditLogs.ts
index 4a780d8..d72ac6e 100644
--- a/src/collections/AuditLogs.ts
+++ b/src/collections/AuditLogs.ts
@@ -21,7 +21,7 @@ type AuditAction = 'create' | 'custom' | 'delete' | 'login' | 'logout' | 'update
type Document = Record
const ignoredFields = new Set(['collection', 'createdAt', 'id', 'updatedAt'])
-const sensitiveField = /api[-_]?key|hash|password|salt|secret|session|token/i
+const sensitiveField = /api[-_]?key|community|credential|hash|password|salt|secret|session|token/i
const canReadAuditLogs: Access = async ({ req }) => {
const authorization = await getAuthorization(req)
@@ -80,6 +80,8 @@ export const writeAudit = async ({
}) => {
const document = clean(after ?? before)
const actor = clean(req.user)
+ const scannerID =
+ typeof req.context.otterScannerID === 'string' ? req.context.otterScannerID : undefined
const documentID = document.id === undefined ? undefined : String(document.id)
const documentLabel = labelFor(document)
const relatedAsset = relationshipID(
@@ -95,10 +97,14 @@ export const writeAudit = async ({
data: {
action,
...(relationshipID(document.site) ? { site: relationshipID(document.site) as string } : {}),
- actorEmail: typeof actor.email === 'string' ? actor.email : undefined,
- actorID: actor.id === undefined ? undefined : String(actor.id),
- actorName: typeof actor.name === 'string' ? actor.name : undefined,
- actorType: req.user ? 'user' : 'system',
+ actorEmail: !scannerID && typeof actor.email === 'string' ? actor.email : undefined,
+ actorID: scannerID || (actor.id === undefined ? undefined : String(actor.id)),
+ actorName: scannerID
+ ? `Otter scanner ${scannerID}`
+ : typeof actor.name === 'string'
+ ? actor.name
+ : undefined,
+ actorType: scannerID ? 'otter' : req.user ? 'user' : 'system',
...(assetID ? { asset: assetID, assetID } : {}),
changes: getAuditChanges(before, after),
documentID,
@@ -220,7 +226,7 @@ export const AuditLogs: CollectionConfig = {
name: 'actorType',
type: 'select',
index: true,
- options: ['user', 'system'],
+ options: ['otter', 'user', 'system'],
required: true,
},
{ name: 'actorID', type: 'text', index: true, label: 'Actor ID' },
diff --git a/src/collections/Otter.ts b/src/collections/Otter.ts
new file mode 100644
index 0000000..206d55e
--- /dev/null
+++ b/src/collections/Otter.ts
@@ -0,0 +1,700 @@
+import {
+ APIError,
+ type Access,
+ type CollectionBeforeChangeHook,
+ type CollectionBeforeDeleteHook,
+ type CollectionConfig,
+} from 'payload'
+import { filterWritableSites, getAuthorization, relationshipID } from '../access/authorization'
+import {
+ encryptOtterCredentials,
+ requireOtterConfigAccess,
+ validateOtterConfiguration,
+} from '../otter/security'
+import { validateScheduleInput } from '../otter/scheduling'
+import { userSuppliedAssetFields } from '../domain/assetFields'
+import { sanitizeCustomFieldValues } from './AssetFields'
+
+const canReadOtter: Access = async ({ req }) => {
+ if (req.context.otterScannerID) return false
+ const authorization = await getAuthorization(req)
+ if (!authorization.canConfigureOtter) return false
+ return authorization.isAdmin ? true : { site: { in: authorization.readableSiteIDs } }
+}
+
+const canWriteOtter: Access = async ({ req }) => {
+ if (req.context.otterScannerID) return false
+ const authorization = await getAuthorization(req)
+ if (!authorization.canConfigureOtter) return false
+ return authorization.isAdmin ? true : { site: { in: authorization.writableSiteIDs } }
+}
+
+const canCreateOtter: Access = async ({ data, req }) => {
+ if (req.context.otterScannerID) return false
+ const authorization = await getAuthorization(req)
+ if (!authorization.canConfigureOtter) return false
+ if (authorization.isAdmin) return true
+ if (!data) return authorization.writableSiteIDs.length > 0
+ const siteID = relationshipID(data.site)
+ return Boolean(siteID && authorization.writableSiteIDs.includes(String(siteID)))
+}
+
+const internal = { create: () => false, read: () => false, update: () => false }
+const serverReadOnly = { create: () => false, update: () => false }
+
+const validateScannerSettings: CollectionBeforeChangeHook = async ({ data, req }) => {
+ if (!req.user || req.context.otterScannerUpdate) return data
+ if (Object.hasOwn(data, 'profile') || Object.hasOwn(data, 'network'))
+ throw new APIError('Scan settings must be changed in Otter Configurations.', 400)
+ return data
+}
+
+const scheduleProfile = async (
+ data: Record,
+ req: Parameters[0]['req'],
+) => {
+ const profileID = relationshipID(data.profile)
+ if (!profileID) throw new APIError('Select an Otter configuration.', 400)
+ const profile = await req.payload.findByID({
+ collection: 'otter-profiles',
+ depth: 0,
+ id: String(profileID),
+ overrideAccess: false,
+ req,
+ })
+ const siteID = relationshipID(profile.site)
+ const scannerID = relationshipID(profile.scanner)
+ if (!siteID || !scannerID)
+ throw new APIError('The configuration must have a scanner and site.', 400)
+ await requireOtterConfigAccess(req, siteID, true)
+ const scanner = await req.payload.findByID({
+ collection: 'otter-scanners',
+ depth: 0,
+ id: String(scannerID),
+ overrideAccess: true,
+ req,
+ })
+ if (String(relationshipID(scanner.site)) !== String(siteID))
+ throw new APIError('Scanner and configuration must belong to the same site.', 400)
+ return { site: siteID, scanner: scannerID }
+}
+
+const canCreateSchedule: Access = async ({ data, req }) => {
+ // Payload evaluates create access without submitted form data when rendering
+ // the create view. Defer profile-specific checks until the form is submitted.
+ if (!relationshipID(data?.profile)) return canCreateOtter({ req })
+ const scope = await scheduleProfile(data, req)
+ return canCreateOtter({ data: { ...data, ...scope }, req })
+}
+
+const validateProfile: CollectionBeforeChangeHook = async ({
+ data,
+ operation,
+ originalDoc,
+ req,
+}) => {
+ if (req.context.otterSchedulerUpdate || req.context.otterCredentialWrite) return data
+ const siteID = relationshipID(Object.hasOwn(data, 'site') ? data.site : originalDoc?.site)
+ const scannerID = relationshipID(
+ Object.hasOwn(data, 'scanner') ? data.scanner : originalDoc?.scanner,
+ )
+ if (!siteID || !scannerID) throw new APIError('Select a site and scanner.', 400)
+ await requireOtterConfigAccess(req, siteID, true)
+ const scanner = await req.payload.findByID({
+ collection: 'otter-scanners',
+ depth: 0,
+ id: String(scannerID),
+ overrideAccess: true,
+ req,
+ })
+ if (String(relationshipID(scanner.site)) !== String(siteID))
+ throw new APIError('The scanner must be bound to the selected site.', 400)
+
+ const configuration = Object.hasOwn(data, 'configuration')
+ ? data.configuration
+ : originalDoc?.configuration
+ const normalizedConfiguration =
+ configuration && typeof configuration === 'object' && !Array.isArray(configuration)
+ ? { ...configuration }
+ : configuration
+ if (normalizedConfiguration && typeof normalizedConfiguration === 'object')
+ for (const key of ['interface', 'sourceMac'])
+ if (
+ normalizedConfiguration[key] === null ||
+ normalizedConfiguration[key] === undefined ||
+ normalizedConfiguration[key] === ''
+ )
+ delete normalizedConfiguration[key]
+ data.configuration = validateOtterConfiguration(normalizedConfiguration)
+ // After the initial default, credentials change only through the write-only endpoint.
+ const previous =
+ operation === 'update' && originalDoc?.id
+ ? await req.payload.findByID({
+ collection: 'otter-profiles',
+ depth: 0,
+ id: originalDoc.id,
+ overrideAccess: true,
+ req,
+ })
+ : undefined
+ data.encryptedCredentials =
+ operation === 'create'
+ ? encryptOtterCredentials({ snmp: [{ version: '2c', community: 'public' }] })
+ : previous?.encryptedCredentials || undefined
+ data.credentialsConfigured = Boolean(data.encryptedCredentials)
+ return data
+}
+
+const validateSchedule: CollectionBeforeChangeHook = async ({
+ data,
+ operation,
+ originalDoc,
+ req,
+}) => {
+ if (req.context.otterSchedulerUpdate) return data
+ const merged = { ...originalDoc, ...data } as Record
+ const scope = await scheduleProfile(merged, req)
+ Object.assign(data, scope)
+ Object.assign(merged, scope)
+ if (operation === 'create') {
+ const schedules = await req.payload.count({
+ collection: 'otter-schedules',
+ overrideAccess: true,
+ req,
+ where: { scanner: { equals: relationshipID(merged.scanner) } },
+ })
+ if (schedules.totalDocs >= 50)
+ throw new APIError('A scanner can have at most 50 schedules.', 400)
+ }
+ if (!merged.enabled) {
+ data.nextRunAt = null
+ return data
+ }
+ data.nextRunAt = validateScheduleInput({
+ cron: typeof merged.cron === 'string' ? merged.cron : undefined,
+ intervalMinutes:
+ typeof merged.intervalMinutes === 'number' ? merged.intervalMinutes : undefined,
+ runAt: typeof merged.runAt === 'string' ? merged.runAt : undefined,
+ scheduleType: merged.scheduleType as 'once' | 'interval' | 'cron',
+ timezone: typeof merged.timezone === 'string' ? merged.timezone : 'UTC',
+ })
+ data.lastRunAt = operation === 'create' ? null : originalDoc?.lastRunAt
+ return data
+}
+
+const preventDeletingReferencedOtterConfig: CollectionBeforeDeleteHook = async ({
+ id,
+ collection,
+ req,
+}) => {
+ const references = []
+ if (collection.slug === 'otter-profiles') {
+ references.push(
+ await req.payload.count({
+ collection: 'otter-schedules',
+ overrideAccess: true,
+ req,
+ where: { profile: { equals: id } },
+ }),
+ await req.payload.count({
+ collection: 'otter-runs',
+ overrideAccess: true,
+ req,
+ where: { profile: { equals: id } },
+ }),
+ )
+ } else {
+ references.push(
+ await req.payload.count({
+ collection: 'otter-runs',
+ overrideAccess: true,
+ req,
+ where: { schedule: { equals: id } },
+ }),
+ )
+ }
+ if (references.some((reference) => reference.totalDocs > 0))
+ throw new APIError('Configuration referenced by scan history cannot be deleted.', 409)
+}
+
+export const OtterScanners: CollectionConfig = {
+ slug: 'otter-scanners',
+ access: {
+ create: () => false,
+ delete: () => false,
+ read: canReadOtter,
+ update: canWriteOtter,
+ },
+ admin: {
+ components: { beforeList: ['@/components/OtterEnrollment'] },
+ defaultColumns: ['name', 'site', 'computerName', 'enabled', 'version', 'lastSeenAt'],
+ description: 'Enrolled Otter scanner agents and their bound sites.',
+ group: 'Otter',
+ useAsTitle: 'name',
+ },
+ defaultSort: 'name',
+ disableBulkDelete: true,
+ disableBulkEdit: true,
+ fields: [
+ { name: 'name', type: 'text', required: true, maxLength: 120 },
+ {
+ name: 'site',
+ type: 'relationship',
+ filterOptions: filterWritableSites,
+ index: true,
+ maxDepth: 0,
+ relationTo: 'sites',
+ required: true,
+ access: { create: () => false, update: () => false },
+ },
+ {
+ name: 'scannerActions',
+ type: 'ui',
+ admin: { components: { Field: '@/components/OtterScannerActions' } },
+ },
+ {
+ name: 'enabled',
+ type: 'checkbox',
+ defaultValue: true,
+ access: serverReadOnly,
+ admin: { readOnly: true },
+ },
+ { name: 'enrolledAt', type: 'date', access: serverReadOnly, admin: { readOnly: true } },
+ { name: 'lastSeenAt', type: 'date', access: serverReadOnly, admin: { readOnly: true } },
+ { name: 'version', type: 'text', access: serverReadOnly, admin: { readOnly: true } },
+ { name: 'capabilities', type: 'json', access: serverReadOnly, admin: { readOnly: true } },
+ { name: 'interfaces', type: 'json', access: serverReadOnly, admin: { readOnly: true } },
+ { name: 'computerName', type: 'text', access: serverReadOnly, admin: { readOnly: true } },
+ {
+ name: 'inventoryReportedAt',
+ type: 'date',
+ access: serverReadOnly,
+ admin: { readOnly: true },
+ },
+ {
+ name: 'networkAdapters',
+ type: 'array',
+ access: serverReadOnly,
+ admin: { readOnly: true },
+ maxRows: 32,
+ fields: [
+ { name: 'name', type: 'text', required: true },
+ { name: 'friendlyName', type: 'text', required: true },
+ { name: 'description', type: 'text', required: true },
+ { name: 'macAddress', type: 'text' },
+ { name: 'ipAddresses', type: 'text', hasMany: true, maxRows: 16 },
+ ],
+ },
+ {
+ name: 'currentRun',
+ type: 'relationship',
+ relationTo: 'otter-runs',
+ access: serverReadOnly,
+ admin: { readOnly: true },
+ },
+ { name: 'enrollmentTokenHash', type: 'text', access: internal, admin: { hidden: true } },
+ { name: 'enrollmentExpiresAt', type: 'date', access: internal, admin: { hidden: true } },
+ { name: 'credentialHash', type: 'text', access: internal, admin: { hidden: true } },
+ { name: 'credentialExpiresAt', type: 'date', access: internal, admin: { hidden: true } },
+ ],
+ indexes: [{ fields: ['site', 'enabled'] }],
+ hooks: { beforeChange: [validateScannerSettings] },
+ lockDocuments: false,
+}
+
+export const OtterProfiles: CollectionConfig = {
+ slug: 'otter-profiles',
+ labels: { singular: 'Otter Configuration', plural: 'Otter Configurations' },
+ access: {
+ create: canCreateOtter,
+ delete: canWriteOtter,
+ read: canReadOtter,
+ update: canWriteOtter,
+ },
+ admin: {
+ defaultColumns: ['name', 'scanner', 'site', 'credentialsConfigured', 'updatedAt'],
+ description:
+ 'Central Otter settings. SNMP and OPC UA credentials are encrypted and write-only.',
+ group: 'Otter',
+ useAsTitle: 'name',
+ },
+ defaultSort: 'name',
+ disableBulkDelete: true,
+ disableBulkEdit: true,
+ fields: [
+ { name: 'name', type: 'text', required: true },
+ {
+ name: 'site',
+ type: 'relationship',
+ filterOptions: filterWritableSites,
+ index: true,
+ maxDepth: 0,
+ relationTo: 'sites',
+ required: true,
+ },
+ {
+ name: 'scanner',
+ type: 'relationship',
+ filterOptions: async ({ req }) => {
+ const auth = await getAuthorization(req)
+ return auth.isAdmin ? true : { site: { in: auth.writableSiteIDs } }
+ },
+ index: true,
+ maxDepth: 0,
+ relationTo: 'otter-scanners',
+ required: true,
+ },
+ {
+ name: 'configuration',
+ type: 'group',
+ admin: {
+ description:
+ 'Managed scan options. Site, controller URL, output path, and API credentials are managed by OTserver.',
+ },
+ fields: [
+ {
+ name: 'targets',
+ type: 'text',
+ hasMany: true,
+ maxRows: 1024,
+ minRows: 1,
+ required: true,
+ admin: {
+ description: 'IPv4 addresses or CIDR ranges; maximum 65,536 addresses per scan.',
+ },
+ },
+ {
+ name: 'interface',
+ type: 'text',
+ admin: {
+ components: { Field: '@/components/OtterInterfaceField' },
+ description:
+ 'Select a reported adapter or enter its technical name. The scanner requires an explicit choice when multiple adapters are available.',
+ },
+ },
+ {
+ name: 'sourceMac',
+ type: 'text',
+ admin: {
+ description:
+ 'Optional expected sender MAC. Otter refreshes and uses the selected adapter’s actual MAC at scan start.',
+ },
+ },
+ {
+ type: 'collapsible',
+ label: 'Discovery and protocols',
+ admin: { initCollapsed: true },
+ fields: [
+ {
+ name: 'noProtocols',
+ type: 'checkbox',
+ defaultValue: false,
+ label: 'Disable native IP protocols',
+ admin: {
+ description:
+ 'Disables S7, EtherNet/IP, BACnet, FINS, Fox, DNP3, IEC 61850, NetBIOS, SMB, RPC, and OPC UA. ARP, PROFINET, SNMP, and LLDP are controlled separately.',
+ },
+ },
+ {
+ name: 'noArp',
+ type: 'checkbox',
+ defaultValue: false,
+ label: 'Disable ARP discovery',
+ },
+ {
+ name: 'noProfinet',
+ type: 'checkbox',
+ defaultValue: false,
+ label: 'Disable PROFINET discovery',
+ },
+ {
+ name: 'allowDcpSource',
+ type: 'checkbox',
+ defaultValue: false,
+ label: 'Use PROFINET DCP-reported addresses as scan sources',
+ },
+ { name: 'noS7', type: 'checkbox', defaultValue: false, label: 'Disable Siemens S7' },
+ { name: 'noEnip', type: 'checkbox', defaultValue: false, label: 'Disable EtherNet/IP' },
+ { name: 'noBacnet', type: 'checkbox', defaultValue: false, label: 'Disable BACnet' },
+ { name: 'noFins', type: 'checkbox', defaultValue: false, label: 'Disable Omron FINS' },
+ { name: 'noFox', type: 'checkbox', defaultValue: false, label: 'Disable Niagara Fox' },
+ { name: 'noDnp3', type: 'checkbox', defaultValue: false, label: 'Disable DNP3' },
+ {
+ name: 'noIec61850',
+ type: 'checkbox',
+ defaultValue: false,
+ label: 'Disable IEC 61850',
+ },
+ { name: 'noNetbios', type: 'checkbox', defaultValue: false, label: 'Disable NetBIOS' },
+ {
+ name: 'noSmb',
+ type: 'checkbox',
+ defaultValue: false,
+ label: 'Disable SMB fingerprinting',
+ },
+ {
+ name: 'noRpc',
+ type: 'checkbox',
+ defaultValue: false,
+ label: 'Disable RPC fingerprinting',
+ },
+ { name: 'noOpcua', type: 'checkbox', defaultValue: false, label: 'Disable OPC UA' },
+ { name: 'noSnmp', type: 'checkbox', defaultValue: false, label: 'Disable SNMP' },
+ { name: 'noLldp', type: 'checkbox', defaultValue: false, label: 'Disable LLDP' },
+ {
+ name: 'opcuaPorts',
+ type: 'number',
+ hasMany: true,
+ defaultValue: [],
+ maxRows: 64,
+ min: 1,
+ max: 65535,
+ admin: {
+ description:
+ 'OPC UA ports to probe. Leave empty to use Otter defaults: 4840, 4841, and 48400.',
+ },
+ },
+ ],
+ },
+ ],
+ required: true,
+ },
+ {
+ name: 'managedSettings',
+ type: 'ui',
+ label: 'Managed settings',
+ admin: { components: { Field: '@/components/OtterManagedSettingsField' } },
+ },
+ {
+ name: 'assetOverrides',
+ type: 'group',
+ label: 'Apply to imported assets',
+ admin: {
+ description:
+ 'Optional human-quality overrides, applied to every imported asset. Saved with each run.',
+ },
+ fields: userSuppliedAssetFields.map(({ label, name, placeholder }) => ({
+ name,
+ label,
+ type: 'text' as const,
+ admin: { placeholder },
+ })),
+ },
+ {
+ name: 'customFieldOverrides',
+ type: 'json',
+ label: 'Custom field overrides',
+ admin: { components: { Field: '@/components/CustomAssetFields' } },
+ },
+ {
+ name: 'runActions',
+ type: 'ui',
+ admin: { components: { Field: '@/components/OtterScannerActions' } },
+ },
+ {
+ name: 'credentialsConfigured',
+ type: 'checkbox',
+ admin: { readOnly: true },
+ defaultValue: false,
+ },
+ {
+ name: 'credentialSettings',
+ type: 'ui',
+ admin: { components: { Field: '@/components/OtterCredentialsField' } },
+ },
+ {
+ name: 'encryptedCredentials',
+ type: 'text',
+ maxLength: 64_000,
+ access: internal,
+ admin: { hidden: true },
+ },
+ ],
+ hooks: {
+ beforeChange: [validateProfile, sanitizeCustomFieldValues],
+ beforeDelete: [preventDeletingReferencedOtterConfig],
+ },
+ indexes: [{ fields: ['scanner', 'site'] }],
+ lockDocuments: false,
+}
+
+export const OtterSchedules: CollectionConfig = {
+ slug: 'otter-schedules',
+ access: {
+ create: canCreateSchedule,
+ delete: canWriteOtter,
+ read: canReadOtter,
+ update: canWriteOtter,
+ },
+ admin: {
+ defaultColumns: ['name', 'scanner', 'scheduleType', 'timezone', 'enabled', 'nextRunAt'],
+ description:
+ 'Select an Otter configuration; its site, scanner, scan options, and import overrides are used automatically. Offline schedules coalesce to their latest due run.',
+ group: 'Otter',
+ useAsTitle: 'name',
+ },
+ defaultSort: 'nextRunAt',
+ disableBulkDelete: true,
+ disableBulkEdit: true,
+ fields: [
+ { name: 'name', type: 'text', required: true },
+ {
+ name: 'site',
+ type: 'relationship',
+ filterOptions: filterWritableSites,
+ index: true,
+ maxDepth: 0,
+ relationTo: 'sites',
+ required: true,
+ admin: { hidden: true },
+ },
+ {
+ name: 'scanner',
+ type: 'relationship',
+ relationTo: 'otter-scanners',
+ required: true,
+ index: true,
+ admin: { hidden: true },
+ },
+ {
+ name: 'profile',
+ label: 'Otter configuration',
+ type: 'relationship',
+ relationTo: 'otter-profiles',
+ required: true,
+ filterOptions: async ({ req }) => {
+ if (req.context.otterSchedulerUpdate) return true
+ const auth = await getAuthorization(req)
+ return auth.isAdmin ? true : { site: { in: auth.writableSiteIDs } }
+ },
+ },
+ {
+ name: 'scheduleType',
+ type: 'select',
+ options: [
+ { label: 'Run once', value: 'once' },
+ { label: 'Repeat every interval', value: 'interval' },
+ { label: 'Calendar schedule (cron)', value: 'cron' },
+ ],
+ required: true,
+ },
+ {
+ name: 'runAt',
+ type: 'date',
+ admin: { condition: (_, siblingData) => siblingData?.scheduleType === 'once' },
+ },
+ {
+ name: 'intervalMinutes',
+ type: 'number',
+ min: 5,
+ max: 10_080,
+ admin: { condition: (_, siblingData) => siblingData?.scheduleType === 'interval' },
+ },
+ {
+ name: 'cron',
+ type: 'text',
+ admin: {
+ condition: (_, siblingData) => siblingData?.scheduleType === 'cron',
+ description: 'Five fields: minute hour day-of-month month day-of-week.',
+ placeholder: '0 2 * * 1-5',
+ },
+ },
+ {
+ name: 'timezone',
+ type: 'text',
+ defaultValue: 'UTC',
+ admin: { description: 'IANA time zone, for example Europe/Berlin.' },
+ required: true,
+ },
+ { name: 'enabled', type: 'checkbox', defaultValue: true },
+ { name: 'nextRunAt', type: 'date', access: serverReadOnly, admin: { readOnly: true } },
+ { name: 'lastRunAt', type: 'date', access: serverReadOnly, admin: { readOnly: true } },
+ ],
+ hooks: {
+ beforeChange: [validateSchedule],
+ beforeDelete: [preventDeletingReferencedOtterConfig],
+ },
+ indexes: [{ fields: ['scanner', 'enabled', 'nextRunAt'] }],
+ lockDocuments: false,
+}
+
+export const OtterRuns: CollectionConfig = {
+ slug: 'otter-runs',
+ access: {
+ create: () => false,
+ delete: () => false,
+ read: canReadOtter,
+ update: () => false,
+ },
+ admin: {
+ defaultColumns: [
+ 'name',
+ 'site',
+ 'scanner',
+ 'status',
+ 'message',
+ 'scheduledFor',
+ 'startedAt',
+ 'completedAt',
+ ],
+ description: 'Immutable scan assignments and outcomes.',
+ group: 'Otter',
+ useAsTitle: 'name',
+ },
+ defaultSort: '-createdAt',
+ disableBulkDelete: true,
+ disableBulkEdit: true,
+ fields: [
+ { name: 'name', type: 'text', required: true },
+ { name: 'site', type: 'relationship', relationTo: 'sites', required: true, index: true },
+ {
+ name: 'scanner',
+ type: 'relationship',
+ relationTo: 'otter-scanners',
+ required: true,
+ index: true,
+ },
+ { name: 'profile', type: 'relationship', relationTo: 'otter-profiles' },
+ { name: 'schedule', type: 'relationship', relationTo: 'otter-schedules' },
+ { name: 'scheduleOccurrenceKey', type: 'text', access: internal, admin: { hidden: true } },
+ {
+ name: 'status',
+ type: 'select',
+ options: [
+ 'queued',
+ 'assigned',
+ 'running',
+ 'completed',
+ 'partial',
+ 'failed',
+ 'cancel-requested',
+ 'cancelled',
+ 'expired',
+ ],
+ required: true,
+ index: true,
+ },
+ { name: 'scheduledFor', type: 'date', index: true },
+ { name: 'assignedAt', type: 'date' },
+ { name: 'leaseExpiresAt', type: 'date' },
+ { name: 'startedAt', type: 'date' },
+ { name: 'completedAt', type: 'date' },
+ { name: 'progress', type: 'number', min: 0, max: 100 },
+ { name: 'message', type: 'textarea', maxLength: 4000 },
+ { name: 'import', type: 'relationship', relationTo: 'asset-imports' },
+ { name: 'configSnapshot', type: 'json', access: internal, admin: { hidden: true } },
+ { name: 'importOverridesSnapshot', type: 'json', access: internal, admin: { hidden: true } },
+ {
+ name: 'encryptedCredentialsSnapshot',
+ type: 'text',
+ access: internal,
+ admin: { hidden: true },
+ },
+ ],
+ indexes: [{ fields: ['scanner', 'status', 'createdAt'] }],
+ lockDocuments: false,
+}
+
+export const otterReadAccess = canReadOtter
+export const otterWriteAccess = canWriteOtter
+export const otterCreateAccess = canCreateOtter
diff --git a/src/collections/Sites.ts b/src/collections/Sites.ts
index 9b7a3e9..f7da22a 100644
--- a/src/collections/Sites.ts
+++ b/src/collections/Sites.ts
@@ -138,10 +138,22 @@ const preventDeletingUsedSites: CollectionBeforeDeleteHook = async ({ id, req })
req,
where: { 'permissions.site': { equals: id } },
})
+ const otters = await req.payload.count({
+ collection: 'otter-scanners',
+ overrideAccess: true,
+ req,
+ where: { site: { equals: id } },
+ })
- if (children.totalDocs || assets.totalDocs || imports.totalDocs || roles.totalDocs) {
+ if (
+ children.totalDocs ||
+ assets.totalDocs ||
+ imports.totalDocs ||
+ roles.totalDocs ||
+ otters.totalDocs
+ ) {
throw new APIError(
- 'Move or delete this site’s child sites, assets, imports, and role permissions before deleting it.',
+ 'Move or delete this site’s child sites, assets, imports, Otter scanners, and role permissions before deleting it.',
400,
)
}
diff --git a/src/collections/UserRoles.ts b/src/collections/UserRoles.ts
index 05eb2b9..da0c0d3 100644
--- a/src/collections/UserRoles.ts
+++ b/src/collections/UserRoles.ts
@@ -123,6 +123,16 @@ export const UserRoles: CollectionConfig = {
defaultValue: false,
label: 'Administrator',
},
+ {
+ name: 'otterConfig',
+ type: 'checkbox',
+ admin: {
+ condition: (data) => !data.isAdmin,
+ description: 'Manage Otter scanners, configuration, and schedules within permitted sites.',
+ },
+ defaultValue: false,
+ label: 'Otter Config',
+ },
{
name: 'permissions',
type: 'array',
diff --git a/src/collections/Users/index.ts b/src/collections/Users/index.ts
index a65286a..de69c28 100644
--- a/src/collections/Users/index.ts
+++ b/src/collections/Users/index.ts
@@ -8,6 +8,7 @@ import {
hideFromNonAdmins,
} from '../../access/authorization'
import { ensureAdminRole } from '../UserRoles'
+import { otterMachineStrategy } from '../../otter/security'
const hasUsers = async (req: PayloadRequest) =>
(
@@ -53,7 +54,7 @@ export const Users: CollectionConfig = {
hidden: hideFromNonAdmins,
useAsTitle: 'name',
},
- auth: { useAPIKey: true },
+ auth: { strategies: [otterMachineStrategy], useAPIKey: true },
fields: [
{
name: 'name',
diff --git a/src/components/BeforeDashboard/index.tsx b/src/components/BeforeDashboard/index.tsx
index 534b16b..85d96d8 100644
--- a/src/components/BeforeDashboard/index.tsx
+++ b/src/components/BeforeDashboard/index.tsx
@@ -56,6 +56,7 @@ const BeforeDashboard = async ({
const canCreateAssets = Boolean(permissions?.collections?.assets?.create)
const canCreateImports = Boolean(permissions?.collections?.['asset-imports']?.create)
const canReadSites = Boolean(permissions?.collections?.sites?.read)
+ const canManageOtters = Boolean(permissions?.collections?.['otter-scanners']?.read)
return (
@@ -67,7 +68,7 @@ const BeforeDashboard = async ({
Track industrial devices, network identities, ownership data, and operational state.
- {canReadSites || canCreateImports || canCreateAssets ? (
+ {canReadSites || canCreateImports || canCreateAssets || canManageOtters ? (
{canReadSites ? (
) : null}
+ {canManageOtters ? (
+
+ Manage Otters
+
+ ) : null}
) : null}
diff --git a/src/components/OtterCredentialsField/index.tsx b/src/components/OtterCredentialsField/index.tsx
new file mode 100644
index 0000000..b076747
--- /dev/null
+++ b/src/components/OtterCredentialsField/index.tsx
@@ -0,0 +1,307 @@
+'use client'
+
+import { Button, useDocumentInfo, useField } from '@payloadcms/ui'
+import { useState } from 'react'
+import type { UIFieldClientComponent } from 'payload'
+
+type SnmpEntry = {
+ version: string
+ community: string
+ username: string
+ contextName: string
+ authProtocol: string
+ authPassword: string
+ privacyProtocol: string
+ privacyPassword: string
+}
+type OpcuaEntry = { username: string; password: string }
+const emptySnmp = (): SnmpEntry => ({
+ version: '2c',
+ community: '',
+ username: '',
+ contextName: '',
+ authProtocol: '',
+ authPassword: '',
+ privacyProtocol: '',
+ privacyPassword: '',
+})
+const input = (label: string, value: string, onChange: (value: string) => void, type = 'text') => (
+
+ {label}
+ onChange(event.target.value)}
+ type={type}
+ value={value}
+ />
+
+)
+
+const OtterCredentialsField: UIFieldClientComponent = ({ readOnly }) => {
+ const { id } = useDocumentInfo()
+ const { value: configured } = useField({ path: 'credentialsConfigured' })
+ const [savedConfigured, setSavedConfigured] = useState()
+ const hasCredentials = savedConfigured ?? Boolean(configured)
+ const [snmp, setSnmp] = useState([])
+ const [opcua, setOpcua] = useState([])
+ const [opcuaUsername, setOpcuaUsername] = useState('')
+ const [opcuaPassword, setOpcuaPassword] = useState('')
+ const [message, setMessage] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ const save = async (clear = false) => {
+ if (!id) return setMessage('Save the profile before configuring credentials.')
+ const settings = snmp
+ .map((entry) =>
+ Object.fromEntries(
+ Object.entries(entry).filter(([key, value]) => key === 'version' || value !== ''),
+ ),
+ )
+ .filter((entry) => Object.keys(entry).length > 1)
+ const opcuaCredentials = opcua.filter((entry) => entry.username || entry.password)
+ const credentials = {
+ ...(settings.length ? { snmp: settings } : {}),
+ ...(opcuaCredentials.length ? { opcuaCredentials } : {}),
+ ...(opcuaUsername ? { opcuaUsername } : {}),
+ ...(opcuaPassword ? { opcuaPassword } : {}),
+ }
+ if (!clear && !Object.keys(credentials).length)
+ return setMessage('Add at least one SNMP or OPC UA credential before saving.')
+
+ setBusy(true)
+ setMessage('')
+ try {
+ const response = await fetch(
+ `/api/otter/v1/profiles/${encodeURIComponent(String(id))}/credentials`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(clear ? { clear: true } : { credentials }),
+ },
+ )
+ if (!response.ok) {
+ const result = (await response.json()) as { errors?: Array<{ message?: string }> }
+ throw new Error(result.errors?.[0]?.message || 'Could not save Otter credentials.')
+ }
+ const result = (await response.json()) as { credentialsConfigured: boolean }
+ setSnmp([])
+ setOpcua([])
+ setOpcuaUsername('')
+ setOpcuaPassword('')
+ setSavedConfigured(result.credentialsConfigured)
+ setMessage(
+ clear
+ ? 'Stored credentials cleared.'
+ : 'Credentials encrypted and saved. Submitted secret values were cleared from this form.',
+ )
+ } catch (error) {
+ setMessage(error instanceof Error ? error.message : 'Could not save Otter credentials.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ SNMP and OPC UA credentials
+
+ {!id
+ ? 'New configurations automatically start with SNMP v2c community public. Save the configuration to replace or clear these defaults.'
+ : hasCredentials
+ ? 'Credentials are configured and encrypted at rest. Existing values cannot be read.'
+ : 'No credentials are stored for this profile.'}
+
+ {!readOnly ? (
+ <>
+
+ SNMP
+ {snmp.map((entry, index) => (
+
+
+ Version
+
+ setSnmp((items) =>
+ items.map((item, i) =>
+ i === index ? { ...item, version: event.target.value } : item,
+ ),
+ )
+ }
+ value={entry.version}
+ >
+ {['auto', '1', '2c', '3'].map((version) => (
+
+ {version}
+
+ ))}
+
+
+ {input(
+ 'Community',
+ entry.community,
+ (community) =>
+ setSnmp((items) =>
+ items.map((item, i) => (i === index ? { ...item, community } : item)),
+ ),
+ 'password',
+ )}
+ {input('Username', entry.username, (username) =>
+ setSnmp((items) =>
+ items.map((item, i) => (i === index ? { ...item, username } : item)),
+ ),
+ )}
+ {input('Context name', entry.contextName, (contextName) =>
+ setSnmp((items) =>
+ items.map((item, i) => (i === index ? { ...item, contextName } : item)),
+ ),
+ )}
+
+ Authentication protocol
+
+ setSnmp((items) =>
+ items.map((item, i) =>
+ i === index ? { ...item, authProtocol: event.target.value } : item,
+ ),
+ )
+ }
+ value={entry.authProtocol}
+ >
+ None
+ {['md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512'].map((protocol) => (
+
+ {protocol.toUpperCase()}
+
+ ))}
+
+
+ {input(
+ 'Authentication password',
+ entry.authPassword,
+ (authPassword) =>
+ setSnmp((items) =>
+ items.map((item, i) => (i === index ? { ...item, authPassword } : item)),
+ ),
+ 'password',
+ )}
+
+ Privacy protocol
+
+ setSnmp((items) =>
+ items.map((item, i) =>
+ i === index ? { ...item, privacyProtocol: event.target.value } : item,
+ ),
+ )
+ }
+ value={entry.privacyProtocol}
+ >
+ None
+ {['des', 'aes128', 'aes192', 'aes256'].map((protocol) => (
+
+ {protocol.toUpperCase()}
+
+ ))}
+
+
+ {input(
+ 'Privacy password',
+ entry.privacyPassword,
+ (privacyPassword) =>
+ setSnmp((items) =>
+ items.map((item, i) => (i === index ? { ...item, privacyPassword } : item)),
+ ),
+ 'password',
+ )}
+ setSnmp((items) => items.filter((_, i) => i !== index))}
+ type="button"
+ >
+ Remove SNMP credential
+
+
+ ))}
+ = 32}
+ onClick={() => setSnmp((items) => [...items, emptySnmp()])}
+ type="button"
+ >
+ Add SNMP credential
+
+
+
+
+ OPC UA
+ {opcua.map((entry, index) => (
+
+ {input('Username', entry.username, (username) =>
+ setOpcua((items) =>
+ items.map((item, i) => (i === index ? { ...item, username } : item)),
+ ),
+ )}
+ {input(
+ 'Password',
+ entry.password,
+ (password) =>
+ setOpcua((items) =>
+ items.map((item, i) => (i === index ? { ...item, password } : item)),
+ ),
+ 'password',
+ )}
+ setOpcua((items) => items.filter((_, i) => i !== index))}
+ type="button"
+ >
+ Remove OPC UA credential
+
+
+ ))}
+ = 32}
+ onClick={() => setOpcua((items) => [...items, { username: '', password: '' }])}
+ type="button"
+ >
+ Add OPC UA credential
+
+ Optional single-credential fallback from otter.json:
+
+ {input('Fallback username', opcuaUsername, setOpcuaUsername)}
+ {input('Fallback password', opcuaPassword, setOpcuaPassword, 'password')}
+
+
+
+
+ void save()} type="button">
+ Encrypt and save credentials
+
+ {hasCredentials ? (
+ void save(true)}
+ type="button"
+ >
+ Clear stored credentials
+
+ ) : null}
+
+ >
+ ) : null}
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+ )
+}
+
+export default OtterCredentialsField
diff --git a/src/components/OtterEnrollment/index.scss b/src/components/OtterEnrollment/index.scss
new file mode 100644
index 0000000..2e22a26
--- /dev/null
+++ b/src/components/OtterEnrollment/index.scss
@@ -0,0 +1,22 @@
+.otter-enrollment {
+ margin-bottom: var(--base);
+ label {
+ display: block;
+ margin-bottom: var(--base);
+ }
+ input,
+ select {
+ display: block;
+ width: 100%;
+ margin-top: calc(var(--base) / 2);
+ }
+}
+
+.otter-enrollment__instructions {
+ code {
+ display: block;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ margin: var(--base) 0;
+ }
+}
diff --git a/src/components/OtterEnrollment/index.tsx b/src/components/OtterEnrollment/index.tsx
new file mode 100644
index 0000000..8b54726
--- /dev/null
+++ b/src/components/OtterEnrollment/index.tsx
@@ -0,0 +1,194 @@
+'use client'
+
+import {
+ Button,
+ ConfirmationModal,
+ CopyToClipboard,
+ Gutter,
+ useAuth,
+ useConfig,
+ useModal,
+} from '@payloadcms/ui'
+import { useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import './index.scss'
+
+export type Enrollment = {
+ enrollmentToken: string
+ expiresAt: string
+ controllerUrl: string
+ scanner?: { id: string }
+}
+
+export const EnrollmentInstructions = ({ enrollment }: { enrollment: Enrollment }) => (
+
+
+ Copy this single-use token now. It expires {new Date(enrollment.expiresAt).toLocaleString()}{' '}
+ and is shown only once.
+
+
{enrollment.enrollmentToken}
+
+
Run the command on the scanner host, then paste the token at the prompt:
+ {['otserver-otter', '.\\otserver-otter.exe'].map((binary) => {
+ const command = `${binary} controller --server-url ${enrollment.controllerUrl} --enroll --ack-authorized`
+ return (
+
+ {command}
+
+
+ )
+ })}
+
+ After enrollment, restart with the same command without --enroll to use the saved
+ credential.
+
+
+)
+
+export async function otterAction(path: string, data?: unknown) {
+ const response = await fetch(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ ...(data === undefined ? {} : { body: JSON.stringify(data) }),
+ })
+ const result = await response.json()
+ if (!response.ok)
+ throw new Error(result.errors?.[0]?.message || result.message || 'The Otter action failed.')
+ return result
+}
+
+export default function OtterEnrollment() {
+ const { permissions } = useAuth()
+ const {
+ config: {
+ routes: { admin },
+ },
+ } = useConfig()
+ const { openModal, isModalOpen } = useModal()
+ const router = useRouter()
+ const [sites, setSites] = useState>([])
+ const [name, setName] = useState('')
+ const [site, setSite] = useState('')
+ const [enrollment, setEnrollment] = useState()
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+ const slug = 'otter-scanner-enrollment'
+ const opened = isModalOpen(slug)
+
+ useEffect(() => {
+ if (!opened) return
+ const abort = new AbortController()
+ void fetch('/api/otter/v1/overview', { cache: 'no-store', signal: abort.signal })
+ .then(async (response) => {
+ if (!response.ok) throw new Error('Could not load enrollment sites.')
+ return response.json() as Promise<{ writableSites: Array<{ id: string; name: string }> }>
+ })
+ .then((result) => {
+ setSites(result.writableSites)
+ setSite(result.writableSites[0]?.id || '')
+ })
+ .catch((error: unknown) => {
+ if (!abort.signal.aborted)
+ setError(error instanceof Error ? error.message : 'Could not load sites.')
+ })
+ return () => abort.abort()
+ }, [opened])
+
+ const clear = () => {
+ setEnrollment(undefined)
+ setName('')
+ setError('')
+ router.refresh()
+ }
+
+ if (!permissions?.collections?.['otter-profiles']?.create) return null
+ return (
+
+ {
+ clear()
+ openModal(slug)
+ }}
+ type="button"
+ >
+ Create New
+
+ {
+ const id = enrollment?.scanner?.id
+ clear()
+ if (id) router.push(`${admin}/collections/otter-scanners/${id}`)
+ }}
+ body={
+ <>
+
+
+ Download Otter for Windows or Linux
+ {' '}
+ on a host that can reach the network you are authorized to scan.
+
+ {error ? {error}
: null}
+ {enrollment ? (
+
+ ) : (
+
+ )}
+ >
+ }
+ />
+
+ )
+}
diff --git a/src/components/OtterInterfaceField/index.tsx b/src/components/OtterInterfaceField/index.tsx
new file mode 100644
index 0000000..8e8bbcf
--- /dev/null
+++ b/src/components/OtterInterfaceField/index.tsx
@@ -0,0 +1,120 @@
+'use client'
+
+import {
+ FieldError,
+ FieldLabel,
+ ReactSelect,
+ TextInput,
+ useDocumentInfo,
+ useField,
+ useFormFields,
+} from '@payloadcms/ui'
+import { useEffect, useState } from 'react'
+import type { ChangeEvent } from 'react'
+import type { TextFieldClientComponent } from 'payload'
+
+type Adapter = {
+ name: string
+ friendlyName: string
+ description: string
+ macAddress?: string
+ ipAddresses: string[]
+}
+
+const OtterInterfaceField: TextFieldClientComponent = ({ field, path }) => {
+ const { setValue, value } = useField({ path })
+ const { id, collectionSlug } = useDocumentInfo()
+ const scanner = useFormFields(([fields]) => fields.scanner?.value)
+ const scannerID =
+ collectionSlug === 'otter-scanners'
+ ? String(id || '')
+ : typeof scanner === 'string'
+ ? scanner
+ : scanner && typeof scanner === 'object' && 'value' in scanner
+ ? String(scanner.value)
+ : ''
+ const [inventory, setInventory] = useState<{ scannerID: string; adapters: Adapter[] }>()
+
+ useEffect(() => {
+ if (!scannerID) return
+ const abort = new AbortController()
+ void fetch('/api/otter/v1/overview', { cache: 'no-store', signal: abort.signal })
+ .then((response) =>
+ response.ok
+ ? (response.json() as Promise<{
+ scanners?: Array<{ id: string; networkAdapters?: Adapter[] }>
+ }>)
+ : null,
+ )
+ .then((overview) =>
+ setInventory({
+ scannerID,
+ adapters:
+ overview?.scanners?.find((entry) => entry.id === scannerID)?.networkAdapters || [],
+ }),
+ )
+ .catch(() => setInventory({ scannerID, adapters: [] }))
+ return () => abort.abort()
+ }, [scannerID])
+
+ const adapters = inventory?.scannerID === scannerID ? inventory.adapters : []
+ const options = adapters.map((adapter) => ({
+ value: adapter.name,
+ label: `${adapter.friendlyName} · ${adapter.ipAddresses.join(', ') || 'no IP'} · ${adapter.macAddress || 'no MAC'}`,
+ }))
+ if (value && !options.some((option) => option.value === value))
+ options.unshift({ value, label: `${value} · not currently reported by scanner` })
+ const selected = options.find((option) => option.value === value)
+ const selectedAdapter = adapters.find((adapter) => adapter.name === value)
+
+ return (
+
+
+ {options.length ? (
+
+ setValue(
+ Array.isArray(option)
+ ? ''
+ : typeof option === 'object' && option
+ ? String(option.value)
+ : '',
+ )
+ }
+ options={options}
+ placeholder="Choose a reported network adapter"
+ value={selected}
+ />
+ ) : null}
+ ) => setValue(event.target.value)}
+ />
+
+ {selectedAdapter ? (
+
+ {selectedAdapter.description} · {selectedAdapter.name} ·{' '}
+ {selectedAdapter.macAddress || 'MAC not reported'} ·{' '}
+ {selectedAdapter.ipAddresses.join(', ') || 'No IP addresses'}
+
+ ) : (
+
+ {value
+ ? 'The selected adapter is not currently reported; its name is preserved.'
+ : options.length
+ ? 'Choose a reported adapter or enter its technical name.'
+ : 'Enroll the scanner to select a reported adapter, or enter its technical name.'}
+
+ )}
+
+ )
+}
+
+export default OtterInterfaceField
diff --git a/src/components/OtterManagedSettingsField/index.tsx b/src/components/OtterManagedSettingsField/index.tsx
new file mode 100644
index 0000000..a31d308
--- /dev/null
+++ b/src/components/OtterManagedSettingsField/index.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import { useFormFields } from '@payloadcms/ui'
+import { useEffect, useState } from 'react'
+import type { UIFieldClientComponent } from 'payload'
+
+type Site = { id: string; name: string }
+type Scanner = { id: string; enabled: boolean; enrolled: boolean }
+type Overview = { controllerUrl?: string; sites?: Site[]; scanners?: Scanner[] }
+
+const idOf = (value: unknown) => {
+ if (typeof value === 'string') return value
+ if (value && typeof value === 'object' && 'value' in value) return String(value.value)
+ return ''
+}
+
+const OtterManagedSettingsField: UIFieldClientComponent = () => {
+ const [name, siteValue, scannerValue] = useFormFields(([fields]) => [
+ fields.name?.value,
+ fields.site?.value,
+ fields.scanner?.value,
+ ])
+ const [overview, setOverview] = useState({})
+
+ useEffect(() => {
+ const abort = new AbortController()
+ void fetch('/api/otter/v1/overview', { cache: 'no-store', signal: abort.signal })
+ .then((response) => (response.ok ? (response.json() as Promise) : {}))
+ .then(setOverview)
+ .catch(() => undefined)
+ return () => abort.abort()
+ }, [])
+
+ const siteID = idOf(siteValue)
+ const scannerID = idOf(scannerValue)
+ const siteName =
+ overview.sites?.find((site) => site.id === siteID)?.name || siteID || 'Select a site'
+ const scanner = overview.scanners?.find((item) => item.id === scannerID)
+ const credential = !scanner
+ ? 'Available after choosing a scanner'
+ : !scanner.enabled
+ ? 'Scanner is disabled'
+ : scanner.enrolled
+ ? 'Enrolled machine credential'
+ : 'Awaiting enrollment'
+
+ return (
+
+ Managed settings
+
+ These otter.json values are fixed by the controller and cannot be overridden by a scan
+ profile.
+
+
+
+
Profile name
+ {typeof name === 'string' ? name : 'Enter a profile name'}
+
+
+
Site
+ {siteName}
+
+
+
serverUrl
+ {overview.controllerUrl || 'Set OTSERVER_PUBLIC_URL on the OTserver deployment'}
+
+
+
output
+ Run-specific export in Otter’s private state directory
+
+
+
apiKey
+ {credential}; scanner authentication is managed by OTserver
+
+
+
+ )
+}
+
+export default OtterManagedSettingsField
diff --git a/src/components/OtterManagementView/index.scss b/src/components/OtterManagementView/index.scss
new file mode 100644
index 0000000..a111fbf
--- /dev/null
+++ b/src/components/OtterManagementView/index.scss
@@ -0,0 +1,138 @@
+.otter-management {
+ display: grid;
+ gap: calc(var(--base) * 2);
+ margin: 0 auto;
+ max-width: 1280px;
+ width: 100%;
+}
+
+.otter-management__header,
+.otter-management__section-heading,
+.otter-management__token {
+ align-items: center;
+ display: flex;
+ gap: var(--base);
+ justify-content: space-between;
+}
+
+.otter-management__header nav,
+.otter-management__actions,
+.otter-management__token,
+.otter-management__commands {
+ display: flex;
+ flex-wrap: wrap;
+ gap: calc(var(--base) / 2);
+}
+
+.otter-management__header h1,
+.otter-management h2,
+.otter-management p {
+ margin: 0;
+}
+.otter-management__header p,
+.otter-management__setup > p,
+.otter-management__section-heading p,
+.otter-management__token p,
+.otter-management__steps small {
+ color: var(--theme-elevation-600);
+ margin-top: 0.35rem;
+}
+.otter-management__eyebrow {
+ color: var(--theme-elevation-600);
+ font-size: 0.8rem;
+ margin-bottom: 0.35rem !important;
+}
+.otter-management__setup,
+.otter-management__section {
+ border-top: 1px solid var(--theme-elevation-200);
+ display: grid;
+ gap: var(--base);
+ padding-top: var(--base);
+}
+.otter-management__steps {
+ display: grid;
+ gap: calc(var(--base) * 1.5);
+ margin: 0;
+ padding-left: 1.5rem;
+}
+.otter-management__steps > li {
+ display: grid;
+ gap: var(--base);
+ padding-left: 0.35rem;
+}
+.otter-management__steps h3 {
+ font-size: 1rem;
+ margin: 0;
+}
+.otter-management__enroll {
+ align-items: end;
+ display: grid;
+ gap: var(--base);
+ grid-template-columns: minmax(220px, 1fr) minmax(180px, 0.8fr) auto;
+}
+.otter-management__enroll label,
+.otter-management__scanner-select,
+.otter-management__steps > li > label {
+ display: grid;
+ gap: 0.4rem;
+}
+.otter-management input,
+.otter-management select {
+ background: var(--theme-input-bg);
+ border: 1px solid var(--theme-elevation-400);
+ border-radius: var(--style-radius-s);
+ color: var(--theme-text);
+ min-height: 42px;
+ padding: 0.55rem 0.7rem;
+}
+.otter-management__message {
+ border-inline-start: 3px solid var(--theme-success-500);
+ padding: 0.75rem 1rem;
+}
+.otter-management__token {
+ align-items: start;
+}
+.otter-management__token code,
+.otter-management__commands code {
+ background: var(--theme-elevation-50);
+ overflow-wrap: anywhere;
+ padding: 0.7rem;
+}
+.otter-management__token code {
+ flex: 1 1 360px;
+}
+.otter-management__commands {
+ align-items: center;
+}
+.otter-management__commands p {
+ flex-basis: 100%;
+}
+.otter-management__commands code {
+ flex: 1 1 360px;
+}
+.otter-management__table-wrap {
+ overflow-x: auto;
+}
+.otter-management .table table {
+ min-width: 720px;
+}
+.otter-management td small {
+ color: var(--theme-elevation-600);
+ display: block;
+ margin-top: 0.3rem;
+}
+.otter-management__empty {
+ color: var(--theme-elevation-600);
+ padding: 1rem 0;
+}
+
+@media (max-width: 900px) {
+ .otter-management__header,
+ .otter-management__section-heading {
+ align-items: start;
+ flex-direction: column;
+ }
+ .otter-management__enroll {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/components/OtterManagementView/index.tsx b/src/components/OtterManagementView/index.tsx
new file mode 100644
index 0000000..c09ed23
--- /dev/null
+++ b/src/components/OtterManagementView/index.tsx
@@ -0,0 +1,8 @@
+import type { AdminViewServerProps } from 'payload'
+import { redirect } from 'next/navigation'
+
+const OtterManagementView = async (props: AdminViewServerProps) => {
+ redirect(`${props.payload.config.routes.admin}/collections/otter-scanners`)
+}
+
+export default OtterManagementView
diff --git a/src/components/OtterManagementView/view.tsx b/src/components/OtterManagementView/view.tsx
new file mode 100644
index 0000000..62c5980
--- /dev/null
+++ b/src/components/OtterManagementView/view.tsx
@@ -0,0 +1,827 @@
+'use client'
+
+import Link from 'next/link'
+import { useCallback, useEffect, useState } from 'react'
+import { Button, Gutter, Pill } from '@payloadcms/ui'
+
+type Site = { id: string; name: string }
+type Scanner = {
+ id: string
+ name: string
+ enabled: boolean
+ enrolled: boolean
+ lastSeenAt?: string
+ nextRunAt?: string
+ site: Site | string
+ version?: string
+ interfaces?: string[]
+ computerName?: string
+ inventoryReportedAt?: string
+ networkAdapters?: Array<{
+ name: string
+ friendlyName: string
+ description: string
+ macAddress?: string
+ ipAddresses: string[]
+ }>
+ currentRun?: string
+ lastRun?: { id: string; name: string; status: string; completedAt?: string }
+}
+type Profile = { id: string; name: string; scanner: string; credentialsConfigured: boolean }
+type Run = {
+ id: string
+ name: string
+ scanner: string
+ status: string
+ completedAt?: string
+ scheduledFor?: string
+ progress?: number
+ message?: string
+ import?: string
+}
+type Overview = {
+ controllerUrl?: string
+ scanners: Scanner[]
+ profiles: Profile[]
+ sites: Site[]
+ writableSites: Site[]
+ runs: Run[]
+}
+
+const date = (value?: string) => (value ? new Date(value).toLocaleString() : '—')
+const errorText = async (response: Response, fallback: string) => {
+ try {
+ const data = (await response.json()) as {
+ errors?: Array<{ message?: string }>
+ message?: string
+ }
+ return data.errors?.[0]?.message || data.message || fallback
+ } catch {
+ return fallback
+ }
+}
+
+export default function OtterManagement({ adminRoute }: { adminRoute: string }) {
+ const [overview, setOverview] = useState({
+ scanners: [],
+ profiles: [],
+ sites: [],
+ writableSites: [],
+ runs: [],
+ })
+ const [name, setName] = useState('')
+ const [site, setSite] = useState('')
+ const [setupScannerID, setSetupScannerID] = useState('')
+ const [token, setToken] = useState('')
+ const [tokenScannerID, setTokenScannerID] = useState('')
+ const [tokenExpiresAt, setTokenExpiresAt] = useState('')
+ const [controllerUrl, setControllerUrl] = useState('')
+ const [setupRunID, setSetupRunID] = useState('')
+ const [message, setMessage] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [observedAt, setObservedAt] = useState(0)
+ const [selectedProfiles, setSelectedProfiles] = useState>({})
+
+ const load = useCallback(async () => {
+ const response = await fetch('/api/otter/v1/overview', { cache: 'no-store' })
+ if (!response.ok)
+ throw new Error(await errorText(response, 'Could not load Otter management data.'))
+ const result = (await response.json()) as Overview
+ setOverview(result)
+ setControllerUrl(result.controllerUrl || '')
+ setObservedAt(Date.now())
+ setSite((current) => current || result.writableSites[0]?.id || '')
+ setSetupScannerID((current) => current || result.scanners[0]?.id || '')
+ return result
+ }, [])
+
+ const checkEnrollment = useCallback(async () => {
+ const result = await load()
+ const scanner = result.scanners.find((entry) => entry.id === tokenScannerID)
+ if (token && scanner?.enrolled) {
+ setToken('')
+ setTokenScannerID('')
+ setTokenExpiresAt('')
+ setMessage(
+ `${scanner.name} is connected. Restart Otter without --enroll to use its saved credential.`,
+ )
+ }
+ }, [load, token, tokenScannerID])
+
+ useEffect(() => {
+ const abort = new AbortController()
+ void fetch('/api/otter/v1/overview', { cache: 'no-store', signal: abort.signal })
+ .then(async (response) => {
+ if (!response.ok)
+ throw new Error(await errorText(response, 'Could not load Otter management data.'))
+ return (await response.json()) as Overview
+ })
+ .then((result) => {
+ setOverview(result)
+ setObservedAt(Date.now())
+ setControllerUrl(result.controllerUrl || '')
+ setSite(result.writableSites[0]?.id || '')
+ setSetupScannerID(result.scanners[0]?.id || '')
+ })
+ .catch((error: unknown) => {
+ if (!(error instanceof DOMException && error.name === 'AbortError'))
+ setMessage(
+ error instanceof Error ? error.message : 'Could not load Otter management data.',
+ )
+ })
+ return () => abort.abort()
+ }, [])
+
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ void checkEnrollment().catch(() => undefined)
+ }, 15_000)
+ return () => window.clearInterval(timer)
+ }, [checkEnrollment])
+
+ const request = async (path: string, data?: unknown) => {
+ const response = await fetch(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ ...(data === undefined ? {} : { body: JSON.stringify(data) }),
+ })
+ if (!response.ok) throw new Error(await errorText(response, 'The Otter action failed.'))
+ return response.json() as Promise>
+ }
+
+ const act = async (work: () => Promise) => {
+ setBusy(true)
+ setMessage('')
+ try {
+ await work()
+ await load()
+ } catch (error) {
+ setMessage(error instanceof Error ? error.message : 'The Otter action failed.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const createScanner = () =>
+ act(async () => {
+ const result = await request('/api/otter/v1/scanners', { name, site })
+ setToken(String(result.enrollmentToken || ''))
+ const scanner = result.scanner as { id?: string; name?: string } | undefined
+ setTokenScannerID(String(scanner?.id || ''))
+ setTokenExpiresAt(String(result.expiresAt || ''))
+ setControllerUrl(String(result.controllerUrl || ''))
+ setSetupScannerID(String(scanner?.id || ''))
+ setSetupRunID('')
+ setMessage(
+ `Enrollment token expires ${date(String(result.expiresAt || ''))}. Copy it now; it is shown only once.`,
+ )
+ setName('')
+ })
+
+ const runNow = (scanner: Scanner, chosenProfileID?: string) => {
+ const scannerProfiles = overview.profiles.filter((profile) => profile.scanner === scanner.id)
+ const profileID = chosenProfileID || selectedProfiles[scanner.id] || scannerProfiles[0]?.id
+ if (!profileID) {
+ setMessage('Create a scan profile for this scanner before running it.')
+ return
+ }
+ void act(async () => {
+ const result = await request('/api/otter/v1/runs', { scannerID: scanner.id, profileID })
+ setSetupRunID(String(result.id || ''))
+ setMessage('Scan queued. Otter will receive it on its next poll.')
+ })
+ }
+
+ const siteName = (value: Scanner['site']) =>
+ typeof value === 'object'
+ ? value.name
+ : overview.sites.find((entry) => entry.id === value)?.name || value
+
+ const setupScanner = overview.scanners.find((scanner) => scanner.id === setupScannerID)
+ const setupProfiles = overview.profiles.filter((profile) => profile.scanner === setupScannerID)
+ const setupProfileID = selectedProfiles[setupScannerID] || setupProfiles[0]?.id || ''
+ const setupRun = overview.runs.find(
+ (run) => run.id === setupRunID && run.scanner === setupScannerID,
+ )
+ const scannerOnline = (scanner: Scanner) =>
+ Boolean(scanner.lastSeenAt && observedAt - Date.parse(scanner.lastSeenAt) < 120_000)
+ const setupSiteID = setupScanner
+ ? typeof setupScanner.site === 'string'
+ ? setupScanner.site
+ : setupScanner.site.id
+ : ''
+ const canWriteSetup = Boolean(
+ setupSiteID && overview.writableSites.some((entry) => entry.id === setupSiteID),
+ )
+ const setupControllerUrl = controllerUrl
+ const linuxEnrollCommand = `otserver-otter controller --server-url ${setupControllerUrl} --enroll --ack-authorized`
+ const windowsEnrollCommand = `.\\otserver-otter.exe controller --server-url ${setupControllerUrl} --enroll --ack-authorized`
+ const linuxStartCommand = `otserver-otter controller --server-url ${setupControllerUrl} --ack-authorized`
+ const windowsStartCommand = `.\\otserver-otter.exe controller --server-url ${setupControllerUrl} --ack-authorized`
+
+ const reenrollSetupScanner = () =>
+ act(async () => {
+ if (!setupScanner) return
+ const result = await request(`/api/otter/v1/scanners/${setupScanner.id}/reenroll`)
+ setToken(String(result.enrollmentToken || ''))
+ setTokenScannerID(setupScanner.id)
+ setTokenExpiresAt(String(result.expiresAt || ''))
+ setControllerUrl(String(result.controllerUrl || ''))
+ setMessage(
+ `New token expires ${date(String(result.expiresAt || ''))}. The previous scanner credential was revoked.`,
+ )
+ })
+
+ const copy = async (value: string, label: string) => {
+ try {
+ await navigator.clipboard.writeText(value)
+ setMessage(`${label} copied.`)
+ } catch {
+ setMessage('Clipboard access is unavailable. Select and copy the value instead.')
+ }
+ }
+
+ return (
+
+
+
+
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+ Set up Otter
+ Follow these steps to connect a scanner and run its first inventory scan.
+
+
+ Download Otter
+
+ Install Otter on the Windows or Linux host that can reach the industrial network.
+
+
+ Download the latest Otter release
+
+
+
+ Create or choose a scanner
+
+ {!overview.writableSites.length ? (
+ You need write access to a site to create an enrollment token.
+ ) : null}
+ {overview.scanners.length ? (
+
+ Or continue setup for an existing scanner
+ {
+ setSetupScannerID(event.target.value)
+ setSetupRunID('')
+ }}
+ value={setupScannerID}
+ >
+ {overview.scanners.map((scanner) => (
+
+ {scanner.name} — {siteName(scanner.site)}
+
+ ))}
+
+
+ ) : null}
+
+ {setupScanner ? (
+ <>
+
+ Connect {setupScanner.name}
+
+ Status:{' '}
+
+ {!setupScanner.enabled
+ ? 'Disabled'
+ : !setupScanner.enrolled
+ ? 'Awaiting enrollment'
+ : scannerOnline(setupScanner)
+ ? 'Connected'
+ : 'Offline'}
+
+ {setupScanner.enrolled && setupScanner.version
+ ? ` · Otter ${setupScanner.version}`
+ : ''}
+
+ {setupScanner.enrolled && setupControllerUrl ? (
+
+
+ Otter is enrolled. Run this command on its host to start or restart its
+ controller:
+
+
{linuxStartCommand}
+
void copy(linuxStartCommand, 'Linux command')}
+ type="button"
+ >
+ Copy Linux command
+
+
{windowsStartCommand}
+
void copy(windowsStartCommand, 'Windows command')}
+ type="button"
+ >
+ Copy Windows command
+
+
+ ) : null}
+ {setupScanner.enrolled && !setupControllerUrl ? (
+
+ Set OTSERVER_PUBLIC_URL to show the controller restart command.
+
+ ) : null}
+ {token && tokenScannerID === setupScannerID ? (
+
+
+
Copy this single-use token now
+
+ Paste it at the Otter prompt. It expires {date(tokenExpiresAt)} and can
+ only be used once.
+
+
+ Controller URL: {controllerUrl}
+
+
+
{token}
+
void copy(token, 'Enrollment token')}
+ type="button"
+ >
+ Copy token
+
+
+
On Linux, run:
+
{linuxEnrollCommand}
+
void copy(linuxEnrollCommand, 'Linux enrollment command')}
+ type="button"
+ >
+ Copy Linux command
+
+
On Windows PowerShell, run:
+
{windowsEnrollCommand}
+
+ void copy(windowsEnrollCommand, 'Windows enrollment command')
+ }
+ type="button"
+ >
+ Copy Windows command
+
+
+
+ ) : null}
+ {!setupScanner.enrolled && !token ? (
+
+ Generate a token above, then run the enrollment command on the Otter host.
+ This page checks for the connection automatically.
+
+ ) : null}
+ {!setupScanner.enrolled ? (
+ void act(checkEnrollment)} type="button">
+ Check connection now
+
+ ) : null}
+ {canWriteSetup ? (
+ void reenrollSetupScanner()}
+ type="button"
+ >
+ Generate a new token (revokes the current credential)
+
+ ) : null}
+
+
+ Choose a scan profile
+ {setupProfiles.length ? (
+
+ Profile for the first scan
+
+ setSelectedProfiles((current) => ({
+ ...current,
+ [setupScanner.id]: event.target.value,
+ }))
+ }
+ value={setupProfileID}
+ >
+ {setupProfiles.map((profile) => (
+
+ {profile.name}
+
+ ))}
+
+
+ ) : null}
+ {setupScanner.computerName ? (
+
+ Computer: {setupScanner.computerName} · Adapter details last
+ reported {date(setupScanner.inventoryReportedAt)}
+
+ ) : (
+
+ Computer and adapter details will appear after this scanner enrolls and checks
+ in.
+
+ )}
+ {setupScanner.networkAdapters?.length ? (
+
+
+
+
+ Interface
+ MAC address
+ IP addresses
+
+
+
+ {setupScanner.networkAdapters.map((adapter) => (
+
+
+ {adapter.friendlyName}
+
+ {adapter.name} · {adapter.description}
+
+
+ {adapter.macAddress || '—'}
+ {adapter.ipAddresses.join(', ') || '—'}
+
+ ))}
+
+
+
+ ) : null}
+ {canWriteSetup ? (
+
+ Create or edit profiles with the{' '}
+
+ Payload profile editor
+
+ . It includes adapter selection, every discovery option, OPC UA ports, and
+ encrypted credentials.
+
+ ) : (
+
+ You have read access to this scanner. A user with write access can create or
+ edit its profiles.
+
+ )}
+
+ Manage scan profiles →
+
+
+
+ Run the first scan
+
+ Otter receives the queued scan on its next poll. Only scan networks you are
+ authorized to assess.
+
+ runNow(setupScanner, setupProfileID)}
+ type="button"
+ >
+ Run first scan
+
+ {!setupScanner.enrolled ? (
+ Enroll and connect the scanner before running its first scan.
+ ) : null}
+ {setupRun ? (
+
+ First scan: {setupRun.status}
+ {typeof setupRun.progress === 'number' ? ` (${setupRun.progress}%)` : ''}
+ {setupRun.message ? ` — ${setupRun.message}` : ''}
+ {setupRun.import ? (
+ <>
+ {' '}
+ ·{' '}
+
+ View imported inventory
+
+ >
+ ) : null}
+
+ ) : null}
+
+ Optional next step:{' '}
+
+ schedule recurring scans
+
+ .
+
+
+ >
+ ) : null}
+
+
+
+
+
+
+
Scanners
+
Configuration and imports remain scoped to each scanner’s site.
+
+
{overview.scanners.length} registered
+
+ {overview.scanners.length ? (
+
+
+
+
+ Scanner
+ Site
+ Connection
+ Next run
+ Actions
+
+
+
+ {overview.scanners.map((scanner) => {
+ const online = scannerOnline(scanner)
+ const activeRun = overview.runs.find((run) => run.id === scanner.currentRun)
+ return (
+
+
+
+ {scanner.name}
+
+
+ {scanner.version ? `Otter ${scanner.version}` : 'Version not reported'}
+
+
+ {siteName(scanner.site)}
+
+
+ {!scanner.enabled
+ ? 'Disabled'
+ : !scanner.enrolled
+ ? 'Awaiting enrollment'
+ : online
+ ? 'Online'
+ : 'Offline'}
+
+ Last seen {date(scanner.lastSeenAt)}
+
+
+ {date(scanner.nextRunAt)}
+ {activeRun ? (
+
+ {activeRun.status}: {activeRun.name}
+
+ ) : null}
+
+
+ {overview.profiles.filter((profile) => profile.scanner === scanner.id)
+ .length ? (
+
+ setSelectedProfiles((current) => ({
+ ...current,
+ [scanner.id]: event.target.value,
+ }))
+ }
+ value={
+ selectedProfiles[scanner.id] ||
+ overview.profiles.find((profile) => profile.scanner === scanner.id)
+ ?.id
+ }
+ >
+ {overview.profiles
+ .filter((profile) => profile.scanner === scanner.id)
+ .map((profile) => (
+
+ {profile.name}
+
+ ))}
+
+ ) : null}
+ runNow(scanner)}
+ type="button"
+ >
+ Run now
+
+
+ void act(async () => {
+ const result = await request(
+ `/api/otter/v1/scanners/${scanner.id}/reenroll`,
+ )
+ setToken(String(result.enrollmentToken || ''))
+ setTokenScannerID(scanner.id)
+ setTokenExpiresAt(String(result.expiresAt || ''))
+ setControllerUrl(String(result.controllerUrl || ''))
+ setSetupScannerID(scanner.id)
+ setMessage(
+ `New enrollment token expires ${date(String(result.expiresAt || ''))}. The existing credential was revoked.`,
+ )
+ })
+ }
+ type="button"
+ >
+ Re-enroll
+
+ {scanner.enabled ? (
+
+ void act(async () => {
+ await request(`/api/otter/v1/scanners/${scanner.id}/revoke`)
+ if (tokenScannerID === scanner.id) {
+ setToken('')
+ setTokenScannerID('')
+ setTokenExpiresAt('')
+ }
+ })
+ }
+ type="button"
+ >
+ Revoke
+
+ ) : null}
+
+
+ )
+ })}
+
+
+
+ ) : (
+ No scanners registered yet.
+ )}
+
+
+
+
+
+
Recent runs
+
Scheduled and manual scans use the same immutable profile snapshot.
+
+
All runs →
+
+ {overview.runs.length ? (
+
+
+
+
+ Run
+ Scanner
+ Status
+ Scheduled
+ Result
+
+
+
+
+ {overview.runs.map((run) => (
+
+
+
+ {run.name}
+
+
+
+ {overview.scanners.find((scanner) => scanner.id === run.scanner)?.name ||
+ run.scanner}
+
+
+
+ {run.status}
+
+
+ {date(run.scheduledFor || run.completedAt)}
+
+ {run.message ||
+ (run.import ? (
+
+ View import
+
+ ) : (
+ '—'
+ ))}
+
+
+ {['queued', 'assigned', 'running'].includes(run.status) ? (
+
+ void act(async () => {
+ await request(`/api/otter/v1/runs/${run.id}/cancel`)
+ })
+ }
+ type="button"
+ >
+ Cancel
+
+ ) : null}
+
+
+ ))}
+
+
+
+ ) : (
+ No scan runs yet.
+ )}
+
+
+
+ )
+}
diff --git a/src/components/OtterNavLink/index.tsx b/src/components/OtterNavLink/index.tsx
new file mode 100644
index 0000000..3c5b8ee
--- /dev/null
+++ b/src/components/OtterNavLink/index.tsx
@@ -0,0 +1,27 @@
+'use client'
+
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { useAuth, useConfig } from '@payloadcms/ui'
+
+const OtterNavLink = () => {
+ const pathname = usePathname()
+ const { permissions } = useAuth()
+ const {
+ config: {
+ routes: { admin },
+ },
+ } = useConfig()
+ const href = `${admin}/otters`
+
+ if (!permissions?.collections?.['otter-scanners']?.read) return null
+
+ return (
+
+ {pathname === href ?
: null}
+ Manage Otters
+
+ )
+}
+
+export default OtterNavLink
diff --git a/src/components/OtterScannerActions/index.tsx b/src/components/OtterScannerActions/index.tsx
new file mode 100644
index 0000000..38c6aed
--- /dev/null
+++ b/src/components/OtterScannerActions/index.tsx
@@ -0,0 +1,117 @@
+'use client'
+
+import { Button, ConfirmationModal, useDocumentInfo, useFormFields, useModal } from '@payloadcms/ui'
+import { useRouter } from 'next/navigation'
+import Link from 'next/link'
+import { useState } from 'react'
+import { EnrollmentInstructions, otterAction, type Enrollment } from '../OtterEnrollment'
+
+export default function OtterScannerActions() {
+ const { id, collectionSlug, docPermissions } = useDocumentInfo()
+ const scanner = useFormFields(([fields]) => fields.scanner?.value)
+ const { openModal } = useModal()
+ const router = useRouter()
+ const [message, setMessage] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [enrollment, setEnrollment] = useState()
+ const act = async (path: string, data?: unknown) => {
+ setBusy(true)
+ setMessage('')
+ try {
+ const result = await otterAction(path, data)
+ if (result.enrollmentToken) {
+ setEnrollment(result)
+ openModal('otter-reenrollment-token')
+ } else
+ setMessage(
+ result.status === 'queued'
+ ? 'Scan queued. Otter will receive it on its next poll.'
+ : 'Scanner credential revoked.',
+ )
+ router.refresh()
+ } catch (error) {
+ setMessage(error instanceof Error ? error.message : 'The action failed.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ if (!id || !docPermissions?.update) return null
+ if (collectionSlug === 'otter-profiles')
+ return (
+
+
+ Save this configuration before running a scan. Scan options and import overrides are saved
+ with the run.
+
+ openModal('otter-run-confirm')}>
+ Run now
+
+
+ act('/api/otter/v1/runs', {
+ scannerID:
+ typeof scanner === 'string'
+ ? scanner
+ : scanner && typeof scanner === 'object' && 'value' in scanner
+ ? scanner.value
+ : '',
+ profileID: id,
+ })
+ }
+ />
+ {message ? {message}
: null}
+
+ )
+ return (
+
+
+ Configure and run scans from{' '}
+ Otter Configurations.
+
+ openModal('otter-reenroll-confirm')}
+ >
+ Re-enroll
+
+ openModal('otter-revoke-confirm')}
+ >
+ Revoke
+
+ {message ? {message}
: null}
+ act(`/api/otter/v1/scanners/${id}/reenroll`)}
+ />
+ act(`/api/otter/v1/scanners/${id}/revoke`)}
+ />
+ : null}
+ confirmLabel="Done"
+ cancelLabel="Close"
+ onConfirm={() => setEnrollment(undefined)}
+ onCancel={() => setEnrollment(undefined)}
+ />
+
+ )
+}
diff --git a/src/components/TopologyNavLink/index.scss b/src/components/TopologyNavLink/index.scss
index 84dc048..888c916 100644
--- a/src/components/TopologyNavLink/index.scss
+++ b/src/components/TopologyNavLink/index.scss
@@ -1,6 +1,7 @@
// ponytail: order-based placement assumes OT Inventory stays the first visible
// nav group; move the link into the group markup if group order ever varies
-.topology-nav-link {
+.topology-nav-link,
+.otter-nav-link {
order: 1;
margin-bottom: calc(var(--base) * 0.5);
}
diff --git a/src/jobs/worker.ts b/src/jobs/worker.ts
index 6a54b0d..1f51bbd 100644
--- a/src/jobs/worker.ts
+++ b/src/jobs/worker.ts
@@ -5,6 +5,9 @@ import { withWorkerFence, type WorkerFence } from '../integrations/payload/worke
import { SYNC_INTERVAL_MS } from './maintenance'
import { safeFailure } from './errors'
import { systemRequest } from '../integrations/payload/requests'
+import { expireOtterLeases } from '../otter/controller'
+
+const OTTER_LEASE_SWEEP_MS = 15_000
export const recoverQueue = async (payload: Payload, fence: WorkerFence) =>
withWorkerFence(fence, async () => {
@@ -60,15 +63,24 @@ export const runWorker = async (payload: Payload, queue: Queue, signal: AbortSig
if (!fence) throw new Error(`Another ${queue} worker owns the queue.`)
let lost = false
let heartbeat: Promise = Promise.resolve()
- const timer = setInterval(() => {
- heartbeat = heartbeat
- .then(() => renewWorker(payload, fence))
- .catch(() => {
- lost = true
- })
- }, 20_000)
+ let timer: ReturnType | undefined
try {
await recoverQueue(payload, fence)
+ if (queue === 'maintenance') await withWorkerFence(fence, () => expireOtterLeases(payload))
+ timer = setInterval(
+ () => {
+ heartbeat = heartbeat
+ .then(async () => {
+ await renewWorker(payload, fence)
+ if (queue === 'maintenance')
+ await withWorkerFence(fence, () => expireOtterLeases(payload))
+ })
+ .catch(() => {
+ lost = true
+ })
+ },
+ queue === 'maintenance' ? OTTER_LEASE_SWEEP_MS : 20_000,
+ )
while (!signal.aborted && !lost) {
if (queue === 'maintenance') await scheduleMaintenance(payload, fence)
await withWorkerFence(fence, () =>
@@ -87,7 +99,7 @@ export const runWorker = async (payload: Payload, queue: Queue, signal: AbortSig
payload.logger.error({ event: 'worker.stopped', queue, error: safeFailure(error) })
throw error
} finally {
- clearInterval(timer)
+ if (timer) clearInterval(timer)
await heartbeat
if (!lost) await renewWorker(payload, fence, true).catch(() => {})
}
diff --git a/src/migrations/20261010_164200_otter_indexes.ts b/src/migrations/20261010_164200_otter_indexes.ts
new file mode 100644
index 0000000..62222d2
--- /dev/null
+++ b/src/migrations/20261010_164200_otter_indexes.ts
@@ -0,0 +1,43 @@
+import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-mongodb'
+
+const indexNames = [
+ 'otter_run_schedule_occurrence_unique',
+ 'otter_scanner_credential_unique',
+ 'otter_scanner_enrollment_unique',
+]
+
+export async function up({ payload }: MigrateUpArgs): Promise {
+ await payload.db.collections['otter-runs'].collection.createIndex(
+ { scheduleOccurrenceKey: 1 },
+ {
+ name: indexNames[0],
+ partialFilterExpression: { scheduleOccurrenceKey: { $type: 'string' } },
+ unique: true,
+ },
+ )
+ await payload.db.collections['otter-scanners'].collection.createIndex(
+ { credentialHash: 1 },
+ {
+ name: indexNames[1],
+ partialFilterExpression: { credentialHash: { $type: 'string' } },
+ unique: true,
+ },
+ )
+ await payload.db.collections['otter-scanners'].collection.createIndex(
+ { enrollmentTokenHash: 1 },
+ {
+ name: indexNames[2],
+ partialFilterExpression: { enrollmentTokenHash: { $type: 'string' } },
+ unique: true,
+ },
+ )
+}
+
+export async function down({ payload }: MigrateDownArgs): Promise {
+ for (const name of indexNames)
+ await (
+ name === indexNames[0]
+ ? payload.db.collections['otter-runs'].collection
+ : payload.db.collections['otter-scanners'].collection
+ ).dropIndex(name)
+}
diff --git a/src/migrations/index.ts b/src/migrations/index.ts
index f442464..981d6df 100644
--- a/src/migrations/index.ts
+++ b/src/migrations/index.ts
@@ -1,5 +1,6 @@
import * as migration_20260922_205151_queued_import_baseline from './20260922_205151_queued_import_baseline'
import * as migration_20260926_111902_site_paths from './20260926_111902_site_paths'
+import * as migration_20261010_164200_otter_indexes from './20261010_164200_otter_indexes'
export const migrations = [
{
@@ -12,4 +13,9 @@ export const migrations = [
down: migration_20260926_111902_site_paths.down,
name: '20260926_111902_site_paths',
},
+ {
+ up: migration_20261010_164200_otter_indexes.up,
+ down: migration_20261010_164200_otter_indexes.down,
+ name: '20261010_164200_otter_indexes',
+ },
]
diff --git a/src/otter/controller.ts b/src/otter/controller.ts
new file mode 100644
index 0000000..44c35a8
--- /dev/null
+++ b/src/otter/controller.ts
@@ -0,0 +1,1172 @@
+import { APIError, type Endpoint, type Payload, type PayloadRequest } from 'payload'
+import { isIP } from 'node:net'
+import { getAuthorization, relationshipID } from '../access/authorization'
+import { inTransaction } from '../integrations/payload/transactions'
+import type { OtterRun, OtterScanner, OtterSchedule } from '../payload-types'
+import {
+ decryptOtterCredentials,
+ encryptOtterCredentials,
+ newToken,
+ requireOtterConfigAccess,
+ requireOtterScanner,
+ scannerIDFromRequest,
+ tokenDigest,
+ validateOtterCredentials,
+ validateOtterConfiguration,
+} from './security'
+import { dueOccurrence } from './scheduling'
+import { getAssetOverrides } from '../application/processImport'
+import { systemRequest } from '../integrations/payload/requests'
+
+const json = (data: unknown, status = 200) =>
+ Response.json(data, { headers: { 'Cache-Control': 'no-store' }, status })
+
+const scannerReport = (input: Record) => {
+ if (
+ input.version !== undefined &&
+ (typeof input.version !== 'string' || input.version.length > 64)
+ )
+ throw new APIError('Scanner version is invalid.', 400)
+ for (const key of ['capabilities', 'interfaces']) {
+ const value = input[key]
+ if (
+ value !== undefined &&
+ (!Array.isArray(value) ||
+ value.length > 128 ||
+ value.some((entry) => typeof entry !== 'string' || entry.length > 256))
+ )
+ throw new APIError(`Scanner ${key} must be a bounded list of text values.`, 400)
+ }
+ if (
+ input.computerName !== undefined &&
+ (typeof input.computerName !== 'string' ||
+ !input.computerName.trim() ||
+ input.computerName.length > 256 ||
+ /[\u0000-\u001f]/.test(input.computerName))
+ )
+ throw new APIError('Scanner computer name is invalid.', 400)
+
+ let networkAdapters:
+ | Array<{
+ name: string
+ friendlyName: string
+ description: string
+ macAddress?: string
+ ipAddresses: string[]
+ }>
+ | undefined
+ if (input.networkAdapters !== undefined) {
+ if (!Array.isArray(input.networkAdapters) || input.networkAdapters.length > 32)
+ throw new APIError('Scanner network adapters must be a bounded list.', 400)
+ networkAdapters = input.networkAdapters.map((value) => {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw new APIError('Scanner network adapter is invalid.', 400)
+ const adapter = value as Record
+ if (
+ Object.keys(adapter).some(
+ (key) =>
+ !['name', 'friendlyName', 'description', 'macAddress', 'ipAddresses'].includes(key),
+ ) ||
+ ['name', 'friendlyName', 'description'].some(
+ (key) =>
+ typeof adapter[key] !== 'string' ||
+ !String(adapter[key]).trim() ||
+ String(adapter[key]).length > 256 ||
+ /[\u0000-\u001f]/.test(String(adapter[key])),
+ ) ||
+ (adapter.macAddress !== undefined &&
+ (typeof adapter.macAddress !== 'string' ||
+ !/^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$/i.test(adapter.macAddress))) ||
+ !Array.isArray(adapter.ipAddresses) ||
+ adapter.ipAddresses.length > 16 ||
+ adapter.ipAddresses.some(
+ (address) => typeof address !== 'string' || isIP(address) === 0 || address.length > 64,
+ )
+ )
+ throw new APIError('Scanner network adapter is invalid.', 400)
+ return {
+ name: adapter.name as string,
+ friendlyName: adapter.friendlyName as string,
+ description: adapter.description as string,
+ ...(typeof adapter.macAddress === 'string'
+ ? { macAddress: adapter.macAddress.toUpperCase() }
+ : {}),
+ ipAddresses: [...new Set(adapter.ipAddresses as string[])],
+ }
+ })
+ }
+ return {
+ ...(typeof input.version === 'string' ? { version: input.version } : {}),
+ ...(Array.isArray(input.capabilities) ? { capabilities: input.capabilities as string[] } : {}),
+ ...(Array.isArray(input.interfaces) ? { interfaces: input.interfaces as string[] } : {}),
+ ...(typeof input.computerName === 'string' ? { computerName: input.computerName.trim() } : {}),
+ ...(networkAdapters ? { networkAdapters } : {}),
+ }
+}
+
+const body = async (req: PayloadRequest, maxBytes = 32_000) => {
+ const length = Number(req.headers.get('content-length') || 0)
+ if (length > maxBytes) throw new APIError('Request body is too large.', 413)
+ const stream = (req as Request).body
+ if (!stream) throw new APIError('A JSON request body is required.', 400)
+ const reader = stream.getReader()
+ const chunks: Uint8Array[] = []
+ let bytes = 0
+ for (;;) {
+ const { done, value } = await reader.read()
+ if (done) break
+ bytes += value.byteLength
+ if (bytes > maxBytes) {
+ await reader.cancel()
+ throw new APIError('Request body is too large.', 413)
+ }
+ chunks.push(value)
+ }
+ const text = Buffer.concat(chunks).toString('utf8')
+ try {
+ const result: unknown = JSON.parse(text)
+ if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error()
+ return result as Record
+ } catch {
+ throw new APIError('A JSON object is required.', 400)
+ }
+}
+
+const isLoopbackURL = (url: URL) =>
+ ['http:', 'https:'].includes(url.protocol) &&
+ ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
+
+const humanOrigin = (req: PayloadRequest) => {
+ const origin = req.headers.get('origin')
+ const configured = process.env.OTSERVER_PUBLIC_URL
+ if (!origin || !req.url) throw new APIError('A same-origin browser request is required.', 403)
+
+ const allowedURLs: URL[] = []
+ let hasConfiguredOrigin = false
+ if (configured) {
+ try {
+ allowedURLs.push(new URL(configured))
+ hasConfiguredOrigin = true
+ } catch {
+ // A malformed public URL does not invalidate the request's actual same origin.
+ }
+ }
+
+ const allowRequestOrigin = !hasConfiguredOrigin || process.env.NODE_ENV !== 'production'
+ let requestURL: URL
+ try {
+ requestURL = new URL(req.url, 'http://otserver.invalid')
+ } catch {
+ throw new APIError('A same-origin browser request is required.', 403)
+ }
+ if (allowRequestOrigin || isLoopbackURL(requestURL)) allowedURLs.push(requestURL)
+
+ const firstHeader = (name: string) => req.headers.get(name)?.split(',')[0]?.trim()
+ const host = firstHeader('x-forwarded-host') || req.headers.get('host')
+ const protocol = firstHeader('x-forwarded-proto') || requestURL.protocol.slice(0, -1)
+ if (host && ['http', 'https'].includes(protocol)) {
+ try {
+ const forwardedURL = new URL(`${protocol}://${host}`)
+ if (allowRequestOrigin || isLoopbackURL(forwardedURL)) allowedURLs.push(forwardedURL)
+ } catch {
+ // Ignore malformed proxy headers and fail closed below.
+ }
+ }
+
+ try {
+ const browserURL = new URL(origin)
+ if (
+ allowedURLs.some(
+ (url) =>
+ url.origin === browserURL.origin ||
+ // Loopback aliases can differ from Next's internal host, including in standalone builds.
+ (isLoopbackURL(url) &&
+ isLoopbackURL(browserURL) &&
+ url.protocol === browserURL.protocol &&
+ url.port === browserURL.port),
+ )
+ )
+ return
+ } catch {
+ // An invalid Origin is rejected with the same response as a foreign origin.
+ }
+ throw new APIError('Cross-origin Otter management requests are not allowed.', 403)
+}
+
+const requireHuman = async (req: PayloadRequest, site?: unknown, write = false) => {
+ if (scannerIDFromRequest(req)) throw new APIError('This action requires a management user.', 403)
+ if (!req.user) throw new APIError('Authentication is required.', 401)
+ const auth = await getAuthorization(req)
+ if (!auth.canConfigureOtter) throw new APIError('The Otter Config role is required.', 403)
+ if (site !== undefined) await requireOtterConfigAccess(req, site, write)
+ return auth
+}
+
+const scannerDocument = async (req: PayloadRequest): Promise => {
+ const id = requireOtterScanner(req)
+ const scanner = await req.payload.findByID({
+ collection: 'otter-scanners',
+ depth: 0,
+ id,
+ overrideAccess: true,
+ req,
+ })
+ if (!scanner.enabled) throw new APIError('Otter scanner is disabled.', 403)
+ return scanner
+}
+
+const profileFor = async (req: PayloadRequest, id: unknown, scannerID: string) => {
+ if (typeof id !== 'string') throw new APIError('A scan profile is required.', 400)
+ const profile = await req.payload.findByID({
+ collection: 'otter-profiles',
+ depth: 0,
+ id,
+ overrideAccess: true,
+ req,
+ })
+ if (String(relationshipID(profile.scanner)) !== scannerID)
+ throw new APIError('The profile does not belong to this scanner.', 403)
+ const configuration = validateOtterConfiguration(profile.configuration)
+ return { configuration, encryptedCredentials: profile.encryptedCredentials, profile }
+}
+
+const publicControllerURL = () => {
+ const value = process.env.OTSERVER_PUBLIC_URL
+ if (!value)
+ throw new APIError('OTSERVER_PUBLIC_URL must be configured before Otter jobs can run.', 503)
+ let url: URL
+ try {
+ url = new URL(value)
+ } catch {
+ throw new APIError('OTSERVER_PUBLIC_URL is invalid.', 503)
+ }
+ if (url.username || url.password || url.search || url.hash)
+ throw new APIError(
+ 'OTSERVER_PUBLIC_URL must not contain credentials, a query, or a fragment.',
+ 503,
+ )
+ if (url.protocol !== 'https:' && !isLoopbackURL(url))
+ throw new APIError('OTSERVER_PUBLIC_URL must use HTTPS, or HTTP on localhost/loopback.', 503)
+ return url.toString().replace(/\/$/, '')
+}
+
+const runInput = async (
+ req: PayloadRequest,
+ scanner: OtterScanner,
+ profileID: unknown,
+ schedule?: OtterSchedule,
+ scheduledFor?: Date,
+) => {
+ const { configuration, encryptedCredentials, profile } = await profileFor(
+ req,
+ profileID,
+ String(scanner.id),
+ )
+ const siteID = String(relationshipID(scanner.site))
+ if (String(relationshipID(profile.site)) !== siteID)
+ throw new APIError('The profile and scanner must belong to the same site.', 400)
+ return {
+ configSnapshot: {
+ ...configuration,
+ name: profile.name,
+ },
+ importOverridesSnapshot: {
+ assetOverrides: getAssetOverrides(profile.assetOverrides),
+ customFieldOverrides: profile.customFieldOverrides || {},
+ },
+ encryptedCredentialsSnapshot: encryptedCredentials || null,
+ name: schedule?.name ? `${profile.name} — ${schedule.name}` : profile.name,
+ profile: profile.id,
+ ...(schedule ? { schedule: schedule.id } : {}),
+ ...(scheduledFor ? { scheduledFor: scheduledFor.toISOString() } : {}),
+ site: siteID,
+ scanner: String(scanner.id),
+ status: 'queued' as const,
+ }
+}
+
+const activeRunStatuses: OtterRun['status'][] = ['assigned', 'running', 'cancel-requested']
+const leaseExpired = (run: OtterRun, now: Date) =>
+ !run.leaseExpiresAt || Date.parse(run.leaseExpiresAt) <= now.getTime()
+const isActiveRun = (run: OtterRun) => activeRunStatuses.includes(run.status)
+const leaseExpiredMessage =
+ 'Scanner lease expired because no heartbeat was received before the deadline. The scanner may have stopped or lost its controller connection.'
+
+const expireRun = async (req: PayloadRequest, run: OtterRun, scanner: OtterScanner, now: Date) => {
+ await req.payload.update({
+ collection: 'otter-runs',
+ id: run.id,
+ overrideAccess: true,
+ req,
+ data: {
+ status: 'expired',
+ completedAt: now.toISOString(),
+ message: leaseExpiredMessage,
+ },
+ })
+ if (String(relationshipID(scanner.currentRun)) !== String(run.id)) return
+ await req.payload.update({
+ collection: 'otter-scanners',
+ context: { otterScannerUpdate: true },
+ id: scanner.id,
+ overrideAccess: true,
+ req,
+ data: { currentRun: null },
+ })
+ scanner.currentRun = null
+}
+
+/** Expire abandoned assignments without relying on another scanner poll. */
+export const expireOtterLeases = async (payload: Payload, now = new Date()) => {
+ const req = await systemRequest(payload)
+ const stale = await payload.find({
+ collection: 'otter-runs',
+ depth: 0,
+ limit: 100,
+ overrideAccess: true,
+ req,
+ where: {
+ and: [
+ { status: { in: activeRunStatuses } },
+ {
+ or: [
+ { leaseExpiresAt: { less_than_equal: now.toISOString() } },
+ { leaseExpiresAt: { exists: false } },
+ ],
+ },
+ ],
+ },
+ })
+ for (const candidate of stale.docs) {
+ await inTransaction(req, async () => {
+ const run = await payload.findByID({
+ collection: 'otter-runs',
+ depth: 0,
+ id: candidate.id,
+ overrideAccess: true,
+ req,
+ })
+ if (!isActiveRun(run) || !leaseExpired(run, now)) return
+ const scannerID = relationshipID(run.scanner)
+ if (!scannerID) {
+ await payload.update({
+ collection: 'otter-runs',
+ id: run.id,
+ overrideAccess: true,
+ req,
+ data: {
+ status: 'expired',
+ completedAt: now.toISOString(),
+ message: leaseExpiredMessage,
+ },
+ })
+ return
+ }
+ const scanner = await payload.findByID({
+ collection: 'otter-scanners',
+ depth: 0,
+ id: String(scannerID),
+ overrideAccess: true,
+ req,
+ })
+ await expireRun(req, run, scanner, now)
+ })
+ }
+}
+
+const assignJob = async (req: PayloadRequest, scanner: OtterScanner, now: Date) => {
+ const activeID = relationshipID(scanner.currentRun)
+ if (activeID) {
+ const active = await req.payload.findByID({
+ collection: 'otter-runs',
+ depth: 0,
+ id: String(activeID),
+ overrideAccess: true,
+ req,
+ })
+ if (isActiveRun(active)) {
+ if (active.status === 'cancel-requested') return active
+ if (!leaseExpired(active, now)) return active
+ await expireRun(req, active, scanner, now)
+ } else {
+ await req.payload.update({
+ collection: 'otter-scanners',
+ context: { otterScannerUpdate: true },
+ id: scanner.id,
+ overrideAccess: true,
+ req,
+ data: { currentRun: null },
+ })
+ scanner.currentRun = null
+ }
+ }
+
+ const schedules = await req.payload.find({
+ collection: 'otter-schedules',
+ depth: 0,
+ limit: 100,
+ overrideAccess: true,
+ req,
+ sort: 'nextRunAt',
+ where: {
+ and: [
+ { scanner: { equals: scanner.id } },
+ { enabled: { equals: true } },
+ { nextRunAt: { less_than_equal: now.toISOString() } },
+ ],
+ },
+ })
+ for (const schedule of schedules.docs) {
+ const due = dueOccurrence(
+ schedule as unknown as import('./scheduling').ScheduleInput & { nextRunAt?: string },
+ now,
+ )
+ if (!due) continue
+ const profileID = relationshipID(schedule.profile)
+ const input = await runInput(req, scanner, String(profileID), schedule, due.occurrence)
+ const occurrenceKey = `${schedule.id}:${due.occurrence.toISOString()}`
+ const existing = await req.payload.find({
+ collection: 'otter-runs',
+ depth: 0,
+ limit: 1,
+ overrideAccess: true,
+ req,
+ where: { scheduleOccurrenceKey: { equals: occurrenceKey } },
+ })
+ if (!existing.docs.length)
+ await req.payload.create({
+ collection: 'otter-runs',
+ data: { ...input, scheduleOccurrenceKey: occurrenceKey },
+ overrideAccess: true,
+ req,
+ })
+ await req.payload.update({
+ collection: 'otter-schedules',
+ id: schedule.id,
+ overrideAccess: true,
+ req,
+ context: { otterSchedulerUpdate: true },
+ data: {
+ enabled: schedule.scheduleType === 'once' ? false : schedule.enabled,
+ nextRunAt: due.nextRunAt || null,
+ },
+ })
+ }
+
+ const pending = await req.payload.find({
+ collection: 'otter-runs',
+ depth: 0,
+ limit: 1,
+ overrideAccess: true,
+ req,
+ sort: 'createdAt',
+ where: { and: [{ scanner: { equals: scanner.id } }, { status: { equals: 'queued' } }] },
+ })
+ const run = pending.docs[0]
+ if (!run) return undefined
+ const leaseExpiresAt = new Date(now.getTime() + 120_000).toISOString()
+ const assigned = await req.payload.update({
+ collection: 'otter-runs',
+ id: run.id,
+ overrideAccess: true,
+ req,
+ data: { assignedAt: now.toISOString(), leaseExpiresAt, status: 'assigned' },
+ })
+ await req.payload.update({
+ collection: 'otter-scanners',
+ context: { otterScannerUpdate: true },
+ id: scanner.id,
+ overrideAccess: true,
+ req,
+ data: { currentRun: String(run.id) },
+ })
+ scanner.currentRun = String(run.id)
+ return assigned
+}
+
+const jobResponse = (run: OtterRun, scanner: OtterScanner) => {
+ if (run.status === 'cancel-requested') return { action: 'cancel', runID: String(run.id) }
+ if (
+ !run.configSnapshot ||
+ typeof run.configSnapshot !== 'object' ||
+ Array.isArray(run.configSnapshot)
+ )
+ throw new APIError('Stored Otter run configuration is invalid.', 500)
+ const configuration = run.configSnapshot as Record
+ return {
+ action: 'run',
+ run: {
+ configuration: {
+ ...configuration,
+ serverUrl: publicControllerURL(),
+ site: String(relationshipID(scanner.site)),
+ },
+ credentials: decryptOtterCredentials(run.encryptedCredentialsSnapshot),
+ id: String(run.id),
+ leaseExpiresAt: run.leaseExpiresAt,
+ uploadPath: '/api/asset-imports',
+ },
+ }
+}
+
+export const otterOverview: Endpoint['handler'] = async (req) => {
+ const authorization = await requireHuman(req)
+ const scanners = await req.payload.find({
+ collection: 'otter-scanners',
+ depth: 1,
+ limit: 200,
+ overrideAccess: false,
+ req,
+ sort: 'name',
+ })
+ const scannerIDs = scanners.docs.map((scanner) => String(scanner.id))
+ const [schedules, runs] = await Promise.all([
+ req.payload.find({
+ collection: 'otter-schedules',
+ depth: 0,
+ limit: 500,
+ overrideAccess: false,
+ req,
+ sort: 'nextRunAt',
+ where: authorization.isAdmin
+ ? { scanner: { in: scannerIDs } }
+ : {
+ and: [{ scanner: { in: scannerIDs } }, { site: { in: authorization.readableSiteIDs } }],
+ },
+ }),
+ req.payload.find({
+ collection: 'otter-runs',
+ depth: 0,
+ limit: 100,
+ overrideAccess: false,
+ req,
+ sort: '-createdAt',
+ where: { scanner: { in: scannerIDs } },
+ }),
+ ])
+ const profiles = await req.payload.find({
+ collection: 'otter-profiles',
+ depth: 0,
+ limit: 500,
+ overrideAccess: false,
+ req,
+ sort: 'name',
+ where: { scanner: { in: scannerIDs } },
+ })
+ const sites = await req.payload.find({
+ collection: 'sites',
+ depth: 0,
+ limit: 1000,
+ overrideAccess: false,
+ req,
+ sort: 'path',
+ where: authorization.isAdmin ? {} : { id: { in: authorization.readableSiteIDs } },
+ })
+ const writableSites = await req.payload.find({
+ collection: 'sites',
+ depth: 0,
+ limit: 1000,
+ overrideAccess: false,
+ req,
+ sort: 'path',
+ where: authorization.isAdmin ? {} : { id: { in: authorization.writableSiteIDs } },
+ })
+ return json({
+ controllerUrl: process.env.OTSERVER_PUBLIC_URL ? publicControllerURL() : '',
+ runs: runs.docs.map(
+ ({
+ id,
+ name,
+ scanner,
+ status,
+ scheduledFor,
+ completedAt,
+ progress,
+ message,
+ import: imported,
+ }) => ({
+ id,
+ name,
+ scanner: String(relationshipID(scanner)),
+ status,
+ scheduledFor,
+ completedAt,
+ progress,
+ message,
+ import: relationshipID(imported),
+ }),
+ ),
+ scanners: scanners.docs.map(
+ ({
+ id,
+ name,
+ site,
+ enabled,
+ enrolledAt,
+ interfaces,
+ networkAdapters,
+ computerName,
+ inventoryReportedAt,
+ lastSeenAt,
+ version,
+ currentRun,
+ }) => ({
+ id,
+ name,
+ site: String(relationshipID(site) ?? ''),
+ enabled,
+ enrolled: Boolean(enrolledAt),
+ enrolledAt,
+ lastSeenAt,
+ version,
+ interfaces: Array.isArray(interfaces)
+ ? interfaces.filter((entry): entry is string => typeof entry === 'string')
+ : [],
+ computerName,
+ networkAdapters: Array.isArray(networkAdapters) ? networkAdapters : [],
+ inventoryReportedAt,
+ currentRun: relationshipID(currentRun),
+ nextRunAt: schedules.docs
+ .filter(
+ (schedule) =>
+ String(relationshipID(schedule.scanner)) === String(id) && schedule.enabled,
+ )
+ .map((schedule) => schedule.nextRunAt)
+ .filter(Boolean)
+ .sort()[0],
+ lastRun: runs.docs.find((run) => String(relationshipID(run.scanner)) === String(id)),
+ }),
+ ),
+ profiles: profiles.docs.map(({ id, name, scanner, site, credentialsConfigured }) => ({
+ id,
+ name,
+ scanner: String(relationshipID(scanner)),
+ site: String(relationshipID(site)),
+ credentialsConfigured,
+ })),
+ sites: sites.docs.map(({ id, name, path }) => ({ id, name: path || name })),
+ writableSites: writableSites.docs.map(({ id, name, path }) => ({ id, name: path || name })),
+ })
+}
+
+export const createOtterScanner: Endpoint['handler'] = async (req) => {
+ humanOrigin(req)
+ const controllerUrl = publicControllerURL()
+ const data = await body(req)
+ const name = typeof data.name === 'string' ? data.name.trim() : ''
+ if (!name || name.length > 120 || /[\u0000-\u001f]/.test(name))
+ throw new APIError('Enter a scanner name of 1 to 120 characters.', 400)
+ const site = String(data.site || '')
+ await requireHuman(req, site, true)
+ const siteDoc = await req.payload.findByID({
+ collection: 'sites',
+ depth: 0,
+ id: site,
+ overrideAccess: false,
+ req,
+ })
+ const token = newToken()
+ const expiresAt = new Date(Date.now() + 15 * 60_000).toISOString()
+ const scanner = await req.payload.create({
+ collection: 'otter-scanners',
+ data: {
+ name,
+ site,
+ enabled: true,
+ enrollmentTokenHash: tokenDigest(token),
+ enrollmentExpiresAt: expiresAt,
+ },
+ overrideAccess: true,
+ req,
+ })
+ return json(
+ {
+ controllerUrl,
+ enrollmentToken: token,
+ expiresAt,
+ scanner: { id: scanner.id, name, site: siteDoc.name },
+ },
+ 201,
+ )
+}
+
+export const enrollOtter: Endpoint['handler'] = async (req) => {
+ const data = await body(req, 64_000)
+ const report = scannerReport(data)
+ const token = typeof data.token === 'string' ? data.token : ''
+ if (!/^[A-Za-z0-9_-]{40,}$/.test(token))
+ throw new APIError('Enrollment token is invalid or expired.', 401)
+ const now = new Date().toISOString()
+ const scanners = await req.payload.find({
+ collection: 'otter-scanners',
+ depth: 0,
+ limit: 1,
+ overrideAccess: true,
+ req,
+ where: {
+ and: [
+ { enrollmentTokenHash: { equals: tokenDigest(token) } },
+ { enrollmentExpiresAt: { greater_than: now } },
+ { credentialHash: { equals: null } },
+ { enabled: { equals: true } },
+ ],
+ },
+ })
+ const scanner = scanners.docs[0]
+ if (!scanner) throw new APIError('Enrollment token is invalid, expired, or already used.', 401)
+
+ const credential = newToken()
+ const credentialExpiresAt = new Date(Date.now() + 365 * 86_400_000).toISOString()
+ const updated = await inTransaction(req, async () => {
+ const claimed = await req.payload.update({
+ collection: 'otter-scanners',
+ context: { otterScannerUpdate: true },
+ where: {
+ and: [
+ { id: { equals: scanner.id } },
+ { enrollmentTokenHash: { equals: tokenDigest(token) } },
+ { enrollmentExpiresAt: { greater_than: now } },
+ { credentialHash: { equals: null } },
+ ],
+ },
+ data: {
+ enrollmentTokenHash: null,
+ enrollmentExpiresAt: null,
+ credentialHash: tokenDigest(credential),
+ credentialExpiresAt,
+ enrolledAt: now,
+ ...report,
+ ...(report.computerName !== undefined || report.networkAdapters !== undefined
+ ? { inventoryReportedAt: now }
+ : {}),
+ },
+ overrideAccess: true,
+ req,
+ })
+ if (!claimed.docs.length) throw new APIError('Enrollment token was already consumed.', 409)
+ return claimed.docs[0]
+ })
+ return json(
+ {
+ credential,
+ credentialExpiresAt,
+ scanner: { id: updated.id, name: updated.name, site: String(relationshipID(updated.site)) },
+ },
+ 201,
+ )
+}
+
+export const pollOtter: Endpoint['handler'] = async (req) => {
+ const scanner = await scannerDocument(req)
+ const report = await body(req, 64_000)
+ const validated = scannerReport(report)
+
+ const now = new Date()
+ const lastSeen = Date.parse(scanner.lastSeenAt || '')
+ const heartbeatDue = !Number.isFinite(lastSeen) || now.getTime() - lastSeen >= 60_000
+ const changed = Object.entries(validated).some(([key, value]) => {
+ const current = scanner[key as keyof OtterScanner]
+ const comparable =
+ key === 'networkAdapters' && Array.isArray(current)
+ ? current.map((adapter) => {
+ const item = adapter as Record
+ return {
+ name: item.name,
+ friendlyName: item.friendlyName,
+ description: item.description,
+ ...(typeof item.macAddress === 'string' ? { macAddress: item.macAddress } : {}),
+ ipAddresses: item.ipAddresses,
+ }
+ })
+ : current
+ return JSON.stringify(value) !== JSON.stringify(comparable)
+ })
+
+ const response = await inTransaction(req, async () => {
+ if (heartbeatDue || changed)
+ await req.payload.update({
+ collection: 'otter-scanners',
+ context: { otterScannerUpdate: true },
+ id: scanner.id,
+ overrideAccess: true,
+ req,
+ data: {
+ lastSeenAt: now.toISOString(),
+ ...validated,
+ ...(validated.computerName !== undefined || validated.networkAdapters !== undefined
+ ? { inventoryReportedAt: now.toISOString() }
+ : {}),
+ },
+ })
+ const refreshed = {
+ ...scanner,
+ ...(heartbeatDue || changed ? { lastSeenAt: now.toISOString() } : {}),
+ }
+ const run = await assignJob(req, refreshed, now)
+ if (!run) return { action: 'idle', retryAfterSeconds: 30 }
+ if (run.status === 'cancel-requested') return jobResponse(run, refreshed)
+ return jobResponse(run, refreshed)
+ })
+ return json(response)
+}
+
+const activeRunForScanner = async (req: PayloadRequest, runID: string) => {
+ const scanner = await scannerDocument(req)
+ const run = await req.payload.findByID({
+ collection: 'otter-runs',
+ depth: 0,
+ id: runID,
+ overrideAccess: true,
+ req,
+ })
+ if (String(relationshipID(run.scanner)) !== String(scanner.id))
+ throw new APIError('Scan run does not belong to this scanner.', 404)
+ return { run, scanner }
+}
+
+export const startOtterRun: Endpoint['handler'] = async (req) => {
+ const runID = String(req.routeParams?.id || '')
+ const result = await inTransaction(req, async () => {
+ const { run, scanner } = await activeRunForScanner(req, runID)
+ if (run.status === 'running') return { status: run.status }
+ if (run.status !== 'assigned') throw new APIError('This scan run cannot be started.', 409)
+ const now = new Date()
+ await req.payload.update({
+ collection: 'otter-runs',
+ id: run.id,
+ overrideAccess: true,
+ req,
+ data: {
+ startedAt: now.toISOString(),
+ leaseExpiresAt: new Date(now.getTime() + 120_000).toISOString(),
+ status: 'running',
+ },
+ })
+ return { status: 'running', scanner: scanner.id }
+ })
+ return json(result)
+}
+
+export const heartbeatOtterRun: Endpoint['handler'] = async (req) => {
+ const runID = String(req.routeParams?.id || '')
+ const data = await body(req, 4_000)
+ if (
+ data.progress !== undefined &&
+ (!Number.isInteger(data.progress) || Number(data.progress) < 0 || Number(data.progress) > 100)
+ )
+ throw new APIError('Progress must be an integer from 0 to 100.', 400)
+ const progress = typeof data.progress === 'number' ? data.progress : undefined
+ const result = await inTransaction(req, async () => {
+ const { run } = await activeRunForScanner(req, runID)
+ if (!['running', 'cancel-requested'].includes(run.status))
+ throw new APIError('This scan run is no longer active.', 409)
+ let leaseExpiresAt = run.leaseExpiresAt
+ if (run.status !== 'cancel-requested') {
+ const now = new Date()
+ leaseExpiresAt = new Date(now.getTime() + 120_000).toISOString()
+ await req.payload.update({
+ collection: 'otter-runs',
+ id: run.id,
+ overrideAccess: true,
+ req,
+ data: {
+ leaseExpiresAt,
+ ...(progress !== undefined ? { progress } : {}),
+ },
+ })
+ }
+ return { cancelRequested: run.status === 'cancel-requested', leaseExpiresAt }
+ })
+ return json(result)
+}
+
+export const finishOtterRun: Endpoint['handler'] = async (req) => {
+ const runID = String(req.routeParams?.id || '')
+ const data = await body(req, 8_000)
+ const status = data.status
+ if (!['completed', 'partial', 'failed', 'cancelled'].includes(String(status)))
+ throw new APIError('Choose a valid terminal run status.', 400)
+ const message =
+ (typeof data.message === 'string' ? data.message.trim().slice(0, 4000) : '') ||
+ (status === 'failed' ? 'Scanner reported a failure without diagnostic details.' : undefined)
+ const importID = typeof data.importID === 'string' ? data.importID : undefined
+ const result = await inTransaction(req, async () => {
+ const { run, scanner } = await activeRunForScanner(req, runID)
+ if (['completed', 'partial', 'failed', 'cancelled'].includes(run.status))
+ return { status: run.status }
+ if (!['assigned', 'running', 'cancel-requested'].includes(run.status))
+ throw new APIError('This scan run is no longer active.', 409)
+ const now = new Date().toISOString()
+ if (importID) {
+ const imported = await req.payload.find({
+ collection: 'asset-imports',
+ depth: 0,
+ limit: 1,
+ overrideAccess: true,
+ req,
+ where: {
+ and: [
+ { id: { equals: importID } },
+ { scanner: { equals: scanner.id } },
+ { otterRun: { equals: run.id } },
+ ],
+ },
+ })
+ if (!imported.docs.length)
+ throw new APIError('Import does not belong to this scanner run.', 400)
+ }
+ await req.payload.update({
+ collection: 'otter-runs',
+ id: run.id,
+ overrideAccess: true,
+ req,
+ data: {
+ status: status as 'completed' | 'partial' | 'failed' | 'cancelled',
+ completedAt: now,
+ progress: status === 'completed' ? 100 : run.progress,
+ ...(message ? { message } : {}),
+ ...(importID ? { import: importID } : {}),
+ },
+ })
+ await req.payload.update({
+ collection: 'otter-scanners',
+ context: { otterScannerUpdate: true },
+ id: scanner.id,
+ overrideAccess: true,
+ req,
+ data: { currentRun: null },
+ })
+ const scheduleID = relationshipID(run.schedule)
+ if (scheduleID)
+ await req.payload.update({
+ collection: 'otter-schedules',
+ id: String(scheduleID),
+ overrideAccess: true,
+ req,
+ context: { otterSchedulerUpdate: true },
+ data: { lastRunAt: now },
+ })
+ return { status }
+ })
+ return json(result)
+}
+
+export const rotateOtterCredential: Endpoint['handler'] = async (req) => {
+ const scanner = await scannerDocument(req)
+ const credential = newToken()
+ const expiresAt = new Date(Date.now() + 365 * 86_400_000).toISOString()
+ await req.payload.update({
+ collection: 'otter-scanners',
+ context: { otterScannerUpdate: true },
+ id: scanner.id,
+ overrideAccess: true,
+ req,
+ data: { credentialHash: tokenDigest(credential), credentialExpiresAt: expiresAt },
+ })
+ return json({ credential, credentialExpiresAt: expiresAt })
+}
+
+export const saveOtterCredentials: Endpoint['handler'] = async (req) => {
+ humanOrigin(req)
+ const id = String(req.routeParams?.id || '')
+ const profile = await req.payload.findByID({
+ collection: 'otter-profiles',
+ depth: 0,
+ id,
+ overrideAccess: false,
+ req,
+ })
+ await requireHuman(req, profile.site, true)
+ const data = await body(req, 32_000)
+ const credentials = data.clear === true ? undefined : validateOtterCredentials(data.credentials)
+ const encryptedCredentials = credentials ? encryptOtterCredentials(credentials) : null
+ await req.payload.update({
+ collection: 'otter-profiles',
+ id,
+ overrideAccess: true,
+ context: { otterCredentialWrite: true },
+ req,
+ data: {
+ encryptedCredentials,
+ credentialsConfigured: Boolean(encryptedCredentials),
+ },
+ })
+ return json({ credentialsConfigured: Boolean(encryptedCredentials) })
+}
+
+export const runOtterNow: Endpoint['handler'] = async (req) => {
+ humanOrigin(req)
+ const data = await body(req)
+ const run = await inTransaction(req, async () => {
+ const scanner = await req.payload.findByID({
+ collection: 'otter-scanners',
+ depth: 0,
+ id: String(data.scannerID || ''),
+ overrideAccess: false,
+ req,
+ })
+ await requireHuman(req, scanner.site, true)
+ if (!scanner.enabled) throw new APIError('Enable the scanner before scheduling a run.', 409)
+ const pending = await req.payload.count({
+ collection: 'otter-runs',
+ overrideAccess: true,
+ req,
+ where: { and: [{ scanner: { equals: scanner.id } }, { status: { equals: 'queued' } }] },
+ })
+ if (pending.totalDocs >= 20) throw new APIError('This scanner already has 20 queued runs.', 429)
+ const input = await runInput(req, scanner, data.profileID)
+ return req.payload.create({
+ collection: 'otter-runs',
+ data: input,
+ overrideAccess: true,
+ req,
+ })
+ })
+ return json({ id: run.id, status: run.status }, 201)
+}
+
+export const cancelOtterRun: Endpoint['handler'] = async (req) => {
+ humanOrigin(req)
+ const id = String(req.routeParams?.id || '')
+ const result = await inTransaction(req, async () => {
+ const run = await req.payload.findByID({
+ collection: 'otter-runs',
+ depth: 0,
+ id,
+ overrideAccess: false,
+ req,
+ })
+ await requireHuman(req, run.site, true)
+ if (run.status === 'queued') {
+ await req.payload.update({
+ collection: 'otter-runs',
+ id,
+ overrideAccess: true,
+ req,
+ data: { status: 'cancelled', completedAt: new Date().toISOString() },
+ })
+ return { status: 'cancelled' }
+ }
+ if (['assigned', 'running'].includes(run.status)) {
+ await req.payload.update({
+ collection: 'otter-runs',
+ id,
+ overrideAccess: true,
+ req,
+ data: { status: 'cancel-requested' },
+ })
+ return { status: 'cancel-requested' }
+ }
+ return { status: run.status }
+ })
+ return json(result)
+}
+
+export const revokeOtterScanner: Endpoint['handler'] = async (req) => {
+ humanOrigin(req)
+ const id = String(req.routeParams?.id || '')
+ await inTransaction(req, async () => {
+ const scanner = await req.payload.findByID({
+ collection: 'otter-scanners',
+ depth: 0,
+ id,
+ overrideAccess: false,
+ req,
+ })
+ await requireHuman(req, scanner.site, true)
+ const activeID = relationshipID(scanner.currentRun)
+ if (activeID)
+ await req.payload.update({
+ collection: 'otter-runs',
+ id: String(activeID),
+ overrideAccess: true,
+ req,
+ data: {
+ status: 'cancelled',
+ completedAt: new Date().toISOString(),
+ message: 'Scanner credential revoked.',
+ },
+ })
+ await req.payload.update({
+ collection: 'otter-scanners',
+ context: { otterScannerUpdate: true },
+ id,
+ overrideAccess: true,
+ req,
+ data: {
+ enabled: false,
+ enrollmentTokenHash: null,
+ enrollmentExpiresAt: null,
+ credentialHash: null,
+ credentialExpiresAt: null,
+ currentRun: null,
+ },
+ })
+ })
+ return json({ revoked: true })
+}
+
+export const reenrollOtterScanner: Endpoint['handler'] = async (req) => {
+ humanOrigin(req)
+ const controllerUrl = publicControllerURL()
+ const id = String(req.routeParams?.id || '')
+ const token = newToken()
+ const expiresAt = new Date(Date.now() + 15 * 60_000).toISOString()
+ await inTransaction(req, async () => {
+ const scanner = await req.payload.findByID({
+ collection: 'otter-scanners',
+ depth: 0,
+ id,
+ overrideAccess: false,
+ req,
+ })
+ await requireHuman(req, scanner.site, true)
+ const activeID = relationshipID(scanner.currentRun)
+ if (activeID)
+ await req.payload.update({
+ collection: 'otter-runs',
+ id: String(activeID),
+ overrideAccess: true,
+ req,
+ data: {
+ status: 'cancelled',
+ completedAt: new Date().toISOString(),
+ message: 'Scanner re-enrolled.',
+ },
+ })
+ await req.payload.update({
+ collection: 'otter-scanners',
+ context: { otterScannerUpdate: true },
+ id,
+ overrideAccess: true,
+ req,
+ data: {
+ enabled: true,
+ currentRun: null,
+ enrollmentTokenHash: tokenDigest(token),
+ enrollmentExpiresAt: expiresAt,
+ enrolledAt: null,
+ lastSeenAt: null,
+ credentialHash: null,
+ credentialExpiresAt: null,
+ },
+ })
+ })
+ return json({ controllerUrl, enrollmentToken: token, expiresAt })
+}
+
+export const otterEndpoints: Endpoint[] = [
+ { path: '/otter/v1/overview', method: 'get', handler: otterOverview },
+ { path: '/otter/v1/scanners', method: 'post', handler: createOtterScanner },
+ { path: '/otter/v1/scanners/:id/revoke', method: 'post', handler: revokeOtterScanner },
+ { path: '/otter/v1/scanners/:id/reenroll', method: 'post', handler: reenrollOtterScanner },
+ { path: '/otter/v1/enroll', method: 'post', handler: enrollOtter },
+ { path: '/otter/v1/poll', method: 'post', handler: pollOtter },
+ { path: '/otter/v1/credentials/rotate', method: 'post', handler: rotateOtterCredential },
+ { path: '/otter/v1/profiles/:id/credentials', method: 'post', handler: saveOtterCredentials },
+ { path: '/otter/v1/runs', method: 'post', handler: runOtterNow },
+ { path: '/otter/v1/runs/:id/cancel', method: 'post', handler: cancelOtterRun },
+ { path: '/otter/v1/runs/:id/start', method: 'post', handler: startOtterRun },
+ { path: '/otter/v1/runs/:id/heartbeat', method: 'post', handler: heartbeatOtterRun },
+ { path: '/otter/v1/runs/:id/finish', method: 'post', handler: finishOtterRun },
+]
diff --git a/src/otter/scheduling.ts b/src/otter/scheduling.ts
new file mode 100644
index 0000000..2784c99
--- /dev/null
+++ b/src/otter/scheduling.ts
@@ -0,0 +1,83 @@
+import { Cron } from 'croner'
+import { APIError } from 'payload'
+
+export type ScheduleInput = {
+ cron?: string
+ intervalMinutes?: number
+ runAt?: string
+ scheduleType?: 'once' | 'interval' | 'cron'
+ timezone?: string
+}
+
+export const cronForSchedule = (pattern: string, timezone: string) => {
+ if (pattern.length > 128 || pattern.trim().split(/\s+/).length !== 5)
+ throw new APIError('Use a five-field cron expression (minute hour day month weekday).', 400)
+ try {
+ return new Cron(pattern, { paused: true, timezone })
+ } catch {
+ throw new APIError('The cron expression or time zone is invalid.', 400)
+ }
+}
+
+export const validateScheduleInput = (schedule: ScheduleInput, now = new Date()) => {
+ const type = schedule.scheduleType
+ const timezone = schedule.timezone || 'UTC'
+ if (timezone.length > 128) throw new APIError('Select a valid IANA time zone.', 400)
+ try {
+ new Intl.DateTimeFormat('en', { timeZone: timezone })
+ } catch {
+ throw new APIError('Select a valid IANA time zone.', 400)
+ }
+
+ if (type === 'once') {
+ const runAt = Date.parse(schedule.runAt || '')
+ if (!Number.isFinite(runAt)) throw new APIError('A valid one-time run date is required.', 400)
+ return new Date(runAt).toISOString()
+ }
+ if (type === 'interval') {
+ const minutes = schedule.intervalMinutes
+ if (!Number.isInteger(minutes) || Number(minutes) < 5 || Number(minutes) > 10_080)
+ throw new APIError('Intervals must be between 5 minutes and 7 days.', 400)
+ return new Date(now.getTime() + Number(minutes) * 60_000).toISOString()
+ }
+ if (type === 'cron') {
+ const job = cronForSchedule(schedule.cron || '', timezone)
+ const next = job.nextRun(now)
+ job.stop()
+ if (!next) throw new APIError('The schedule has no upcoming run.', 400)
+ return next.toISOString()
+ }
+ throw new APIError('Choose a supported schedule type.', 400)
+}
+
+export const dueOccurrence = (
+ schedule: ScheduleInput & { nextRunAt?: string },
+ now = new Date(),
+) => {
+ const next = Date.parse(schedule.nextRunAt || '')
+ if (!Number.isFinite(next) || next > now.getTime()) return undefined
+
+ if (schedule.scheduleType === 'once') return { occurrence: new Date(next), nextRunAt: undefined }
+ if (schedule.scheduleType === 'interval') {
+ const interval = Number(schedule.intervalMinutes) * 60_000
+ if (!Number.isFinite(interval) || interval <= 0) return undefined
+ const latest = next + Math.floor((now.getTime() - next) / interval) * interval
+ return {
+ occurrence: new Date(latest),
+ nextRunAt: new Date(latest + interval).toISOString(),
+ }
+ }
+ if (schedule.scheduleType === 'cron') {
+ const job = cronForSchedule(schedule.cron || '', schedule.timezone || 'UTC')
+ const nextRunAt = job.nextRun(now)
+ if (!nextRunAt) {
+ job.stop()
+ return undefined
+ }
+ const occurrence = job.previousRuns(1, now)[0]
+ job.stop()
+ if (!occurrence) return undefined
+ return { occurrence, nextRunAt: nextRunAt.toISOString() }
+ }
+ return undefined
+}
diff --git a/src/otter/security.ts b/src/otter/security.ts
new file mode 100644
index 0000000..f9c5fab
--- /dev/null
+++ b/src/otter/security.ts
@@ -0,0 +1,315 @@
+import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
+import { APIError, type AuthStrategy, type PayloadRequest, type TypedUser } from 'payload'
+import { getAuthorization, relationshipID } from '../access/authorization'
+
+const credentialPrefix = 'Otter '
+
+export const tokenDigest = (token: string) => createHash('sha256').update(token).digest('hex')
+
+export const newToken = () => randomBytes(32).toString('base64url')
+
+const pathOf = (req?: PayloadRequest) => {
+ if (!req?.url) return ''
+ try {
+ return new URL(req.url, 'http://otserver.invalid').pathname
+ } catch {
+ return ''
+ }
+}
+
+const acceptsScannerCredentials = (req?: PayloadRequest) => {
+ const path = pathOf(req)
+ return (
+ path.startsWith('/api/otter/v1/') ||
+ path === '/api/asset-imports' ||
+ path.startsWith('/api/asset-imports/')
+ )
+}
+
+export const otterMachineStrategy: AuthStrategy = {
+ name: 'otter-machine',
+ authenticate: async ({ headers, payload, req }) => {
+ const authorization = headers.get('authorization')
+ if (!req || !authorization?.startsWith(credentialPrefix) || !acceptsScannerCredentials(req))
+ return { user: null }
+
+ const token = authorization.slice(credentialPrefix.length)
+ if (!/^[A-Za-z0-9_-]{40,}$/.test(token)) return { user: null }
+ const now = new Date().toISOString()
+ const result = await payload.find({
+ collection: 'otter-scanners',
+ depth: 0,
+ limit: 1,
+ overrideAccess: true,
+ where: {
+ and: [
+ { credentialHash: { equals: tokenDigest(token) } },
+ { enabled: { equals: true } },
+ { credentialExpiresAt: { greater_than: now } },
+ ],
+ },
+ })
+ const scanner = result.docs[0]
+ const siteID = relationshipID(scanner?.site)
+ if (!scanner || !siteID) return { user: null }
+
+ req.context.otterScannerID = String(scanner.id)
+ req.context.otterScannerSiteID = String(siteID)
+
+ return {
+ user: {
+ collection: 'users',
+ email: `otter-${scanner.id}@otter.invalid`,
+ id: `otter:${scanner.id}`,
+ otterScannerID: String(scanner.id),
+ role: null,
+ } as unknown as TypedUser,
+ }
+ },
+}
+
+const encryptionKey = () => {
+ const value = process.env.OTSERVER_OTTER_ENCRYPTION_KEY
+ if (!value || !/^[\da-f]{64}$/i.test(value))
+ throw new APIError('Otter credential encryption is not configured.', 503)
+ return Buffer.from(value, 'hex')
+}
+
+export const encryptOtterCredentials = (value: unknown) => {
+ const iv = randomBytes(12)
+ const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv)
+ const encrypted = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()])
+ return [
+ 'v1',
+ iv.toString('base64url'),
+ cipher.getAuthTag().toString('base64url'),
+ encrypted.toString('base64url'),
+ ].join('.')
+}
+
+export const decryptOtterCredentials = (value?: string | null) => {
+ if (!value) return {}
+ const [version, iv, tag, encrypted] = value.split('.')
+ if (version !== 'v1' || !iv || !tag || !encrypted)
+ throw new APIError('Stored Otter credentials have an unsupported format.', 500)
+ const key = encryptionKey()
+ try {
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'))
+ decipher.setAuthTag(Buffer.from(tag, 'base64url'))
+ return JSON.parse(
+ Buffer.concat([
+ decipher.update(Buffer.from(encrypted, 'base64url')),
+ decipher.final(),
+ ]).toString('utf8'),
+ ) as Record
+ } catch {
+ throw new APIError('Stored Otter credentials could not be decrypted.', 500)
+ }
+}
+
+const configKeys = new Set([
+ 'targets',
+ 'interface',
+ 'sourceMac',
+ 'noProtocols',
+ 'noArp',
+ 'noProfinet',
+ 'allowDcpSource',
+ 'noS7',
+ 'noEnip',
+ 'noBacnet',
+ 'noFins',
+ 'noFox',
+ 'noDnp3',
+ 'noIec61850',
+ 'noNetbios',
+ 'noSmb',
+ 'noRpc',
+ 'noOpcua',
+ 'noSnmp',
+ 'noLldp',
+ 'opcuaPorts',
+])
+
+const ipv4 = /^(?:\d{1,3}\.){3}\d{1,3}(?:\/(?:[0-9]|[12][0-9]|3[0-2]))?$/
+
+export const validateOtterConfiguration = (input: unknown) => {
+ if (!input || typeof input !== 'object' || Array.isArray(input))
+ throw new APIError('Otter configuration must be an object.', 400)
+ const config = input as Record
+ const unknown = Object.keys(config).find((key) => !configKeys.has(key))
+ if (unknown) throw new APIError(`Unsupported Otter configuration option: ${unknown}.`, 400)
+
+ if (!Array.isArray(config.targets) || !config.targets.length || config.targets.length > 1024)
+ throw new APIError('Enter one or more valid IPv4 addresses or CIDR ranges.', 400)
+ let targetCount = 0
+ for (const target of config.targets) {
+ if (
+ typeof target !== 'string' ||
+ target.length > 64 ||
+ !ipv4.test(target) ||
+ target
+ .split('/')[0]
+ .split('.')
+ .some((part) => Number(part) > 255)
+ )
+ throw new APIError('Enter one or more valid IPv4 addresses or CIDR ranges.', 400)
+ const prefix = Number(target.split('/')[1] || 32)
+ targetCount += 2 ** (32 - prefix)
+ if (targetCount > 65_536)
+ throw new APIError('The configured targets exceed Otter’s 65,536-address scan limit.', 400)
+ }
+
+ if (
+ config.interface !== undefined &&
+ (typeof config.interface !== 'string' ||
+ !config.interface.trim() ||
+ config.interface.length > 256)
+ )
+ throw new APIError('The interface must be a non-empty name of at most 256 characters.', 400)
+ if (
+ config.sourceMac !== undefined &&
+ (typeof config.sourceMac !== 'string' ||
+ !/^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$/i.test(config.sourceMac))
+ )
+ throw new APIError('The source MAC address is invalid.', 400)
+
+ for (const [key, value] of Object.entries(config)) {
+ if (key.startsWith('no') || key === 'allowDcpSource') {
+ if (typeof value !== 'boolean') throw new APIError(`${key} must be a boolean.`, 400)
+ }
+ }
+ if (
+ config.opcuaPorts !== undefined &&
+ (!Array.isArray(config.opcuaPorts) ||
+ config.opcuaPorts.length > 64 ||
+ config.opcuaPorts.some(
+ (port) => !Number.isInteger(port) || Number(port) < 1 || Number(port) > 65535,
+ ))
+ )
+ throw new APIError('OPC UA ports must be integers from 1 to 65535.', 400)
+
+ return structuredClone(config)
+}
+
+export const validateOtterCredentials = (input: unknown) => {
+ if (!input || typeof input !== 'object' || Array.isArray(input))
+ throw new APIError('Otter credentials must be an object.', 400)
+ const value = input as Record
+ const allowed = new Set(['snmp', 'opcuaCredentials', 'opcuaUsername', 'opcuaPassword'])
+ const unknown = Object.keys(value).find((key) => !allowed.has(key))
+ if (unknown) throw new APIError(`Unsupported credential option: ${unknown}.`, 400)
+ if (value.snmp !== undefined) {
+ const entries = Array.isArray(value.snmp) ? value.snmp : [value.snmp]
+ if (
+ entries.length > 32 ||
+ entries.some((item) => {
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return true
+ const credential = item as Record
+ const keys = new Set([
+ 'version',
+ 'community',
+ 'username',
+ 'contextName',
+ 'authProtocol',
+ 'authPassword',
+ 'privacyProtocol',
+ 'privacyPassword',
+ ])
+ if (Object.keys(credential).some((key) => !keys.has(key))) return true
+ for (const [key, field] of Object.entries(credential)) {
+ if (
+ typeof field !== 'string' ||
+ field.length > (key.toLowerCase().includes('password') ? 4096 : 512)
+ )
+ return true
+ }
+ if (
+ credential.authProtocol !== undefined &&
+ !['md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512'].includes(
+ String(credential.authProtocol).toLowerCase(),
+ )
+ )
+ return true
+ if (
+ credential.privacyProtocol !== undefined &&
+ !['des', 'aes128', 'aes192', 'aes256'].includes(
+ String(credential.privacyProtocol).toLowerCase(),
+ )
+ )
+ return true
+ if (
+ (credential.authProtocol !== undefined &&
+ (typeof credential.authPassword !== 'string' || credential.authPassword.length < 8)) ||
+ (credential.privacyProtocol !== undefined &&
+ (typeof credential.privacyPassword !== 'string' ||
+ credential.privacyPassword.length < 8))
+ )
+ return true
+ return Boolean(
+ credential.version !== undefined &&
+ !['1', '2c', '3', 'auto'].includes(String(credential.version).toLowerCase()),
+ )
+ })
+ )
+ throw new APIError('SNMP credentials contain invalid entries.', 400)
+ }
+ const opcua = value.opcuaCredentials
+ if (
+ opcua !== undefined &&
+ (Array.isArray(opcua)
+ ? opcua.length > 32 || opcua.some((entry) => !isOpcuaCredential(entry))
+ : !isOpcuaCredential(opcua))
+ )
+ throw new APIError('OPC UA credentials contain invalid entries.', 400)
+ for (const key of ['opcuaUsername', 'opcuaPassword'])
+ if (
+ value[key] !== undefined &&
+ (typeof value[key] !== 'string' || value[key].length > (key === 'opcuaPassword' ? 4096 : 512))
+ )
+ throw new APIError(`${key} must be text.`, 400)
+ return structuredClone(value)
+}
+
+const isOpcuaCredential = (value: unknown) =>
+ Boolean(
+ value &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ Object.keys(value).every((key) => ['username', 'password'].includes(key)) &&
+ typeof (value as Record).username === 'string' &&
+ (value as Record).username.length <= 512 &&
+ ((value as Record).password === undefined ||
+ (typeof (value as Record).password === 'string' &&
+ (value as Record).password.length <= 4096)),
+ )
+
+export const scannerIDFromRequest = (req: PayloadRequest) => {
+ const value = req.context.otterScannerID
+ return typeof value === 'string' ? value : undefined
+}
+
+export const requireOtterScanner = (req: PayloadRequest) => {
+ const id = scannerIDFromRequest(req)
+ if (!id) throw new APIError('Otter scanner authentication is required.', 401)
+ return id
+}
+
+export const requireOtterConfigAccess = async (
+ req: PayloadRequest,
+ site: unknown,
+ write = false,
+) => {
+ const authorization = await getAuthorization(req)
+ const siteID = String(relationshipID(site) ?? '')
+ if (
+ !authorization.canConfigureOtter ||
+ !(
+ authorization.isAdmin ||
+ (write
+ ? authorization.writableSiteIDs.includes(siteID)
+ : authorization.readableSiteIDs.includes(siteID))
+ )
+ )
+ throw new APIError('Otter Config permission for this site is required.', 403)
+}
diff --git a/src/payload-types.ts b/src/payload-types.ts
index d8d9407..6d4b1cd 100644
--- a/src/payload-types.ts
+++ b/src/payload-types.ts
@@ -76,6 +76,10 @@ export interface Config {
'asset-installations': AssetInstallation;
'identity-cases': IdentityCase;
'asset-imports': AssetImport;
+ 'otter-scanners': OtterScanner;
+ 'otter-profiles': OtterProfile;
+ 'otter-schedules': OtterSchedule;
+ 'otter-runs': OtterRun;
'asset-fields': AssetField;
'user-roles': UserRole;
users: User;
@@ -103,6 +107,10 @@ export interface Config {
'asset-installations': AssetInstallationsSelect | AssetInstallationsSelect;
'identity-cases': IdentityCasesSelect | IdentityCasesSelect;
'asset-imports': AssetImportsSelect | AssetImportsSelect;
+ 'otter-scanners': OtterScannersSelect | OtterScannersSelect;
+ 'otter-profiles': OtterProfilesSelect | OtterProfilesSelect;
+ 'otter-schedules': OtterSchedulesSelect | OtterSchedulesSelect;
+ 'otter-runs': OtterRunsSelect | OtterRunsSelect;
'asset-fields': AssetFieldsSelect | AssetFieldsSelect;
'user-roles': UserRolesSelect | UserRolesSelect;
users: UsersSelect | UsersSelect;
@@ -464,12 +472,14 @@ export interface IdentityCase {
*/
export interface AssetImport {
id: string;
+ name?: string | null;
/**
* Queued imports require OTSERVER_QUEUED_IMPORTS=on and a healthy worker.
*/
executionMode?: ('sync' | 'queued') | null;
jobID?: string | null;
submittedBy?: string | null;
+ executorType?: string | null;
executorID?: string | null;
retriedBy?: string | null;
fileDigest?: string | null;
@@ -492,6 +502,8 @@ export interface AssetImport {
* Every asset in this file will be assigned to this site.
*/
site: string | Site;
+ scanner?: string | null;
+ otterRun?: string | null;
source: 'otserver-otter' | 'proneta' | 'nmap';
/**
* Optional values overwrite these fields on every new or existing asset in this import.
@@ -548,6 +560,214 @@ export interface AssetImport {
focalX?: number | null;
focalY?: number | null;
}
+/**
+ * Enrolled Otter scanner agents and their bound sites.
+ *
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "otter-scanners".
+ */
+export interface OtterScanner {
+ id: string;
+ name: string;
+ site: string | Site;
+ enabled?: boolean | null;
+ enrolledAt?: string | null;
+ lastSeenAt?: string | null;
+ version?: string | null;
+ capabilities?:
+ | {
+ [k: string]: unknown;
+ }
+ | unknown[]
+ | string
+ | number
+ | boolean
+ | null;
+ interfaces?:
+ | {
+ [k: string]: unknown;
+ }
+ | unknown[]
+ | string
+ | number
+ | boolean
+ | null;
+ computerName?: string | null;
+ inventoryReportedAt?: string | null;
+ networkAdapters?:
+ | {
+ name: string;
+ friendlyName: string;
+ description: string;
+ macAddress?: string | null;
+ ipAddresses?: string[] | null;
+ id?: string | null;
+ }[]
+ | null;
+ currentRun?: (string | null) | OtterRun;
+ enrollmentTokenHash?: string | null;
+ enrollmentExpiresAt?: string | null;
+ credentialHash?: string | null;
+ credentialExpiresAt?: string | null;
+ updatedAt: string;
+ createdAt: string;
+}
+/**
+ * Immutable scan assignments and outcomes.
+ *
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "otter-runs".
+ */
+export interface OtterRun {
+ id: string;
+ name: string;
+ site: string | Site;
+ scanner: string | OtterScanner;
+ profile?: (string | null) | OtterProfile;
+ schedule?: (string | null) | OtterSchedule;
+ scheduleOccurrenceKey?: string | null;
+ status:
+ | 'queued'
+ | 'assigned'
+ | 'running'
+ | 'completed'
+ | 'partial'
+ | 'failed'
+ | 'cancel-requested'
+ | 'cancelled'
+ | 'expired';
+ scheduledFor?: string | null;
+ assignedAt?: string | null;
+ leaseExpiresAt?: string | null;
+ startedAt?: string | null;
+ completedAt?: string | null;
+ progress?: number | null;
+ message?: string | null;
+ import?: (string | null) | AssetImport;
+ configSnapshot?:
+ | {
+ [k: string]: unknown;
+ }
+ | unknown[]
+ | string
+ | number
+ | boolean
+ | null;
+ importOverridesSnapshot?:
+ | {
+ [k: string]: unknown;
+ }
+ | unknown[]
+ | string
+ | number
+ | boolean
+ | null;
+ encryptedCredentialsSnapshot?: string | null;
+ updatedAt: string;
+ createdAt: string;
+}
+/**
+ * Central Otter settings. SNMP and OPC UA credentials are encrypted and write-only.
+ *
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "otter-profiles".
+ */
+export interface OtterProfile {
+ id: string;
+ name: string;
+ site: string | Site;
+ scanner: string | OtterScanner;
+ /**
+ * Managed scan options. Site, controller URL, output path, and API credentials are managed by OTserver.
+ */
+ configuration: {
+ /**
+ * IPv4 addresses or CIDR ranges; maximum 65,536 addresses per scan.
+ */
+ targets: string[];
+ /**
+ * Select a reported adapter or enter its technical name. The scanner requires an explicit choice when multiple adapters are available.
+ */
+ interface?: string | null;
+ /**
+ * Optional expected sender MAC. Otter refreshes and uses the selected adapter’s actual MAC at scan start.
+ */
+ sourceMac?: string | null;
+ /**
+ * Disables S7, EtherNet/IP, BACnet, FINS, Fox, DNP3, IEC 61850, NetBIOS, SMB, RPC, and OPC UA. ARP, PROFINET, SNMP, and LLDP are controlled separately.
+ */
+ noProtocols?: boolean | null;
+ noArp?: boolean | null;
+ noProfinet?: boolean | null;
+ allowDcpSource?: boolean | null;
+ noS7?: boolean | null;
+ noEnip?: boolean | null;
+ noBacnet?: boolean | null;
+ noFins?: boolean | null;
+ noFox?: boolean | null;
+ noDnp3?: boolean | null;
+ noIec61850?: boolean | null;
+ noNetbios?: boolean | null;
+ noSmb?: boolean | null;
+ noRpc?: boolean | null;
+ noOpcua?: boolean | null;
+ noSnmp?: boolean | null;
+ noLldp?: boolean | null;
+ /**
+ * OPC UA ports to probe. Leave empty to use Otter defaults: 4840, 4841, and 48400.
+ */
+ opcuaPorts?: number[] | null;
+ };
+ /**
+ * Optional human-quality overrides, applied to every imported asset. Saved with each run.
+ */
+ assetOverrides?: {
+ assetOwner?: string | null;
+ location?: string | null;
+ };
+ customFieldOverrides?:
+ | {
+ [k: string]: unknown;
+ }
+ | unknown[]
+ | string
+ | number
+ | boolean
+ | null;
+ credentialsConfigured?: boolean | null;
+ encryptedCredentials?: string | null;
+ updatedAt: string;
+ createdAt: string;
+}
+/**
+ * Select an Otter configuration; its site, scanner, scan options, and import overrides are used automatically. Offline schedules coalesce to their latest due run.
+ *
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "otter-schedules".
+ */
+export interface OtterSchedule {
+ id: string;
+ name: string;
+ site: string | Site;
+ scanner: string | OtterScanner;
+ profile: string | OtterProfile;
+ scheduleType: 'once' | 'interval' | 'cron';
+ runAt?: string | null;
+ intervalMinutes?: number | null;
+ /**
+ * Five fields: minute hour day-of-month month day-of-week.
+ */
+ cron?: string | null;
+ /**
+ * IANA time zone, for example Europe/Berlin.
+ */
+ timezone: string;
+ enabled?: boolean | null;
+ nextRunAt?: string | null;
+ lastRunAt?: string | null;
+ updatedAt: string;
+ createdAt: string;
+}
/**
* Define optional fields that appear on every asset and import.
*
@@ -578,6 +798,10 @@ export interface UserRole {
* The permanent Admin role has unrestricted access.
*/
isAdmin?: boolean | null;
+ /**
+ * Manage Otter scanners, configuration, and schedules within permitted sites.
+ */
+ otterConfig?: boolean | null;
/**
* Each permission applies to the selected site and all descendants.
*/
@@ -828,7 +1052,7 @@ export interface AuditLog {
documentLabel?: string | null;
asset?: (string | null) | Asset;
assetID?: string | null;
- actorType: 'user' | 'system';
+ actorType: 'otter' | 'user' | 'system';
actorID?: string | null;
actorName?: string | null;
actorEmail?: string | null;
@@ -1289,9 +1513,11 @@ export interface IdentityCasesSelect {
* via the `definition` "asset-imports_select".
*/
export interface AssetImportsSelect {
+ name?: T;
executionMode?: T;
jobID?: T;
submittedBy?: T;
+ executorType?: T;
executorID?: T;
retriedBy?: T;
fileDigest?: T;
@@ -1303,6 +1529,8 @@ export interface AssetImportsSelect {
appliedKey?: T;
duplicateOf?: T;
site?: T;
+ scanner?: T;
+ otterRun?: T;
source?: T;
assetOverrides?:
| T
@@ -1334,6 +1562,130 @@ export interface AssetImportsSelect {
focalX?: T;
focalY?: T;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "otter-scanners_select".
+ */
+export interface OtterScannersSelect {
+ name?: T;
+ site?: T;
+ enabled?: T;
+ enrolledAt?: T;
+ lastSeenAt?: T;
+ version?: T;
+ capabilities?: T;
+ interfaces?: T;
+ computerName?: T;
+ inventoryReportedAt?: T;
+ networkAdapters?:
+ | T
+ | {
+ name?: T;
+ friendlyName?: T;
+ description?: T;
+ macAddress?: T;
+ ipAddresses?: T;
+ id?: T;
+ };
+ currentRun?: T;
+ enrollmentTokenHash?: T;
+ enrollmentExpiresAt?: T;
+ credentialHash?: T;
+ credentialExpiresAt?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "otter-profiles_select".
+ */
+export interface OtterProfilesSelect {
+ name?: T;
+ site?: T;
+ scanner?: T;
+ configuration?:
+ | T
+ | {
+ targets?: T;
+ interface?: T;
+ sourceMac?: T;
+ noProtocols?: T;
+ noArp?: T;
+ noProfinet?: T;
+ allowDcpSource?: T;
+ noS7?: T;
+ noEnip?: T;
+ noBacnet?: T;
+ noFins?: T;
+ noFox?: T;
+ noDnp3?: T;
+ noIec61850?: T;
+ noNetbios?: T;
+ noSmb?: T;
+ noRpc?: T;
+ noOpcua?: T;
+ noSnmp?: T;
+ noLldp?: T;
+ opcuaPorts?: T;
+ };
+ assetOverrides?:
+ | T
+ | {
+ assetOwner?: T;
+ location?: T;
+ };
+ customFieldOverrides?: T;
+ credentialsConfigured?: T;
+ encryptedCredentials?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "otter-schedules_select".
+ */
+export interface OtterSchedulesSelect {
+ name?: T;
+ site?: T;
+ scanner?: T;
+ profile?: T;
+ scheduleType?: T;
+ runAt?: T;
+ intervalMinutes?: T;
+ cron?: T;
+ timezone?: T;
+ enabled?: T;
+ nextRunAt?: T;
+ lastRunAt?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "otter-runs_select".
+ */
+export interface OtterRunsSelect {
+ name?: T;
+ site?: T;
+ scanner?: T;
+ profile?: T;
+ schedule?: T;
+ scheduleOccurrenceKey?: T;
+ status?: T;
+ scheduledFor?: T;
+ assignedAt?: T;
+ leaseExpiresAt?: T;
+ startedAt?: T;
+ completedAt?: T;
+ progress?: T;
+ message?: T;
+ import?: T;
+ configSnapshot?: T;
+ importOverridesSnapshot?: T;
+ encryptedCredentialsSnapshot?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "asset-fields_select".
@@ -1352,6 +1704,7 @@ export interface AssetFieldsSelect {
export interface UserRolesSelect {
name?: T;
isAdmin?: T;
+ otterConfig?: T;
permissions?:
| T
| {
diff --git a/src/payload.config.ts b/src/payload.config.ts
index 5967aa2..1cba23a 100644
--- a/src/payload.config.ts
+++ b/src/payload.config.ts
@@ -20,6 +20,7 @@ import { WorkerLeases, WorkerHeartbeats } from './collections/WorkerState'
import { readiness, workerDiagnostics } from './jobs/diagnostics'
import { superviseWorker } from './jobs/worker'
import { migrations } from './migrations'
+import { otterEndpoints } from './otter/controller'
import {
NetworkEndpoints,
ServiceBindings,
@@ -27,6 +28,7 @@ import {
AssetInstallations,
IdentityCases,
} from './collections/Identity'
+import { OtterProfiles, OtterRuns, OtterScanners, OtterSchedules } from './collections/Otter'
const dirname = import.meta.dirname || path.resolve('src')
@@ -49,6 +51,7 @@ export default buildConfig({
endpoints: [
{ path: '/health/ready', method: 'get', handler: readiness },
{ path: '/operations', method: 'get', handler: workerDiagnostics },
+ ...otterEndpoints,
],
admin: {
components: {
@@ -66,6 +69,10 @@ export default buildConfig({
Component: '@/components/TopologyView',
path: '/topology',
},
+ otters: {
+ Component: '@/components/OtterManagementView',
+ path: '/otters',
+ },
},
},
importMap: {
@@ -88,6 +95,10 @@ export default buildConfig({
AssetInstallations,
IdentityCases,
AssetImports,
+ OtterScanners,
+ OtterProfiles,
+ OtterSchedules,
+ OtterRuns,
AssetFields,
UserRoles,
Users,
diff --git a/tests/int/asset-import-hooks.int.spec.ts b/tests/int/asset-import-hooks.int.spec.ts
index 5b757e6..c43e5f4 100644
--- a/tests/int/asset-import-hooks.int.spec.ts
+++ b/tests/int/asset-import-hooks.int.spec.ts
@@ -4,7 +4,9 @@ import { AssetImports } from '../../src/collections/AssetImports'
const beforeValidate = AssetImports.hooks!.beforeValidate![0]!
const afterChange = AssetImports.hooks!.afterChange![0]!
-const protectProcessing = AssetImports.hooks!.beforeChange![2]!
+const protectProcessing = AssetImports.hooks!.beforeChange!.find(
+ (hook) => hook.name === 'protectProcessing',
+)!
const applyReplayKey = AssetImports.hooks!.beforeChange![3]!
const beforeOperation = AssetImports.hooks!.beforeOperation![0]!
diff --git a/tests/int/components.int.spec.ts b/tests/int/components.int.spec.ts
index d8fc8fa..3e2fa2c 100644
--- a/tests/int/components.int.spec.ts
+++ b/tests/int/components.int.spec.ts
@@ -6,9 +6,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
documentID: undefined as string | undefined,
- fieldValue: {} as Record | string,
+ authPermissions: undefined as
+ { collections?: Record } | undefined,
+ fieldValue: {} as Record | boolean | string,
openModal: vi.fn(),
push: vi.fn(),
+ refresh: vi.fn(),
+ redirect: vi.fn(() => {
+ throw new Error('redirect')
+ }),
+ collectionSlug: undefined as string | undefined,
query: {} as { search?: string; where?: Record },
refineListData: vi.fn(),
selection: { count: 0, getQueryParams: vi.fn(() => ''), selectAll: 'none' } as {
@@ -26,24 +33,96 @@ vi.mock('@payloadcms/ui', async () => {
delete props.buttonStyle
return createElement('button', props, children as never)
},
- ConfirmationModal: ({ body, onConfirm }: { body: unknown; onConfirm: () => void }) =>
+ Gutter: ({ children }: { children: React.ReactNode }) => createElement('div', null, children),
+ Pill: ({ children }: { children: React.ReactNode }) => createElement('span', null, children),
+ FieldLabel: ({ label }: { label: string }) => createElement('label', null, label),
+ FieldError: () => null,
+ ReactSelect: ({
+ options,
+ onChange,
+ value,
+ }: {
+ options: Array<{ label: string; value: string }>
+ onChange: (value: { label: string; value: string } | null) => void
+ value?: { label: string; value: string }
+ }) =>
+ createElement(
+ 'select',
+ {
+ value: value?.value || '',
+ onChange: (event: React.ChangeEvent) =>
+ onChange(
+ options.find(({ value: optionValue }) => optionValue === event.target.value) || null,
+ ),
+ },
+ options.map(({ label, value }) => createElement('option', { key: value, value }, label)),
+ ),
+ TextInput: ({
+ value,
+ onChange,
+ path,
+ }: {
+ value?: string
+ onChange: React.ChangeEventHandler
+ path: string
+ }) => createElement('input', { value, onChange, 'data-path': path }),
+ ConfirmationModal: ({
+ body,
+ onConfirm,
+ onCancel,
+ confirmLabel = 'Confirm',
+ cancelLabel = 'Cancel',
+ }: {
+ body: unknown
+ onConfirm: () => void
+ onCancel?: () => void
+ confirmLabel?: string
+ cancelLabel?: string
+ }) =>
createElement(
'div',
null,
body as never,
- createElement('button', { onClick: onConfirm }, 'Confirm'),
+ createElement('button', { onClick: onConfirm }, confirmLabel),
+ createElement('button', { onClick: onCancel }, cancelLabel),
),
CopyToClipboard: ({ defaultMessage }: { defaultMessage: string }) =>
createElement('span', null, defaultMessage),
- useDocumentInfo: () => ({ id: mocks.documentID }),
+ useDocumentInfo: () => ({
+ id: mocks.documentID,
+ collectionSlug: mocks.collectionSlug,
+ docPermissions: { update: true },
+ }),
useField: () => ({ setValue: mocks.setValue, value: mocks.fieldValue }),
+ useFormFields: (selector: (state: [{ scanner?: { value: string } }]) => unknown) =>
+ selector([{ scanner: { value: 'scanner-1' } }]),
useListQuery: () => ({ query: mocks.query, refineListData: mocks.refineListData }),
- useModal: () => ({ openModal: mocks.openModal }),
+ useModal: () => ({ openModal: mocks.openModal, closeModal: vi.fn(), isModalOpen: () => true }),
useSelection: () => mocks.selection,
+ useAuth: () => ({ permissions: mocks.authPermissions }),
+ useConfig: () => ({ config: { routes: { admin: '/admin' } } }),
}
})
-vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }) }))
+vi.mock('next/navigation', () => ({
+ redirect: mocks.redirect,
+ usePathname: () => '/admin/otters',
+ useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }),
+}))
+vi.mock('next/link', async () => {
+ const { createElement } = await import('react')
+ return {
+ default: ({ href, children, ...props }: Record) =>
+ createElement('a', { href, ...props }, children as never),
+ }
+})
+vi.mock('@payloadcms/next/templates', async () => {
+ const { createElement } = await import('react')
+ return {
+ DefaultTemplate: ({ children }: { children: React.ReactNode }) =>
+ createElement('div', null, children),
+ }
+})
import AssetListInteractions from '../../src/components/AssetListInteractions'
import CopyButton from '../../src/components/AssetView/CopyButton'
@@ -51,6 +130,13 @@ import DensityToggle from '../../src/components/AssetView/DensityToggle'
import CustomAssetFields from '../../src/components/CustomAssetFields'
import ImportInstructions from '../../src/components/ImportInstructions'
import SiteIDField from '../../src/components/SiteIDField'
+import OtterCredentialsField from '../../src/components/OtterCredentialsField'
+import OtterInterfaceField from '../../src/components/OtterInterfaceField'
+import OtterManagementView from '../../src/components/OtterManagementView'
+import OtterManagement from '../../src/components/OtterManagementView/view'
+import OtterNavLink from '../../src/components/OtterNavLink'
+import OtterEnrollment from '../../src/components/OtterEnrollment'
+import OtterScannerActions from '../../src/components/OtterScannerActions'
let container: HTMLDivElement
let root: Root
@@ -68,6 +154,8 @@ beforeEach(() => {
).IS_REACT_ACT_ENVIRONMENT = true
vi.clearAllMocks()
mocks.documentID = undefined
+ mocks.collectionSlug = undefined
+ mocks.authPermissions = undefined
mocks.fieldValue = {}
mocks.query = {}
mocks.selection = { count: 0, getQueryParams: vi.fn(() => ''), selectAll: 'none' }
@@ -313,3 +401,651 @@ describe('site ID field', () => {
expect(container.textContent).toBe('')
})
})
+
+describe('Otter profile credentials field', () => {
+ const click = async (label: string) => {
+ const button = [...container.querySelectorAll('button')].find((entry) =>
+ entry.textContent?.includes(label),
+ )!
+ await act(async () => button.click())
+ }
+
+ const setInput = async (label: string, value: string) => {
+ const field = [...container.querySelectorAll('label')].find((entry) =>
+ entry.textContent?.trim().startsWith(label),
+ )!
+ const element = field.querySelector('input')!
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
+ element,
+ value,
+ )
+ element.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ }
+
+ it('saves write-only credentials and clears them without reloading the document', async () => {
+ mocks.documentID = 'profile-1'
+ mocks.fieldValue = true
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ credentialsConfigured: true }) })
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ credentialsConfigured: false }) })
+ vi.stubGlobal('fetch', fetchMock)
+ await render(createElement(OtterCredentialsField))
+ expect(container.textContent).toContain('encrypted at rest')
+ await click('Add SNMP credential')
+ await setInput('Community', 'secret')
+ await click('Encrypt and save credentials')
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 1,
+ '/api/otter/v1/profiles/profile-1/credentials',
+ expect.objectContaining({ method: 'POST' }),
+ )
+ expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
+ credentials: { snmp: [{ version: '2c', community: 'secret' }] },
+ })
+ expect(container.textContent).toContain('Credentials encrypted and saved')
+
+ await click('Clear stored credentials')
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 2,
+ '/api/otter/v1/profiles/profile-1/credentials',
+ expect.objectContaining({ body: JSON.stringify({ clear: true }) }),
+ )
+ expect(container.textContent).toContain('No credentials are stored')
+ })
+
+ it('requires a saved profile for credentials and hides controls in read-only mode', async () => {
+ mocks.documentID = 'profile-1'
+ mocks.fieldValue = false
+ vi.stubGlobal('fetch', vi.fn())
+ await render(createElement(OtterCredentialsField))
+ await click('Encrypt and save credentials')
+ expect(container.textContent).toContain('Add at least one')
+ expect(fetch).not.toHaveBeenCalled()
+
+ await click('Add OPC UA credential')
+ await setInput('Username', 'operator')
+ mocks.documentID = undefined
+ await render(createElement(OtterCredentialsField))
+ expect(container.textContent).toContain('SNMP v2c community public')
+ await click('Encrypt and save credentials')
+ expect(container.textContent).toContain('Save the profile before configuring credentials')
+
+ await act(async () => root.unmount())
+ root = createRoot(container)
+ await render(createElement(OtterCredentialsField, { readOnly: true } as never))
+ expect(container.querySelector('input')).toBeNull()
+ expect(container.querySelector('button')).toBeNull()
+ })
+
+ it('shows credential API failures without returning stored values', async () => {
+ mocks.documentID = 'profile-1'
+ mocks.fieldValue = false
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: false,
+ json: async () => ({ errors: [{ message: 'Otter Config permission is required.' }] }),
+ }),
+ )
+ await render(createElement(OtterCredentialsField))
+ await click('Add OPC UA credential')
+ await setInput('Username', 'operator')
+ await click('Encrypt and save credentials')
+ expect(container.textContent).toContain('Otter Config permission is required')
+ })
+})
+
+describe('Otter profile interface field', () => {
+ it('offers the selected scanner adapters and displays their addresses', async () => {
+ mocks.fieldValue = 'eth0'
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ scanners: [
+ {
+ id: 'scanner-1',
+ networkAdapters: [
+ {
+ name: 'eth0',
+ friendlyName: 'Industrial Ethernet',
+ description: 'Primary adapter',
+ macAddress: '00:11:22:33:44:55',
+ ipAddresses: ['192.0.2.10'],
+ },
+ {
+ name: 'eth1',
+ friendlyName: 'Service Ethernet',
+ description: 'Maintenance adapter',
+ macAddress: '00:11:22:33:44:66',
+ ipAddresses: ['192.0.2.20'],
+ },
+ ],
+ },
+ ],
+ }),
+ }),
+ )
+ await render(
+ createElement(OtterInterfaceField, {
+ field: { label: 'Network interface', type: 'text' },
+ path: 'configuration.interface',
+ } as never),
+ )
+ expect(container.textContent).toContain('Primary adapter')
+ expect(container.textContent).toContain('00:11:22:33:44:55')
+ const select = container.querySelector('select')!
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set?.call(
+ select,
+ 'eth1',
+ )
+ select.dispatchEvent(new Event('change', { bubbles: true }))
+ })
+ expect(mocks.setValue).toHaveBeenCalledWith('eth1')
+ })
+})
+
+describe('Otter management overview', () => {
+ it('creates an enrollment token in the scanner list popup and opens native settings', async () => {
+ mocks.authPermissions = { collections: { 'otter-profiles': { create: true } } }
+ const fetch = vi.fn().mockImplementation(async (_url: string, options?: RequestInit) => ({
+ ok: true,
+ json: async () =>
+ options?.method === 'POST'
+ ? {
+ scanner: { id: 'new-scanner' },
+ enrollmentToken: 'single-use-token',
+ controllerUrl: 'https://otserver.example',
+ expiresAt: new Date().toISOString(),
+ }
+ : { writableSites: [{ id: 'site-1', name: 'Plant' }] },
+ }))
+ vi.stubGlobal('fetch', fetch)
+ await render(createElement(OtterEnrollment))
+ expect(container.textContent).toContain('Create New')
+ const input = container.querySelector('input')!
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
+ input,
+ 'Line scanner',
+ )
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ await act(async () =>
+ container
+ .querySelector('form')!
+ .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })),
+ )
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/otter/v1/scanners',
+ expect.objectContaining({ body: JSON.stringify({ name: 'Line scanner', site: 'site-1' }) }),
+ )
+ expect(container.textContent).toContain('single-use-token')
+ expect(container.textContent).toContain('--enroll --ack-authorized')
+ await act(async () =>
+ [...container.querySelectorAll('button')]
+ .find((button) => button.textContent === 'Configure scanner')!
+ .click(),
+ )
+ expect(mocks.push).toHaveBeenCalledWith('/admin/collections/otter-scanners/new-scanner')
+ expect(container.textContent).not.toContain('single-use-token')
+ })
+
+ it('runs the saved configuration only after confirming authorization with Yes', async () => {
+ mocks.documentID = 'profile-1'
+ mocks.collectionSlug = 'otter-profiles'
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: 'queued' }) }),
+ )
+ await render(createElement(OtterScannerActions))
+ await act(async () =>
+ [...container.querySelectorAll('button')]
+ .find((button) => button.textContent === 'Run now')!
+ .click(),
+ )
+ expect(mocks.openModal).toHaveBeenCalledWith('otter-run-confirm')
+ expect(fetch).not.toHaveBeenCalled()
+ expect(container.textContent).toContain('permission to run this configuration')
+ await act(async () =>
+ [...container.querySelectorAll('button')]
+ .find((button) => button.textContent === 'No')!
+ .click(),
+ )
+ expect(fetch).not.toHaveBeenCalled()
+ await act(async () =>
+ [...container.querySelectorAll('button')]
+ .find((button) => button.textContent === 'Run now')!
+ .click(),
+ )
+ await act(async () =>
+ [...container.querySelectorAll('button')]
+ .find((button) => button.textContent === 'Yes')!
+ .click(),
+ )
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/otter/v1/runs',
+ expect.objectContaining({
+ body: JSON.stringify({ scannerID: 'scanner-1', profileID: 'profile-1' }),
+ }),
+ )
+ expect(container.textContent).toContain('Scan queued')
+ })
+
+ it('directs scanner settings to configurations instead of running an implicit profile', async () => {
+ mocks.documentID = 'scanner-1'
+ mocks.collectionSlug = 'otter-scanners'
+ await render(createElement(OtterScannerActions))
+ expect(container.querySelector('a')?.getAttribute('href')).toBe(
+ '/admin/collections/otter-profiles',
+ )
+ expect(container.textContent).not.toContain('Run now')
+ })
+
+ it('shows the Otter navigation link only to users with scanner read access', async () => {
+ await render(createElement(OtterNavLink))
+ expect(container.querySelector('a')).toBeNull()
+
+ mocks.authPermissions = { collections: { 'otter-scanners': { read: true } } }
+ await render(createElement(OtterNavLink))
+ expect(container.querySelector('a')?.getAttribute('href')).toBe('/admin/otters')
+ expect(container.textContent).toContain('Manage Otters')
+ })
+
+ it('redirects the old management URL to the scanner collection', async () => {
+ await expect(
+ OtterManagementView({
+ payload: { config: { routes: { admin: '/admin' } } },
+ viewType: 'custom',
+ } as never),
+ ).rejects.toThrow('redirect')
+ expect(mocks.redirect).toHaveBeenCalledWith('/admin/collections/otter-scanners')
+ })
+
+ it('loads site-scoped scanners and performs run, cancel, enroll, and revoke actions', async () => {
+ const overview = {
+ scanners: [
+ {
+ id: 'scanner-1',
+ name: 'Line A',
+ site: { id: 'site-1', name: 'Plant / Line A' },
+ enabled: true,
+ enrolled: true,
+ lastSeenAt: new Date().toISOString(),
+ version: '0.9.0',
+ nextRunAt: new Date().toISOString(),
+ currentRun: 'run-1',
+ },
+ { id: 'scanner-2', name: 'Offline Otter', site: 'site-1', enabled: false, enrolled: false },
+ {
+ id: 'scanner-3',
+ name: 'No profile',
+ site: 'site-unknown',
+ enabled: true,
+ enrolled: true,
+ },
+ ],
+ profiles: [
+ { id: 'profile-1', name: 'Line A', scanner: 'scanner-1', credentialsConfigured: true },
+ {
+ id: 'profile-2',
+ name: 'Line A deep scan',
+ scanner: 'scanner-1',
+ credentialsConfigured: false,
+ },
+ ],
+ sites: [
+ { id: 'site-1', name: 'Plant / Line A' },
+ { id: 'site-2', name: 'Plant / Line B' },
+ ],
+ writableSites: [
+ { id: 'site-1', name: 'Plant / Line A' },
+ { id: 'site-2', name: 'Plant / Line B' },
+ ],
+ runs: [
+ { id: 'run-1', name: 'Line A scan', scanner: 'scanner-1', status: 'running' },
+ {
+ id: 'run-2',
+ name: 'Finished scan',
+ scanner: 'scanner-1',
+ status: 'completed',
+ import: 'import-1',
+ },
+ {
+ id: 'run-3',
+ name: 'Unknown scanner result',
+ scanner: 'removed-scanner',
+ status: 'failed',
+ message: 'Partial data.',
+ },
+ ],
+ }
+ const fetchMock = vi.fn(async (url: string | URL | Request, options?: RequestInit) => {
+ if (String(url) === '/api/otter/v1/overview')
+ return { ok: true, json: async () => overview } as Response
+ if (options?.body && String(url) === '/api/otter/v1/runs')
+ return { ok: true, json: async () => ({ id: 'run-3' }) } as Response
+ if (String(url) === '/api/otter/v1/scanners') {
+ overview.scanners.push({
+ id: 'scanner-new',
+ name: 'New scanner',
+ site: 'site-2',
+ enabled: true,
+ enrolled: false,
+ })
+ return {
+ ok: true,
+ json: async () => ({
+ enrollmentToken: 'new-enrollment-token',
+ expiresAt: new Date().toISOString(),
+ controllerUrl: 'https://otserver.example',
+ scanner: { id: 'scanner-new', name: 'New scanner', site: 'site-2' },
+ }),
+ } as Response
+ }
+ if (String(url).endsWith('/reenroll'))
+ return {
+ ok: true,
+ json: async () => ({
+ enrollmentToken: 'enrollment-token',
+ controllerUrl: 'https://otserver.example',
+ }),
+ } as Response
+ if (String(url).endsWith('/revoke') || String(url).endsWith('/cancel'))
+ return { ok: true, json: async () => ({ revoked: true }) } as Response
+ return { ok: false, json: async () => ({ message: 'Unexpected request.' }) } as Response
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ await render(createElement(OtterManagement, { adminRoute: '/admin' }))
+ expect(container.textContent).toContain('Line A')
+ expect(container.textContent).toContain('Online')
+ expect(container.textContent).toContain('Disabled')
+
+ const profileSelect = container.querySelector(
+ 'select[aria-label="Profile to run on Line A"]',
+ )!
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set?.call(
+ profileSelect,
+ 'profile-2',
+ )
+ profileSelect.dispatchEvent(new Event('change', { bubbles: true }))
+ })
+ const runNow = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Run now'),
+ )!
+ await act(async () => runNow.click())
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/otter/v1/runs',
+ expect.objectContaining({
+ body: JSON.stringify({ scannerID: 'scanner-1', profileID: 'profile-2' }),
+ }),
+ )
+ const runButtons = [...container.querySelectorAll('button')].filter((button) =>
+ button.textContent?.includes('Run now'),
+ )
+ await act(async () => runButtons[2]!.click())
+ expect(container.textContent).toContain('Create a scan profile for this scanner')
+
+ const cancel = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Cancel'),
+ )!
+ await act(async () => cancel.click())
+ expect(fetchMock).toHaveBeenCalledWith('/api/otter/v1/runs/run-1/cancel', expect.anything())
+
+ const reenroll = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Re-enroll'),
+ )!
+ await act(async () => reenroll.click())
+ expect(container.textContent).toContain('enrollment-token')
+ expect(container.textContent).toContain('Controller URL:')
+
+ const revoke = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Revoke'),
+ )!
+ await act(async () => revoke.click())
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/otter/v1/scanners/scanner-1/revoke',
+ expect.anything(),
+ )
+
+ const name = container.querySelector('input[maxlength="120"]')!
+ const siteSelect = container.querySelectorAll('select')[0]!
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set?.call(
+ siteSelect,
+ 'site-2',
+ )
+ siteSelect.dispatchEvent(new Event('change', { bubbles: true }))
+ })
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
+ name,
+ 'New scanner',
+ )
+ name.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ await act(async () => {
+ const enroll = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Generate enrollment token'),
+ )!
+ enroll.click()
+ })
+ expect(container.textContent).toContain('new-enrollment-token')
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/otter/v1/scanners',
+ expect.objectContaining({ body: JSON.stringify({ name: 'New scanner', site: 'site-2' }) }),
+ )
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ vi.stubGlobal('navigator', { clipboard: { writeText } })
+ const copy = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Copy token'),
+ )!
+ await act(async () => copy.click())
+ expect(writeText).toHaveBeenCalledWith('new-enrollment-token')
+
+ fetchMock.mockImplementationOnce(async (url: string | URL | Request) => {
+ if (String(url) === '/api/otter/v1/runs')
+ return {
+ ok: false,
+ json: async () => {
+ throw new Error('not JSON')
+ },
+ } as unknown as Response
+ return { ok: true, json: async () => overview } as Response
+ })
+ const updatedRunButton = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Run now'),
+ )!
+ await act(async () => updatedRunButton.click())
+ expect(container.textContent).toContain('The Otter action failed')
+
+ fetchMock.mockImplementationOnce(
+ async () =>
+ ({
+ ok: false,
+ json: async () => ({ errors: [{ message: 'Cancellation denied.' }] }),
+ }) as Response,
+ )
+ const cancelAgain = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Cancel'),
+ )!
+ await act(async () => cancelAgain.click())
+ expect(container.textContent).toContain('Cancellation denied')
+
+ fetchMock.mockImplementationOnce(
+ async () =>
+ ({
+ ok: false,
+ json: async () => ({ message: 'Re-enrollment denied.' }),
+ }) as Response,
+ )
+ const reenrollAgain = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Re-enroll'),
+ )!
+ await act(async () => reenrollAgain.click())
+ expect(container.textContent).toContain('Re-enrollment denied')
+ })
+
+ it('guides a new scanner from enrollment through a first queued scan', async () => {
+ const overview = {
+ controllerUrl: 'https://otserver.example',
+ scanners: [] as Array>,
+ profiles: [] as Array>,
+ sites: [{ id: 'site-1', name: 'Plant' }],
+ writableSites: [{ id: 'site-1', name: 'Plant' }],
+ runs: [] as Array>,
+ }
+ const fetchMock = vi.fn(async (url: string | URL | Request) => {
+ const path = String(url)
+ if (path === '/api/otter/v1/overview')
+ return { ok: true, json: async () => overview } as Response
+ if (path === '/api/otter/v1/scanners') {
+ overview.scanners.push({
+ id: 'scanner-1',
+ name: 'Plant Otter',
+ site: 'site-1',
+ enabled: true,
+ enrolled: false,
+ })
+ return {
+ ok: true,
+ json: async () => ({
+ enrollmentToken: 'single-use-token',
+ expiresAt: '2026-10-11T18:00:00.000Z',
+ controllerUrl: 'https://otserver.example',
+ scanner: { id: 'scanner-1', name: 'Plant Otter' },
+ }),
+ } as Response
+ }
+ if (path === '/api/otter/v1/runs') {
+ overview.runs.unshift({
+ id: 'run-1',
+ name: 'Plant Otter scan',
+ scanner: 'scanner-1',
+ status: 'queued',
+ })
+ return { ok: true, json: async () => ({ id: 'run-1', status: 'queued' }) } as Response
+ }
+ return { ok: false, json: async () => ({ message: 'Unexpected request.' }) } as Response
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ await render(createElement(OtterManagement, { adminRoute: '/admin' }))
+ expect(container.textContent).toContain('Download the latest Otter release')
+
+ const name = container.querySelector('.otter-management__enroll input')!
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
+ name,
+ 'Plant Otter',
+ )
+ name.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ await act(async () => {
+ container
+ .querySelector('.otter-management__enroll')!
+ .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ })
+ expect(container.textContent).toContain('single-use-token')
+ expect(container.textContent).toContain('--enroll --ack-authorized')
+ expect(container.textContent).toContain('Paste it at the Otter prompt')
+
+ overview.scanners[0]!.enrolled = true
+ overview.scanners[0]!.lastSeenAt = new Date().toISOString()
+ overview.scanners[0]!.computerName = 'plant-otter-host'
+ overview.scanners[0]!.inventoryReportedAt = new Date().toISOString()
+ overview.scanners[0]!.networkAdapters = [
+ {
+ name: 'eth0',
+ friendlyName: 'Industrial Ethernet',
+ description: 'Production network adapter',
+ macAddress: '00:11:22:33:44:55',
+ ipAddresses: ['192.168.1.2'],
+ },
+ ]
+ overview.profiles.push({
+ id: 'profile-1',
+ name: 'Plant Otter scan',
+ scanner: 'scanner-1',
+ credentialsConfigured: false,
+ })
+ const checkConnection = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Check connection now'),
+ )!
+ await act(async () => checkConnection.click())
+ expect(container.textContent).toContain('Restart Otter without --enroll')
+ expect(container.textContent).not.toContain('single-use-token')
+ expect(container.textContent).toContain('plant-otter-host')
+ expect(container.textContent).toContain('00:11:22:33:44:55')
+ expect(
+ [...container.querySelectorAll('a[href="/admin/collections/otter-profiles"]')].some((link) =>
+ link.textContent?.includes('Payload profile editor'),
+ ),
+ ).toBe(true)
+
+ const firstScan = [...container.querySelectorAll('button')].find((button) =>
+ button.textContent?.includes('Run first scan'),
+ )!
+ await act(async () => firstScan.click())
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/otter/v1/runs',
+ expect.objectContaining({
+ body: JSON.stringify({ scannerID: 'scanner-1', profileID: 'profile-1' }),
+ }),
+ )
+ expect(container.textContent).toContain('First scan: queued')
+ })
+
+ it('shows empty and failed load states', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ scanners: [], profiles: [], sites: [], writableSites: [], runs: [] }),
+ }),
+ )
+ await render(createElement(OtterManagement, { adminRoute: '/admin' }))
+ expect(container.textContent).toContain('No scanners registered yet')
+ expect(container.textContent).toContain('No scan runs yet')
+
+ await act(async () => root.unmount())
+ root = createRoot(container)
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: false,
+ json: async () => {
+ throw new Error('not JSON')
+ },
+ }),
+ )
+ await render(createElement(OtterManagement, { adminRoute: '/admin' }))
+ expect(container.textContent).toContain('Could not load Otter management data')
+ })
+
+ it('aborts the initial overview request when unmounted', async () => {
+ let signal: AbortSignal | undefined
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, options?: RequestInit) => {
+ signal = options?.signal as AbortSignal
+ return new Promise((_resolve, reject) =>
+ signal?.addEventListener('abort', () =>
+ reject(new DOMException('Aborted', 'AbortError')),
+ ),
+ )
+ }),
+ )
+ await render(createElement(OtterManagement, { adminRoute: '/admin' }))
+ await act(async () => root.unmount())
+ root = createRoot(container)
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 0)))
+ expect(signal?.aborted).toBe(true)
+ expect(container.textContent).not.toContain('AbortError')
+ })
+})
diff --git a/tests/int/coverage.int.spec.ts b/tests/int/coverage.int.spec.ts
index 35c4594..71849a1 100644
--- a/tests/int/coverage.int.spec.ts
+++ b/tests/int/coverage.int.spec.ts
@@ -124,6 +124,7 @@ describe('site authorization', () => {
expect(await getSiteAndDescendantIDs(['plant'], req as never)).toEqual(['plant', 'line'])
const authorization = await getAuthorization(req as never)
expect(authorization).toEqual({
+ canConfigureOtter: false,
isAdmin: false,
readableSiteIDs: ['plant', 'archive', 'line'],
writableSiteIDs: ['plant', 'line'],
@@ -134,6 +135,7 @@ describe('site authorization', () => {
it('handles anonymous, missing-role, and admin access', async () => {
expect(await getAuthorization(request() as never)).toEqual({
+ canConfigureOtter: false,
isAdmin: false,
readableSiteIDs: [],
writableSiteIDs: [],
@@ -886,12 +888,14 @@ describe('admin presentation', () => {
assets: { create: true },
'asset-imports': { create: true },
sites: { read: true },
+ 'otter-scanners': { read: true },
},
} as never,
})
const html = renderToStaticMarkup(view)
expect(html).toContain('Total assets4')
expect(html).toContain('Manage sites')
+ expect(html).toContain('Manage Otters')
expect(html).toContain('PLC')
expect(html).toContain('Plant')
})
diff --git a/tests/int/jobs-runtime.int.spec.ts b/tests/int/jobs-runtime.int.spec.ts
index f37285d..49745ca 100644
--- a/tests/int/jobs-runtime.int.spec.ts
+++ b/tests/int/jobs-runtime.int.spec.ts
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
acquireWorker: vi.fn(),
authorization: { isAdmin: true },
delay: vi.fn(async () => undefined),
+ expireOtterLeases: vi.fn(async () => undefined),
renewWorker: vi.fn(),
systemRequest: vi.fn(async () => ({ context: {} })),
}))
@@ -21,6 +22,10 @@ vi.mock('../../src/jobs/ownership', () => ({
vi.mock('../../src/jobs/maintenance', () => ({ SYNC_INTERVAL_MS: 10_000 }))
+vi.mock('../../src/otter/controller', () => ({
+ expireOtterLeases: mocks.expireOtterLeases,
+}))
+
vi.mock('../../src/integrations/payload/requests', () => ({
systemRequest: mocks.systemRequest,
}))
@@ -236,12 +241,40 @@ describe('worker scheduling and execution', () => {
controller.abort()
})
await runWorker(payload as never, 'maintenance', controller.signal)
+ expect(mocks.expireOtterLeases).toHaveBeenCalledWith(payload)
expect(payload.jobs.run).toHaveBeenCalledWith(
expect.objectContaining({ queue: 'maintenance', limit: 1, sequential: true }),
)
expect(mocks.renewWorker).toHaveBeenLastCalledWith(payload, fence, true)
})
+ it('reaps expired Otter leases while a maintenance task is still running', async () => {
+ vi.useFakeTimers()
+ const controller = new AbortController()
+ const payload = makePayload()
+ let releaseJob!: () => void
+ let markStarted!: () => void
+ const started = new Promise((resolve) => {
+ markStarted = resolve
+ })
+ const job = new Promise((resolve) => {
+ releaseJob = () => resolve(undefined)
+ })
+ payload.jobs.run.mockImplementationOnce(() => {
+ markStarted()
+ return job
+ })
+
+ const worker = runWorker(payload as never, 'maintenance', controller.signal)
+ await started
+ await vi.advanceTimersByTimeAsync(15_000)
+
+ expect(mocks.expireOtterLeases).toHaveBeenCalledTimes(2)
+ controller.abort()
+ releaseJob()
+ await worker
+ })
+
it('logs a sanitized execution failure and still releases ownership', async () => {
const payload = makePayload()
payload.jobs.run.mockRejectedValueOnce(Object.assign(new Error('secret'), { status: 400 }))
diff --git a/tests/int/migrations.int.spec.ts b/tests/int/migrations.int.spec.ts
index b2686a7..fdf6cd9 100644
--- a/tests/int/migrations.int.spec.ts
+++ b/tests/int/migrations.int.spec.ts
@@ -5,6 +5,10 @@ import {
down as removeSitePaths,
up as addSitePaths,
} from '../../src/migrations/20260926_111902_site_paths'
+import {
+ down as removeOtterIndexes,
+ up as addOtterIndexes,
+} from '../../src/migrations/20261010_164200_otter_indexes'
describe('queued import baseline migration', () => {
it('adds synchronous defaults and reverses only untouched records', async () => {
@@ -79,3 +83,39 @@ describe('site path migration', () => {
expect(updateMany).toHaveBeenCalledWith({}, { $unset: { path: '' } }, { session: args.session })
})
})
+
+describe('Otter index migration', () => {
+ it('creates partial unique indexes for credentials and schedule occurrences', async () => {
+ const runCollection = { createIndex: vi.fn(async () => 'run-index'), dropIndex: vi.fn() }
+ const scannerCollection = {
+ createIndex: vi.fn(async () => 'scanner-index'),
+ dropIndex: vi.fn(),
+ }
+ const args = {
+ payload: {
+ db: {
+ collections: {
+ 'otter-runs': { collection: runCollection },
+ 'otter-scanners': { collection: scannerCollection },
+ },
+ },
+ },
+ session: { id: 'session' },
+ }
+
+ await addOtterIndexes(args as never)
+ expect(runCollection.createIndex).toHaveBeenCalledWith(
+ { scheduleOccurrenceKey: 1 },
+ {
+ name: 'otter_run_schedule_occurrence_unique',
+ unique: true,
+ partialFilterExpression: { scheduleOccurrenceKey: { $type: 'string' } },
+ },
+ )
+ expect(scannerCollection.createIndex).toHaveBeenCalledTimes(2)
+ await removeOtterIndexes(args as never)
+ expect(runCollection.dropIndex).toHaveBeenCalledWith('otter_run_schedule_occurrence_unique')
+ expect(scannerCollection.dropIndex).toHaveBeenCalledWith('otter_scanner_credential_unique')
+ expect(scannerCollection.dropIndex).toHaveBeenCalledWith('otter_scanner_enrollment_unique')
+ })
+})
diff --git a/tests/int/otter-client.int.spec.ts b/tests/int/otter-client.int.spec.ts
new file mode 100644
index 0000000..b9558b3
--- /dev/null
+++ b/tests/int/otter-client.int.spec.ts
@@ -0,0 +1,340 @@
+import config from '@/payload.config'
+import { spawn, execFileSync } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { getPayload, handleEndpoints } from 'payload'
+import { describe, expect, it } from 'vitest'
+
+// Explicit opt-in: the normal TypeScript suite does not require a Rust toolchain.
+describe.skipIf(!process.env.OTTER_CLIENT_BIN)('Rust controller interoperability', () => {
+ it.each(['bare', 'http'])('enrolls, imports, and restarts using %s URLs', async (format) => {
+ const binary = resolve(process.env.OTTER_CLIENT_BIN!)
+ const interfaces = JSON.parse(
+ execFileSync(binary, ['interfaces', '--json'], { encoding: 'utf8' }),
+ ) as { name: string }[]
+ expect(interfaces.length).toBeGreaterThan(0)
+ const payload = await getPayload({ config })
+ const directory = await mkdtemp(join(tmpdir(), 'otter-client-'))
+ const oldURL = process.env.OTSERVER_PUBLIC_URL
+ const oldKey = process.env.OTSERVER_OTTER_ENCRYPTION_KEY
+ process.env.OTSERVER_OTTER_ENCRYPTION_KEY = 'c'.repeat(64)
+ const created: {
+ collection:
+ | 'sites'
+ | 'user-roles'
+ | 'users'
+ | 'otter-scanners'
+ | 'otter-profiles'
+ | 'otter-runs'
+ | 'asset-imports'
+ id: string
+ }[] = []
+ const requests: { path: string; status: number }[] = []
+ let client: ReturnType | undefined
+ let output = ''
+ const server = createServer(async (incoming, outgoing) => {
+ try {
+ const chunks: Buffer[] = []
+ for await (const chunk of incoming) chunks.push(Buffer.from(chunk))
+ const headers = new Headers()
+ for (const [key, value] of Object.entries(incoming.headers)) {
+ if (value !== undefined) headers.set(key, Array.isArray(value) ? value.join(', ') : value)
+ }
+ const path = incoming.url!
+ const body = Buffer.concat(chunks)
+ const response = await handleEndpoints({
+ config,
+ path,
+ request: new Request(`${process.env.OTSERVER_PUBLIC_URL}${path}`, {
+ method: incoming.method,
+ headers,
+ ...(body.length ? { body: new Uint8Array(body) } : {}),
+ }),
+ })
+ requests.push({ path, status: response.status })
+ outgoing.writeHead(response.status, Object.fromEntries(response.headers))
+ outgoing.end(Buffer.from(await response.arrayBuffer()))
+ } catch {
+ outgoing.writeHead(500).end()
+ }
+ })
+ try {
+ await new Promise((done) => server.listen(0, '127.0.0.1', done))
+ const address = server.address() as { port: number }
+ const url = `http://127.0.0.1:${address.port}`
+ process.env.OTSERVER_PUBLIC_URL = url
+ const site = await payload.create({
+ collection: 'sites',
+ data: { name: `Client lab ${randomUUID()}`, type: 'Test' },
+ })
+ created.push({ collection: 'sites', id: site.id })
+ const role = await payload.create({
+ collection: 'user-roles',
+ data: {
+ name: `Client operator ${randomUUID()}`,
+ otterConfig: true,
+ permissions: [{ site: site.id, access: 'read-write' }],
+ },
+ })
+ created.push({ collection: 'user-roles', id: role.id })
+ const apiKey = randomUUID()
+ const user = await payload.create({
+ collection: 'users',
+ data: {
+ name: 'Client lab',
+ email: `client-${randomUUID()}@example.test`,
+ password: randomUUID(),
+ role: role.id,
+ enableAPIKey: true,
+ apiKey,
+ },
+ })
+ created.push({ collection: 'users', id: user.id })
+ const management = async (path: string, body: unknown) => {
+ const response = await fetch(`${url}/api/otter/v1/${path}`, {
+ method: 'POST',
+ headers: {
+ Origin: url,
+ Authorization: `users API-Key ${apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ })
+ expect(response.ok).toBe(true)
+ return response.json()
+ }
+ const scanner = (await management('scanners', { name: 'Rust client', site: site.id })) as {
+ enrollmentToken: string
+ scanner: { id: string }
+ }
+ created.push({ collection: 'otter-scanners', id: scanner.scanner.id })
+ const profile = await payload.create({
+ collection: 'otter-profiles',
+ overrideAccess: false,
+ user,
+ data: {
+ name: 'Read-only client interoperability',
+ site: site.id,
+ scanner: scanner.scanner.id,
+ configuration: {
+ targets: ['192.0.2.1'],
+ interface: interfaces[0].name,
+ sourceMac: '00:11:22:33:44:55',
+ noProtocols: true,
+ noArp: true,
+ noProfinet: true,
+ noSnmp: true,
+ noLldp: true,
+ },
+ },
+ })
+ created.push({ collection: 'otter-profiles', id: profile.id })
+ const run = (await management('runs', {
+ scannerID: scanner.scanner.id,
+ profileID: profile.id,
+ })) as { id: string }
+ created.push({ collection: 'otter-runs', id: run.id })
+ const startClient = (
+ serverURL: string,
+ enroll: boolean,
+ overwriteStateDir = false,
+ enrollmentToken = scanner.enrollmentToken,
+ ) => {
+ client = spawn(binary, [
+ 'controller',
+ '--server-url',
+ serverURL,
+ '--state-dir',
+ join(directory, 'state'),
+ '--file-credentials',
+ '--poll-seconds',
+ '1',
+ ...(enroll ? ['--enroll'] : []),
+ ...(overwriteStateDir ? ['--overwrite-state-dir'] : []),
+ '--ack-authorized',
+ ])
+ client.stdout!.on('data', (chunk) => {
+ output += chunk.toString()
+ })
+ client.stderr!.on('data', (chunk) => {
+ output += chunk.toString()
+ })
+ const exited = new Promise((_, reject) => {
+ client!.once('error', reject)
+ client!.once('close', (code, signal) => {
+ reject(new Error(`Rust controller exited (${code ?? signal}): ${output}`))
+ })
+ })
+ client.stdin!.end(enroll ? `${enrollmentToken}\n` : undefined)
+ return exited
+ }
+ let saved: {
+ serverUrl?: string
+ scanner?: { id: string; site: string }
+ assignment?: { id: string; phase: string; importId?: string }
+ fileCredential?: { credential: string }
+ } = {}
+ // A takeover must replace the previous controller binding without replaying its work or deleting exports.
+ const stateDirectory = join(directory, 'state')
+ await mkdir(stateDirectory, { mode: 0o700 })
+ const previousState = JSON.stringify({
+ serverUrl: 'https://previous-controller.example/',
+ scanner: { id: 'previous-scanner', name: 'Previous scanner', site: 'previous-site' },
+ credentialExpiresAt: '',
+ fileCredential: {
+ credential: 'p'.repeat(43),
+ credentialExpiresAt: new Date(Date.now() + 86400_000).toISOString(),
+ },
+ assignment: { id: 'previous-run', phase: 'scanning', status: 'failed', importId: null },
+ pendingImports: [{ id: 'previous-import', status: 'pending', retries: 0 }],
+ })
+ await writeFile(join(stateDirectory, 'agent-state.json'), previousState, { mode: 0o600 })
+ await writeFile(
+ join(stateDirectory, 'previous-run.otserver.json'),
+ 'retained previous export',
+ { mode: 0o600 },
+ )
+ await expect(startClient(url, true, true, 'i'.repeat(43))).rejects.toThrow(
+ /Scanner credential is expired or not accepted/,
+ )
+ expect(await readFile(join(stateDirectory, 'agent-state.json'), 'utf8')).toBe(previousState)
+ await Promise.race([
+ startClient(format === 'bare' ? `127.0.0.1:${address.port}` : url, true, true),
+ expect
+ .poll(
+ async () => {
+ saved = JSON.parse(
+ await readFile(join(directory, 'state', 'agent-state.json'), 'utf8'),
+ )
+ return saved.assignment?.phase
+ },
+ { timeout: 30_000 },
+ )
+ .toBe('terminal'),
+ ])
+ expect(saved.serverUrl).toBe(`${url}/`)
+ expect(saved.scanner).toMatchObject({ id: scanner.scanner.id, site: site.id })
+ expect(saved.assignment?.id).toBe(run.id)
+ expect(await readFile(join(stateDirectory, 'previous-run.otserver.json'), 'utf8')).toBe(
+ 'retained previous export',
+ )
+ expect(
+ requests.some(
+ ({ path }) => path.includes('previous-run') || path.includes('previous-import'),
+ ),
+ ).toBe(false)
+ expect(saved.assignment?.importId).toBeTruthy()
+ const finished = await payload.findByID({ collection: 'otter-runs', id: run.id, depth: 0 })
+ expect(finished).toMatchObject({ status: 'completed', import: saved.assignment!.importId })
+ const imported = await payload.findByID({
+ collection: 'asset-imports',
+ id: saved.assignment!.importId!,
+ depth: 0,
+ })
+ expect(imported).toMatchObject({
+ status: 'completed',
+ source: 'otserver-otter',
+ site: site.id,
+ scanner: scanner.scanner.id,
+ otterRun: run.id,
+ executionMode: 'sync',
+ })
+ expect(requests).toContainEqual({ path: '/api/otter/v1/enroll', status: 201 })
+ expect(requests).toContainEqual({ path: '/api/asset-imports', status: 201 })
+ for (const action of ['start', 'heartbeat', 'finish'])
+ expect(requests).toContainEqual({
+ path: `/api/otter/v1/runs/${run.id}/${action}`,
+ status: 200,
+ })
+ const exported = JSON.parse(
+ await readFile(join(directory, 'state', `${run.id}.otserver.json`), 'utf8'),
+ )
+ expect(exported).toMatchObject({ format: 'otserver-scan', schemaVersion: 2, devices: [] })
+ await new Promise((done) => {
+ client!.once('close', () => done())
+ client!.kill('SIGKILL')
+ })
+ await payload.update({
+ collection: 'otter-profiles',
+ id: profile.id,
+ overrideAccess: false,
+ user,
+ data: { configuration: { ...profile.configuration, interface: `missing-${randomUUID()}` } },
+ })
+ const failedRun = (await management('runs', {
+ scannerID: scanner.scanner.id,
+ profileID: profile.id,
+ })) as { id: string }
+ created.push({ collection: 'otter-runs', id: failedRun.id })
+ const requestCount = requests.length
+ await Promise.race([
+ startClient(url, false),
+ expect
+ .poll(
+ () =>
+ requests
+ .slice(requestCount)
+ .some(({ path, status }) => path === '/api/otter/v1/poll' && status === 200),
+ { timeout: 10_000 },
+ )
+ .toBe(true),
+ ])
+ expect(
+ requests.filter(({ path, status }) => path === '/api/otter/v1/enroll' && status === 201),
+ ).toHaveLength(1)
+ expect(requests.filter(({ path }) => path === '/api/asset-imports')).toHaveLength(1)
+ expect(
+ requests.filter(({ path }) => path === `/api/otter/v1/runs/${run.id}/start`),
+ ).toHaveLength(1)
+ await expect
+ .poll(
+ async () => {
+ const failed = await payload.findByID({
+ collection: 'otter-runs',
+ id: failedRun.id,
+ depth: 0,
+ })
+ return { status: failed.status, message: failed.message }
+ },
+ { timeout: 15_000 },
+ )
+ .toEqual({
+ status: 'failed',
+ message: 'The selected network interface is not available on this scanner.',
+ })
+ expect(requests).toContainEqual({
+ path: `/api/otter/v1/runs/${failedRun.id}/finish`,
+ status: 200,
+ })
+ expect(requests.filter(({ path }) => path === '/api/asset-imports')).toHaveLength(1)
+ expect(output).not.toContain(scanner.enrollmentToken)
+ expect(output).not.toContain(saved.fileCredential!.credential)
+ } finally {
+ if (client && client.exitCode === null && client.signalCode === null) {
+ const stopped = new Promise((done) => client!.once('close', () => done()))
+ client.kill('SIGKILL')
+ await stopped
+ }
+ await new Promise((done) => server.close(() => done()))
+ const site = created.find((document) => document.collection === 'sites')
+ if (site) {
+ const imports = await payload.find({
+ collection: 'asset-imports',
+ pagination: false,
+ where: { site: { equals: site.id } },
+ })
+ for (const imported of imports.docs)
+ await payload.delete({ collection: 'asset-imports', id: imported.id })
+ }
+ for (const document of created.reverse()) await payload.delete(document)
+ await rm(directory, { recursive: true, force: true })
+ if (oldURL === undefined) delete process.env.OTSERVER_PUBLIC_URL
+ else process.env.OTSERVER_PUBLIC_URL = oldURL
+ if (oldKey === undefined) delete process.env.OTSERVER_OTTER_ENCRYPTION_KEY
+ else process.env.OTSERVER_OTTER_ENCRYPTION_KEY = oldKey
+ }
+ })
+})
diff --git a/tests/int/otter-management.int.spec.ts b/tests/int/otter-management.int.spec.ts
new file mode 100644
index 0000000..b8a2bd6
--- /dev/null
+++ b/tests/int/otter-management.int.spec.ts
@@ -0,0 +1,1510 @@
+import { createPayloadRequest, getPayload, type Payload, type PayloadRequest } from 'payload'
+import { randomUUID } from 'node:crypto'
+import { readFile } from 'node:fs/promises'
+import { beforeAll, describe, expect, it } from 'vitest'
+import config from '@/payload.config'
+import { ensureAdminRole } from '../../src/collections/UserRoles'
+import { executeQueuedImport } from '../../src/application/queuedImports'
+import { decryptOtterCredentials } from '../../src/otter/security'
+import { relationshipID } from '../../src/access/authorization'
+import {
+ cancelOtterRun,
+ createOtterScanner,
+ enrollOtter,
+ expireOtterLeases,
+ finishOtterRun,
+ heartbeatOtterRun,
+ otterOverview,
+ pollOtter,
+ reenrollOtterScanner,
+ revokeOtterScanner,
+ rotateOtterCredential,
+ runOtterNow,
+ saveOtterCredentials,
+ startOtterRun,
+} from '../../src/otter/controller'
+
+let payload: Payload
+
+beforeAll(async () => {
+ payload = await getPayload({ config })
+})
+
+const request = async (
+ path: string,
+ options: { body?: unknown; token?: string; method?: string; origin?: string; url?: string } = {},
+) => {
+ const {
+ body,
+ token,
+ method = 'POST',
+ origin = 'https://otserver.example',
+ url = `https://otserver.example/api${path}`,
+ } = options
+ const request = new Request(url, {
+ method,
+ headers: {
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
+ ...(origin ? { Origin: origin } : {}),
+ ...(token ? { Authorization: token } : {}),
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ })
+ return createPayloadRequest({ config: payload.config, request })
+}
+
+const routeRequest = async (
+ path: string,
+ id: string,
+ token: string,
+ body?: unknown,
+ origin = '',
+) => {
+ const req = await request(path, { body, token, origin })
+ ;(req as PayloadRequest & { routeParams?: Record }).routeParams = { id }
+ return req
+}
+
+describe('Otter management controller', () => {
+ it('enrolls once, delivers encrypted profile credentials, and completes a managed run', async () => {
+ const oldURL = process.env.OTSERVER_PUBLIC_URL
+ const oldKey = process.env.OTSERVER_OTTER_ENCRYPTION_KEY
+ const oldQueuedImports = process.env.OTSERVER_QUEUED_IMPORTS
+ process.env.OTSERVER_PUBLIC_URL = 'https://otserver.example'
+ process.env.OTSERVER_OTTER_ENCRYPTION_KEY = 'b'.repeat(64)
+ const siteName = `Otter site ${randomUUID()}`
+ const roleName = `Otter role ${randomUUID()}`
+ const email = `otter-${randomUUID()}@example.test`
+ const readerEmail = `otter-reader-${randomUUID()}@example.test`
+ const password = randomUUID()
+ let siteID: string | undefined
+ let otherSiteID: string | undefined
+ let roleID: string | undefined
+ let userID: string | undefined
+ let readerRoleID: string | undefined
+ let readerID: string | undefined
+ let adminID: string | undefined
+ let scannerID: string | undefined
+ let foreignScannerID: string | undefined
+ let profileID: string | undefined
+ let foreignProfileID: string | undefined
+ let runID: string | undefined
+ let queuedCancelID: string | undefined
+ let scheduledRunID: string | undefined
+ let oneTimeScheduleID: string | undefined
+ let oneTimeRunID: string | undefined
+ let revokedRunID: string | undefined
+ let reenrollRunID: string | undefined
+ let foreignRunID: string | undefined
+ let expiredRunID: string | undefined
+ let badSnapshotRunID: string | undefined
+ let noSecretRunID: string | undefined
+ let fallbackFailureRunID: string | undefined
+ let scheduleID: string | undefined
+ let importID: string | undefined
+ let queuedImportID: string | undefined
+ let customFieldID: string | undefined
+
+ try {
+ const site = await payload.create({
+ collection: 'sites',
+ data: { name: siteName, type: 'Plant' },
+ })
+ siteID = site.id
+ const otherSite = await payload.create({
+ collection: 'sites',
+ data: { name: `Other Otter site ${randomUUID()}`, type: 'Plant' },
+ })
+ otherSiteID = otherSite.id
+ const role = await payload.create({
+ collection: 'user-roles',
+ data: {
+ name: roleName,
+ otterConfig: true,
+ permissions: [{ site: site.id, access: 'read-write' }],
+ },
+ })
+ roleID = role.id
+ const user = await payload.create({
+ collection: 'users',
+ data: { email, name: 'Otter operator', password, role: role.id },
+ })
+ userID = user.id
+ const login = await payload.login({ collection: 'users', data: { email, password } })
+ const readerRole = await payload.create({
+ collection: 'user-roles',
+ data: {
+ name: `Otter site writer without config ${randomUUID()}`,
+ permissions: [{ site: site.id, access: 'read-write' }],
+ },
+ })
+ readerRoleID = readerRole.id
+ const reader = await payload.create({
+ collection: 'users',
+ data: { email: readerEmail, name: 'Site writer', password, role: readerRole.id },
+ })
+ readerID = reader.id
+ const readerLogin = await payload.login({
+ collection: 'users',
+ data: { email: readerEmail, password },
+ })
+ await expect(
+ otterOverview(
+ await request('/otter/v1/overview', { method: 'GET', token: `JWT ${readerLogin.token}` }),
+ ),
+ ).rejects.toMatchObject({ status: 403 })
+ const adminRole = await ensureAdminRole(payload)
+ const adminEmail = `otter-admin-${randomUUID()}@example.test`
+ const admin = await payload.create({
+ collection: 'users',
+ data: {
+ email: adminEmail,
+ name: 'Otter administrator',
+ password,
+ role: adminRole.id,
+ },
+ })
+ adminID = admin.id
+ const adminLogin = await payload.login({
+ collection: 'users',
+ data: { email: adminEmail, password },
+ })
+
+ const configuredURL = process.env.OTSERVER_PUBLIC_URL
+ delete process.env.OTSERVER_PUBLIC_URL
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'Plant Otter', site: site.id },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 503 })
+ process.env.OTSERVER_PUBLIC_URL = configuredURL
+ for (const invalidControllerURL of [
+ 'http://scanner-host.example',
+ 'https://user:password@otserver.example',
+ 'https://otserver.example?query=not-allowed',
+ 'https://otserver.example#fragment',
+ ]) {
+ process.env.OTSERVER_PUBLIC_URL = invalidControllerURL
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'Plant Otter', site: site.id },
+ token: `JWT ${login.token}`,
+ origin: new URL(invalidControllerURL).origin,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 503 })
+ }
+ process.env.OTSERVER_PUBLIC_URL = configuredURL
+ process.env.OTSERVER_PUBLIC_URL = 'http://scanner-host.example'
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'Plant Otter', site: site.id },
+ token: `JWT ${login.token}`,
+ origin: 'http://scanner-host.example',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 503 })
+ process.env.OTSERVER_PUBLIC_URL = configuredURL
+
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'Plant Otter', site: site.id },
+ token: `JWT ${login.token}`,
+ origin: 'https://attacker.example',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 403 })
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'Plant Otter', site: site.id },
+ token: `JWT ${login.token}`,
+ origin: '',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 403 })
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', { body: { name: 'Plant Otter', site: site.id } }),
+ ),
+ ).rejects.toMatchObject({ status: 401 })
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: ' ', site: site.id },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'n'.repeat(121), site: site.id },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'bad\nname', site: site.id },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'No site', site: '' },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 403 })
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'Cross-site form', site: site.id },
+ token: `JWT ${login.token}`,
+ origin: 'https://attacker.example',
+ url: 'http://localhost:3000/api/otter/v1/scanners',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 403 })
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'x'.repeat(33_000), site: site.id },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 413 })
+ const declaredLargeBody = await createPayloadRequest({
+ config: payload.config,
+ request: new Request('https://otserver.example/api/otter/v1/scanners', {
+ method: 'POST',
+ headers: {
+ Authorization: `JWT ${login.token}`,
+ 'Content-Length': '40000',
+ 'Content-Type': 'application/json',
+ Origin: 'https://otserver.example',
+ },
+ body: '{}',
+ }),
+ })
+ await expect(createOtterScanner(declaredLargeBody)).rejects.toMatchObject({ status: 413 })
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', { body: null, token: `JWT ${login.token}` }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ await expect(
+ createOtterScanner(await request('/otter/v1/scanners', { token: `JWT ${login.token}` })),
+ ).rejects.toMatchObject({ status: 400 })
+ const invalidJSON = await createPayloadRequest({
+ config: payload.config,
+ request: new Request('https://otserver.example/api/otter/v1/scanners', {
+ method: 'POST',
+ headers: {
+ Authorization: `JWT ${login.token}`,
+ 'Content-Type': 'application/json',
+ Origin: 'https://otserver.example',
+ },
+ body: '{',
+ }),
+ })
+ await expect(createOtterScanner(invalidJSON)).rejects.toMatchObject({ status: 400 })
+
+ process.env.OTSERVER_PUBLIC_URL = 'http://127.0.0.1:3000'
+ const createResponse = await createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'Plant Otter', site: site.id },
+ token: `JWT ${login.token}`,
+ origin: 'http://localhost:3000',
+ url: 'http://127.0.0.1:3000/api/otter/v1/scanners',
+ }),
+ )
+ expect(createResponse.status).toBe(201)
+ const created = (await createResponse.json()) as {
+ enrollmentToken: string
+ scanner: { id: string }
+ }
+ scannerID = created.scanner.id
+ expect(created).toMatchObject({ controllerUrl: 'http://127.0.0.1:3000' })
+
+ const foreignScanner = await payload.create({
+ collection: 'otter-scanners',
+ data: { name: 'Other scanner', site: site.id, enabled: true },
+ overrideAccess: true,
+ user,
+ })
+ foreignScannerID = foreignScanner.id
+
+ const enrollmentResponse = await enrollOtter(
+ await request('/otter/v1/enroll', {
+ body: {
+ token: created.enrollmentToken,
+ version: '0.9.0',
+ capabilities: ['scan-v2', 'controller-v1'],
+ interfaces: ['eth0'],
+ computerName: 'otter-host-1',
+ networkAdapters: [
+ {
+ name: 'eth0',
+ friendlyName: 'Ethernet',
+ description: 'Industrial network adapter',
+ macAddress: '00:11:22:33:44:55',
+ ipAddresses: ['192.0.2.10', '2001:db8::10'],
+ },
+ ],
+ },
+ origin: '',
+ }),
+ )
+ expect(enrollmentResponse.status).toBe(201)
+ const enrollment = (await enrollmentResponse.json()) as { credential: string }
+ expect(
+ await payload.findByID({ collection: 'otter-scanners', id: scannerID!, depth: 0 }),
+ ).toMatchObject({
+ computerName: 'otter-host-1',
+ version: '0.9.0',
+ interfaces: ['eth0'],
+ networkAdapters: [
+ {
+ name: 'eth0',
+ friendlyName: 'Ethernet',
+ macAddress: '00:11:22:33:44:55',
+ ipAddresses: ['192.0.2.10', '2001:db8::10'],
+ },
+ ],
+ })
+ await expect(
+ enrollOtter(await request('/otter/v1/enroll', { body: { token: 42 }, origin: '' })),
+ ).rejects.toMatchObject({ status: 401 })
+ await expect(
+ createOtterScanner(
+ await request('/otter/v1/scanners', {
+ body: { name: 'Machine cannot enroll', site: site.id },
+ token: `Otter ${enrollment.credential}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 403 })
+ await expect(
+ enrollOtter(
+ await request('/otter/v1/enroll', {
+ body: { token: created.enrollmentToken },
+ origin: '',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 401 })
+ await expect(
+ runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: 42 },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+
+ const customField = await payload.create({
+ collection: 'asset-fields',
+ data: { label: `Otter override ${randomUUID()}`, type: 'number' },
+ })
+ customFieldID = customField.id
+ const profile = await payload.create({
+ collection: 'otter-profiles',
+ data: {
+ name: 'Line A',
+ site: site.id,
+ scanner: scannerID,
+ configuration: { targets: ['192.0.2.10'], interface: 'eth0', noSnmp: false },
+ assetOverrides: { assetOwner: 'Central owner' },
+ customFieldOverrides: { [customField.id]: 7 },
+ },
+ overrideAccess: false,
+ user,
+ })
+ profileID = profile.id
+ expect(profile.credentialsConfigured).toBe(true)
+ expect(profile.encryptedCredentials).toBeUndefined()
+ const storedProfile = await payload.findByID({
+ collection: 'otter-profiles',
+ id: profile.id,
+ depth: 0,
+ })
+ expect(decryptOtterCredentials(storedProfile.encryptedCredentials)).toEqual({
+ snmp: [{ version: '2c', community: 'public' }],
+ })
+ await expect(
+ payload.update({
+ collection: 'otter-profiles',
+ id: profile.id,
+ data: { customFieldOverrides: { [customField.id]: 'invalid number' } },
+ overrideAccess: false,
+ user,
+ }),
+ ).rejects.toThrow(/Invalid value for custom field/)
+ await payload.update({
+ collection: 'otter-profiles',
+ id: profile.id,
+ data: { site: otherSite.id },
+ context: { otterCredentialWrite: true },
+ overrideAccess: true,
+ user: admin,
+ })
+ await expect(
+ runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: profile.id },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ await payload.update({
+ collection: 'otter-profiles',
+ id: profile.id,
+ data: { site: site.id },
+ context: { otterCredentialWrite: true },
+ overrideAccess: true,
+ user: admin,
+ })
+ const foreignProfile = await payload.create({
+ collection: 'otter-profiles',
+ data: {
+ name: 'Other scanner profile',
+ site: site.id,
+ scanner: foreignScanner.id,
+ configuration: { targets: ['192.0.2.11'] },
+ },
+ overrideAccess: true,
+ user,
+ })
+ foreignProfileID = foreignProfile.id
+ await expect(
+ payload.update({
+ collection: 'otter-scanners',
+ id: scannerID,
+ data: { profile: foreignProfile.id } as never,
+ overrideAccess: false,
+ user,
+ }),
+ ).rejects.toThrow(/Otter Configurations/i)
+ await expect(
+ payload.update({
+ collection: 'otter-scanners',
+ id: scannerID,
+ data: { network: { targets: ['192.0.2.20'], interface: 'eth1' } } as never,
+ overrideAccess: false,
+ user,
+ }),
+ ).rejects.toThrow(/Otter Configurations/i)
+ await expect(
+ payload.update({
+ collection: 'otter-scanners',
+ id: scannerID,
+ data: { name: 'Unauthorized edit' },
+ overrideAccess: false,
+ user: reader,
+ }),
+ ).rejects.toThrow()
+ const configuredScanner = await payload.update({
+ collection: 'otter-scanners',
+ id: scannerID,
+ data: { enabled: false, computerName: 'spoofed-host' },
+ overrideAccess: false,
+ user,
+ })
+ expect(configuredScanner.enabled).toBe(true)
+ expect(configuredScanner.computerName).not.toBe('spoofed-host')
+ await expect(
+ runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ const defaultRun = await runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: profile.id },
+ token: `JWT ${login.token}`,
+ }),
+ )
+ const defaultRunID = ((await defaultRun.json()) as { id: string }).id
+ const snapshot = await payload.findByID({
+ collection: 'otter-runs',
+ id: defaultRunID,
+ depth: 0,
+ })
+ expect(snapshot.profile).toBe(profile.id)
+ expect(snapshot.configSnapshot).toMatchObject({
+ targets: ['192.0.2.10'],
+ interface: 'eth0',
+ noSnmp: false,
+ })
+ expect(snapshot.importOverridesSnapshot).toMatchObject({
+ assetOverrides: { assetOwner: 'Central owner' },
+ })
+ expect(decryptOtterCredentials(snapshot.encryptedCredentialsSnapshot)).toEqual({
+ snmp: [{ version: '2c', community: 'public' }],
+ })
+ await payload.delete({ collection: 'otter-runs', id: defaultRunID })
+ await expect(
+ runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: foreignProfile.id },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 403 })
+ const foreignRun = await payload.create({
+ collection: 'otter-runs',
+ data: {
+ name: 'Foreign scanner run',
+ site: site.id,
+ scanner: foreignScanner.id,
+ status: 'assigned',
+ },
+ overrideAccess: true,
+ user,
+ })
+ foreignRunID = foreignRun.id
+ await expect(
+ startOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/start',
+ foreignRun.id,
+ `Otter ${enrollment.credential}`,
+ ),
+ ),
+ ).rejects.toMatchObject({ status: 404 })
+ const credentialResponse = await saveOtterCredentials(
+ await routeRequest(
+ `/otter/v1/profiles/${profile.id}/credentials`,
+ profile.id,
+ `JWT ${login.token}`,
+ { credentials: { snmp: [{ version: '2c', community: 'managed-secret' }] } },
+ 'https://otserver.example',
+ ),
+ )
+ expect(credentialResponse.status).toBe(200)
+ const readableProfile = await payload.findByID({
+ collection: 'otter-profiles',
+ id: profile.id,
+ depth: 0,
+ overrideAccess: false,
+ user,
+ })
+ expect(readableProfile.encryptedCredentials).toBeUndefined()
+ expect(readableProfile.credentialsConfigured).toBe(true)
+ const editedProfile = await payload.update({
+ collection: 'otter-profiles',
+ id: profile.id,
+ data: { name: 'Line A updated' },
+ overrideAccess: false,
+ user,
+ })
+ expect(editedProfile.credentialsConfigured).toBe(true)
+
+ const runResponse = await runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: profile.id },
+ token: `JWT ${login.token}`,
+ }),
+ )
+ expect(runResponse.status).toBe(201)
+ const createdRun = (await runResponse.json()) as { id: string }
+ runID = createdRun.id
+ await payload.update({
+ collection: 'otter-profiles',
+ id: profile.id,
+ data: {
+ assetOverrides: { assetOwner: 'Future owner' },
+ customFieldOverrides: { [customField.id]: 9 },
+ },
+ overrideAccess: false,
+ user,
+ })
+
+ const poll = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {
+ version: '0.9.0',
+ capabilities: ['scan-v2', 'controller-v1'],
+ interfaces: ['eth0'],
+ computerName: 'otter-host-2',
+ networkAdapters: [
+ {
+ name: 'eth0',
+ friendlyName: 'Ethernet',
+ description: 'Industrial network adapter',
+ macAddress: '00:11:22:33:44:55',
+ ipAddresses: ['192.0.2.11'],
+ },
+ ],
+ },
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ const assignment = (await poll.json()) as {
+ action: string
+ run: {
+ id: string
+ credentials: { snmp: Array<{ community: string }> }
+ configuration: object
+ }
+ }
+ expect(assignment.action).toBe('run')
+ expect(assignment.run.id).toBe(runID)
+ expect(assignment.run.configuration).toMatchObject({
+ serverUrl: 'http://127.0.0.1:3000',
+ site: site.id,
+ targets: ['192.0.2.10'],
+ })
+ expect(assignment.run.credentials.snmp[0].community).toBe('managed-secret')
+ expect(
+ await payload.findByID({ collection: 'otter-scanners', id: scannerID!, depth: 0 }),
+ ).toMatchObject({
+ computerName: 'otter-host-2',
+ networkAdapters: [{ ipAddresses: ['192.0.2.11'] }],
+ })
+ await expect(
+ pollOtter(
+ await request('/otter/v1/poll', {
+ body: { version: 12 },
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ await expect(
+ pollOtter(
+ await request('/otter/v1/poll', {
+ body: {
+ networkAdapters: [
+ {
+ name: 'eth0',
+ friendlyName: 'Ethernet',
+ description: 'Adapter',
+ macAddress: 'invalid',
+ ipAddresses: [],
+ },
+ ],
+ },
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ await expect(
+ pollOtter(
+ await request('/otter/v1/poll', {
+ body: { capabilities: 'not a list' },
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+
+ const scanData = JSON.parse(
+ await readFile(
+ 'tests/otserver_otter_files/OTserver-Otter-known-vulnerability.json',
+ 'utf8',
+ ),
+ ) as { devices: unknown[]; links?: unknown[] }
+ scanData.devices = []
+ scanData.links = []
+ const file = Buffer.from(JSON.stringify(scanData))
+ const importReq = await request('/asset-imports', {
+ token: `Otter ${enrollment.credential}`,
+ })
+ const imported = await payload.create({
+ collection: 'asset-imports',
+ data: {
+ sourceVersion: 'managed-test',
+ otterRun: runID,
+ assetOverrides: { assetOwner: 'must be discarded' },
+ customFieldOverrides: {
+ [customField.id]: 'scanner-supplied invalid number must be discarded',
+ },
+ } as never,
+ file: { data: file, mimetype: 'application/json', name: 'managed.json', size: file.length },
+ overrideAccess: false,
+ req: importReq,
+ })
+ importID = imported.id
+ expect(imported).toMatchObject({
+ site: expect.objectContaining({ id: site.id }),
+ source: 'otserver-otter',
+ scanner: scannerID,
+ otterRun: runID,
+ status: 'completed',
+ })
+ expect(imported.assetOverrides).toEqual({ assetOwner: 'Central owner' })
+ expect(imported.customFieldOverrides).toEqual({ [customField.id]: 7 })
+ expect(imported.name).toContain(
+ `Otter — ${configuredScanner.name} — ${site.path || site.name} — `,
+ )
+ expect(imported.name).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
+ process.env.OTSERVER_QUEUED_IMPORTS = 'on'
+ const queuedImport = await payload.create({
+ collection: 'asset-imports',
+ data: {
+ executionMode: 'queued',
+ sourceVersion: 'managed-test',
+ otterRun: runID,
+ } as never,
+ file: {
+ data: file,
+ mimetype: 'application/json',
+ name: 'managed-queued.json',
+ size: file.length,
+ },
+ overrideAccess: false,
+ req: await request('/asset-imports', { token: `Otter ${enrollment.credential}` }),
+ })
+ queuedImportID = queuedImport.id
+ const queuedDocument = await payload.findByID({
+ collection: 'asset-imports',
+ id: queuedImport.id,
+ depth: 0,
+ overrideAccess: true,
+ })
+ expect(queuedDocument).toMatchObject({ executorType: 'otter', status: 'pending' })
+ expect(queuedDocument.processingInput).toMatchObject({
+ assetOverrides: { assetOwner: 'Central owner' },
+ customFieldOverrides: { [customField.id]: 7 },
+ })
+ await executeQueuedImport(payload, queuedImport.id, queuedDocument.jobID!, 0)
+ expect(
+ await payload.findByID({
+ collection: 'asset-imports',
+ id: queuedImport.id,
+ depth: 0,
+ overrideAccess: true,
+ }),
+ ).toMatchObject({ status: 'completed', duplicateOf: importID })
+ delete process.env.OTSERVER_QUEUED_IMPORTS
+
+ const started = await startOtterRun(
+ await routeRequest('/otter/v1/runs/start', runID, `Otter ${enrollment.credential}`),
+ )
+ expect(started.status).toBe(200)
+ const repeatedStart = await startOtterRun(
+ await routeRequest('/otter/v1/runs/start', runID, `Otter ${enrollment.credential}`),
+ )
+ expect(await repeatedStart.json()).toMatchObject({ status: 'running' })
+ await expect(
+ heartbeatOtterRun(
+ await routeRequest('/otter/v1/runs/heartbeat', runID, `Otter ${enrollment.credential}`, {
+ progress: 101,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ const heartbeat = await heartbeatOtterRun(
+ await routeRequest('/otter/v1/runs/heartbeat', runID, `Otter ${enrollment.credential}`, {
+ progress: 50,
+ }),
+ )
+ expect(await heartbeat.json()).toMatchObject({ cancelRequested: false })
+ const heartbeatWithoutProgress = await heartbeatOtterRun(
+ await routeRequest('/otter/v1/runs/heartbeat', runID, `Otter ${enrollment.credential}`, {}),
+ )
+ expect(await heartbeatWithoutProgress.json()).toMatchObject({ cancelRequested: false })
+ await expect(
+ finishOtterRun(
+ await routeRequest('/otter/v1/runs/finish', runID, `Otter ${enrollment.credential}`, {
+ status: 'completed',
+ importID: 'not-this-run',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ const finished = await finishOtterRun(
+ await routeRequest('/otter/v1/runs/finish', runID, `Otter ${enrollment.credential}`, {
+ status: 'completed',
+ importID,
+ }),
+ )
+ expect(await finished.json()).toMatchObject({ status: 'completed' })
+ await expect(
+ finishOtterRun(
+ await routeRequest('/otter/v1/runs/finish', runID, `Otter ${enrollment.credential}`, {
+ status: 'unknown',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 400 })
+ await expect(
+ startOtterRun(
+ await routeRequest('/otter/v1/runs/start', runID, `Otter ${enrollment.credential}`),
+ ),
+ ).rejects.toMatchObject({ status: 409 })
+ const repeatedFinish = await finishOtterRun(
+ await routeRequest('/otter/v1/runs/finish', runID, `Otter ${enrollment.credential}`, {
+ status: 'completed',
+ }),
+ )
+ expect(await repeatedFinish.json()).toMatchObject({ status: 'completed' })
+ await expect(
+ heartbeatOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/heartbeat',
+ runID,
+ `Otter ${enrollment.credential}`,
+ {},
+ ),
+ ),
+ ).rejects.toMatchObject({ status: 409 })
+ const cancelCompleted = await cancelOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/cancel',
+ runID,
+ `JWT ${login.token}`,
+ undefined,
+ 'https://otserver.example',
+ ),
+ )
+ expect(await cancelCompleted.json()).toMatchObject({ status: 'completed' })
+ await payload.update({
+ collection: 'otter-scanners',
+ id: scannerID,
+ data: { currentRun: runID },
+ overrideAccess: true,
+ })
+ const staleAssignmentPoll = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ expect(await staleAssignmentPoll.json()).toMatchObject({ action: 'idle' })
+
+ const badSnapshotRun = await payload.create({
+ collection: 'otter-runs',
+ data: {
+ name: 'Invalid snapshot',
+ site: site.id,
+ scanner: scannerID,
+ status: 'queued',
+ configSnapshot: null,
+ },
+ overrideAccess: true,
+ user,
+ })
+ badSnapshotRunID = badSnapshotRun.id
+ await expect(
+ pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 500 })
+ expect(
+ await payload.findByID({ collection: 'otter-runs', id: badSnapshotRun.id, depth: 0 }),
+ ).toMatchObject({ status: 'queued' })
+ await payload.delete({
+ collection: 'otter-runs',
+ id: badSnapshotRun.id,
+ overrideAccess: true,
+ })
+ badSnapshotRunID = undefined
+
+ const leaseRun = await runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: profile.id },
+ token: `JWT ${login.token}`,
+ }),
+ )
+ expiredRunID = ((await leaseRun.json()) as { id: string }).id
+ await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ await payload.update({
+ collection: 'otter-runs',
+ id: expiredRunID,
+ data: { status: 'running', leaseExpiresAt: '2000-01-01T00:00:00.000Z' },
+ overrideAccess: true,
+ })
+ await expireOtterLeases(payload)
+ expect(
+ await payload.findByID({ collection: 'otter-runs', id: expiredRunID, depth: 0 }),
+ ).toMatchObject({ status: 'expired', message: expect.stringContaining('no heartbeat') })
+ expect(
+ await payload.findByID({ collection: 'otter-scanners', id: scannerID!, depth: 0 }),
+ ).toMatchObject({ currentRun: null })
+ const expiredPoll = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ expect(await expiredPoll.json()).toMatchObject({ action: 'idle' })
+ expect(
+ await payload.findByID({ collection: 'otter-runs', id: expiredRunID, depth: 0 }),
+ ).toMatchObject({ status: 'expired', message: expect.stringContaining('no heartbeat') })
+
+ const queuedToCancel = await runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: profile.id },
+ token: `JWT ${login.token}`,
+ }),
+ )
+ queuedCancelID = ((await queuedToCancel.json()) as { id: string }).id
+ await expect(
+ finishOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/finish',
+ queuedCancelID,
+ `Otter ${enrollment.credential}`,
+ {
+ status: 'completed',
+ },
+ ),
+ ),
+ ).rejects.toMatchObject({ status: 409 })
+ const cancelledQueued = await cancelOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/cancel',
+ queuedCancelID,
+ `JWT ${login.token}`,
+ undefined,
+ 'https://otserver.example',
+ ),
+ )
+ expect(await cancelledQueued.json()).toMatchObject({ status: 'cancelled' })
+
+ const schedule = await payload.create({
+ collection: 'otter-schedules',
+ data: {
+ name: 'Every five minutes',
+ site: otherSite.id,
+ scanner: foreignScanner.id,
+ profile: profile.id,
+ scheduleType: 'interval',
+ intervalMinutes: 5,
+ timezone: 'UTC',
+ enabled: true,
+ },
+ overrideAccess: false,
+ user,
+ })
+ scheduleID = schedule.id
+ expect(relationshipID(schedule.site)).toBe(site.id)
+ expect(relationshipID(schedule.scanner)).toBe(scannerID)
+ await expect(
+ payload.create({
+ collection: 'otter-schedules',
+ data: {
+ name: 'Unauthorized schedule',
+ profile: profile.id,
+ scheduleType: 'interval',
+ intervalMinutes: 5,
+ } as never,
+ overrideAccess: false,
+ user: reader,
+ }),
+ ).rejects.toThrow()
+ await expect(
+ payload.delete({
+ collection: 'otter-profiles',
+ id: profile.id,
+ overrideAccess: false,
+ user,
+ }),
+ ).rejects.toMatchObject({ status: 409 })
+ const disabledSchedule = await payload.update({
+ collection: 'otter-schedules',
+ id: schedule.id,
+ data: { enabled: false },
+ overrideAccess: false,
+ user,
+ })
+ expect(disabledSchedule.nextRunAt).toBeNull()
+ await payload.update({
+ collection: 'otter-schedules',
+ id: schedule.id,
+ data: { enabled: true },
+ overrideAccess: false,
+ user,
+ })
+ await payload.update({
+ collection: 'otter-schedules',
+ id: schedule.id,
+ context: { otterSchedulerUpdate: true },
+ data: { nextRunAt: '2000-01-01T00:00:00.000Z' },
+ overrideAccess: true,
+ })
+ const scheduledPoll = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: { version: '0.9.0', capabilities: ['scan-v2'], interfaces: ['eth0'] },
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ const scheduledAssignment = (await scheduledPoll.json()) as {
+ action: string
+ run: { id: string }
+ }
+ scheduledRunID = scheduledAssignment.run.id
+ expect(scheduledAssignment.action).toBe('run')
+ expect(
+ await payload.findByID({
+ collection: 'otter-runs',
+ id: scheduledAssignment.run.id,
+ depth: 0,
+ }),
+ ).toMatchObject({ schedule: schedule.id, status: 'assigned' })
+ const repeatedPoll = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: { version: '0.9.0', capabilities: ['scan-v2'], interfaces: ['eth0'] },
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ expect((await repeatedPoll.json()).run.id).toBe(scheduledAssignment.run.id)
+ await expect(
+ payload.delete({
+ collection: 'otter-schedules',
+ id: schedule.id,
+ overrideAccess: false,
+ user,
+ }),
+ ).rejects.toMatchObject({ status: 409 })
+ const cancellation = await cancelOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/cancel',
+ scheduledAssignment.run.id,
+ `JWT ${login.token}`,
+ undefined,
+ 'https://otserver.example',
+ ),
+ )
+ expect(await cancellation.json()).toMatchObject({ status: 'cancel-requested' })
+ await payload.update({
+ collection: 'otter-runs',
+ id: scheduledAssignment.run.id,
+ data: { leaseExpiresAt: '2000-01-01T00:00:00.000Z' },
+ overrideAccess: true,
+ })
+ const cancellationPoll = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ expect(await cancellationPoll.json()).toMatchObject({
+ action: 'cancel',
+ runID: scheduledAssignment.run.id,
+ })
+ const cancellationHeartbeat = await heartbeatOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/heartbeat',
+ scheduledAssignment.run.id,
+ `Otter ${enrollment.credential}`,
+ {},
+ ),
+ )
+ expect(await cancellationHeartbeat.json()).toMatchObject({ cancelRequested: true })
+ const cancelled = await finishOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/finish',
+ scheduledAssignment.run.id,
+ `Otter ${enrollment.credential}`,
+ { status: 'cancelled' },
+ ),
+ )
+ expect(await cancelled.json()).toMatchObject({ status: 'cancelled' })
+ await payload.update({
+ collection: 'otter-schedules',
+ id: schedule.id,
+ context: { otterSchedulerUpdate: true },
+ data: { nextRunAt: '2000-01-01T00:00:00.000Z' },
+ overrideAccess: true,
+ })
+ const duplicateOccurrencePoll = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ expect(await duplicateOccurrencePoll.json()).toMatchObject({ action: 'idle' })
+ await payload.update({
+ collection: 'otter-schedules',
+ id: schedule.id,
+ data: { enabled: false },
+ overrideAccess: false,
+ user,
+ })
+ const oneTimeSchedule = await payload.create({
+ collection: 'otter-schedules',
+ data: {
+ name: 'One time catch-up',
+ profile: profile.id,
+ scheduleType: 'once',
+ runAt: '2000-01-01T00:00:00.000Z',
+ timezone: 'UTC',
+ enabled: true,
+ } as never, // Site and scanner are supplied by the selected configuration.
+ overrideAccess: false,
+ user,
+ })
+ oneTimeScheduleID = oneTimeSchedule.id
+ expect(relationshipID(oneTimeSchedule.site)).toBe(site.id)
+ expect(relationshipID(oneTimeSchedule.scanner)).toBe(scannerID)
+ const oneTimePoll = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ const oneTimeAssignment = (await oneTimePoll.json()) as { run: { id: string } }
+ oneTimeRunID = oneTimeAssignment.run.id
+ const oneTimeFinish = await finishOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/finish',
+ oneTimeRunID,
+ `Otter ${enrollment.credential}`,
+ { status: 'partial', message: 'Some protocols did not respond.' },
+ ),
+ )
+ expect(await oneTimeFinish.json()).toMatchObject({ status: 'partial' })
+
+ const rotation = await rotateOtterCredential(
+ await request('/otter/v1/credentials/rotate', {
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ const rotated = (await rotation.json()) as { credential: string }
+ await expect(
+ pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 401 })
+ const runBeforeRevocation = await runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: profile.id },
+ token: `JWT ${login.token}`,
+ }),
+ )
+ revokedRunID = ((await runBeforeRevocation.json()) as { id: string }).id
+ await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${rotated.credential}`,
+ origin: '',
+ }),
+ )
+ const revoked = await revokeOtterScanner(
+ await routeRequest(
+ '/otter/v1/scanners/revoke',
+ scannerID,
+ `JWT ${login.token}`,
+ undefined,
+ 'https://otserver.example',
+ ),
+ )
+ expect(await revoked.json()).toMatchObject({ revoked: true })
+ expect(
+ await payload.findByID({ collection: 'otter-runs', id: revokedRunID, depth: 0 }),
+ ).toMatchObject({ status: 'cancelled' })
+ await expect(
+ runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: profile.id },
+ token: `JWT ${login.token}`,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 409 })
+ const reenrollment = await reenrollOtterScanner(
+ await routeRequest(
+ '/otter/v1/scanners/reenroll',
+ scannerID,
+ `JWT ${login.token}`,
+ undefined,
+ 'https://otserver.example',
+ ),
+ )
+ const newEnrollment = (await reenrollment.json()) as { enrollmentToken: string }
+ const reissued = await enrollOtter(
+ await request('/otter/v1/enroll', {
+ body: { token: newEnrollment.enrollmentToken },
+ origin: '',
+ }),
+ )
+ expect(reissued.status).toBe(201)
+ const reissuedCredential = (await reissued.json()) as { credential: string }
+ const idle = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${reissuedCredential.credential}`,
+ origin: '',
+ }),
+ )
+ expect(await idle.json()).toMatchObject({ action: 'idle' })
+ const runForReenrollment = await runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: profile.id },
+ token: `JWT ${login.token}`,
+ }),
+ )
+ reenrollRunID = ((await runForReenrollment.json()) as { id: string }).id
+ await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${reissuedCredential.credential}`,
+ origin: '',
+ }),
+ )
+ const reenrollmentWithActiveRun = await reenrollOtterScanner(
+ await routeRequest(
+ '/otter/v1/scanners/reenroll',
+ scannerID,
+ `JWT ${login.token}`,
+ undefined,
+ 'https://otserver.example',
+ ),
+ )
+ expect(reenrollmentWithActiveRun.status).toBe(200)
+ expect(
+ await payload.findByID({ collection: 'otter-runs', id: reenrollRunID, depth: 0 }),
+ ).toMatchObject({ status: 'cancelled' })
+ const finalEnrollment = (await reenrollmentWithActiveRun.json()) as {
+ enrollmentToken: string
+ }
+ const finalCredentialResponse = await enrollOtter(
+ await request('/otter/v1/enroll', {
+ body: { token: finalEnrollment.enrollmentToken },
+ origin: '',
+ }),
+ )
+ const finalCredential = (await finalCredentialResponse.json()) as { credential: string }
+ await saveOtterCredentials(
+ await routeRequest(
+ `/otter/v1/profiles/${profile.id}/credentials`,
+ profile.id,
+ `JWT ${login.token}`,
+ { clear: true },
+ 'https://otserver.example',
+ ),
+ )
+ const clearedProfile = await payload.update({
+ collection: 'otter-profiles',
+ id: profile.id,
+ data: { name: 'Line A after clearing credentials' },
+ overrideAccess: false,
+ user,
+ })
+ expect(clearedProfile.credentialsConfigured).toBe(false)
+ const noSecretRun = await runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: profile.id },
+ token: `JWT ${login.token}`,
+ }),
+ )
+ noSecretRunID = ((await noSecretRun.json()) as { id: string }).id
+ const encryptionKey = process.env.OTSERVER_OTTER_ENCRYPTION_KEY
+ delete process.env.OTSERVER_OTTER_ENCRYPTION_KEY
+ const noSecretPoll = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${finalCredential.credential}`,
+ origin: '',
+ }),
+ )
+ process.env.OTSERVER_OTTER_ENCRYPTION_KEY = encryptionKey
+ const noSecretAssignment = (await noSecretPoll.json()) as {
+ run: { id: string; credentials: object }
+ }
+ expect(noSecretAssignment.run.id).toBe(noSecretRunID)
+ expect(noSecretAssignment.run.credentials).toEqual({})
+ await finishOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/finish',
+ noSecretRunID,
+ `Otter ${finalCredential.credential}`,
+ { status: 'failed', message: 'No target responded.' },
+ ),
+ )
+ expect(
+ await payload.findByID({ collection: 'otter-runs', id: noSecretRunID, depth: 0 }),
+ ).toMatchObject({ status: 'failed', message: 'No target responded.' })
+ const fallbackFailure = await runOtterNow(
+ await request('/otter/v1/runs', {
+ body: { scannerID, profileID: profile.id },
+ token: `JWT ${login.token}`,
+ }),
+ )
+ fallbackFailureRunID = ((await fallbackFailure.json()) as { id: string }).id
+ await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${finalCredential.credential}`,
+ origin: '',
+ }),
+ )
+ await finishOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/finish',
+ fallbackFailureRunID,
+ `Otter ${finalCredential.credential}`,
+ { status: 'failed', message: ' ' },
+ ),
+ )
+ expect(
+ await payload.findByID({ collection: 'otter-runs', id: fallbackFailureRunID, depth: 0 }),
+ ).toMatchObject({
+ status: 'failed',
+ message: 'Scanner reported a failure without diagnostic details.',
+ })
+ await expect(
+ pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${rotated.credential}`,
+ origin: '',
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 401 })
+
+ const overview = await otterOverview(
+ await request('/otter/v1/overview', { method: 'GET', token: `JWT ${login.token}` }),
+ )
+ const overviewData = (await overview.json()) as {
+ scanners: Array<{ id: string; name: string }>
+ runs: Array<{ id: string; status: string }>
+ }
+ expect(overviewData.scanners).toEqual(
+ expect.arrayContaining([expect.objectContaining({ id: scannerID, name: 'Plant Otter' })]),
+ )
+ expect(overviewData.runs).toEqual(
+ expect.arrayContaining([expect.objectContaining({ id: runID, status: 'completed' })]),
+ )
+ const adminOverview = await otterOverview(
+ await request('/otter/v1/overview', { method: 'GET', token: `JWT ${adminLogin.token}` }),
+ )
+ expect(await adminOverview.json()).toMatchObject({
+ controllerUrl: 'http://127.0.0.1:3000',
+ scanners: expect.arrayContaining([
+ expect.objectContaining({ id: scannerID, site: site.id, enrolled: true }),
+ ]),
+ })
+ } finally {
+ if (oldQueuedImports === undefined) delete process.env.OTSERVER_QUEUED_IMPORTS
+ else process.env.OTSERVER_QUEUED_IMPORTS = oldQueuedImports
+ if (queuedImportID)
+ await payload
+ .delete({ collection: 'asset-imports', id: queuedImportID, overrideAccess: true })
+ .catch(() => {})
+ if (importID)
+ await payload
+ .delete({ collection: 'asset-imports', id: importID, overrideAccess: true })
+ .catch(() => {})
+ if (revokedRunID)
+ await payload
+ .delete({ collection: 'otter-runs', id: revokedRunID, overrideAccess: true })
+ .catch(() => {})
+ if (expiredRunID)
+ await payload
+ .delete({ collection: 'otter-runs', id: expiredRunID, overrideAccess: true })
+ .catch(() => {})
+ if (fallbackFailureRunID)
+ await payload
+ .delete({ collection: 'otter-runs', id: fallbackFailureRunID, overrideAccess: true })
+ .catch(() => {})
+ if (badSnapshotRunID)
+ await payload
+ .delete({ collection: 'otter-runs', id: badSnapshotRunID, overrideAccess: true })
+ .catch(() => {})
+ if (noSecretRunID)
+ await payload
+ .delete({ collection: 'otter-runs', id: noSecretRunID, overrideAccess: true })
+ .catch(() => {})
+ if (foreignRunID)
+ await payload
+ .delete({ collection: 'otter-runs', id: foreignRunID, overrideAccess: true })
+ .catch(() => {})
+ if (scheduledRunID)
+ await payload
+ .delete({ collection: 'otter-runs', id: scheduledRunID, overrideAccess: true })
+ .catch(() => {})
+ if (oneTimeRunID)
+ await payload
+ .delete({ collection: 'otter-runs', id: oneTimeRunID, overrideAccess: true })
+ .catch(() => {})
+ if (reenrollRunID)
+ await payload
+ .delete({ collection: 'otter-runs', id: reenrollRunID, overrideAccess: true })
+ .catch(() => {})
+ if (queuedCancelID)
+ await payload
+ .delete({ collection: 'otter-runs', id: queuedCancelID, overrideAccess: true })
+ .catch(() => {})
+ if (scheduleID)
+ await payload
+ .delete({ collection: 'otter-schedules', id: scheduleID, overrideAccess: true })
+ .catch(() => {})
+ if (oneTimeScheduleID)
+ await payload
+ .delete({ collection: 'otter-schedules', id: oneTimeScheduleID, overrideAccess: true })
+ .catch(() => {})
+ if (runID)
+ await payload
+ .delete({ collection: 'otter-runs', id: runID, overrideAccess: true })
+ .catch(() => {})
+ if (profileID)
+ await payload
+ .delete({ collection: 'otter-profiles', id: profileID, overrideAccess: true })
+ .catch(() => {})
+ if (foreignProfileID)
+ await payload
+ .delete({ collection: 'otter-profiles', id: foreignProfileID, overrideAccess: true })
+ .catch(() => {})
+ if (customFieldID)
+ await payload
+ .delete({ collection: 'asset-fields', id: customFieldID, overrideAccess: true })
+ .catch(() => {})
+ if (scannerID)
+ await payload
+ .delete({ collection: 'otter-scanners', id: scannerID, overrideAccess: true })
+ .catch(() => {})
+ if (foreignScannerID)
+ await payload
+ .delete({ collection: 'otter-scanners', id: foreignScannerID, overrideAccess: true })
+ .catch(() => {})
+ if (userID) await payload.delete({ collection: 'users', id: userID }).catch(() => {})
+ if (readerID) await payload.delete({ collection: 'users', id: readerID }).catch(() => {})
+ if (adminID) await payload.delete({ collection: 'users', id: adminID }).catch(() => {})
+ if (roleID) await payload.delete({ collection: 'user-roles', id: roleID }).catch(() => {})
+ if (readerRoleID)
+ await payload.delete({ collection: 'user-roles', id: readerRoleID }).catch(() => {})
+ if (siteID) await payload.delete({ collection: 'sites', id: siteID }).catch(() => {})
+ if (otherSiteID)
+ await payload.delete({ collection: 'sites', id: otherSiteID }).catch(() => {})
+ if (oldURL === undefined) delete process.env.OTSERVER_PUBLIC_URL
+ else process.env.OTSERVER_PUBLIC_URL = oldURL
+ if (oldKey === undefined) delete process.env.OTSERVER_OTTER_ENCRYPTION_KEY
+ else process.env.OTSERVER_OTTER_ENCRYPTION_KEY = oldKey
+ }
+ })
+})
diff --git a/tests/int/otter-security.int.spec.ts b/tests/int/otter-security.int.spec.ts
new file mode 100644
index 0000000..7070f7b
--- /dev/null
+++ b/tests/int/otter-security.int.spec.ts
@@ -0,0 +1,866 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { readFile } from 'node:fs/promises'
+import type { Field } from 'payload'
+import {
+ canCreateSiteDocument,
+ canCreateOtterImport,
+ canReadOtterImports,
+ canReadSites,
+ canReadSiteDocuments,
+ canWriteSiteDocuments,
+ enforceWritableSite,
+ filterWritableSites,
+} from '../../src/access/authorization'
+import { getAuditChanges } from '../../src/collections/AuditLogs'
+import { createOtterScanner, pollOtter } from '../../src/otter/controller'
+import {
+ otterCreateAccess,
+ OtterProfiles,
+ OtterSchedules,
+ otterReadAccess,
+ otterWriteAccess,
+} from '../../src/collections/Otter'
+import {
+ decryptOtterCredentials,
+ encryptOtterCredentials,
+ newToken,
+ otterMachineStrategy,
+ requireOtterConfigAccess,
+ requireOtterScanner,
+ scannerIDFromRequest,
+ tokenDigest,
+ validateOtterConfiguration,
+ validateOtterCredentials,
+} from '../../src/otter/security'
+import { cronForSchedule, dueOccurrence, validateScheduleInput } from '../../src/otter/scheduling'
+
+const encryptionKey = 'a'.repeat(64)
+
+beforeEach(() => {
+ process.env.OTSERVER_OTTER_ENCRYPTION_KEY = encryptionKey
+})
+
+describe.each(['development', 'production'])('Otter browser requests in %s', (environment) => {
+ beforeEach(() => {
+ vi.stubEnv('NODE_ENV', environment)
+ vi.stubEnv('OTSERVER_PUBLIC_URL', 'https://otserver.example')
+ })
+ afterEach(() => vi.unstubAllEnvs())
+
+ const create = (url: string, origin: string, headers: Record = {}) =>
+ createOtterScanner(
+ Object.assign(
+ new Request(`${url}/api/otter/v1/scanners`, {
+ method: 'POST',
+ headers: { ...headers, ...(origin ? { Origin: origin } : {}) },
+ body: JSON.stringify({ name: 'Local Otter', site: 'site-1' }),
+ }),
+ { context: {} },
+ ) as never,
+ )
+
+ it.each(['localhost', '127.0.0.1', '[::1]'])(
+ 'allows HTTP controller URLs on %s',
+ async (host) => {
+ const url = `http://${host}:3000`
+ vi.stubEnv('OTSERVER_PUBLIC_URL', url)
+ // Passing the URL checks must still reach authentication, never bypass it.
+ await expect(create(url, url)).rejects.toMatchObject({ status: 401 })
+ },
+ )
+
+ it.each(['localhost', '127.0.0.1', '[::1]'])(
+ 'accepts the local browser origin %s despite a public URL',
+ async (host) => {
+ await expect(create('http://127.0.0.1:3000', `http://${host}:3000`)).rejects.toMatchObject({
+ status: 401,
+ })
+ },
+ )
+
+ it('accepts loopback Host and forwarded headers when the server URL is internal', async () => {
+ await expect(
+ create('http://0.0.0.0:3000', 'http://localhost:3000', {
+ host: 'localhost:3000',
+ }),
+ ).rejects.toMatchObject({ status: 401 })
+ await expect(
+ create('http://internal:3000', 'http://[::1]:3000', {
+ 'x-forwarded-host': '[::1]:3000, internal:3000',
+ 'x-forwarded-proto': 'http, http',
+ }),
+ ).rejects.toMatchObject({ status: 401 })
+ })
+
+ it.each([
+ '',
+ 'null',
+ 'not a URL',
+ 'https://attacker.example',
+ 'http://localhost.attacker.example:3000',
+ 'http://192.168.50.10:3000',
+ 'http://localhost:4000',
+ 'https://localhost:3000',
+ ])('rejects unrelated or malformed browser origins: %s', async (origin) => {
+ await expect(create('http://localhost:3000', origin)).rejects.toMatchObject({ status: 403 })
+ })
+
+ it.each([
+ 'http://scanner-host.example',
+ 'http://192.168.50.10:3000',
+ 'http://localhost.attacker.example:3000',
+ 'ftp://localhost:3000',
+ 'https://user:password@localhost:3000',
+ 'http://localhost:3000?query=1',
+ 'http://localhost:3000#fragment',
+ ])('rejects insecure or invalid controller URLs: %s', async (url) => {
+ vi.stubEnv('OTSERVER_PUBLIC_URL', url)
+ const origin = new URL(url).origin
+ await expect(create(origin, origin)).rejects.toMatchObject({ status: 503 })
+ })
+
+ it('accepts the configured HTTPS origin behind a proxy', async () => {
+ await expect(create('http://internal:3000', 'https://otserver.example')).rejects.toMatchObject({
+ status: 401,
+ })
+ })
+
+ it('keeps production pinned to the configured non-loopback origin', async () => {
+ vi.stubEnv('NODE_ENV', 'production')
+ await expect(
+ create('https://other.example', 'https://other.example', {
+ 'x-forwarded-host': 'other.example',
+ 'x-forwarded-proto': 'https',
+ }),
+ ).rejects.toMatchObject({ status: 403 })
+ await expect(create('https://other.example', 'http://localhost:3000')).rejects.toMatchObject({
+ status: 403,
+ })
+ })
+})
+
+describe('Otter security and scheduling boundaries', () => {
+ it('covers every local Otter configuration option with managed fields or encrypted credentials', async () => {
+ const source = await readFile('otserver-otter/src/main.rs', 'utf8')
+ const config = source.match(/pub struct ScannerConfig \{([\s\S]*?)\n\}/)![1]!
+ const options = [...config.matchAll(/pub (\w+):/g)].map(([, name]) =>
+ name!.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()),
+ )
+ const fieldNames = (fields: Field[]): string[] =>
+ fields.flatMap((field) => [
+ ...('name' in field ? [field.name] : []),
+ ...('fields' in field ? fieldNames(field.fields) : []),
+ ])
+ const managed = ['output', 'serverUrl', 'apiKey']
+ const encrypted = ['snmp', 'opcuaCredentials', 'opcuaUsername', 'opcuaPassword']
+ expect(
+ options.filter(
+ (option) =>
+ ![...fieldNames(OtterProfiles.fields), ...managed, ...encrypted].includes(option),
+ ),
+ ).toEqual([])
+ })
+
+ it('encrypts credentials with authenticated encryption and never stores plaintext', () => {
+ const credentials = {
+ snmp: [{ version: '3', username: 'inventory', authPassword: 'do-not-log-this' }],
+ }
+ const encrypted = encryptOtterCredentials(credentials)
+ expect(encrypted).not.toContain('do-not-log-this')
+ expect(decryptOtterCredentials(encrypted)).toEqual(credentials)
+ const [version, iv, tag, ciphertext] = encrypted.split('.')
+ const alteredTag = (tag!.startsWith('A') ? 'B' : 'A') + tag!.slice(1)
+ expect(() => decryptOtterCredentials(`${version}.${iv}.${alteredTag}.${ciphertext}`)).toThrow()
+ })
+
+ it('requires a deployment encryption key before storing secrets', () => {
+ delete process.env.OTSERVER_OTTER_ENCRYPTION_KEY
+ expect(() => encryptOtterCredentials({ snmp: [] })).toThrow(/not configured/i)
+ })
+
+ it('rejects malformed keys and encrypted credential envelopes', () => {
+ process.env.OTSERVER_OTTER_ENCRYPTION_KEY = 'z'.repeat(64)
+ expect(() => encryptOtterCredentials({ snmp: [] })).toThrow(/not configured/i)
+ expect(() => decryptOtterCredentials('v2.iv.tag.data')).toThrow(/unsupported format/i)
+ process.env.OTSERVER_OTTER_ENCRYPTION_KEY = encryptionKey
+ expect(decryptOtterCredentials(null)).toEqual({})
+ const encrypted = encryptOtterCredentials({ opcuaCredentials: [] })
+ process.env.OTSERVER_OTTER_ENCRYPTION_KEY = 'c'.repeat(64)
+ expect(() => decryptOtterCredentials(encrypted)).toThrow(/could not be decrypted/i)
+ })
+
+ it('validates scanner input and rejects management-controlled or unsupported settings', () => {
+ expect(() => validateOtterConfiguration(null)).toThrow(/object/i)
+ expect(() => validateOtterConfiguration({})).toThrow(/IPv4/i)
+ expect(
+ validateOtterConfiguration({
+ targets: ['192.0.2.1', '192.0.2.0/24'],
+ interface: 'eth0',
+ noSnmp: true,
+ noSmb: true,
+ noRpc: true,
+ opcuaPorts: [4840],
+ }),
+ ).toMatchObject({ targets: ['192.0.2.1', '192.0.2.0/24'], noSmb: true, noRpc: true })
+ expect(() => validateOtterConfiguration({ targets: ['192.0.2.1'], apiKey: 'secret' })).toThrow(
+ /unsupported/i,
+ )
+ expect(() => validateOtterConfiguration({ targets: ['192.0.2.999'] })).toThrow(/IPv4/i)
+ expect(() => validateOtterConfiguration({ targets: ['192.0.2.1'], noArp: 'false' })).toThrow(
+ /boolean/i,
+ )
+ expect(() => validateOtterConfiguration({ targets: ['192.0.2.0/15'] })).toThrow(/65,536/i)
+ expect(() => validateOtterConfiguration({ targets: ['192.0.2.1'], interface: ' ' })).toThrow(
+ /interface/i,
+ )
+ expect(() => validateOtterConfiguration({ targets: ['192.0.2.1'], sourceMac: 'bad' })).toThrow(
+ /MAC/i,
+ )
+ expect(() => validateOtterConfiguration({ targets: ['192.0.2.1'], opcuaPorts: [0] })).toThrow(
+ /ports/i,
+ )
+ })
+
+ it('accepts only the supported bounded credential shapes', () => {
+ expect(
+ validateOtterCredentials({
+ snmp: [{ version: '3', username: 'inventory', authPassword: 'secret' }],
+ opcuaCredentials: [{ username: 'operator', password: 'secret' }],
+ }),
+ ).toHaveProperty('snmp')
+ expect(
+ validateOtterCredentials({ snmp: { version: '2c', community: 'public' } }),
+ ).toHaveProperty('snmp')
+ expect(() => validateOtterCredentials({ apiKey: 'secret' })).toThrow(/unsupported/i)
+ expect(() => validateOtterCredentials({ snmp: [{ community: 7 }] })).toThrow(/invalid/i)
+ expect(() => validateOtterCredentials({ snmp: [null] })).toThrow(/invalid/i)
+ expect(() => validateOtterCredentials({ snmp: [{ unexpected: 'field' }] })).toThrow(/invalid/i)
+ expect(() => validateOtterCredentials('invalid')).toThrow(/object/i)
+ expect(() => validateOtterCredentials({ snmp: [{ version: 'v99' }] })).toThrow(/invalid/i)
+ expect(() => validateOtterCredentials({ snmp: [{ authProtocol: 'bad' }] })).toThrow(/invalid/i)
+ expect(() => validateOtterCredentials({ snmp: [{ authProtocol: 'sha256' }] })).toThrow(
+ /invalid/i,
+ )
+ expect(() => validateOtterCredentials({ snmp: [{ privacyProtocol: 'bad' }] })).toThrow(
+ /invalid/i,
+ )
+ expect(() => validateOtterCredentials({ snmp: [{ privacyProtocol: 'aes128' }] })).toThrow(
+ /invalid/i,
+ )
+ expect(
+ validateOtterCredentials({
+ snmp: [
+ {
+ version: '3',
+ username: 'operator',
+ authProtocol: 'sha256',
+ authPassword: 'auth-password',
+ privacyProtocol: 'aes128',
+ privacyPassword: 'privacy-password',
+ },
+ ],
+ }),
+ ).toHaveProperty('snmp')
+ expect(() => validateOtterCredentials({ opcuaCredentials: [{ username: 7 }] })).toThrow(
+ /OPC UA/i,
+ )
+ expect(validateOtterCredentials({ opcuaCredentials: { username: 'operator' } })).toHaveProperty(
+ 'opcuaCredentials',
+ )
+ expect(() => validateOtterCredentials({ opcuaUsername: 7 })).toThrow(/text/i)
+ expect(() => validateOtterCredentials({ opcuaPassword: 4 })).toThrow(/text/i)
+ })
+
+ it('redacts scanner and protocol credentials from audit changes', () => {
+ expect(
+ getAuditChanges(
+ {},
+ {
+ community: 'private-community',
+ encryptedCredentials: 'ciphertext',
+ snmpAuthPassword: 'secret-password',
+ },
+ ),
+ ).toEqual({
+ community: { after: '[REDACTED]' },
+ encryptedCredentials: { after: '[REDACTED]' },
+ snmpAuthPassword: { after: '[REDACTED]' },
+ })
+ })
+
+ it('issues opaque enrollment credentials and restricts their authentication routes', async () => {
+ const credential = newToken()
+ expect(credential).toHaveLength(43)
+ expect(tokenDigest(credential)).toMatch(/^[a-f\d]{64}$/)
+ const scanner = { id: 'scanner-1', site: 'site-1' }
+ const payload = { find: async () => ({ docs: [scanner] }) }
+ const req = { context: {}, url: 'https://otserver.example/api/otter/v1/poll' }
+ const result = await otterMachineStrategy.authenticate({
+ headers: new Headers({ authorization: `Otter ${credential}` }),
+ payload: payload as never,
+ req: req as never,
+ })
+ expect(result.user).toMatchObject({ id: 'otter:scanner-1', collection: 'users' })
+ expect(req.context).toMatchObject({ otterScannerID: 'scanner-1', otterScannerSiteID: 'site-1' })
+ expect(scannerIDFromRequest(req as never)).toBe('scanner-1')
+ expect(requireOtterScanner(req as never)).toBe('scanner-1')
+
+ const noScanner = { context: {} }
+ expect(scannerIDFromRequest(noScanner as never)).toBeUndefined()
+ expect(() => requireOtterScanner(noScanner as never)).toThrow(/authentication/i)
+
+ const forbidden = await otterMachineStrategy.authenticate({
+ headers: new Headers({ authorization: `Otter ${credential}` }),
+ payload: payload as never,
+ req: { ...req, url: 'https://otserver.example/api/assets' } as never,
+ })
+ expect(forbidden.user).toBeNull()
+ const missingScanner = await otterMachineStrategy.authenticate({
+ headers: new Headers({ authorization: `Otter ${credential}` }),
+ payload: { find: async () => ({ docs: [] }) } as never,
+ req: { context: {}, url: 'https://otserver.example/api/otter/v1/poll' } as never,
+ })
+ expect(missingScanner.user).toBeNull()
+ const scannerWithoutSite = await otterMachineStrategy.authenticate({
+ headers: new Headers({ authorization: `Otter ${credential}` }),
+ payload: { find: async () => ({ docs: [{ id: 'scanner-without-site' }] }) } as never,
+ req: { context: {}, url: 'https://otserver.example/api/otter/v1/poll' } as never,
+ })
+ expect(scannerWithoutSite.user).toBeNull()
+ const invalidToken = await otterMachineStrategy.authenticate({
+ headers: new Headers({ authorization: 'Otter bad-token' }),
+ payload: payload as never,
+ req: { context: {}, url: 'https://otserver.example/api/otter/v1/poll' } as never,
+ })
+ expect(invalidToken.user).toBeNull()
+ const invalidRequestURL = await otterMachineStrategy.authenticate({
+ headers: new Headers({ authorization: `Otter ${credential}` }),
+ payload: payload as never,
+ req: { context: {}, url: 'http://[' } as never,
+ })
+ expect(invalidRequestURL.user).toBeNull()
+ const noHeader = await otterMachineStrategy.authenticate({
+ headers: new Headers(),
+ payload: payload as never,
+ req: { context: {}, url: 'https://otserver.example/api/otter/v1/poll' } as never,
+ })
+ expect(noHeader.user).toBeNull()
+ const noRequest = await otterMachineStrategy.authenticate({
+ headers: new Headers({ authorization: `Otter ${credential}` }),
+ payload: payload as never,
+ })
+ expect(noRequest.user).toBeNull()
+ })
+
+ it('rejects controller polling for a disabled scanner principal', async () => {
+ await expect(
+ pollOtter({
+ context: { otterScannerID: 'disabled-scanner' },
+ payload: {
+ findByID: async () => ({ id: 'disabled-scanner', enabled: false }),
+ },
+ } as never),
+ ).rejects.toMatchObject({ status: 403 })
+ })
+
+ it('limits scanner API access to its own exact-site imports', async () => {
+ const req: { context: Record; user: { id: string } } = {
+ context: { otterScannerID: 'scanner-1', otterScannerSiteID: 'site-1' },
+ user: { id: 'otter:scanner-1' },
+ }
+ expect(
+ await canCreateOtterImport({
+ data: { site: 'site-1', source: 'otserver-otter' },
+ req: req as never,
+ }),
+ ).toBe(true)
+ expect(
+ await canCreateOtterImport({
+ data: { site: 'site-2', source: 'otserver-otter' },
+ req: req as never,
+ }),
+ ).toBe(false)
+ expect(
+ await canCreateOtterImport({
+ data: { site: 'site-1', source: 'nmap' },
+ req: req as never,
+ }),
+ ).toBe(false)
+ expect(await canReadOtterImports({ req: req as never })).toEqual({
+ scanner: { equals: 'scanner-1' },
+ })
+ expect(await canWriteSiteDocuments({ req: req as never })).toBe(false)
+ expect(await canReadSiteDocuments({ req: req as never })).toBe(false)
+ expect(await canCreateSiteDocument({ data: { site: 'site-1' }, req: req as never })).toBe(false)
+ req.context.assetImport = true
+ expect(await canWriteSiteDocuments({ req: req as never })).toEqual({
+ site: { equals: 'site-1' },
+ })
+ expect(await canReadSiteDocuments({ req: req as never })).toEqual({
+ site: { equals: 'site-1' },
+ })
+ expect(await canReadSites({ req: req as never })).toEqual({ id: { equals: 'site-1' } })
+ expect(await filterWritableSites({ req: req as never })).toEqual({
+ id: { equals: 'site-1' },
+ })
+ expect(
+ await canCreateSiteDocument({
+ data: { site: 'site-1' },
+ req: req as never,
+ }),
+ ).toBe(true)
+ expect(
+ await canCreateSiteDocument({
+ data: { site: 'site-2' },
+ req: req as never,
+ }),
+ ).toBe(false)
+ const data = { site: 'site-1' }
+ await expect(enforceWritableSite({ data, req: req as never } as never)).resolves.toBe(data)
+ await expect(
+ enforceWritableSite({
+ data: { site: 'site-2' },
+ req: { context: req.context, user: req.user } as never,
+ } as never),
+ ).rejects.toMatchObject({ status: 403 })
+ })
+
+ it('requires Otter Config and a writable assigned site for human management', async () => {
+ const req = {
+ context: {},
+ user: { id: 'user-1', role: 'role-1' },
+ payload: {
+ find: async ({
+ collection,
+ where,
+ }: {
+ collection: string
+ where?: Record
+ }) => {
+ if (collection === 'user-roles')
+ return {
+ docs: [
+ {
+ isAdmin: false,
+ otterConfig: true,
+ permissions: [{ site: 'site-1', access: 'read-write' }],
+ },
+ ],
+ }
+ if (collection === 'sites') {
+ const parents = (where?.parent as { in?: string[] } | undefined)?.in || []
+ return { docs: parents.includes('site-1') ? [{ id: 'child-1' }] : [] }
+ }
+ return { docs: [] }
+ },
+ },
+ }
+ await expect(requireOtterConfigAccess(req as never, 'site-1', true)).resolves.toBeUndefined()
+ await expect(requireOtterConfigAccess(req as never, 'site-1', false)).resolves.toBeUndefined()
+ await expect(requireOtterConfigAccess(req as never, 'child-1', true)).resolves.toBeUndefined()
+ await expect(requireOtterConfigAccess(req as never, 'other-site', true)).rejects.toMatchObject({
+ status: 403,
+ })
+ await expect(requireOtterConfigAccess(req as never, undefined, false)).rejects.toMatchObject({
+ status: 403,
+ })
+ const noOtterRole = {
+ ...req,
+ context: {},
+ payload: {
+ find: async ({ collection }: { collection: string }) =>
+ collection === 'user-roles'
+ ? {
+ docs: [
+ {
+ isAdmin: false,
+ otterConfig: false,
+ permissions: [{ site: 'site-1', access: 'read-write' }],
+ },
+ ],
+ }
+ : { docs: [] },
+ },
+ }
+ await expect(
+ requireOtterConfigAccess(noOtterRole as never, 'site-1', true),
+ ).rejects.toMatchObject({
+ status: 403,
+ })
+ const readOnlyOtter = {
+ ...req,
+ context: {},
+ payload: {
+ find: async ({ collection }: { collection: string }) =>
+ collection === 'user-roles'
+ ? {
+ docs: [
+ {
+ isAdmin: false,
+ otterConfig: true,
+ permissions: [{ site: 'site-1', access: 'read' }],
+ },
+ ],
+ }
+ : { docs: [] },
+ },
+ }
+ await expect(
+ requireOtterConfigAccess(readOnlyOtter as never, 'site-1', false),
+ ).resolves.toBeUndefined()
+ await expect(
+ requireOtterConfigAccess(readOnlyOtter as never, 'site-1', true),
+ ).rejects.toMatchObject({
+ status: 403,
+ })
+ })
+
+ it('applies the Otter Config role and site scope to management collections', async () => {
+ const makeRequest = (otterConfig: boolean, isAdmin = false) => ({
+ context: {},
+ user: { id: 'user-1', role: 'role-1' },
+ payload: {
+ find: async ({
+ collection,
+ where,
+ }: {
+ collection: string
+ where?: Record
+ }) => {
+ if (collection === 'user-roles')
+ return {
+ docs: [
+ {
+ isAdmin,
+ otterConfig,
+ permissions: [{ site: 'site-1', access: 'read-write' }],
+ },
+ ],
+ }
+ if (collection === 'sites') {
+ const parents = (where?.parent as { in?: string[] } | undefined)?.in || []
+ return { docs: parents.includes('site-1') ? [{ id: 'child-1' }] : [] }
+ }
+ return { docs: [] }
+ },
+ },
+ })
+ const manager = makeRequest(true)
+ expect(await otterReadAccess({ req: manager as never })).toMatchObject({
+ site: { in: ['site-1', 'child-1'] },
+ })
+ expect(await otterWriteAccess({ req: manager as never })).toMatchObject({
+ site: { in: ['site-1', 'child-1'] },
+ })
+ expect(await otterCreateAccess({ data: { site: 'site-1' }, req: manager as never })).toBe(true)
+ expect(await otterCreateAccess({ data: { site: 'outside' }, req: manager as never })).toBe(
+ false,
+ )
+ expect(await otterCreateAccess({ req: manager as never })).toBe(true)
+
+ const reader = makeRequest(false)
+ expect(await otterReadAccess({ req: reader as never })).toBe(false)
+ expect(await otterWriteAccess({ req: reader as never })).toBe(false)
+ expect(await otterCreateAccess({ req: reader as never })).toBe(false)
+
+ const admin = makeRequest(false, true)
+ expect(await otterReadAccess({ req: admin as never })).toBe(true)
+ expect(await otterWriteAccess({ req: admin as never })).toBe(true)
+ expect(await otterCreateAccess({ data: { site: 'outside' }, req: admin as never })).toBe(true)
+ expect(await otterCreateAccess({ data: {}, req: admin as never })).toBe(true)
+ await expect(requireOtterConfigAccess(admin as never, 'outside', true)).resolves.toBeUndefined()
+
+ const scanner = {
+ ...manager,
+ context: { otterScannerID: 'scanner-1' },
+ }
+ expect(await otterReadAccess({ req: scanner as never })).toBe(false)
+ expect(await otterWriteAccess({ req: scanner as never })).toBe(false)
+ expect(await otterCreateAccess({ req: scanner as never })).toBe(false)
+ })
+
+ it('validates scanner/profile pairing, schedule caps, and admin schedule controls', async () => {
+ let scannerSite = 'site-1'
+ let profileSite = 'site-1'
+ let profileScanner = 'scanner-1'
+ let scheduledCount = 50
+ const req = {
+ context: {},
+ user: { id: 'admin-1', role: 'admin-role' },
+ payload: {
+ find: async ({ collection }: { collection: string }) =>
+ collection === 'user-roles' ? { docs: [{ isAdmin: true }] } : { docs: [] },
+ findByID: async ({ collection, id }: { collection: string; id: string }) =>
+ collection === 'otter-scanners'
+ ? { id, site: scannerSite }
+ : {
+ id,
+ site: profileSite,
+ scanner: profileScanner,
+ encryptedCredentials: 'ciphertext',
+ },
+ count: async () => ({ totalDocs: scheduledCount }),
+ },
+ }
+ const scheduleHook = OtterSchedules.hooks!.beforeChange![0]!
+ const scheduleCreateAccess = OtterSchedules.access!.create!
+ const profileHook = OtterProfiles.hooks!.beforeChange![0]!
+ const schedule = {
+ cron: '0 2 * * *',
+ enabled: true,
+ intervalMinutes: 5,
+ name: 'Test schedule',
+ profile: 'profile-1',
+ scanner: 'scanner-1',
+ scheduleType: 'interval',
+ site: 'site-1',
+ timezone: 'UTC',
+ }
+ await expect(scheduleCreateAccess({ req } as never)).resolves.toBe(true)
+ await expect(scheduleCreateAccess({ data: {}, req } as never)).resolves.toBe(true)
+ await expect(
+ scheduleHook({ data: schedule, operation: 'create', req } as never),
+ ).rejects.toMatchObject({ status: 400 })
+
+ await expect(
+ scheduleHook({ data: { enabled: true }, operation: 'create', req } as never),
+ ).rejects.toMatchObject({ status: 400 })
+
+ scheduledCount = 0
+ scannerSite = 'outside-site'
+ await expect(
+ scheduleHook({ data: schedule, operation: 'create', req } as never),
+ ).rejects.toMatchObject({ status: 400 })
+
+ scannerSite = 'site-1'
+ profileSite = 'outside-site'
+ await expect(
+ scheduleHook({ data: schedule, operation: 'create', req } as never),
+ ).rejects.toMatchObject({ status: 400 })
+ profileSite = 'site-1'
+ profileScanner = 'another-scanner'
+ await expect(
+ scheduleHook({ data: { ...schedule }, operation: 'create', req } as never),
+ ).resolves.toMatchObject({ scanner: 'another-scanner', site: 'site-1' })
+ profileScanner = 'scanner-1'
+
+ await expect(
+ profileHook({
+ data: { name: 'Missing scanner', configuration: { targets: ['192.0.2.1'] } },
+ operation: 'create',
+ req,
+ } as never),
+ ).rejects.toMatchObject({ status: 400 })
+ scannerSite = 'outside-site'
+ await expect(
+ profileHook({
+ data: {
+ name: 'Wrong site',
+ site: 'site-1',
+ scanner: 'scanner-1',
+ configuration: { targets: ['192.0.2.1'] },
+ },
+ operation: 'create',
+ req,
+ } as never),
+ ).rejects.toMatchObject({ status: 400 })
+ scannerSite = 'site-1'
+ const profileUpdate = await profileHook({
+ data: { name: 'Updated' },
+ operation: 'update',
+ originalDoc: {
+ id: 'profile-1',
+ site: 'site-1',
+ scanner: 'scanner-1',
+ configuration: { targets: ['192.0.2.1'] },
+ },
+ req,
+ } as never)
+ expect(profileUpdate).toMatchObject({
+ encryptedCredentials: 'ciphertext',
+ credentialsConfigured: true,
+ })
+
+ scheduledCount = 0
+ const disabled = { ...schedule, enabled: false }
+ expect(await scheduleHook({ data: disabled, operation: 'create', req } as never)).toMatchObject(
+ {
+ enabled: false,
+ nextRunAt: null,
+ },
+ )
+ await expect(
+ scheduleHook({
+ data: { enabled: false },
+ operation: 'update',
+ originalDoc: {
+ site: 'site-1',
+ scanner: 'scanner-1',
+ scheduleType: 'interval',
+ intervalMinutes: 5,
+ timezone: 'UTC',
+ enabled: true,
+ },
+ req,
+ } as never),
+ ).rejects.toMatchObject({ status: 400 })
+
+ const scannerFilter = OtterProfiles.fields.find(
+ (field) => 'name' in field && field.name === 'scanner',
+ )
+ const filter =
+ scannerFilter && 'filterOptions' in scannerFilter ? scannerFilter.filterOptions : undefined
+ expect(typeof filter === 'function' ? await filter({ req } as never) : null).toBe(true)
+ const siteManager = {
+ context: {},
+ user: { id: 'operator-1', role: 'operator-role' },
+ payload: {
+ find: async ({ collection }: { collection: string }) =>
+ collection === 'user-roles'
+ ? {
+ docs: [
+ {
+ isAdmin: false,
+ otterConfig: true,
+ permissions: [{ site: 'site-1', access: 'read-write' }],
+ },
+ ],
+ }
+ : { docs: [] },
+ },
+ }
+ expect(
+ typeof filter === 'function' ? await filter({ req: siteManager } as never) : null,
+ ).toEqual({ site: { in: ['site-1'] } })
+
+ const scheduleFields = OtterSchedules.fields.filter((field) => 'name' in field)
+ for (const [name, type] of [
+ ['runAt', 'once'],
+ ['intervalMinutes', 'interval'],
+ ['cron', 'cron'],
+ ]) {
+ const field = scheduleFields.find((candidate) => candidate.name === name)
+ const condition = field && 'admin' in field ? field.admin?.condition : undefined
+ expect(
+ typeof condition === 'function'
+ ? condition({}, { scheduleType: type }, {} as never)
+ : false,
+ ).toBe(true)
+ expect(
+ typeof condition === 'function'
+ ? condition({}, { scheduleType: 'other' }, {} as never)
+ : false,
+ ).toBe(false)
+ }
+ })
+
+ it('validates calendar time zones and coalesces interval catch-up to the latest occurrence', () => {
+ expect(
+ validateScheduleInput(
+ { scheduleType: 'interval', intervalMinutes: 5 },
+ new Date('2026-01-01T00:00:00.000Z'),
+ ),
+ ).toBe('2026-01-01T00:05:00.000Z')
+ expect(
+ dueOccurrence(
+ {
+ scheduleType: 'interval',
+ intervalMinutes: 5,
+ nextRunAt: '2026-01-01T00:00:00.000Z',
+ },
+ new Date('2026-01-01T00:17:00.000Z'),
+ ),
+ ).toEqual({
+ occurrence: new Date('2026-01-01T00:15:00.000Z'),
+ nextRunAt: '2026-01-01T00:20:00.000Z',
+ })
+ expect(() => validateScheduleInput({ scheduleType: 'interval', intervalMinutes: 1 })).toThrow()
+ expect(() =>
+ validateScheduleInput({ scheduleType: 'interval', intervalMinutes: 10_081 }),
+ ).toThrow()
+ expect(() => validateScheduleInput({ scheduleType: 'once', runAt: 'not a date' })).toThrow()
+ expect(() => validateScheduleInput({ scheduleType: 'once' })).toThrow()
+ expect(validateScheduleInput({ scheduleType: 'once', runAt: '2026-01-01T00:00:00Z' })).toBe(
+ '2026-01-01T00:00:00.000Z',
+ )
+ expect(() => validateScheduleInput({ scheduleType: 'unknown' as never })).toThrow()
+ expect(() => validateScheduleInput({ scheduleType: 'cron' })).toThrow()
+ expect(() =>
+ validateScheduleInput({ scheduleType: 'interval', intervalMinutes: 5, timezone: 'Bad/Zone' }),
+ ).toThrow()
+ expect(() =>
+ validateScheduleInput({ scheduleType: 'cron', cron: '0 0 * * *', timezone: 'x'.repeat(129) }),
+ ).toThrow()
+ expect(() => cronForSchedule('too many fields in this schedule', 'UTC')).toThrow()
+ expect(() => cronForSchedule('x x x x x', 'UTC')).toThrow()
+ expect(
+ validateScheduleInput(
+ { scheduleType: 'cron', cron: '0 2 * * *', timezone: 'UTC' },
+ new Date('2026-01-01T00:00:00.000Z'),
+ ),
+ ).toBe('2026-01-01T02:00:00.000Z')
+ expect(() => validateScheduleInput({ scheduleType: 'cron', cron: '99 99 * * *' })).toThrow()
+ expect(() => validateScheduleInput({ scheduleType: 'cron', cron: '0 0 31 2 *' })).toThrow(
+ /upcoming/i,
+ )
+ expect(
+ dueOccurrence(
+ { scheduleType: 'once', nextRunAt: '2026-01-01T00:00:00.000Z' },
+ new Date('2026-01-02T00:00:00.000Z'),
+ ),
+ ).toEqual({ occurrence: new Date('2026-01-01T00:00:00.000Z'), nextRunAt: undefined })
+ expect(
+ dueOccurrence(
+ {
+ scheduleType: 'cron',
+ cron: '0 2 * * *',
+ timezone: 'UTC',
+ nextRunAt: '2030-01-01T00:00:00Z',
+ },
+ new Date('2026-01-01T00:00:00Z'),
+ ),
+ ).toBeUndefined()
+ expect(
+ dueOccurrence(
+ { scheduleType: 'unsupported' as never, nextRunAt: '2026-01-01T00:00:00Z' },
+ new Date('2026-01-02T00:00:00Z'),
+ ),
+ ).toBeUndefined()
+ expect(
+ dueOccurrence(
+ { scheduleType: 'interval', intervalMinutes: 0, nextRunAt: '2026-01-01T00:00:00Z' },
+ new Date('2026-01-02T00:00:00Z'),
+ ),
+ ).toBeUndefined()
+ expect(dueOccurrence({ scheduleType: 'once' })).toBeUndefined()
+ expect(
+ dueOccurrence(
+ { scheduleType: 'cron', cron: '0 2 * * *', nextRunAt: '2026-01-01T00:00:00Z' },
+ new Date('2026-01-02T04:00:00Z'),
+ ),
+ ).toBeDefined()
+ expect(
+ dueOccurrence(
+ { scheduleType: 'cron', cron: '0 0 31 2 *', nextRunAt: '2026-01-01T00:00:00Z' },
+ new Date('2026-01-02T00:00:00Z'),
+ ),
+ ).toBeUndefined()
+ })
+
+ it('coalesces missed cron occurrences to the most recent run', () => {
+ expect(
+ dueOccurrence(
+ {
+ cron: '0 2 * * *',
+ scheduleType: 'cron',
+ timezone: 'UTC',
+ nextRunAt: '2026-01-01T02:00:00.000Z',
+ },
+ new Date('2026-01-02T04:00:00.000Z'),
+ ),
+ ).toEqual({
+ occurrence: new Date('2026-01-02T02:00:00.000Z'),
+ nextRunAt: '2026-01-03T02:00:00.000Z',
+ })
+ })
+})
diff --git a/tests/int/queued-import-runtime.int.spec.ts b/tests/int/queued-import-runtime.int.spec.ts
index e367b64..ba32e4f 100644
--- a/tests/int/queued-import-runtime.int.spec.ts
+++ b/tests/int/queued-import-runtime.int.spec.ts
@@ -96,23 +96,34 @@ describe('queued import execution boundary', () => {
})
it('enforces current site write access for every principal state', async () => {
- await expect(requireSiteWriter('site-1', { user: undefined } as never)).rejects.toMatchObject({
- status: 403,
- })
+ await expect(
+ requireSiteWriter('site-1', { context: {}, user: undefined } as never),
+ ).rejects.toMatchObject({ status: 403 })
mocks.authorization = { isAdmin: false, writableSiteIDs: ['site-1'] }
await expect(
- requireSiteWriter({ id: 'site-1' }, { user: { id: 'user-1' } } as never),
+ requireSiteWriter({ id: 'site-1' }, { context: {}, user: { id: 'user-1' } } as never),
).resolves.toBeUndefined()
mocks.authorization = { isAdmin: false, writableSiteIDs: ['another-site'] }
await expect(
- requireSiteWriter('site-1', { user: { id: 'user-1' } } as never),
+ requireSiteWriter('site-1', { context: {}, user: { id: 'user-1' } } as never),
).rejects.toMatchObject({ status: 403 })
mocks.authorization = { isAdmin: true, writableSiteIDs: [] }
await expect(
- requireSiteWriter('site-1', { user: { id: 'admin-1' } } as never),
+ requireSiteWriter('site-1', { context: {}, user: { id: 'admin-1' } } as never),
).resolves.toBeUndefined()
})
+ it('restricts scanner-backed queued imports to the scanner’s exact site', async () => {
+ const req = {
+ context: { otterScannerID: 'scanner-1', otterScannerSiteID: 'site-1' },
+ user: { id: 'otter:scanner-1' },
+ }
+ await expect(requireSiteWriter('site-1', req as never)).resolves.toBeUndefined()
+ await expect(requireSiteWriter('child-site', req as never)).rejects.toMatchObject({
+ status: 403,
+ })
+ })
+
it('treats completion and superseded jobs as idempotent no-ops', async () => {
for (const doc of [
{ ...baseDoc(), status: 'completed' },
diff --git a/tests/int/topology-view.int.spec.ts b/tests/int/topology-view.int.spec.ts
index a831b84..385789f 100644
--- a/tests/int/topology-view.int.spec.ts
+++ b/tests/int/topology-view.int.spec.ts
@@ -458,6 +458,7 @@ describe('topology view registration and site visibility', () => {
const resolvedConfig = await config
const adminComponents = resolvedConfig.admin?.components
expect(adminComponents?.afterNavLinks).toContain('@/components/TopologyNavLink')
+ expect(adminComponents?.afterNavLinks).not.toContain('@/components/OtterNavLink')
const views = adminComponents?.views as Record | undefined
expect(views).toBeDefined()
From fa83ce33e11111979c83f3aa71c64c29247ff132 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCveyda?=
Date: Tue, 13 Oct 2026 10:17:19 +0200
Subject: [PATCH 2/5] Fixed test issues
---
otserver-otter | 2 +-
tests/int/components.int.spec.ts | 352 ++++++++++++++++++++++++++++++-
tests/int/jobs.int.spec.ts | 28 ++-
3 files changed, 359 insertions(+), 23 deletions(-)
diff --git a/otserver-otter b/otserver-otter
index d64f6bf..3d592c6 160000
--- a/otserver-otter
+++ b/otserver-otter
@@ -1 +1 @@
-Subproject commit d64f6bf31b9fa68a8789a6aeaeb974a55b2efdf5
+Subproject commit 3d592c652bbd7ab70dccde2b2775901f87c85768
diff --git a/tests/int/components.int.spec.ts b/tests/int/components.int.spec.ts
index 3e2fa2c..34f039b 100644
--- a/tests/int/components.int.spec.ts
+++ b/tests/int/components.int.spec.ts
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
documentID: undefined as string | undefined,
+ documentCanUpdate: true,
authPermissions: undefined as
{ collections?: Record } | undefined,
fieldValue: {} as Record | boolean | string,
@@ -16,8 +17,15 @@ const mocks = vi.hoisted(() => ({
throw new Error('redirect')
}),
collectionSlug: undefined as string | undefined,
+ modalOpen: true,
+ pathname: '/admin/otters',
query: {} as { search?: string; where?: Record },
refineListData: vi.fn(),
+ formFields: {} as {
+ name?: { value: unknown }
+ site?: { value: unknown }
+ scanner?: { value: unknown }
+ },
selection: { count: 0, getQueryParams: vi.fn(() => ''), selectAll: 'none' } as {
count: number
getQueryParams: () => string
@@ -91,13 +99,26 @@ vi.mock('@payloadcms/ui', async () => {
useDocumentInfo: () => ({
id: mocks.documentID,
collectionSlug: mocks.collectionSlug,
- docPermissions: { update: true },
+ docPermissions: { update: mocks.documentCanUpdate },
}),
useField: () => ({ setValue: mocks.setValue, value: mocks.fieldValue }),
- useFormFields: (selector: (state: [{ scanner?: { value: string } }]) => unknown) =>
- selector([{ scanner: { value: 'scanner-1' } }]),
+ useFormFields: (
+ selector: (
+ state: [
+ {
+ name?: { value: unknown }
+ site?: { value: unknown }
+ scanner?: { value: unknown }
+ },
+ ],
+ ) => unknown,
+ ) => selector([mocks.formFields]),
useListQuery: () => ({ query: mocks.query, refineListData: mocks.refineListData }),
- useModal: () => ({ openModal: mocks.openModal, closeModal: vi.fn(), isModalOpen: () => true }),
+ useModal: () => ({
+ openModal: mocks.openModal,
+ closeModal: vi.fn(),
+ isModalOpen: () => mocks.modalOpen,
+ }),
useSelection: () => mocks.selection,
useAuth: () => ({ permissions: mocks.authPermissions }),
useConfig: () => ({ config: { routes: { admin: '/admin' } } }),
@@ -106,7 +127,7 @@ vi.mock('@payloadcms/ui', async () => {
vi.mock('next/navigation', () => ({
redirect: mocks.redirect,
- usePathname: () => '/admin/otters',
+ usePathname: () => mocks.pathname,
useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }),
}))
vi.mock('next/link', async () => {
@@ -135,8 +156,10 @@ import OtterInterfaceField from '../../src/components/OtterInterfaceField'
import OtterManagementView from '../../src/components/OtterManagementView'
import OtterManagement from '../../src/components/OtterManagementView/view'
import OtterNavLink from '../../src/components/OtterNavLink'
-import OtterEnrollment from '../../src/components/OtterEnrollment'
+import OtterEnrollment, { otterAction } from '../../src/components/OtterEnrollment'
import OtterScannerActions from '../../src/components/OtterScannerActions'
+import OtterManagedSettingsField from '../../src/components/OtterManagedSettingsField'
+import TopologyNavLink from '../../src/components/TopologyNavLink'
let container: HTMLDivElement
let root: Root
@@ -148,16 +171,26 @@ const render = async (component: React.ReactNode) => {
})
}
+const renderFresh = async (component: React.ReactNode) => {
+ await act(async () => root.unmount())
+ root = createRoot(container)
+ await render(component)
+}
+
beforeEach(() => {
;(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true
vi.clearAllMocks()
mocks.documentID = undefined
+ mocks.documentCanUpdate = true
mocks.collectionSlug = undefined
mocks.authPermissions = undefined
mocks.fieldValue = {}
mocks.query = {}
+ mocks.formFields = { scanner: { value: 'scanner-1' } }
+ mocks.modalOpen = true
+ mocks.pathname = '/admin/otters'
mocks.selection = { count: 0, getQueryParams: vi.fn(() => ''), selectAll: 'none' }
window.localStorage.clear()
container = document.createElement('div')
@@ -1049,3 +1082,310 @@ describe('Otter management overview', () => {
expect(container.textContent).not.toContain('AbortError')
})
})
+
+describe('remaining Otter component states', () => {
+ it('renders controller-owned settings for selected and unresolved values', async () => {
+ let overview: {
+ controllerUrl?: string
+ sites?: Array<{ id: string; name: string }>
+ scanners?: Array<{ id: string; enabled: boolean; enrolled: boolean }>
+ } = {
+ controllerUrl: 'https://otserver.example',
+ sites: [{ id: 'site-1', name: 'Plant' }],
+ scanners: [{ id: 'scanner-1', enabled: true, enrolled: true }],
+ }
+ const fetchMock = vi.fn(async () => ({ ok: true, json: async () => overview }))
+ vi.stubGlobal('fetch', fetchMock)
+ const field = createElement(OtterManagedSettingsField)
+
+ mocks.formFields = {
+ name: { value: 'Production' },
+ site: { value: { value: 'site-1' } },
+ scanner: { value: { value: 'scanner-1' } },
+ }
+ await render(field)
+ expect(container.textContent).toContain('Production')
+ expect(container.textContent).toContain('Plant')
+ expect(container.textContent).toContain('https://otserver.example')
+ expect(container.textContent).toContain('Enrolled machine credential')
+
+ overview = {
+ sites: [],
+ scanners: [{ id: 'scanner-1', enabled: false, enrolled: true }],
+ }
+ mocks.formFields = {
+ name: { value: 3 },
+ site: { value: 'deleted-site' },
+ scanner: { value: 'scanner-1' },
+ }
+ await renderFresh(field)
+ expect(container.textContent).toContain('Enter a profile name')
+ expect(container.textContent).toContain('deleted-site')
+ expect(container.textContent).toContain('Scanner is disabled')
+ expect(container.textContent).toContain('Set OTSERVER_PUBLIC_URL')
+
+ overview = { scanners: [{ id: 'scanner-1', enabled: true, enrolled: false }] }
+ mocks.formFields = { site: { value: '' }, scanner: { value: 'scanner-1' } }
+ await renderFresh(field)
+ expect(container.textContent).toContain('Select a site')
+ expect(container.textContent).toContain('Awaiting enrollment')
+
+ mocks.formFields = {}
+ fetchMock.mockResolvedValueOnce({ ok: false, json: async () => ({}) })
+ await renderFresh(field)
+ expect(container.textContent).toContain('Available after choosing a scanner')
+ expect(container.textContent).toContain('Select a site')
+
+ fetchMock.mockRejectedValueOnce('offline')
+ await renderFresh(field)
+ expect(container.textContent).toContain('Available after choosing a scanner')
+ })
+
+ it('shows adapter fallbacks and keeps manual interface names usable', async () => {
+ mocks.collectionSlug = 'otter-scanners'
+ mocks.documentID = 'scanner-2'
+ mocks.fieldValue = 'eth9'
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
+ const field = createElement(OtterInterfaceField, { field: {}, path: 'interface' } as never)
+ await render(field)
+ expect(fetch).toHaveBeenCalledWith('/api/otter/v1/overview', expect.anything())
+ expect(container.textContent).toContain('eth9 · not currently reported')
+ expect(container.textContent).toContain('not currently reported; its name is preserved')
+
+ const input = container.querySelector('input')!
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, 'eth8')
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ expect(mocks.setValue).toHaveBeenCalledWith('eth8')
+
+ const select = container.querySelector('select')!
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set?.call(select, '')
+ select.dispatchEvent(new Event('change', { bubbles: true }))
+ })
+ expect(mocks.setValue).toHaveBeenCalledWith('')
+
+ mocks.collectionSlug = undefined
+ mocks.documentID = undefined
+ mocks.formFields = { scanner: { value: { value: 'scanner-3' } } }
+ mocks.fieldValue = 'eth0'
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ scanners: [
+ {
+ id: 'scanner-3',
+ networkAdapters: [
+ { name: 'eth0', friendlyName: 'LAN', description: 'Local', ipAddresses: [] },
+ ],
+ },
+ ],
+ }),
+ }),
+ )
+ await renderFresh(field)
+ expect(container.textContent).toContain('LAN · no IP · no MAC')
+ expect(container.textContent).toContain('MAC not reported · No IP addresses')
+ })
+
+ it('shows the topology nav indicator only on its route', async () => {
+ const link = createElement(TopologyNavLink)
+ await render(link)
+ expect(container.querySelector('.nav__link-indicator')).toBeNull()
+
+ mocks.pathname = '/admin/topology'
+ await renderFresh(link)
+ expect(container.querySelector('.nav__link-indicator')).not.toBeNull()
+ })
+
+ it('updates multiple SNMP and OPC UA credential rows independently', async () => {
+ mocks.documentID = 'profile-1'
+ mocks.fieldValue = false
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ credentialsConfigured: true }),
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ await render(createElement(OtterCredentialsField))
+
+ const click = async (label: string) => {
+ const button = [...container.querySelectorAll('button')].find((entry) =>
+ entry.textContent?.includes(label),
+ )!
+ await act(async () => button.click())
+ }
+ const setLabelInput = async (label: HTMLLabelElement, value: string) => {
+ const input = label.querySelector('input')!
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
+ input,
+ value,
+ )
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ }
+ const setSelect = async (select: HTMLSelectElement, value: string) => {
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set?.call(
+ select,
+ value,
+ )
+ select.dispatchEvent(new Event('change', { bubbles: true }))
+ })
+ }
+
+ await click('Add SNMP credential')
+ await click('Add SNMP credential')
+ const snmpSelects = container.querySelectorAll(
+ '.otter-credentials__section select',
+ )
+ await setSelect(snmpSelects[0]!, 'auto')
+ await setSelect(snmpSelects[1]!, 'sha256')
+ await setSelect(snmpSelects[2]!, 'aes128')
+ for (const [label, value] of [
+ ['Community', 'private'],
+ ['Username', 'snmp-user'],
+ ['Context name', 'engineering'],
+ ['Authentication password', 'auth-secret'],
+ ['Privacy password', 'privacy-secret'],
+ ]) {
+ const field = [...container.querySelectorAll('label')].find((entry) =>
+ entry.textContent?.trim().startsWith(label),
+ )!
+ await setLabelInput(field, value)
+ }
+
+ await click('Add OPC UA credential')
+ await click('Add OPC UA credential')
+ const opcuaSection = container.querySelectorAll('.otter-credentials__section')[1]!
+ const opcuaUsername = [...opcuaSection.querySelectorAll('label')].find((entry) =>
+ entry.textContent?.trim().startsWith('Username'),
+ )!
+ const opcuaPassword = [...opcuaSection.querySelectorAll('label')].find((entry) =>
+ entry.textContent?.trim().startsWith('Password'),
+ )!
+ await setLabelInput(opcuaUsername, 'operator')
+ await setLabelInput(opcuaPassword, 'opcua-secret')
+
+ for (const label of ['Remove SNMP credential', 'Remove OPC UA credential']) {
+ const remove = [...container.querySelectorAll('button')].filter((entry) =>
+ entry.textContent?.includes(label),
+ )[1]!
+ await act(async () => remove.click())
+ }
+ await click('Encrypt and save credentials')
+
+ expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
+ credentials: {
+ snmp: [
+ {
+ version: 'auto',
+ community: 'private',
+ username: 'snmp-user',
+ contextName: 'engineering',
+ authProtocol: 'sha256',
+ authPassword: 'auth-secret',
+ privacyProtocol: 'aes128',
+ privacyPassword: 'privacy-secret',
+ },
+ ],
+ opcuaCredentials: [{ username: 'operator', password: 'opcua-secret' }],
+ },
+ })
+ expect(container.textContent).toContain('Credentials encrypted and saved')
+ })
+
+ it('handles enrollment permission, loading, and response error paths', async () => {
+ const enrollment = createElement(OtterEnrollment)
+ const fetchMock = vi.fn().mockRejectedValue('offline')
+ vi.stubGlobal('fetch', fetchMock)
+ mocks.modalOpen = false
+ await render(enrollment)
+ expect(container.textContent).toBe('')
+ expect(fetchMock).not.toHaveBeenCalled()
+
+ mocks.authPermissions = { collections: { 'otter-profiles': { create: true } } }
+ mocks.modalOpen = false
+ await renderFresh(enrollment)
+ expect(fetchMock).not.toHaveBeenCalled()
+ await act(async () =>
+ [...container.querySelectorAll('button')]
+ .find((button) => button.textContent === 'Create New')!
+ .click(),
+ )
+ expect(mocks.openModal).toHaveBeenCalledWith('otter-scanner-enrollment')
+ mocks.modalOpen = true
+ await renderFresh(enrollment)
+ expect(container.textContent).toContain('Could not load sites.')
+
+ const actions = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ accepted: true }) })
+ .mockResolvedValueOnce({ ok: false, json: async () => ({ message: 'Enrollment rejected.' }) })
+ .mockResolvedValueOnce({ ok: false, json: async () => ({}) })
+ vi.stubGlobal('fetch', actions)
+ await expect(otterAction('/api/ok')).resolves.toEqual({ accepted: true })
+ await expect(otterAction('/api/denied')).rejects.toThrow('Enrollment rejected.')
+ await expect(otterAction('/api/unknown')).rejects.toThrow('The Otter action failed.')
+ expect(actions.mock.calls[0]?.[1]).toMatchObject({ method: 'POST' })
+ expect(actions.mock.calls[0]?.[1]).not.toHaveProperty('body')
+ })
+
+ it('handles scanner re-enrollment, revocation, and non-Error failures', async () => {
+ const actions = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ enrollmentToken: 'new-token',
+ expiresAt: new Date().toISOString(),
+ controllerUrl: 'https://otserver.example',
+ }),
+ })
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ status: 'revoked' }) })
+ .mockRejectedValueOnce('offline')
+ vi.stubGlobal('fetch', actions)
+ const component = createElement(OtterScannerActions)
+
+ await render(component)
+ expect(container.textContent).toBe('')
+ mocks.documentID = 'scanner-1'
+ mocks.documentCanUpdate = false
+ await renderFresh(component)
+ expect(container.textContent).toBe('')
+
+ mocks.documentCanUpdate = true
+ mocks.collectionSlug = 'otter-scanners'
+ await renderFresh(component)
+ const click = async (label: string) => {
+ const button = [...container.querySelectorAll('button')].find((entry) =>
+ entry.textContent?.includes(label),
+ )!
+ await act(async () => button.click())
+ }
+ await click('Re-enroll')
+ const confirmation = [...container.querySelectorAll('button')].filter(
+ (entry) => entry.textContent === 'Confirm',
+ )[0]!
+ await act(async () => confirmation.click())
+ expect(container.textContent).toContain('new-token')
+
+ await click('Revoke')
+ const confirmations = [...container.querySelectorAll('button')].filter(
+ (entry) => entry.textContent === 'Confirm',
+ )
+ await act(async () => confirmations[1]!.click())
+ expect(container.textContent).toContain('Scanner credential revoked.')
+
+ await click('Revoke')
+ const retryConfirmations = [...container.querySelectorAll('button')].filter(
+ (entry) => entry.textContent === 'Confirm',
+ )
+ await act(async () => retryConfirmations[1]!.click())
+ expect(container.textContent).toContain('The action failed.')
+ expect(mocks.refresh).toHaveBeenCalled()
+ })
+})
diff --git a/tests/int/jobs.int.spec.ts b/tests/int/jobs.int.spec.ts
index 3a0a0e1..48ff51c 100644
--- a/tests/int/jobs.int.spec.ts
+++ b/tests/int/jobs.int.spec.ts
@@ -90,9 +90,7 @@ describe('durable workers and queued imports', () => {
expect(fence).not.toBeNull()
expect(await acquireWorker(payload, 'imports')).toBeNull()
await recoverQueue(payload, fence!)
- await withWorkerFence(fence!, () =>
- payload.jobs.run({ queue: 'imports', limit: 1, sequential: true, silent: true }),
- )
+ await withWorkerFence(fence!, () => payload.jobs.runByID({ id: jobID!, silent: true }))
let completed = await payload.findByID({ collection: 'asset-imports', id: importID })
expect(completed.status).toBe('completed')
@@ -109,9 +107,7 @@ describe('durable workers and queued imports', () => {
data: { completedAt: null, processing: true, hasError: false, error: null },
})
await recoverQueue(payload, fence!)
- await withWorkerFence(fence!, () =>
- payload.jobs.run({ queue: 'imports', limit: 1, sequential: true, silent: true }),
- )
+ await withWorkerFence(fence!, () => payload.jobs.runByID({ id: jobID!, silent: true }))
completed = await payload.findByID({ collection: 'asset-imports', id: importID })
expect(completed.status).toBe('completed')
expect(
@@ -168,10 +164,9 @@ describe('durable workers and queued imports', () => {
jobID = (await payload.findByID({ collection: 'asset-imports', id: imported.id })).jobID!
await payload.delete({ collection: 'users', id: user.id })
fence = await acquireWorker(payload, 'imports')
+ expect(fence).not.toBeNull()
await recoverQueue(payload, fence!)
- await withWorkerFence(fence!, () =>
- payload.jobs.run({ queue: 'imports', limit: 1, sequential: true, silent: true }),
- )
+ await withWorkerFence(fence!, () => payload.jobs.runByID({ id: jobID!, silent: true }))
const failed = await payload.findByID({ collection: 'asset-imports', id: importID })
expect(failed).toMatchObject({
status: 'failed',
@@ -231,9 +226,10 @@ describe('durable workers and queued imports', () => {
else await rm(storedPath)
fence = await acquireWorker(payload, 'imports')
+ expect(fence).not.toBeNull()
await recoverQueue(payload, fence!)
await withWorkerFence(fence!, () =>
- payload.jobs.run({ queue: 'imports', limit: 1, sequential: true, silent: true }),
+ payload.jobs.runByID({ id: imported!.jobID!, silent: true }),
)
expect(
await payload.findByID({ collection: 'asset-imports', id: imported.id }),
@@ -257,7 +253,7 @@ describe('durable workers and queued imports', () => {
const retried = await payload.findByID({ collection: 'asset-imports', id: imported.id })
jobIDs.push(retried.jobID!)
await withWorkerFence(fence!, () =>
- payload.jobs.run({ queue: 'imports', limit: 1, sequential: true, silent: true }),
+ payload.jobs.runByID({ id: retried.jobID!, silent: true }),
)
expect(
await payload.findByID({ collection: 'asset-imports', id: imported.id }),
@@ -323,10 +319,9 @@ describe('durable workers and queued imports', () => {
await payload.update({ collection: 'users', id: user.id, data: { role: readerRole.id } })
fence = await acquireWorker(payload, 'imports')
+ expect(fence).not.toBeNull()
await recoverQueue(payload, fence!)
- await withWorkerFence(fence!, () =>
- payload.jobs.run({ queue: 'imports', limit: 1, sequential: true, silent: true }),
- )
+ await withWorkerFence(fence!, () => payload.jobs.runByID({ id: jobID!, silent: true }))
expect(await payload.findByID({ collection: 'asset-imports', id: importID })).toMatchObject({
status: 'failed',
error: 'The executing user no longer has write permission for this site.',
@@ -372,10 +367,11 @@ describe('durable workers and queued imports', () => {
imports.push(await payload.findByID({ collection: 'asset-imports', id: accepted.id }))
}
fence = await acquireWorker(payload, 'imports')
+ expect(fence).not.toBeNull()
await recoverQueue(payload, fence!)
await withWorkerFence(fence!, async () => {
- await payload.jobs.run({ queue: 'imports', limit: 1, sequential: true, silent: true })
- await payload.jobs.run({ queue: 'imports', limit: 1, sequential: true, silent: true })
+ await payload.jobs.runByID({ id: imports[0]!.jobID!, silent: true })
+ await payload.jobs.runByID({ id: imports[1]!.jobID!, silent: true })
})
const completed = await Promise.all(
imports.map(({ id }) => payload.findByID({ collection: 'asset-imports', id })),
From aa2306fba81f81912d557ce88f2b4281d14db062 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCveyda?=
Date: Tue, 13 Oct 2026 17:40:41 +0200
Subject: [PATCH 3/5] Architecture cleanup. Better testing
---
.github/workflows/ci.yml | 6 +-
.github/workflows/docker-publish.yml | 45 ++-
AGENTS.md | 36 +--
README.md | 18 +-
otserver-otter | 2 +-
otter_management.md | 38 +++
package.json | 8 +-
pnpm-lock.yaml | 24 +-
src/application/assetPanel.ts | 134 ++++++++
src/application/processImport.ts | 131 ++++----
src/collections/AssetClasses.ts | 32 --
src/collections/AssetExport.ts | 2 +-
src/collections/AssetImports.ts | 25 +-
src/collections/Assets.ts | 39 ++-
src/collections/AuditLogs.ts | 29 +-
src/collections/UserRoles.ts | 17 +-
src/components/AssetListInteractions.tsx | 2 +-
src/components/AssetView/index.tsx | 194 ++++++-----
src/components/DeviceIdentity/index.tsx | 128 +++++---
src/components/ImportInstructions/index.tsx | 4 +-
src/components/OtterInterfaceField/index.tsx | 20 +-
.../TopologyView/TopologyCanvas.tsx | 19 +-
src/components/TopologyView/graph.ts | 292 +++++++++++++++++
src/components/TopologyView/index.tsx | 277 +---------------
src/identity/actions.ts | 11 +-
src/identity/evidence.ts | 186 +++++++----
src/identity/reconcile.ts | 2 +
src/identity/relationships.ts | 13 +-
src/importers/otserverOtter.ts | 195 ++++++++----
.../20261013_130000_legacy_identity.ts | 144 +++++++++
src/migrations/index.ts | 6 +
src/otter/controller.ts | 47 ++-
src/otter/runLease.ts | 25 ++
src/search/assetLucene.ts | 55 +---
src/search/assetSearchFields.ts | 71 +++++
src/search/whereToLucene.ts | 13 +-
src/worker.ts | 6 +-
tests/check-test-safety.sh | 19 ++
tests/container-bootstrap.sh | 200 +++++++++++-
tests/int/api.int.spec.ts | 149 +++++++++
tests/int/asset-lucene.int.spec.ts | 6 +
tests/int/asset-panel.int.spec.ts | 167 ++++++++++
tests/int/assets.int.spec.ts | 3 +
tests/int/audit.int.spec.ts | 26 +-
tests/int/components.int.spec.ts | 128 ++++----
tests/int/coverage.int.spec.ts | 79 ++---
tests/int/device-identity.int.spec.ts | 165 +++++++++-
tests/int/identity-evidence.int.spec.ts | 233 ++++++++++++--
tests/int/jobs.int.spec.ts | 35 +-
tests/int/migrations.int.spec.ts | 223 ++++++-------
tests/int/otserver-otter.int.spec.ts | 300 ++++++++++++++++--
tests/int/otter-management.int.spec.ts | 190 ++++++++---
tests/int/process-import-runtime.int.spec.ts | 15 +-
tests/int/topology-view.int.spec.ts | 5 +-
tests/run-vitest.sh | 76 +++++
vitest.config.mts | 2 +
vitest.setup.ts | 15 +-
57 files changed, 3165 insertions(+), 1167 deletions(-)
create mode 100644 src/application/assetPanel.ts
create mode 100644 src/components/TopologyView/graph.ts
create mode 100644 src/migrations/20261013_130000_legacy_identity.ts
create mode 100644 src/otter/runLease.ts
create mode 100644 src/search/assetSearchFields.ts
create mode 100644 tests/check-test-safety.sh
create mode 100644 tests/int/asset-panel.int.spec.ts
create mode 100644 tests/run-vitest.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6d8d7fd..65f5519 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,11 +14,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
- DATABASE_URL: mongodb://127.0.0.1:27017/otserver-ci?replicaSet=rs0&directConnection=true
OTSERVER_SECRET: ci-only-secret
steps:
- uses: actions/checkout@v6
- - run: docker compose up --detach --wait mongo
+ with:
+ submodules: recursive
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@@ -28,6 +28,6 @@ jobs:
- run: pnpm check:generated
- run: pnpm exec prettier --check .
- run: pnpm lint
- - run: pnpm test:coverage:app
+ - run: pnpm test:coverage
- run: pnpm build
- run: git diff --check
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 361e121..0943653 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -9,12 +9,45 @@ permissions:
packages: write
jobs:
+ verify:
+ name: Verify release candidate
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ submodules: recursive
+ - uses: pnpm/action-setup@v6
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+ - name: Install native build dependencies
+ run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev openssl
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm check:generated
+ - run: pnpm lint
+ - run: pnpm test:coverage
+ - run: pnpm build
+ - name: Run exact management/Otter controller gate
+ run: ./otserver-otter/lab/test-controller.sh
+ - run: pnpm test:container
+ - name: Upload controller reports
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: release-controller-${{ github.sha }}
+ path: otserver-otter/lab/artifacts/controller.*/
+ if-no-files-found: ignore
publish:
+ needs: verify
name: Build and publish image
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
+ with:
+ submodules: recursive
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
@@ -31,7 +64,8 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
latest
- - uses: docker/build-push-action@v6
+ - id: publish-image
+ uses: docker/build-push-action@v6
with:
context: .
push: true
@@ -39,3 +73,12 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+ - name: Record release inputs and artifact digest
+ env:
+ IMAGE_DIGEST: ${{ steps.publish-image.outputs.digest }}
+ run: |
+ {
+ echo "Management commit: $(git rev-parse HEAD)"
+ echo "Otter commit: $(git -C otserver-otter rev-parse HEAD)"
+ echo "Image digest: $IMAGE_DIGEST"
+ } | tee -a "$GITHUB_STEP_SUMMARY"
diff --git a/AGENTS.md b/AGENTS.md
index 3e7c9e4..9e8cc11 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -196,9 +196,11 @@ To add an importer:
PRONETA has no public stable schema: accept known paths and capitalization variants, tolerate missing
and unknown fields, and preserve useful raw data. Nmap input must be XML produced with `-oX`.
-OTserver Otter JSON must validate against schema version 2. A contract change requires coordinated
-updates to the canonical schema and Rust implementation in `otserver-otter`, an updated submodule
-pointer here, plus this repository's TypeScript importer, fixtures, and tests.
+Otter producer conformance is checked against the canonical schema and shared fixtures; management
+also rejects malformed required structure while keeping unknown optional evidence opaque. A contract
+change requires coordinated updates to the canonical schema and Rust implementation in
+`otserver-otter`, an updated submodule pointer here, plus this repository's TypeScript importer,
+fixtures, and tests.
Human-supplied bulk import fields are declared once in `userSuppliedAssetFields` in
`src/collections/Assets.ts`; the asset and import UIs derive from it. Custom field definitions are
@@ -242,29 +244,23 @@ Requirements: Node.js 22, pnpm 10, and a MongoDB 8 replica set. Copy `.env.examp
a long random `OTSERVER_SECRET`. `docker compose up` provides the standalone OTserver and MongoDB;
the optional `workers` profile is only for separate high-throughput workers.
-Integration tests need a replica set matching `DATABASE_URL`, or the optional `TEST_DATABASE_URL`
-override. Start a throwaway instance
-with Podman:
+Test commands own a disposable MongoDB 8 replica set and a unique upload directory. Use Docker or
+Podman; direct Vitest invocations stop before loading Payload unless an owning test harness supplies
+`OTSERVER_TEST_DATABASE_OWNER`, `TEST_DATABASE_URL`, and `OTSERVER_TEST_UPLOAD_DIR`.
```bash
-podman run -d --rm --name otserver-identity-test-mongo -p 27018:27017 \
- docker.io/library/mongo:8 --replSet rs0 --bind_ip_all
-podman exec otserver-identity-test-mongo mongosh --quiet --eval \
- 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]})'
-TEST_DATABASE_URL='mongodb://127.0.0.1:27018/otserver-test?replicaSet=rs0&directConnection=true' \
- OTSERVER_VULNERABILITY_FEEDS=off pnpm test
+pnpm test
```
-Tear it down with `podman stop otserver-identity-test-mongo`; `--rm` removes it.
MongoDB operations sharing a request transaction must run serially. `withAudit` establishes the
transaction before Payload 3.87's parallel relationship validation can race its first command.
Run `pnpm test:container` after changing the Dockerfile or startup/bootstrap behavior. It detects
Docker or Podman, builds the production image, starts an isolated MongoDB replica set, verifies the
-fresh-database seeds, embedded workers, and first-user registration, and removes its containers and
-network.
+fresh-database seeds, external workers without `.env`, queue processing, first-user registration,
+and a database/upload/secret restore, then removes its containers and network.
-`vitest.config.mts` sets a 60s per-test timeout because these tests boot Payload against a real
+`vitest.config.mts` sets 60s per-test and hook timeouts because these tests boot Payload against a real
MongoDB. A cold container makes the first run slower than later ones.
Run the smallest relevant checks while developing, then the full set before committing:
@@ -279,16 +275,16 @@ git diff --check
Run `pnpm check:generated` for generated-file drift. Production upgrades drain workers, back up the
database and uploads, and apply versioned migrations before workers restart; see `docs/operations.md`.
-The full `pnpm benchmark:capacity` dataset is release-only and must use a disposable database.
`pnpm test:coverage` enforces at least 90% statements, branches, functions, and lines for the
-application.
+application. `src/migrations/**` is excluded from the percentage; exercise migration behavior with
+the real database migration runner in focused integration tests.
Vitest runs integration files serially because they share database state. New behavior affecting
authorization, imports, quality precedence, audit logs, parsing, or bulk operations needs a focused
integration test. Otter implementation and protocol tests belong in the `otserver-otter`
-repository. Run `pnpm test:otter-client` against the test replica set to build the headless executable
-and verify real-client REST enrollment, import, and credential reuse after restart. To test a specific
+repository. Run `pnpm test:otter-client` to build the headless executable and verify real-client REST
+enrollment, import, and credential reuse after restart. To test a specific
build, use `OTTER_CLIENT_BIN=otserver-otter/target/debug/otserver-otter pnpm test:int
tests/int/otter-client.int.spec.ts`. Local-file CLI/GUI operation must
remain independent of the managed `controller` command and its private state directory.
diff --git a/README.md b/README.md
index d4c616a..2dffe76 100644
--- a/README.md
+++ b/README.md
@@ -180,9 +180,9 @@ Confirm both worker heartbeats in the administrator operations endpoint. Do not
mode without running both worker services.
For an upgrade from MAC-only inventory, follow the [identity migration](docs/device-identity.md#upgrade-from-mac-only-inventory)
-before starting this version. It requires a replica set and removal of the legacy unique MAC index.
-For subsequent upgrades, back up those volumes, update the image tag if pinned, then run
-`docker compose pull && docker compose up -d` again.
+with workers stopped and before accepting new imports. It requires a replica set and removal of the
+legacy unique MAC index.
+For database upgrades, worker operation, backup, and restore, follow the [operations guide](docs/operations.md).
### Local development
@@ -281,6 +281,8 @@ Import files are treated as untrusted input: parsers enforce size and structure
optional vendor data, and report malformed or uncorrelatable observations as warnings. The canonical
wire contract is pinned through the `otserver-otter` submodule at
[`otserver-otter/contracts/otserver-scan-v2.schema.json`](otserver-otter/contracts/otserver-scan-v2.schema.json).
+The Assets CSV is a schema-dependent diagnostic export; its columns follow the stored asset fields and
+may change as that schema evolves.
Example searches:
@@ -399,7 +401,8 @@ tests/int/ Application and importer integration tests
## Development
-Run the smallest relevant check while working, then the full application suite:
+Run the smallest relevant check while working, then the full application suite. Test commands create
+and remove their own MongoDB 8 replica set and upload directory; Docker or Podman is required:
```bash
pnpm test
@@ -415,12 +418,11 @@ pnpm test:container
Set `CONTAINER_RUNTIME=docker` or `CONTAINER_RUNTIME=podman` to select one explicitly.
-Verify controller interoperability with the real Rust executable and Payload REST API against a
-disposable MongoDB replica-set database (requires the Rust toolchain and initialized Otter submodule):
+Verify controller interoperability with the real Rust executable and Payload REST API (requires the
+Rust toolchain and initialized Otter submodule):
```bash
-TEST_DATABASE_URL='mongodb://127.0.0.1:27018/otserver-test?replicaSet=rs0&directConnection=true' \
- pnpm test:otter-client
+pnpm test:otter-client
```
This builds the headless client and tests enrollment using bare loopback and explicit HTTP URLs,
diff --git a/otserver-otter b/otserver-otter
index 3d592c6..10e9675 160000
--- a/otserver-otter
+++ b/otserver-otter
@@ -1 +1 @@
-Subproject commit 3d592c652bbd7ab70dccde2b2775901f87c85768
+Subproject commit 10e967551e12f729b5e3b9336e081abad808dc53
diff --git a/otter_management.md b/otter_management.md
index 3f29bdf..dc1c85a 100644
--- a/otter_management.md
+++ b/otter_management.md
@@ -10,6 +10,41 @@ mode enrolls, polls, executes assigned snapshots, and uploads with a machine cre
result remains the existing `otserver-scan` schema-version-2 document. Scanner setup, private state
storage, recovery, and verification commands are documented in `otserver-otter/README.md`.
+## Scan-v2 contract and identity evidence
+
+The canonical Draft 2020-12 schema is `otserver-otter/contracts/otserver-scan-v2.schema.json`.
+Otter validates every serialized export against it, including UUID and date-time formats. Shared valid
+and invalid fixtures are checked by both Rust and TypeScript tests. Management remains a tolerant
+consumer for unknown optional protocol evidence, but required scan metadata, observations, links, and
+their required fields must be valid. New exports set `scan.interface.ipRoutePolicy` to
+`operating-system`; the selected interface records capture selection and does not bind native IP
+probes to that interface.
+
+Only these source-qualified fields can authorize physical identity in management:
+
+- **EtherNet/IP:** `raw.vendorId` and `raw.deviceType` must be integers; `raw.serialNumber`, the
+ source-qualified `raw.serialClaim`, and `fields.serialNumber` must agree. The claim manufacturer
+ ID/scope must match the vendor ID/device type. `raw.subject` must identify the same device or
+ qualified MAC-free endpoint as the observation.
+- **S7:** `raw.module` and `fields.serialNumber` must both be present, and `raw.serialClaim` must name
+ the same serial with Siemens as issuer and CPU scope. `raw.subject` must resolve to the observed
+ device or qualified MAC-free endpoint.
+- **SNMP ENTITY-MIB:** `raw.physicalEntities` supplies the entity reference, class, and scoped serial
+ claim. The claim reference, class/scope, issuer, and original serial must match the SNMP OID values.
+ `raw.subject` must match the reported device MAC, or a MAC-free endpoint must match its observation
+ IP and UUID `raw.observationId`. Only one unparented chassis/stack selected by
+ `raw.identityComponentRef` and the matching top-level `raw.serialClaim` can identify the device.
+ Module-only and ambiguous-root records cannot identify the device; a separately qualified component
+ claim can identify only that component.
+- **PROFINET:** `raw.pnioRecords` retains API/slot/subslot/index selection and
+ `raw.physicalEntities` retains component-scoped serial claims as evidence. Management does not
+ promote those claims to device identity. Legacy device-level manufacturer/software fields come only
+ from API 0, slot 0, subslot 1, index `0xAFF0`.
+
+Other `raw` protocol data remains opaque to identity resolution. A serial-like string without the
+source, manufacturer, component reference, and subject scope above can remain evidence, but it cannot
+authorize an automatic physical merge.
+
## Operator setup
Set these values on every OTserver web process that manages or serves Otter requests:
@@ -135,6 +170,9 @@ interface selector displays each reported adapter's friendly name, MAC, and addr
technical name. `sourceMac` is optional; Otter refreshes the selected adapter's actual MAC at scan
start. The controller supplies the scanner's fixed site, OTserver URL, profile name, and upload
destination. Profiles cannot specify `site`, `serverUrl`, `apiKey`, or an arbitrary local output path.
+The selected interface controls Layer-2 capture and ARP/DCP source selection. TCP/UDP, SNMP, and other
+native IP probes use the operating system's route table; operators of multi-homed scanners must set
+routes for the target networks in the operating system.
Enter SNMP/OPC UA secrets through the profile's **SNMP and OPC UA credentials** controls. They are
validated and encrypted at rest with AES-256-GCM using `OTSERVER_OTTER_ENCRYPTION_KEY`. The regular
diff --git a/package.json b/package.json
index a1680b9..d9a3eef 100644
--- a/package.json
+++ b/package.json
@@ -18,14 +18,14 @@
"worker:maintenance": "cross-env NODE_OPTIONS=--no-deprecation OTSERVER_WORKER_MODE=external tsx src/worker.ts maintenance",
"migrate": "cross-env NODE_OPTIONS=--no-deprecation payload migrate",
"migrate:status": "cross-env NODE_OPTIONS=--no-deprecation payload migrate:status",
- "benchmark:capacity": "cross-env NODE_OPTIONS=--no-deprecation tsx scripts/benchmark-capacity.ts",
"check:generated": "node scripts/check-generated.mjs",
"test": "pnpm run test:int",
"test:container": "sh tests/container-bootstrap.sh",
+ "test:safety": "sh tests/check-test-safety.sh",
"test:otter-client": "cargo build --locked --no-default-features --manifest-path otserver-otter/Cargo.toml && cross-env OTTER_CLIENT_BIN=otserver-otter/target/debug/otserver-otter OTSERVER_VULNERABILITY_FEEDS=off pnpm test:int tests/int/otter-client.int.spec.ts",
"test:coverage": "pnpm run test:coverage:app",
- "test:coverage:app": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config=vitest.config.mts --coverage",
- "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config=vitest.config.mts"
+ "test:coverage:app": "pnpm run test:safety && sh tests/run-vitest.sh --coverage",
+ "test:int": "pnpm run test:safety && sh tests/run-vitest.sh"
},
"dependencies": {
"@payloadcms/db-mongodb": "3.90.1",
@@ -44,6 +44,8 @@
"tsx": "4.20.6"
},
"devDependencies": {
+ "ajv": "8.18.0",
+ "ajv-formats": "3.0.1",
"@types/node": "22.19.9",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b38e058..5488c23 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -96,6 +96,12 @@ importers:
'@vitest/coverage-v8':
specifier: 5.0.0
version: 5.0.0(vitest@5.0.0)
+ ajv:
+ specifier: 8.18.0
+ version: 8.18.0
+ ajv-formats:
+ specifier: 3.0.1
+ version: 3.0.1(ajv@8.18.0)
eslint:
specifier: ^9.39.5
version: 9.39.5
@@ -1529,6 +1535,14 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
@@ -4800,6 +4814,10 @@ snapshots:
acorn@8.19.0: {}
+ ajv-formats@3.0.1(ajv@8.18.0):
+ optionalDependencies:
+ ajv: 8.18.0
+
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -5373,7 +5391,7 @@ snapshots:
eslint: 9.39.5
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.7.3))(eslint@9.39.5))(eslint@9.39.5)
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.7.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5)
eslint-plugin-react: 7.37.5(eslint@9.39.5)
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5)
@@ -5406,7 +5424,7 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.7.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5)
transitivePeerDependencies:
- supports-color
@@ -5421,7 +5439,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.7.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.2.0
diff --git a/src/application/assetPanel.ts b/src/application/assetPanel.ts
new file mode 100644
index 0000000..aea270b
--- /dev/null
+++ b/src/application/assetPanel.ts
@@ -0,0 +1,134 @@
+import type { Payload, TypedUser } from 'payload'
+import type { Asset } from '../payload-types'
+import { assetHistoryScope } from '../identity/relationships'
+import { findAssemblyVulnerabilities } from '../vulnerabilities/match'
+
+export const loadAssetPanel = async ({
+ asset,
+ payload,
+ user,
+ historyPage,
+ discoveryPage,
+ topologyPage,
+ pageSize,
+}: {
+ asset: Asset
+ payload: Payload
+ user?: TypedUser
+ historyPage: number
+ discoveryPage: number
+ topologyPage: number
+ pageSize: number
+}) => {
+ const limit = Number.isInteger(pageSize) ? Math.max(1, Math.min(pageSize, 100)) : 50
+ const site = typeof asset.site === 'object' ? asset.site : undefined
+ const assetClass = typeof asset.assetClass === 'object' ? asset.assetClass : undefined
+ const siteID = typeof asset.site === 'object' ? asset.site.id : asset.site
+ const assetClassID = typeof asset.assetClass === 'object' ? asset.assetClass.id : asset.assetClass
+ const historyScope = asset.uuid
+ ? await assetHistoryScope(asset.id, payload, user)
+ : {
+ assets: { asset: { equals: asset.id } },
+ observations: { asset: { equals: asset.id } },
+ truncated: false,
+ }
+
+ const siteRequest =
+ site?.path || !siteID
+ ? Promise.resolve(site)
+ : payload.findByID({
+ collection: 'sites',
+ depth: 0,
+ disableErrors: true,
+ id: String(siteID),
+ overrideAccess: false,
+ select: { name: true, path: true },
+ user,
+ })
+ const assetClassRequest =
+ assetClass?.name || !assetClassID
+ ? Promise.resolve(assetClass)
+ : payload.findByID({
+ collection: 'asset-classes',
+ depth: 0,
+ disableErrors: true,
+ id: String(assetClassID),
+ overrideAccess: false,
+ select: { name: true },
+ user,
+ })
+
+ const [
+ definitions,
+ auditLogs,
+ observations,
+ topologyLinks,
+ vulnerabilityMatches,
+ endpointCount,
+ loadedSite,
+ loadedAssetClass,
+ ] = await Promise.all([
+ payload.find({
+ collection: 'asset-fields',
+ depth: 0,
+ limit,
+ overrideAccess: false,
+ select: { label: true, type: true },
+ sort: 'label',
+ user,
+ }),
+ payload.find({
+ collection: 'audit-logs',
+ depth: 0,
+ limit,
+ overrideAccess: false,
+ page: historyPage,
+ sort: '-createdAt',
+ user,
+ where: historyScope.assets,
+ }),
+ payload.find({
+ collection: 'asset-observations',
+ depth: 0,
+ limit,
+ overrideAccess: false,
+ page: discoveryPage,
+ sort: '-observedAt',
+ user,
+ where: historyScope.observations,
+ }),
+ payload.find({
+ collection: 'topology-links',
+ depth: 0,
+ limit,
+ overrideAccess: false,
+ page: topologyPage,
+ sort: '-observedAt',
+ user,
+ where: {
+ or: [{ localAsset: { equals: asset.id } }, { remoteAsset: { equals: asset.id } }],
+ },
+ }),
+ findAssemblyVulnerabilities(payload, asset, { user }),
+ payload.count({
+ collection: 'network-endpoints',
+ overrideAccess: false,
+ user,
+ where: { and: [{ asset: { equals: asset.id } }, { endedAt: { exists: false } }] },
+ }),
+ siteRequest,
+ assetClassRequest,
+ ])
+
+ return {
+ definitions,
+ auditLogs,
+ observations,
+ topologyLinks,
+ vulnerabilityMatches,
+ endpointCount,
+ loadedSite,
+ loadedAssetClass,
+ historyScopeTruncated: historyScope.truncated,
+ }
+}
diff --git a/src/application/processImport.ts b/src/application/processImport.ts
index 26961aa..c8c315c 100644
--- a/src/application/processImport.ts
+++ b/src/application/processImport.ts
@@ -20,6 +20,7 @@ import {
suggestAttachmentChange,
} from '../identity/reconcile'
import { scopedKey } from '../identity/keys'
+import { withRequestContext } from '../integrations/payload/context'
const parsers = {
nmap: parseNmap,
@@ -38,7 +39,8 @@ export const getAssetOverrides = (value: unknown) => {
)
}
-export const processImport = async ({
+/** Runs within the caller's transaction; transaction commit and rollback belong to the caller. */
+const processImportInContext = async ({
doc,
data,
req,
@@ -49,8 +51,6 @@ export const processImport = async ({
req: PayloadRequest
throwValidationErrors?: boolean
}): Promise => {
- const context = req.context
-
const startedAt = Date.now()
let created = 0
let updated = 0
@@ -77,8 +77,6 @@ export const processImport = async ({
)
await requireTransaction(req)
mutating = true
- req.context.assetImport = true
- req.context.identityImportSource = doc.source
const appliedKey = scopedKey(
'import-v1',
site,
@@ -98,21 +96,22 @@ export const processImport = async ({
req,
})
if (replay.docs[0]) {
- context.skipAssetImport = true
- return req.payload.update({
- collection: 'asset-imports',
- id: doc.id,
- overrideAccess: false,
- req,
- context: { skipAssetImport: true },
- data: {
- status: 'completed',
- duplicateOf: replay.docs[0].id,
- createdAssets: 0,
- updatedAssets: 0,
- warnings: 'This exact import and its overrides were already applied.',
- },
- })
+ return withRequestContext(req, { skipAssetImport: true }, () =>
+ req.payload.update({
+ collection: 'asset-imports',
+ id: doc.id,
+ overrideAccess: false,
+ req,
+ context: { skipAssetImport: true },
+ data: {
+ status: 'completed',
+ duplicateOf: replay.docs[0].id,
+ createdAssets: 0,
+ updatedAssets: 0,
+ warnings: 'This exact import and its overrides were already applied.',
+ },
+ }),
+ )
}
const assetIDs = new Map()
@@ -345,48 +344,62 @@ export const processImport = async ({
console.warn(`Import ${doc.id} took ${durationSeconds.toFixed(1)} seconds.`)
}
- context.skipAssetImport = true
- context.appliedImportKey = appliedKey
- return req.payload.update({
- collection: 'asset-imports',
- context: { skipAssetImport: true, appliedImportKey: appliedKey },
- data: {
- createdAssets: created,
- appliedKey,
- error: null,
- projectName: topology.projectName,
- scanMetadata: topology.scanMetadata,
- skippedAssets:
- topology.warnings.length +
- (doc.source === 'otserver-otter' ? topology.unresolved?.length || 0 : 0),
- sourceVersion,
- status: 'completed',
- topologyName: topology.topologyName,
- unresolved: topology.unresolved,
- updatedAssets: updated,
- warnings: warnings.join('\n') || null,
- },
- id: doc.id,
- overrideAccess: false,
- req,
- })
+ return withRequestContext(req, { appliedImportKey: appliedKey, skipAssetImport: true }, () =>
+ req.payload.update({
+ collection: 'asset-imports',
+ context: { skipAssetImport: true, appliedImportKey: appliedKey },
+ data: {
+ createdAssets: created,
+ appliedKey,
+ error: null,
+ projectName: topology.projectName,
+ scanMetadata: topology.scanMetadata,
+ skippedAssets:
+ topology.warnings.length +
+ (doc.source === 'otserver-otter' ? topology.unresolved?.length || 0 : 0),
+ sourceVersion,
+ status: 'completed',
+ topologyName: topology.topologyName,
+ unresolved: topology.unresolved,
+ updatedAssets: updated,
+ warnings: warnings.join('\n') || null,
+ },
+ id: doc.id,
+ overrideAccess: false,
+ req,
+ }),
+ )
} catch (error) {
if (!throwValidationErrors) req.payload.logger.error(error)
// A write failure must escape the hook so Payload rolls back the entire inventory transaction.
if (mutating || throwValidationErrors) throw error
- context.skipAssetImport = true
- return req.payload.update({
- collection: 'asset-imports',
- context: { skipAssetImport: true },
- data: {
- createdAssets: created,
- error: error instanceof Error ? error.message : 'Import failed.',
- status: 'failed',
- updatedAssets: updated,
- },
- id: doc.id,
- overrideAccess: false,
- req,
- })
+ return withRequestContext(req, { skipAssetImport: true }, () =>
+ req.payload.update({
+ collection: 'asset-imports',
+ context: { skipAssetImport: true },
+ data: {
+ createdAssets: created,
+ error: error instanceof Error ? error.message : 'Import failed.',
+ status: 'failed',
+ updatedAssets: updated,
+ },
+ id: doc.id,
+ overrideAccess: false,
+ req,
+ }),
+ )
}
}
+
+export const processImport = async (
+ input: Parameters[0],
+): Promise =>
+ withRequestContext(
+ input.req,
+ {
+ assetImport: true,
+ importWrite: true,
+ identityImportSource: input.doc.source,
+ },
+ () => processImportInContext(input),
+ )
diff --git a/src/collections/AssetClasses.ts b/src/collections/AssetClasses.ts
index 5c4177d..ed334a1 100644
--- a/src/collections/AssetClasses.ts
+++ b/src/collections/AssetClasses.ts
@@ -167,38 +167,6 @@ export const initializeAssetClasses = async (payload: Payload) => {
overrideAccess: true,
})
}
-
- const assets = await payload.find({
- collection: 'assets',
- depth: 0,
- overrideAccess: true,
- pagination: false,
- where: { assetClass: { exists: false } },
- })
- for (const asset of assets.docs) {
- const legacyType = (asset as { assetType?: unknown }).assetType
- const key = typeof legacyType === 'string' ? legacyType : 'other'
- const assetClass = await ensureAssetClass(payload, key)
- const existingProvenance = asset.fieldProvenance
- const fieldProvenance: Record =
- existingProvenance &&
- typeof existingProvenance === 'object' &&
- !Array.isArray(existingProvenance)
- ? { ...(existingProvenance as Record) }
- : {}
- if (fieldProvenance.assetType && !fieldProvenance.assetClass) {
- fieldProvenance.assetClass = fieldProvenance.assetType
- }
- delete fieldProvenance.assetType
-
- await payload.update({
- collection: 'assets',
- context: { assetClassMigration: true },
- data: { assetClass: assetClass.id, assetType: null, fieldProvenance },
- id: asset.id,
- overrideAccess: true,
- })
- }
}
export const assignDefaultAssetClass: CollectionBeforeValidateHook = async ({
diff --git a/src/collections/AssetExport.ts b/src/collections/AssetExport.ts
index 61b2e8c..7da2f9e 100644
--- a/src/collections/AssetExport.ts
+++ b/src/collections/AssetExport.ts
@@ -113,7 +113,7 @@ export const exportAssetsCSV: PayloadHandler = async (req) => {
return new Response(stream as unknown as BodyInit, {
headers: {
'Cache-Control': 'no-store',
- 'Content-Disposition': `attachment; filename="assets-${cutoff.slice(0, 10)}.csv"`,
+ 'Content-Disposition': `attachment; filename="assets-diagnostic-${cutoff.slice(0, 10)}.csv"`,
'Content-Type': 'text/csv; charset=utf-8',
},
})
diff --git a/src/collections/AssetImports.ts b/src/collections/AssetImports.ts
index baa1d17..3dd5f20 100644
--- a/src/collections/AssetImports.ts
+++ b/src/collections/AssetImports.ts
@@ -26,7 +26,7 @@ import {
requireSiteWriter,
retryImport,
} from '../application/queuedImports'
-import { withRequestContext } from '../integrations/payload/context'
+import { assertCurrentOtterRunLease } from '../otter/runLease'
export { getAssetOverrides } from '../application/processImport'
const MAX_DIAGNOSTICS_LENGTH = 4_000_000
@@ -52,9 +52,7 @@ const bindOtterUpload: CollectionBeforeValidateHook = ({ data, operation, req })
const runImport: CollectionAfterChangeHook = async ({ context, doc, req, operation }) => {
if (context.skipAssetImport || !req.file) return doc
if (doc.executionMode === 'queued') return operation === 'create' ? queueImport(doc, req) : doc
- return withRequestContext(req, { importWrite: true }, () =>
- processImport({ doc, data: req.file!.data, req }),
- )
+ return processImport({ doc, data: req.file.data, req })
}
const managed = [
@@ -132,6 +130,13 @@ const protectProcessing: CollectionBeforeChangeHook = async ({
delete data.assetOverrides
delete data.customFieldOverrides
const runID = scannerRunID
+ const scanner = await req.payload.findByID({
+ collection: 'otter-scanners',
+ id: scannerID,
+ depth: 0,
+ overrideAccess: true,
+ req,
+ })
if (runID) {
const run = await req.payload.findByID({
collection: 'otter-runs',
@@ -143,12 +148,13 @@ const protectProcessing: CollectionBeforeChangeHook = async ({
if (
String(relationshipID(run.scanner)) !== scannerID ||
String(relationshipID(run.site)) !== siteID ||
- !['assigned', 'running', 'cancel-requested'].includes(run.status)
+ !['assigned', 'running'].includes(run.status)
)
throw new APIError(
'The scan run does not belong to this scanner or is no longer active.',
409,
)
+ assertCurrentOtterRunLease(run, scanner, new Date())
data.otterRun = String(runID)
const snapshot = run.importOverridesSnapshot
if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) {
@@ -158,13 +164,6 @@ const protectProcessing: CollectionBeforeChangeHook = async ({
} else {
delete data.otterRun
}
- const scanner = await req.payload.findByID({
- collection: 'otter-scanners',
- id: scannerID,
- depth: 0,
- overrideAccess: true,
- req,
- })
const site = await req.payload.findByID({
collection: 'sites',
id: siteID,
@@ -472,6 +471,6 @@ export const AssetImports: CollectionConfig = {
bulkUpload: false,
mimeTypes: ['application/json', 'application/xml', 'text/json', 'text/plain', 'text/xml'],
pasteURL: false,
- staticDir: 'import-files',
+ staticDir: process.env.OTSERVER_TEST_UPLOAD_DIR || 'import-files',
},
}
diff --git a/src/collections/Assets.ts b/src/collections/Assets.ts
index f768344..0aefb8e 100644
--- a/src/collections/Assets.ts
+++ b/src/collections/Assets.ts
@@ -98,7 +98,7 @@ export const Assets: CollectionConfig = {
name: 'identityRevision',
type: 'number',
defaultValue: 0,
- admin: { hidden: true },
+ admin: { hidden: true, disableListFilter: true },
access: { create: () => false, update: () => false },
},
{
@@ -117,7 +117,13 @@ export const Assets: CollectionConfig = {
options: ['unknown', 'device', 'chassis', 'module'],
},
{ name: 'catalogNumber', type: 'text', label: 'Catalog / part number' },
- { name: 'slotCapacity', type: 'number', min: 0, max: 65536 },
+ {
+ name: 'slotCapacity',
+ type: 'number',
+ min: 0,
+ max: 65536,
+ admin: { disableListFilter: true },
+ },
{
name: 'lifecycle',
type: 'select',
@@ -126,19 +132,34 @@ export const Assets: CollectionConfig = {
index: true,
admin: { readOnly: true, position: 'sidebar' },
},
- { name: 'baselined', type: 'checkbox', defaultValue: false, admin: { position: 'sidebar' } },
- { name: 'mergedInto', type: 'relationship', relationTo: 'assets', admin: { readOnly: true } },
- { name: 'replacedBy', type: 'relationship', relationTo: 'assets', admin: { readOnly: true } },
+ {
+ name: 'baselined',
+ type: 'checkbox',
+ defaultValue: false,
+ admin: { disableListFilter: true, position: 'sidebar' },
+ },
+ {
+ name: 'mergedInto',
+ type: 'relationship',
+ relationTo: 'assets',
+ admin: { disableListFilter: true, readOnly: true },
+ },
+ {
+ name: 'replacedBy',
+ type: 'relationship',
+ relationTo: 'assets',
+ admin: { disableListFilter: true, readOnly: true },
+ },
{
name: 'networkAddresses',
type: 'array',
- admin: { hidden: true },
+ admin: { disableListFilter: true, hidden: true },
fields: [{ name: 'address', type: 'text', index: true, required: true }],
},
{
name: 'networkMACs',
type: 'array',
- admin: { hidden: true },
+ admin: { disableListFilter: true, hidden: true },
fields: [{ name: 'address', type: 'text', index: true, required: true }],
},
{
@@ -191,12 +212,12 @@ export const Assets: CollectionConfig = {
{
name: 'fieldProvenance',
type: 'json',
- admin: { hidden: true },
+ admin: { disableListFilter: true, hidden: true },
},
{
name: 'assetType',
type: 'text',
- admin: { hidden: true },
+ admin: { disableListFilter: true, hidden: true },
label: 'Legacy asset type',
},
{
diff --git a/src/collections/AuditLogs.ts b/src/collections/AuditLogs.ts
index d72ac6e..941ad5c 100644
--- a/src/collections/AuditLogs.ts
+++ b/src/collections/AuditLogs.ts
@@ -22,6 +22,15 @@ type Document = Record
const ignoredFields = new Set(['collection', 'createdAt', 'id', 'updatedAt'])
const sensitiveField = /api[-_]?key|community|credential|hash|password|salt|secret|session|token/i
+const assetSubjectFields: Record = {
+ 'asset-identifiers': ['asset'],
+ 'asset-installations': ['module', 'parent'],
+ 'asset-observations': ['asset'],
+ 'identity-cases': ['asset', 'candidate'],
+ 'network-endpoints': ['asset'],
+ 'service-bindings': ['asset'],
+ 'topology-links': ['localAsset', 'remoteAsset'],
+}
const canReadAuditLogs: Access = async ({ req }) => {
const authorization = await getAuthorization(req)
@@ -69,13 +78,17 @@ export const writeAudit = async ({
action,
after,
before,
+ assetID: explicitAssetID,
req,
+ siteID: explicitSiteID,
targetCollection,
}: {
action: AuditAction
after?: unknown
+ assetID?: string
before?: unknown
req: PayloadRequest
+ siteID?: string
targetCollection: string
}) => {
const document = clean(after ?? before)
@@ -84,11 +97,17 @@ export const writeAudit = async ({
typeof req.context.otterScannerID === 'string' ? req.context.otterScannerID : undefined
const documentID = document.id === undefined ? undefined : String(document.id)
const documentLabel = labelFor(document)
- const relatedAsset = relationshipID(
- document.asset ?? document.localAsset ?? document.module ?? document.parent,
- )
+ const relatedAsset = assetSubjectFields[targetCollection]
+ ?.map((field) => relationshipID(document[field]))
+ .find(Boolean)
const assetID =
- targetCollection === 'assets' ? documentID : relatedAsset ? String(relatedAsset) : undefined
+ explicitAssetID ||
+ (targetCollection === 'assets'
+ ? documentID
+ : relatedAsset === undefined
+ ? undefined
+ : String(relatedAsset))
+ const siteID = explicitSiteID || relationshipID(document.site)
const forwardedFor = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
const requestPath = req.url ? new URL(req.url, 'http://local').pathname : undefined
@@ -96,7 +115,7 @@ export const writeAudit = async ({
collection: 'audit-logs',
data: {
action,
- ...(relationshipID(document.site) ? { site: relationshipID(document.site) as string } : {}),
+ ...(siteID ? { site: siteID as string } : {}),
actorEmail: !scannerID && typeof actor.email === 'string' ? actor.email : undefined,
actorID: scannerID || (actor.id === undefined ? undefined : String(actor.id)),
actorName: scannerID
diff --git a/src/collections/UserRoles.ts b/src/collections/UserRoles.ts
index da0c0d3..9c875b5 100644
--- a/src/collections/UserRoles.ts
+++ b/src/collections/UserRoles.ts
@@ -71,22 +71,7 @@ export const ensureAdminRole = async (payload: Payload, req?: PayloadRequest) =>
}
export const initializeAuthorization = async (payload: Payload) => {
- const adminRole = await ensureAdminRole(payload)
- const users = await payload.find({
- collection: 'users',
- depth: 0,
- overrideAccess: true,
- pagination: false,
- where: { role: { exists: false } },
- })
- for (const user of users.docs) {
- await payload.update({
- collection: 'users',
- data: { role: adminRole.id },
- id: user.id,
- overrideAccess: true,
- })
- }
+ await ensureAdminRole(payload)
}
export const UserRoles: CollectionConfig = {
diff --git a/src/components/AssetListInteractions.tsx b/src/components/AssetListInteractions.tsx
index d2f475a..76f1544 100644
--- a/src/components/AssetListInteractions.tsx
+++ b/src/components/AssetListInteractions.tsx
@@ -82,7 +82,7 @@ const AssetListInteractions = () => {
className="list-selection__button asset-export-csv"
onClick={() => void downloadCSV()}
>
- Download CSV
+ Download diagnostic CSV
,
actionsTarget,
)}
diff --git a/src/components/AssetView/index.tsx b/src/components/AssetView/index.tsx
index cb8ee86..8acd959 100644
--- a/src/components/AssetView/index.tsx
+++ b/src/components/AssetView/index.tsx
@@ -11,9 +11,10 @@ import {
statusLabels,
} from '@/components/labels'
import DeviceIdentity from '@/components/DeviceIdentity'
-import { assetHistoryScope } from '@/identity/relationships'
+import { loadAssetPanel } from '@/application/assetPanel'
import type { Asset, AssetObservation, AuditLog, TopologyLink } from '@/payload-types'
-import { bySeverity, findAssemblyVulnerabilities } from '@/vulnerabilities/match'
+import type { VulnerabilityMatch } from '@/vulnerabilities/matching'
+import { bySeverity } from '@/vulnerabilities/match'
import { documentClientProps } from '@/integrations/payload/admin'
import './index.scss'
@@ -53,6 +54,11 @@ const firstParam = (value: unknown): string | undefined => {
return typeof item === 'string' ? item : undefined
}
+const pageNumber = (value: unknown) => {
+ const page = Number(firstParam(value))
+ return Number.isSafeInteger(page) && page > 0 ? page : 1
+}
+
const relationID = (value: unknown): string | undefined => {
if (typeof value === 'string' || typeof value === 'number') return String(value)
const item = record(value)
@@ -142,6 +148,27 @@ const Section = ({
)
+const PageNavigation = ({
+ currentPage,
+ href,
+ label,
+ totalPages,
+}: {
+ currentPage: number
+ href: (page: number) => string
+ label: string
+ totalPages: number
+}) =>
+ totalPages > 1 ? (
+
+ {currentPage > 1 ? ← Newer : }
+
+ {label} page {currentPage} of {totalPages}
+
+ {currentPage < totalPages ? Older → : null}
+
+ ) : null
+
const InspectorCard = ({ children, title }: { children: ReactNode; title: string }) => (
@@ -265,7 +292,7 @@ const VulnerabilityList = ({
matches,
}: {
assetURL: string
- matches: ReturnType
extends Promise ? T : never
+ matches: VulnerabilityMatch[]
}) => {
const visible = matches.slice(0, MAX_LISTED_VULNERABILITIES)
@@ -313,55 +340,13 @@ const AssetView = async (props: DocumentViewServerProps) => {
const selectedTab: Tab = tabs.some(({ id }) => id === selectedParam)
? (selectedParam as Tab)
: 'overview'
- const historyPageParam = firstParam(props.searchParams?.historyPage)
- const requestedHistoryPage = Number(historyPageParam)
- const historyPage =
- Number.isInteger(requestedHistoryPage) && requestedHistoryPage > 0 ? requestedHistoryPage : 1
+ const historyPage = pageNumber(props.searchParams?.historyPage)
+ const discoveryPage = pageNumber(props.searchParams?.discoveryPage)
+ const topologyPage = pageNumber(props.searchParams?.topologyPage)
const populatedSite = typeof asset.site === 'object' ? asset.site : undefined
const populatedAssetClass = typeof asset.assetClass === 'object' ? asset.assetClass : undefined
- const siteID = relationID(asset.site)
- const assetClassID = relationID(asset.assetClass)
- const historyScope = asset.uuid
- ? await assetHistoryScope(asset.id, props.payload, props.user)
- : {
- assets: { asset: { equals: asset.id } },
- observations: { asset: { equals: asset.id } },
- }
const customFields = record(asset.customFields)
- const endpointCountRequest = props.payload.count
- ? props.payload.count({
- collection: 'network-endpoints',
- overrideAccess: false,
- user: props.user,
- where: { and: [{ asset: { equals: asset.id } }, { endedAt: { exists: false } }] },
- })
- : Promise.resolve({ totalDocs: asset.networkAddresses?.length || 0 })
- const siteRequest =
- populatedSite?.path || !siteID
- ? Promise.resolve(populatedSite)
- : props.payload.findByID({
- collection: 'sites',
- depth: 0,
- disableErrors: true,
- id: siteID,
- overrideAccess: false,
- select: { name: true, path: true },
- user: props.user,
- })
- const assetClassRequest =
- populatedAssetClass?.name || !assetClassID
- ? Promise.resolve(populatedAssetClass)
- : props.payload.findByID({
- collection: 'asset-classes',
- depth: 0,
- disableErrors: true,
- id: assetClassID,
- overrideAccess: false,
- select: { name: true },
- user: props.user,
- })
-
- const [
+ const {
definitions,
auditLogs,
observations,
@@ -370,50 +355,16 @@ const AssetView = async (props: DocumentViewServerProps) => {
endpointCount,
loadedSite,
loadedAssetClass,
- ] = await Promise.all([
- props.payload.find({
- collection: 'asset-fields',
- depth: 0,
- overrideAccess: false,
- pagination: false,
- sort: 'label',
- user: props.user,
- }),
- props.payload.find({
- collection: 'audit-logs',
- depth: 0,
- limit: 50,
- overrideAccess: false,
- page: historyPage,
- sort: '-createdAt',
- user: props.user,
- where: historyScope.assets,
- }),
- props.payload.find({
- collection: 'asset-observations',
- depth: 0,
- limit: 20,
- overrideAccess: false,
- sort: '-observedAt',
- user: props.user,
- where: historyScope.observations,
- }),
- props.payload.find({
- collection: 'topology-links',
- depth: 0,
- limit: 20,
- overrideAccess: false,
- sort: '-observedAt',
- user: props.user,
- where: {
- or: [{ localAsset: { equals: asset.id } }, { remoteAsset: { equals: asset.id } }],
- },
- }),
- findAssemblyVulnerabilities(props.payload, asset, { user: props.user }),
- endpointCountRequest,
- siteRequest,
- assetClassRequest,
- ])
+ historyScopeTruncated,
+ } = await loadAssetPanel({
+ asset,
+ payload: props.payload,
+ user: props.user,
+ historyPage,
+ discoveryPage,
+ topologyPage,
+ pageSize: 50,
+ })
const matches = vulnerabilityMatches.slice().sort(bySeverity)
const severityCounts = Object.fromEntries(
@@ -427,6 +378,8 @@ const AssetView = async (props: DocumentViewServerProps) => {
const latestObservation = observations.docs[0]
const tabURL = (tab: Tab, page?: number) =>
`${assetURL}?tab=${tab}${page ? `&historyPage=${page}` : ''}`
+ const pageURL = (tab: Tab, parameter: string, page: number) =>
+ `${assetURL}?tab=${tab}&${parameter}=${page}`
const recordLinks = () =>
tabs.map(({ id, label }) => (
{
title="Custom fields"
/>
) : null}
+ {definitions.hasNextPage ? (
+ Only the first 200 custom field definitions are shown.
+ ) : null}
{
title="Observed connections"
wide
/>
+ {topologyLinks.totalPages > 1 ? (
+ pageURL('overview', 'topologyPage', page)}
+ label="Topology"
+ totalPages={topologyLinks.totalPages}
+ />
+ ) : null}
>
)
@@ -589,6 +553,12 @@ const AssetView = async (props: DocumentViewServerProps) => {
title="Observed topology"
wide
/>
+ pageURL('network', 'topologyPage', page)}
+ label="Topology"
+ totalPages={topologyLinks.totalPages}
+ />
>
)
@@ -735,17 +705,25 @@ const AssetView = async (props: DocumentViewServerProps) => {
)
const discovery = (
-
- observationDetails(observation, index === 0),
- )
- : [{ label: 'Evidence', value: 'No scanner evidence recorded yet.', wide: true }]
- }
- title="Discovery evidence"
- wide
- />
+ <>
+
+ observationDetails(observation, index === 0),
+ )
+ : [{ label: 'Evidence', value: 'No scanner evidence recorded yet.', wide: true }]
+ }
+ title="Discovery evidence"
+ wide
+ />
+ pageURL('discovery', 'discoveryPage', page)}
+ label="Discovery evidence"
+ totalPages={observations.totalPages}
+ />
+ >
)
const history = (
@@ -819,7 +797,7 @@ const AssetView = async (props: DocumentViewServerProps) => {
className="asset-view__secondary-action"
href={`${apiRoute}/assets/export-csv?where[id][equals]=${encodeURIComponent(asset.id)}`}
>
- Export CSV
+ Export diagnostic CSV
@@ -872,6 +850,12 @@ const AssetView = async (props: DocumentViewServerProps) => {
{recordLinks()}
+ {historyScopeTruncated ? (
+
+ History is bounded to 200 merged records and 200 related endpoints. Some earlier entries
+ may be outside this view.
+
+ ) : null}
{mainContent}
diff --git a/src/components/DeviceIdentity/index.tsx b/src/components/DeviceIdentity/index.tsx
index 19ff3bf..6f314c1 100644
--- a/src/components/DeviceIdentity/index.tsx
+++ b/src/components/DeviceIdentity/index.tsx
@@ -22,53 +22,75 @@ export default async function DeviceIdentity({
}) {
const admin = payload.config.routes.admin
const site = idOf(asset.site)
- const authorization = await getAuthorization(await createLocalReq({ user }, payload))
- const writable = authorization.isAdmin || authorization.writableSiteIDs.includes(site)
+ const showNetwork = view === 'all' || view === 'network'
+ const showHardware = view === 'all' || view === 'hardware'
+ const authorization = showHardware
+ ? await getAuthorization(await createLocalReq({ user }, payload))
+ : undefined
+ const writable = Boolean(
+ showHardware &&
+ authorization &&
+ (authorization.isAdmin || authorization.writableSiteIDs.includes(site)),
+ )
+ const isAdmin = authorization?.isAdmin ?? false
+ const writableSiteIDs = authorization?.writableSiteIDs ?? []
+ const emptyPage = { docs: [], hasNextPage: false }
const [endpoints, services, identifiers, installations, cases, targets, sites] =
await Promise.all([
- payload.find({
- collection: 'network-endpoints',
- where: { asset: { equals: asset.id } },
- depth: 1,
- pagination: false,
- overrideAccess: false,
- user,
- }),
- payload.find({
- collection: 'service-bindings',
- where: { asset: { equals: asset.id } },
- depth: 0,
- pagination: false,
- overrideAccess: false,
- user,
- }),
- payload.find({
- collection: 'asset-identifiers',
- where: { asset: { equals: asset.id } },
- depth: 0,
- pagination: false,
- overrideAccess: false,
- user,
- }),
- payload.find({
- collection: 'asset-installations',
- where: { or: [{ parent: { equals: asset.id } }, { module: { equals: asset.id } }] },
- depth: 1,
- pagination: false,
- overrideAccess: false,
- user,
- }),
- payload.find({
- collection: 'identity-cases',
- where: { or: [{ asset: { equals: asset.id } }, { candidate: { equals: asset.id } }] },
- depth: 0,
- pagination: false,
- overrideAccess: false,
- user,
- }),
+ showNetwork
+ ? payload.find({
+ collection: 'network-endpoints',
+ limit: 200,
+ where: { asset: { equals: asset.id } },
+ depth: 1,
+ overrideAccess: false,
+ user,
+ })
+ : Promise.resolve(emptyPage as never),
+ showNetwork
+ ? payload.find({
+ collection: 'service-bindings',
+ limit: 200,
+ where: { asset: { equals: asset.id } },
+ depth: 0,
+ overrideAccess: false,
+ user,
+ })
+ : Promise.resolve(emptyPage as never),
+ showHardware
+ ? payload.find({
+ collection: 'asset-identifiers',
+ limit: 200,
+ where: { asset: { equals: asset.id } },
+ depth: 0,
+ overrideAccess: false,
+ user,
+ })
+ : Promise.resolve(emptyPage as never),
+ showHardware
+ ? payload.find({
+ collection: 'asset-installations',
+ limit: 200,
+ where: { or: [{ parent: { equals: asset.id } }, { module: { equals: asset.id } }] },
+ depth: 1,
+ overrideAccess: false,
+ user,
+ })
+ : Promise.resolve(emptyPage as never),
+ showHardware
+ ? payload.find({
+ collection: 'identity-cases',
+ limit: 200,
+ where: { or: [{ asset: { equals: asset.id } }, { candidate: { equals: asset.id } }] },
+ depth: 0,
+ overrideAccess: false,
+ user,
+ })
+ : Promise.resolve(emptyPage as never),
writable
? payload.find({
collection: 'assets',
+ limit: 200,
where: {
and: [
{ site: { equals: site } },
@@ -77,27 +99,31 @@ export default async function DeviceIdentity({
],
},
depth: 0,
- pagination: false,
overrideAccess: false,
user,
})
- : Promise.resolve({ docs: [] }),
+ : Promise.resolve(emptyPage as never),
writable
? payload.find({
collection: 'sites',
- where: authorization.isAdmin ? {} : { id: { in: authorization.writableSiteIDs } },
+ limit: 200,
+ where: isAdmin ? {} : { id: { in: writableSiteIDs } },
depth: 0,
- pagination: false,
overrideAccess: false,
user,
})
- : Promise.resolve({ docs: [] }),
+ : Promise.resolve(emptyPage as never),
])
const recordLink = (collection: string, id: string, label: string) => (
{label}
)
- const showNetwork = view === 'all' || view === 'network'
- const showHardware = view === 'all' || view === 'hardware'
+ const panelTruncated =
+ endpoints.hasNextPage ||
+ services.hasNextPage ||
+ identifiers.hasNextPage ||
+ installations.hasNextPage ||
+ cases.hasNextPage
+ const actionChoicesTruncated = targets.hasNextPage || sites.hasNextPage
return (
@@ -119,6 +145,10 @@ export default async function DeviceIdentity({
{asset.replacedBy ? (
Replaced by {recordLink('assets', idOf(asset.replacedBy), idOf(asset.replacedBy))}.
) : null}
+ {panelTruncated ? Showing the first 200 related identity records.
: null}
+ {actionChoicesTruncated ? (
+ Action selectors show the first 200 matching assets or sites.
+ ) : null}
{showHardware ? (
<>
Hardware identifiers
diff --git a/src/components/ImportInstructions/index.tsx b/src/components/ImportInstructions/index.tsx
index 4d2b8a6..d16ad4f 100644
--- a/src/components/ImportInstructions/index.tsx
+++ b/src/components/ImportInstructions/index.tsx
@@ -40,7 +40,9 @@ const ImportInstructions = () => {
Choose an importer, prepare its file as described below, select the destination site,
- and upload the file. Assets are merged only by MAC address.
+ and upload the file. Qualified manufacturer and serial evidence can identify physical
+ hardware; MAC addresses bind current network endpoints within the selected site.
+ Conflicting or unresolved evidence is retained for review.
{importSources.map((source) => (
diff --git a/src/components/OtterInterfaceField/index.tsx b/src/components/OtterInterfaceField/index.tsx
index 8e8bbcf..fcc1232 100644
--- a/src/components/OtterInterfaceField/index.tsx
+++ b/src/components/OtterInterfaceField/index.tsx
@@ -38,19 +38,17 @@ const OtterInterfaceField: TextFieldClientComponent = ({ field, path }) => {
useEffect(() => {
if (!scannerID) return
const abort = new AbortController()
- void fetch('/api/otter/v1/overview', { cache: 'no-store', signal: abort.signal })
+ void fetch(`/api/otter/v1/scanners/${encodeURIComponent(scannerID)}`, {
+ cache: 'no-store',
+ signal: abort.signal,
+ })
.then((response) =>
- response.ok
- ? (response.json() as Promise<{
- scanners?: Array<{ id: string; networkAdapters?: Adapter[] }>
- }>)
- : null,
+ response.ok ? (response.json() as Promise<{ networkAdapters?: Adapter[] }>) : null,
)
- .then((overview) =>
+ .then((scanner) =>
setInventory({
scannerID,
- adapters:
- overview?.scanners?.find((entry) => entry.id === scannerID)?.networkAdapters || [],
+ adapters: scanner?.networkAdapters || [],
}),
)
.catch(() => setInventory({ scannerID, adapters: [] }))
@@ -113,6 +111,10 @@ const OtterInterfaceField: TextFieldClientComponent = ({ field, path }) => {
: 'Enroll the scanner to select a reported adapter, or enter its technical name.'}
)}
+
+ Layer-2 capture and ARP/DCP use this adapter. TCP/UDP, SNMP, and other native IP probes use
+ the operating system route table; configure host routes for multi-homed scanners.
+
)
}
diff --git a/src/components/TopologyView/TopologyCanvas.tsx b/src/components/TopologyView/TopologyCanvas.tsx
index 4d37d06..02ca256 100644
--- a/src/components/TopologyView/TopologyCanvas.tsx
+++ b/src/components/TopologyView/TopologyCanvas.tsx
@@ -16,24 +16,7 @@ import {
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
-
-export type GraphNode = {
- id: string
- ipAddress?: string
- label: string
- status?: string
- type: 'asset' | 'layer2' | 'router' | 'switch'
-}
-
-export type GraphEdge = {
- count?: number
- id: string
- label?: string
- redundant?: boolean
- source: string
- target: string
- type: 'explicit' | 'layer2'
-}
+import type { GraphEdge, GraphNode } from './graph'
const NodeHandles = () => (
<>
diff --git a/src/components/TopologyView/graph.ts b/src/components/TopologyView/graph.ts
new file mode 100644
index 0000000..7702996
--- /dev/null
+++ b/src/components/TopologyView/graph.ts
@@ -0,0 +1,292 @@
+export type GraphNode = {
+ id: string
+ ipAddress?: string
+ label: string
+ status?: string
+ type: 'asset' | 'layer2' | 'router' | 'switch'
+}
+
+export type GraphEdge = {
+ count?: number
+ id: string
+ label?: string
+ redundant?: boolean
+ source: string
+ target: string
+ type: 'explicit' | 'layer2'
+}
+
+export const ipv4ToInt = (ip: string): number | null => {
+ const parts = ip.split('.')
+ if (parts.length !== 4) return null
+ let result = 0
+ for (const part of parts) {
+ const num = Number(part)
+ if (!Number.isInteger(num) || num < 0 || num > 255) return null
+ result = (result << 8) | num
+ }
+ return result >>> 0
+}
+
+export const intToIpv4 = (num: number): string =>
+ [(num >>> 24) & 255, (num >>> 16) & 255, (num >>> 8) & 255, num & 255].join('.')
+
+export const maskToPrefix = (mask: number): number => {
+ let count = 0
+ let m = mask
+ while (m & 0x80000000) {
+ count++
+ m = (m << 1) >>> 0
+ }
+ return count
+}
+
+export const computeSubnet = (
+ ipAddress: string,
+ networkMask: string,
+): { network: string; prefix: number } | null => {
+ const ip = ipv4ToInt(ipAddress)
+ const mask = ipv4ToInt(networkMask)
+ if (ip === null || mask === null) return null
+ const network = (ip & mask) >>> 0
+ return { network: intToIpv4(network), prefix: maskToPrefix(mask) }
+}
+
+export const endpointPort = (endpoint: unknown): string | undefined => {
+ if (!endpoint || typeof endpoint !== 'object') return undefined
+ const port = (endpoint as Record).portId
+ return typeof port === 'string' && port ? port : undefined
+}
+
+type AssetRecord = {
+ assetClass?: null | string
+ gatewayAddress?: null | string
+ id: string
+ ipAddress?: null | string
+ macAddress?: null | string
+ name?: null | string
+ networkMask?: null | string
+ status?: null | string
+}
+
+type LinkRecord = {
+ id: string
+ local: unknown
+ localAsset?: null | string
+ remote: unknown
+ remoteAsset?: null | string
+}
+
+type ArpRecord = {
+ asset?: null | string
+ import?: null | string
+}
+
+type ExplicitConnection = {
+ id: string
+ pair: string
+ source: string
+ sourcePort?: string
+ target: string
+ targetPort?: string
+}
+
+export const buildTopologyGraph = (
+ assetDocs: AssetRecord[],
+ linkDocs: LinkRecord[],
+ arpDocs: ArpRecord[] = [],
+): { edges: GraphEdge[]; nodes: GraphNode[] } => {
+ const assetIds = new Set(assetDocs.map((asset) => String(asset.id)))
+ const nodes: GraphNode[] = []
+ const edges: GraphEdge[] = []
+ const explicitConnections = new Map()
+ for (const link of linkDocs) {
+ const localId = link.localAsset ? String(link.localAsset) : null
+ const remoteId = link.remoteAsset ? String(link.remoteAsset) : null
+ if (!localId || !remoteId || !assetIds.has(localId) || !assetIds.has(remoteId)) continue
+ if (localId === remoteId) continue
+
+ const localPort = endpointPort(link.local)
+ const remotePort = endpointPort(link.remote)
+ const [source, sourcePort, target, targetPort] =
+ localId < remoteId
+ ? [localId, localPort, remoteId, remotePort]
+ : [remoteId, remotePort, localId, localPort]
+ const pair = JSON.stringify([source, target])
+ const key = JSON.stringify([source, sourcePort ?? null, target, targetPort ?? null])
+ const connection = {
+ id: String(link.id),
+ pair,
+ source,
+ sourcePort,
+ target,
+ targetPort,
+ }
+ const existing = explicitConnections.get(key)
+ if (!existing || connection.id.localeCompare(existing.id) < 0)
+ explicitConnections.set(key, connection)
+ }
+
+ const connectionsByPair = new Map()
+ for (const connection of explicitConnections.values()) {
+ const connections = connectionsByPair.get(connection.pair) ?? []
+ connections.push(connection)
+ connectionsByPair.set(connection.pair, connections)
+ }
+
+ const explicitNeighbors = new Map>()
+ for (const connections of connectionsByPair.values()) {
+ connections.sort((left, right) => left.id.localeCompare(right.id))
+ const [{ source, target }] = connections
+ const labels = connections.map(({ sourcePort, targetPort }) =>
+ sourcePort && targetPort
+ ? `${sourcePort} - ${targetPort}`
+ : sourcePort || targetPort || 'ports unknown',
+ )
+ const label =
+ connections.length === 1
+ ? labels[0] === 'ports unknown'
+ ? undefined
+ : labels[0]
+ : `${connections.length} links: ${labels.join(', ')}`
+ const sourceNeighbors = explicitNeighbors.get(source) ?? new Set()
+ const targetNeighbors = explicitNeighbors.get(target) ?? new Set()
+ sourceNeighbors.add(target)
+ targetNeighbors.add(source)
+ explicitNeighbors.set(source, sourceNeighbors)
+ explicitNeighbors.set(target, targetNeighbors)
+ edges.push({
+ count: connections.length,
+ id: `link-${connections[0].id}`,
+ label,
+ source,
+ target,
+ type: 'explicit',
+ })
+ }
+
+ const gatewayAddresses = new Set(
+ assetDocs.flatMap(({ gatewayAddress }) => (gatewayAddress ? [gatewayAddress] : [])),
+ )
+ for (const asset of assetDocs) {
+ const type =
+ asset.ipAddress && gatewayAddresses.has(asset.ipAddress)
+ ? 'router'
+ : asset.assetClass === 'network-device' ||
+ (explicitNeighbors.get(String(asset.id))?.size ?? 0) > 1
+ ? 'switch'
+ : 'asset'
+ nodes.push({
+ id: String(asset.id),
+ ipAddress: asset.ipAddress ?? undefined,
+ label: asset.name || asset.macAddress || String(asset.id),
+ status: asset.status ?? undefined,
+ type,
+ })
+ }
+
+ const membersByImport = new Map>()
+ for (const observation of arpDocs) {
+ const assetId = observation.asset ? String(observation.asset) : ''
+ const importId = observation.import ? String(observation.import) : ''
+ if (!assetIds.has(assetId) || !importId) continue
+ const members = membersByImport.get(importId) ?? new Set()
+ members.add(assetId)
+ membersByImport.set(importId, members)
+ }
+
+ const parent = new Map()
+ const root = (id: string): string => {
+ const next = parent.get(id)
+ if (!next || next === id) return id
+ const result = root(next)
+ parent.set(id, result)
+ return result
+ }
+ for (const members of membersByImport.values()) {
+ const [first, ...rest] = [...members].sort()
+ if (!first) continue
+ if (!parent.has(first)) parent.set(first, first)
+ for (const member of rest) {
+ if (!parent.has(member)) parent.set(member, member)
+ parent.set(root(member), root(first))
+ }
+ }
+
+ const layer2Domains = new Map()
+ for (const assetId of parent.keys()) {
+ const domain = root(assetId)
+ const members = layer2Domains.get(domain) ?? []
+ members.push(assetId)
+ layer2Domains.set(domain, members)
+ }
+
+ const assetsById = new Map(assetDocs.map((asset) => [String(asset.id), asset]))
+ const nodesById = new Map(nodes.map((node) => [node.id, node]))
+ for (const members of layer2Domains.values()) {
+ if (members.length < 2) continue
+ members.sort()
+ const subnets = new Set(
+ members.flatMap((id) => {
+ const asset = assetsById.get(id)
+ if (!asset?.ipAddress || !asset.networkMask) return []
+ const subnet = computeSubnet(asset.ipAddress, asset.networkMask)
+ return subnet ? [`${subnet.network}/${subnet.prefix}`] : []
+ }),
+ )
+ const networkId = `layer2-${members[0]}`
+ nodes.push({
+ id: networkId,
+ label: subnets.size === 1 ? `Inferred network (${[...subnets][0]})` : 'Inferred network',
+ type: 'layer2',
+ })
+
+ const memberSet = new Set(members)
+ const neighbors = new Map(members.map((member) => [member, new Set()]))
+ for (const edge of edges) {
+ if (edge.type !== 'explicit' || !memberSet.has(edge.source) || !memberSet.has(edge.target))
+ continue
+ neighbors.get(edge.source)!.add(edge.target)
+ neighbors.get(edge.target)!.add(edge.source)
+ }
+ const remaining = new Set(members)
+ const components: string[][] = []
+ while (remaining.size) {
+ const first = remaining.values().next().value as string
+ const component: string[] = []
+ const pending = [first]
+ remaining.delete(first)
+ while (pending.length) {
+ const member = pending.shift()!
+ component.push(member)
+ for (const neighbor of neighbors.get(member) ?? []) {
+ if (!remaining.delete(neighbor)) continue
+ pending.push(neighbor)
+ }
+ }
+ components.push(component)
+ }
+ const representatives = new Set()
+ if (components.length > 1) {
+ const rank: Record = { switch: 0, router: 1, asset: 2, layer2: 3 }
+ for (const component of components) {
+ component.sort((left, right) => {
+ const typeRank = rank[nodesById.get(left)!.type] - rank[nodesById.get(right)!.type]
+ return typeRank || left.localeCompare(right)
+ })
+ representatives.add(component[0])
+ }
+ }
+ for (const memberId of members) {
+ edges.push({
+ id: `${networkId}-${memberId}`,
+ redundant: !representatives.has(memberId),
+ source: networkId,
+ target: memberId,
+ type: 'layer2',
+ })
+ }
+ }
+
+ return { edges, nodes }
+}
diff --git a/src/components/TopologyView/index.tsx b/src/components/TopologyView/index.tsx
index 373fe0b..ccf1cdc 100644
--- a/src/components/TopologyView/index.tsx
+++ b/src/components/TopologyView/index.tsx
@@ -4,285 +4,10 @@ import { DefaultTemplate } from '@payloadcms/next/templates'
import { SiteSelector } from './SiteSelector'
import { TopologyCanvas } from './TopologyCanvas'
-import type { GraphEdge, GraphNode } from './TopologyCanvas'
+import { buildTopologyGraph } from './graph'
import './index.scss'
-export const ipv4ToInt = (ip: string): number | null => {
- const parts = ip.split('.')
- if (parts.length !== 4) return null
- let result = 0
- for (const part of parts) {
- const num = Number(part)
- if (!Number.isInteger(num) || num < 0 || num > 255) return null
- result = (result << 8) | num
- }
- return result >>> 0
-}
-
-export const intToIpv4 = (num: number): string =>
- [(num >>> 24) & 255, (num >>> 16) & 255, (num >>> 8) & 255, num & 255].join('.')
-
-export const maskToPrefix = (mask: number): number => {
- let count = 0
- let m = mask
- while (m & 0x80000000) {
- count++
- m = (m << 1) >>> 0
- }
- return count
-}
-
-export const computeSubnet = (
- ipAddress: string,
- networkMask: string,
-): { network: string; prefix: number } | null => {
- const ip = ipv4ToInt(ipAddress)
- const mask = ipv4ToInt(networkMask)
- if (ip === null || mask === null) return null
- const network = (ip & mask) >>> 0
- return { network: intToIpv4(network), prefix: maskToPrefix(mask) }
-}
-
-export const endpointPort = (endpoint: unknown): string | undefined => {
- if (!endpoint || typeof endpoint !== 'object') return undefined
- const port = (endpoint as Record).portId
- return typeof port === 'string' && port ? port : undefined
-}
-
-type AssetRecord = {
- assetClass?: null | string
- gatewayAddress?: null | string
- id: string
- ipAddress?: null | string
- macAddress?: null | string
- name?: null | string
- networkMask?: null | string
- status?: null | string
-}
-
-type LinkRecord = {
- id: string
- local: unknown
- localAsset?: null | string
- remote: unknown
- remoteAsset?: null | string
-}
-
-type ArpRecord = {
- asset?: null | string
- import?: null | string
-}
-
-type ExplicitConnection = {
- id: string
- pair: string
- source: string
- sourcePort?: string
- target: string
- targetPort?: string
-}
-
-export const buildTopologyGraph = (
- assetDocs: AssetRecord[],
- linkDocs: LinkRecord[],
- arpDocs: ArpRecord[] = [],
-): { edges: GraphEdge[]; nodes: GraphNode[] } => {
- const assetIds = new Set(assetDocs.map((asset) => String(asset.id)))
- const nodes: GraphNode[] = []
- const edges: GraphEdge[] = []
- const explicitConnections = new Map()
- for (const link of linkDocs) {
- const localId = link.localAsset ? String(link.localAsset) : null
- const remoteId = link.remoteAsset ? String(link.remoteAsset) : null
- if (!localId || !remoteId || !assetIds.has(localId) || !assetIds.has(remoteId)) continue
- if (localId === remoteId) continue
-
- const localPort = endpointPort(link.local)
- const remotePort = endpointPort(link.remote)
- const [source, sourcePort, target, targetPort] =
- localId < remoteId
- ? [localId, localPort, remoteId, remotePort]
- : [remoteId, remotePort, localId, localPort]
- const pair = JSON.stringify([source, target])
- const key = JSON.stringify([source, sourcePort ?? null, target, targetPort ?? null])
- const connection = {
- id: String(link.id),
- pair,
- source,
- sourcePort,
- target,
- targetPort,
- }
- const existing = explicitConnections.get(key)
- if (!existing || connection.id.localeCompare(existing.id) < 0)
- explicitConnections.set(key, connection)
- }
-
- const connectionsByPair = new Map()
- for (const connection of explicitConnections.values()) {
- const connections = connectionsByPair.get(connection.pair) ?? []
- connections.push(connection)
- connectionsByPair.set(connection.pair, connections)
- }
-
- const explicitNeighbors = new Map>()
- for (const connections of connectionsByPair.values()) {
- connections.sort((left, right) => left.id.localeCompare(right.id))
- const [{ source, target }] = connections
- const labels = connections.map(({ sourcePort, targetPort }) =>
- sourcePort && targetPort
- ? `${sourcePort} - ${targetPort}`
- : sourcePort || targetPort || 'ports unknown',
- )
- const label =
- connections.length === 1
- ? labels[0] === 'ports unknown'
- ? undefined
- : labels[0]
- : `${connections.length} links: ${labels.join(', ')}`
- const sourceNeighbors = explicitNeighbors.get(source) ?? new Set()
- const targetNeighbors = explicitNeighbors.get(target) ?? new Set()
- sourceNeighbors.add(target)
- targetNeighbors.add(source)
- explicitNeighbors.set(source, sourceNeighbors)
- explicitNeighbors.set(target, targetNeighbors)
- edges.push({
- count: connections.length,
- id: `link-${connections[0].id}`,
- label,
- source,
- target,
- type: 'explicit',
- })
- }
-
- const gatewayAddresses = new Set(
- assetDocs.flatMap(({ gatewayAddress }) => (gatewayAddress ? [gatewayAddress] : [])),
- )
- for (const asset of assetDocs) {
- const type =
- asset.ipAddress && gatewayAddresses.has(asset.ipAddress)
- ? 'router'
- : asset.assetClass === 'network-device' ||
- (explicitNeighbors.get(String(asset.id))?.size ?? 0) > 1
- ? 'switch'
- : 'asset'
- nodes.push({
- id: String(asset.id),
- ipAddress: asset.ipAddress ?? undefined,
- label: asset.name || asset.macAddress || String(asset.id),
- status: asset.status ?? undefined,
- type,
- })
- }
-
- const membersByImport = new Map>()
- for (const observation of arpDocs) {
- const assetId = observation.asset ? String(observation.asset) : ''
- const importId = observation.import ? String(observation.import) : ''
- if (!assetIds.has(assetId) || !importId) continue
- const members = membersByImport.get(importId) ?? new Set()
- members.add(assetId)
- membersByImport.set(importId, members)
- }
-
- const parent = new Map()
- const root = (id: string): string => {
- const next = parent.get(id)
- if (!next || next === id) return id
- const result = root(next)
- parent.set(id, result)
- return result
- }
- for (const members of membersByImport.values()) {
- const [first, ...rest] = [...members].sort()
- if (!first) continue
- if (!parent.has(first)) parent.set(first, first)
- for (const member of rest) {
- if (!parent.has(member)) parent.set(member, member)
- parent.set(root(member), root(first))
- }
- }
-
- const layer2Domains = new Map()
- for (const assetId of parent.keys()) {
- const domain = root(assetId)
- const members = layer2Domains.get(domain) ?? []
- members.push(assetId)
- layer2Domains.set(domain, members)
- }
-
- const assetsById = new Map(assetDocs.map((asset) => [String(asset.id), asset]))
- const nodesById = new Map(nodes.map((node) => [node.id, node]))
- for (const members of layer2Domains.values()) {
- if (members.length < 2) continue
- members.sort()
- const subnets = new Set(
- members.flatMap((id) => {
- const asset = assetsById.get(id)
- if (!asset?.ipAddress || !asset.networkMask) return []
- const subnet = computeSubnet(asset.ipAddress, asset.networkMask)
- return subnet ? [`${subnet.network}/${subnet.prefix}`] : []
- }),
- )
- const networkId = `layer2-${members[0]}`
- nodes.push({
- id: networkId,
- label: subnets.size === 1 ? `Inferred network (${[...subnets][0]})` : 'Inferred network',
- type: 'layer2',
- })
-
- const memberSet = new Set(members)
- const neighbors = new Map(members.map((member) => [member, new Set()]))
- for (const edge of edges) {
- if (edge.type !== 'explicit' || !memberSet.has(edge.source) || !memberSet.has(edge.target))
- continue
- neighbors.get(edge.source)!.add(edge.target)
- neighbors.get(edge.target)!.add(edge.source)
- }
- const remaining = new Set(members)
- const components: string[][] = []
- while (remaining.size) {
- const first = remaining.values().next().value as string
- const component: string[] = []
- const pending = [first]
- remaining.delete(first)
- while (pending.length) {
- const member = pending.shift()!
- component.push(member)
- for (const neighbor of neighbors.get(member) ?? []) {
- if (!remaining.delete(neighbor)) continue
- pending.push(neighbor)
- }
- }
- components.push(component)
- }
- const representatives = new Set()
- if (components.length > 1) {
- const rank: Record = { switch: 0, router: 1, asset: 2, layer2: 3 }
- for (const component of components) {
- component.sort((left, right) => {
- const typeRank = rank[nodesById.get(left)!.type] - rank[nodesById.get(right)!.type]
- return typeRank || left.localeCompare(right)
- })
- representatives.add(component[0])
- }
- }
- for (const memberId of members) {
- edges.push({
- id: `${networkId}-${memberId}`,
- redundant: !representatives.has(memberId),
- source: networkId,
- target: memberId,
- type: 'layer2',
- })
- }
- }
-
- return { edges, nodes }
-}
-
const TopologyView = async (props: AdminViewServerProps) => {
const { payload } = props
const user = props.user ?? props.initPageResult?.req?.user
diff --git a/src/identity/actions.ts b/src/identity/actions.ts
index 58dd49c..ef4a441 100644
--- a/src/identity/actions.ts
+++ b/src/identity/actions.ts
@@ -389,11 +389,20 @@ export const performIdentityAction = async (
...(target ? { relatedAsset: target.id } : {}),
selection: input,
}
- await writeAudit({ action: 'custom', after: event, req, targetCollection: 'assets' })
+ await writeAudit({
+ action: 'custom',
+ after: event,
+ assetID: id,
+ siteID: idOf(source.site),
+ req,
+ targetCollection: 'assets',
+ })
if (target)
await writeAudit({
action: 'custom',
after: { ...event, id: target.id, asset: target.id, relatedAsset: id },
+ assetID: target.id,
+ siteID: idOf(target.site),
req,
targetCollection: 'assets',
})
diff --git a/src/identity/evidence.ts b/src/identity/evidence.ts
index 9374f9e..beaf029 100644
--- a/src/identity/evidence.ts
+++ b/src/identity/evidence.ts
@@ -21,19 +21,96 @@ export type ServiceEvidence = {
const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
+const snmpScope = (value: string): HardwareIdentity['scope'] | undefined =>
+ value === 'chassis' || value === 'stack'
+ ? 'chassis'
+ : value === 'module' || value === 'cpu'
+ ? value
+ : undefined
+
+const subjectMatches = (asset: ImportedAsset | undefined, observation: ImportedObservation) => {
+ const subject = record(record(observation.raw).subject)
+ const fields = observation.mergeFields || observation.fields
+ const macAddress = text(asset?.macAddress) || text(fields.macAddress)
+ if (subject.kind === 'device') {
+ const subjectMAC = text(subject.macAddress)
+ return Boolean(
+ macAddress &&
+ subjectMAC &&
+ validateMACAddress(subjectMAC) === true &&
+ normalizeMAC(subjectMAC) === normalizeMAC(macAddress),
+ )
+ }
+ const ipAddress = text(subject.ipAddress)
+ const observationID = text(record(observation.raw).observationId)
+ return Boolean(
+ !macAddress &&
+ /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(observationID) &&
+ isIP(ipAddress) !== 0 &&
+ ipAddress === text(fields.ipAddress),
+ )
+}
+
+const snmpEntityIdentity = (
+ asset: ImportedAsset | undefined,
+ observation: ImportedObservation,
+ index: string,
+ entityClass: string,
+ manufacturer: string,
+ serial: string,
+): HardwareIdentity | undefined => {
+ const raw = record(observation.raw)
+ const ref = `entity:${index}`
+ const entity = array(raw.physicalEntities)
+ .map(record)
+ .find(
+ (item) =>
+ text(item.ref) === ref &&
+ text(item.entPhysicalIndex) === index &&
+ text(item.class) === entityClass,
+ )
+ const claim = record(entity?.serialClaim)
+ const scope = snmpScope(entityClass)
+ if (
+ !entity ||
+ !scope ||
+ !subjectMatches(asset, observation) ||
+ !manufacturer ||
+ !serial ||
+ claim.scope !== entityClass ||
+ claim.componentRef !== ref ||
+ claim.issuer !== manufacturer ||
+ claim.original !== serial
+ )
+ return undefined
+ try {
+ return normalizeIdentity({ authority: 'entity-mib', manufacturer, scope, serial })
+ } catch {
+ return undefined
+ }
+}
+
/** Only source-specific identity layouts that the pinned v2 scanner actually emits. */
export const observationIdentity = (
observation: ImportedObservation,
+ asset?: ImportedAsset,
): HardwareIdentity | undefined => {
const raw = record(observation.raw)
const fields = observation.mergeFields || observation.fields
const serial = text(fields.serialNumber)
+ const claim = record(raw.serialClaim)
let identity: HardwareIdentity | undefined
if (
observation.source === 'ethernet-ip' &&
Number.isInteger(raw.vendorId) &&
Number.isInteger(raw.deviceType) &&
- serial === text(raw.serialNumber)
+ serial === text(raw.serialNumber) &&
+ subjectMatches(asset, observation) &&
+ claim.scope ===
+ (raw.deviceType === 12 ? 'adapter' : raw.deviceType === 14 ? 'cpu' : 'device') &&
+ Number(claim.manufacturerId) === raw.vendorId &&
+ text(claim.original) === serial &&
+ (!text(claim.issuer) || text(claim.issuer) === text(fields.vendor))
) {
identity = {
authority: 'cip',
@@ -41,40 +118,16 @@ export const observationIdentity = (
scope: raw.deviceType === 12 ? 'adapter' : raw.deviceType === 14 ? 'cpu' : 'device',
serial,
}
- } else if (observation.source === 's7' && text(raw.module) && serial) {
+ } else if (
+ observation.source === 's7' &&
+ text(raw.module) &&
+ serial &&
+ subjectMatches(asset, observation) &&
+ claim.scope === 'cpu' &&
+ text(claim.issuer).toLowerCase() === 'siemens' &&
+ text(claim.original) === serial
+ ) {
identity = { authority: 'siemens', manufacturer: 'siemens', scope: 'cpu', serial }
- } else if (observation.source === 'profinet-dcp') {
- const pnio = array(raw.pnioRecords)
- .map(record)
- .map((item) => record(item.parsed))
- const claims = pnio.flatMap((parsed) => [
- ...(text(parsed.manufacturerId) && text(parsed.serialNumber)
- ? [{ vendor: parsed.manufacturerId, serial: text(parsed.serialNumber) }]
- : []),
- ...array(parsed.im5Data)
- .map(record)
- .flatMap((im) =>
- text(im.vendorId) && text(im.imSerialNumber)
- ? [{ vendor: im.vendorId, serial: text(im.imSerialNumber) }]
- : [],
- ),
- ])
- const match = claims.find(({ serial: value }) => value === serial)
- const manufacturer = match ? Number.parseInt(text(match.vendor), 16) : NaN
- if (
- match &&
- serial &&
- Number.isInteger(manufacturer) &&
- manufacturer >= 1 &&
- manufacturer <= 65535
- ) {
- identity = {
- authority: 'profinet',
- manufacturer: String(manufacturer),
- scope: 'device',
- serial,
- }
- }
}
if (!identity) return undefined
try {
@@ -210,7 +263,7 @@ export const serviceEvidence = (device: Record): ServiceEvidenc
export const expandPhysicalEvidence = (asset: ImportedAsset): ImportedAsset[] => {
const observations = asset.observations || []
const claims = observations.flatMap((observation) => {
- const identity = observationIdentity(observation)
+ const identity = observationIdentity(observation, asset)
return identity ? [{ identity, observation }] : []
})
const root = claims.find(({ observation }) => observation.source === 'ethernet-ip') || claims[0]
@@ -235,6 +288,7 @@ export const expandPhysicalEvidence = (asset: ImportedAsset): ImportedAsset[] =>
const entityRoot = '1.3.6.1.2.1.47.1.1.1.1.'
for (const observation of observations.filter(({ source }) => source === 'snmp')) {
const raw = record(observation.raw)
+ const physicalEntities = array(raw.physicalEntities).map(record)
const indexes = [
...new Set(
Object.keys(raw).flatMap((key) =>
@@ -244,18 +298,22 @@ export const expandPhysicalEvidence = (asset: ImportedAsset): ImportedAsset[] =>
]
const entities = indexes.flatMap((index) => {
const value = (column: number) => raw[`${entityRoot}${column}.${index}`]
- if (![3, 9].includes(Number(value(5)))) return []
- const identity: HardwareIdentity = {
- authority: 'entity-mib',
- manufacturer: text(value(12)),
- scope: Number(value(5)) === 3 ? 'chassis' : 'module',
- serial: text(value(11)),
- }
- try {
- normalizeIdentity(identity)
- } catch {
- return []
- }
+ const className = new Map([
+ [3, 'chassis'],
+ [9, 'module'],
+ [11, 'stack'],
+ [12, 'cpu'],
+ ]).get(Number(value(5)))
+ if (!className) return []
+ const identity = snmpEntityIdentity(
+ asset,
+ observation,
+ index,
+ className,
+ text(value(12)),
+ text(value(11)),
+ )
+ if (!identity) return []
return [
{
index,
@@ -283,16 +341,28 @@ export const expandPhysicalEvidence = (asset: ImportedAsset): ImportedAsset[] =>
: item,
)
}
+ const roots = physicalEntities.filter(
+ (entity) => !text(entity.parentRef) && ['chassis', 'stack'].includes(text(entity.class)),
+ )
+ const selectedRoot = roots.length === 1 ? roots[0] : undefined
+ const selectedRootClaim = record(selectedRoot?.serialClaim)
+ const topLevelClaim = record(raw.serialClaim)
+ const selectedRootEntity = entities.find(
+ ({ index, identity, parent }) =>
+ selectedRoot &&
+ text(selectedRoot.entPhysicalIndex) === index &&
+ parent === '0' &&
+ identity.scope === 'chassis' &&
+ text(raw.identityComponentRef) === text(selectedRoot.ref) &&
+ topLevelClaim.scope === selectedRootClaim.scope &&
+ topLevelClaim.componentRef === selectedRootClaim.componentRef &&
+ topLevelClaim.issuer === selectedRootClaim.issuer &&
+ topLevelClaim.original === selectedRootClaim.original,
+ )
for (const entity of entities) {
- const componentRef = `${asset.macAddress}:entity:${entity.index}`
+ const componentRef = `${asset.macAddress || text(raw.observationId)}:entity:${entity.index}`
const parent = entities.find(({ index }) => index === entity.parent)
- // Preserve the root chassis as its own record too when another protocol identified an adapter.
- if (
- !root &&
- entity.identity.scope === 'chassis' &&
- entity.parent === '0' &&
- entities.filter((item) => item.parent === '0').length === 1
- ) {
+ if (!root && selectedRootEntity?.index === entity.index) {
result[0].identity = entity.identity
result[0].componentRef = componentRef
result[0].observations = observations.map((item) =>
@@ -310,7 +380,11 @@ export const expandPhysicalEvidence = (asset: ImportedAsset): ImportedAsset[] =>
name: entity.fields.model || entity.identity.serial,
identity: entity.identity,
componentRef,
- ...(parent ? { parentComponentRef: `${asset.macAddress}:entity:${parent.index}` } : {}),
+ ...(parent
+ ? {
+ parentComponentRef: `${asset.macAddress || text(raw.observationId)}:entity:${parent.index}`,
+ }
+ : {}),
...(Number.isInteger(entity.position) && Number(entity.position) >= 0
? { slotPath: String(entity.position) }
: {}),
diff --git a/src/identity/reconcile.ts b/src/identity/reconcile.ts
index 2b4d643..e2cf891 100644
--- a/src/identity/reconcile.ts
+++ b/src/identity/reconcile.ts
@@ -408,6 +408,8 @@ export const bindImportedIdentity = async (
endpoint: result.id,
observedAt,
},
+ assetID: String(asset.id),
+ siteID: site,
req,
targetCollection: 'network-endpoints',
})
diff --git a/src/identity/relationships.ts b/src/identity/relationships.ts
index c3f50b1..617609d 100644
--- a/src/identity/relationships.ts
+++ b/src/identity/relationships.ts
@@ -321,31 +321,36 @@ export const validateInstallation: CollectionBeforeChangeHook = async ({
}
export const assetHistoryScope = async (assetID: string, payload: Payload, user?: TypedUser) => {
+ const limit = 200
const ids = new Set([assetID])
let pending = [assetID]
- while (pending.length) {
+ let truncated = false
+ while (pending.length && ids.size < limit) {
const aliases = await payload.find({
collection: 'assets',
depth: 0,
- pagination: false,
+ limit: Math.max(1, limit - ids.size),
overrideAccess: false,
user,
where: { mergedInto: { in: pending } },
})
+ truncated ||= aliases.hasNextPage
pending = aliases.docs.map(({ id }) => id).filter((id) => !ids.has(id))
for (const id of pending) ids.add(id)
}
+ if (pending.length) truncated = true
const endpoints = await payload.find({
collection: 'network-endpoints',
depth: 0,
- pagination: false,
+ limit,
overrideAccess: false,
user,
where: { asset: { in: [...ids] } },
})
+ truncated ||= endpoints.hasNextPage
const assets: Where = { asset: { in: [...ids] } }
const observations: Where = {
or: [assets, { endpoint: { in: endpoints.docs.map(({ id }) => id) } }],
}
- return { assets, observations }
+ return { assets, observations, truncated }
}
diff --git a/src/importers/otserverOtter.ts b/src/importers/otserverOtter.ts
index 68662d4..583ab49 100644
--- a/src/importers/otserverOtter.ts
+++ b/src/importers/otserverOtter.ts
@@ -10,8 +10,12 @@ import {
} from './types'
import { normalizeMAC } from '../domain/network'
import { endpointEvidence, expandPhysicalEvidence, serviceEvidence } from '../identity/evidence'
+import { text } from '../identity/keys'
const macPattern = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
+const uuidPattern = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i
+const dateTimePattern =
+ /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/
const sources: Record = {
arp: 'medium',
bacnet: 'medium',
@@ -63,6 +67,12 @@ const record = (value: unknown): Record =>
: {}
const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
+const isRecord = (value: unknown): value is Record =>
+ Boolean(value && typeof value === 'object' && !Array.isArray(value))
+const isDateTime = (value: unknown): value is string =>
+ typeof value === 'string' && dateTimePattern.test(value) && !Number.isNaN(Date.parse(value))
+const isStringList = (value: unknown): value is string[] =>
+ Array.isArray(value) && value.every((item) => typeof item === 'string')
const rejectSecrets = (value: unknown) => {
const pending: { path: string; value: unknown }[] = [{ path: '$', value }]
@@ -153,37 +163,91 @@ export const parseOTserverOtter = (input: string): ImportResult => {
}
const scanner = record(root.scanner)
const scan = record(root.scan)
- if (typeof scanner.version !== 'string' || typeof scan.id !== 'string') {
+ if (
+ typeof scanner.name !== 'string' ||
+ !scanner.name.trim() ||
+ typeof scanner.version !== 'string' ||
+ !scanner.version.trim() ||
+ typeof scan.id !== 'string' ||
+ !uuidPattern.test(scan.id)
+ ) {
throw new Error('Otter and scan metadata are required.')
}
+ const selectedInterface = record(scan.interface)
+ if (
+ !isDateTime(scan.startedAt) ||
+ !isDateTime(scan.finishedAt) ||
+ !Array.isArray(scan.targets) ||
+ !scan.targets.length ||
+ scan.targets.some((target) => typeof target !== 'string' || !target.trim()) ||
+ typeof selectedInterface.id !== 'string' ||
+ !selectedInterface.id.trim() ||
+ typeof selectedInterface.name !== 'string' ||
+ !selectedInterface.name.trim() ||
+ (selectedInterface.addresses !== undefined && !isStringList(selectedInterface.addresses))
+ )
+ throw new Error('Otter scan metadata is invalid.')
if (
!Array.isArray(root.devices) ||
- (root.links !== undefined && !Array.isArray(root.links)) ||
- (root.unresolved !== undefined && !Array.isArray(root.unresolved))
+ !Array.isArray(root.links) ||
+ !Array.isArray(root.unresolved) ||
+ !isStringList(root.warnings) ||
+ !isStringList(root.errors)
)
- throw new Error('Otter devices, links, and unresolved observations must be arrays.')
+ throw new Error(
+ 'Otter devices, links, unresolved observations, warnings, and errors are invalid.',
+ )
const identities = new Set()
const assets = array(root.devices).flatMap((entry, index): ImportedAsset[] => {
const device = record(entry)
const identity = mac(device.macAddress, `devices[${index}].macAddress`)
+ if (
+ !isStringList(device.macAddresses) ||
+ !isStringList(device.ipAddresses) ||
+ !Array.isArray(device.observations) ||
+ !Array.isArray(device.interfaces) ||
+ !Array.isArray(device.ports)
+ )
+ throw new Error(`devices[${index}] is missing required arrays.`)
+ for (const [macIndex, value] of device.macAddresses.entries())
+ mac(value, `devices[${index}].macAddresses[${macIndex}]`)
+ for (const [field, values] of [
+ ['interfaces', device.interfaces],
+ ['ports', device.ports],
+ ] as const)
+ values.forEach((item, itemIndex) => {
+ const value = record(item)
+ if (
+ !isRecord(item) ||
+ typeof value.key !== 'string' ||
+ !value.key.trim() ||
+ typeof value.source !== 'string' ||
+ !value.source.trim()
+ )
+ throw new Error(`devices[${index}].${field}[${itemIndex}] is invalid.`)
+ })
if (identities.has(identity)) throw new Error(`Duplicate device MAC address: ${identity}.`)
identities.add(identity)
const observations = array(device.observations).map(
(entry, observationIndex): ImportedObservation => {
const observation = record(entry)
- const source =
- typeof observation.source === 'string' && observation.source in sources
- ? observation.source
- : 'unknown'
if (
- typeof observation.observedAt !== 'string' ||
- Number.isNaN(Date.parse(observation.observedAt))
- ) {
+ !isRecord(entry) ||
+ typeof observation.source !== 'string' ||
+ !isRecord(observation.fields) ||
+ !Object.hasOwn(observation, 'raw') ||
+ !isStringList(observation.warnings)
+ )
+ throw new Error(`devices[${index}].observations[${observationIndex}] is invalid.`)
+ if (!isDateTime(observation.observedAt))
throw new Error(
`devices[${index}].observations[${observationIndex}].observedAt is invalid.`,
)
- }
+ const source =
+ typeof observation.source === 'string' && observation.source in sources
+ ? observation.source
+ : 'unknown'
const originalFields = record(observation.fields)
const fields = { ...originalFields }
const mergeFields = safeFields(originalFields, identity)
@@ -194,14 +258,27 @@ export const parseOTserverOtter = (input: string): ImportResult => {
if (fields.macAddress === undefined) fields.macAddress = identity
const raw = record(observation.raw)
- const serialClaim = record(raw.serialClaim)
+ const serialClaim = source === 'profinet-dcp' ? {} : record(raw.serialClaim)
+ const physicalEntities = array(raw.physicalEntities).map(record)
+ const snmpRoots = physicalEntities.filter(
+ (entity) => !text(entity.parentRef) && ['chassis', 'stack'].includes(text(entity.class)),
+ )
+ const legacySnmpRoots = physicalEntities.filter(
+ (entity) => !text(entity.parentRef) && entity.class === undefined,
+ )
const primaryEntity =
source === 'snmp'
- ? array(raw.physicalEntities)
- .map(record)
- .find((entity) => !entity.parentRef)
+ ? snmpRoots.length === 1
+ ? snmpRoots[0]
+ : snmpRoots.length === 0 && legacySnmpRoots.length === 1
+ ? legacySnmpRoots[0]
+ : undefined
: undefined
const primarySerialClaim = record(primaryEntity?.serialClaim)
+ if (source === 'profinet-dcp' || source === 'snmp') {
+ delete fields.serialNumber
+ delete mergeFields.serialNumber
+ }
addTextField(
fields,
mergeFields,
@@ -214,7 +291,7 @@ export const parseOTserverOtter = (input: string): ImportResult => {
fields,
mergeFields,
'serialNumber',
- serialClaim.original,
+ source === 'snmp' ? undefined : serialClaim.original,
primarySerialClaim.original,
)
addTextField(fields, mergeFields, 'name', primaryEntity?.name)
@@ -228,56 +305,41 @@ export const parseOTserverOtter = (input: string): ImportResult => {
source === 's7' ? raw.basicHardware : undefined,
serialClaim.catalogNumber,
primaryEntity?.catalogNumber,
- source === 'snmp' ? raw['1.3.6.1.4.1.4329.6.3.2.1.1.2.0'] : undefined,
+ source === 'snmp' && primaryEntity ? raw['1.3.6.1.4.1.4329.6.3.2.1.1.2.0'] : undefined,
)
addTextField(
fields,
mergeFields,
'hardwareVersion',
primaryEntity?.hardwareRevision,
- source === 'snmp' ? raw['1.3.6.1.4.1.4329.6.3.2.1.1.4.0'] : undefined,
+ source === 'snmp' && primaryEntity ? raw['1.3.6.1.4.1.4329.6.3.2.1.1.4.0'] : undefined,
)
addTextField(fields, mergeFields, 'firmwareVersion', primaryEntity?.firmwareRevision)
if (source === 'arp') addTextField(fields, mergeFields, 'vendor', raw.ouiVendor)
if (source === 'profinet-dcp') {
- const pnio = array(raw.pnioRecords).map((item) => record(record(item).parsed))
- const imRecords = pnio.flatMap((parsed) => array(parsed.im5Data).map(record))
+ const primary = array(raw.pnioRecords)
+ .map(record)
+ .find(
+ (item) =>
+ Number(item.api) === 0 &&
+ Number(item.slot) === 0 &&
+ Number(item.subslot) === 1 &&
+ text(item.index).toUpperCase() === '0xAFF0',
+ )
+ const parsed = record(primary?.parsed)
addTextField(
fields,
mergeFields,
'vendor',
originalFields.manufacturer,
- ...pnio.map((item) => item.manufacturerName),
- ...imRecords.map((item) => item.vendorName),
- )
- addTextField(
- fields,
- mergeFields,
- 'serialNumber',
- ...pnio.map((item) => item.serialNumber),
- ...imRecords.map((item) => item.imSerialNumber),
- )
- addTextField(
- fields,
- mergeFields,
- 'catalogNumber',
- ...pnio.map((item) => item.orderId),
- ...imRecords.map((item) => item.imOrderId),
- )
- addTextField(
- fields,
- mergeFields,
- 'hardwareVersion',
- ...pnio.map((item) => item.hardwareRevision),
- ...imRecords.map((item) => item.imHardwareRevision),
+ parsed.manufacturerName,
)
addTextField(
fields,
mergeFields,
'firmwareVersion',
originalFields.softwareRevision,
- ...pnio.map((item) => item.softwareRevision),
- ...imRecords.map((item) => item.imSoftwareRevision),
+ parsed.softwareRevision,
)
}
return {
@@ -289,9 +351,7 @@ export const parseOTserverOtter = (input: string): ImportResult => {
quality: sources[source],
raw: observation.raw,
source,
- warnings: array(observation.warnings).filter(
- (item): item is string => typeof item === 'string',
- ),
+ warnings: observation.warnings as string[],
}
},
)
@@ -311,12 +371,18 @@ export const parseOTserverOtter = (input: string): ImportResult => {
})
const unresolved: unknown[] = []
- for (const entry of array(root.unresolved)) {
+ for (const [index, entry] of array(root.unresolved).entries()) {
const value = record(entry)
- if (typeof value.observedAt !== 'string' || Number.isNaN(Date.parse(value.observedAt))) {
- unresolved.push(entry)
- continue
- }
+ if (
+ !isRecord(entry) ||
+ typeof value.source !== 'string' ||
+ !isRecord(value.fields) ||
+ !Object.hasOwn(value, 'raw') ||
+ !isStringList(value.warnings)
+ )
+ throw new Error(`unresolved[${index}] is invalid.`)
+ if (!isDateTime(value.observedAt))
+ throw new Error(`unresolved[${index}].observedAt is invalid.`)
const source =
typeof value.source === 'string' && value.source in sources ? value.source : 'unknown'
const evidence = expandPhysicalEvidence({
@@ -328,9 +394,7 @@ export const parseOTserverOtter = (input: string): ImportResult => {
quality: sources[source],
source,
raw: value.raw,
- warnings: array(value.warnings).filter(
- (item): item is string => typeof item === 'string',
- ),
+ warnings: value.warnings as string[],
},
],
}).filter((asset) => asset.identity)
@@ -344,14 +408,21 @@ export const parseOTserverOtter = (input: string): ImportResult => {
const remote = record(link.remote)
local.macAddress = mac(local.macAddress, `links[${index}].local.macAddress`)
remote.macAddress = mac(remote.macAddress, `links[${index}].remote.macAddress`)
- if (typeof link.observedAt !== 'string' || Number.isNaN(Date.parse(link.observedAt)))
- throw new Error(`links[${index}].observedAt is invalid.`)
+ if (
+ !isRecord(entry) ||
+ typeof link.source !== 'string' ||
+ !isRecord(link.local) ||
+ !isRecord(link.remote) ||
+ !Object.hasOwn(link, 'raw')
+ )
+ throw new Error(`links[${index}] is invalid.`)
+ if (!isDateTime(link.observedAt)) throw new Error(`links[${index}].observedAt is invalid.`)
return {
local,
observedAt: link.observedAt,
raw: link.raw,
remote,
- source: typeof link.source === 'string' ? link.source : 'unknown',
+ source: ['lldp', 'profinet-dcp', 'snmp'].includes(link.source) ? link.source : 'unknown',
}
})
return {
@@ -360,8 +431,6 @@ export const parseOTserverOtter = (input: string): ImportResult => {
scanMetadata: { scan, scanner },
sourceVersion: scanner.version,
unresolved,
- warnings: [...array(root.warnings), ...array(root.errors)].filter(
- (item): item is string => typeof item === 'string',
- ),
+ warnings: [...(root.warnings as string[]), ...(root.errors as string[])],
}
}
diff --git a/src/migrations/20261013_130000_legacy_identity.ts b/src/migrations/20261013_130000_legacy_identity.ts
new file mode 100644
index 0000000..bc5c431
--- /dev/null
+++ b/src/migrations/20261013_130000_legacy_identity.ts
@@ -0,0 +1,144 @@
+import type { MigrateUpArgs } from '@payloadcms/db-mongodb'
+import { ensureAssetClass } from '../collections/AssetClasses'
+import { getAuditChanges } from '../collections/AuditLogs'
+import { ensureAdminRole } from '../collections/UserRoles'
+
+type LegacyAsset = {
+ _id: unknown
+ assetType?: unknown
+ fieldProvenance?: unknown
+ name?: unknown
+ site?: unknown
+}
+type LegacyUser = { _id: unknown; email?: unknown; name?: unknown }
+
+const writeMigrationAudit = async (
+ { payload, req, session }: MigrateUpArgs,
+ {
+ after,
+ assetID,
+ before,
+ siteID,
+ targetCollection,
+ }: {
+ after: Record
+ assetID?: string
+ before: Record
+ siteID?: string
+ targetCollection: string
+ },
+) => {
+ const ObjectId = payload.db.connection.base.Types.ObjectId
+ const id = String(after.id)
+ const label = [after.name, after.title, after.label, after.email, after.filename].find(
+ (value) => typeof value === 'string' && value,
+ ) as string | undefined
+ const now = new Date()
+ const ipAddress = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
+ await payload.db.collections['audit-logs'].collection.insertOne(
+ {
+ _id: new ObjectId(),
+ action: 'update',
+ ...(siteID ? { site: new ObjectId(siteID) } : {}),
+ summary: `update ${targetCollection}${label ? `: ${label}` : ` ${id}`}`,
+ targetCollection,
+ documentID: id,
+ ...(label ? { documentLabel: label } : {}),
+ ...(assetID ? { asset: new ObjectId(assetID), assetID } : {}),
+ actorType: 'system',
+ actorName: 'Versioned migration',
+ ...(ipAddress ? { ipAddress } : {}),
+ requestMethod: req.method,
+ ...(req.url ? { requestPath: new URL(req.url, 'http://local').pathname } : {}),
+ changes: getAuditChanges(before, after),
+ createdAt: now,
+ updatedAt: now,
+ },
+ { session },
+ )
+}
+
+export async function up({ payload, req, session }: MigrateUpArgs): Promise {
+ const adminRole = await ensureAdminRole(payload, req)
+ const users = (await payload.db.collections.users.collection
+ .find({ role: { $exists: false } }, { projection: { _id: 1, email: 1, name: 1 }, session })
+ .toArray()) as LegacyUser[]
+ // Migration writes use the active session directly and record audit entries in the same transaction.
+ for (const user of users) {
+ const id = String(user._id)
+ const updated = await payload.db.collections.users.collection.updateOne(
+ { _id: user._id as never, role: { $exists: false } },
+ {
+ $set: {
+ role: new payload.db.connection.base.Types.ObjectId(adminRole.id),
+ },
+ },
+ { session },
+ )
+ if (updated.modifiedCount)
+ await writeMigrationAudit(
+ { payload, req, session },
+ {
+ after: { email: user.email, id, name: user.name, role: adminRole.id },
+ before: { email: user.email, id, name: user.name },
+ targetCollection: 'users',
+ },
+ )
+ }
+
+ const assets = (await payload.db.collections.assets.collection
+ .find(
+ { assetClass: { $exists: false } },
+ { projection: { _id: 1, assetType: 1, fieldProvenance: 1, name: 1, site: 1 }, session },
+ )
+ .toArray()) as LegacyAsset[]
+ for (const asset of assets) {
+ const key = typeof asset.assetType === 'string' ? asset.assetType : 'other'
+ const assetClass = await ensureAssetClass(payload, key, req)
+ const existing = asset.fieldProvenance
+ const fieldProvenance: Record =
+ existing && typeof existing === 'object' && !Array.isArray(existing)
+ ? { ...(existing as Record) }
+ : {}
+ if (fieldProvenance.assetType && !fieldProvenance.assetClass)
+ fieldProvenance.assetClass = fieldProvenance.assetType
+ delete fieldProvenance.assetType
+
+ const id = String(asset._id)
+ const siteID = String(asset.site || '')
+ const before = {
+ id,
+ name: asset.name,
+ assetType: asset.assetType,
+ fieldProvenance: existing,
+ }
+ const updated = await payload.db.collections.assets.collection.updateOne(
+ { _id: asset._id as never, assetClass: { $exists: false } },
+ {
+ $set: {
+ assetClass: new payload.db.connection.base.Types.ObjectId(assetClass.id),
+ assetType: null,
+ fieldProvenance,
+ },
+ },
+ { session },
+ )
+ if (updated.modifiedCount)
+ await writeMigrationAudit(
+ { payload, req, session },
+ {
+ after: { ...before, assetClass: assetClass.id, assetType: null, fieldProvenance },
+ before,
+ assetID: id,
+ siteID,
+ targetCollection: 'assets',
+ },
+ )
+ }
+}
+
+export async function down(): Promise {
+ throw new Error(
+ 'Legacy asset classification and Admin-role migration is not reversible; restore a database backup to roll back.',
+ )
+}
diff --git a/src/migrations/index.ts b/src/migrations/index.ts
index 981d6df..3c9fd05 100644
--- a/src/migrations/index.ts
+++ b/src/migrations/index.ts
@@ -1,6 +1,7 @@
import * as migration_20260922_205151_queued_import_baseline from './20260922_205151_queued_import_baseline'
import * as migration_20260926_111902_site_paths from './20260926_111902_site_paths'
import * as migration_20261010_164200_otter_indexes from './20261010_164200_otter_indexes'
+import * as migration_20261013_130000_legacy_identity from './20261013_130000_legacy_identity'
export const migrations = [
{
@@ -18,4 +19,9 @@ export const migrations = [
down: migration_20261010_164200_otter_indexes.down,
name: '20261010_164200_otter_indexes',
},
+ {
+ up: migration_20261013_130000_legacy_identity.up,
+ down: migration_20261013_130000_legacy_identity.down,
+ name: '20261013_130000_legacy_identity',
+ },
]
diff --git a/src/otter/controller.ts b/src/otter/controller.ts
index 44c35a8..1b97d0b 100644
--- a/src/otter/controller.ts
+++ b/src/otter/controller.ts
@@ -17,6 +17,12 @@ import {
import { dueOccurrence } from './scheduling'
import { getAssetOverrides } from '../application/processImport'
import { systemRequest } from '../integrations/payload/requests'
+import {
+ activeRunStatuses,
+ assertCurrentOtterRunLease,
+ assertOtterRunOwner,
+ isRunLeaseExpired,
+} from './runLease'
const json = (data: unknown, status = 200) =>
Response.json(data, { headers: { 'Cache-Control': 'no-store' }, status })
@@ -285,9 +291,6 @@ const runInput = async (
}
}
-const activeRunStatuses: OtterRun['status'][] = ['assigned', 'running', 'cancel-requested']
-const leaseExpired = (run: OtterRun, now: Date) =>
- !run.leaseExpiresAt || Date.parse(run.leaseExpiresAt) <= now.getTime()
const isActiveRun = (run: OtterRun) => activeRunStatuses.includes(run.status)
const leaseExpiredMessage =
'Scanner lease expired because no heartbeat was received before the deadline. The scanner may have stopped or lost its controller connection.'
@@ -346,7 +349,7 @@ export const expireOtterLeases = async (payload: Payload, now = new Date()) => {
overrideAccess: true,
req,
})
- if (!isActiveRun(run) || !leaseExpired(run, now)) return
+ if (!isActiveRun(run) || !isRunLeaseExpired(run, now)) return
const scannerID = relationshipID(run.scanner)
if (!scannerID) {
await payload.update({
@@ -385,8 +388,7 @@ const assignJob = async (req: PayloadRequest, scanner: OtterScanner, now: Date)
req,
})
if (isActiveRun(active)) {
- if (active.status === 'cancel-requested') return active
- if (!leaseExpired(active, now)) return active
+ if (!isRunLeaseExpired(active, now)) return active
await expireRun(req, active, scanner, now)
} else {
await req.payload.update({
@@ -649,6 +651,24 @@ export const otterOverview: Endpoint['handler'] = async (req) => {
})
}
+export const getOtterScanner: Endpoint['handler'] = async (req) => {
+ const id = String(req.routeParams?.id || '')
+ const scanner = await req.payload.findByID({
+ collection: 'otter-scanners',
+ depth: 0,
+ id,
+ overrideAccess: false,
+ req,
+ })
+ await requireHuman(req, scanner.site)
+ return json({
+ id: String(scanner.id),
+ computerName: scanner.computerName,
+ networkAdapters: Array.isArray(scanner.networkAdapters) ? scanner.networkAdapters : [],
+ version: scanner.version,
+ })
+}
+
export const createOtterScanner: Endpoint['handler'] = async (req) => {
humanOrigin(req)
const controllerUrl = publicControllerURL()
@@ -819,8 +839,7 @@ const activeRunForScanner = async (req: PayloadRequest, runID: string) => {
overrideAccess: true,
req,
})
- if (String(relationshipID(run.scanner)) !== String(scanner.id))
- throw new APIError('Scan run does not belong to this scanner.', 404)
+ assertOtterRunOwner(run, scanner)
return { run, scanner }
}
@@ -828,9 +847,10 @@ export const startOtterRun: Endpoint['handler'] = async (req) => {
const runID = String(req.routeParams?.id || '')
const result = await inTransaction(req, async () => {
const { run, scanner } = await activeRunForScanner(req, runID)
+ const now = new Date()
+ assertCurrentOtterRunLease(run, scanner, now)
if (run.status === 'running') return { status: run.status }
if (run.status !== 'assigned') throw new APIError('This scan run cannot be started.', 409)
- const now = new Date()
await req.payload.update({
collection: 'otter-runs',
id: run.id,
@@ -857,12 +877,13 @@ export const heartbeatOtterRun: Endpoint['handler'] = async (req) => {
throw new APIError('Progress must be an integer from 0 to 100.', 400)
const progress = typeof data.progress === 'number' ? data.progress : undefined
const result = await inTransaction(req, async () => {
- const { run } = await activeRunForScanner(req, runID)
+ const { run, scanner } = await activeRunForScanner(req, runID)
+ const now = new Date()
+ assertCurrentOtterRunLease(run, scanner, now)
if (!['running', 'cancel-requested'].includes(run.status))
throw new APIError('This scan run is no longer active.', 409)
let leaseExpiresAt = run.leaseExpiresAt
if (run.status !== 'cancel-requested') {
- const now = new Date()
leaseExpiresAt = new Date(now.getTime() + 120_000).toISOString()
await req.payload.update({
collection: 'otter-runs',
@@ -894,8 +915,7 @@ export const finishOtterRun: Endpoint['handler'] = async (req) => {
const { run, scanner } = await activeRunForScanner(req, runID)
if (['completed', 'partial', 'failed', 'cancelled'].includes(run.status))
return { status: run.status }
- if (!['assigned', 'running', 'cancel-requested'].includes(run.status))
- throw new APIError('This scan run is no longer active.', 409)
+ assertCurrentOtterRunLease(run, scanner, new Date())
const now = new Date().toISOString()
if (importID) {
const imported = await req.payload.find({
@@ -1157,6 +1177,7 @@ export const reenrollOtterScanner: Endpoint['handler'] = async (req) => {
export const otterEndpoints: Endpoint[] = [
{ path: '/otter/v1/overview', method: 'get', handler: otterOverview },
+ { path: '/otter/v1/scanners/:id', method: 'get', handler: getOtterScanner },
{ path: '/otter/v1/scanners', method: 'post', handler: createOtterScanner },
{ path: '/otter/v1/scanners/:id/revoke', method: 'post', handler: revokeOtterScanner },
{ path: '/otter/v1/scanners/:id/reenroll', method: 'post', handler: reenrollOtterScanner },
diff --git a/src/otter/runLease.ts b/src/otter/runLease.ts
new file mode 100644
index 0000000..f3aab5e
--- /dev/null
+++ b/src/otter/runLease.ts
@@ -0,0 +1,25 @@
+import { APIError } from 'payload'
+import { relationshipID } from '../access/authorization'
+import type { OtterRun, OtterScanner } from '../payload-types'
+
+export const activeRunStatuses: OtterRun['status'][] = ['assigned', 'running', 'cancel-requested']
+
+export const isRunLeaseExpired = (run: OtterRun, now: Date) => {
+ const expiresAt = Date.parse(run.leaseExpiresAt || '')
+ return !Number.isFinite(expiresAt) || expiresAt <= now.getTime()
+}
+
+export const assertOtterRunOwner = (run: OtterRun, scanner: OtterScanner) => {
+ if (String(relationshipID(run.scanner)) !== String(scanner.id))
+ throw new APIError('Scan run does not belong to this scanner.', 404)
+}
+
+export const assertCurrentOtterRunLease = (run: OtterRun, scanner: OtterScanner, now: Date) => {
+ assertOtterRunOwner(run, scanner)
+ if (
+ !activeRunStatuses.includes(run.status) ||
+ String(relationshipID(scanner.currentRun)) !== String(run.id)
+ )
+ throw new APIError('This scan run is no longer the scanner’s current assignment.', 409)
+ if (isRunLeaseExpired(run, now)) throw new APIError('The scanner lease has expired.', 409)
+}
diff --git a/src/search/assetLucene.ts b/src/search/assetLucene.ts
index 17f20c5..f557c25 100644
--- a/src/search/assetLucene.ts
+++ b/src/search/assetLucene.ts
@@ -1,5 +1,6 @@
import lucene from 'lucene-query-parser'
import { APIError, type CollectionBeforeOperationHook, type Where } from 'payload'
+import { assetSearchFields } from './assetSearchFields'
type LuceneNode = {
boost?: null | number
@@ -18,57 +19,6 @@ type LuceneNode = {
term_min?: string
}
-type FieldKind = 'date' | 'keyword' | 'number' | 'text'
-
-const fields: Record = {
- assetowner: { kind: 'text', path: 'assetOwner' },
- uuid: { kind: 'keyword', path: 'uuid' },
- lifecycle: { kind: 'keyword', path: 'lifecycle' },
- physicalkind: { kind: 'keyword', path: 'physicalKind' },
- catalog: { kind: 'text', path: 'catalogNumber' },
- catalognumber: { kind: 'text', path: 'catalogNumber' },
- assetclass: { kind: 'text', path: 'assetClass.name' },
- assetclassid: { kind: 'keyword', path: 'assetClass' },
- class: { kind: 'text', path: 'assetClass.name' },
- classid: { kind: 'keyword', path: 'assetClass' },
- criticality: { kind: 'keyword', path: 'criticality' },
- createdat: { kind: 'date', path: 'createdAt' },
- description: { kind: 'text', path: 'description' },
- firmware: { kind: 'text', path: 'firmwareVersion' },
- firmwareversion: { kind: 'text', path: 'firmwareVersion' },
- gateway: { kind: 'text', path: 'gatewayAddress' },
- gatewayaddress: { kind: 'text', path: 'gatewayAddress' },
- hardware: { kind: 'text', path: 'hardwareVersion' },
- hardwareversion: { kind: 'text', path: 'hardwareVersion' },
- importsource: { kind: 'text', path: 'importSource' },
- id: { kind: 'keyword', path: 'id' },
- ip: { kind: 'keyword', path: 'ipAddress' },
- ipaddress: { kind: 'keyword', path: 'ipAddress' },
- lastimportedat: { kind: 'date', path: 'lastImportedAt' },
- lastseen: { kind: 'date', path: 'lastSeen' },
- location: { kind: 'text', path: 'location' },
- mac: { kind: 'keyword', path: 'macAddress' },
- macaddress: { kind: 'keyword', path: 'macAddress' },
- model: { kind: 'text', path: 'model' },
- name: { kind: 'text', path: 'name' },
- networkmask: { kind: 'text', path: 'networkMask' },
- notes: { kind: 'text', path: 'notes' },
- operatingsystem: { kind: 'text', path: 'operatingSystem' },
- os: { kind: 'text', path: 'operatingSystem' },
- osaccuracy: { kind: 'number', path: 'osAccuracy' },
- owner: { kind: 'text', path: 'assetOwner' },
- protocol: { kind: 'keyword', path: 'protocols' },
- protocols: { kind: 'keyword', path: 'protocols' },
- serialnumber: { kind: 'text', path: 'serialNumber' },
- site: { kind: 'text', path: 'site.name' },
- siteid: { kind: 'keyword', path: 'site' },
- sourceversion: { kind: 'text', path: 'sourceVersion' },
- status: { kind: 'keyword', path: 'status' },
- type: { kind: 'text', path: 'assetClass.name' },
- updatedat: { kind: 'date', path: 'updatedAt' },
- vendor: { kind: 'text', path: 'vendor' },
-}
-
const implicitFields = [
'name',
'description',
@@ -86,7 +36,8 @@ const fail = (message: string): never => {
throw new APIError(`Invalid asset search: ${message}`, 400)
}
-const fieldFor = (name: string) => fields[name.toLowerCase()] ?? fail(`unknown field "${name}".`)
+const fieldFor = (name: string) =>
+ assetSearchFields[name.toLowerCase()] ?? fail(`unknown field "${name}".`)
const normalizeKeyword = (path: string, value: string) => {
if (path === 'macAddress') return value.replaceAll('-', ':').toUpperCase()
diff --git a/src/search/assetSearchFields.ts b/src/search/assetSearchFields.ts
new file mode 100644
index 0000000..fd7d916
--- /dev/null
+++ b/src/search/assetSearchFields.ts
@@ -0,0 +1,71 @@
+export type AssetSearchFieldKind = 'date' | 'keyword' | 'number' | 'text'
+
+type AssetSearchField = {
+ kind: AssetSearchFieldKind
+ path: string
+ wherePath?: string
+ whereField?: string
+}
+
+// This browser-safe manifest drives both Lucene parsing and Payload filter serialization.
+export const assetSearchFields: Record = {
+ assetowner: { kind: 'text', path: 'assetOwner' },
+ uuid: { kind: 'keyword', path: 'uuid' },
+ lifecycle: { kind: 'keyword', path: 'lifecycle' },
+ physicalkind: { kind: 'keyword', path: 'physicalKind' },
+ catalog: { kind: 'text', path: 'catalogNumber' },
+ catalognumber: { kind: 'text', path: 'catalogNumber' },
+ assetclass: { kind: 'text', path: 'assetClass.name' },
+ assetclassid: { kind: 'keyword', path: 'assetClass' },
+ class: { kind: 'text', path: 'assetClass.name' },
+ classid: { kind: 'keyword', path: 'assetClass', wherePath: 'assetClass' },
+ criticality: { kind: 'keyword', path: 'criticality' },
+ createdat: { kind: 'date', path: 'createdAt' },
+ deletedat: { kind: 'date', path: 'deletedAt' },
+ description: { kind: 'text', path: 'description' },
+ firmware: { kind: 'text', path: 'firmwareVersion' },
+ firmwareversion: { kind: 'text', path: 'firmwareVersion' },
+ gateway: { kind: 'text', path: 'gatewayAddress', wherePath: 'gatewayAddress' },
+ gatewayaddress: { kind: 'text', path: 'gatewayAddress' },
+ hardware: { kind: 'text', path: 'hardwareVersion' },
+ hardwareversion: { kind: 'text', path: 'hardwareVersion' },
+ importsource: { kind: 'text', path: 'importSource' },
+ id: { kind: 'keyword', path: 'id' },
+ ip: { kind: 'keyword', path: 'ipAddress', wherePath: 'ipAddress' },
+ ipaddress: { kind: 'keyword', path: 'ipAddress' },
+ lastimportedat: { kind: 'date', path: 'lastImportedAt' },
+ lastseen: { kind: 'date', path: 'lastSeen' },
+ location: { kind: 'text', path: 'location' },
+ mac: { kind: 'keyword', path: 'macAddress', wherePath: 'macAddress' },
+ macaddress: { kind: 'keyword', path: 'macAddress' },
+ model: { kind: 'text', path: 'model' },
+ name: { kind: 'text', path: 'name' },
+ networkmask: { kind: 'text', path: 'networkMask' },
+ notes: { kind: 'text', path: 'notes' },
+ operatingsystem: { kind: 'text', path: 'operatingSystem' },
+ os: { kind: 'text', path: 'operatingSystem', wherePath: 'operatingSystem' },
+ osaccuracy: { kind: 'number', path: 'osAccuracy' },
+ owner: { kind: 'text', path: 'assetOwner', wherePath: 'assetOwner' },
+ protocol: { kind: 'keyword', path: 'protocols' },
+ protocols: { kind: 'keyword', path: 'protocols' },
+ serialnumber: { kind: 'text', path: 'serialNumber' },
+ site: { kind: 'text', path: 'site.name' },
+ siteid: { kind: 'keyword', path: 'site', wherePath: 'site' },
+ sourceversion: { kind: 'text', path: 'sourceVersion' },
+ status: { kind: 'keyword', path: 'status' },
+ type: { kind: 'text', path: 'assetClass.name' },
+ updatedat: { kind: 'date', path: 'updatedAt' },
+ vendor: { kind: 'text', path: 'vendor' },
+ vulnerabilitycount: {
+ kind: 'number',
+ path: 'vulnerabilityCount',
+ wherePath: 'vulnerabilityCount',
+ whereField: 'vulnerabilityCount',
+ },
+}
+
+export const graphicalAssetFieldNames = Object.fromEntries(
+ Object.entries(assetSearchFields).flatMap(([name, field]) =>
+ field.wherePath ? [[field.wherePath, field.whereField || name]] : [],
+ ),
+)
diff --git a/src/search/whereToLucene.ts b/src/search/whereToLucene.ts
index d104b23..bbab40c 100644
--- a/src/search/whereToLucene.ts
+++ b/src/search/whereToLucene.ts
@@ -1,16 +1,7 @@
import type { Where } from 'payload'
+import { graphicalAssetFieldNames } from './assetSearchFields'
-const fieldNames: Record = {
- assetOwner: 'owner',
- assetClass: 'classid',
- gatewayAddress: 'gateway',
- ipAddress: 'ip',
- macAddress: 'mac',
- operatingSystem: 'os',
- site: 'siteid',
-}
-
-const fieldName = (path: string) => fieldNames[path] ?? path
+const fieldName = (path: string) => graphicalAssetFieldNames[path] ?? path
const quote = (value: unknown) =>
`"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
const term = (value: unknown) => String(value).replaceAll(/([+\-!(){}[\]^"~*?:\\/\s])/g, '\\$1')
diff --git a/src/worker.ts b/src/worker.ts
index cb8a75e..93b517a 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -1,4 +1,8 @@
-process.loadEnvFile?.()
+try {
+ process.loadEnvFile?.()
+} catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+}
const { getPayload } = await import('payload')
const { default: config } = await import('./payload.config')
diff --git a/tests/check-test-safety.sh b/tests/check-test-safety.sh
new file mode 100644
index 0000000..87886a8
--- /dev/null
+++ b/tests/check-test-safety.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
+if output=$(env \
+ -u DATABASE_URL \
+ -u TEST_DATABASE_URL \
+ -u OTSERVER_TEST_DATABASE_OWNER \
+ -u OTSERVER_TEST_UPLOAD_DIR \
+ pnpm --dir "$ROOT" exec vitest run --config "$ROOT/vitest.config.mts" \
+ "$ROOT/tests/int/coverage.int.spec.ts" 2>&1); then
+ printf '%s\n' 'Vitest unexpectedly ran without a test-owned database.' >&2
+ exit 1
+fi
+
+case "$output" in
+ *'Tests require a test-owned MongoDB replica set and upload directory'*) ;;
+ *) printf '%s\n' "$output" >&2; exit 1 ;;
+esac
diff --git a/tests/container-bootstrap.sh b/tests/container-bootstrap.sh
index 4300508..1a0179e 100644
--- a/tests/container-bootstrap.sh
+++ b/tests/container-bootstrap.sh
@@ -29,6 +29,9 @@ fi
PREFIX="otserver-bootstrap-$$"
APP="$PREFIX-app"
+IMPORT_WORKER="$PREFIX-import-worker"
+MAINTENANCE_WORKER="$PREFIX-maintenance-worker"
+RESTORED_APP="$PREFIX-restored-app"
IMAGE="$PREFIX"
MONGO="$PREFIX-mongo"
NETWORK="$PREFIX"
@@ -39,9 +42,12 @@ cleanup() {
trap - EXIT INT TERM
if [ "$status" -ne 0 ]; then
"$RUNTIME" logs "$APP" >&2 || true
+ "$RUNTIME" logs "$IMPORT_WORKER" >&2 || true
+ "$RUNTIME" logs "$MAINTENANCE_WORKER" >&2 || true
+ "$RUNTIME" logs "$RESTORED_APP" >&2 || true
"$RUNTIME" logs "$MONGO" >&2 || true
fi
- "$RUNTIME" rm --force "$APP" "$MONGO" >/dev/null 2>&1 || true
+ "$RUNTIME" rm --force "$APP" "$IMPORT_WORKER" "$MAINTENANCE_WORKER" "$RESTORED_APP" "$MONGO" >/dev/null 2>&1 || true
"$RUNTIME" volume rm "$UPLOADS" >/dev/null 2>&1 || true
"$RUNTIME" network rm "$NETWORK" >/dev/null 2>&1 || true
"$RUNTIME" image rm "$IMAGE" >/dev/null 2>&1 || true
@@ -94,7 +100,9 @@ done
--network "$NETWORK" \
--env DATABASE_URL='mongodb://mongo:27017/otserver?replicaSet=rs0' \
--env OTSERVER_SECRET='container-bootstrap-test-secret-change-me' \
+ --env OTSERVER_OTTER_ENCRYPTION_KEY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' \
--env OTSERVER_QUEUED_IMPORTS=on \
+ --env OTSERVER_WORKER_MODE=external \
--env OTSERVER_VULNERABILITY_FEEDS=off \
--volume "$UPLOADS:/app/import-files" \
"$IMAGE" >/dev/null
@@ -115,8 +123,28 @@ until "$RUNTIME" exec "$APP" node -e \
sleep 1
done
-"$RUNTIME" exec --interactive "$APP" node --input-type=module <<'NODE'
+for worker in "$IMPORT_WORKER" "$MAINTENANCE_WORKER"; do
+ queue=imports
+ if [ "$worker" = "$MAINTENANCE_WORKER" ]; then queue=maintenance; fi
+ "$RUNTIME" run --detach \
+ --name "$worker" \
+ --network "$NETWORK" \
+ --env DATABASE_URL='mongodb://mongo:27017/otserver?replicaSet=rs0' \
+ --env OTSERVER_SECRET='container-bootstrap-test-secret-change-me' \
+ --env OTSERVER_OTTER_ENCRYPTION_KEY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' \
+ --env OTSERVER_QUEUED_IMPORTS=on \
+ --env OTSERVER_WORKER_MODE=external \
+ --env OTSERVER_VULNERABILITY_FEEDS=off \
+ --volume "$UPLOADS:/app/import-files:ro" \
+ "$IMAGE" node_modules/.bin/tsx src/worker.ts "$queue" >/dev/null
+ "$RUNTIME" exec "$worker" sh -c 'test ! -e /app/.env'
+done
+
+"$RUNTIME" exec --interactive "$APP" node --import tsx --input-type=module <<'NODE'
import assert from 'node:assert/strict'
+import { getPayload } from 'payload'
+import config from './src/payload.config.ts'
+import { systemRequest } from './src/integrations/payload/requests.ts'
const baseURL = 'http://127.0.0.1:3000'
const json = async (path, options) => {
@@ -177,6 +205,97 @@ assert.deepEqual(
)
assert.ok(classes.docs.find(({ legacyKey }) => legacyKey === 'plc').assignmentRules.length > 0)
+const payload = await getPayload({ config })
+try {
+ const user = await payload.findByID({
+ collection: 'users',
+ id: currentUser.user.id,
+ depth: 0,
+ overrideAccess: true,
+ })
+ const site = await payload.create({
+ collection: 'sites',
+ data: { name: 'External worker smoke site', type: 'Test' },
+ overrideAccess: false,
+ user,
+ })
+ const bytes = Buffer.from(`
+
+
+ `)
+ const accepted = await payload.create({
+ collection: 'asset-imports',
+ data: {
+ executionMode: 'queued',
+ site: site.id,
+ source: 'nmap',
+ sourceVersion: '7.95',
+ },
+ file: {
+ data: bytes,
+ mimetype: 'application/xml',
+ name: 'external-worker-smoke.xml',
+ size: bytes.length,
+ },
+ overrideAccess: false,
+ user,
+ })
+ const imported = await payload.findByID({
+ collection: 'asset-imports',
+ id: accepted.id,
+ depth: 0,
+ overrideAccess: true,
+ })
+ const scanner = await payload.create({
+ collection: 'otter-scanners',
+ data: { name: 'External worker restore scanner', site: site.id },
+ overrideAccess: true,
+ user,
+ })
+ await payload.create({
+ collection: 'otter-profiles',
+ data: {
+ name: 'External worker restore configuration',
+ site: site.id,
+ scanner: scanner.id,
+ configuration: { targets: ['192.0.2.42'] },
+ },
+ overrideAccess: false,
+ user,
+ })
+ const maintenance = await payload.jobs.queue({
+ task: 'maintenance-v1',
+ queue: 'maintenance',
+ input: { version: 1 },
+ req: await systemRequest(payload),
+ })
+
+ let importStatus
+ let maintenanceJob
+ for (let attempt = 0; attempt < 60; attempt += 1) {
+ importStatus = (await payload.findByID({
+ collection: 'asset-imports',
+ id: accepted.id,
+ depth: 0,
+ overrideAccess: true,
+ })).status
+ maintenanceJob = await payload.db.collections['payload-jobs'].findById(maintenance.id).lean()
+ if (importStatus === 'failed' || maintenanceJob?.hasError)
+ throw new Error('An external worker failed its smoke-test job.')
+ if (importStatus === 'completed' && maintenanceJob?.completedAt) break
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ }
+ assert.equal(importStatus, 'completed')
+ assert.ok(maintenanceJob?.completedAt)
+ assert.equal(
+ (await payload.count({ collection: 'assets', where: { site: { equals: site.id } } })).totalDocs,
+ 1,
+ )
+ assert.ok(imported.jobID)
+} finally {
+ await payload.destroy()
+}
+
let operations
for (let attempt = 0; attempt < 60; attempt += 1) {
operations = await json('/api/operations', { headers: authorization })
@@ -191,7 +310,7 @@ assert.deepEqual(
],
)
-console.log('Fresh database initialized and both standalone worker queues are healthy.')
+console.log('Fresh database initialized; external workers processed an import and maintenance job.')
NODE
# Rehearse the MongoDB archive portion of disaster recovery against a separate namespace.
@@ -206,7 +325,78 @@ NODE
--nsFrom='otserver.*' \
--nsTo='otserver-restore-drill.*' >/dev/null
"$RUNTIME" exec "$MONGO" mongosh --quiet --eval \
- 'const restored = db.getSiblingDB("otserver-restore-drill"); if (restored.users.countDocuments({}) !== 1 || restored["user-roles"].countDocuments({name:"Admin"}) !== 1 || restored["asset-classes"].countDocuments({}) !== 8) quit(1); restored.dropDatabase()' \
+ 'const restored = db.getSiblingDB("otserver-restore-drill"); if (restored.users.countDocuments({}) !== 1 || restored["user-roles"].countDocuments({name:"Admin"}) !== 1 || restored["asset-classes"].countDocuments({}) !== 8 || restored.assets.countDocuments({}) !== 1 || restored["asset-imports"].countDocuments({filename:"external-worker-smoke.xml",status:"completed"}) !== 1) quit(1)' \
>/dev/null
-echo "Container bootstrap and database restore drill passed with $RUNTIME."
+"$RUNTIME" run --detach \
+ --name "$RESTORED_APP" \
+ --network "$NETWORK" \
+ --env DATABASE_URL='mongodb://mongo:27017/otserver-restore-drill?replicaSet=rs0' \
+ --env OTSERVER_SECRET='container-bootstrap-test-secret-change-me' \
+ --env OTSERVER_OTTER_ENCRYPTION_KEY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' \
+ --env OTSERVER_WORKER_MODE=external \
+ --env OTSERVER_VULNERABILITY_FEEDS=off \
+ --volume "$UPLOADS:/app/import-files" \
+ "$IMAGE" >/dev/null
+
+attempt=0
+until "$RUNTIME" exec "$RESTORED_APP" node -e \
+ "fetch('http://127.0.0.1:3000/api/health/ready').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" \
+ >/dev/null 2>&1; do
+ if [ "$("$RUNTIME" inspect --format '{{.State.Running}}' "$RESTORED_APP" 2>/dev/null || true)" != 'true' ]; then
+ echo 'Restored OTserver exited before becoming ready.' >&2
+ exit 1
+ fi
+ attempt=$((attempt + 1))
+ if [ "$attempt" -ge 120 ]; then
+ echo 'Restored OTserver did not become ready within 120 seconds.' >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+"$RUNTIME" exec --interactive "$RESTORED_APP" node --import tsx --input-type=module <<'NODE'
+import assert from 'node:assert/strict'
+import { access } from 'node:fs/promises'
+import path from 'node:path'
+import { getPayload } from 'payload'
+import config from './src/payload.config.ts'
+import { decryptOtterCredentials } from './src/otter/security.ts'
+
+const payload = await getPayload({ config })
+try {
+ const importResult = await payload.find({
+ collection: 'asset-imports',
+ depth: 0,
+ limit: 1,
+ overrideAccess: true,
+ where: { filename: { equals: 'external-worker-smoke.xml' } },
+ })
+ const imported = importResult.docs[0]
+ assert.ok(imported)
+ assert.equal(imported.status, 'completed')
+ assert.ok(imported.filename)
+ await access(path.join('/app/import-files', imported.filename))
+
+ const profileResult = await payload.find({
+ collection: 'otter-profiles',
+ depth: 0,
+ limit: 1,
+ overrideAccess: true,
+ where: { name: { equals: 'External worker restore configuration' } },
+ })
+ const profile = profileResult.docs[0]
+ assert.ok(profile)
+ const stored = await payload.db.collections['otter-profiles'].findById(profile.id).lean()
+ assert.deepEqual(decryptOtterCredentials(stored?.encryptedCredentials), {
+ snmp: [{ version: '2c', community: 'public' }],
+ })
+} finally {
+ await payload.destroy()
+}
+NODE
+
+"$RUNTIME" exec "$MONGO" mongosh --quiet --eval \
+ 'db.getSiblingDB("otserver-restore-drill").dropDatabase()' >/dev/null
+
+echo "Container bootstrap, external workers, and database/upload/secret restore passed with $RUNTIME."
diff --git a/tests/int/api.int.spec.ts b/tests/int/api.int.spec.ts
index ce68c81..328ea6a 100644
--- a/tests/int/api.int.spec.ts
+++ b/tests/int/api.int.spec.ts
@@ -956,6 +956,155 @@ describe('asset CRUD', () => {
}
})
+ it('rolls back inventory, evidence, and audit rows after a mid-import write failure', async () => {
+ const macAddress = `02:${randomBytes(5).toString('hex').toUpperCase().match(/.{2}/g)?.join(':')}`
+ let siteID: string | undefined
+ let userID: string | undefined
+ let observationWriteAttempted = false
+ const observationModel = (
+ payload.db as unknown as {
+ collections: Record Promise }>
+ }
+ ).collections['asset-observations']
+ const insertSpy = vi.spyOn(observationModel, 'create').mockImplementation(async () => {
+ observationWriteAttempted = true
+ throw new Error('injected observation persistence failure')
+ })
+
+ try {
+ const adminRole = await ensureAdminRole(payload)
+ const user = await payload.create({
+ collection: 'users',
+ data: {
+ email: `rollback-${randomUUID()}@example.test`,
+ name: 'Import rollback operator',
+ password: randomUUID(),
+ role: adminRole.id,
+ },
+ })
+ userID = user.id
+ const site = await payload.create({
+ collection: 'sites',
+ data: { name: `Rollback site ${randomUUID()}`, type: 'Test' },
+ overrideAccess: false,
+ user,
+ })
+ siteID = site.id
+ const scan = JSON.parse(
+ await readFile(
+ new URL(
+ '../otserver_otter_files/OTserver-Otter-known-vulnerability.json',
+ import.meta.url,
+ ),
+ 'utf8',
+ ),
+ ) as {
+ devices: {
+ macAddress: string
+ macAddresses: string[]
+ interfaces: { macAddress?: string }[]
+ observations: { fields: Record; raw: Record }[]
+ }[]
+ links: unknown[]
+ unresolved: unknown[]
+ }
+ const [device] = scan.devices
+ device.macAddress = macAddress
+ device.macAddresses = [macAddress]
+ for (const networkInterface of device.interfaces)
+ if (networkInterface.macAddress) networkInterface.macAddress = macAddress
+ for (const observation of device.observations) {
+ if (typeof observation.fields.macAddress === 'string')
+ observation.fields.macAddress = macAddress
+ const subject = observation.raw.subject
+ if (
+ subject &&
+ typeof subject === 'object' &&
+ !Array.isArray(subject) &&
+ (subject as Record).kind === 'device'
+ )
+ (subject as Record).macAddress = macAddress
+ }
+ scan.devices = [device]
+ scan.links = []
+ scan.unresolved = []
+ const file = Buffer.from(JSON.stringify(scan))
+ await expect(
+ payload.create({
+ collection: 'asset-imports',
+ data: { site: site.id, source: 'otserver-otter', sourceVersion: '0.2.0' } as never,
+ file: {
+ data: file,
+ mimetype: 'application/json',
+ name: `rollback-${randomUUID()}.json`,
+ size: file.length,
+ },
+ overrideAccess: false,
+ user,
+ }),
+ ).rejects.toThrow('injected observation persistence failure')
+ expect(observationWriteAttempted).toBe(true)
+
+ const assets = await payload.find({
+ collection: 'assets',
+ where: { macAddress: { equals: macAddress } },
+ })
+ const endpoints = await payload.find({
+ collection: 'network-endpoints',
+ where: { macAddress: { equals: macAddress } },
+ })
+ const imports = await payload.find({
+ collection: 'asset-imports',
+ where: { site: { equals: site.id } },
+ })
+ const evidence = await payload.find({
+ collection: 'asset-observations',
+ where: { site: { equals: site.id } },
+ })
+ const audit = await payload.find({
+ collection: 'audit-logs',
+ where: {
+ and: [
+ { site: { equals: site.id } },
+ {
+ targetCollection: {
+ in: ['assets', 'network-endpoints', 'asset-observations', 'asset-imports'],
+ },
+ },
+ ],
+ },
+ })
+ expect(assets.totalDocs).toBe(0)
+ expect(endpoints.totalDocs).toBe(0)
+ expect(imports.totalDocs).toBe(0)
+ expect(evidence.totalDocs).toBe(0)
+ expect(audit.totalDocs).toBe(0)
+ } finally {
+ insertSpy.mockRestore()
+ if (siteID) {
+ for (const imported of (
+ await payload.find({ collection: 'asset-imports', where: { site: { equals: siteID } } })
+ ).docs)
+ await payload.delete({ collection: 'asset-imports', id: imported.id })
+ for (const asset of (
+ await payload.find({
+ collection: 'assets',
+ where: { macAddress: { equals: macAddress } },
+ })
+ ).docs)
+ await payload.delete({ collection: 'assets', id: asset.id })
+ await payload.delete({ collection: 'sites', id: siteID })
+ }
+ if (userID) await payload.delete({ collection: 'users', id: userID })
+ const logs = await payload.find({
+ collection: 'audit-logs',
+ pagination: false,
+ where: { documentID: { in: [siteID, userID].filter(Boolean) as string[] } },
+ })
+ for (const log of logs.docs) await payload.delete({ collection: 'audit-logs', id: log.id })
+ }
+ })
+
it('imports the anonymized full Nmap scan', async () => {
const fixture = await readFile(new URL('../nmap_files/nmap.xml', import.meta.url), 'utf8')
const randomMACPrefix = `02:${randomBytes(4).toString('hex').toUpperCase().match(/.{2}/g)?.join(':')}`
diff --git a/tests/int/asset-lucene.int.spec.ts b/tests/int/asset-lucene.int.spec.ts
index 91182d2..921eab8 100644
--- a/tests/int/asset-lucene.int.spec.ts
+++ b/tests/int/asset-lucene.int.spec.ts
@@ -16,6 +16,12 @@ describe('asset Lucene search', () => {
expect(parseAssetSearch('osAccuracy:[80 TO 100]')).toEqual({
osAccuracy: { greater_than_equal: 80, less_than_equal: 100 },
})
+ expect(parseAssetSearch(whereToLucene({ vulnerabilityCount: { greater_than: 0 } }))).toEqual({
+ vulnerabilityCount: { greater_than: 0 },
+ })
+ expect(whereToLucene({ vulnerabilityCount: { greater_than: 0 } })).toBe(
+ 'vulnerabilityCount:{0 TO *}',
+ )
expect(parseAssetSearch('status:(online OR maintenance)')).toEqual({
or: [{ status: { equals: 'online' } }, { status: { equals: 'maintenance' } }],
})
diff --git a/tests/int/asset-panel.int.spec.ts b/tests/int/asset-panel.int.spec.ts
new file mode 100644
index 0000000..40487cf
--- /dev/null
+++ b/tests/int/asset-panel.int.spec.ts
@@ -0,0 +1,167 @@
+import config from '@/payload.config'
+import { randomBytes, randomUUID } from 'node:crypto'
+import { beforeAll, describe, expect, it, vi } from 'vitest'
+import { getPayload, type Payload } from 'payload'
+
+import { ensureAssetClass } from '../../src/collections/AssetClasses'
+import { loadAssetPanel } from '../../src/application/assetPanel'
+
+let payload: Payload
+let assetClassID: string
+
+beforeAll(async () => {
+ payload = await getPayload({ config })
+ assetClassID = (await ensureAssetClass(payload, 'other')).id
+})
+
+const randomMAC = () =>
+ `02:${randomBytes(5).toString('hex').toUpperCase().match(/.{2}/g)?.join(':')}`
+
+describe('asset panel read interface', () => {
+ it('bounds panel page sizes and scopes legacy history without a UUID', async () => {
+ const calls: { collection: string; limit?: number; where?: unknown }[] = []
+ const payload = {
+ count: vi.fn(async () => ({ totalDocs: 0 })),
+ find: vi.fn(async (args: { collection: string; limit?: number; where?: unknown }) => {
+ calls.push(args)
+ return { docs: [], hasNextPage: false }
+ }),
+ findByID: vi.fn(async ({ collection }: { collection: string }) =>
+ collection === 'sites'
+ ? { id: 'site-1', name: 'Test site', path: 'Test site' }
+ : { id: 'class-1', name: 'Other' },
+ ),
+ }
+ const asset = { id: 'legacy-asset', site: 'site-1', assetClass: 'class-1', uuid: null }
+
+ for (const [pageSize, expectedLimit] of [
+ [0, 1],
+ [101, 100],
+ [1.5, 50],
+ ]) {
+ calls.length = 0
+ await loadAssetPanel({
+ asset: asset as never,
+ payload: payload as never,
+ historyPage: 1,
+ discoveryPage: 1,
+ topologyPage: 1,
+ pageSize,
+ })
+ expect(calls.filter(({ collection }) => collection === 'audit-logs')[0]).toMatchObject({
+ limit: expectedLimit,
+ where: { asset: { equals: asset.id } },
+ })
+ }
+ })
+
+ it('paginates history and scopes related reads to the authorized site', async () => {
+ let siteID: string | undefined
+ let otherSiteID: string | undefined
+ let roleID: string | undefined
+ let userID: string | undefined
+ const assetIDs: string[] = []
+
+ try {
+ const site = await payload.create({
+ collection: 'sites',
+ data: { name: `Asset panel ${randomUUID()}`, type: 'Plant' },
+ })
+ siteID = site.id
+ const otherSite = await payload.create({
+ collection: 'sites',
+ data: { name: `Other panel ${randomUUID()}`, type: 'Plant' },
+ })
+ otherSiteID = otherSite.id
+ const role = await payload.create({
+ collection: 'user-roles',
+ data: {
+ name: `Asset panel reader ${randomUUID()}`,
+ permissions: [{ site: site.id, access: 'read' }],
+ },
+ })
+ roleID = role.id
+ const user = await payload.create({
+ collection: 'users',
+ data: {
+ email: `asset-panel-${randomUUID()}@example.test`,
+ name: 'Asset panel reader',
+ password: randomUUID(),
+ role: role.id,
+ },
+ })
+ userID = user.id
+ const asset = await payload.create({
+ collection: 'assets',
+ data: {
+ assetClass: assetClassID,
+ criticality: 'medium',
+ macAddress: randomMAC(),
+ name: 'Panel asset',
+ site: site.id,
+ status: 'offline',
+ },
+ })
+ assetIDs.push(asset.id)
+ const foreign = await payload.create({
+ collection: 'assets',
+ data: {
+ assetClass: assetClassID,
+ criticality: 'medium',
+ macAddress: randomMAC(),
+ name: 'Foreign panel asset',
+ site: otherSite.id,
+ status: 'offline',
+ },
+ })
+ assetIDs.push(foreign.id)
+ await payload.update({ collection: 'assets', id: asset.id, data: { status: 'online' } })
+ const readerAsset = await payload.findByID({
+ collection: 'assets',
+ id: asset.id,
+ depth: 1,
+ overrideAccess: false,
+ user,
+ })
+
+ const firstPage = await loadAssetPanel({
+ asset: readerAsset,
+ payload,
+ user,
+ historyPage: 1,
+ discoveryPage: 1,
+ topologyPage: 1,
+ pageSize: 1,
+ })
+ const secondPage = await loadAssetPanel({
+ asset: readerAsset,
+ payload,
+ user,
+ historyPage: 2,
+ discoveryPage: 1,
+ topologyPage: 1,
+ pageSize: 1,
+ })
+
+ expect(firstPage.auditLogs).toMatchObject({ page: 1, totalPages: expect.any(Number) })
+ expect(firstPage.auditLogs.totalPages).toBeGreaterThan(1)
+ expect(firstPage.auditLogs.docs).toHaveLength(1)
+ expect(secondPage.auditLogs.page).toBe(2)
+ expect(secondPage.auditLogs.docs).toHaveLength(1)
+ expect(firstPage.auditLogs.docs[0]?.id).not.toBe(secondPage.auditLogs.docs[0]?.id)
+ expect(
+ [...firstPage.auditLogs.docs, ...secondPage.auditLogs.docs].every(
+ ({ assetID: target }) => target === asset.id,
+ ),
+ ).toBe(true)
+ expect(firstPage.historyScopeTruncated).toBe(false)
+ } finally {
+ for (const assetID of assetIDs)
+ await payload.delete({ collection: 'assets', id: assetID }).catch(() => {})
+ for (const site of [siteID, otherSiteID])
+ if (site) await payload.delete({ collection: 'sites', id: site }).catch(() => {})
+ if (userID) await payload.delete({ collection: 'users', id: userID }).catch(() => {})
+ if (roleID) await payload.delete({ collection: 'user-roles', id: roleID }).catch(() => {})
+ }
+ })
+})
diff --git a/tests/int/assets.int.spec.ts b/tests/int/assets.int.spec.ts
index 3da353b..246b5c5 100644
--- a/tests/int/assets.int.spec.ts
+++ b/tests/int/assets.int.spec.ts
@@ -151,6 +151,7 @@ describe('asset network fields', () => {
},
payload: {
config: { routes: { admin: '/admin' } },
+ count: vi.fn().mockResolvedValue({ totalDocs: 0 }),
findByID: vi.fn().mockImplementation(({ collection }: { collection: string }) =>
Promise.resolve(
collection === 'sites'
@@ -303,6 +304,7 @@ describe('asset network fields', () => {
},
payload: {
config: { routes: { admin: '/admin' } },
+ count: vi.fn().mockResolvedValue({ totalDocs: 0 }),
findByID: vi
.fn()
.mockImplementation(({ collection }: { collection: string }) =>
@@ -382,6 +384,7 @@ describe('asset network fields', () => {
},
payload: {
config: { routes: { admin: '/admin' } },
+ count: vi.fn().mockResolvedValue({ totalDocs: 0 }),
findByID: vi
.fn()
.mockImplementation(({ collection }: { collection: string }) =>
diff --git a/tests/int/audit.int.spec.ts b/tests/int/audit.int.spec.ts
index 0dc8240..f2c043d 100644
--- a/tests/int/audit.int.spec.ts
+++ b/tests/int/audit.int.spec.ts
@@ -44,6 +44,29 @@ describe('audit log', () => {
overrideAccess: false,
user,
})
+ const childSite = await payload.create({
+ collection: 'sites',
+ data: { name: `Audit child site ${randomUUID()}`, parent: site.id, type: 'Area' },
+ overrideAccess: false,
+ user,
+ })
+ await payload.update({
+ collection: 'sites',
+ id: childSite.id,
+ data: { parent: null },
+ overrideAccess: false,
+ user,
+ })
+ const siteLogs = await payload.find({
+ collection: 'audit-logs',
+ depth: 0,
+ pagination: false,
+ where: {
+ and: [{ targetCollection: { equals: 'sites' } }, { documentID: { equals: childSite.id } }],
+ },
+ })
+ expect(siteLogs.docs).toHaveLength(2)
+ expect(siteLogs.docs.every(({ asset, assetID }) => !asset && !assetID)).toBe(true)
const asset = await payload.create({
collection: 'assets',
data: {
@@ -193,6 +216,7 @@ describe('audit log', () => {
).rejects.toThrow()
await payload.delete({ collection: 'asset-fields', id: setting.id })
+ await payload.delete({ collection: 'sites', id: childSite.id })
await payload.delete({ collection: 'users', id: reader.id })
await payload.delete({ collection: 'user-roles', id: readerRole.id })
await payload.delete({ collection: 'sites', id: site.id })
@@ -206,7 +230,7 @@ describe('audit log', () => {
{ actorID: { equals: user.id } },
{
documentID: {
- in: [asset.id, reader.id, readerRole.id, setting.id, site.id, user.id],
+ in: [asset.id, reader.id, readerRole.id, setting.id, site.id, childSite.id, user.id],
},
},
],
diff --git a/tests/int/components.int.spec.ts b/tests/int/components.int.spec.ts
index 34f039b..96ceb0a 100644
--- a/tests/int/components.int.spec.ts
+++ b/tests/int/components.int.spec.ts
@@ -390,7 +390,7 @@ describe('asset list interactions', () => {
})
const bar = document.querySelector('.list-selection__actions')!
const button = bar.querySelector('.asset-export-csv')!
- expect(button.textContent).toContain('Download CSV')
+ expect(button.textContent).toContain('Download diagnostic CSV')
await act(async () => button.click())
expect(fetch).toHaveBeenCalledWith('/api/assets/export-csv?where%5Bid%5D%5Bin%5D=abc')
@@ -534,30 +534,27 @@ describe('Otter profile credentials field', () => {
describe('Otter profile interface field', () => {
it('offers the selected scanner adapters and displays their addresses', async () => {
mocks.fieldValue = 'eth0'
+ mocks.formFields = { scanner: { value: 'scanner-201' } }
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
- scanners: [
+ id: 'scanner-201',
+ networkAdapters: [
{
- id: 'scanner-1',
- networkAdapters: [
- {
- name: 'eth0',
- friendlyName: 'Industrial Ethernet',
- description: 'Primary adapter',
- macAddress: '00:11:22:33:44:55',
- ipAddresses: ['192.0.2.10'],
- },
- {
- name: 'eth1',
- friendlyName: 'Service Ethernet',
- description: 'Maintenance adapter',
- macAddress: '00:11:22:33:44:66',
- ipAddresses: ['192.0.2.20'],
- },
- ],
+ name: 'eth0',
+ friendlyName: 'Industrial Ethernet',
+ description: 'Primary adapter',
+ macAddress: '00:11:22:33:44:55',
+ ipAddresses: ['192.0.2.10'],
+ },
+ {
+ name: 'eth1',
+ friendlyName: 'Service Ethernet',
+ description: 'Maintenance adapter',
+ macAddress: '00:11:22:33:44:66',
+ ipAddresses: ['192.0.2.20'],
},
],
}),
@@ -571,6 +568,11 @@ describe('Otter profile interface field', () => {
)
expect(container.textContent).toContain('Primary adapter')
expect(container.textContent).toContain('00:11:22:33:44:55')
+ expect(container.textContent).toContain('operating system route table')
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/otter/v1/scanners/scanner-201',
+ expect.objectContaining({ cache: 'no-store' }),
+ )
const select = container.querySelector('select')!
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set?.call(
@@ -1148,7 +1150,7 @@ describe('remaining Otter component states', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const field = createElement(OtterInterfaceField, { field: {}, path: 'interface' } as never)
await render(field)
- expect(fetch).toHaveBeenCalledWith('/api/otter/v1/overview', expect.anything())
+ expect(fetch).toHaveBeenCalledWith('/api/otter/v1/scanners/scanner-2', expect.anything())
expect(container.textContent).toContain('eth9 · not currently reported')
expect(container.textContent).toContain('not currently reported; its name is preserved')
@@ -1175,13 +1177,9 @@ describe('remaining Otter component states', () => {
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
- scanners: [
- {
- id: 'scanner-3',
- networkAdapters: [
- { name: 'eth0', friendlyName: 'LAN', description: 'Local', ipAddresses: [] },
- ],
- },
+ id: 'scanner-3',
+ networkAdapters: [
+ { name: 'eth0', friendlyName: 'LAN', description: 'Local', ipAddresses: [] },
],
}),
}),
@@ -1201,7 +1199,7 @@ describe('remaining Otter component states', () => {
expect(container.querySelector('.nav__link-indicator')).not.toBeNull()
})
- it('updates multiple SNMP and OPC UA credential rows independently', async () => {
+ it('keeps remaining SNMP and OPC UA credential rows after removing the first row', async () => {
mocks.documentID = 'profile-1'
mocks.fieldValue = false
const fetchMock = vi.fn().mockResolvedValue({
@@ -1217,7 +1215,10 @@ describe('remaining Otter component states', () => {
)!
await act(async () => button.click())
}
- const setLabelInput = async (label: HTMLLabelElement, value: string) => {
+ const setEntryInput = async (entry: ParentNode, name: string, value: string) => {
+ const label = [...entry.querySelectorAll('label')].find((item) =>
+ item.textContent?.trim().startsWith(name),
+ )!
const input = label.querySelector('input')!
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
@@ -1239,43 +1240,54 @@ describe('remaining Otter component states', () => {
await click('Add SNMP credential')
await click('Add SNMP credential')
+ const snmpEntries = container.querySelectorAll(
+ '.otter-credentials__section:first-of-type .otter-credentials__entry',
+ )
const snmpSelects = container.querySelectorAll(
'.otter-credentials__section select',
)
- await setSelect(snmpSelects[0]!, 'auto')
- await setSelect(snmpSelects[1]!, 'sha256')
- await setSelect(snmpSelects[2]!, 'aes128')
- for (const [label, value] of [
- ['Community', 'private'],
- ['Username', 'snmp-user'],
- ['Context name', 'engineering'],
- ['Authentication password', 'auth-secret'],
- ['Privacy password', 'privacy-secret'],
- ]) {
- const field = [...container.querySelectorAll('label')].find((entry) =>
- entry.textContent?.trim().startsWith(label),
- )!
- await setLabelInput(field, value)
+ await setSelect(snmpSelects[0]!, '1')
+ await setSelect(snmpSelects[1]!, 'sha1')
+ await setSelect(snmpSelects[2]!, 'des')
+ await setSelect(snmpSelects[3]!, 'auto')
+ await setSelect(snmpSelects[4]!, 'sha256')
+ await setSelect(snmpSelects[5]!, 'aes128')
+ for (const [entry, values] of [
+ [
+ snmpEntries[0]!,
+ [
+ ['Community', 'discard-community'],
+ ['Username', 'discard-user'],
+ ['Context name', 'discard-context'],
+ ['Authentication password', 'discard-auth'],
+ ['Privacy password', 'discard-privacy'],
+ ],
+ ],
+ [
+ snmpEntries[1]!,
+ [
+ ['Community', 'private'],
+ ['Username', 'snmp-user'],
+ ['Context name', 'engineering'],
+ ['Authentication password', 'auth-secret'],
+ ['Privacy password', 'privacy-secret'],
+ ],
+ ],
+ ] as const) {
+ for (const [name, value] of values) await setEntryInput(entry, name, value)
}
await click('Add OPC UA credential')
await click('Add OPC UA credential')
const opcuaSection = container.querySelectorAll('.otter-credentials__section')[1]!
- const opcuaUsername = [...opcuaSection.querySelectorAll('label')].find((entry) =>
- entry.textContent?.trim().startsWith('Username'),
- )!
- const opcuaPassword = [...opcuaSection.querySelectorAll('label')].find((entry) =>
- entry.textContent?.trim().startsWith('Password'),
- )!
- await setLabelInput(opcuaUsername, 'operator')
- await setLabelInput(opcuaPassword, 'opcua-secret')
-
- for (const label of ['Remove SNMP credential', 'Remove OPC UA credential']) {
- const remove = [...container.querySelectorAll('button')].filter((entry) =>
- entry.textContent?.includes(label),
- )[1]!
- await act(async () => remove.click())
- }
+ const opcuaEntries = opcuaSection.querySelectorAll('.otter-credentials__entry')
+ await setEntryInput(opcuaEntries[0]!, 'Username', 'discard-opcua-user')
+ await setEntryInput(opcuaEntries[0]!, 'Password', 'discard-opcua-password')
+ await setEntryInput(opcuaEntries[1]!, 'Username', 'operator')
+ await setEntryInput(opcuaEntries[1]!, 'Password', 'opcua-secret')
+
+ await act(async () => snmpEntries[0]!.querySelector('button')!.click())
+ await act(async () => opcuaEntries[0]!.querySelector('button')!.click())
await click('Encrypt and save credentials')
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
diff --git a/tests/int/coverage.int.spec.ts b/tests/int/coverage.int.spec.ts
index 71849a1..4e2dce0 100644
--- a/tests/int/coverage.int.spec.ts
+++ b/tests/int/coverage.int.spec.ts
@@ -328,15 +328,15 @@ describe('collection safety hooks', () => {
find: vi
.fn()
.mockResolvedValueOnce({ docs: [] })
- .mockResolvedValueOnce({
+ .mockResolvedValue({
docs: [{ id: 'admin-role', isAdmin: true, name: 'Admin', permissions: [] }],
- })
- .mockResolvedValueOnce({ docs: [{ id: 'user-1' }] }),
+ }),
update: vi.fn(),
}
expect(await ensureAdminRole(payload as never)).toEqual({ id: 'admin-role' })
await initializeAuthorization(payload as never)
- expect(payload.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'user-1' }))
+ expect(payload.create).toHaveBeenCalledTimes(1)
+ expect(payload.update).not.toHaveBeenCalled()
const userReq = {
payload: {
@@ -407,41 +407,25 @@ describe('collection safety hooks', () => {
await expect(invoke(deleteRole, { id: 'role-1', req: roleReq })).resolves.toBeUndefined()
})
- it('seeds asset-class rules and migrates legacy asset fields', async () => {
+ it('seeds asset-class rules without migrating legacy asset fields at startup', async () => {
const updates: unknown[] = []
const payload = {
count: vi.fn().mockResolvedValue({ totalDocs: 1 }),
find: vi
.fn()
- .mockImplementation(
- async ({
- collection,
- where,
- }: {
- collection: string
- where?: { legacyKey?: { equals?: string } }
- }) => {
- if (collection === 'assets') {
- return {
+ .mockImplementation(async ({ where }: { where?: { legacyKey?: { equals?: string } } }) => {
+ return where?.legacyKey?.equals === 'plc'
+ ? {
docs: [
- { id: 'asset-1', assetType: 'plc', fieldProvenance: { assetType: 'low' } },
- { id: 'asset-2', assetType: 42, fieldProvenance: [] },
+ {
+ assignmentRules: [{ manufacturerRegex: 'kept', modelRegex: 'kept' }],
+ id: 'class-plc',
+ ruleSeedVersion: 0,
+ },
],
}
- }
- return where?.legacyKey?.equals === 'plc'
- ? {
- docs: [
- {
- assignmentRules: [{ manufacturerRegex: 'kept', modelRegex: 'kept' }],
- id: 'class-plc',
- ruleSeedVersion: 0,
- },
- ],
- }
- : { docs: [{ assignmentRules: [], id: 'class-1', ruleSeedVersion: 0 }] }
- },
- ),
+ : { docs: [] }
+ }),
update: vi.fn().mockImplementation(async (value: unknown) => {
updates.push(value)
return value
@@ -449,7 +433,7 @@ describe('collection safety hooks', () => {
}
await initializeAssetClasses(payload as never)
- expect(updates.length).toBeGreaterThan(2)
+ expect(updates).toHaveLength(1)
expect(updates).toContainEqual(
expect.objectContaining({
data: expect.objectContaining({
@@ -458,10 +442,6 @@ describe('collection safety hooks', () => {
id: 'class-plc',
}),
)
- expect(updates.at(-1)).toMatchObject({
- data: { assetClass: 'class-1', assetType: null, fieldProvenance: {} },
- id: 'asset-2',
- })
})
it('validates assignment rules and falls back to the Other class', async () => {
@@ -588,15 +568,20 @@ describe('collection safety hooks', () => {
const scan = {
devices: [
{
+ interfaces: [],
macAddress: 'AA:BB:CC:DD:EE:01',
+ macAddresses: ['AA:BB:CC:DD:EE:01'],
+ ipAddresses: [],
observations: [
{
fields: { name: 'PLC 1' },
observedAt: '2026-09-01T08:00:00.000Z',
+ raw: {},
source: 's7',
warnings: [],
},
],
+ ports: [],
},
],
format: 'otserver-scan',
@@ -605,14 +590,30 @@ describe('collection safety hooks', () => {
local: { macAddress: '00:11:22:33:44:55' },
observedAt: '2026-09-01T08:00:00.000Z',
remote: { macAddress: '66:77:88:99:AA:BB' },
+ raw: {},
source: 'lldp',
},
],
- scan: { id: 'scan-1' },
- scanner: { version: '' },
+ scan: {
+ id: '0c66dd2d-b5f2-462a-86ae-d43ef798ccdb',
+ startedAt: '2026-09-01T08:00:00Z',
+ finishedAt: '2026-09-01T08:01:00Z',
+ targets: ['192.0.2.0/24'],
+ interface: { id: 'test', name: 'Test adapter' },
+ },
+ scanner: { name: 'OTserver Otter', version: '0.4.0' },
schemaVersion: 2,
- unresolved: [{ fields: {}, observedAt: '2026-09-01T08:00:00.000Z', source: 'arp' }],
+ unresolved: [
+ {
+ fields: {},
+ observedAt: '2026-09-01T08:00:00.000Z',
+ raw: {},
+ source: 'arp',
+ warnings: [],
+ },
+ ],
warnings: ['interface down'],
+ errors: [],
}
const file = { data: Buffer.from(JSON.stringify(scan)) }
const importReq = (payload: Record) => ({ context: {}, file, payload })
diff --git a/tests/int/device-identity.int.spec.ts b/tests/int/device-identity.int.spec.ts
index b04d9c7..53f9c40 100644
--- a/tests/int/device-identity.int.spec.ts
+++ b/tests/int/device-identity.int.spec.ts
@@ -72,24 +72,41 @@ describe('device identity', () => {
})
it('extracts qualified v2 identities, conserves endpoint ambiguity, and preserves ENTITY-MIB containment', () => {
+ const address = mac()
const observation = {
source: 'ethernet-ip',
observedAt: '2026-09-01T00:00:00Z',
quality: 'high' as const,
- fields: { serialNumber: '000000AB' },
- raw: { vendorId: 1, deviceType: 12, serialNumber: '000000AB' },
+ fields: { macAddress: address, serialNumber: '000000AB' },
+ raw: {
+ vendorId: 1,
+ deviceType: 12,
+ serialNumber: '000000AB',
+ observationId: 'df4f3d70-dd8a-4454-85de-034211f4742d',
+ subject: { kind: 'device', macAddress: address },
+ serialClaim: {
+ scope: 'adapter',
+ manufacturerId: 1,
+ original: '000000AB',
+ encoding: 'cip-uint32-hex',
+ normalizationVersion: 1,
+ },
+ },
}
expect(observationIdentity(observation)?.scope).toBe('adapter')
expect(observationIdentity({ ...observation, raw: {} })).toBeUndefined()
expect(
observationIdentity({
...observation,
- fields: { serialNumber: '00000000' },
- raw: { vendorId: 1, deviceType: 12, serialNumber: '00000000' },
+ fields: { macAddress: address, serialNumber: '00000000' },
+ raw: {
+ ...observation.raw,
+ serialNumber: '00000000',
+ serialClaim: { ...observation.raw.serialClaim, original: '00000000' },
+ },
}),
).toBeUndefined()
expect(observationIdentity({ ...observation, source: 'unknown' })).toBeUndefined()
- const address = mac()
expect(
endpointEvidence({ macAddress: address, ipAddresses: ['bad', '192.0.2.1'] })[0].addresses,
).toEqual([{ address: '192.0.2.1' }])
@@ -130,6 +147,44 @@ describe('device identity', () => {
'13.2': 'CPU',
}).map(([key, value]) => [root + key, value]),
)
+ Object.assign(raw, {
+ observationId: 'df4f3d70-dd8a-4454-85de-034211f4742d',
+ subject: { kind: 'device', macAddress: address },
+ identityComponentRef: 'entity:1',
+ serialClaim: {
+ scope: 'chassis',
+ componentRef: 'entity:1',
+ issuer: 'ACME',
+ original: 'CHASSIS001',
+ encoding: 'snmp-display-string',
+ normalizationVersion: 1,
+ },
+ physicalEntities: [
+ {
+ ref: 'entity:1',
+ entPhysicalIndex: '1',
+ class: 'chassis',
+ serialClaim: {
+ scope: 'chassis',
+ componentRef: 'entity:1',
+ issuer: 'ACME',
+ original: 'CHASSIS001',
+ },
+ },
+ {
+ ref: 'entity:2',
+ entPhysicalIndex: '2',
+ class: 'module',
+ parentRef: 'entity:1',
+ serialClaim: {
+ scope: 'module',
+ componentRef: 'entity:2',
+ issuer: 'ACME',
+ original: 'MODULE001',
+ },
+ },
+ ],
+ })
const entities = expandPhysicalEvidence({
name: 'rack',
macAddress: address,
@@ -149,7 +204,18 @@ describe('device identity', () => {
...observation,
source: 's7',
fields: { serialNumber: 'CPU001' },
- raw: { module: '6ES7-CPU' },
+ raw: {
+ module: '6ES7-CPU',
+ observationId: 'df4f3d70-dd8a-4454-85de-034211f4742d',
+ subject: { kind: 'device', macAddress: address },
+ serialClaim: {
+ scope: 'cpu',
+ issuer: 'Siemens',
+ original: 'CPU001',
+ encoding: 's7-szl-text',
+ normalizationVersion: 1,
+ },
+ },
},
],
})
@@ -263,7 +329,21 @@ describe('device identity', () => {
macAddress,
firmwareVersion: '3.1',
},
- raw: { vendorId: 1, deviceType: 12, serialNumber: hardwareSerial },
+ raw: {
+ vendorId: 1,
+ deviceType: 12,
+ serialNumber: hardwareSerial,
+ observationId: randomUUID(),
+ subject: { kind: 'device', macAddress },
+ serialClaim: {
+ scope: 'adapter',
+ issuer: 'Rockwell Automation',
+ manufacturerId: 1,
+ original: hardwareSerial,
+ encoding: 'cip-uint32-hex',
+ normalizationVersion: 1,
+ },
+ },
warnings: [],
},
],
@@ -320,6 +400,7 @@ describe('device identity', () => {
vendor: 'Siemens AG',
},
raw: { ouiVendor: 'Siemens AG' },
+ warnings: [],
},
{
source: 's7',
@@ -351,6 +432,7 @@ describe('device identity', () => {
subject: { kind: 'device', macAddress },
listeners: [{ address: ipAddress, port: 102, route: [], transport: 'tcp' }],
},
+ warnings: [],
},
],
},
@@ -1119,12 +1201,18 @@ describe('device identity', () => {
it('preserves MAC-free unresolved v2 evidence and offers weak clues only for review', async () => {
const siteID = await site()
const noMAC = scan(serial(), [])
+ noMAC.scan.targets.push('192.0.2.70')
+ const macFreeSerial = `S7-${serial()}`
const root = '1.3.6.1.2.1.47.1.1.1.1.'
const observation = {
- source: 'snmp',
+ source: 's7',
observedAt: '2026-09-10T00:00:00Z',
- fields: {},
+ fields: { ipAddress: '192.0.2.70', serialNumber: macFreeSerial },
raw: {
+ observationId: randomUUID(),
+ subject: { kind: 'target', ipAddress: '192.0.2.70' },
+ module: '6ES7-CPU',
+ serialClaim: { scope: 'cpu', issuer: 'Siemens', original: macFreeSerial },
[`${root}4.1`]: 0,
[`${root}5.1`]: 3,
[`${root}11.1`]: 'Chassis-' + serial(),
@@ -1132,7 +1220,19 @@ describe('device identity', () => {
},
warnings: [],
}
- const input = { ...noMAC, unresolved: [observation, { fields: {} }] }
+ const input = {
+ ...noMAC,
+ unresolved: [
+ observation,
+ {
+ source: 'unknown',
+ observedAt: '2026-09-10T00:00:00Z',
+ fields: {},
+ raw: {},
+ warnings: [],
+ },
+ ],
+ }
expect(parseOTserverOtter(JSON.stringify(input)).assets[0].macAddress).toBeUndefined()
const imported = await upload(siteID, input)
expect(imported.createdAssets).toBe(1)
@@ -1232,6 +1332,15 @@ describe('device identity', () => {
fields: { ipAddress: '192.0.2.70' },
warnings: [],
raw: {
+ observationId: randomUUID(),
+ subject: { kind: 'device', macAddress: input.devices[0]!.macAddress },
+ identityComponentRef: 'entity:1',
+ serialClaim: {
+ scope: 'chassis',
+ componentRef: 'entity:1',
+ issuer: 'Acme',
+ original: chassisSerial,
+ },
[`${root}4.1`]: 0,
[`${root}5.1`]: 3,
[`${root}11.1`]: chassisSerial,
@@ -1244,6 +1353,32 @@ describe('device identity', () => {
[`${root}12.2`]: 'Acme',
[`${root}13.2`]: 'CPU module',
[`${root}9.2`]: '1.2.3',
+ physicalEntities: [
+ {
+ ref: 'entity:1',
+ entPhysicalIndex: '1',
+ class: 'chassis',
+ parentRef: null,
+ serialClaim: {
+ scope: 'chassis',
+ componentRef: 'entity:1',
+ issuer: 'Acme',
+ original: chassisSerial,
+ },
+ },
+ {
+ ref: 'entity:2',
+ entPhysicalIndex: '2',
+ class: 'module',
+ parentRef: 'entity:1',
+ serialClaim: {
+ scope: 'module',
+ componentRef: 'entity:2',
+ issuer: 'Acme',
+ original: moduleSerial,
+ },
+ },
+ ],
},
}
const frame = {
@@ -1523,6 +1658,16 @@ describe('device identity', () => {
expect(panels[2]).toContain('ifIndex:1')
expect(panels[2]).toContain('Confirm maintenance baseline')
expect(panels[3]).not.toContain('Reconcile identity or lifecycle')
+ const networkPanel = renderToStaticMarkup(
+ await DeviceIdentity({ asset: newCPU, payload, user, view: 'network' }),
+ )
+ const hardwarePanel = renderToStaticMarkup(
+ await DeviceIdentity({ asset: newCPU, payload, user, view: 'hardware' }),
+ )
+ expect(networkPanel).toContain('Network identity')
+ expect(networkPanel).not.toContain('Hardware identifiers')
+ expect(hardwarePanel).toContain('Hardware identifiers')
+ expect(hardwarePanel).not.toContain('Current endpoints')
expect(
renderToStaticMarkup(
await DeviceIdentity({
diff --git a/tests/int/identity-evidence.int.spec.ts b/tests/int/identity-evidence.int.spec.ts
index 64c358a..463a06f 100644
--- a/tests/int/identity-evidence.int.spec.ts
+++ b/tests/int/identity-evidence.int.spec.ts
@@ -50,17 +50,46 @@ describe('identity evidence boundaries', () => {
})
it('does not mistake malformed or partial protocol values for physical identity', () => {
+ const macAddress = '00:11:22:33:44:55'
const base = {
source: 'ethernet-ip',
observedAt: '2026-09-01T00:00:00Z',
quality: 'high' as const,
- fields: { serialNumber: '00000042' },
- raw: { vendorId: 1, deviceType: 14, serialNumber: '00000042' },
+ fields: { macAddress, serialNumber: '00000042', vendor: 'Acme' },
+ raw: {
+ vendorId: 1,
+ deviceType: 14,
+ serialNumber: '00000042',
+ observationId: 'df4f3d70-dd8a-4454-85de-034211f4742d',
+ subject: { kind: 'device', macAddress },
+ serialClaim: {
+ scope: 'cpu',
+ issuer: 'Acme',
+ manufacturerId: 1,
+ original: '00000042',
+ },
+ },
}
expect(observationIdentity(base)?.scope).toBe('cpu')
- expect(observationIdentity({ ...base, raw: { ...base.raw, deviceType: 2 } })?.scope).toBe(
- 'device',
- )
+ expect(
+ observationIdentity({
+ ...base,
+ raw: {
+ ...base.raw,
+ deviceType: 2,
+ serialClaim: { ...base.raw.serialClaim, scope: 'device' },
+ },
+ })?.scope,
+ ).toBe('device')
+ expect(
+ observationIdentity({
+ ...base,
+ raw: {
+ ...base.raw,
+ serialClaim: { ...base.raw.serialClaim, scope: 'adapter' },
+ },
+ }),
+ ).toBeUndefined()
expect(observationIdentity({ ...base, raw: { ...base.raw, vendorId: 0 } })).toBeUndefined()
expect(observationIdentity({ ...base, raw: { ...base.raw, vendorId: 1.5 } })).toBeUndefined()
expect(
@@ -75,6 +104,22 @@ describe('identity evidence boundaries', () => {
const root = '1.3.6.1.2.1.47.1.1.1.1.'
const raw = {
unrelated: 'ignored',
+ observationId: 'df4f3d70-dd8a-4454-85de-034211f4742d',
+ subject: { kind: 'device', macAddress: '00:11:22:33:44:55' },
+ physicalEntities: [
+ {
+ ref: 'entity:2',
+ entPhysicalIndex: '2',
+ class: 'module',
+ parentRef: null,
+ serialClaim: {
+ scope: 'module',
+ componentRef: 'entity:2',
+ issuer: 'Acme',
+ original: 'MODULE42',
+ },
+ },
+ ],
[`${root}5.1`]: 6,
[`${root}5.2`]: 9,
[`${root}11.2`]: 'MODULE42',
@@ -87,7 +132,7 @@ describe('identity evidence boundaries', () => {
}
const items = expandPhysicalEvidence({
name: 'Adapter',
- macAddress: '00:11:22:33:44:55',
+ macAddress,
observations: [base, { ...base, source: 'snmp', raw }],
})
expect(items).toHaveLength(2)
@@ -122,7 +167,7 @@ describe('identity evidence boundaries', () => {
).toEqual({ serialNumber: 'X' })
})
- it('derives PROFINET identity from source-qualified I&M serial claims', () => {
+ it('does not promote PROFINET component serial claims to device identity', () => {
const base = {
source: 'profinet-dcp',
observedAt: '2026-09-01T00:00:00Z',
@@ -142,12 +187,7 @@ describe('identity evidence boundaries', () => {
],
},
}
- expect(observationIdentity(base)).toEqual({
- authority: 'profinet',
- manufacturer: '42',
- scope: 'device',
- serial: 'S C-V1X40202',
- })
+ expect(observationIdentity(base)).toBeUndefined()
const im5 = {
...base,
fields: { serialNumber: 'A1B2C3' },
@@ -163,12 +203,7 @@ describe('identity evidence boundaries', () => {
],
},
}
- expect(observationIdentity(im5)).toEqual({
- authority: 'profinet',
- manufacturer: '258',
- scope: 'device',
- serial: 'A1B2C3',
- })
+ expect(observationIdentity(im5)).toBeUndefined()
expect(observationIdentity({ ...base, fields: { serialNumber: 'S C-OTHER' } })).toBeUndefined()
expect(
observationIdentity({
@@ -190,24 +225,156 @@ describe('identity evidence boundaries', () => {
const split = expandPhysicalEvidence({
name: 'PLC',
macAddress: '00:11:22:33:44:55',
- observations: [base, { ...base, source: 's7', raw: { module: '6ES7' } }],
+ observations: [
+ base,
+ {
+ ...base,
+ source: 's7',
+ raw: {
+ module: '6ES7',
+ observationId: 'df4f3d70-dd8a-4454-85de-034211f4742d',
+ subject: { kind: 'device', macAddress: '00:11:22:33:44:55' },
+ serialClaim: {
+ scope: 'cpu',
+ issuer: 'Siemens',
+ original: 'S C-V1X40202',
+ encoding: 's7-szl-text',
+ normalizationVersion: 1,
+ },
+ },
+ },
+ ],
})
- expect(split).toHaveLength(2)
+ expect(split).toHaveLength(1)
expect(split[0].identity).toEqual({
- authority: 'profinet',
- manufacturer: '42',
- scope: 'device',
+ authority: 'siemens',
+ manufacturer: 'siemens',
+ scope: 'cpu',
serial: 'S C-V1X40202',
})
- expect(split[1]).toMatchObject({
- identity: { authority: 'siemens', manufacturer: 'siemens', scope: 'cpu' },
- componentRef: hardwareKey({
- authority: 'siemens',
- manufacturer: 'siemens',
- scope: 'cpu',
- serial: 'S C-V1X40202',
- }),
- observedViaMAC: '00:11:22:33:44:55',
+ })
+
+ it('uses only qualified unique SNMP chassis roots for device identity', () => {
+ const root = '1.3.6.1.2.1.47.1.1.1.1.'
+ const macAddress = '00:11:22:33:44:55'
+ const entity = (index: string, className: string, serial: string) => ({
+ ref: `entity:${index}`,
+ entPhysicalIndex: index,
+ class: className,
+ parentRef: null,
+ serialClaim: {
+ scope: className,
+ componentRef: `entity:${index}`,
+ issuer: 'Acme',
+ original: serial,
+ },
+ })
+ const observation = (classes: Array<[string, number, string, string]>, selected?: string) => {
+ const raw: Record = {
+ observationId: 'df4f3d70-dd8a-4454-85de-034211f4742d',
+ subject: { kind: 'device', macAddress },
+ physicalEntities: classes.map(([index, , className, serial]) =>
+ entity(index, className, serial),
+ ),
+ }
+ for (const [index, classID, , serial] of classes) {
+ raw[`${root}4.${index}`] = 0
+ raw[`${root}5.${index}`] = classID
+ raw[`${root}11.${index}`] = serial
+ raw[`${root}12.${index}`] = 'Acme'
+ }
+ if (selected) {
+ raw.identityComponentRef = `entity:${selected}`
+ raw.serialClaim = {
+ scope: classes.find(([index]) => index === selected)?.[2],
+ componentRef: `entity:${selected}`,
+ issuer: 'Acme',
+ original: classes.find(([index]) => index === selected)?.[3],
+ }
+ }
+ return {
+ source: 'snmp',
+ observedAt: '2026-09-01T00:00:00Z',
+ quality: 'high' as const,
+ fields: { ipAddress: '192.0.2.10' },
+ raw,
+ }
+ }
+
+ const unique = expandPhysicalEvidence({
+ name: 'Switch',
+ macAddress,
+ observations: [observation([['1', 3, 'chassis', 'ROOT1']], '1')],
+ })
+ expect(unique[0].identity).toEqual({
+ authority: 'entity-mib',
+ manufacturer: 'acme',
+ scope: 'chassis',
+ serial: 'ROOT1',
+ })
+
+ const moduleOnly = expandPhysicalEvidence({
+ name: 'Switch',
+ macAddress,
+ observations: [observation([['1', 9, 'module', 'MODULE1']])],
+ })
+ expect(moduleOnly[0].identity).toBeUndefined()
+ expect(moduleOnly[1]).toMatchObject({ identity: { scope: 'module', serial: 'MODULE1' } })
+
+ const ambiguous = expandPhysicalEvidence({
+ name: 'Switch',
+ macAddress,
+ observations: [
+ observation([
+ ['1', 3, 'chassis', 'ROOT1'],
+ ['2', 11, 'stack', 'ROOT2'],
+ ]),
+ ],
+ })
+ expect(ambiguous[0].identity).toBeUndefined()
+ })
+
+ it('retains component identity when an SNMP module is qualified', () => {
+ const component = expandPhysicalEvidence({
+ name: 'Adapter',
+ macAddress: '00:11:22:33:44:55',
+ observations: [
+ {
+ source: 'snmp',
+ observedAt: '2026-09-01T00:00:00Z',
+ quality: 'high',
+ fields: { ipAddress: '192.0.2.10' },
+ raw: {
+ observationId: 'df4f3d70-dd8a-4454-85de-034211f4742d',
+ subject: { kind: 'device', macAddress: '00:11:22:33:44:55' },
+ physicalEntities: [
+ {
+ ref: 'entity:2',
+ entPhysicalIndex: '2',
+ class: 'module',
+ serialClaim: {
+ scope: 'module',
+ componentRef: 'entity:2',
+ issuer: 'Acme',
+ original: 'MODULE42',
+ },
+ },
+ ],
+ '1.3.6.1.2.1.47.1.1.1.1.4.2': 99,
+ '1.3.6.1.2.1.47.1.1.1.1.5.2': 9,
+ '1.3.6.1.2.1.47.1.1.1.1.11.2': 'MODULE42',
+ '1.3.6.1.2.1.47.1.1.1.1.12.2': 'Acme',
+ },
+ },
+ ],
+ })
+ expect(component[1]).toMatchObject({
+ identity: {
+ authority: 'entity-mib',
+ manufacturer: 'acme',
+ scope: 'module',
+ serial: 'MODULE42',
+ },
})
})
diff --git a/tests/int/jobs.int.spec.ts b/tests/int/jobs.int.spec.ts
index 48ff51c..ef23d35 100644
--- a/tests/int/jobs.int.spec.ts
+++ b/tests/int/jobs.int.spec.ts
@@ -11,7 +11,7 @@ import { recoverQueue } from '../../src/jobs/worker'
import { withWorkerFence } from '../../src/integrations/payload/workerContext'
import type { AssetImport } from '../../src/payload-types'
import { retryImport } from '../../src/application/queuedImports'
-import { principalRequest } from '../../src/integrations/payload/requests'
+import { principalRequest, systemRequest } from '../../src/integrations/payload/requests'
const nmap = (mac: string) =>
Buffer.from(`
@@ -40,6 +40,39 @@ const createFixture = async (payload: Payload) => {
}
describe('durable workers and queued imports', () => {
+ it('rejects an old worker write after another worker takes over its expired lease', async () => {
+ const payload = await getPayload({ config })
+ const name = `Fenced worker write ${randomUUID()}`
+ let currentFence: Awaited> = null
+ let staleFence: Awaited> = null
+
+ try {
+ staleFence = await acquireWorker(payload, 'imports')
+ expect(staleFence).not.toBeNull()
+ await renewWorker(payload, staleFence!, true)
+ currentFence = await acquireWorker(payload, 'imports')
+ expect(currentFence).not.toBeNull()
+ expect(currentFence!.owner).not.toBe(staleFence!.owner)
+
+ const req = await systemRequest(payload)
+ await expect(
+ withWorkerFence(staleFence!, () =>
+ payload.create({
+ collection: 'sites',
+ data: { name, type: 'Test' },
+ overrideAccess: true,
+ req,
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 409 })
+ expect(
+ await payload.count({ collection: 'sites', where: { name: { equals: name } } }),
+ ).toMatchObject({ totalDocs: 0 })
+ } finally {
+ if (currentFence) await renewWorker(payload, currentFence, true).catch(() => {})
+ }
+ })
+
it('commits once across redelivery after inventory commit and before acknowledgement', async () => {
process.env.OTSERVER_QUEUED_IMPORTS = 'on'
const payload = await getPayload({ config })
diff --git a/tests/int/migrations.int.spec.ts b/tests/int/migrations.int.spec.ts
index fdf6cd9..9c2e862 100644
--- a/tests/int/migrations.int.spec.ts
+++ b/tests/int/migrations.int.spec.ts
@@ -1,121 +1,132 @@
-import { describe, expect, it, vi } from 'vitest'
+import config from '@/payload.config'
+import { randomUUID } from 'node:crypto'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { createLocalReq, getPayload, type Payload } from 'payload'
+import { ensureAssetClass, initializeAssetClasses } from '../../src/collections/AssetClasses'
+import { ensureAdminRole, initializeAuthorization } from '../../src/collections/UserRoles'
+import { migrations } from '../../src/migrations'
-import { down, up } from '../../src/migrations/20260922_205151_queued_import_baseline'
-import {
- down as removeSitePaths,
- up as addSitePaths,
-} from '../../src/migrations/20260926_111902_site_paths'
-import {
- down as removeOtterIndexes,
- up as addOtterIndexes,
-} from '../../src/migrations/20261010_164200_otter_indexes'
+let payload: Payload
-describe('queued import baseline migration', () => {
- it('adds synchronous defaults and reverses only untouched records', async () => {
- const updateMany = vi.fn(async () => ({ modifiedCount: 1 }))
- const args = {
- payload: {
- db: { collections: { 'asset-imports': { collection: { updateMany } } } },
- },
- session: { id: 'session' },
- }
-
- await up(args as never)
- expect(updateMany).toHaveBeenNthCalledWith(
- 1,
- { executionMode: { $exists: false } },
- { $set: { executionMode: 'sync', attemptCount: 0 } },
- { session: args.session },
- )
-
- await down(args as never)
- expect(updateMany).toHaveBeenNthCalledWith(
- 2,
- { executionMode: 'sync', jobID: { $exists: false } },
- { $unset: { executionMode: '', attemptCount: '' } },
- { session: args.session },
- )
- })
+beforeAll(async () => {
+ payload = await getPayload({ config })
})
-describe('site path migration', () => {
- it('backfills hierarchical paths and removes them on rollback', async () => {
- const docs = [
- { _id: 'line', name: 'Line 1', parent: 'plant' },
- { _id: 'plant', name: 'Plant' },
- ]
- const bulkWrite = vi.fn(async () => ({ modifiedCount: 2 }))
- const updateMany = vi.fn(async () => ({ modifiedCount: 2 }))
- const args = {
- payload: {
- db: {
- collections: {
- sites: {
- collection: {
- bulkWrite,
- find: vi.fn(() => ({ toArray: vi.fn(async () => docs) })),
- updateMany,
- },
- },
- },
- },
+describe('versioned legacy identity migration', () => {
+ it('leaves legacy rows unchanged at startup and migrates populated rows explicitly', async () => {
+ const site = await payload.create({
+ collection: 'sites',
+ data: { name: `Legacy migration ${randomUUID()}`, type: 'Test' },
+ })
+ const childSite = await payload.create({
+ collection: 'sites',
+ data: { name: `Legacy child ${randomUUID()}`, parent: site.id, type: 'Test' },
+ })
+ const adminRole = await ensureAdminRole(payload)
+ const user = await payload.create({
+ collection: 'users',
+ data: {
+ email: `legacy-migration-${randomUUID()}@example.test`,
+ name: 'Legacy user',
+ password: randomUUID(),
+ role: adminRole.id,
},
- session: { id: 'session' },
- }
+ })
+ const initialClass = await ensureAssetClass(payload, 'other')
+ const asset = await payload.create({
+ collection: 'assets',
+ data: {
+ assetClass: initialClass.id,
+ criticality: 'low',
+ name: 'Legacy PLC',
+ site: site.id,
+ status: 'unknown',
+ },
+ })
- await addSitePaths(args as never)
- expect(bulkWrite).toHaveBeenCalledWith(
- [
+ try {
+ await payload.db.collections.users.updateOne({ _id: user.id }, { $unset: { role: '' } })
+ await payload.db.collections.assets.updateOne(
+ { _id: asset.id },
{
- updateOne: {
- filter: { _id: 'line' },
- update: { $set: { path: 'Plant / Line 1' } },
+ $set: {
+ assetType: 'plc',
+ fieldProvenance: { assetType: { quality: 'medium', source: 'nmap' } },
},
+ $unset: { assetClass: '' },
},
- {
- updateOne: { filter: { _id: 'plant' }, update: { $set: { path: 'Plant' } } },
- },
- ],
- { session: args.session },
- )
+ )
+ await initializeAssetClasses(payload)
+ await initializeAuthorization(payload)
+ const legacyAsset = (await payload.db.collections.assets
+ .findById(asset.id)
+ .select({ assetClass: 1 })
+ .lean()) as unknown as Record | null
+ const legacyUser = (await payload.db.collections.users
+ .findById(user.id)
+ .select({ role: 1 })
+ .lean()) as unknown as Record | null
+ expect(legacyAsset).not.toHaveProperty('assetClass')
+ expect(legacyUser).not.toHaveProperty('role')
- await removeSitePaths(args as never)
- expect(updateMany).toHaveBeenCalledWith({}, { $unset: { path: '' } }, { session: args.session })
- })
-})
+ await payload.db.migrate({ migrations: migrations as never })
-describe('Otter index migration', () => {
- it('creates partial unique indexes for credentials and schedule occurrences', async () => {
- const runCollection = { createIndex: vi.fn(async () => 'run-index'), dropIndex: vi.fn() }
- const scannerCollection = {
- createIndex: vi.fn(async () => 'scanner-index'),
- dropIndex: vi.fn(),
- }
- const args = {
- payload: {
- db: {
- collections: {
- 'otter-runs': { collection: runCollection },
- 'otter-scanners': { collection: scannerCollection },
- },
+ const migratedAsset = (await payload.db.collections.assets
+ .findById(asset.id)
+ .lean()) as unknown as Record | null
+ const migratedUser = (await payload.db.collections.users
+ .findById(user.id)
+ .lean()) as unknown as Record | null
+ expect(migratedAsset).not.toBeNull()
+ expect(migratedUser).not.toBeNull()
+ expect(String(migratedAsset!.assetClass)).toBe((await ensureAssetClass(payload, 'plc')).id)
+ expect(migratedAsset!.fieldProvenance).toMatchObject({
+ assetClass: { quality: 'medium', source: 'nmap' },
+ })
+ expect(migratedAsset!.assetType).toBeFalsy()
+ expect(String(migratedUser?.role)).toBe(adminRole.id)
+ const auditCount = await payload.count({
+ collection: 'audit-logs',
+ where: {
+ and: [{ targetCollection: { equals: 'assets' } }, { documentID: { equals: asset.id } }],
},
- },
- session: { id: 'session' },
- }
+ })
+ expect(auditCount.totalDocs).toBeGreaterThan(0)
+ const migrationArgs = {
+ payload,
+ req: await createLocalReq({}, payload),
+ session: undefined,
+ }
+ const baseline = migrations[0]!
+ await baseline.up(migrationArgs as never)
+ await baseline.down(migrationArgs as never)
+ await baseline.up(migrationArgs as never)
- await addOtterIndexes(args as never)
- expect(runCollection.createIndex).toHaveBeenCalledWith(
- { scheduleOccurrenceKey: 1 },
- {
- name: 'otter_run_schedule_occurrence_unique',
- unique: true,
- partialFilterExpression: { scheduleOccurrenceKey: { $type: 'string' } },
- },
- )
- expect(scannerCollection.createIndex).toHaveBeenCalledTimes(2)
- await removeOtterIndexes(args as never)
- expect(runCollection.dropIndex).toHaveBeenCalledWith('otter_run_schedule_occurrence_unique')
- expect(scannerCollection.dropIndex).toHaveBeenCalledWith('otter_scanner_credential_unique')
- expect(scannerCollection.dropIndex).toHaveBeenCalledWith('otter_scanner_enrollment_unique')
+ const sitePaths = migrations[1]!
+ await payload.db.collections.sites.updateOne(
+ { _id: childSite.id },
+ { $set: { parent: childSite.id } },
+ )
+ await expect(sitePaths.up(migrationArgs as never)).rejects.toThrow(
+ 'Cannot migrate cyclic site hierarchy.',
+ )
+ await payload.db.collections.sites.updateOne(
+ { _id: childSite.id },
+ { $set: { parent: site.id } },
+ )
+ await sitePaths.up(migrationArgs as never)
+ await sitePaths.down(migrationArgs as never)
+ await sitePaths.up(migrationArgs as never)
+
+ const otterIndexes = migrations[2]!
+ await otterIndexes.down(migrationArgs as never)
+ await otterIndexes.up(migrationArgs as never)
+ await expect(migrations.at(-1)!.down({} as never)).rejects.toThrow('not reversible')
+ } finally {
+ await payload.delete({ collection: 'assets', id: asset.id })
+ await payload.delete({ collection: 'users', id: user.id })
+ await payload.delete({ collection: 'sites', id: childSite.id })
+ await payload.delete({ collection: 'sites', id: site.id })
+ }
})
})
diff --git a/tests/int/otserver-otter.int.spec.ts b/tests/int/otserver-otter.int.spec.ts
index 14591af..1528475 100644
--- a/tests/int/otserver-otter.int.spec.ts
+++ b/tests/int/otserver-otter.int.spec.ts
@@ -1,4 +1,6 @@
import config from '@/payload.config'
+import Ajv2020 from 'ajv/dist/2020.js'
+import addFormats from 'ajv-formats'
import { randomBytes, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
@@ -278,6 +280,48 @@ describe('OTserver Otter importer', () => {
})
})
+ it('checks shared fixtures against the canonical schema with format validation', async () => {
+ const contractFile = (name: string) =>
+ readFile(new URL(`../../otserver-otter/contracts/${name}`, import.meta.url), 'utf8')
+ const [schemaJSON, validJSON, invalidJSON] = await Promise.all([
+ contractFile('otserver-scan-v2.schema.json'),
+ contractFile('otserver-scan-v2-valid.json'),
+ contractFile('otserver-scan-v2-invalid.json'),
+ ])
+ const ajv = new Ajv2020({
+ allErrors: true,
+ allowUnionTypes: true,
+ strict: true,
+ strictRequired: false,
+ })
+ addFormats(ajv)
+ const validate = ajv.compile(JSON.parse(schemaJSON))
+ const valid = JSON.parse(validJSON)
+ const invalid = JSON.parse(invalidJSON)
+
+ expect(validate(valid)).toBe(true)
+ expect(validate(invalid)).toBe(false)
+ expect(
+ validate.errors?.some(
+ ({ instancePath, keyword }) => instancePath === '/scan/id' && keyword === 'format',
+ ),
+ ).toBe(true)
+ const imported = parseOTserverOtter(validJSON)
+ expect(imported.assets).toHaveLength(3)
+ expect(imported.assets.find(({ identity }) => identity?.scope === 'chassis')).toMatchObject({
+ identity: { authority: 'entity-mib', scope: 'chassis', serial: 'SNMP-ROOT' },
+ })
+ expect(imported.assets.find(({ identity }) => identity?.scope === 'module')).toMatchObject({
+ identity: { authority: 'entity-mib', scope: 'module', serial: 'SNMP-MODULE' },
+ })
+ expect(imported.assets.some(({ serialNumber }) => serialNumber === 'PNIO-SUBMODULE')).toBe(
+ false,
+ )
+ expect(imported.links).toHaveLength(1)
+ expect(imported.unresolved).toHaveLength(1)
+ expect(() => parseOTserverOtter(invalidJSON)).toThrow('metadata are required')
+ })
+
it('validates scanner files and rejects exported credentials', () => {
const file = exportFile(randomMAC(), randomMAC())
expect(parseOTserverOtter(JSON.stringify(file)).assets).toHaveLength(2)
@@ -370,7 +414,7 @@ describe('OTserver Otter importer', () => {
})
})
- it('imports PROFINET DCP and I&M record details from PNIO scans', () => {
+ it('retains PNIO evidence without promoting component serials to device identity', () => {
const localMAC = randomMAC()
const file = mutableExport(localMAC, randomMAC())
file.devices[0].observations = [
@@ -431,35 +475,51 @@ describe('OTserver Otter importer', () => {
const [asset] = parseOTserverOtter(JSON.stringify(file)).assets
expect(asset).toMatchObject({
- catalogNumber: 'ORD-1',
firmwareVersion: 'V1.2.3',
- hardwareVersion: '3',
- identity: {
- authority: 'profinet',
- manufacturer: '4660',
- scope: 'device',
- serial: 'SERIAL',
- },
- serialNumber: 'SERIAL',
vendor: 'Chengdu Zongheng Intelligence Control Technology Co., Ltd.',
})
+ expect(asset.identity).toBeUndefined()
+ expect(asset.serialNumber).toBeUndefined()
expect(asset.observations?.[0]).toMatchObject({
fields: {
manufacturer: 'Chengdu Zongheng Intelligence Control Technology Co., Ltd.',
softwareRevision: 'V1.2.3',
},
mergeFields: {
- catalogNumber: 'ORD-1',
- firmwareVersion: 'V1.2.3',
- hardwareVersion: '3',
protocols: ['profinet'],
- serialNumber: 'SERIAL',
+ firmwareVersion: 'V1.2.3',
vendor: 'Chengdu Zongheng Intelligence Control Technology Co., Ltd.',
},
quality: 'high',
raw: { pnioRecords: expect.any(Array) },
source: 'profinet-dcp',
})
+ const moduleOnly = mutableExport(randomMAC(), randomMAC())
+ moduleOnly.devices[0].observations = [
+ {
+ source: 'profinet-dcp',
+ observedAt: '2026-10-01T16:11:25Z',
+ fields: { manufacturer: 'Acme', serialNumber: 'MODULE-SERIAL' },
+ raw: {
+ pnioRecords: [
+ {
+ api: 7,
+ index: '0xAFF0',
+ slot: 3,
+ subslot: 1,
+ parsed: { manufacturerId: '1234', serialNumber: 'MODULE-SERIAL' },
+ },
+ ],
+ },
+ warnings: [],
+ },
+ ]
+ const [moduleAsset] = parseOTserverOtter(JSON.stringify(moduleOnly)).assets
+ expect(moduleAsset.identity).toBeUndefined()
+ expect(moduleAsset.serialNumber).toBeUndefined()
+ expect(moduleAsset.observations?.[0]?.raw).toMatchObject({
+ pnioRecords: [{ api: 7, slot: 3, subslot: 1 }],
+ })
})
it('validates identities, observations, links, and untrusted field types', () => {
@@ -473,8 +533,10 @@ describe('OTserver Otter importer', () => {
const cleaned = mutableExport(localMAC, remoteMAC)
cleaned.devices = [
{
- interfaces: 'invalid',
+ interfaces: [],
+ macAddresses: [localMAC],
macAddress: localMAC.toLowerCase().replaceAll(':', '-'),
+ ipAddresses: [],
observations: [
{
fields: {
@@ -490,16 +552,17 @@ describe('OTserver Otter importer', () => {
unknown: 'preserved',
},
observedAt: '2026-08-10T10:00:00Z',
+ raw: {},
source: 'future-protocol',
- warnings: ['kept', 1],
+ warnings: ['kept'],
},
],
- ports: null,
+ ports: [],
},
]
cleaned.links = []
- cleaned.warnings = ['warning', 1]
- cleaned.errors = ['error', false]
+ cleaned.warnings = ['warning']
+ cleaned.errors = ['error']
const result = parseOTserverOtter(JSON.stringify(cleaned))
expect(result.assets[0]).toMatchObject({
macAddress: localMAC,
@@ -534,9 +597,24 @@ describe('OTserver Otter importer', () => {
expect(() => parseOTserverOtter(JSON.stringify(duplicate))).toThrow('Duplicate device MAC')
const noObservations = mutableExport(localMAC, remoteMAC)
- noObservations.devices = [{ macAddress: localMAC }]
+ noObservations.devices = [
+ {
+ macAddress: localMAC,
+ macAddresses: [localMAC],
+ ipAddresses: [],
+ observations: [],
+ interfaces: [],
+ ports: [],
+ },
+ ]
expect(() => parseOTserverOtter(JSON.stringify(noObservations))).toThrow('has no observations')
+ const missingRequiredArray = mutableExport(localMAC, remoteMAC)
+ delete missingRequiredArray.devices[0].interfaces
+ expect(() => parseOTserverOtter(JSON.stringify(missingRequiredArray))).toThrow(
+ 'missing required arrays',
+ )
+
const badObservation = mutableExport(localMAC, remoteMAC)
badObservation.devices[0].observations![0].observedAt = 'invalid'
expect(() => parseOTserverOtter(JSON.stringify(badObservation))).toThrow(
@@ -553,7 +631,9 @@ describe('OTserver Otter importer', () => {
const unknownLinkSource = mutableExport(localMAC, remoteMAC)
unknownLinkSource.links[0].source = 1
- expect(parseOTserverOtter(JSON.stringify(unknownLinkSource)).links?.[0].source).toBe('unknown')
+ expect(() => parseOTserverOtter(JSON.stringify(unknownLinkSource))).toThrow(
+ 'links[0] is invalid',
+ )
})
it('imports every contract shape and supported asset field while merging by quality', async () => {
@@ -772,6 +852,184 @@ describe('OTserver Otter importer', () => {
}
})
+ it('imports the shared non-empty contract through identity, topology, and audit persistence', async () => {
+ const payload: Payload = await getPayload({ config })
+ const firstMAC = randomMAC()
+ const secondMAC = randomMAC()
+ let siteID: string | undefined
+ let userID: string | undefined
+ let importID: string | undefined
+
+ try {
+ const adminRole = await ensureAdminRole(payload)
+ const user = await payload.create({
+ collection: 'users',
+ data: {
+ email: `contract-${randomUUID()}@example.test`,
+ name: 'Contract fixture operator',
+ password: randomUUID(),
+ role: adminRole.id,
+ },
+ })
+ userID = user.id
+ const site = await payload.create({
+ collection: 'sites',
+ data: { name: `Contract site ${randomUUID()}`, type: 'Test' },
+ overrideAccess: false,
+ user,
+ })
+ siteID = site.id
+
+ let contents = await readFile(
+ new URL('../../otserver-otter/contracts/otserver-scan-v2-valid.json', import.meta.url),
+ 'utf8',
+ )
+ for (const [original, replacement] of [
+ ['02:10:00:00:00:01', firstMAC],
+ ['02:10:00:00:00:02', secondMAC],
+ ])
+ contents = contents.replaceAll(original, replacement)
+ const file = Buffer.from(contents)
+ const imported = await payload.create({
+ collection: 'asset-imports',
+ data: { site: site.id, source: 'otserver-otter', sourceVersion: '0.4.0' } as never,
+ file: {
+ data: file,
+ mimetype: 'application/json',
+ name: 'shared-contract.json',
+ size: file.length,
+ },
+ overrideAccess: false,
+ user,
+ })
+ importID = imported.id
+ expect(imported).toMatchObject({ createdAssets: 3, status: 'completed', skippedAssets: 1 })
+
+ const assets = await payload.find({
+ collection: 'assets',
+ depth: 0,
+ pagination: false,
+ where: { site: { equals: site.id } },
+ })
+ const chassis = assets.docs.find(({ serialNumber }) => serialNumber === 'SNMP-ROOT')
+ const physicalModule = assets.docs.find(({ serialNumber }) => serialNumber === 'SNMP-MODULE')
+ expect(chassis).toMatchObject({
+ macAddress: firstMAC,
+ physicalKind: 'chassis',
+ fieldProvenance: { vendor: { quality: 'high', source: 'snmp' } },
+ })
+ expect(physicalModule).toMatchObject({ physicalKind: 'module' })
+ const identities = await payload.find({
+ collection: 'asset-identifiers',
+ depth: 0,
+ pagination: false,
+ where: { site: { equals: site.id } },
+ })
+ expect(identities.docs).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ asset: chassis!.id,
+ authority: 'entity-mib',
+ scope: 'chassis',
+ serial: 'SNMP-ROOT',
+ source: 'qualified-otter-v2',
+ }),
+ expect.objectContaining({
+ asset: physicalModule!.id,
+ authority: 'entity-mib',
+ scope: 'module',
+ serial: 'SNMP-MODULE',
+ source: 'qualified-otter-v2',
+ }),
+ ]),
+ )
+ expect(chassis?.serialNumber).not.toBe('PNIO-SUBMODULE')
+
+ const endpoints = await payload.find({
+ collection: 'network-endpoints',
+ depth: 0,
+ pagination: false,
+ where: { site: { equals: site.id } },
+ })
+ expect(endpoints.docs).toHaveLength(2)
+ const installations = await payload.find({
+ collection: 'asset-installations',
+ depth: 0,
+ pagination: false,
+ where: { site: { equals: site.id } },
+ })
+ expect(installations.docs).toHaveLength(1)
+ expect(installations.docs[0]).toMatchObject({
+ parent: chassis!.id,
+ module: physicalModule!.id,
+ })
+ const links = await payload.find({
+ collection: 'topology-links',
+ depth: 0,
+ where: { import: { equals: imported.id } },
+ })
+ expect(links.docs).toHaveLength(1)
+ const observations = await payload.find({
+ collection: 'asset-observations',
+ depth: 0,
+ pagination: false,
+ where: { import: { equals: imported.id } },
+ })
+ expect(observations.docs).toHaveLength(4)
+ expect(observations.docs.find(({ source }) => source === 'profinet-dcp')?.raw).toMatchObject({
+ pnioRecords: [{ api: 7, slot: 3, subslot: 1 }],
+ })
+ const audit = await payload.find({
+ collection: 'audit-logs',
+ depth: 0,
+ pagination: false,
+ where: {
+ and: [
+ { site: { equals: site.id } },
+ { targetCollection: { equals: 'asset-observations' } },
+ ],
+ },
+ })
+ expect(audit.docs).toHaveLength(4)
+ expect(audit.docs.every(({ assetID }) => Boolean(assetID))).toBe(true)
+ } finally {
+ if (importID) {
+ await payload.delete({
+ collection: 'asset-observations',
+ overrideAccess: true,
+ where: { import: { equals: importID } },
+ })
+ await payload.delete({
+ collection: 'topology-links',
+ overrideAccess: true,
+ where: { import: { equals: importID } },
+ })
+ await payload.delete({ collection: 'asset-imports', id: importID })
+ }
+ if (siteID) {
+ await payload.delete({
+ collection: 'asset-installations',
+ overrideAccess: true,
+ where: { site: { equals: siteID } },
+ })
+ const assets = await payload.find({
+ collection: 'assets',
+ depth: 0,
+ pagination: false,
+ where: { site: { equals: siteID } },
+ })
+ for (const asset of assets.docs)
+ await payload.delete({ collection: 'assets', id: asset.id })
+ await payload.delete({
+ collection: 'audit-logs',
+ where: { site: { equals: siteID } },
+ })
+ await payload.delete({ collection: 'sites', id: siteID })
+ }
+ if (userID) await payload.delete({ collection: 'users', id: userID })
+ }
+ })
+
it('normalizes legacy REST uploads while preserving permissions and audit identity', async () => {
const payload: Payload = await getPayload({ config })
const apiKey = randomUUID()
diff --git a/tests/int/otter-management.int.spec.ts b/tests/int/otter-management.int.spec.ts
index b8a2bd6..49c0237 100644
--- a/tests/int/otter-management.int.spec.ts
+++ b/tests/int/otter-management.int.spec.ts
@@ -13,6 +13,7 @@ import {
enrollOtter,
expireOtterLeases,
finishOtterRun,
+ getOtterScanner,
heartbeatOtterRun,
otterOverview,
pollOtter,
@@ -345,27 +346,38 @@ describe('Otter management controller', () => {
})
foreignScannerID = foreignScanner.id
- const enrollmentResponse = await enrollOtter(
- await request('/otter/v1/enroll', {
- body: {
- token: created.enrollmentToken,
- version: '0.9.0',
- capabilities: ['scan-v2', 'controller-v1'],
- interfaces: ['eth0'],
- computerName: 'otter-host-1',
- networkAdapters: [
- {
- name: 'eth0',
- friendlyName: 'Ethernet',
- description: 'Industrial network adapter',
- macAddress: '00:11:22:33:44:55',
- ipAddresses: ['192.0.2.10', '2001:db8::10'],
- },
- ],
- },
- origin: '',
- }),
+ const enrollmentAttempts = await Promise.all(
+ Array.from({ length: 2 }, () =>
+ request('/otter/v1/enroll', {
+ body: {
+ token: created.enrollmentToken,
+ version: '0.9.0',
+ capabilities: ['scan-v2', 'controller-v1'],
+ interfaces: ['eth0'],
+ computerName: 'otter-host-1',
+ networkAdapters: [
+ {
+ name: 'eth0',
+ friendlyName: 'Ethernet',
+ description: 'Industrial network adapter',
+ macAddress: '00:11:22:33:44:55',
+ ipAddresses: ['192.0.2.10', '2001:db8::10'],
+ },
+ ],
+ },
+ origin: '',
+ }),
+ ),
+ )
+ const enrollmentResults = await Promise.allSettled(
+ enrollmentAttempts.map((req) => enrollOtter(req)),
)
+ const successfulEnrollments = enrollmentResults.filter(
+ (result): result is PromiseFulfilledResult => result.status === 'fulfilled',
+ )
+ expect(successfulEnrollments).toHaveLength(1)
+ expect(enrollmentResults.filter((result) => result.status === 'rejected')).toHaveLength(1)
+ const enrollmentResponse = successfulEnrollments[0]!.value
expect(enrollmentResponse.status).toBe(201)
const enrollment = (await enrollmentResponse.json()) as { credential: string }
expect(
@@ -383,6 +395,19 @@ describe('Otter management controller', () => {
},
],
})
+ const scannerDetails = await getOtterScanner(
+ await routeRequest('/otter/v1/scanners', scannerID!, `JWT ${login.token}`),
+ )
+ expect(await scannerDetails.json()).toMatchObject({
+ id: scannerID,
+ computerName: 'otter-host-1',
+ networkAdapters: [expect.objectContaining({ name: 'eth0' })],
+ })
+ await expect(
+ getOtterScanner(
+ await routeRequest('/otter/v1/scanners', scannerID!, `JWT ${readerLogin.token}`),
+ ),
+ ).rejects.toMatchObject({ status: 403 })
await expect(
enrollOtter(await request('/otter/v1/enroll', { body: { token: 42 }, origin: '' })),
).rejects.toMatchObject({ status: 401 })
@@ -937,6 +962,54 @@ describe('Otter management controller', () => {
data: { status: 'running', leaseExpiresAt: '2000-01-01T00:00:00.000Z' },
overrideAccess: true,
})
+ await expect(
+ startOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/start',
+ expiredRunID,
+ `Otter ${enrollment.credential}`,
+ ),
+ ),
+ ).rejects.toMatchObject({ status: 409 })
+ await expect(
+ heartbeatOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/heartbeat',
+ expiredRunID,
+ `Otter ${enrollment.credential}`,
+ {},
+ ),
+ ),
+ ).rejects.toMatchObject({ status: 409 })
+ await expect(
+ finishOtterRun(
+ await routeRequest(
+ '/otter/v1/runs/finish',
+ expiredRunID,
+ `Otter ${enrollment.credential}`,
+ {
+ status: 'completed',
+ },
+ ),
+ ),
+ ).rejects.toMatchObject({ status: 409 })
+ await expect(
+ payload.create({
+ collection: 'asset-imports',
+ data: { otterRun: expiredRunID, sourceVersion: 'managed-test' } as never,
+ file: {
+ data: file,
+ mimetype: 'application/json',
+ name: 'expired.json',
+ size: file.length,
+ },
+ overrideAccess: false,
+ req: await request('/asset-imports', { token: `Otter ${enrollment.credential}` }),
+ }),
+ ).rejects.toMatchObject({ status: 409 })
+ expect(
+ await payload.findByID({ collection: 'otter-runs', id: expiredRunID, depth: 0 }),
+ ).toMatchObject({ status: 'running', leaseExpiresAt: '2000-01-01T00:00:00.000Z' })
await expireOtterLeases(payload)
expect(
await payload.findByID({ collection: 'otter-runs', id: expiredRunID, depth: 0 }),
@@ -1047,19 +1120,40 @@ describe('Otter management controller', () => {
data: { nextRunAt: '2000-01-01T00:00:00.000Z' },
overrideAccess: true,
})
- const scheduledPoll = await pollOtter(
- await request('/otter/v1/poll', {
- body: { version: '0.9.0', capabilities: ['scan-v2'], interfaces: ['eth0'] },
- token: `Otter ${enrollment.credential}`,
- origin: '',
- }),
+ const scheduledPolls = await Promise.allSettled(
+ Array.from({ length: 2 }, async () =>
+ pollOtter(
+ await request('/otter/v1/poll', {
+ body: { version: '0.9.0', capabilities: ['scan-v2'], interfaces: ['eth0'] },
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ ),
+ ),
)
- const scheduledAssignment = (await scheduledPoll.json()) as {
- action: string
- run: { id: string }
- }
+ const successfulPolls = scheduledPolls.filter(
+ (result): result is PromiseFulfilledResult => result.status === 'fulfilled',
+ )
+ expect(successfulPolls.length).toBeGreaterThan(0)
+ const scheduledAssignments = await Promise.all(
+ successfulPolls.map(
+ async ({ value }) => (await value.json()) as { action: string; run: { id: string } },
+ ),
+ )
+ const scheduledAssignment = scheduledAssignments[0]!
+ for (const assignment of scheduledAssignments)
+ expect(assignment).toMatchObject({ action: 'run', run: { id: scheduledAssignment.run.id } })
scheduledRunID = scheduledAssignment.run.id
expect(scheduledAssignment.action).toBe('run')
+ const scheduledOccurrences = await payload.find({
+ collection: 'otter-runs',
+ depth: 0,
+ limit: 10,
+ overrideAccess: true,
+ where: { schedule: { equals: schedule.id } },
+ })
+ expect(scheduledOccurrences.docs).toHaveLength(1)
+ expect(scheduledOccurrences.docs[0]?.scheduleOccurrenceKey).toEqual(expect.any(String))
expect(
await payload.findByID({
collection: 'otter-runs',
@@ -1093,12 +1187,6 @@ describe('Otter management controller', () => {
),
)
expect(await cancellation.json()).toMatchObject({ status: 'cancel-requested' })
- await payload.update({
- collection: 'otter-runs',
- id: scheduledAssignment.run.id,
- data: { leaseExpiresAt: '2000-01-01T00:00:00.000Z' },
- overrideAccess: true,
- })
const cancellationPoll = await pollOtter(
await request('/otter/v1/poll', {
body: {},
@@ -1128,21 +1216,6 @@ describe('Otter management controller', () => {
),
)
expect(await cancelled.json()).toMatchObject({ status: 'cancelled' })
- await payload.update({
- collection: 'otter-schedules',
- id: schedule.id,
- context: { otterSchedulerUpdate: true },
- data: { nextRunAt: '2000-01-01T00:00:00.000Z' },
- overrideAccess: true,
- })
- const duplicateOccurrencePoll = await pollOtter(
- await request('/otter/v1/poll', {
- body: {},
- token: `Otter ${enrollment.credential}`,
- origin: '',
- }),
- )
- expect(await duplicateOccurrencePoll.json()).toMatchObject({ action: 'idle' })
await payload.update({
collection: 'otter-schedules',
id: schedule.id,
@@ -1184,6 +1257,21 @@ describe('Otter management controller', () => {
),
)
expect(await oneTimeFinish.json()).toMatchObject({ status: 'partial' })
+ await payload.update({
+ collection: 'otter-schedules',
+ id: oneTimeSchedule.id,
+ context: { otterSchedulerUpdate: true },
+ data: { enabled: true, nextRunAt: '2000-01-01T00:00:00.000Z' },
+ overrideAccess: true,
+ })
+ const duplicateOccurrencePoll = await pollOtter(
+ await request('/otter/v1/poll', {
+ body: {},
+ token: `Otter ${enrollment.credential}`,
+ origin: '',
+ }),
+ )
+ expect(await duplicateOccurrencePoll.json()).toMatchObject({ action: 'idle' })
const rotation = await rotateOtterCredential(
await request('/otter/v1/credentials/rotate', {
diff --git a/tests/int/process-import-runtime.int.spec.ts b/tests/int/process-import-runtime.int.spec.ts
index 1c122ec..db48540 100644
--- a/tests/int/process-import-runtime.int.spec.ts
+++ b/tests/int/process-import-runtime.int.spec.ts
@@ -70,8 +70,13 @@ describe('processImport runtime boundaries', () => {
id,
...data,
}))
+ const originalContext = {
+ assetImport: false,
+ importWrite: 'outer-operation',
+ identityImportSource: 'previous-source',
+ }
const req = {
- context: {},
+ context: { ...originalContext },
payload: { create, find, update },
}
mocks.parseNmap.mockReturnValue({
@@ -127,6 +132,7 @@ describe('processImport runtime boundaries', () => {
topologyName: undefined,
warnings: 'one warning',
})
+ expect(req.context).toEqual(originalContext)
})
it('rejects an oversized identity import before opening a transaction', async () => {
@@ -136,14 +142,19 @@ describe('processImport runtime boundaries', () => {
warnings: [],
})
+ const req = {
+ context: { assetImport: false, importWrite: 'outer-operation' },
+ payload: {},
+ }
await expect(
processImport({
data: Buffer.from(' '),
doc: doc as never,
- req: { context: {}, payload: {} } as never,
+ req: req as never,
throwValidationErrors: true,
}),
).rejects.toThrow('limited to 2000')
expect(mocks.requireTransaction).not.toHaveBeenCalled()
+ expect(req.context).toEqual({ assetImport: false, importWrite: 'outer-operation' })
})
})
diff --git a/tests/int/topology-view.int.spec.ts b/tests/int/topology-view.int.spec.ts
index 385789f..2b2927f 100644
--- a/tests/int/topology-view.int.spec.ts
+++ b/tests/int/topology-view.int.spec.ts
@@ -3,13 +3,14 @@ import { randomUUID } from 'node:crypto'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { getPayload, type Payload } from 'payload'
-import TopologyView, {
+import TopologyView from '../../src/components/TopologyView'
+import {
buildTopologyGraph,
computeSubnet,
endpointPort,
ipv4ToInt,
maskToPrefix,
-} from '../../src/components/TopologyView'
+} from '../../src/components/TopologyView/graph'
import { ensureAdminRole } from '../../src/collections/UserRoles'
vi.mock('@payloadcms/ui', () => ({ DefaultListView: () => null }))
diff --git a/tests/run-vitest.sh b/tests/run-vitest.sh
new file mode 100644
index 0000000..0c93ccf
--- /dev/null
+++ b/tests/run-vitest.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
+RUNTIME=${CONTAINER_RUNTIME:-}
+export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--no-deprecation"
+
+fail() { printf '%s\n' "$*" >&2; exit 1; }
+
+if [[ -n ${OTSERVER_TEST_DATABASE_OWNER:-} ]]; then
+ [[ -n ${TEST_DATABASE_URL:-} && -n ${OTSERVER_TEST_UPLOAD_DIR:-} && -d ${OTSERVER_TEST_UPLOAD_DIR} ]] || \
+ fail 'An owned test run must provide TEST_DATABASE_URL and an existing OTSERVER_TEST_UPLOAD_DIR.'
+ export DATABASE_URL="$TEST_DATABASE_URL"
+ exec pnpm --dir "$ROOT" exec vitest run --config "$ROOT/vitest.config.mts" "$@"
+fi
+
+if [[ -z "$RUNTIME" ]]; then
+ for candidate in docker podman; do
+ if command -v "$candidate" >/dev/null && "$candidate" info >/dev/null 2>&1; then
+ RUNTIME=$candidate
+ break
+ fi
+ done
+fi
+[[ -n "$RUNTIME" ]] || fail 'Docker or Podman is required to create a disposable test database.'
+"$RUNTIME" info >/dev/null 2>&1 || fail "Container runtime is unavailable: $RUNTIME"
+
+UPLOADS=$(mktemp -d "${TMPDIR:-/tmp}/otserver-test-uploads.XXXXXX")
+RUN_ID=${UPLOADS##*/}
+CONTAINER="${RUN_ID//./-}-mongo"
+CONTAINER_ID=''
+cleanup() {
+ status=$?
+ trap - EXIT INT TERM
+ if [[ -n "$CONTAINER_ID" ]]; then
+ if [[ "$status" -ne 0 ]]; then "$RUNTIME" logs "$CONTAINER_ID" >&2 || true; fi
+ "$RUNTIME" rm --force --volumes "$CONTAINER_ID" >/dev/null 2>&1 || true
+ fi
+ rm -rf -- "$UPLOADS"
+ exit "$status"
+}
+trap cleanup EXIT
+trap 'exit 130' INT
+trap 'exit 143' TERM
+
+CONTAINER_ID=$("$RUNTIME" run --detach --rm --name "$CONTAINER" \
+ --publish 127.0.0.1::27017 docker.io/library/mongo:8 --replSet rs0 --bind_ip_all)
+wait_for_mongo() {
+ local expression=$1
+ for ((attempt = 0; attempt < 60; attempt++)); do
+ if "$RUNTIME" exec "$CONTAINER_ID" mongosh --quiet --eval "$expression" >/dev/null 2>&1; then
+ return
+ fi
+ sleep 1
+ done
+ fail 'Disposable MongoDB did not become ready within 60 seconds.'
+}
+wait_for_mongo 'if (!db.runCommand({ping: 1}).ok) quit(1)'
+"$RUNTIME" exec "$CONTAINER_ID" mongosh --quiet --eval \
+ 'const result = rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]}); if (!result.ok) quit(1)' \
+ >/dev/null
+wait_for_mongo 'if (!db.hello().isWritablePrimary) quit(1)'
+
+ADDRESS=$("$RUNTIME" port "$CONTAINER_ID" 27017/tcp)
+[[ "$ADDRESS" =~ ^127\.0\.0\.1:[0-9]+$ ]] || fail 'Expected one loopback-only MongoDB port mapping.'
+export TEST_DATABASE_URL="mongodb://$ADDRESS/otserver_test?replicaSet=rs0&directConnection=true"
+export DATABASE_URL="$TEST_DATABASE_URL"
+export OTSERVER_TEST_DATABASE_OWNER="$RUN_ID"
+export OTSERVER_TEST_UPLOAD_DIR="$UPLOADS"
+export OTSERVER_SECRET
+OTSERVER_SECRET=$(node --input-type=module -e 'import { randomBytes } from "node:crypto"; console.log(randomBytes(32).toString("hex"))')
+export OTSERVER_OTTER_ENCRYPTION_KEY
+OTSERVER_OTTER_ENCRYPTION_KEY=$(node --input-type=module -e 'import { randomBytes } from "node:crypto"; console.log(randomBytes(32).toString("hex"))')
+export OTSERVER_VULNERABILITY_FEEDS=off
+
+pnpm --dir "$ROOT" exec vitest run --config "$ROOT/vitest.config.mts" "$@"
diff --git a/vitest.config.mts b/vitest.config.mts
index 381cf81..6ba90a6 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -11,6 +11,7 @@ export default defineConfig({
'src/components/TopologyView/**',
'src/environment.d.ts',
'src/importers/types.ts',
+ 'src/migrations/**',
'src/payload-types.ts',
'src/payload.config.ts',
'src/types/**',
@@ -25,6 +26,7 @@ export default defineConfig({
include: ['tests/int/**/*.int.spec.{ts,tsx}'],
setupFiles: ['./vitest.setup.ts'],
// Integration tests boot Payload against a real MongoDB; the 5s default is too tight.
+ hookTimeout: 60_000,
testTimeout: 60_000,
},
})
diff --git a/vitest.setup.ts b/vitest.setup.ts
index 23e34fd..2be61cb 100644
--- a/vitest.setup.ts
+++ b/vitest.setup.ts
@@ -1,6 +1,13 @@
import { existsSync } from 'node:fs'
-if (existsSync('.env')) process.loadEnvFile?.()
-
-// A dedicated replica-set database can be selected without editing the operator's .env.
-if (process.env.TEST_DATABASE_URL) process.env.DATABASE_URL = process.env.TEST_DATABASE_URL
+if (
+ !process.env.OTSERVER_TEST_DATABASE_OWNER ||
+ !process.env.TEST_DATABASE_URL ||
+ process.env.DATABASE_URL !== process.env.TEST_DATABASE_URL ||
+ !process.env.OTSERVER_TEST_UPLOAD_DIR ||
+ !existsSync(process.env.OTSERVER_TEST_UPLOAD_DIR)
+) {
+ throw new Error(
+ 'Tests require a test-owned MongoDB replica set and upload directory. Run them with `pnpm test` or `pnpm test:coverage`.',
+ )
+}
From 0e416a723c3ede667b60816665d713ca7b87e85a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCveyda?=
Date: Tue, 13 Oct 2026 19:22:17 +0200
Subject: [PATCH 4/5] Prettier
---
otserver-otter | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/otserver-otter b/otserver-otter
index 10e9675..c546e2d 160000
--- a/otserver-otter
+++ b/otserver-otter
@@ -1 +1 @@
-Subproject commit 10e967551e12f729b5e3b9336e081abad808dc53
+Subproject commit c546e2d121ceb3f33d38595d02dd911f93fa0f6b
From 2a70e8f8748aae3e6941e62a02f609a740498a7a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=BCveyda?=
Date: Tue, 13 Oct 2026 20:28:53 +0200
Subject: [PATCH 5/5] changed sh to fixed bash
---
package.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index d9a3eef..62ee991 100644
--- a/package.json
+++ b/package.json
@@ -21,11 +21,11 @@
"check:generated": "node scripts/check-generated.mjs",
"test": "pnpm run test:int",
"test:container": "sh tests/container-bootstrap.sh",
- "test:safety": "sh tests/check-test-safety.sh",
+ "test:safety": "bash tests/check-test-safety.sh",
"test:otter-client": "cargo build --locked --no-default-features --manifest-path otserver-otter/Cargo.toml && cross-env OTTER_CLIENT_BIN=otserver-otter/target/debug/otserver-otter OTSERVER_VULNERABILITY_FEEDS=off pnpm test:int tests/int/otter-client.int.spec.ts",
"test:coverage": "pnpm run test:coverage:app",
- "test:coverage:app": "pnpm run test:safety && sh tests/run-vitest.sh --coverage",
- "test:int": "pnpm run test:safety && sh tests/run-vitest.sh"
+ "test:coverage:app": "pnpm run test:safety && bash tests/run-vitest.sh --coverage",
+ "test:int": "pnpm run test:safety && bash tests/run-vitest.sh"
},
"dependencies": {
"@payloadcms/db-mongodb": "3.90.1",