From 07ecde8cf8d1222e6a604bc1e81614d081d8b210 Mon Sep 17 00:00:00 2001 From: rramos Date: Tue, 1 Sep 2026 09:09:03 +0200 Subject: [PATCH 1/5] feat(postgres): replace CouchDB with PostgreSQL Replaces the CouchDB datastore with PostgreSQL, accessed via Spring Data JPA with Flyway-managed schema migrations. - Schema: one table per former CouchDB document type (advisories, advisory_versions, comments, audit_trail_documents, audit_trail_workflows, audit_trail_comments, counters), with the CSAF document itself kept as a JSONB column (src/main/resources/db/migration/V1__initial_schema.sql) - Entities: one JPA entity per table - Repositories: one Spring Data repository per entity, wrapped by PostgresRepositoryService - Service layer: AdvisoryService rewritten against PostgresRepositoryService; CouchDB Mango-selector building in AdvisorySearchUtil was removed in favor of in-application CSAF JSON filter evaluation in AdvisoryService, and AdvisoryWorkflowUtil had its now-unused CouchDB-querying methods removed - Configuration: Flyway owns the schema, Hibernate runs validation-only - Tests: data-layer tests now run against a disposable PostgreSQL testcontainer (PostgreSQLExtension) instead of a CouchDB one - Cleanup: removed CouchDbService, CouchDBFilterCreator and the CouchDB test extension; dependency swapped from the Cloudant SDK to spring-boot-starter-data-jpa, the PostgreSQL driver and Flyway See documents/couchdb-to-postgres-migration.md for the full write-up. --- .env.example | 13 +- README.md | 12 +- docker/.env.example | 39 +- docker/README.md | 56 +- docker/compose.yaml | 33 +- docker/data/cms-db/.gitignore | 3 - documents/couchdb-to-postgres-migration.md | 107 ++ pom.xml | 38 +- .../couchdb/CouchDBFilterCreator.java | 210 ---- .../couchdb/CouchDbService.java | 374 ------ .../csaf_cms_backend/couchdb/DbField.java | 18 +- .../entity/AdvisoryEntity.java | 134 ++ .../entity/AdvisoryVersionEntity.java | 118 ++ .../entity/AuditTrailCommentEntity.java | 92 ++ .../entity/AuditTrailDocumentEntity.java | 118 ++ .../entity/AuditTrailWorkflowEntity.java | 125 ++ .../entity/CommentEntity.java | 127 ++ .../entity/CounterEntity.java | 37 + .../exception/CsafExceptionKey.java | 3 +- .../json/AdvisoryWrapper.java | 2 +- .../csaf_cms_backend/json/CommentWrapper.java | 2 +- .../repository/AdvisoryRepository.java | 35 + .../repository/AdvisoryVersionRepository.java | 16 + .../AuditTrailCommentRepository.java | 19 + .../AuditTrailDocumentRepository.java | 16 + .../AuditTrailWorkflowRepository.java | 16 + .../repository/CommentRepository.java | 18 + .../repository/CounterRepository.java | 26 + .../service/AdvisorySearchUtil.java | 96 +- .../service/AdvisoryService.java | 1073 ++++++++++------- .../service/AdvisoryWorkflowUtil.java | 57 +- .../service/EntityConverter.java | 387 ++++++ .../service/PostgresRepositoryService.java | 416 +++++++ src/main/resources/application.properties | 24 +- .../db/migration/V1__initial_schema.sql | 96 ++ .../csaf_cms_backend/CouchDBExtension.java | 92 -- .../csaf_cms_backend/PostgreSQLExtension.java | 71 ++ .../couchdb/CouchDbServiceTest.java | 411 ------- .../json/AdvisoryWrapperTest.java | 14 +- .../json/CommentWrapperTest.java | 12 +- .../json/RemoveIdHelperTest.java | 2 +- .../template/DocumentTemplateServiceTest.java | 10 +- .../JavascriptExporterNoLogoTest.java | 10 +- .../mustache/JavascriptExporterTest.java | 10 +- .../service/AdvisorySearchUtilTest.java | 4 +- .../AdvisoryServiceExportNoLogoTest.java | 6 +- .../service/AdvisoryServiceTest.java | 303 +++-- ...AdvisoryWorkflowIntegerVersioningTest.java | 41 +- ...dvisoryWorkflowSemanticVersioningTest.java | 6 +- .../service/PandocServiceTest.java | 6 +- .../service/WeasyprintServiceTest.java | 6 +- src/test/resources/application.properties | 12 +- 52 files changed, 3096 insertions(+), 1876 deletions(-) delete mode 100644 docker/data/cms-db/.gitignore create mode 100644 documents/couchdb-to-postgres-migration.md delete mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDBFilterCreator.java delete mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbService.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryVersionEntity.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailCommentEntity.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailDocumentEntity.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailWorkflowEntity.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/CommentEntity.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/CounterEntity.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryRepository.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryVersionRepository.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailCommentRepository.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailDocumentRepository.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailWorkflowRepository.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/CommentRepository.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/CounterRepository.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/EntityConverter.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java create mode 100644 src/main/resources/db/migration/V1__initial_schema.sql delete mode 100644 src/test/java/de/bsi/secvisogram/csaf_cms_backend/CouchDBExtension.java create mode 100644 src/test/java/de/bsi/secvisogram/csaf_cms_backend/PostgreSQLExtension.java delete mode 100644 src/test/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbServiceTest.java diff --git a/.env.example b/.env.example index d153b462..b9486641 100644 --- a/.env.example +++ b/.env.example @@ -10,13 +10,12 @@ # CSAF_CMS_BACKEND_PORT=8081 # CSAF_CMS_BACKEND_CSRF_ENABLED=true -# CouchDB connection used by the backend when running standalone (./mvnw spring-boot:run). -# CSAF_COUCHDB_HOST=localhost -# CSAF_COUCHDB_PORT=5984 -# CSAF_COUCHDB_SSL=false -# CSAF_COUCHDB_DBNAME=csaf -# CSAF_COUCHDB_USER=admin -# CSAF_COUCHDB_PASSWORD=admin +# PostgreSQL connection used by the backend when running standalone (./mvnw spring-boot:run). +# CSAF_DB_HOST=localhost +# CSAF_DB_PORT=5432 +# CSAF_DB_NAME=csaf +# CSAF_DB_USER=csaf +# CSAF_DB_PASSWORD=csaf # keycloak # CSAF_OIDC_ISSUER_URL=http://localhost/realms/csaf diff --git a/README.md b/README.md index bf47e0c0..754da2af 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ It offers a REST service for listing, searching, deleting, creating, commenting ## Getting started > **Just want to run/try the backend locally?** This section describes a manual/production -> setup (building the jar yourself, providing your own Keycloak/CouchDB/proxy infrastructure). +> setup (building the jar yourself, providing your own Keycloak/PostgreSQL/proxy infrastructure). > If you just want to develop against or try out the backend locally with Docker, you don't > need any of the steps below — skip straight to [Developing](#developing) and follow > [docker/README.md](docker/README.md) instead. @@ -28,7 +28,7 @@ To run the CSAF CMS server you need the following: - [Keycloak](https://www.keycloak.org/) - A proxy like [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) -- [CouchDB](https://couchdb.apache.org/) +- [PostgreSQL](https://www.postgresql.org/) You can find an example setup for local development in the 'compose.yaml' and an example configuration for Keycloak in 'docker/config/keycloak/csaf-realm.json'. You can @@ -97,7 +97,7 @@ Duplicates are identified by their tracking ID and not imported again. ## Developing -For the full, step-by-step guide to running the whole stack (CouchDB, Keycloak, oauth2-proxy, +For the full, step-by-step guide to running the whole stack (PostgreSQL, Keycloak, oauth2-proxy, validator service, Secvisogram and the backend) locally with Docker Compose — including the required `.env` setup, cookie secret generation, default users and how to debug the backend on the host — see **[docker/README.md](docker/README.md)**. @@ -148,8 +148,8 @@ OpenAPI specification http://localhost:8081/api-docs -For accessing CouchDB when running the local Docker setup, see -[Accessing CouchDB](docker/README.md#accessing-couchdb) in `docker/README.md`. +For accessing PostgreSQL when running the local Docker setup, see +[Accessing PostgreSQL](docker/README.md#accessing-postgresql) in `docker/README.md`. ## Contributing @@ -173,7 +173,7 @@ You can find our guidelines here [CONTRIBUTING.md](https://github.com/secvisogra For further reference, please consider the following sections: * [Mustache](https://docs.spring.io/spring-boot/docs/2.6.2/reference/htmlsingle/#boot-features-spring-mvc-template-engines) -* [Spring Data Couchbase](https://docs.spring.io/spring-boot/docs/2.6.2/reference/htmlsingle/#boot-features-couchbase) +* [Spring Data JPA](https://docs.spring.io/spring-boot/docs/2.6.2/reference/htmlsingle/#boot-features-jpa-and-spring-data) * [Spring Web](https://docs.spring.io/spring-boot/docs/2.6.2/reference/htmlsingle/#boot-features-developing-web-applications) [(back to top)](#bsi-secvisogram-csaf-backend) diff --git a/docker/.env.example b/docker/.env.example index 5c397dbc..bcb61ed9 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -10,16 +10,16 @@ CSAF_CMS_BACKEND_HOST=backend-cms # csaf-cms-backend service and as the port oauth2-proxy forwards to). CSAF_CMS_BACKEND_PORT=8081 -# CouchDB container credentials. -CSAF_COUCHDB_PORT=5984 -CSAF_COUCHDB_SSL=false -CSAF_COUCHDB_DBNAME=csaf -CSAF_COUCHDB_USER=admin -CSAF_COUCHDB_PASSWORD=admin +# PostgreSQL (CMS Backend) +CSAF_DB_HOST=cms-db.csaf.internal +CSAF_DB_PORT=5432 +CSAF_DB_NAME=csaf +CSAF_DB_USER=csaf +CSAF_DB_PASSWORD=csaf # Keycloak CSAF_KEYCLOAK_PORT=9000 -CSAF_KEYCLOAK_DATABASE_PORT=5432 +CSAF_KEYCLOAK_DATABASE_PORT=5433 CSAF_KEYCLOAK_DATABASE_NAME=keycloak CSAF_KEYCLOAK_DATABASE_USER=keycloak CSAF_KEYCLOAK_DATABASE_PASSWORD=keycloak @@ -40,3 +40,28 @@ CSAF_CLIENT_ID=secvisogram CSAF_CLIENT_SECRET=dev-only-3f8a1c7e2b9d4f6a0e5c8b3d7a1f9e2c # https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview/#generating-a-cookie-secret CSAF_COOKIE_SECRET= + +# document templates +CSAF_TEMPLATES_FILE=./src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/model/template/allTemplates.json +CSAF_COMPANY_LOGO_PATH=./src/test/resources/eXXcellent_solutions.png + +# versioning strategy: Integer or Semantic +CSAF_VERSIONING=Semantic + +# revision history summary an workflow changes +CSAF_SUMMARY_PUBLICATION=Initial Publication + +# validation server url for call from backend +CSAF_VALIDATION_BASE_URL=http://localhost:8082/api/v1 + +# max. levenshtein distance between changed values in the csaf document to decide whether a change is a patch or a minor change +CSAF_VERSIONING_LEVENSHTEIN=4 + +# generation of /document/tracking/id's + +# Base URL (of the server hosting the documents) +CSAF_REFERENCES_BASE_URL=https://example.com +# Company code in the temporary tracking id +CSAF_TRACKINGID_COMPANY=exmpl +# Number of digits of the sequential number of the tracking id. Missing digits are filled with zeros +CSAF_TRACKINGID_DIGITS=7 \ No newline at end of file diff --git a/docker/README.md b/docker/README.md index d32b60f9..3bbaf8c9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,6 +1,6 @@ # Local development with Docker -This guide describes how to run the whole CSAF CMS Backend stack (CouchDB, +This guide describes how to run the whole CSAF CMS Backend stack (PostgreSQL, Keycloak, oauth2-proxy, the validator service, Secvisogram and the backend itself) locally with Docker Compose. **This is the only guide you need if you just want to try out or develop against the backend locally** — you do not @@ -31,7 +31,7 @@ should not be used in production. Container_Boundary(c1, "Backend") { Container(backend, "CSAF-CMS-Backend", "Spring Boot") - ContainerDb(backend-db, "CouchDB", "CMS-Backend-Database") + ContainerDb(backend-db, "PostgreSQL", "CMS-Backend-Database") } } @@ -60,12 +60,12 @@ Configuration is split across two **.env** files, each with its own **.env.example** template: - **`.env`** (repo root): configuration for the backend application itself - (CouchDB connection, OIDC issuer, document templates, versioning, tracking + (PostgreSQL connection, OIDC issuer, document templates, versioning, tracking IDs, workflow flags, ...). This is read both when running the backend on the host (`./mvnw spring-boot:run`) and by the containerized `backend-cms` compose service. - **`docker/.env`**: configuration needed only to orchestrate the local - docker compose stack itself (Keycloak, oauth2-proxy, CouchDB container + docker compose stack itself (Keycloak, oauth2-proxy, PostgreSQL container credentials, ports, ...). Follow these steps **in order**: @@ -77,7 +77,7 @@ Follow these steps **in order**: cp docker/.env.example docker/.env ``` - A few values (e.g. CouchDB credentials, the backend port) intentionally + A few values (e.g. PostgreSQL credentials, the backend port) intentionally appear in both files, with comments cross-referencing each other — keep them in sync if you change them. @@ -87,17 +87,13 @@ Follow these steps **in order**: 3. Start the stack: run `docker compose up -d --build` in folder `docker`. -4. Set up your CouchDB server: open `http://127.0.0.1:5984/_utils/#/setup` - log in with the credentials from `docker/.env` (CSAF_COUCHDB_USER / - CSAF_COUCHDB_PASSWORD) and run the - [Single Node Setup](https://docs.couchdb.org/en/stable/setup/single-node.html). - This creates databases like **_users** and stops CouchDB from spamming - our logs (admin credentials from `docker/.env`). + The `cms-db` PostgreSQL container creates its database, user and password + from `CSAF_DB_NAME` / `CSAF_DB_USER` / `CSAF_DB_PASSWORD` in `docker/.env` + on first start — no manual setup step is needed. The backend then applies + its schema automatically via Flyway migrations on startup (see + [documents/couchdb-to-postgres-migration.md](../documents/couchdb-to-postgres-migration.md)). -5. Create a database in CouchDB with the name specified in - `CSAF_COUCHDB_DBNAME`. - -6. Keycloak is initialized automatically: on startup it imports the `csaf` +4. Keycloak is initialized automatically: on startup it imports the `csaf` realm, the `secvisogram` client, all client roles and the development test users from `docker/config/keycloak/csaf-realm.json` (via `--import-realm`). There is no manual setup step and no need to copy the @@ -106,33 +102,34 @@ Follow these steps **in order**: value** (see `docker/.env.example`). Keycloak imports the realm with this secret and oauth2-proxy is configured with the same value, so both sides always match. If you change it after the first start, - delete the Keycloak database volume (`docker/data/keycloak-db`) so - the realm gets re-imported with the new secret. + delete the Keycloak database volume (`docker compose down` and + `docker volume rm docker_keycloak-db-data`) so the realm gets + re-imported with the new secret. - Note: `--import-realm` only imports the realm if it does not already exist. If you change the realm file later and want it re-imported, remove the Keycloak database volume first: `docker compose down` and - delete `docker/data/keycloak-db`. + `docker volume rm docker_keycloak-db-data`. -7. (optional) Initialize the trusted CSAF provider: +5. (optional) Initialize the trusted CSAF provider: `docker compose up trusted-provider-setup`. - The folder `docker/config/trustedprovider` contains example / development PGP keys. - More details on configuring the trusted provider can be found at [GoCSAF](https://github.com/gocsaf/csaf). -8. (required for exports) Install +6. (required for exports) Install [pandoc (tested with version 2.18)](https://pandoc.org/installing.html) as well as [weasyprint (tested with version 56.0)](https://weasyprint.org/) and make sure both are in your PATH. -9. (optional for exports) Define the path to a company logo that should be +7. (optional for exports) Define the path to a company logo that should be used in the exports through the environment variable `CSAF_COMPANY_LOGO_PATH`. The path can either be relative to the project root or absolute. See `.env.example` for an example. -10. `backend-cms` is now running. If you are actively working on backend - code, see [Debugging the backend](#debugging-the-backend) below for the - faster host-based workflow with IDE debugging. +8. `backend-cms` is now running. If you are actively working on backend + code, see [Debugging the backend](#debugging-the-backend) below for the + faster host-based workflow with IDE debugging. You should now be able to navigate to `http://localhost/api/v1/about`, log in with one of the users below and get a response from the server. @@ -164,12 +161,15 @@ To run/debug the backend on the host (e.g. from an IDE) instead of in its contai To switch back to the containerized backend, set `CSAF_CMS_BACKEND_HOST` back to `backend-cms` and run `docker compose up -d` again. -## Accessing CouchDB +## Accessing PostgreSQL -The port is defined in `docker/.env` (`CSAF_COUCHDB_PORT`, default 5984). +The port is defined in `docker/.env` (`CSAF_DB_PORT`, default 5432). Connect +with `psql` or any PostgreSQL client using the host/port and the credentials +from `docker/.env` (`CSAF_DB_USER` / `CSAF_DB_PASSWORD` / `CSAF_DB_NAME`), e.g.: -- CouchDB admin UI: [http://localhost:5984/_utils/#login](http://localhost:5984/_utils/#login) -- CouchDB info: [http://localhost:5984/](http://localhost:5984/) +```shell +psql -h localhost -p 5432 -U csaf -d csaf +``` ## Building images and running from local source diff --git a/docker/compose.yaml b/docker/compose.yaml index 0b5fca3b..24009b08 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -3,19 +3,20 @@ ############################################################################### services: - cms-couchdb: - image: couchdb:3.4 + cms-db: + image: postgres:17-alpine restart: on-failure env_file: .env environment: - COUCHDB_USER: ${CSAF_COUCHDB_USER} - COUCHDB_PASSWORD: ${CSAF_COUCHDB_PASSWORD} + POSTGRES_DB: ${CSAF_DB_NAME} + POSTGRES_USER: ${CSAF_DB_USER} + POSTGRES_PASSWORD: ${CSAF_DB_PASSWORD} volumes: - - ./data/cms-db:/opt/couchdb/data + - cms-db-data:/var/lib/postgresql/data ports: - - "${CSAF_COUCHDB_PORT}:5984" + - "${CSAF_DB_PORT}:5432" healthcheck: - test: ["CMD-SHELL", "curl http://127.0.0.1:5984/_up || exit 1"] + test: ["CMD-SHELL", "pg_isready -U ${CSAF_DB_USER}"] interval: 10s timeout: 10s retries: 10 @@ -25,7 +26,7 @@ services: image: postgres:17-alpine container_name: keycloak-db volumes: - - ./data/keycloak-db:/var/lib/postgresql/data + - keycloak-db-data:/var/lib/postgresql/data env_file: .env environment: POSTGRES_DB: ${CSAF_KEYCLOAK_DATABASE_NAME} @@ -165,7 +166,7 @@ services: test: ["CMD-SHELL", "wget -O /dev/null http://127.0.0.1:8082/health || exit 1"] interval: 10s timeout: 10s - retries: 10 + retries: 10 backend-cms: image: ghcr.io/secvisogram/csaf-cms-backend:1.1.6 @@ -174,7 +175,7 @@ services: # Internal service names, only reachable from inside this container. The values in ../.env # (localhost-based) remain correct for running the backend on the host via # "./mvnw spring-boot:run" during debugging. - CSAF_COUCHDB_HOST: cms-couchdb + CSAF_DB_HOST: cms-db CSAF_VALIDATION_BASE_URL: "http://validator:8082/api/v1" CSAF_OIDC_JWKS_URL: "http://keycloak:8080/realms/${CSAF_REALM}/protocol/openid-connect/certs" CSAF_AUTOPUBLISH_URL: "http://reverse-proxy/cgi-bin/csaf_provider.go/api/upload" @@ -191,7 +192,7 @@ services: retries: 10 start_period: 30s depends_on: - cms-couchdb: + cms-db: condition: service_healthy keycloak: condition: service_healthy @@ -235,7 +236,7 @@ services: build: context: ./container/trustedprovider dockerfile: Dockerfile - env_file: + env_file: - .env environment: - PUID=1000 @@ -269,4 +270,10 @@ services: depends_on: trusted-provider: condition: service_healthy - + +volumes: + # Named (Docker-managed) volumes rather than host bind-mounts: Postgres's initdb + # hard-requires chmod 0700 on its data directory. Named volumes live in Docker's own Linux-side + # filesystem instead, avoiding any issues with Windows. + cms-db-data: + keycloak-db-data: diff --git a/docker/data/cms-db/.gitignore b/docker/data/cms-db/.gitignore deleted file mode 100644 index 8ff99379..00000000 --- a/docker/data/cms-db/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -_dbs.couch -_nodes.couch -shards/* diff --git a/documents/couchdb-to-postgres-migration.md b/documents/couchdb-to-postgres-migration.md new file mode 100644 index 00000000..41bafce5 --- /dev/null +++ b/documents/couchdb-to-postgres-migration.md @@ -0,0 +1,107 @@ +# CouchDB to PostgreSQL Migration + +## 1 Background + +The backend originally stored all data in a single CouchDB database, with +every document carrying a `type` field (`Advisory`, `AdvisoryVersion`, +`Comment`, `CommentAuditTrail`, `AuditTrailDocument`, `AuditTrailWorkflow`, +`Counter`) to distinguish what it represented. Filtering and searching were +done through CouchDB's Mango selector query language. + +Goals of the migration: + +- Remove the CouchDB-specific query layer (Mango selectors), which does not + support joins or transactions. +- Gain real transactional guarantees for multi-step writes (an advisory + update, its audit trail entry, and its version snapshot should succeed or + fail together). +- Use a relational DB schema for the parts of the data that are naturally + relational (advisories, comments, audit trails), while keeping the CSAF + document itself as a single JSON value, since its structure is defined by + an external specification and varies considerably between documents. + +## 2 Approach + +| Phase | Area | What changed | +|-------|----------------|------------------------------------------------------------------------------------------------------------------------------------------| +| 1 | DB Schema | Seven CouchDB document types became seven tables (Section 3); CSAF content kept as JSONB | +| 2 | Dependencies | Removed the Cloudant SDK; added Spring Data JPA, the PostgreSQL driver, and Flyway | +| 3 | Configuration | Flyway owns the DB schema; Hibernate runs in validation-only mode (Section 3) | +| 4 | Entities | One JPA entity per table | +| 5 | Repositories | One Spring Data repository per entity, wrapped by a single bridge service | +| 6 | Service layer | `AdvisoryService` rewritten against the Phase 5 bridge service; Mango selectors replaced by in-application filter evaluation (Section 4) | +| 7 | Tests | Data-layer tests run against a real, disposable PostgreSQL instance | +| 8 | Data migration | Optional one-time import tool, kept external to the main application (Section 5) | +| 9 | Cleanup | Remove remaining CouchDB-era classes once nothing else depends on them (Section 6) | + +## 3 Target DB Schema + +See file **V1__initial_schema.sql** for the full schema definition. The +following is a summary of the most important points: + +Each of the seven CouchDB document types becomes its own table. The full +CSAF document is kept as a `JSONB` column rather than normalized into +relational columns. + +Indexes cover the columns the application actually filters or joins on: +workflow state, owner, the advisory/comment foreign keys, and the tracking +ID (extracted from the JSON via a functional index, enforced unique at the +database level). + +**Optimistic locking**: CouchDB's `_rev` field is replaced by a plain +`version` column on `advisories` and `comments`, using the standard JPA +`@Version` mechanism. The client-visible "revision" value returned by the +API is simply this number rendered as a string, so the REST contract is +unchanged. + +DB schema changes are managed by Flyway migrations. Hibernate runs in +validation-only mode. + +**How Spring Boot runs migrations**: Flyway is triggered automatically at +application startup, before the rest of the application context finishes +initializing. Migration files live under `src/main/resources/db/migration`, +named `V__.sql` (for example `V1__initial_schema.sql`). +Flyway keeps its own bookkeeping table, `flyway_schema_history`, in the +target database, recording every migration it has applied together with a +checksum of its contents. + +## 4 Query and Filter + +Old query path: + +```text +Filter expression (JSON) -> CouchDB Mango selector -> CouchDB _find +``` + +New query path: the same JSON-encoded filter expression is parsed once per +request and evaluated against each row's CSAF JSON in the application +layer. This keeps the REST API's filter syntax unchanged for existing +clients. + +A malformed filter expression is rejected with a client error matching how +the CouchDB version behaved. + +A GIN index on the CSAF column (`idx_advisories_csaf`, using +`jsonb_path_ops`) already exists to support this kind of query directly in +SQL - containment (`@>`) and jsonpath (`@?`) lookups over arbitrary, deep, +or array-valued CSAF fields - but nothing currently issues one. See +Section 6. + +## 5 One-Time Data Migration + +Moving existing CouchDB data into PostgreSQL is a separate, one-time +concern from the schema and code changes above, and is deliberately kept +out of the main application. The plan (not yet built, see Section 6) is a +separate import tool that would read every document out of the old CouchDB +database over its plain HTTP API, group it by type, and write the +equivalent rows into PostgreSQL, honoring the same foreign-key +relationships as the schema in Section 3. + +## 6 Status and Remaining Work + +| Item | Status | +|---|---------------------------------------------------------------------------------------------------------------------------------| +| Schema, entities, repositories, service layer, configuration, tests | Done | +| One-time data-migration tool (Section 5) | Not built. Only needed if CouchDB data must be preserved | +| Remaining CouchDB-era classes (field-name constants, exception types) | Still in place and in active use. Left there for compatibility with existing code. Can be replaced in a new iteration. | +| Search predicates pushed down into SQL, and server-side paging (Section 4) | Open. The current in-application filtering is correct but requires loading every row before filtering, which does not scale. It also blocks adding real server-side paging: a database-level `LIMIT`/`OFFSET` applied before an in-application filter would cut pages at the wrong rows, so paging can only be added correctly once filtering runs in SQL. | diff --git a/pom.xml b/pom.xml index 1ace0fdf..18c7c9ff 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ de.bsi.csaf csaf-cms-backend jar - 1.1.7-SNAPSHOT + 2.0.0-SNAPSHOT csaf-cms-backend https://github.com/secvisogram/csaf-cms-backend @@ -21,7 +21,6 @@ 4.10.4.1 3.6.0 0.8.15 - 0.10.22 2.2.55 2.2.55 1.15.0 @@ -113,7 +112,6 @@ true - **/CouchDbServiceTest.java **/AdvisoryWorkflowSemanticVersioningTest.java **/AdvisorySearchUtilTest.java @@ -150,9 +148,26 @@ - com.ibm.cloud - cloudant - ${dependency.version.cloudant} + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + runtime + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + org.springframework.boot + spring-boot-flyway @@ -176,6 +191,11 @@ ${dependency.version.springdoc-openapi} + + + commons-codec + commons-codec + @@ -263,6 +283,12 @@ testcontainers-junit-jupiter test + + org.testcontainers + testcontainers-postgresql + ${testcontainers.version} + test + org.springframework.boot spring-boot-configuration-processor diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDBFilterCreator.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDBFilterCreator.java deleted file mode 100644 index 0a0a706f..00000000 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDBFilterCreator.java +++ /dev/null @@ -1,210 +0,0 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; - -import de.bsi.secvisogram.csaf_cms_backend.model.filter.*; -import java.util.*; -import java.util.stream.Collectors; - -/** - * Create the selector part of a CouchDB find expression as Cloudant FindOptions selector - * CouchDB find - * Cloudant postfind - * */ -public class CouchDBFilterCreator { - - /** - * Builder Method for CouchDBFilterCreator - * @param expr expression to convert - * @return Cloudant expression - */ - public static Map expr2CouchDBFilter(Expression expr) { - return new CouchDBFilterCreator().expression2CouchDbFilter(expr); - } - - private final Map operator2CouchDB = Map.of( - TypeOfOperator.Equal, "$eq", - TypeOfOperator.Greater, "$gt", - TypeOfOperator.GreaterOrEqual, "$gte", - TypeOfOperator.Less, "$lt", - TypeOfOperator.LessOrEqual, "$lte", - TypeOfOperator.NotEqual, "$ne", - TypeOfOperator.ContainsIgnoreCase, "$regex" - ); - - - /** - * Builder Method for CouchDBFilterCreator - * @param expr expression to convert - * @param arrayFieldSelectors Selectors that mark an array field in the JSON structure - * @return Cloudant expression - */ - public static Map expr2CouchDBFilter(Expression expr, String[] ... arrayFieldSelectors) { - return new CouchDBFilterCreator(Arrays.asList(arrayFieldSelectors)).expression2CouchDbFilter(expr); - } - - /** - * Selectors that mark an array field in the JSON structure - */ - private final List arrayFieldSelectors; - - public CouchDBFilterCreator() { - - this.arrayFieldSelectors = Collections.emptyList(); - - } - - public CouchDBFilterCreator(List arrayFieldSelectors) { - - this.arrayFieldSelectors = Collections.unmodifiableList(arrayFieldSelectors); - } - - /** - * Create Cloudant expression for given expression - * @param expr the expression to convert - * @return the converted expression object - */ - public Map expression2CouchDbFilter(Expression expr) { - - return expression2Filter(expr); - } - - /** - * Create recursive Cloudant expression for given expression - * @param expr the expression to convert - * @return the converted expression object - */ - private Map expression2Filter(Expression expr) { - - ExpressionHandler> couchDbExprHandler = new ExpressionHandler<>() { - @Override - public Map and(AndExpression andExpr) { - List andExpressions = andExpr.getExpressions() - .stream() - .map(expr -> expression2Filter(expr)) - .collect(Collectors.toList()); - - return createAndExpression(andExpressions); - } - - @Override - public Map or(OrExpression orExpr) { - List orExpressions = orExpr.getExpressions() - .stream() - .map(expr -> expression2Filter(expr)) - .collect(Collectors.toList()); - - return createOrExpression(orExpressions); - } - - @Override - public Map operator(OperatorExpression opExpr) { - return createOperatorExpression(opExpr); - } - }; - return expr.handleExpr(couchDbExprHandler); - } - - /** - * Create Cloudant expression for given operator expression, like - *
-     *     "document": {
-     *             "acknowledgments": {
-     *                "$elemMatch": {
-     *                   "urls": {
-     *                      "$elemMatch": {
-     *                         "$eq": "exccellent.de"
-     *                      }
-     *                   }
-     *                }
-     *             }
-     *          }
-     * 
- * @param opExpr the expression to convert - * @return the converted expression object - */ - private Map createOperatorExpression(OperatorExpression opExpr) { - - final String couchDBOperator = convertOperator(opExpr); - - final Object compareValue; - if (opExpr.getValueType() == TypeOfValue.Decimal) { - compareValue = Double.valueOf(opExpr.getValue()); - } else if (opExpr.getValueType() == TypeOfValue.Boolean) { - compareValue = "true".equals(opExpr.getValue()); - } else if (opExpr.getOperatorType() == TypeOfOperator.ContainsIgnoreCase) { - compareValue = "(?i)^.*" + opExpr.getValue() + ".*"; - } else { - compareValue = opExpr.getValue(); - } - - return createSelectorExpression(opExpr.getSelector(), couchDBOperator, compareValue); - } - - /** - * Create operator selector expression - * @param selector the json selector - * @param couchDBOperator the couchDB operator: $gt, $eq, ... - * @param compareValue the value to compare - * @return the created selector expression - */ - private Map createSelectorExpression(String[] selector, String couchDBOperator, Object compareValue) { - // create subfields selector for nested objects - Map lastOperator = Map.of(couchDBOperator, compareValue); - for (int i = selector.length - 1; i >= 0; i--) { - // a query on an array field needs a $elemMatch to be inserted - if (isArrayFieldSelector(selector, i)) { - lastOperator = Map.of("$elemMatch", lastOperator); - } - lastOperator = Map.of(selector[i], lastOperator); - } - - return lastOperator; - } - - /** - * Checks whether the selector is a path to an array field in the JSON structure - * @param selectorToCheck path to check - * @param toIndex compare selector from 0 to toIndex - * @return true - selector is an array - */ - private boolean isArrayFieldSelector(String[] selectorToCheck, int toIndex) { - - Optional arrayFieldSelector = this.arrayFieldSelectors.stream() - .filter(selector -> Arrays.compare(selectorToCheck, 0, toIndex + 1, selector, 0, selector.length) == 0) - .findFirst(); - - return arrayFieldSelector.isPresent(); - } - - /** Convert expression operator to couchDB operator - * @param opExpr the expression to get the operator - * @return the couchDB operator - * */ - private String convertOperator(OperatorExpression opExpr) { - - String couchDBOperator = operator2CouchDB.get(opExpr.getOperatorType()); - if (couchDBOperator == null) { - throw new RuntimeException("Invalid Operator " + opExpr.getOperatorType().name()); - } - return couchDBOperator; - } - - /** - * Create Cloudant And expression for list of expressions - * @param expressions the expressions to add to the 'and' expression - * @return the cloudant and expression object - */ - private Map createAndExpression(List expressions) { - - return Map.of("$and", expressions); - } - - /** - * Create Cloudant Or expression for list of expressions - * @param expressions the expressions to add to the 'or' expression - * @return the cloudant or expression object - */ - private Map createOrExpression(List expressions) { - - return Map.of("$or", expressions); - } -} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbService.java deleted file mode 100644 index a5bc1a7e..00000000 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbService.java +++ /dev/null @@ -1,374 +0,0 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; - -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDBFilterCreator.expr2CouchDBFilter; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.TYPE_FIELD; -import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.equal; - -import com.ibm.cloud.cloudant.v1.Cloudant; -import com.ibm.cloud.cloudant.v1.model.*; -import com.ibm.cloud.sdk.core.security.BasicAuthenticator; -import com.ibm.cloud.sdk.core.service.exception.BadRequestException; -import com.ibm.cloud.sdk.core.service.exception.ConflictException; -import com.ibm.cloud.sdk.core.service.exception.NotFoundException; -import de.bsi.secvisogram.csaf_cms_backend.json.ObjectType; -import de.bsi.secvisogram.csaf_cms_backend.service.IdAndRevision; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Repository; - -/** - * Service to create, update and delete objects in a couchDB database - */ -@Repository -public class CouchDbService { - - private static final Logger LOG = LoggerFactory.getLogger(CouchDbService.class); - private static final String CLOUDANT_SERVICE_NAME = "SECVISOGRAM"; - - @Value("${csaf.couchdb.dbname}") - private String dbName; - - @Value("${csaf.couchdb.host}") - private String dbHost; - - @Value("${csaf.couchdb.ssl}") - private Boolean dbSsl; - - @Value("${csaf.couchdb.port}") - private int dbPort; - - @Value("${csaf.couchdb.user}") - private String dbUser; - - @Value("${csaf.couchdb.password}") - private String dbPassword; - - /** - * Get the CouchDB connection string - * - * @return CouchDB connection string - */ - private String getDbUrl() { - String protocol = this.dbSsl ? "https://" : "http://"; - return protocol + dbHost + ":" + dbPort; - } - - /** - * Get the version of the couchdb server - * - * @return server version - */ - public String getServerVersion() { - - Cloudant client = createCloudantClient(); - ServerInformation serverInformation = client - .getServerInformation() - .execute() - .getResult(); - - return serverInformation.getVersion(); - } - - /** - * Get the count of all documents in the database - * - * @return count of documents - */ - public Long getDocumentCount() { - - Cloudant client = createCloudantClient(); - GetDatabaseInformationOptions dbInformationOptions = - new GetDatabaseInformationOptions.Builder(this.dbName).build(); - - DatabaseInformation dbInformationResponse = client - .getDatabaseInformation(dbInformationOptions) - .execute() - .getResult(); - - return dbInformationResponse.getDocCount(); - } - - - /** - * Write a new document to the database with a UUID as objectId - * - * @param uuid id fo the new document - * @param createString JSON encoded string of the document to add - * @return revision for concurrent control - */ - public String writeDocument(final UUID uuid, String createString) { - return writeDocument(uuid.toString(), createString); - } - - /** - * Write a new document to the database - * - * @param objectId id fo the new document - * @param createString JSON encoded string of the document to add - * @return revision for concurrent control - */ - public String writeDocument(final String objectId, String createString) { - Cloudant client = createCloudantClient(); - - PutDocumentOptions createDocumentOptions = new PutDocumentOptions.Builder() - .db(this.dbName) - .docId(objectId) - .contentType("application/json") - .body(new ByteArrayInputStream(createString.getBytes(StandardCharsets.UTF_8))) - .build(); - DocumentResult createDocumentResponse = client - .putDocument(createDocumentOptions) - .execute() - .getResult(); - - return createDocumentResponse.getRev(); - } - - - /** - * Change a document in the couchDB - * - * @param updateString the new root node as string - * @return new revision for concurrent control - */ - public String updateDocument(String updateString) throws DatabaseException { - - Cloudant client = createCloudantClient(); - - PostDocumentOptions updateDocumentOptions = - new PostDocumentOptions.Builder() - .db(this.dbName) - .contentType("application/json") - .body(new ByteArrayInputStream(updateString.getBytes(StandardCharsets.UTF_8))) - .build(); - - try { - DocumentResult response = client - .postDocument(updateDocumentOptions) - .execute() - .getResult(); - if (!response.isOk()) { - throw new DatabaseException(response.getError()); - } - return response.getRev(); - } catch (BadRequestException brEx) { - String msg = "Bad request, possibly the given revision is invalid"; - LOG.error(msg); - throw new DatabaseException(msg, brEx); - } catch (ConflictException | NotFoundException nfEx) { - String msg = "No element with given ID"; - LOG.error(msg); - throw new IdNotFoundException(msg, nfEx); - } - } - - /** - * Read a document from the database as stream - * - * @param uuid id of the document to read - * @return the requested document as stream - * @throws IdNotFoundException if the requested document was not found - */ - public InputStream readDocumentAsStream(final String uuid) throws IdNotFoundException { - - Cloudant client = createCloudantClient(); - GetDocumentOptions documentOptions = - new GetDocumentOptions.Builder() - .db(this.dbName) - .docId(uuid) - .build(); - - try { - return client.getDocumentAsStream(documentOptions).execute().getResult(); - } catch (NotFoundException nfEx) { - String msg = "No element with such an ID"; - LOG.error(msg); - throw new IdNotFoundException(msg, nfEx); - } - } - - /** - * Read the information of all documents of a given type - * - * @param type the {@link ObjectType} of objects to retrieve information on - * @param fields the fields of information to select - * @return list of all requested document information - */ - public List readAllDocuments(ObjectType type, Collection fields) { - - Map selector = expr2CouchDBFilter(equal(type.name(), TYPE_FIELD.getDbName())); - return findDocuments(selector, fields); - } - - /** - * Read the information of the documents matching the selector - * - * @param selector the selector to search for - * @param fields the fields of information to select - * @return list of all document information that match the selector - */ - public List findDocuments(Map selector, Collection fields) { - - Cloudant client = createCloudantClient(); - - PostFindOptions findOptions = new PostFindOptions.Builder() - .db(this.dbName) - .selector(selector) - .fields(fields.stream().map(DbField::getDbName).collect(Collectors.toList())) - .limit((long) Integer.MAX_VALUE) - .build(); - - FindResult findDocumentResult = client - .postFind(findOptions) - .execute() - .getResult(); - - return findDocumentResult.getDocs(); - } - - /** - * Read the information of the documents matching the selector - * - * @param selector the selector to search for - * @param fields the fields of information to select - * @return the result as stream - */ - public InputStream findDocumentsAsStream(Map selector, Collection fields) { - - Cloudant client = createCloudantClient(); - - PostFindOptions findOptions = new PostFindOptions.Builder() - .db(this.dbName) - .selector(selector) - .fields(fields.stream().map(DbField::getDbName).collect(Collectors.toList())) - .limit((long) Integer.MAX_VALUE) - .build(); - - return client - .postFindAsStream(findOptions) - .execute() - .getResult(); - } - - /** - * Delete a document from the database - * - * @param uuid id of the object to delete - * @param revision revision of the document to delete - */ - public void deleteDocument(final String uuid, final String revision) throws DatabaseException { - - Cloudant client = createCloudantClient(); - DeleteDocumentOptions documentOptions = - new DeleteDocumentOptions.Builder() - .db(this.dbName) - .docId(uuid) - .rev(revision) - .build(); - - try { - DocumentResult response = client.deleteDocument(documentOptions).execute().getResult(); - if (response.isOk() == null || !response.isOk()) { - throw new DatabaseException(response.getError()); - } - } catch (BadRequestException brEx) { - String msg = "Bad request, possibly the given revision is invalid"; - LOG.error(msg); - throw new DatabaseException(msg, brEx); - } catch (NotFoundException nfEx) { - String msg = "No element with such an ID"; - LOG.error(msg); - throw new IdNotFoundException(msg, nfEx); - } - - } - - /** - * Delete multiple objects from the database - * - * @param objectsToDelete collection of ids and revisions of all documents to delete - * @throws DatabaseException Deletion of at least one object failed - */ - public void bulkDeleteDocuments(final Collection objectsToDelete) throws DatabaseException { - - Cloudant client = createCloudantClient(); - List documents = objectsToDelete.stream() - .map(this::createBulkDelete) - .collect(Collectors.toList()); - - - BulkDocs bulkDocs = new BulkDocs.Builder() - .docs(documents) - .build(); - - PostBulkDocsOptions bulkDocsOptions = new PostBulkDocsOptions.Builder() - .db(this.dbName) - .bulkDocs(bulkDocs) - .build(); - - try { - List responses = - client.postBulkDocs(bulkDocsOptions).execute() - .getResult(); - for (DocumentResult response : responses) { - if (response.isOk() == null || !response.isOk()) { - throw new DatabaseException(response.getError()); - } - } - } catch (BadRequestException brEx) { - String msg = "Bad request, possibly one of the given revisions is invalid"; - LOG.error(msg); - throw new DatabaseException(msg, brEx); - } - } - - /** - * Convert IdAndRevision to delete document - * - * @param object the id and revision to convert - * @return the converted object - */ - private Document createBulkDelete(IdAndRevision object) { - - Document eventDoc1 = new Document(); - eventDoc1.setId(object.getId()); - eventDoc1.setRev(object.getRevision()); - eventDoc1.setDeleted(Boolean.TRUE); - return eventDoc1; - } - - /** - * Create a client to access the couchDB database - * - * @return the new client - */ - private Cloudant createCloudantClient() { - BasicAuthenticator authenticator = createBasicAuthenticator(); - Cloudant cloudant = new Cloudant(CLOUDANT_SERVICE_NAME, authenticator); - cloudant.setServiceUrl(getDbUrl()); - return cloudant; - } - - /** - * Create authenticator for the couchDB database - * - * @return a new base authentication - */ - private BasicAuthenticator createBasicAuthenticator() { - - return new BasicAuthenticator.Builder() - .username(this.dbUser) - .password(this.dbPassword) - .build(); - } - -} \ No newline at end of file diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/DbField.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/DbField.java index 1e252cb4..33176734 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/DbField.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/DbField.java @@ -1,23 +1,19 @@ package de.bsi.secvisogram.csaf_cms_backend.couchdb; -import com.ibm.cloud.cloudant.v1.model.Document; import tools.jackson.databind.JsonNode; +/** + * Interface for database field metadata (field name and JSON path). + * Retained from the CouchDB era for compatibility with wrapper classes. + */ public interface DbField { String getDbName(); - String[] getFieldPath(); - - default Object val(Document couchDbDoc) { - return couchDbDoc.get(this.getDbName()); - } - default String stringVal(Document couchDbDoc) { - return couchDbDoc.get(this.getDbName()).toString(); - } + String[] getFieldPath(); - default String stringVal(JsonNode couchDbDoc) { - return couchDbDoc.get(this.getDbName()).asString(); + default String stringVal(JsonNode doc) { + return doc.get(this.getDbName()).asString(); } } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java new file mode 100644 index 00000000..476d4ab6 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java @@ -0,0 +1,134 @@ +package de.bsi.secvisogram.csaf_cms_backend.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import tools.jackson.databind.JsonNode; + +/** + * JPA entity for the advisories table. + */ +@Entity +@Table(name = "advisories") +public class AdvisoryEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "workflow_state", nullable = false) + private String workflowState = "Draft"; + + @Column(nullable = false) + private String owner; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb", nullable = false) + private JsonNode csaf; + + @Column(name = "versioning_type", nullable = false) + private String versioningType = "Semantic"; + + @Column(name = "last_major_version") + private String lastMajorVersion; + + @Column(name = "tmp_tracking_id") + private String tmpTrackingId; + + @Column(name = "advisory_reference") + private UUID advisoryReference; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Version + private Long version; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getWorkflowState() { + return workflowState; + } + + public void setWorkflowState(String workflowState) { + this.workflowState = workflowState; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public JsonNode getCsaf() { + return csaf; + } + + public void setCsaf(JsonNode csaf) { + this.csaf = csaf; + } + + public String getVersioningType() { + return versioningType; + } + + public void setVersioningType(String versioningType) { + this.versioningType = versioningType; + } + + public String getLastMajorVersion() { + return lastMajorVersion; + } + + public void setLastMajorVersion(String lastMajorVersion) { + this.lastMajorVersion = lastMajorVersion; + } + + public String getTmpTrackingId() { + return tmpTrackingId; + } + + public void setTmpTrackingId(String tmpTrackingId) { + this.tmpTrackingId = tmpTrackingId; + } + + public UUID getAdvisoryReference() { + return advisoryReference; + } + + public void setAdvisoryReference(UUID advisoryReference) { + this.advisoryReference = advisoryReference; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Long getVersion() { + return version; + } + + public void setVersion(Long version) { + this.version = version; + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryVersionEntity.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryVersionEntity.java new file mode 100644 index 00000000..f2f1dec9 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryVersionEntity.java @@ -0,0 +1,118 @@ +package de.bsi.secvisogram.csaf_cms_backend.entity; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import tools.jackson.databind.JsonNode; + +/** + * JPA entity for the advisory_versions table. + */ +@Entity +@Table(name = "advisory_versions") +@SuppressFBWarnings(value = "EI_EXPOSE_REP", + justification = "Entity") +public class AdvisoryVersionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "advisory_id", nullable = false) + private AdvisoryEntity advisory; + + @Column(name = "workflow_state", nullable = false) + private String workflowState; + + @Column(nullable = false) + private String owner; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb", nullable = false) + private JsonNode csaf; + + @Column(name = "versioning_type", nullable = false) + private String versioningType; + + @Column(name = "last_major_version") + private String lastMajorVersion; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public AdvisoryEntity getAdvisory() { + return advisory; + } + + public void setAdvisory(AdvisoryEntity advisory) { + this.advisory = advisory; + } + + public String getWorkflowState() { + return workflowState; + } + + public void setWorkflowState(String workflowState) { + this.workflowState = workflowState; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public JsonNode getCsaf() { + return csaf; + } + + public void setCsaf(JsonNode csaf) { + this.csaf = csaf; + } + + public String getVersioningType() { + return versioningType; + } + + public void setVersioningType(String versioningType) { + this.versioningType = versioningType; + } + + public String getLastMajorVersion() { + return lastMajorVersion; + } + + public void setLastMajorVersion(String lastMajorVersion) { + this.lastMajorVersion = lastMajorVersion; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailCommentEntity.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailCommentEntity.java new file mode 100644 index 00000000..fdba5acd --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailCommentEntity.java @@ -0,0 +1,92 @@ +package de.bsi.secvisogram.csaf_cms_backend.entity; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +/** + * JPA entity for the audit_trail_comments table. + */ +@Entity +@Table(name = "audit_trail_comments") +@SuppressFBWarnings(value = "EI_EXPOSE_REP", + justification = "Entity") +public class AuditTrailCommentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id", nullable = false) + private CommentEntity comment; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "\"user\"", nullable = false) + private String user; + + @Column(name = "change_type", nullable = false) + private String changeType; + + @Column(name = "comment_text") + private String commentText; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public CommentEntity getComment() { + return comment; + } + + public void setComment(CommentEntity comment) { + this.comment = comment; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public String getChangeType() { + return changeType; + } + + public void setChangeType(String changeType) { + this.changeType = changeType; + } + + public String getCommentText() { + return commentText; + } + + public void setCommentText(String commentText) { + this.commentText = commentText; + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailDocumentEntity.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailDocumentEntity.java new file mode 100644 index 00000000..2e71c64b --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailDocumentEntity.java @@ -0,0 +1,118 @@ +package de.bsi.secvisogram.csaf_cms_backend.entity; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import tools.jackson.databind.JsonNode; + +/** + * JPA entity for the audit_trail_documents table. + */ +@Entity +@Table(name = "audit_trail_documents") +@SuppressFBWarnings(value = "EI_EXPOSE_REP", + justification = "Entity") +public class AuditTrailDocumentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "advisory_id", nullable = false) + private AdvisoryEntity advisory; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "\"user\"", nullable = false) + private String user; + + @Column(name = "change_type", nullable = false) + private String changeType; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private JsonNode diff; + + @Column(name = "old_doc_version") + private String oldDocVersion; + + @Column(name = "doc_version") + private String docVersion; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public AdvisoryEntity getAdvisory() { + return advisory; + } + + public void setAdvisory(AdvisoryEntity advisory) { + this.advisory = advisory; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public String getChangeType() { + return changeType; + } + + public void setChangeType(String changeType) { + this.changeType = changeType; + } + + public JsonNode getDiff() { + return diff; + } + + public void setDiff(JsonNode diff) { + this.diff = diff; + } + + public String getOldDocVersion() { + return oldDocVersion; + } + + public void setOldDocVersion(String oldDocVersion) { + this.oldDocVersion = oldDocVersion; + } + + public String getDocVersion() { + return docVersion; + } + + public void setDocVersion(String docVersion) { + this.docVersion = docVersion; + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailWorkflowEntity.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailWorkflowEntity.java new file mode 100644 index 00000000..0510154c --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AuditTrailWorkflowEntity.java @@ -0,0 +1,125 @@ +package de.bsi.secvisogram.csaf_cms_backend.entity; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +/** + * JPA entity for the audit_trail_workflows table. + */ +@Entity +@Table(name = "audit_trail_workflows") +@SuppressFBWarnings(value = "EI_EXPOSE_REP", + justification = "Entity") +public class AuditTrailWorkflowEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "advisory_id", nullable = false) + private AdvisoryEntity advisory; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "\"user\"", nullable = false) + private String user; + + @Column(name = "change_type", nullable = false) + private String changeType; + + @Column(name = "old_state") + private String oldState; + + @Column(name = "new_state") + private String newState; + + @Column(name = "old_doc_version") + private String oldDocVersion; + + @Column(name = "doc_version") + private String docVersion; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public AdvisoryEntity getAdvisory() { + return advisory; + } + + public void setAdvisory(AdvisoryEntity advisory) { + this.advisory = advisory; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public String getChangeType() { + return changeType; + } + + public void setChangeType(String changeType) { + this.changeType = changeType; + } + + public String getOldState() { + return oldState; + } + + public void setOldState(String oldState) { + this.oldState = oldState; + } + + public String getNewState() { + return newState; + } + + public void setNewState(String newState) { + this.newState = newState; + } + + public String getOldDocVersion() { + return oldDocVersion; + } + + public void setOldDocVersion(String oldDocVersion) { + this.oldDocVersion = oldDocVersion; + } + + public String getDocVersion() { + return docVersion; + } + + public void setDocVersion(String docVersion) { + this.docVersion = docVersion; + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/CommentEntity.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/CommentEntity.java new file mode 100644 index 00000000..07d59aba --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/CommentEntity.java @@ -0,0 +1,127 @@ +package de.bsi.secvisogram.csaf_cms_backend.entity; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.util.UUID; + +/** + * JPA entity for the comments table. + */ +@Entity +@Table(name = "comments") +@SuppressFBWarnings(value = "EI_EXPOSE_REP", + justification = "Entity") +public class CommentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "advisory_id", nullable = false) + private AdvisoryEntity advisory; + + @Column(nullable = false) + private String owner; + + @Column(name = "comment_text", nullable = false) + private String commentText; + + @Column(name = "csaf_node_id") + private String csafNodeId; + + @Column(name = "field_name") + private String fieldName; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "answer_to") + private CommentEntity answerTo; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Version + private Long version; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public AdvisoryEntity getAdvisory() { + return advisory; + } + + public void setAdvisory(AdvisoryEntity advisory) { + this.advisory = advisory; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getCommentText() { + return commentText; + } + + public void setCommentText(String commentText) { + this.commentText = commentText; + } + + public String getCsafNodeId() { + return csafNodeId; + } + + public void setCsafNodeId(String csafNodeId) { + this.csafNodeId = csafNodeId; + } + + public String getFieldName() { + return fieldName; + } + + public void setFieldName(String fieldName) { + this.fieldName = fieldName; + } + + public CommentEntity getAnswerTo() { + return answerTo; + } + + public void setAnswerTo(CommentEntity answerTo) { + this.answerTo = answerTo; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Long getVersion() { + return version; + } + + public void setVersion(Long version) { + this.version = version; + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/CounterEntity.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/CounterEntity.java new file mode 100644 index 00000000..16aa63ca --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/CounterEntity.java @@ -0,0 +1,37 @@ +package de.bsi.secvisogram.csaf_cms_backend.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * JPA entity for the counters table. + */ +@Entity +@Table(name = "counters") +public class CounterEntity { + + @Id + @Column(length = 100) + private String id; + + @Column(nullable = false) + private Long count = 0L; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Long getCount() { + return count; + } + + public void setCount(Long count) { + this.count = count; + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/exception/CsafExceptionKey.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/exception/CsafExceptionKey.java index d99adfaa..a0f79f90 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/exception/CsafExceptionKey.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/exception/CsafExceptionKey.java @@ -14,5 +14,6 @@ public enum CsafExceptionKey { SummaryInHistoryEmpty, ErrorCreatingTrackingIdCounter, DuplicateImport, - TrackingIdAlreadyAssigned; + TrackingIdAlreadyAssigned, + CsafHasWrongRevision; } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java index 88b4e186..524e294c 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java @@ -59,7 +59,7 @@ public class AdvisoryWrapper { * @return the wrapper * @throws IOException error in processing the input stream */ - public static AdvisoryWrapper createFromCouchDb(InputStream advisoryStream) throws IOException, CsafException { + public static AdvisoryWrapper createFromStream(InputStream advisoryStream) throws IOException, CsafException { final ObjectMapper jacksonMapper = new JsonMapper(); AdvisoryWrapper advisoryFromDb = new AdvisoryWrapper(jacksonMapper.readValue(advisoryStream, ObjectNode.class)); diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapper.java index 20494190..6f842a34 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapper.java @@ -36,7 +36,7 @@ public class CommentWrapper { * @return the wrapper * @throws IOException error in processing the input stream */ - public static CommentWrapper createFromCouchDb(InputStream commentStream) throws IOException, CsafException { + public static CommentWrapper createFromStream(InputStream commentStream) throws IOException, CsafException { final ObjectMapper jacksonMapper = new JsonMapper(); CommentWrapper wrapperFomDb = new CommentWrapper(jacksonMapper.readValue(commentStream, ObjectNode.class)); diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryRepository.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryRepository.java new file mode 100644 index 00000000..bde39ff6 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryRepository.java @@ -0,0 +1,35 @@ +package de.bsi.secvisogram.csaf_cms_backend.repository; + +import de.bsi.secvisogram.csaf_cms_backend.entity.AdvisoryEntity; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * Spring Data JPA repository for advisories. + */ +public interface AdvisoryRepository extends JpaRepository, + JpaSpecificationExecutor { + + List findByWorkflowState(String workflowState); + + List findByOwner(String owner); + + /** + * Find an advisory by CSAF tracking ID (inside JSONB). + */ + @Query(value = "SELECT * FROM advisories WHERE csaf -> 'document' -> 'tracking' ->> 'id' = :trackingId", + nativeQuery = true) + Optional findByTrackingId(@Param("trackingId") String trackingId); + + /** + * Check if an advisory with a given CSAF tracking ID exists. + */ + @Query(value = "SELECT EXISTS(SELECT 1 FROM advisories WHERE csaf -> 'document' -> 'tracking' ->> 'id' = :trackingId)", + nativeQuery = true) + boolean existsByTrackingId(@Param("trackingId") String trackingId); +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryVersionRepository.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryVersionRepository.java new file mode 100644 index 00000000..e4384415 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryVersionRepository.java @@ -0,0 +1,16 @@ +package de.bsi.secvisogram.csaf_cms_backend.repository; + +import de.bsi.secvisogram.csaf_cms_backend.entity.AdvisoryVersionEntity; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Spring Data JPA repository for advisory version snapshots. + */ +public interface AdvisoryVersionRepository extends JpaRepository { + + List findByAdvisoryId(UUID advisoryId); + + void deleteByAdvisoryId(UUID advisoryId); +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailCommentRepository.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailCommentRepository.java new file mode 100644 index 00000000..4741e2a4 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailCommentRepository.java @@ -0,0 +1,19 @@ +package de.bsi.secvisogram.csaf_cms_backend.repository; + +import de.bsi.secvisogram.csaf_cms_backend.entity.AuditTrailCommentEntity; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Spring Data JPA repository for comment audit trail entries. + */ +public interface AuditTrailCommentRepository extends JpaRepository { + + List findByCommentId(UUID commentId); + + void deleteByCommentId(UUID commentId); + + void deleteByCommentIdIn(Collection commentIds); +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailDocumentRepository.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailDocumentRepository.java new file mode 100644 index 00000000..a90965e4 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailDocumentRepository.java @@ -0,0 +1,16 @@ +package de.bsi.secvisogram.csaf_cms_backend.repository; + +import de.bsi.secvisogram.csaf_cms_backend.entity.AuditTrailDocumentEntity; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Spring Data JPA repository for advisory document audit trail entries. + */ +public interface AuditTrailDocumentRepository extends JpaRepository { + + List findByAdvisoryId(UUID advisoryId); + + void deleteByAdvisoryId(UUID advisoryId); +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailWorkflowRepository.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailWorkflowRepository.java new file mode 100644 index 00000000..4d34c470 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AuditTrailWorkflowRepository.java @@ -0,0 +1,16 @@ +package de.bsi.secvisogram.csaf_cms_backend.repository; + +import de.bsi.secvisogram.csaf_cms_backend.entity.AuditTrailWorkflowEntity; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Spring Data JPA repository for advisory workflow audit trail entries. + */ +public interface AuditTrailWorkflowRepository extends JpaRepository { + + List findByAdvisoryId(UUID advisoryId); + + void deleteByAdvisoryId(UUID advisoryId); +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/CommentRepository.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/CommentRepository.java new file mode 100644 index 00000000..b3565cba --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/CommentRepository.java @@ -0,0 +1,18 @@ +package de.bsi.secvisogram.csaf_cms_backend.repository; + +import de.bsi.secvisogram.csaf_cms_backend.entity.CommentEntity; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Spring Data JPA repository for comments and answers. + */ +public interface CommentRepository extends JpaRepository { + + List findByAdvisoryId(UUID advisoryId); + + List findByAnswerToId(UUID commentId); + + void deleteByAdvisoryId(UUID advisoryId); +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/CounterRepository.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/CounterRepository.java new file mode 100644 index 00000000..081b7d72 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/CounterRepository.java @@ -0,0 +1,26 @@ +package de.bsi.secvisogram.csaf_cms_backend.repository; + +import de.bsi.secvisogram.csaf_cms_backend.entity.CounterEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * Spring Data JPA repository for sequential counters. + * Provides atomic increment to replace the racy read-modify-write CouchDB pattern. + */ +public interface CounterRepository extends JpaRepository { + + /** + * Atomically increment the counter and return the new value. + * This eliminates the race condition present in the CouchDB implementation. + * + *

Note: {@code @Modifying} is intentionally omitted. The {@code RETURNING} clause + * makes this a row-returning query in PostgreSQL, so Spring Data must use + * {@code getResultList()} (SELECT path) rather than {@code executeUpdate()} to + * capture the returned value.

+ */ + @Query(value = "UPDATE counters SET count = count + 1 WHERE id = :id RETURNING count", + nativeQuery = true) + Long incrementAndGet(@Param("id") String id); +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtil.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtil.java index 69e5d4ac..2b7b621f 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtil.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtil.java @@ -1,106 +1,16 @@ package de.bsi.secvisogram.csaf_cms_backend.service; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDBFilterCreator.expr2CouchDBFilter; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.TYPE_FIELD; -import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.equal; - -import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; -import de.bsi.secvisogram.csaf_cms_backend.exception.CsafExceptionKey; -import de.bsi.secvisogram.csaf_cms_backend.json.ObjectType; -import de.bsi.secvisogram.csaf_cms_backend.model.filter.AndExpression; import de.bsi.secvisogram.csaf_cms_backend.model.filter.Expression; -import de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression; -import jakarta.annotation.Nullable; -import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.json.JsonMapper; +/** + * Utility for converting filter expressions to/from JSON. + */ public class AdvisorySearchUtil { - private static final Logger LOG = LoggerFactory.getLogger(AdvisorySearchUtil.class); - private static final String[] selectDocAcknowledgments = {"csaf", "document", "acknowledgments"}; - private static final String[] selectDocAcknowledgmentsNames = {"csaf", "document", "acknowledgments", "names"}; - private static final String[] selectDocAcknowledgmentsUrls = {"csaf", "document", "acknowledgments", "urls"}; - private static final String[] selectDocNotes = {"csaf", "document", "notes"}; - private static final String[] selectDocReferences = {"csaf", "document", "references"}; - private static final String[] selectDocTrackingRev = {"csaf", "document", "tracking", "revision_history"}; - private static final String[] selectDocTrackingAliases = {"csaf", "document", "tracking", "aliases"}; - private static final String[] selectVulnerabilities = {"csaf", "vulnerabilities"}; - private static final String[] selectProductTreeFullProductNames = {"csaf", "product_tree", "full_product_names"}; - private static final String[] selectProductTreeBranches = {"csaf", "product_tree", "branches"}; - - - /** - * Build a CouchDB Mango selector for advisories of the given type that match the optional - * search expression AND an optional visibility constraint expression. - * - * @param expression optional search expression (JSON-encoded); {@code null} or blank means - * no content filter - * @param objectType the advisory type to filter by (Advisory or AdvisoryVersion) - * @param visibilityExpr optional additional visibility filter; {@code null} means no extra - * constraint (caller can see everything) - * @return a ready-to-use Mango selector map - * @throws CsafException if the expression string cannot be parsed - */ - public static Map buildAdvisoryExpression(String expression, ObjectType objectType, - @Nullable Expression visibilityExpr) - throws CsafException { - - try { - final Map resulSelector; - OperatorExpression typeExpr = equal(objectType.name(), TYPE_FIELD.getDbName()); - - if (expression != null && !expression.isBlank()) { - Expression searchExpression = json2Expression(expression); - - if (visibilityExpr != null) { - AndExpression wholeExpr = new AndExpression(typeExpr, searchExpression, visibilityExpr); - resulSelector = expr2CouchDBFilter(wholeExpr, - selectDocAcknowledgments, - selectDocAcknowledgmentsNames, - selectDocAcknowledgmentsUrls, - selectDocNotes, - selectDocReferences, - selectDocTrackingRev, - selectDocTrackingAliases, - selectVulnerabilities, - selectProductTreeFullProductNames, - selectProductTreeBranches); - } else { - AndExpression wholeExpr = new AndExpression(typeExpr, searchExpression); - resulSelector = expr2CouchDBFilter(wholeExpr, - selectDocAcknowledgments, - selectDocAcknowledgmentsNames, - selectDocAcknowledgmentsUrls, - selectDocNotes, - selectDocReferences, - selectDocTrackingRev, - selectDocTrackingAliases, - selectVulnerabilities, - selectProductTreeFullProductNames, - selectProductTreeBranches); - } - } else { - if (visibilityExpr != null) { - resulSelector = expr2CouchDBFilter(new AndExpression(typeExpr, visibilityExpr)); - } else { - resulSelector = expr2CouchDBFilter(typeExpr); - } - } - return resulSelector; - } catch (JacksonException ex) { - LOG.debug("Invalid expression", ex); - throw new CsafException("Invalid filter expression", CsafExceptionKey.InvalidFilterExpression, - HttpStatus.BAD_REQUEST); - } - } - - /** * Convert Search Expression to JSON String * diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java index 9e9f709a..a2b87a38 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java @@ -1,73 +1,66 @@ package de.bsi.secvisogram.csaf_cms_backend.service; -import com.ibm.cloud.sdk.core.service.exception.BadRequestException; -import com.ibm.cloud.sdk.core.service.exception.NotFoundException; import de.bsi.secvisogram.csaf_cms_backend.config.CsafConfiguration; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.*; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.IdNotFoundException; +import de.bsi.secvisogram.csaf_cms_backend.entity.*; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafExceptionKey; import de.bsi.secvisogram.csaf_cms_backend.json.*; import de.bsi.secvisogram.csaf_cms_backend.model.*; import de.bsi.secvisogram.csaf_cms_backend.model.filter.AndExpression; import de.bsi.secvisogram.csaf_cms_backend.model.filter.Expression; +import de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression; +import de.bsi.secvisogram.csaf_cms_backend.model.filter.OrExpression; +import de.bsi.secvisogram.csaf_cms_backend.model.filter.TypeOfOperator; import de.bsi.secvisogram.csaf_cms_backend.mustache.JavascriptExporter; import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateAdvisoryRequest; import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateCommentRequest; import de.bsi.secvisogram.csaf_cms_backend.rest.response.*; import de.bsi.secvisogram.csaf_cms_backend.validator.ValidatorServiceClient; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.info.BuildProperties; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.annotation.Secured; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -import tools.jackson.core.JacksonException; +import org.springframework.transaction.annotation.Transactional; import tools.jackson.databind.JsonNode; -import tools.jackson.databind.MappingIterator; -import tools.jackson.databind.json.JsonMapper; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; import java.io.IOException; -import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.text.SimpleDateFormat; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.*; -import java.util.function.BiConsumer; import java.util.stream.Collectors; import static de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles.Role.AUDITOR; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisoryAuditTrailField.ADVISORY_ID; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisorySearchField.DOCUMENT_TRACKING_ID; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDBFilterCreator.expr2CouchDBFilter; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.ID_FIELD; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.TYPE_FIELD; import static de.bsi.secvisogram.csaf_cms_backend.exception.CsafExceptionKey.*; import static de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus.Final; import static de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus.Interim; -import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.containsIgnoreCase; -import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.equal; import static de.bsi.secvisogram.csaf_cms_backend.service.AdvisoryWorkflowUtil.*; import static java.util.Collections.emptyList; import static org.springframework.http.HttpStatus.*; - @Service public class AdvisoryService { private static final Logger LOG = LoggerFactory.getLogger(AdvisoryService.class); + @Autowired - private CouchDbService couchDbService; + private PostgresRepositoryService postgresService; @Autowired private JavascriptExporter javascriptExporter; @@ -128,12 +121,13 @@ public TrackingIdAssignmentPhase getTrackingIdAssignmentPhase() { private BuildProperties buildProperties; /** - * get number of documents + * Get the total number of records across all tables (advisories, comments, audit trails, counters). + * Used primarily by integration tests to verify correct creation/deletion of records. * - * @return number of all documents in the DB + * @return total count of all entities in the DB */ - public Long getDocumentCount() { - return couchDbService.getDocumentCount(); + public long getDocumentCount() { + return postgresService.getTotalDocumentCount(); } /** @@ -142,21 +136,23 @@ public Long getDocumentCount() { * @return a list of information objects */ @Secured({CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUDITOR}) - public List getAdvisoryInformations(String expression) throws IOException, CsafException { + public List getAdvisoryInformations(String expression) + throws IOException, CsafException { Authentication credentials = getAuthentication(); + Expression parsedExpr = parseFilterExpression(expression); Expression visibilityExpr = AdvisoryWorkflowUtil.buildVisibilityExpression(credentials); - // Visibility filtering is now pushed into the DB query via buildAdvisoryExpression(credentials). - List allAdvisories = readAllAdvisories(expression, ObjectType.Advisory, visibilityExpr); - // set calculated fields in response + List allAdvisories = + readAllAdvisories(parsedExpr, ObjectType.Advisory, visibilityExpr, credentials); for (AdvisoryInformationResponse response : allAdvisories) { enrichAdvisory(response, credentials); } List allResponses = new ArrayList<>(allAdvisories); if (hasRole(AUDITOR, credentials)) { - List allAdvisoryVersions = readAllAdvisories(expression, ObjectType.AdvisoryVersion, null); + List allAdvisoryVersions = + readAllAdvisoryVersions(parsedExpr, credentials); for (AdvisoryInformationResponse response : allAdvisoryVersions) { enrichAdvisoryVersion(response); } @@ -165,18 +161,195 @@ public List getAdvisoryInformations(String expressi return allResponses; } - private List readAllAdvisories(String expression, ObjectType objectType, - Expression visibilityExpr) - throws CsafException, IOException { + private Expression parseFilterExpression(String expression) throws CsafException { + if (expression == null || expression.isBlank()) { + return null; + } + try { + return AdvisorySearchUtil.json2Expression(expression); + } catch (Exception e) { + throw new CsafException("Invalid filter expression", InvalidFilterExpression, BAD_REQUEST); + } + } - Map> infoFields = AdvisoryWorkflowUtil.advisoryReadFields(); - Map selector = AdvisorySearchUtil.buildAdvisoryExpression(expression, objectType, visibilityExpr); - List docList = this.findDocuments(selector, new ArrayList<>(infoFields.keySet())); - return docList.stream() - .map(couchDbDoc -> AdvisoryWrapper.convertToAdvisoryInfo(couchDbDoc, infoFields)) + private List readAllAdvisories( + Expression parsedExpr, + ObjectType objectType, + Expression visibilityExpr, + Authentication credentials) { + + List entities = postgresService.findAllAdvisories(); + return entities.stream() + .filter(entity -> matchesCsafExpression(entity.getCsaf(), parsedExpr)) + .map(EntityConverter::toAdvisoryInfo) + .filter(info -> matchesVisibility(info, visibilityExpr, credentials)) .toList(); } + private List readAllAdvisoryVersions( + Expression parsedExpr, + Authentication credentials) { + + // Advisory versions are immutable snapshots in the advisory_versions table, + // created each time a new version cycle begins via createNewCsafDocumentVersion. + return postgresService.findAllAdvisoryVersions().stream() + .filter(entity -> matchesCsafExpression(entity.getCsaf(), parsedExpr)) + .map(EntityConverter::toAdvisoryVersionInfo) + .toList(); + } + + /** + * Check whether an advisory info item passes the visibility expression filter. + * When visibilityExpr is null the caller can see everything. + */ + private boolean matchesVisibility(AdvisoryInformationResponse info, + Expression visibilityExpr, + Authentication credentials) { + + if (visibilityExpr == null) { + return true; + } + // Re-use the canViewAdvisory logic which already encodes all visibility rules + return AdvisoryWorkflowUtil.canViewAdvisory( + info.getOwner(), + info.getWorkflowState(), + credentials, + info.getCurrentReleaseDate() + ); + } + + /** + * Check whether the entity's full CSAF JSON matches an already-parsed filter expression. + * This enables filtering by arbitrary deep paths (acknowledgments, vulnerabilities, + * product_tree, etc.) that are not available in the flattened {@link AdvisoryInformationResponse}. + * + *

The expression is parsed once, up front, by {@link #parseFilterExpression} rather than + * per entity here: a malformed expression must be rejected with a proper error, and a + * checked {@link CsafException} cannot be thrown from inside a stream {@code Predicate}.

+ * + * @param csafJson the entity's CSAF JSON ({@code JsonNode}) + * @param parsedExpr the already-parsed expression, or {@code null} when no filter was supplied + * @return {@code true} if the CSAF JSON matches the expression or no expression was supplied + */ + private boolean matchesCsafExpression(JsonNode csafJson, Expression parsedExpr) { + + if (parsedExpr == null) { + return true; + } + return evaluateCsafExpression(parsedExpr, csafJson); + } + + /** + * Recursively evaluate an {@link Expression} against the entity's CSAF JSON. + */ + private boolean evaluateCsafExpression(Expression expr, JsonNode csafJson) { + + if (expr instanceof OperatorExpression opExpr) { + return evaluateCsafOperator(opExpr, csafJson); + } + if (expr instanceof AndExpression andExpr) { + for (Expression child : andExpr.getExpressions()) { + if (!evaluateCsafExpression(child, csafJson)) { + return false; + } + } + return true; + } + if (expr instanceof OrExpression orExpr) { + for (Expression child : orExpr.getExpressions()) { + if (evaluateCsafExpression(child, csafJson)) { + return true; + } + } + return false; + } + return true; + } + + /** + * Evaluate a single operator expression against the CSAF JSON. + * The selector path starts with "csaf" (e.g. ["csaf","document","title"]); + * the "csaf" prefix is stripped and the remainder is used to navigate the JSON tree. + * Array nodes are traversed recursively so that a match in any element qualifies. + */ + private boolean evaluateCsafOperator( + OperatorExpression opExpr, + JsonNode csafJson) { + + String[] path = opExpr.getSelector(); + String filterValue = opExpr.getValue(); + + // Strip "csaf" prefix; remaining segments address the CSAF JSON + int startIdx = (path.length > 0 && "csaf".equals(path[0])) ? 1 : 0; + String[] csafPath = Arrays.copyOfRange(path, startIdx, path.length); + + List values = collectJsonValues(csafJson, csafPath, 0); + if (values.isEmpty()) { + // Path not found → cannot evaluate; pass through + return true; + } + + return values.stream().anyMatch(value -> matchesOperator(opExpr.getOperatorType(), value, filterValue)); + } + + /** + * Collect all text values reachable by following the given path segments through the JSON tree. + * When an array node is encountered at any level, the remaining path is applied to each element. + */ + private List collectJsonValues(JsonNode node, String[] pathSegments, int segmentIndex) { + + if (node == null || node.isMissingNode() || node.isNull()) { + return List.of(); + } + if (segmentIndex >= pathSegments.length) { + // Reached the target depth — collect value(s) + if (node.isArray()) { + List result = new ArrayList<>(); + for (JsonNode element : node) { + if (element.isValueNode()) { + result.add(element.asText()); + } + } + return result; + } + if (node.isValueNode()) { + return List.of(node.asText()); + } + return List.of(); + } + + String segment = pathSegments[segmentIndex]; + if (node.isArray()) { + // Apply current segment to each array element + List result = new ArrayList<>(); + for (JsonNode element : node) { + result.addAll(collectJsonValues(element, pathSegments, segmentIndex)); + } + return result; + } + // Navigate into the child + JsonNode child = node.get(segment); + return collectJsonValues(child, pathSegments, segmentIndex + 1); + } + + /** + * Check whether a single string value matches the filter using the given operator. + */ + private boolean matchesOperator( + TypeOfOperator operatorType, + String fieldValue, String filterValue) { + + return switch (operatorType) { + case Equal -> fieldValue.equals(filterValue); + case NotEqual -> !fieldValue.equals(filterValue); + case ContainsIgnoreCase -> fieldValue.toLowerCase().contains(filterValue.toLowerCase()); + case Greater -> fieldValue.compareTo(filterValue) > 0; + case GreaterOrEqual -> fieldValue.compareTo(filterValue) >= 0; + case Less -> fieldValue.compareTo(filterValue) < 0; + case LessOrEqual -> fieldValue.compareTo(filterValue) <= 0; + }; + } + private void enrichAdvisory(AdvisoryInformationResponse response, Authentication credentials) { response.setDeletable(canDeleteAdvisory(response, credentials)); response.setChangeable(canChangeAdvisory(response, credentials)); @@ -191,59 +364,43 @@ private void enrichAdvisoryVersion(AdvisoryInformationResponse response) { response.setCanCreateVersion(false); } - private List getAllowedStates(AdvisoryInformationResponse response, Authentication credentials) { + private List getAllowedStates(AdvisoryInformationResponse response, + Authentication credentials) { final var allowOwnDocumentsApproved = configuration.getWorkflow().isAllowOwnDocumentsApproved(); return Arrays.stream(WorkflowState.values()) - .filter(state -> AdvisoryWorkflowUtil.canChangeWorkflow(response, state, credentials, allowOwnDocumentsApproved)) + .filter(state -> AdvisoryWorkflowUtil.canChangeWorkflow( + response, state, credentials, allowOwnDocumentsApproved)) .collect(Collectors.toList()); } - /** - * read from {@link CouchDbService#findDocumentsAsStream(Map, Collection)} and convert it to a list of JsonNode - * - * @param selector the selector to search for - * @param fields the fields of information to select - * @return the result nodes of the search - */ - List findDocuments(Map selector, Collection fields) throws IOException { - - return AdvisoryWorkflowUtil.findDocuments(this.couchDbService, selector, fields); - } - /** * Adds an advisory to the system * * @param newCsafJson the advisory as JSON String * @return a tuple of assigned id as UUID and the current revision for concurrent control - * @throws JacksonException if the given JSON string is not valid */ @Secured({CsafRoles.ROLE_AUTHOR}) public IdAndRevision addAdvisory(CreateAdvisoryRequest newCsafJson) throws IOException, CsafException { LOG.debug("addAdvisory"); Authentication credentials = getAuthentication(); - return addAdvisoryForCredentials(newCsafJson, credentials); } - - IdAndRevision addAdvisoryForCredentials(CreateAdvisoryRequest newCsafJson, Authentication credentials) throws IOException, CsafException { + @Transactional + IdAndRevision addAdvisoryForCredentials(CreateAdvisoryRequest newCsafJson, Authentication credentials) + throws IOException, CsafException { if (newCsafJson.getSummary() == null || newCsafJson.getSummary().isBlank()) { throw new CsafException("Summary must not be empty", SummaryInHistoryEmpty, BAD_REQUEST); } - UUID advisoryId = UUID.randomUUID(); AdvisoryWrapper emptyAdvisory = AdvisoryWrapper.createInitialEmptyAdvisoryForUser(credentials.getName()); AdvisoryWrapper newAdvisoryNode = AdvisoryWrapper.createNewFromCsaf(newCsafJson, credentials.getName(), this.versioningStrategy); newAdvisoryNode.setDocumentTrackingGeneratorEngineName(buildProperties.getName()); newAdvisoryNode.setDocumentTrackingGeneratorEngineVersion(buildProperties.getVersion()); - AuditTrailWrapper auditTrail = AdvisoryAuditTrailDiffWrapper.createNewFromAdvisories(emptyAdvisory, newAdvisoryNode) - .setAdvisoryId(advisoryId.toString()) - .setChangeType(ChangeType.Create) - .setUser(credentials.getName()); newAdvisoryNode.removeAllRevisionHistoryElements(); String timestampNow = getCurrentTimestamp(); @@ -258,10 +415,24 @@ IdAndRevision addAdvisoryForCredentials(CreateAdvisoryRequest newCsafJson, Authe addTemporaryTrackingId(newAdvisoryNode); } - String revision = couchDbService.writeDocument(advisoryId, newAdvisoryNode.advisoryAsString()); - this.couchDbService.writeDocument(UUID.randomUUID(), auditTrail.auditTrailAsString()); - - return new IdAndRevision(advisoryId.toString(), revision); + // Persist advisory (ID generated by @GeneratedValue) + AdvisoryEntity entity = EntityConverter.toEntity(newAdvisoryNode, null); + AdvisoryEntity saved = postgresService.saveAdvisory(entity); + + // Persist audit trail (document diff) + AdvisoryAuditTrailDiffWrapper diffWrapper = + AdvisoryAuditTrailDiffWrapper.createNewFromAdvisories(emptyAdvisory, newAdvisoryNode); + AuditTrailDocumentEntity auditEntity = EntityConverter.toAuditTrailDocumentEntity( + saved, + credentials.getName(), + ChangeType.Create, + diffWrapper.getDiffPatch(), + diffWrapper.getOldDocVersion(), + diffWrapper.getDocVersion() + ); + postgresService.saveAuditTrailDocument(auditEntity); + + return new IdAndRevision(saved.getId().toString(), String.valueOf(saved.getVersion())); } /** @@ -269,69 +440,84 @@ IdAndRevision addAdvisoryForCredentials(CreateAdvisoryRequest newCsafJson, Authe * * @param newCsafJson the advisory as JSON * @return a tuple of assigned id as UUID and the current revision for concurrent control - * @throws JacksonException if the given JSON string is not valid */ @Secured({CsafRoles.ROLE_PUBLISHER}) public IdAndRevision importAdvisory(JsonNode newCsafJson) throws IOException, CsafException { LOG.debug("importAdvisory"); Authentication credentials = getAuthentication(); - return importAdvisoryForCredentials(newCsafJson, credentials); } - IdAndRevision importAdvisoryForCredentials(JsonNode nodeToImport, Authentication credentials) throws IOException, CsafException { + IdAndRevision importAdvisoryForCredentials(JsonNode nodeToImport, Authentication credentials) + throws IOException, CsafException { + return importAdvisoryForUser(nodeToImport, credentials.getName()); } /** - * Import an advisory to the system for a system user - * Should only be used for imports on application startup + * Import an advisory to the system for a system user. + * Should only be used for imports on application startup. * * @param nodeToImport the advisory as JSON * @return a tuple of ID and revision of the imported advisory * @throws IOException when there are errors reading a file * @throws CsafException when there are errors processing the advisory - * this could be invalid CSAF documents, importing a duplicate or importing an advisory which - * is not in interim or final status */ public IdAndRevision importAdvisoryForSystem(JsonNode nodeToImport) throws IOException, CsafException { return importAdvisoryForUser(nodeToImport, "_SYSTEM_IMPORT_"); } + @Transactional IdAndRevision importAdvisoryForUser(JsonNode nodeToImport, String userName) throws IOException, CsafException { - UUID advisoryId = UUID.randomUUID(); if (!ValidatorServiceClient.isCsafValid(this.validationBaseUrl, nodeToImport)) { throw new CsafException("Advisory is no valid CSAF document", - CsafExceptionKey.AdvisoryValidationError, HttpStatus.UNPROCESSABLE_ENTITY); + AdvisoryValidationError, HttpStatus.UNPROCESSABLE_ENTITY); } AdvisoryWrapper emptyAdvisory = AdvisoryWrapper.createInitialEmptyAdvisoryForUser(userName); AdvisoryWrapper newAdvisoryNode = AdvisoryWrapper.importNewFromCsaf(nodeToImport, userName); String documentTrackingStatus = newAdvisoryNode.getDocumentTrackingStatus(); - if (!documentTrackingStatus.equals(Interim.getCsafValue()) && - !documentTrackingStatus.equals(Final.getCsafValue())) { + if (!documentTrackingStatus.equals(Interim.getCsafValue()) + && !documentTrackingStatus.equals(Final.getCsafValue())) { throw new CsafException("Advisory is not in state final or interim", - CsafExceptionKey.AdvisoryValidationError, HttpStatus.UNPROCESSABLE_ENTITY); + AdvisoryValidationError, HttpStatus.UNPROCESSABLE_ENTITY); } - Map selector = expr2CouchDBFilter(equal(newAdvisoryNode.getDocumentTrackingId(), DOCUMENT_TRACKING_ID.getDbName())); - List docList = findDocuments(selector, List.of(ID_FIELD)); - if (!docList.isEmpty()) { - throw new CsafException("Trying to import a duplicate advisory (identical tracking ID)", DuplicateImport, UNPROCESSABLE_ENTITY); + // Check for duplicate tracking ID + String trackingId = newAdvisoryNode.getDocumentTrackingId(); + if (postgresService.advisoryExistsByTrackingId(trackingId)) { + throw new CsafException("Trying to import a duplicate advisory (identical tracking ID)", + DuplicateImport, UNPROCESSABLE_ENTITY); } - AuditTrailWrapper auditTrail = AdvisoryAuditTrailDiffWrapper.createNewFromAdvisories(emptyAdvisory, newAdvisoryNode) - .setAdvisoryId(advisoryId.toString()) - .setChangeType(ChangeType.Create) - .setUser(userName); - - - String revision = couchDbService.writeDocument(advisoryId, newAdvisoryNode.advisoryAsString()); - this.couchDbService.writeDocument(UUID.randomUUID(), auditTrail.auditTrailAsString()); + // Persist advisory (ID generated by @GeneratedValue) + AdvisoryEntity entity = EntityConverter.toEntity(newAdvisoryNode, null); + AdvisoryEntity saved; + try { + saved = postgresService.saveAdvisory(entity); + } catch (DataIntegrityViolationException e) { + // in case of race condition the unique index on tracking-id would be the one avoiding inserting + // CSAF doc with duplicated tracking-id + throw new CsafException("Trying to import a duplicate advisory (identical tracking ID)", + DuplicateImport, UNPROCESSABLE_ENTITY); + } - return new IdAndRevision(advisoryId.toString(), revision); + // Persist audit trail + AdvisoryAuditTrailDiffWrapper diffWrapper = + AdvisoryAuditTrailDiffWrapper.createNewFromAdvisories(emptyAdvisory, newAdvisoryNode); + AuditTrailDocumentEntity auditEntity = EntityConverter.toAuditTrailDocumentEntity( + saved, + userName, + ChangeType.Create, + diffWrapper.getDiffPatch(), + diffWrapper.getOldDocVersion(), + diffWrapper.getDocVersion() + ); + postgresService.saveAuditTrailDocument(auditEntity); + + return new IdAndRevision(saved.getId().toString(), String.valueOf(saved.getVersion())); } /** @@ -347,7 +533,7 @@ void addTemporaryTrackingId(AdvisoryWrapper newAdvisoryNode) throws CsafExceptio } /** - * Get the next unique tracking id from the db for the given counterId + * Get the next unique tracking id from the db for the given counterId. * * @param counterId id of the counter * @return next id @@ -355,27 +541,11 @@ void addTemporaryTrackingId(AdvisoryWrapper newAdvisoryNode) throws CsafExceptio */ long getNewTrackingIdCounter(String counterId) throws CsafException { - Map selector = expr2CouchDBFilter(equal(counterId, ID_FIELD.getDbName())); try { - List docList = findDocuments(selector, List.of(ID_FIELD)); - if (docList.isEmpty()) { - final TrackingIdCounter counter = TrackingIdCounter.createInitialCounter(counterId); - final String result = new JsonMapper().writeValueAsString(counter); - this.couchDbService.writeDocument(counterId, result); - } - } catch (JacksonException | IOException e) { - throw new CsafException("Error create new counter for tracking Id", ErrorCreatingTrackingIdCounter, INTERNAL_SERVER_ERROR); - } - - try (InputStream counterStream = couchDbService.readDocumentAsStream(counterId)) { - MappingIterator counterIter = new JsonMapper().readerFor(TrackingIdCounter.class).readValues(counterStream); - TrackingIdCounter counter = counterIter.next(); - counter.increaseCount(); - final String result = new JsonMapper().writeValueAsString(counter); - this.couchDbService.updateDocument(result); - return counter.getCount(); - } catch (JacksonException | DatabaseException | IOException ex) { - throw new CsafException("Error create new counter for tracking Id", ErrorCreatingTrackingIdCounter, INTERNAL_SERVER_ERROR); + return postgresService.incrementAndGetCounter(counterId); + } catch (Exception ex) { + throw new CsafException("Error create new counter for tracking Id", + ErrorCreatingTrackingIdCounter, INTERNAL_SERVER_ERROR); } } @@ -388,15 +558,16 @@ long getNewTrackingIdCounter(String counterId) throws CsafException { */ public AdvisoryResponse getAdvisory(String advisoryId) throws DatabaseException, CsafException { - try (InputStream advisoryStream = couchDbService.readDocumentAsStream(advisoryId)) { + try { + AdvisoryEntity entity = findAdvisoryEntityOrThrow(advisoryId); + AdvisoryWrapper advisory = EntityConverter.toWrapper(entity); - AdvisoryWrapper advisory = AdvisoryWrapper.createFromCouchDb(advisoryStream); if (canViewAdvisory(advisory, getAuthentication())) { - Authentication credentials = getAuthentication(); boolean isVersion = advisory.getType() == ObjectType.AdvisoryVersion; - AdvisoryResponse response = new AdvisoryResponse(advisoryId, advisory.getWorkflowState(), advisory.getCsaf()); + AdvisoryResponse response = new AdvisoryResponse(advisoryId, advisory.getWorkflowState(), + advisory.getCsaf()); response.setTitle(advisory.getDocumentTitle()); response.setCurrentReleaseDate(advisory.getDocumentTrackingCurrentReleaseDate()); response.setDocumentTrackingId(advisory.getDocumentTrackingId()); @@ -404,7 +575,8 @@ public AdvisoryResponse getAdvisory(String advisoryId) throws DatabaseException, response.setDeletable(!isVersion && canDeleteAdvisory(response, credentials)); response.setChangeable(!isVersion && canChangeAdvisory(response, credentials)); response.setCanCreateVersion(!isVersion && canCreateNewVersion(response, credentials)); - List allowedStateChanges = (!isVersion) ? getAllowedStates(response, credentials) : emptyList(); + List allowedStateChanges = + (!isVersion) ? getAllowedStates(response, credentials) : emptyList(); response.setAllowedStateChanges(allowedStateChanges); response.setRevision(advisory.getRevision()); return response; @@ -418,119 +590,90 @@ public AdvisoryResponse getAdvisory(String advisoryId) throws DatabaseException, } /** - * Deletes an advisory with given id from the database and all comments and answers belonging to it + * Deletes an advisory with given id from the database. + * Comments and audit trails are removed via ON DELETE CASCADE in the schema. * * @param advisoryId the ID of the advisory to delete * @param revision the revision for concurrent control - * @throws BadRequestException if the request was - * @throws NotFoundException if there is no advisory with given ID */ @Secured({CsafRoles.ROLE_AUTHOR}) + @Transactional public void deleteAdvisory(String advisoryId, String revision) throws DatabaseException, IOException, CsafException { LOG.debug("deleteAdvisory"); - InputStream advisoryStream = couchDbService.readDocumentAsStream(advisoryId); - AdvisoryWrapper advisory = AdvisoryWrapper.createFromCouchDb(advisoryStream); + AdvisoryEntity entity = findAdvisoryEntityOrThrow(advisoryId); + AdvisoryWrapper advisory = EntityConverter.toWrapper(entity); if (canDeleteAdvisory(advisory, getAuthentication())) { - - this.couchDbService.deleteDocument(advisoryId, revision); - deleteAllAuditTrailDocumentsFromDbFor(advisoryId, ADVISORY_ID.getDbName()); - deleteAllCommentsFromDbForAdvisory(advisoryId); + checkAdvisoryRevision(entity, revision); + postgresService.deleteAdvisory(entity); } else { throw new AccessDeniedException("User has not the permission to delete the advisory"); } } - private void deleteAllCommentsFromDbForAdvisory(String advisoryId) throws IOException, DatabaseException { - AndExpression searchExpr = new AndExpression(equal(ObjectType.Comment.name(), TYPE_FIELD.getDbName()), - equal(advisoryId, CommentField.ADVISORY_ID.getDbName())); - - Collection fields = Arrays.asList(CouchDbField.ID_FIELD, CouchDbField.REVISION_FIELD); - - Map selector = expr2CouchDBFilter(searchExpr); - List commentsToDelete = this.findDocuments(selector, fields); - - Collection bulkDeletes = new ArrayList<>(commentsToDelete.size()); - for (JsonNode doc : commentsToDelete) { - String commentId = CouchDbField.ID_FIELD.stringVal(doc); - String commentRev = CouchDbField.REVISION_FIELD.stringVal(doc); - deleteComment(commentId, commentRev); - } - this.couchDbService.bulkDeleteDocuments(bulkDeletes); - } - - private void deleteAllAuditTrailDocumentsFromDbFor(String itemId, String idKey) throws IOException, DatabaseException { - - Collection fields = Arrays.asList(CouchDbField.ID_FIELD, CouchDbField.REVISION_FIELD); - - AndExpression searchExpr = new AndExpression(containsIgnoreCase("AuditTrail", TYPE_FIELD.getDbName()), - equal(itemId, idKey)); - Map selector = expr2CouchDBFilter(searchExpr); - var auditTrailDocs = this.findDocuments(selector, fields); - - Collection bulkDeletes = new ArrayList<>(auditTrailDocs.size()); - for (JsonNode doc : auditTrailDocs) { - bulkDeletes.add(new IdAndRevision(CouchDbField.ID_FIELD.stringVal(doc), - CouchDbField.REVISION_FIELD.stringVal(doc))); - } - this.couchDbService.bulkDeleteDocuments(bulkDeletes); - - } - /** * @param advisoryId the ID of the advisory to update * @param revision the revision for concurrent control * @param changedCsafJson the updated csaf json * @return the new revision of the updated csaf document - * @throws JacksonException if the given JSON string is not valid - * @throws DatabaseException if there was an error updating the advisory in the DB + * @throws DatabaseException if there was an error updating the advisory in the DB */ - public String updateAdvisory(String advisoryId, String revision, CreateAdvisoryRequest changedCsafJson) throws IOException, DatabaseException, CsafException { + @Transactional + public String updateAdvisory(String advisoryId, String revision, CreateAdvisoryRequest changedCsafJson) + throws IOException, DatabaseException, CsafException { LOG.debug("updateAdvisory"); - try (InputStream existingAdvisoryStream = this.couchDbService.readDocumentAsStream(advisoryId)) { + AdvisoryEntity existingEntity = findAdvisoryEntityOrThrow(advisoryId); + AdvisoryWrapper oldAdvisoryNode = EntityConverter.toWrapper(existingEntity); - if (existingAdvisoryStream == null) { - throw new DatabaseException("Invalid advisory ID!"); - } - AdvisoryWrapper oldAdvisoryNode = AdvisoryWrapper.createFromCouchDb(existingAdvisoryStream); - Authentication credentials = getAuthentication(); - if (canChangeAdvisory(oldAdvisoryNode, credentials)) { + Authentication credentials = getAuthentication(); + if (canChangeAdvisory(oldAdvisoryNode, credentials)) { + checkAdvisoryRevision(existingEntity, revision); - if (changedCsafJson.getSummary() == null || changedCsafJson.getSummary().isBlank()) { - throw new CsafException("Summary must not be empty", SummaryInHistoryEmpty, UNPROCESSABLE_ENTITY); - } + if (changedCsafJson.getSummary() == null || changedCsafJson.getSummary().isBlank()) { + throw new CsafException("Summary must not be empty", SummaryInHistoryEmpty, UNPROCESSABLE_ENTITY); + } - AdvisoryWrapper newAdvisoryNode = AdvisoryWrapper.updateFromExisting(oldAdvisoryNode, changedCsafJson); - newAdvisoryNode.setRevision(revision); - newAdvisoryNode.setDocumentTrackingGeneratorEngineName(buildProperties.getName()); - newAdvisoryNode.setDocumentTrackingGeneratorEngineVersion(buildProperties.getVersion()); - PatchType changeType = AdvisoryWorkflowUtil.getChangeType(oldAdvisoryNode, newAdvisoryNode, configuration.getVersioning().getLevenshtein()); - String nextVersion = oldAdvisoryNode.getVersioningStrategy().getNextVersion(changeType, oldAdvisoryNode.getDocumentTrackingVersion(), oldAdvisoryNode.getLastVersion()); - newAdvisoryNode.setDocumentTrackingVersion(nextVersion); - String timestampNow = getCurrentTimestamp(); - if (newAdvisoryNode.currentReleaseDateIsNotSetOrInPast(timestampNow)) { - newAdvisoryNode.setDocumentTrackingCurrentReleaseDate(timestampNow); - } - if (oldAdvisoryNode.usesSemanticVersioning() + AdvisoryWrapper newAdvisoryNode = AdvisoryWrapper.updateFromExisting(oldAdvisoryNode, changedCsafJson); + newAdvisoryNode.setRevision(revision); + newAdvisoryNode.setDocumentTrackingGeneratorEngineName(buildProperties.getName()); + newAdvisoryNode.setDocumentTrackingGeneratorEngineVersion(buildProperties.getVersion()); + PatchType changeType = AdvisoryWorkflowUtil.getChangeType(oldAdvisoryNode, newAdvisoryNode, + configuration.getVersioning().getLevenshtein()); + String nextVersion = oldAdvisoryNode.getVersioningStrategy().getNextVersion(changeType, + oldAdvisoryNode.getDocumentTrackingVersion(), oldAdvisoryNode.getLastVersion()); + newAdvisoryNode.setDocumentTrackingVersion(nextVersion); + String timestampNow = getCurrentTimestamp(); + if (newAdvisoryNode.currentReleaseDateIsNotSetOrInPast(timestampNow)) { + newAdvisoryNode.setDocumentTrackingCurrentReleaseDate(timestampNow); + } + if (oldAdvisoryNode.usesSemanticVersioning() && newAdvisoryNode.versionIsUntilIncludingInitialPublication() && !oldAdvisoryNode.getDocumentTrackingVersion().equals(nextVersion)) { - newAdvisoryNode.addRevisionHistoryElement(changedCsafJson, timestampNow); - } else { - newAdvisoryNode.editLastRevisionHistoryElement(changedCsafJson, timestampNow); - } - - String result = this.couchDbService.updateDocument(newAdvisoryNode.advisoryAsString()); - - AuditTrailWrapper auditTrail = AdvisoryAuditTrailDiffWrapper.createNewFromAdvisories(oldAdvisoryNode, newAdvisoryNode) - .setAdvisoryId(advisoryId) - .setChangeType(ChangeType.Update) - .setUser(credentials.getName()); - this.couchDbService.writeDocument(UUID.randomUUID(), auditTrail.auditTrailAsString()); - return result; + newAdvisoryNode.addRevisionHistoryElement(changedCsafJson, timestampNow); } else { - throw new CsafException("User has no permission to edit the advisory", NoPermissionForAdvisory, UNAUTHORIZED); + newAdvisoryNode.editLastRevisionHistoryElement(changedCsafJson, timestampNow); } + + AdvisoryEntity updated = EntityConverter.toEntity(newAdvisoryNode, existingEntity); + AdvisoryEntity saved = postgresService.saveAdvisory(updated); + + // Persist audit trail + AdvisoryAuditTrailDiffWrapper diffWrapper = + AdvisoryAuditTrailDiffWrapper.createNewFromAdvisories(oldAdvisoryNode, newAdvisoryNode); + AuditTrailDocumentEntity auditEntity = EntityConverter.toAuditTrailDocumentEntity( + saved, + credentials.getName(), + ChangeType.Update, + diffWrapper.getDiffPatch(), + diffWrapper.getOldDocVersion(), + diffWrapper.getDocVersion() + ); + postgresService.saveAuditTrailDocument(auditEntity); + + return String.valueOf(saved.getVersion()); + } else { + throw new CsafException("User has no permission to edit the advisory", NoPermissionForAdvisory, UNAUTHORIZED); } } @@ -544,78 +687,78 @@ public String updateAdvisory(String advisoryId, String revision, CreateAdvisoryR * @throws DatabaseException if the advisory with the given id does not exist * @throws CsafException if the user has no permission to edit the advisory or if the advisory already has a final tracking id assigned */ + @Transactional public String assignTrackingId(String advisoryId, String revision) throws IOException, DatabaseException, CsafException { LOG.debug("assignTrackingId"); - try (InputStream existingAdvisoryStream = this.couchDbService.readDocumentAsStream(advisoryId)) { + AdvisoryEntity existingEntity = findAdvisoryEntityOrThrow(advisoryId); + AdvisoryWrapper existingAdvisoryNode = EntityConverter.toWrapper(existingEntity); + Authentication credentials = getAuthentication(); + if (!canChangeAdvisory(existingAdvisoryNode, credentials)) { + throw new CsafException("User has no permission to edit the advisory", NoPermissionForAdvisory, UNAUTHORIZED); + } - if (existingAdvisoryStream == null) { - throw new DatabaseException("Invalid advisory ID!"); - } - AdvisoryWrapper existingAdvisoryNode = AdvisoryWrapper.createFromCouchDb(existingAdvisoryStream); - Authentication credentials = getAuthentication(); - if (!canChangeAdvisory(existingAdvisoryNode, credentials)) { - throw new CsafException("User has no permission to edit the advisory", NoPermissionForAdvisory, UNAUTHORIZED); - } + if (existingAdvisoryNode.isFinalTrackingIdAssigned()) { + throw new CsafException("Advisory already has a final tracking id assigned", + TrackingIdAlreadyAssigned, CONFLICT); + } - if (existingAdvisoryNode.isFinalTrackingIdAssigned()) { - throw new CsafException("Advisory already has a final tracking id assigned", - TrackingIdAlreadyAssigned, CONFLICT); - } + checkAdvisoryRevision(existingEntity, revision); - AdvisoryWrapper oldAdvisoryNode = AdvisoryWrapper.createCopy(existingAdvisoryNode); - setFinalTrackingIdAndUrl(existingAdvisoryNode); - existingAdvisoryNode.setRevision(revision); + AdvisoryWrapper oldAdvisoryNode = AdvisoryWrapper.createCopy(existingAdvisoryNode); + setFinalTrackingIdAndUrl(existingAdvisoryNode); + existingAdvisoryNode.setRevision(revision); - String newRevision = this.couchDbService.updateDocument(existingAdvisoryNode.advisoryAsString()); + AdvisoryEntity updated = EntityConverter.toEntity(existingAdvisoryNode, existingEntity); + AdvisoryEntity saved = postgresService.saveAdvisory(updated); - AuditTrailWrapper auditTrail = AdvisoryAuditTrailDiffWrapper.createNewFromAdvisories(oldAdvisoryNode, existingAdvisoryNode) - .setAdvisoryId(advisoryId) - .setChangeType(ChangeType.Update) - .setUser(credentials.getName()); - this.couchDbService.writeDocument(UUID.randomUUID(), auditTrail.auditTrailAsString()); + AdvisoryAuditTrailDiffWrapper diffWrapper = + AdvisoryAuditTrailDiffWrapper.createNewFromAdvisories(oldAdvisoryNode, existingAdvisoryNode); + AuditTrailDocumentEntity auditEntity = EntityConverter.toAuditTrailDocumentEntity( + saved, + credentials.getName(), + ChangeType.Update, + diffWrapper.getDiffPatch(), + diffWrapper.getOldDocVersion(), + diffWrapper.getDocVersion() + ); + postgresService.saveAuditTrailDocument(auditEntity); - return newRevision; - } + return String.valueOf(saved.getVersion()); } /** - * Export the Advisory with the given advisoryId in the given format. The export will be written to a - * temporary file and the path to the file will be returned. + * Export the Advisory with the given advisoryId in the given format. * * @param advisoryId the id of the advisory that should be exported * @param format the format in which the export should be written (default JSON on null) * @return the path to the temporary file that contains the export - * @throws CsafException if the advisory with the given id does not exist or the export format is unknown - * @throws IOException on any error regarding writing/reading from disk - * @throws InterruptedException if the export did take too long and thus timed out */ @Secured({CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUDITOR}) public Path exportAdvisory( @Nonnull final String advisoryId, @Nullable final ExportFormat format) throws IOException, CsafException { - // read the advisory form the database + try { - final InputStream existingAdvisoryStream = this.couchDbService.readDocumentAsStream(advisoryId); - final AdvisoryWrapper advisoryNode = AdvisoryWrapper.createFromCouchDb(existingAdvisoryStream); + AdvisoryEntity entity = findAdvisoryEntityOrThrow(advisoryId); + AdvisoryWrapper advisoryNode = EntityConverter.toWrapper(entity); final JsonNode csaf = advisoryNode.getCsaf(); RemoveIdHelper.removeCommentIds(csaf); final String csafDocument = csaf.toString(); - final String filename = advisoryNode.getDocumentTrackingId() == null ? "advisory__" : advisoryNode.getDocumentTrackingId(); - + + final String filename = advisoryNode.getDocumentTrackingId() == null ? "advisory__" : advisoryNode.getDocumentTrackingId(); + // if format is JSON - write it to temporary file and return the path if (format == ExportFormat.JSON || format == null) { final Path jsonFile = Files.createTempFile(filename, ".json"); Files.writeString(jsonFile, csafDocument); return jsonFile; } else { - // other formats have to start with an HTML export first final String htmlExport = javascriptExporter.createHtml(csafDocument); final Path htmlFile = Files.createTempFile(filename, ".html"); Files.writeString(htmlFile, htmlExport); if (format == ExportFormat.HTML) { - // we already have an HTML file - done! return htmlFile; } else if (format == ExportFormat.Markdown && pandocService.isReady()) { final Path markdownFile = Files.createTempFile(filename, ".md"); @@ -628,14 +771,18 @@ public Path exportAdvisory( Files.delete(htmlFile); return pdfFile; } - throw new CsafException("Unknown export format: " + format, CsafExceptionKey.UnknownExportFormat, BAD_REQUEST); + throw new CsafException("Unknown export format: " + format, + CsafExceptionKey.UnknownExportFormat, BAD_REQUEST); } } catch (IdNotFoundException e) { throw new CsafException("Can not find advisory with ID " + advisoryId, - CsafExceptionKey.AdvisoryNotFound, HttpStatus.NOT_FOUND); + CsafExceptionKey.AdvisoryNotFound, NOT_FOUND); + } catch (DatabaseException e) { + throw new CsafException("Database error reading advisory " + advisoryId, + CsafExceptionKey.AdvisoryNotFound, NOT_FOUND); } } - + /** * Export the Advisory with the given advisoryId and perform release activities on the document * The export will be written to a temporary file and the path to the file will be returned. @@ -652,17 +799,13 @@ public Path exportAdvisoryForAutoPublish( throws IOException, CsafException { // read the advisory form the database try { - final InputStream existingAdvisoryStream = this.couchDbService.readDocumentAsStream(advisoryId); - final AdvisoryWrapper finalAdvisory = AdvisoryWrapper.createFromCouchDb(existingAdvisoryStream); - - //final AdvisoryWrapper finalAdvisory = createReleaseReadyAdvisoryAndValidate(draftAdvisory, draftAdvisory.getDocumentTrackingCurrentReleaseDate()); - - final JsonNode csaf = finalAdvisory.getCsaf(); + final JsonNode csaf = this.postgresService.readDocumentAsJsonNode(advisoryId); + RemoveIdHelper.removeCommentIds(csaf); final String csafDocument = csaf.toString(); // if format is JSON - write it to temporary file and return the path - final Path jsonFile = Files.createTempFile(finalAdvisory.getDocumentTrackingId(), ".json"); + final Path jsonFile = Files.createTempFile(advisoryId, ".json"); Files.writeString(jsonFile, csafDocument); return jsonFile; } catch (IdNotFoundException e) { @@ -670,34 +813,35 @@ public Path exportAdvisoryForAutoPublish( CsafExceptionKey.AdvisoryNotFound, HttpStatus.NOT_FOUND); } } - + /** - * Changes the workflow state of the advisory to the given new WorkflowState + * Changes the workflow state of the advisory to the given new WorkflowState. * - * @param advisoryId the ID of the advisory to update the workflow state of - * @param revision the revision for concurrent control - * @param newWorkflowState the new workflow state to set + * @param advisoryId the ID of the advisory to update the workflow state of + * @param revision the revision for concurrent control + * @param newWorkflowState the new workflow state to set + * @param proposedTime optional proposed publication time + * @param documentTrackingStatus optional new document tracking status * @return the new revision of the updated csaf document - * @throws DatabaseException if there was an error updating the advisory in the DB */ - public String changeAdvisoryWorkflowState(String advisoryId, String revision, WorkflowState newWorkflowState, - String proposedTime, DocumentTrackingStatus documentTrackingStatus) + @Transactional + public String changeAdvisoryWorkflowState(String advisoryId, String revision, + WorkflowState newWorkflowState, String proposedTime, + DocumentTrackingStatus documentTrackingStatus) throws IOException, DatabaseException, CsafException { Authentication credentials = getAuthentication(); - InputStream existingAdvisoryStream = couchDbService.readDocumentAsStream(advisoryId); - if (existingAdvisoryStream == null) { - throw new DatabaseException("Invalid advisory ID!"); - } - AdvisoryWrapper existingAdvisoryNode = AdvisoryWrapper.createFromCouchDb(existingAdvisoryStream); + AdvisoryEntity existingEntity = findAdvisoryEntityOrThrow(advisoryId); + AdvisoryWrapper existingAdvisoryNode = EntityConverter.toWrapper(existingEntity); final var allowOwnDocumentsApproved = configuration.getWorkflow().isAllowOwnDocumentsApproved(); if (canChangeWorkflow(existingAdvisoryNode, newWorkflowState, credentials, allowOwnDocumentsApproved)) { + checkAdvisoryRevision(existingEntity, revision); WorkflowState previousWorkflowState = existingAdvisoryNode.getWorkflowState(); String previousVersion = existingAdvisoryNode.getDocumentTrackingVersion(); - - String workflowStateChangeMsg = "Status changed from " + previousWorkflowState + " to " + newWorkflowState; + String workflowStateChangeMsg = "Status changed from " + previousWorkflowState + + " to " + newWorkflowState; existingAdvisoryNode.setWorkflowState(newWorkflowState); if (documentTrackingStatus != null) { @@ -712,7 +856,8 @@ public String changeAdvisoryWorkflowState(String advisoryId, String revision, Wo if (existingAdvisoryNode.currentReleaseDateIsNotSetOrInPast(timestampNow)) { existingAdvisoryNode.setDocumentTrackingCurrentReleaseDate(timestampNow); } - if (existingAdvisoryNode.usesSemanticVersioning() && existingAdvisoryNode.versionIsUntilIncludingInitialPublication()) { + if (existingAdvisoryNode.usesSemanticVersioning() + && existingAdvisoryNode.versionIsUntilIncludingInitialPublication()) { existingAdvisoryNode.addRevisionHistoryElement(workflowStateChangeMsg, "", timestampNow); } else if (existingAdvisoryNode.usesIntegerVersioning() && "0".equals(previousVersion)) { String lastRevSummary = existingAdvisoryNode.getLastRevisionHistoryElementSummary(); @@ -730,7 +875,8 @@ public String changeAdvisoryWorkflowState(String advisoryId, String revision, Wo if (existingAdvisoryNode.currentReleaseDateIsNotSetOrInPast(timestampNow)) { existingAdvisoryNode.setDocumentTrackingCurrentReleaseDate(timestampNow); } - if (existingAdvisoryNode.usesSemanticVersioning() && existingAdvisoryNode.versionIsUntilIncludingInitialPublication()) { + if (existingAdvisoryNode.usesSemanticVersioning() + && existingAdvisoryNode.versionIsUntilIncludingInitialPublication()) { existingAdvisoryNode.addRevisionHistoryElement(workflowStateChangeMsg, "", timestampNow); } else { existingAdvisoryNode.setLastRevisionHistoryElementNumberAndDate(nextVersion, timestampNow); @@ -743,7 +889,6 @@ public String changeAdvisoryWorkflowState(String advisoryId, String revision, Wo } if (newWorkflowState == WorkflowState.RfPublication) { - // In this step we only want to check if the document would be valid if published but not change it yet. createReleaseReadyAdvisoryAndValidate(existingAdvisoryNode, proposedTime); } @@ -752,46 +897,57 @@ public String changeAdvisoryWorkflowState(String advisoryId, String revision, Wo proposedTime = existingAdvisoryNode.getDocumentTrackingCurrentReleaseDate(); if (proposedTime == null) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'.000000000Z"); - proposedTime = sdf.format(new Date()); + proposedTime = sdf.format(new Date()); } } - + if (documentTrackingStatus == null) { existingAdvisoryNode.setDocumentTrackingStatus(DocumentTrackingStatus.Final); } - + if (existingAdvisoryNode.getDocumentDistributionTlp() == null) { throw new CsafException("TLP-Level missing", CsafExceptionKey.AdvisoryValidationError, BAD_REQUEST); } //TODO: Check, if further checks for upload are needed - + existingAdvisoryNode = createReleaseReadyAdvisoryAndValidate(existingAdvisoryNode, proposedTime); - setFinalTrackingIdAndUrl(existingAdvisoryNode); + if (existingAdvisoryNode.getLastMajorVersion() < 1) { + setFinalTrackingIdAndUrl(existingAdvisoryNode); + } } - + if (newWorkflowState == WorkflowState.Published && (previousWorkflowState != WorkflowState.AutoPublish)) { - + existingAdvisoryNode = createReleaseReadyAdvisoryAndValidate(existingAdvisoryNode, proposedTime); - setFinalTrackingIdAndUrl(existingAdvisoryNode); + if (existingAdvisoryNode.getLastMajorVersion() < 1) { + setFinalTrackingIdAndUrl(existingAdvisoryNode); + } } - AuditTrailWrapper auditTrail = AdvisoryAuditTrailWorkflowWrapper.createNewFrom(newWorkflowState, previousWorkflowState) - .setOldDocVersion(previousVersion) - .setDocVersion(existingAdvisoryNode.getDocumentTrackingVersion()) - .setAdvisoryId(advisoryId) - .setUser(credentials.getName()); - this.couchDbService.writeDocument(UUID.randomUUID(), auditTrail.auditTrailAsString()); + // Persist workflow audit trail + AuditTrailWorkflowEntity workflowAudit = EntityConverter.toAuditTrailWorkflowEntity( + existingEntity, + credentials.getName(), + previousWorkflowState.name(), + newWorkflowState.name(), + previousVersion, + existingAdvisoryNode.getDocumentTrackingVersion() + ); + postgresService.saveAuditTrailWorkflow(workflowAudit); existingAdvisoryNode.setRevision(revision); - return this.couchDbService.updateDocument(existingAdvisoryNode.advisoryAsString()); + AdvisoryEntity updated = EntityConverter.toEntity(existingAdvisoryNode, existingEntity); + AdvisoryEntity saved = postgresService.saveAdvisory(updated); + return String.valueOf(saved.getVersion()); } else { - throw new CsafException("User has not the permission to change the workflow state of the advisory", + throw new CsafException( + "User has not the permission to change the workflow state of the advisory", NoPermissionForAdvisory, UNAUTHORIZED); } } /** - * Set the final tracking id in the advisory and a DocumentReferencesNode with the url of the tracking id + * Set the final tracking id in the advisory and a DocumentReferencesNode with the url. * * @param advisoryNode the node to set the tracking id * @throws CsafException error creating counter @@ -808,8 +964,8 @@ void setFinalTrackingIdAndUrl(AdvisoryWrapper advisoryNode) throws CsafException advisoryNode.setFinalTrackingIdAndUrl(this.referencesBaseUrl, this.trackingidCompany, this.trackingidDigits, sequentialNumber, createHtmlReference); } - - private AdvisoryWrapper createReleaseReadyAdvisoryAndValidate(AdvisoryWrapper advisory, String releaseDate) throws CsafException, IOException { + private AdvisoryWrapper createReleaseReadyAdvisoryAndValidate(AdvisoryWrapper advisory, + String releaseDate) throws CsafException, IOException { AdvisoryWrapper advisoryCopy = AdvisoryWrapper.createCopy(advisory); @@ -855,29 +1011,35 @@ private AdvisoryWrapper createReleaseReadyAdvisoryAndValidate(AdvisoryWrapper ad } /** - * Adds a new version of the document in Draft workflow state + * Adds a new version of the document in Draft workflow state. * * @param advisoryId the ID of the advisory to create a new version of * @param revision the revision for concurrent control * @return the revision of the updated CSAF document - * @throws DatabaseException if there was an error updating the document in the database */ + @Transactional public String createNewCsafDocumentVersion(String advisoryId, String revision) throws IOException, DatabaseException, CsafException { LOG.debug("createNewCsafDocumentVersion"); Authentication credentials = getAuthentication(); - InputStream existingAdvisoryStream = couchDbService.readDocumentAsStream(advisoryId); - if (existingAdvisoryStream == null) { - throw new DatabaseException("Invalid advisory ID!"); - } - AdvisoryWrapper existingAdvisoryNode = AdvisoryWrapper.createFromCouchDb(existingAdvisoryStream); + AdvisoryEntity existingEntity = findAdvisoryEntityOrThrow(advisoryId); + AdvisoryWrapper existingAdvisoryNode = EntityConverter.toWrapper(existingEntity); if (canCreateNewVersion(existingAdvisoryNode, credentials)) { + checkAdvisoryRevision(existingEntity, revision); - // make copy of current state + // Persist a version snapshot of the current Published state AdvisoryWrapper advisoryVersionBackup = AdvisoryWrapper.createVersionFrom(existingAdvisoryNode); - // Set existing version to Draft + AdvisoryVersionEntity versionEntity = + EntityConverter.toVersionEntity(advisoryVersionBackup, existingEntity); + postgresService.saveAdvisoryVersion(versionEntity); + + // Capture pre-mutation state for the audit trail before any mutations occur + String previousWorkflowState = existingAdvisoryNode.getWorkflowState().name(); + String previousDocVersion = existingAdvisoryNode.getDocumentTrackingVersion(); + + // Update the advisory to Draft for new version editing existingAdvisoryNode.setLastVersion(existingAdvisoryNode.getDocumentTrackingVersion()); existingAdvisoryNode.setWorkflowState(WorkflowState.Draft); existingAdvisoryNode.setDocumentTrackingStatus(DocumentTrackingStatus.Draft); @@ -888,144 +1050,140 @@ public String createNewCsafDocumentVersion(String advisoryId, String revision) existingAdvisoryNode.addRevisionHistoryElement("New Version", "", timestampNow); existingAdvisoryNode.setRevision(revision); - AuditTrailWrapper auditTrail = AdvisoryAuditTrailWorkflowWrapper.createNewFrom(WorkflowState.Draft, existingAdvisoryNode.getWorkflowState()) - .setDocVersion(existingAdvisoryNode.getDocumentTrackingVersion()) - .setOldDocVersion(existingAdvisoryNode.getDocumentTrackingVersion()) - .setAdvisoryId(advisoryId) - .setUser(credentials.getName()); - this.couchDbService.writeDocument(UUID.randomUUID(), auditTrail.auditTrailAsString()); - this.deleteAllCommentsFromDbForAdvisory(existingAdvisoryNode.getAdvisoryId()); - this.couchDbService.writeDocument(UUID.randomUUID(), advisoryVersionBackup.advisoryAsString()); - return this.couchDbService.updateDocument(existingAdvisoryNode.advisoryAsString()); + // Persist workflow audit trail using pre-mutation values + AuditTrailWorkflowEntity workflowAudit = EntityConverter.toAuditTrailWorkflowEntity( + existingEntity, + credentials.getName(), + previousWorkflowState, + WorkflowState.Draft.name(), + previousDocVersion, + existingAdvisoryNode.getDocumentTrackingVersion() + ); + postgresService.saveAuditTrailWorkflow(workflowAudit); + + // Delete existing comments (new version starts clean) + postgresService.deleteCommentsByAdvisoryId(UUID.fromString(advisoryId)); + + AdvisoryEntity updated = EntityConverter.toEntity(existingAdvisoryNode, existingEntity); + AdvisoryEntity saved = postgresService.saveAdvisory(updated); + return String.valueOf(saved.getVersion()); } else { throw new CsafException("User has not the permission to create a new Version in this state", NoPermissionForAdvisory, UNAUTHORIZED); - } } /** - * Adds a comment to the advisory + * Adds a comment to the advisory. * * @param advisoryId the ID of the advisory to add the comment to * @param comment the comment to add as JSON string, requires a commentText * @return a tuple of ID and revision of the added comment - * @throws DatabaseException when there are database errors - * @throws CsafException when a known csaf exception occurs */ @Secured({CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_REVIEWER}) - public IdAndRevision addComment(String advisoryId, CreateCommentRequest comment) throws DatabaseException, CsafException { + @Transactional + public IdAndRevision addComment(String advisoryId, CreateCommentRequest comment) + throws DatabaseException, CsafException { LOG.debug("addComment"); - UUID commentId = UUID.randomUUID(); Authentication credentials = getAuthentication(); - AdvisoryInformationResponse advisoryInfo = getAdvisoryForId(advisoryId, this.couchDbService); + AdvisoryInformationResponse advisoryInfo = getAdvisoryInfoForId(advisoryId); if (AdvisoryWorkflowUtil.canAddAndReplyCommentToAdvisory(advisoryInfo, credentials)) { CommentWrapper newComment = CommentWrapper.createNew(advisoryId, comment); newComment.setOwner(credentials.getName()); - String commentRevision = this.couchDbService.writeDocument(commentId, newComment.commentAsString()); - AuditTrailWrapper auditTrail = CommentAuditTrailWrapper.createNew(newComment) - .setCommentId(commentId.toString()) - .setUser(credentials.getName()); - this.couchDbService.writeDocument(UUID.randomUUID(), auditTrail.auditTrailAsString()); - return new IdAndRevision(commentId.toString(), commentRevision); + AdvisoryEntity advisoryEntity; + try { + advisoryEntity = findAdvisoryEntityOrThrow(advisoryId); + } catch (IOException e) { + throw new DatabaseException(e); + } + CommentEntity commentEntity = EntityConverter.toEntity(newComment, advisoryEntity, null); + CommentEntity savedComment = postgresService.saveComment(commentEntity); + + AuditTrailCommentEntity auditEntity = EntityConverter.toAuditTrailCommentEntity( + savedComment, credentials.getName(), ChangeType.Create, newComment.getText()); + postgresService.saveAuditTrailComment(auditEntity); + + return new IdAndRevision(savedComment.getId().toString(), String.valueOf(savedComment.getVersion())); } else { throw new AccessDeniedException("User has not the permission to add a comment to the advisory"); } } /** - * Get a specific comment (or answer) + * Get a specific comment (or answer). * * @param commentId the ID of the comment to get * @return the requested comment - * @throws IdNotFoundException if there is no comment with given ID */ @Secured({CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_AUDITOR}) public CommentResponse getComment(String commentId) throws DatabaseException, CsafException { + CommentEntity commentEntity = findCommentEntityOrThrow(commentId); + Authentication credentials = getAuthentication(); + String advisoryId = commentEntity.getAdvisory().getId().toString(); + AdvisoryInformationResponse advisoryInfo = getAdvisoryInfoForId(advisoryId); - try (InputStream commentStream = couchDbService.readDocumentAsStream(commentId)) { - CommentWrapper comment = CommentWrapper.createFromCouchDb(commentStream); - - Authentication credentials = getAuthentication(); - AdvisoryInformationResponse advisoryInfo = getAdvisoryForId(comment.getAdvisoryId(), this.couchDbService); - if (AdvisoryWorkflowUtil.canViewComment(advisoryInfo, credentials)) { - return new CommentResponse( - commentId, - comment.getRevision(), - comment.getAdvisoryId(), - comment.getOwner(), - comment.getText(), - comment.getCsafNodeId(), - comment.getFieldName(), - comment.getAnswerTo() - ); - } else { - throw new CsafException("User has not the permission to view comment from the advisory", - NoPermissionForAdvisory, UNAUTHORIZED); - } - - } catch (IOException e) { - throw new DatabaseException(e); + if (AdvisoryWorkflowUtil.canViewComment(advisoryInfo, credentials)) { + String answerTo = (commentEntity.getAnswerTo() != null) + ? commentEntity.getAnswerTo().getId().toString() : null; + return new CommentResponse( + commentId, + String.valueOf(commentEntity.getVersion()), + advisoryId, + commentEntity.getOwner(), + commentEntity.getCommentText(), + commentEntity.getCsafNodeId(), + commentEntity.getFieldName(), + answerTo + ); + } else { + throw new CsafException("User has not the permission to view comment from the advisory", + NoPermissionForAdvisory, UNAUTHORIZED); } } /** - * Retrieves all comments for a given advisory + * Retrieves all comments for a given advisory. * * @param advisoryId the ID of the advisory to get comments of * @return a list of information on all comments for the requested advisory - * @throws IOException when there are errors in JSON handling */ @Secured({CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_AUDITOR}) public List getComments(String advisoryId) throws IOException, CsafException { Authentication credentials = getAuthentication(); - AdvisoryInformationResponse advisoryInfo = getAdvisoryForId(advisoryId, this.couchDbService); - if (AdvisoryWorkflowUtil.canViewComment(advisoryInfo, credentials)) { + AdvisoryInformationResponse advisoryInfo = getAdvisoryInfoForId(advisoryId); - List fields = Arrays.asList( - CouchDbField.ID_FIELD, - CouchDbField.REVISION_FIELD, - CommentField.ADVISORY_ID, - CommentField.CSAF_NODE_ID, - CommentField.OWNER, - CommentField.ANSWER_TO - ); - - AndExpression searchExpr = new AndExpression( - equal(ObjectType.Comment.name(), TYPE_FIELD.getDbName()), - equal(advisoryId, CommentField.ADVISORY_ID.getDbName()) - ); - Map selector = expr2CouchDBFilter(searchExpr); - List commentInfosJson = this.findDocuments(selector, fields); - - return commentInfosJson.stream().map(CommentWrapper::convertToCommentInfo).toList(); + if (AdvisoryWorkflowUtil.canViewComment(advisoryInfo, credentials)) { + return postgresService.findCommentsByAdvisoryId(UUID.fromString(advisoryId)).stream() + .map(EntityConverter::toCommentInfo) + .toList(); } else { throw new AccessDeniedException("User has not the permission to add a comment to the advisory"); } } /** - * Deletes a comment without its answers from the database + * Deletes a comment without its answers from the database. * * @param commentId the ID of the comment to remove * @param commentRevision the comment's revision for concurrent control - * @throws DatabaseException when there are database errors - * @throws IOException when there are errors in JSON handling */ - void deleteComment(String commentId, String commentRevision) throws DatabaseException, IOException { + @Transactional + void deleteComment(String commentId, String commentRevision) throws DatabaseException, IOException, CsafException { - couchDbService.deleteDocument(commentId, commentRevision); - deleteAllAuditTrailDocumentsFromDbFor(commentId, CommentAuditTrailField.COMMENT_ID.getDbName()); + CommentEntity entity = findCommentEntityOrThrow(commentId); + checkCommentRevision(entity, commentRevision); + // Audit trail entries are removed via ON DELETE CASCADE in the schema + postgresService.deleteComment(entity); } /** - * Updates the text of a comment (or answer) + * Updates the text of a comment (or answer). * * @param commentId the ID of the comment to update * @param revision the revision for concurrent control @@ -1033,114 +1191,186 @@ void deleteComment(String commentId, String commentRevision) throws DatabaseExce * @return the new revision of the updated comment */ @Secured({CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_REVIEWER}) - public String updateComment(String advisoryId, String commentId, String revision, String newText) throws IOException, DatabaseException, CsafException { + @Transactional + public String updateComment(String advisoryId, String commentId, String revision, String newText) + throws IOException, DatabaseException, CsafException { Authentication credentials = getAuthentication(); - InputStream existingCommentStream = this.couchDbService.readDocumentAsStream(commentId); - if (existingCommentStream == null) { - throw new DatabaseException("Invalid comment ID!"); - } - CommentWrapper comment = CommentWrapper.createFromCouchDb(existingCommentStream); - final String commentOwner = comment.getOwner(); + CommentEntity commentEntity = findCommentEntityOrThrow(commentId); + final String commentOwner = commentEntity.getOwner(); if (commentOwner == null || !commentOwner.equals(credentials.getName())) { throw new AccessDeniedException("User has not the permission to change the comment"); } - comment.setRevision(revision); - comment.setText(newText); - - AuditTrailWrapper auditTrail = CommentAuditTrailWrapper.createNew(comment) - .setCommentId(commentId) - .setCommentText(newText) - .setCreatedAtToNow() - .setChangeType(ChangeType.Update) - .setUser("Mustermann"); - - String newRevision = this.couchDbService.updateDocument(comment.commentAsString()); - this.couchDbService.writeDocument(UUID.randomUUID(), auditTrail.auditTrailAsString()); - return newRevision; + checkCommentRevision(commentEntity, revision); + commentEntity.setCommentText(newText); + CommentEntity saved = postgresService.saveComment(commentEntity); + + AuditTrailCommentEntity auditEntity = EntityConverter.toAuditTrailCommentEntity( + saved, credentials.getName(), ChangeType.Update, newText); + postgresService.saveAuditTrailComment(auditEntity); + + return String.valueOf(saved.getVersion()); } /** - * Adds an answer to a comment + * Adds an answer to a comment. * * @param commentId the ID of the comment to add the answer to * @param commentText the answer to add, requires a commentText * @return a tuple of ID and revision of the added comment - * @throws DatabaseException when there are database errors - * @throws CsafException when there are errors in reading advisory */ @Secured({CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_REVIEWER}) - public IdAndRevision addAnswer(String advisoryId, String commentId, String commentText) throws DatabaseException, CsafException { + @Transactional + public IdAndRevision addAnswer(String advisoryId, String commentId, String commentText) + throws DatabaseException, CsafException { Authentication credentials = getAuthentication(); - AdvisoryInformationResponse advisoryInfo = getAdvisoryForId(advisoryId, this.couchDbService); + AdvisoryInformationResponse advisoryInfo = getAdvisoryInfoForId(advisoryId); + if (AdvisoryWorkflowUtil.canAddAndReplyCommentToAdvisory(advisoryInfo, credentials)) { - UUID answerId = UUID.randomUUID(); + AdvisoryEntity advisoryEntity; + try { + advisoryEntity = findAdvisoryEntityOrThrow(advisoryId); + } catch (IOException e) { + throw new DatabaseException(e); + } + CommentEntity parentComment = findCommentEntityOrThrow(commentId); CommentWrapper newAnswer = CommentWrapper.createNewAnswerFromJson(advisoryId, commentId, commentText); newAnswer.setOwner(credentials.getName()); - String commentRevision = this.couchDbService.writeDocument(answerId, newAnswer.commentAsString()); - AuditTrailWrapper auditTrail = CommentAuditTrailWrapper.createNew(newAnswer) - .setCommentId(answerId.toString()) - .setChangeType(ChangeType.Create) - .setUser(credentials.getName()); - this.couchDbService.writeDocument(UUID.randomUUID(), auditTrail.auditTrailAsString()); + CommentEntity answerEntity = EntityConverter.toEntity(newAnswer, advisoryEntity, null); + answerEntity.setAnswerTo(parentComment); + CommentEntity savedAnswer = postgresService.saveComment(answerEntity); - return new IdAndRevision(answerId.toString(), commentRevision); + AuditTrailCommentEntity auditEntity = EntityConverter.toAuditTrailCommentEntity( + savedAnswer, credentials.getName(), ChangeType.Create, commentText); + postgresService.saveAuditTrailComment(auditEntity); + + return new IdAndRevision(savedAnswer.getId().toString(), String.valueOf(savedAnswer.getVersion())); } else { throw new AccessDeniedException("User has not the permission to add a comment to the advisory"); } } /** - * Retrieves all answers for a given comment + * Retrieves all answers for a given comment. * * @param commentId the ID of the comment to get answers of * @return a list of information on all answers for the requested comment - * @throws IOException when there are errors in JSON handling */ @Secured({CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_AUDITOR}) - public List getAnswers(String advisoryId, String commentId) throws IOException, CsafException { + public List getAnswers(String advisoryId, String commentId) + throws IOException, CsafException { Authentication credentials = getAuthentication(); - AdvisoryInformationResponse advisoryInfo = getAdvisoryForId(advisoryId, this.couchDbService); - if (AdvisoryWorkflowUtil.canViewComment(advisoryInfo, credentials)) { - List fields = Arrays.asList( - CouchDbField.ID_FIELD, CouchDbField.REVISION_FIELD, CommentField.ANSWER_TO, CommentField.OWNER); + AdvisoryInformationResponse advisoryInfo = getAdvisoryInfoForId(advisoryId); - AndExpression searchExpr = new AndExpression( - equal(ObjectType.Comment.name(), TYPE_FIELD.getDbName()), - equal(commentId, CommentField.ANSWER_TO.getDbName()) - ); - Map selector = expr2CouchDBFilter(searchExpr); - List answerInfosJson = this.findDocuments(selector, fields); - - return answerInfosJson.stream().map(CommentWrapper::convertToAnswerInfo).toList(); + if (AdvisoryWorkflowUtil.canViewComment(advisoryInfo, credentials)) { + return postgresService.findAnswersByCommentId(UUID.fromString(commentId)).stream() + .map(EntityConverter::toAnswerInfo) + .toList(); } else { throw new AccessDeniedException("User has not the permission to view comments of the advisory"); } } /** - * Deletes an answer from the database + * Deletes an answer from the database. * * @param answerId the ID of the comment to remove * @param answerRevision the comment's revision for concurrent control - * @throws DatabaseException when there are database errors - * @throws IOException when there are errors in JSON handling */ - void deleteAnswer(String answerId, String answerRevision) throws DatabaseException, IOException { - couchDbService.deleteDocument(answerId, answerRevision); - deleteAllAuditTrailDocumentsFromDbFor(answerId, CommentAuditTrailField.COMMENT_ID.getDbName()); + @Transactional + void deleteAnswer(String answerId, String answerRevision) throws DatabaseException, IOException, CsafException { + + CommentEntity entity = findCommentEntityOrThrow(answerId); + checkCommentRevision(entity, answerRevision); + // Audit trail entries are removed via ON DELETE CASCADE + postgresService.deleteComment(UUID.fromString(answerId)); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Load an advisory info response for the given ID without requiring a full wrapper load. + * Used for permission checks before comment operations. + */ + private AdvisoryInformationResponse getAdvisoryInfoForId(String advisoryId) throws CsafException { + + Optional optionalEntity = postgresService.findAdvisoryById(UUID.fromString(advisoryId)); + if (optionalEntity.isEmpty()) { + throw new CsafException("Advisory not found", CsafExceptionKey.AdvisoryNotFound, NOT_FOUND); + } + return EntityConverter.toAdvisoryInfo(optionalEntity.get()); + } + + /** + * Load an advisory entity by ID, throwing a DatabaseException when not found. + */ + private AdvisoryEntity findAdvisoryEntityOrThrow(String advisoryId) + throws IOException, DatabaseException { + + try { + UUID id = UUID.fromString(advisoryId); + return postgresService.findAdvisoryById(id) + .orElseThrow(() -> new IdNotFoundException("Advisory not found: " + advisoryId)); + } catch (IllegalArgumentException e) { + throw new IdNotFoundException("Invalid advisory ID format: " + advisoryId); + } + } + + /** + * Load a comment entity by ID, throwing a DatabaseException when not found. + */ + private CommentEntity findCommentEntityOrThrow(String commentId) throws DatabaseException { + + try { + UUID id = UUID.fromString(commentId); + return postgresService.findCommentById(id) + .orElseThrow(() -> new IdNotFoundException("Comment not found: " + commentId)); + } catch (IllegalArgumentException e) { + throw new IdNotFoundException("Invalid comment ID format: " + commentId); + } + } + + /** + * Verify that the client-supplied revision still matches the advisory's current + * optimistic-lock version before allowing a mutation to proceed. + * + * @param entity the freshly loaded advisory entity + * @param revision the revision the client last saw + * @throws CsafException with {@link CsafExceptionKey#CsafHasWrongRevision} (HTTP 409) if the + * advisory was changed by someone else in the meantime + */ + private void checkAdvisoryRevision(AdvisoryEntity entity, String revision) throws CsafException { + if (!String.valueOf(entity.getVersion()).equals(revision)) { + throw new CsafException( + "The advisory was changed by someone else in the meantime, please reload and try again", + CsafHasWrongRevision, CONFLICT); + } } /** - * Get the credentials for the authenticated in user. + * Verify that the client-supplied revision still matches the comment's current + * optimistic-lock version before allowing a mutation to proceed. * - * @return the credentials + * @param entity the freshly loaded comment entity + * @param revision the revision the client last saw + * @throws CsafException with {@link CsafExceptionKey#CsafHasWrongRevision} (HTTP 409) if the + * comment was changed by someone else in the meantime */ + private void checkCommentRevision(CommentEntity entity, String revision) throws CsafException { + if (!String.valueOf(entity.getVersion()).equals(revision)) { + throw new CsafException( + "The comment was changed by someone else in the meantime, please reload and try again", + CsafHasWrongRevision, CONFLICT); + } + } + private Authentication getAuthentication() { return SecurityContextHolder.getContext().getAuthentication(); } @@ -1148,5 +1378,4 @@ private Authentication getAuthentication() { private String getCurrentTimestamp() { return DateTimeFormatter.ISO_INSTANT.format(Instant.now()); } - } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java index 9db6dfba..4fe7b773 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java @@ -1,27 +1,22 @@ package de.bsi.secvisogram.csaf_cms_backend.service; import static de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles.Role.*; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDBFilterCreator.expr2CouchDBFilter; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.ID_FIELD; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.TYPE_FIELD; import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.equal; import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.less; import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.notEqual; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.*; -import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; -import de.bsi.secvisogram.csaf_cms_backend.exception.CsafExceptionKey; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisoryField; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisorySearchField; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.DbField; import de.bsi.secvisogram.csaf_cms_backend.json.AdvisoryWrapper; -import de.bsi.secvisogram.csaf_cms_backend.json.ObjectType; import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; import de.bsi.secvisogram.csaf_cms_backend.model.filter.AndExpression; import de.bsi.secvisogram.csaf_cms_backend.model.filter.Expression; import de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression; import de.bsi.secvisogram.csaf_cms_backend.model.filter.OrExpression; import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryInformationResponse; -import java.io.IOException; -import java.io.InputStream; import java.time.Instant; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -31,13 +26,9 @@ import org.apache.commons.text.similarity.LevenshteinResults; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.json.JsonMapper; -import tools.jackson.databind.node.ArrayNode; /** * Helper Methods for workflow and permissions @@ -401,46 +392,6 @@ public static Map> advi ); } - public static AdvisoryInformationResponse getAdvisoryForId(String advisoryId, CouchDbService couchDbService) throws CsafException { - - Map> infoFields = AdvisoryWorkflowUtil.advisoryReadFields(); - OperatorExpression typeIsAdvisory = equal(ObjectType.Advisory.name(), TYPE_FIELD.getDbName()); - OperatorExpression advisoryIdIsEqual = equal(advisoryId, ID_FIELD.getDbName()); - Map selector = expr2CouchDBFilter(new AndExpression(typeIsAdvisory, advisoryIdIsEqual)); - try { - List docList = findDocuments(couchDbService, selector, new ArrayList<>(infoFields.keySet())); - List allResposes = docList.stream() - .map(couchDbDoc -> AdvisoryWrapper.convertToAdvisoryInfo(couchDbDoc, infoFields)) - .toList(); - if (allResposes.size() == 1) { - return allResposes.get(0); - } else { - throw new CsafException("Advisory not found", CsafExceptionKey.AdvisoryNotFound, HttpStatus.NOT_FOUND); - } - } catch (IOException ex) { - LOG.error("Could not create Advisory", ex); - throw new CsafException(ex, CsafExceptionKey.AdvisoryNotFound, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - /** - * read from {@link CouchDbService#findDocumentsAsStream(Map, Collection)} and convert it to a list of JsonNode - * - * @param selector the selector to search for - * @param fields the fields of information to select - * @return the result nodes of the search - */ - public static List findDocuments(CouchDbService couchDbService, Map selector, Collection fields) - throws IOException { - - InputStream inputStream = couchDbService.findDocumentsAsStream(selector, fields); - ObjectMapper mapper = new JsonMapper(); - JsonNode couchDbResultNode = mapper.readValue(inputStream, JsonNode.class); - ArrayNode couchDbDocs = (ArrayNode) couchDbResultNode.get("docs"); - List docNodes = new ArrayList<>(); - couchDbDocs.forEach(docNodes::add); - return docNodes; - } public static PatchType getChangeType(AdvisoryWrapper oldAdvisoryNode, AdvisoryWrapper newAdvisory, int maxLevenshteinDistance) { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/EntityConverter.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/EntityConverter.java new file mode 100644 index 00000000..26443eed --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/EntityConverter.java @@ -0,0 +1,387 @@ +package de.bsi.secvisogram.csaf_cms_backend.service; + +import de.bsi.secvisogram.csaf_cms_backend.entity.AdvisoryEntity; +import de.bsi.secvisogram.csaf_cms_backend.entity.AdvisoryVersionEntity; +import de.bsi.secvisogram.csaf_cms_backend.entity.AuditTrailCommentEntity; +import de.bsi.secvisogram.csaf_cms_backend.entity.AuditTrailDocumentEntity; +import de.bsi.secvisogram.csaf_cms_backend.entity.AuditTrailWorkflowEntity; +import de.bsi.secvisogram.csaf_cms_backend.entity.CommentEntity; +import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; +import de.bsi.secvisogram.csaf_cms_backend.json.AdvisoryWrapper; +import de.bsi.secvisogram.csaf_cms_backend.json.CommentWrapper; +import de.bsi.secvisogram.csaf_cms_backend.model.ChangeType; +import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryInformationResponse; +import de.bsi.secvisogram.csaf_cms_backend.rest.response.AnswerInformationResponse; +import de.bsi.secvisogram.csaf_cms_backend.rest.response.CommentInformationResponse; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Utility class for converting between JPA entities and the domain wrapper objects + * used by the service layer. Bridges the gap between the JPA persistence model and + * the JSON-centric wrapper model used during the migration from CouchDB. + */ +public final class EntityConverter { + + private EntityConverter() { + // utility class + } + + // ------------------------------------------------------------------------- + // Advisory conversions + // ------------------------------------------------------------------------- + + /** + * Build an {@link AdvisoryWrapper} from a persisted {@link AdvisoryEntity}. + * + *

The wrapper node is constructed to be structurally identical to what + * {@code AdvisoryWrapper.createFromStream} would produce, with synthetic + * {@code _id} and {@code _rev} fields populated from the entity's primary key + * and optimistic-lock version respectively.

+ * + * @param entity the advisory entity to convert + * @return an AdvisoryWrapper backed by the entity data + * @throws IOException if the CSAF JSON cannot be serialised/parsed + * @throws CsafException if the resulting node is not of type Advisory + */ + public static AdvisoryWrapper toWrapper(AdvisoryEntity entity) throws IOException, CsafException { + + ObjectMapper mapper = new JsonMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("_id", entity.getId().toString()); + node.put("_rev", String.valueOf(entity.getVersion())); + node.put("type", "Advisory"); + node.put("workflowState", entity.getWorkflowState()); + node.put("owner", entity.getOwner()); + if (entity.getCsaf() != null) { + node.set("csaf", entity.getCsaf()); + } + node.put("versioningType", entity.getVersioningType()); + if (entity.getLastMajorVersion() != null) { + node.put("lastMajorVersion", entity.getLastMajorVersion()); + } + if (entity.getTmpTrackingId() != null) { + node.put("tmpTrackingId", entity.getTmpTrackingId()); + } + if (entity.getCreatedAt() != null) { + node.put("createdAt", entity.getCreatedAt().toString()); + } + + String json = node.toString(); + return AdvisoryWrapper.createFromStream( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + } + + /** + * Build an {@link AdvisoryWrapper} from a persisted {@link AdvisoryVersionEntity}. + * + * @param entity the advisory version entity + * @return an AdvisoryWrapper backed by the version entity data + * @throws IOException if the CSAF JSON cannot be serialised/parsed + * @throws CsafException if the resulting node is not of acceptable type + */ + public static AdvisoryWrapper toWrapper(AdvisoryVersionEntity entity) throws IOException, CsafException { + + ObjectMapper mapper = new JsonMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("_id", entity.getId().toString()); + node.put("type", "AdvisoryVersion"); + node.put("workflowState", entity.getWorkflowState()); + node.put("owner", entity.getOwner()); + if (entity.getCsaf() != null) { + node.set("csaf", entity.getCsaf()); + } + node.put("versioningType", entity.getVersioningType()); + if (entity.getLastMajorVersion() != null) { + node.put("lastMajorVersion", entity.getLastMajorVersion()); + } + if (entity.getAdvisory() != null) { + node.put("advisoryReference", entity.getAdvisory().getId().toString()); + } + if (entity.getCreatedAt() != null) { + node.put("createdAt", entity.getCreatedAt().toString()); + } + + String json = node.toString(); + return AdvisoryWrapper.createFromStream( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + } + + /** + * Populate an {@link AdvisoryEntity} from an {@link AdvisoryWrapper}. + * + *

When {@code existing} is non-null its identity and creation timestamp are + * preserved; only mutable fields are overwritten. When {@code existing} is null + * a new entity is created. The entity ID is set only when the wrapper carries one.

+ * + * @param wrapper the advisory wrapper to read from + * @param existing the entity to update, or {@code null} to create a new one + * @return the populated entity (never null) + */ + public static AdvisoryEntity toEntity(AdvisoryWrapper wrapper, AdvisoryEntity existing) { + + AdvisoryEntity entity = (existing != null) ? existing : new AdvisoryEntity(); + if (wrapper.getAdvisoryId() != null) { + entity.setId(UUID.fromString(wrapper.getAdvisoryId())); + } + entity.setWorkflowState(wrapper.getWorkflowStateString()); + entity.setOwner(wrapper.getOwner()); + if (wrapper.getCsaf() != null) { + entity.setCsaf(wrapper.getCsaf()); + } + entity.setVersioningType(wrapper.getVersioningType()); + entity.setLastMajorVersion(wrapper.getLastVersion()); + entity.setTmpTrackingId(wrapper.getTempTrackingIdInFromMeta()); + if (entity.getCreatedAt() == null) { + entity.setCreatedAt(Instant.now()); + } + return entity; + } + + /** + * Build an {@link AdvisoryVersionEntity} from an advisory version snapshot wrapper. + * + * @param versionWrapper the advisory version wrapper + * @param parentEntity the parent advisory entity this version belongs to + * @return a populated version entity + */ + public static AdvisoryVersionEntity toVersionEntity(AdvisoryWrapper versionWrapper, AdvisoryEntity parentEntity) { + + AdvisoryVersionEntity entity = new AdvisoryVersionEntity(); + entity.setAdvisory(parentEntity); + entity.setWorkflowState(versionWrapper.getWorkflowStateString()); + entity.setOwner(versionWrapper.getOwner()); + if (versionWrapper.getCsaf() != null) { + entity.setCsaf(versionWrapper.getCsaf()); + } + entity.setVersioningType(versionWrapper.getVersioningType()); + entity.setLastMajorVersion(versionWrapper.getLastVersion()); + entity.setCreatedAt(Instant.now()); + return entity; + } + + // ------------------------------------------------------------------------- + // Comment conversions + // ------------------------------------------------------------------------- + + /** + * Populate a {@link CommentEntity} from a {@link CommentWrapper}. + * + * @param wrapper the comment wrapper + * @param advisoryEntity the advisory this comment belongs to + * @param existing the entity to update, or {@code null} to create a new one + * @return the populated entity + */ + public static CommentEntity toEntity(CommentWrapper wrapper, AdvisoryEntity advisoryEntity, + CommentEntity existing) { + + CommentEntity entity = (existing != null) ? existing : new CommentEntity(); + if (wrapper.getCommentId() != null) { + entity.setId(UUID.fromString(wrapper.getCommentId())); + } + entity.setAdvisory(advisoryEntity); + entity.setOwner(wrapper.getOwner()); + entity.setCommentText(wrapper.getText()); + entity.setCsafNodeId(wrapper.getCsafNodeId()); + entity.setFieldName(wrapper.getFieldName()); + if (entity.getCreatedAt() == null) { + entity.setCreatedAt(Instant.now()); + } + return entity; + } + + /** + * Build an {@link AdvisoryInformationResponse} from an {@link AdvisoryEntity} + * without loading comments. Revision is set to the entity's optimistic-lock version. + * + * @param entity the advisory entity + * @return a populated response + */ + public static AdvisoryInformationResponse toAdvisoryInfo(AdvisoryEntity entity) { + + AdvisoryInformationResponse response = new AdvisoryInformationResponse(entity.getId().toString()); + response.setRevision(String.valueOf(entity.getVersion())); + response.setWorkflowState(entity.getWorkflowState()); + response.setOwner(entity.getOwner()); + JsonNode csafNode = entity.getCsaf(); + if (csafNode != null) { + JsonNode titleNode = csafNode.at("/document/title"); + if (!titleNode.isMissingNode()) { + response.setTitle(titleNode.asString()); + } + JsonNode trackingIdNode = csafNode.at("/document/tracking/id"); + if (!trackingIdNode.isMissingNode()) { + response.setDocumentTrackingId(trackingIdNode.asString()); + } + JsonNode releaseDateNode = csafNode.at("/document/tracking/current_release_date"); + if (!releaseDateNode.isMissingNode()) { + response.setCurrentReleaseDate(releaseDateNode.asString()); + } + } + return response; + } + + /** + * Build an {@link AdvisoryInformationResponse} from an {@link AdvisoryVersionEntity}. + * Version snapshots have no optimistic-lock version field, so revision is left null. + * + * @param entity the advisory version entity + * @return a populated response + */ + public static AdvisoryInformationResponse toAdvisoryVersionInfo(AdvisoryVersionEntity entity) { + + String id = (entity.getAdvisory() != null) + ? entity.getAdvisory().getId().toString() : entity.getId().toString(); + AdvisoryInformationResponse response = new AdvisoryInformationResponse(id); + response.setWorkflowState(entity.getWorkflowState()); + response.setOwner(entity.getOwner()); + JsonNode csafNode = entity.getCsaf(); + if (csafNode != null) { + JsonNode titleNode = csafNode.at("/document/title"); + if (!titleNode.isMissingNode()) { + response.setTitle(titleNode.asString()); + } + JsonNode trackingIdNode = csafNode.at("/document/tracking/id"); + if (!trackingIdNode.isMissingNode()) { + response.setDocumentTrackingId(trackingIdNode.asString()); + } + JsonNode releaseDateNode = csafNode.at("/document/tracking/current_release_date"); + if (!releaseDateNode.isMissingNode()) { + response.setCurrentReleaseDate(releaseDateNode.asString()); + } + } + return response; + } + + /** + * Build a {@link CommentInformationResponse} from a {@link CommentEntity}. + * + * @param entity the comment entity + * @return a populated response + */ + public static CommentInformationResponse toCommentInfo(CommentEntity entity) { + + String advisoryId = (entity.getAdvisory() != null) ? entity.getAdvisory().getId().toString() : null; + CommentInformationResponse response = new CommentInformationResponse( + entity.getId().toString(), + advisoryId, + entity.getCsafNodeId(), + entity.getOwner() + ); + if (entity.getAnswerTo() != null) { + response.setAnswerTo(entity.getAnswerTo().getId().toString()); + } + return response; + } + + /** + * Build an {@link AnswerInformationResponse} from a {@link CommentEntity} + * that represents an answer (i.e. its {@code answerTo} is set). + * + * @param entity the answer entity + * @return a populated response + */ + public static AnswerInformationResponse toAnswerInfo(CommentEntity entity) { + + String answerTo = (entity.getAnswerTo() != null) ? entity.getAnswerTo().getId().toString() : null; + return new AnswerInformationResponse(entity.getId().toString(), answerTo, entity.getOwner()); + } + + // ------------------------------------------------------------------------- + // Audit trail conversions + // ------------------------------------------------------------------------- + + /** + * Build an {@link AuditTrailDocumentEntity} from audit trail wrapper data. + * + * @param advisoryEntity the advisory this audit trail entry belongs to + * @param user the user who triggered the change + * @param changeType the type of change + * @param diffPatch the JSON-Patch diff as a tools.jackson JsonNode (may be null) + * @param oldDocVersion the document version before the change + * @param docVersion the document version after the change + * @return a populated audit trail document entity + */ + public static AuditTrailDocumentEntity toAuditTrailDocumentEntity( + AdvisoryEntity advisoryEntity, + String user, + ChangeType changeType, + JsonNode diffPatch, + String oldDocVersion, + String docVersion) { + + AuditTrailDocumentEntity entity = new AuditTrailDocumentEntity(); + entity.setAdvisory(advisoryEntity); + entity.setCreatedAt(Instant.now()); + entity.setUser(user); + entity.setChangeType(changeType.name()); + entity.setOldDocVersion(oldDocVersion); + entity.setDocVersion(docVersion); + if (diffPatch != null) { + entity.setDiff(diffPatch); + } + return entity; + } + + /** + * Build an {@link AuditTrailWorkflowEntity} from audit trail wrapper data. + * + * @param advisoryEntity the advisory this audit trail entry belongs to + * @param user the user who triggered the change + * @param oldState the workflow state before the transition + * @param newState the workflow state after the transition + * @param oldDocVersion the document version before the change + * @param docVersion the document version after the change + * @return a populated audit trail workflow entity + */ + public static AuditTrailWorkflowEntity toAuditTrailWorkflowEntity( + AdvisoryEntity advisoryEntity, + String user, + String oldState, + String newState, + String oldDocVersion, + String docVersion) { + + AuditTrailWorkflowEntity entity = new AuditTrailWorkflowEntity(); + entity.setAdvisory(advisoryEntity); + entity.setCreatedAt(Instant.now()); + entity.setUser(user); + entity.setChangeType(ChangeType.Update.name()); + entity.setOldState(oldState); + entity.setNewState(newState); + entity.setOldDocVersion(oldDocVersion); + entity.setDocVersion(docVersion); + return entity; + } + + /** + * Build an {@link AuditTrailCommentEntity} from comment audit trail data. + * + * @param commentEntity the comment this audit trail entry belongs to + * @param user the user who triggered the change + * @param changeType the type of change + * @param commentText the comment text at the time of the change (may be null) + * @return a populated audit trail comment entity + */ + public static AuditTrailCommentEntity toAuditTrailCommentEntity( + CommentEntity commentEntity, + String user, + ChangeType changeType, + String commentText) { + + AuditTrailCommentEntity entity = new AuditTrailCommentEntity(); + entity.setComment(commentEntity); + entity.setCreatedAt(Instant.now()); + entity.setUser(user); + entity.setChangeType(changeType.name()); + entity.setCommentText(commentText); + return entity; + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java new file mode 100644 index 00000000..1b954bee --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java @@ -0,0 +1,416 @@ +package de.bsi.secvisogram.csaf_cms_backend.service; + +import de.bsi.secvisogram.csaf_cms_backend.couchdb.IdNotFoundException; +import de.bsi.secvisogram.csaf_cms_backend.entity.*; +import de.bsi.secvisogram.csaf_cms_backend.repository.*; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.JsonNode; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Bridge service that wraps the JPA repositories and provides a single access point + * for all PostgreSQL persistence operations. Acts as an adapter layer during the + * gradual migration away from CouchDB, allowing the rest of the service layer to + * be migrated incrementally without exposing individual repositories directly. + */ +@Service +@SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "Spring DI") +public class PostgresRepositoryService { + + private static final Sort BY_ID = Sort.by("id"); + + private final AdvisoryRepository advisoryRepository; + private final AdvisoryVersionRepository advisoryVersionRepository; + private final AuditTrailDocumentRepository auditTrailDocumentRepository; + private final AuditTrailWorkflowRepository auditTrailWorkflowRepository; + private final CommentRepository commentRepository; + private final AuditTrailCommentRepository auditTrailCommentRepository; + private final CounterRepository counterRepository; + + /** + * Create a new PostgresRepositoryService with all required repositories. + * + * @param advisoryRepository repository for advisory entities + * @param advisoryVersionRepository repository for advisory version snapshots + * @param auditTrailDocumentRepository repository for document audit trail entries + * @param auditTrailWorkflowRepository repository for workflow audit trail entries + * @param commentRepository repository for comment entities + * @param auditTrailCommentRepository repository for comment audit trail entries + * @param counterRepository repository for sequential counters + */ + public PostgresRepositoryService( + AdvisoryRepository advisoryRepository, + AdvisoryVersionRepository advisoryVersionRepository, + AuditTrailDocumentRepository auditTrailDocumentRepository, + AuditTrailWorkflowRepository auditTrailWorkflowRepository, + CommentRepository commentRepository, + AuditTrailCommentRepository auditTrailCommentRepository, + CounterRepository counterRepository) { + this.advisoryRepository = advisoryRepository; + this.advisoryVersionRepository = advisoryVersionRepository; + this.auditTrailDocumentRepository = auditTrailDocumentRepository; + this.auditTrailWorkflowRepository = auditTrailWorkflowRepository; + this.commentRepository = commentRepository; + this.auditTrailCommentRepository = auditTrailCommentRepository; + this.counterRepository = counterRepository; + } + + // --- Advisory operations --- + + /** + * Persist a new advisory or update an existing one. + * + * @param entity the advisory entity to save + * @return the saved entity (with generated ID if new), with an up-to-date {@code version} + */ + public AdvisoryEntity saveAdvisory(AdvisoryEntity entity) { + return advisoryRepository.saveAndFlush(entity); + } + + /** + * Look up a single advisory by its primary key. + * + * @param id the advisory UUID + * @return an Optional containing the entity, or empty if not found + */ + public Optional findAdvisoryById(UUID id) { + return advisoryRepository.findById(id); + } + + /** + * Return every advisory stored in the database, ordered by id. Without an explicit + * order PostgreSQL returns rows in storage order, which changes after updates; + * CouchDB used to return documents in {@code _id} order. + * + * @return all advisory entities + */ + public List findAllAdvisories() { + return advisoryRepository.findAll(BY_ID); + } + + /** + * Return all advisories that satisfy the given JPA Specification. + * + * @param spec the filtering specification + * @return matching advisory entities, ordered by id + */ + public List findAdvisories(Specification spec) { + return advisoryRepository.findAll(spec, BY_ID); + } + + /** + * Delete the advisory identified by the given UUID. + * + * @param id the advisory UUID to delete + */ + @Transactional + public void deleteAdvisory(UUID id) { + advisoryRepository.deleteById(id); + } + + /** + * Delete the advisory. + * + * @param entity the advisory to delete + */ + @Transactional + public void deleteAdvisory(AdvisoryEntity entity) { + advisoryRepository.delete(entity); + } + + /** + * Check whether an advisory with the given CSAF tracking ID already exists. + * + * @param trackingId the CSAF document tracking ID + * @return true if at least one advisory carries that tracking ID + */ + public boolean advisoryExistsByTrackingId(String trackingId) { + return advisoryRepository.existsByTrackingId(trackingId); + } + + /** + * Return the total number of advisories in the database. + * + * @return advisory count + */ + public long getAdvisoryCount() { + return advisoryRepository.count(); + } + + /** + * Return the total number of documents across all tables. + * This is primarily used by integration tests to verify that the expected number of records + * are created/deleted across all entity types (advisories, comments, audit trails, counters). + * + * @return total count of all entities + */ + public long getTotalDocumentCount() { + return advisoryRepository.count() + + advisoryVersionRepository.count() + + auditTrailDocumentRepository.count() + + auditTrailWorkflowRepository.count() + + commentRepository.count() + + auditTrailCommentRepository.count() + + counterRepository.count(); + } + + // --- Advisory Version operations --- + + /** + * Persist a new advisory version snapshot or update an existing one. + * + * @param entity the version entity to save + * @return the saved entity + */ + public AdvisoryVersionEntity saveAdvisoryVersion(AdvisoryVersionEntity entity) { + return advisoryVersionRepository.save(entity); + } + + /** + * Return every advisory version snapshot stored in the database, ordered by id. + * + * @return all advisory version entities + */ + public List findAllAdvisoryVersions() { + return advisoryVersionRepository.findAll(BY_ID); + } + + /** + * Delete all version snapshots belonging to the given advisory. + * + * @param advisoryId the advisory UUID whose versions should be removed + */ + @Transactional + public void deleteAdvisoryVersionsByAdvisoryId(UUID advisoryId) { + advisoryVersionRepository.deleteByAdvisoryId(advisoryId); + } + + // --- Audit Trail Document operations --- + + /** + * Persist a new document audit trail entry or update an existing one. + * + * @param entity the audit trail document entity to save + * @return the saved entity + */ + public AuditTrailDocumentEntity saveAuditTrailDocument(AuditTrailDocumentEntity entity) { + return auditTrailDocumentRepository.save(entity); + } + + /** + * Return all document audit trail entries for the given advisory. + * + * @param advisoryId the advisory UUID + * @return matching audit trail document entries + */ + public List findAuditTrailDocumentsByAdvisoryId(UUID advisoryId) { + return auditTrailDocumentRepository.findByAdvisoryId(advisoryId); + } + + /** + * Delete all document audit trail entries belonging to the given advisory. + * + * @param advisoryId the advisory UUID + */ + @Transactional + public void deleteAuditTrailDocumentsByAdvisoryId(UUID advisoryId) { + auditTrailDocumentRepository.deleteByAdvisoryId(advisoryId); + } + + // --- Audit Trail Workflow operations --- + + /** + * Persist a new workflow audit trail entry or update an existing one. + * + * @param entity the audit trail workflow entity to save + * @return the saved entity + */ + public AuditTrailWorkflowEntity saveAuditTrailWorkflow(AuditTrailWorkflowEntity entity) { + return auditTrailWorkflowRepository.save(entity); + } + + /** + * Return all workflow audit trail entries for the given advisory. + * + * @param advisoryId the advisory UUID + * @return matching audit trail workflow entries + */ + public List findAuditTrailWorkflowsByAdvisoryId(UUID advisoryId) { + return auditTrailWorkflowRepository.findByAdvisoryId(advisoryId); + } + + /** + * Delete all workflow audit trail entries belonging to the given advisory. + * + * @param advisoryId the advisory UUID + */ + @Transactional + public void deleteAuditTrailWorkflowsByAdvisoryId(UUID advisoryId) { + auditTrailWorkflowRepository.deleteByAdvisoryId(advisoryId); + } + + // --- Comment operations --- + + /** + * Persist a new comment or update an existing one. + * + * @param entity the comment entity to save + * @return the saved entity, with an up-to-date {@code version} + */ + public CommentEntity saveComment(CommentEntity entity) { + return commentRepository.saveAndFlush(entity); + } + + /** + * Look up a single comment by its primary key. + * + * @param id the comment UUID + * @return an Optional containing the entity, or empty if not found + */ + public Optional findCommentById(UUID id) { + return commentRepository.findById(id); + } + + /** + * Return all top-level comments and answers belonging to the given advisory. + * + * @param advisoryId the advisory UUID + * @return matching comment entities + */ + public List findCommentsByAdvisoryId(UUID advisoryId) { + return commentRepository.findByAdvisoryId(advisoryId); + } + + /** + * Return all answers (replies) that reference the given parent comment. + * + * @param commentId the UUID of the parent comment + * @return answer entities whose answerTo references the given comment + */ + public List findAnswersByCommentId(UUID commentId) { + return commentRepository.findByAnswerToId(commentId); + } + + /** + * Delete the comment identified by the given UUID. + * + * @param id the comment UUID to delete + */ + @Transactional + public void deleteComment(UUID id) { + commentRepository.deleteById(id); + } + + /** + * Delete the comment. + * + * @param entity the comment entity to delete + */ + @Transactional + public void deleteComment(CommentEntity entity) { + commentRepository.delete(entity); + } + + /** + * Delete all comments belonging to the given advisory. + * + * @param advisoryId the advisory UUID + */ + @Transactional + public void deleteCommentsByAdvisoryId(UUID advisoryId) { + commentRepository.deleteByAdvisoryId(advisoryId); + } + + // --- Audit Trail Comment operations --- + + /** + * Persist a new comment audit trail entry or update an existing one. + * + * @param entity the audit trail comment entity to save + * @return the saved entity + */ + public AuditTrailCommentEntity saveAuditTrailComment(AuditTrailCommentEntity entity) { + return auditTrailCommentRepository.save(entity); + } + + /** + * Delete all audit trail entries for the given comment. + * + * @param commentId the comment UUID + */ + @Transactional + public void deleteAuditTrailCommentsByCommentId(UUID commentId) { + auditTrailCommentRepository.deleteByCommentId(commentId); + } + + // --- Counter operations --- + + /** + * Atomically increment the named counter and return its new value. + * If the counter does not yet exist it is created with an initial value of zero + * before the increment is applied, so the first call returns 1. + * + *

This method runs in its own transaction ({@code REQUIRES_NEW}) so that a + * {@link DataIntegrityViolationException} caused by a concurrent first-time insert + * rolls back only this unit of work and does not poison the caller's transaction. + * On conflict the method retries once: the row is guaranteed to exist at that point, + * so the retry always succeeds.

+ * + *

{@code saveAndFlush} is used (rather than {@code save}) to guarantee the new + * row is written to the database before the subsequent native UPDATE executes. + * Without an explicit flush, JPA's {@code AUTO} flush mode does not guarantee + * ordering relative to native queries.

+ * + * @param counterId the logical name of the counter (e.g. "TMP_TRACKING_ID_COUNTER") + * @return the post-increment counter value (always >= 1) + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long incrementAndGetCounter(String counterId) { + Long newValue = counterRepository.incrementAndGet(counterId); + if (newValue != null) { + return newValue; + } + try { + CounterEntity counter = new CounterEntity(); + counter.setId(counterId); + counter.setCount(0L); + counterRepository.saveAndFlush(counter); + } catch (DataIntegrityViolationException ignored) { + // A concurrent caller created the row first; proceed to increment below. + } + Long retried = counterRepository.incrementAndGet(counterId); + if (retried == null) { + throw new IllegalStateException( + "Counter '" + counterId + "' could not be incremented after creation"); + } + return retried; + } + + /** + * Read a document from the database as JsonNode + * + * @param uuid id of the document to read + * @return the requested document as JsonNode + * @throws IdNotFoundException if the requested document was not found + */ + public JsonNode readDocumentAsJsonNode(final String uuid) throws IdNotFoundException { + + Optional optionalEntity = this.findAdvisoryById(UUID.fromString(uuid)); + + if (optionalEntity.isEmpty()) { + throw new IdNotFoundException("Advisory not found"); + } else { + return optionalEntity.get().getCsaf(); + } + + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2e5706b9..72c32f63 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,12 +13,24 @@ csaf.csrf.enabled=${CSAF_CMS_BACKEND_CSRF_ENABLED:true} # s. https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.6-Release-Notes#pathpattern-based-path-matching-strategy-for-spring-mvc spring.mvc.pathmatch.matching-strategy=ANT_PATH_MATCHER spring.config.import=optional:file:.env[.properties] -csaf.couchdb.host=${CSAF_COUCHDB_HOST:localhost} -csaf.couchdb.port=${CSAF_COUCHDB_PORT:5984} -csaf.couchdb.ssl=${CSAF_COUCHDB_SSL:false} -csaf.couchdb.dbname=${CSAF_COUCHDB_DBNAME:csaf} -csaf.couchdb.user=${CSAF_COUCHDB_USER:admin} -csaf.couchdb.password=${CSAF_COUCHDB_PASSWORD:admin} + +# PostgreSQL +spring.datasource.url=jdbc:postgresql://${CSAF_DB_HOST:localhost}:${CSAF_DB_PORT:5432}/${CSAF_DB_NAME:csaf} +spring.datasource.username=${CSAF_DB_USER:csaf} +spring.datasource.password=${CSAF_DB_PASSWORD:csaf} +spring.datasource.driver-class-name=org.postgresql.Driver +jakarta.persistence.jdbc.url=jdbc:postgresql://${CSAF_DB_HOST:localhost}:${CSAF_DB_PORT:5432}/${CSAF_DB_NAME:csaf} + +# JPA / Hibernate +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=true +spring.jpa.open-in-view=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.type.json_format_mapper=jackson3 + +# Flyway +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration # keycloak spring.security.oauth2.resourceserver.jwt.issuer-uri=${CSAF_OIDC_ISSUER_URL:http://localhost/realms/csaf} diff --git a/src/main/resources/db/migration/V1__initial_schema.sql b/src/main/resources/db/migration/V1__initial_schema.sql new file mode 100644 index 00000000..686022e1 --- /dev/null +++ b/src/main/resources/db/migration/V1__initial_schema.sql @@ -0,0 +1,96 @@ +-- V1__initial_schema.sql +-- Initial PostgreSQL schema for CSAF CMS Backend +-- Replaces single-database CouchDB document model with normalized relational tables + +-- Advisories (was ObjectType.Advisory) +CREATE TABLE advisories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_state VARCHAR(20) NOT NULL DEFAULT 'Draft', + owner VARCHAR(255) NOT NULL, + csaf JSONB NOT NULL, + versioning_type VARCHAR(10) NOT NULL DEFAULT 'Semantic', + last_major_version VARCHAR(50), + tmp_tracking_id VARCHAR(255), + advisory_reference UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + version BIGINT NOT NULL DEFAULT 0 +); + +-- Advisory version snapshots (was ObjectType.AdvisoryVersion) +CREATE TABLE advisory_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, + workflow_state VARCHAR(20) NOT NULL, + owner VARCHAR(255) NOT NULL, + csaf JSONB NOT NULL, + versioning_type VARCHAR(10) NOT NULL, + last_major_version VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Audit trail for document changes (was ObjectType.AuditTrailDocument) +CREATE TABLE audit_trail_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + "user" VARCHAR(255) NOT NULL, + change_type VARCHAR(20) NOT NULL, + diff JSONB, + old_doc_version VARCHAR(50), + doc_version VARCHAR(50) +); + +-- Audit trail for workflow transitions (was ObjectType.AuditTrailWorkflow) +CREATE TABLE audit_trail_workflows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + "user" VARCHAR(255) NOT NULL, + change_type VARCHAR(20) NOT NULL, + old_state VARCHAR(20), + new_state VARCHAR(20), + old_doc_version VARCHAR(50), + doc_version VARCHAR(50) +); + +-- Comments and answers (was ObjectType.Comment) +CREATE TABLE comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, + owner VARCHAR(255) NOT NULL, + comment_text TEXT NOT NULL, + csaf_node_id VARCHAR(255), + field_name VARCHAR(255), + answer_to UUID REFERENCES comments(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + version BIGINT NOT NULL DEFAULT 0 +); + +-- Audit trail for comment changes (was ObjectType.CommentAuditTrail) +CREATE TABLE audit_trail_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + "user" VARCHAR(255) NOT NULL, + change_type VARCHAR(20) NOT NULL, + comment_text TEXT +); + +-- Sequential counters (was ObjectType.Counter) +CREATE TABLE counters ( + id VARCHAR(100) PRIMARY KEY, + count BIGINT NOT NULL DEFAULT 0 +); + +-- Indexes for common query patterns +CREATE INDEX idx_advisories_workflow_state ON advisories(workflow_state); +CREATE INDEX idx_advisories_owner ON advisories(owner); +CREATE UNIQUE INDEX idx_advisories_tracking_id + ON advisories ((csaf -> 'document' -> 'tracking' ->> 'id')); +CREATE INDEX idx_advisories_csaf ON advisories USING GIN (csaf jsonb_path_ops); +CREATE INDEX idx_advisory_versions_advisory_id ON advisory_versions(advisory_id); +CREATE INDEX idx_audit_trail_documents_advisory_id ON audit_trail_documents(advisory_id); +CREATE INDEX idx_audit_trail_workflows_advisory_id ON audit_trail_workflows(advisory_id); +CREATE INDEX idx_comments_advisory_id ON comments(advisory_id); +CREATE INDEX idx_comments_answer_to ON comments(answer_to); +CREATE INDEX idx_audit_trail_comments_comment_id ON audit_trail_comments(comment_id); diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/CouchDBExtension.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/CouchDBExtension.java deleted file mode 100644 index 0118916d..00000000 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/CouchDBExtension.java +++ /dev/null @@ -1,92 +0,0 @@ -package de.bsi.secvisogram.csaf_cms_backend; - -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import org.junit.jupiter.api.extension.*; -import org.testcontainers.containers.GenericContainer; - -/** - * Test extension to start a CouchDB container, create a database and set corresponding application properties. - * The test database is cleared (created before and deleted after) for each single test - */ -public class CouchDBExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback { - - private GenericContainer couchDb; - - public static final String couchDbVersion = "3.3.3"; - private static final String user = "testUser"; - private static final String password = "testPassword"; - private static final int initialPort = 5984; - private static final String dbName = "test-db"; - - @Override - public void beforeAll(ExtensionContext context) { - couchDb = new GenericContainer<>("couchdb:" + couchDbVersion) - .withEnv("COUCHDB_USER", user) - .withEnv("COUCHDB_PASSWORD", password) - .withCommand() - .withExposedPorts(initialPort); - - couchDb.start(); - - System.setProperty("csaf.couchdb.host", couchDb.getHost()); - System.setProperty("csaf.couchdb.port", couchDb.getMappedPort(initialPort).toString()); - System.setProperty("csaf.couchdb.ssl", "false"); - System.setProperty("csaf.couchdb.user", user); - System.setProperty("csaf.couchdb.password", password); - System.setProperty("csaf.couchdb.dbname", dbName); - - } - - @Override - public void beforeEach(ExtensionContext context) throws Exception { - createDatabase(); - } - - @Override - public void afterEach(ExtensionContext context) throws IOException { - deleteDatabase(); - } - - @SuppressFBWarnings(value = "URLCONNECTION_SSRF_FD", justification = "URL is built from dynamic values but not user input") - private void createDatabase() throws IOException { - - HttpURLConnection connection = (HttpURLConnection) createCreateDeleteDatabaseUrl().openConnection(); - connection.setRequestMethod("PUT"); - connection.setRequestProperty("Authorization", createBasicAuth()); - connection.getResponseCode(); - } - - @SuppressFBWarnings(value = "URLCONNECTION_SSRF_FD", justification = "URL is built from dynamic values but not user input") - private void deleteDatabase() throws IOException { - - HttpURLConnection connection = (HttpURLConnection) createCreateDeleteDatabaseUrl().openConnection(); - connection.setRequestMethod("DELETE"); - connection.setRequestProperty("Authorization", createBasicAuth()); - connection.getResponseCode(); - } - - - private String createBasicAuth() { - - String auth = user + ":" + password; - return "Basic " + java.util.Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); - } - - private URL createCreateDeleteDatabaseUrl() throws IOException { - return new URL("http://" - + couchDb.getHost() + ":" - + couchDb.getMappedPort(initialPort) - + "/" + dbName); - } - - - @Override - public void afterAll(ExtensionContext context) { - couchDb.stop(); - } - -} diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/PostgreSQLExtension.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/PostgreSQLExtension.java new file mode 100644 index 00000000..81aa2d80 --- /dev/null +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/PostgreSQLExtension.java @@ -0,0 +1,71 @@ +package de.bsi.secvisogram.csaf_cms_backend; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; + +import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Test extension that starts a PostgreSQL container and sets the corresponding Spring datasource + * properties. The container is shared across all tests in the same JVM (static) and cleaned up + * on JVM shutdown. Flyway runs automatically on Spring context startup (creates the schema). + * + *

The database is truncated before each test to isolate test data, mirroring the per-test + * database recreation that the former CouchDBExtension performed.

+ * + *

Usage: {@code @ExtendWith(PostgreSQLExtension.class)} on integration test classes.

+ */ +public class PostgreSQLExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback { + + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("csaf_test") + .withUsername("test") + .withPassword("test"); + + @Override + public void beforeAll(ExtensionContext context) { + if (!POSTGRES.isRunning()) { + POSTGRES.start(); + } + System.setProperty("spring.datasource.url", POSTGRES.getJdbcUrl()); + System.setProperty("spring.datasource.username", POSTGRES.getUsername()); + System.setProperty("spring.datasource.password", POSTGRES.getPassword()); + // Enable JPA and Flyway for integration tests (overrides test application.properties exclusion) + System.setProperty("spring.autoconfigure.exclude", ""); + System.setProperty("spring.jpa.hibernate.ddl-auto", "validate"); + System.setProperty("spring.flyway.enabled", "true"); + System.setProperty("spring.flyway.clean-disabled", "false"); + } + + @Override + public void beforeEach(@NonNull ExtensionContext context) throws SQLException { + // Truncate all application tables before each test to ensure isolation. + // Uses TRUNCATE ... CASCADE to handle foreign key dependencies. + try (Connection conn = java.sql.DriverManager.getConnection( + POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + Statement stmt = conn.createStatement()) { + stmt.execute(""" + TRUNCATE TABLE audit_trail_comments, + audit_trail_documents, + audit_trail_workflows, + advisory_versions, + comments, + advisories, + counters + CASCADE + """); + } + } + + @Override + public void afterAll(ExtensionContext context) { + // Container is shared (static), stopped by JVM shutdown hook + } +} diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbServiceTest.java deleted file mode 100644 index 945e085a..00000000 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbServiceTest.java +++ /dev/null @@ -1,411 +0,0 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; - -import com.google.gson.internal.LazilyParsedNumber; -import com.ibm.cloud.cloudant.v1.model.Document; -import de.bsi.secvisogram.csaf_cms_backend.CouchDBExtension; -import de.bsi.secvisogram.csaf_cms_backend.fixture.TestModelField; -import de.bsi.secvisogram.csaf_cms_backend.fixture.TestModelRoot; -import de.bsi.secvisogram.csaf_cms_backend.json.ObjectType; -import de.bsi.secvisogram.csaf_cms_backend.model.filter.AndExpression; -import de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression; -import de.bsi.secvisogram.csaf_cms_backend.service.IdAndRevision; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.annotation.DirtiesContext; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.ObjectWriter; -import tools.jackson.databind.json.JsonMapper; -import tools.jackson.databind.node.ObjectNode; - -import java.io.IOException; -import java.io.InputStream; -import java.util.*; - -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDBFilterCreator.expr2CouchDBFilter; -import static de.bsi.secvisogram.csaf_cms_backend.fixture.TestModelArray.ENTRY_VALUE; -import static de.bsi.secvisogram.csaf_cms_backend.fixture.TestModelFirstLevel.SECOND_LEVEL; -import static de.bsi.secvisogram.csaf_cms_backend.fixture.TestModelRoot.*; -import static de.bsi.secvisogram.csaf_cms_backend.fixture.TestModelSecondLevel.LEVEL_2_VALUE; -import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.*; -import static java.util.stream.Collectors.toList; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.collection.IsIterableContainingInAnyOrder.containsInAnyOrder; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * Test for the CouchDB service. The required CouchDB container is started in the CouchDBExtension. - */ -@SpringBootTest -@ExtendWith(CouchDBExtension.class) -@DirtiesContext -public class CouchDbServiceTest { - - private static final String[] ARRAY_FIELD_SELECTOR = {ARRAY_VALUES}; - - private static final List ROOT_PRIMITIVE_FIELDS = Arrays.asList(TestModelField.FIRST_STRING, TestModelField.SECOND_STRING, - TestModelField.DECIMAL_VALUE, TestModelField.BOOLEAN_VALUE); - - @Autowired - private CouchDbService couchDbService; - - @Test - public void getServerVersionTest() { - Assertions.assertEquals(CouchDBExtension.couchDbVersion, this.couchDbService.getServerVersion()); - } - - - @Test - @SuppressFBWarnings(value = "PRMC_POSSIBLY_REDUNDANT_METHOD_CALLS", justification = "document count should increase") - public void writeDocumentTest() throws IOException { - - long countBefore = this.couchDbService.getDocumentCount(); - - UUID uuid = UUID.randomUUID(); - String revision = insertTestDocument(uuid); - - Assertions.assertNotNull(revision); - - Assertions.assertEquals(countBefore + 1, this.couchDbService.getDocumentCount()); - } - - @Test - @SuppressFBWarnings(value = "PRMC_POSSIBLY_REDUNDANT_METHOD_CALLS", justification = "document count should increase") - public void updateDocumentTest() throws DatabaseException { - - long countBefore = this.couchDbService.getDocumentCount(); - - UUID uuid = UUID.randomUUID(); - var initialDoc = """ - { - "testKey": "TestValue" - } - """; - var revision = this.couchDbService.writeDocument(uuid, initialDoc); - Assertions.assertNotNull(revision); - Assertions.assertEquals(countBefore + 1, this.couchDbService.getDocumentCount()); - var updateDoc = """ - { "_rev": "%s", - "_id": "%s", - "testKey": "ChangeValue" - } - """.formatted(revision, uuid.toString()); - revision = this.couchDbService.updateDocument(updateDoc); - - Assertions.assertNotNull(revision); - Assertions.assertEquals(countBefore + 1, this.couchDbService.getDocumentCount()); - } - - @Test - @SuppressFBWarnings(value = "PRMC_POSSIBLY_REDUNDANT_METHOD_CALLS", justification = "document count should increase") - public void updateDocumentTest_invalidRevision() { - - long countBefore = this.couchDbService.getDocumentCount(); - - UUID uuid = UUID.randomUUID(); - var initialDoc = """ - { - "testKey": "TestValue" - } - """; - var revision = this.couchDbService.writeDocument(uuid, initialDoc); - Assertions.assertNotNull(revision); - Assertions.assertEquals(countBefore + 1, this.couchDbService.getDocumentCount()); - var updateDoc = """ - { "_rev": "%s", - "_id": "%s", - "testKey": "ChangeValue" - } - """.formatted("Invalid Revison", uuid.toString()); - assertThrows(DatabaseException.class, - () -> this.couchDbService.updateDocument(updateDoc)); - } - - @Test - @SuppressFBWarnings(value = "PRMC_POSSIBLY_REDUNDANT_METHOD_CALLS", justification = "document should not change") - public void deleteDocumentTest() throws IOException, DatabaseException { - - long countBefore = this.couchDbService.getDocumentCount(); - - final UUID uuid = UUID.randomUUID(); - String revision = insertTestDocument(uuid); - - Assertions.assertEquals(countBefore + 1, this.couchDbService.getDocumentCount()); - this.couchDbService.deleteDocument(uuid.toString(), revision); - - Assertions.assertEquals(countBefore, this.couchDbService.getDocumentCount()); - } - - @Test - public void deleteCsafDocumentFromDb_invalidRevision() throws IOException { - - final UUID uuid = UUID.randomUUID(); - insertTestDocument(uuid); - - assertThrows(DatabaseException.class, - () -> this.couchDbService.deleteDocument(uuid.toString(), "invalid revision")); - } - - @Test - public void deleteCsafDocumentFromDb_doesNotExist() throws IOException { - - final UUID uuid = UUID.randomUUID(); - final String revision = insertTestDocument(uuid); - - assertThrows(IdNotFoundException.class, - () -> this.couchDbService.deleteDocument("idDoesNotExist", revision)); - } - - @Test - @SuppressFBWarnings(value = "PRMC_POSSIBLY_REDUNDANT_METHOD_CALLS", justification = "document should not change") - public void bulkDeleteDocuments() throws IOException, DatabaseException { - - long countBefore = this.couchDbService.getDocumentCount(); - - final UUID uuid1 = UUID.randomUUID(); - String revision1 = insertTestDocument(uuid1); - final UUID uuid2 = UUID.randomUUID(); - String revision2 = insertTestDocument(uuid2); - - Assertions.assertEquals(countBefore + 2, this.couchDbService.getDocumentCount()); - this.couchDbService.bulkDeleteDocuments(Arrays.asList(new IdAndRevision(uuid1.toString(), revision1), - new IdAndRevision(uuid2.toString(), revision2))); - - Assertions.assertEquals(countBefore, this.couchDbService.getDocumentCount()); - } - - @Test - @SuppressFBWarnings(value = "PRMC_POSSIBLY_REDUNDANT_METHOD_CALLS", justification = "document should not change") - public void bulkDeleteDocumentsTest_invalidRevision() throws IOException { - - long countBefore = this.couchDbService.getDocumentCount(); - - final UUID uuid1 = UUID.randomUUID(); - String revision1 = insertTestDocument(uuid1); - final UUID uuid2 = UUID.randomUUID(); - insertTestDocument(uuid2); - - Assertions.assertEquals(countBefore + 2, this.couchDbService.getDocumentCount()); - assertThrows(DatabaseException.class, - () -> this.couchDbService.bulkDeleteDocuments(Arrays.asList( - new IdAndRevision(uuid1.toString(), revision1), - new IdAndRevision(uuid2.toString(), "Invalid Revision")))); - - Assertions.assertEquals(countBefore + 2, this.couchDbService.getDocumentCount()); - } - - @Test - public void readAllDocumentsTest() throws IOException { - - UUID advisoryId = UUID.randomUUID(); - insertTestDocument(advisoryId); - - long docCount = this.couchDbService.getDocumentCount(); - - List infoFields = List.of( - AdvisorySearchField.DOCUMENT_TITLE, - AdvisorySearchField.DOCUMENT_TRACKING_ID, - CouchDbField.REVISION_FIELD, - CouchDbField.ID_FIELD - ); - - final List docs = this.couchDbService.readAllDocuments(ObjectType.Advisory, infoFields); - Assertions.assertEquals(docCount, docs.size()); - Assertions.assertEquals(advisoryId.toString(), docs.get(0).getId()); - } - - private String insertTestDocument(UUID documentUuid) throws IOException { - String owner = "Mustermann"; - String jsonFileName = "exxcellent-2021AB123.json"; - try (InputStream csafJsonStream = CouchDbServiceTest.class.getResourceAsStream(jsonFileName)) { - ObjectNode objectNode = toAdvisoryJson(csafJsonStream, owner); - return this.couchDbService.writeDocument(documentUuid, objectNode.toString()); - } - } - - /** - * Test find with Native Cloudant selector - * @throws IOException unexpected exception - */ - @Test - public void findDocumentsTest_native() throws IOException { - - this.writeToDb(new TestModelRoot().setFirstString("Hans").setSecondString("Dampf").setDecimalValue(12.55)); - this.writeToDb(new TestModelRoot().setFirstString("Franz").setSecondString("Dampf")); - - List foundDocs = this.couchDbService.findDocuments(Map.of(SECOND_STRING, "Dampf"), - ROOT_PRIMITIVE_FIELDS); - - assertThat(foundDocs.size(), equalTo(2)); - - foundDocs = this.couchDbService.findDocuments(Map.of(FIRST_STRING, "Hans"), - ROOT_PRIMITIVE_FIELDS); - assertThat(foundDocs.size(), equalTo(1)); - } - - @Test - public void findDocumentsTest_operatorEqual() throws IOException { - - this.writeToDb(new TestModelRoot().setFirstString("zzz").setSecondString("AAA").setDecimalValue(12.55)); - this.writeToDb(new TestModelRoot().setFirstString("yyy").setSecondString("AAA")); - this.writeToDb(new TestModelRoot().setFirstString("xxx").setSecondString("BBB")); - - Map filter = expr2CouchDBFilter(equal("AAA", SECOND_STRING)); - List foundDocs = this.couchDbService.findDocuments(filter, ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttribute(foundDocs, FIRST_STRING), containsInAnyOrder("yyy", "zzz")); - - Map filterNe = expr2CouchDBFilter(notEqual("yyy", FIRST_STRING)); - foundDocs = this.couchDbService.findDocuments(filterNe, ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttribute(foundDocs, FIRST_STRING), containsInAnyOrder("xxx", "zzz")); - } - - @Test - public void findDocumentsTest_operatorContainsIgnoreCase() throws IOException { - - this.writeToDb(new TestModelRoot().setFirstString("123Abc45")); - this.writeToDb(new TestModelRoot().setFirstString("123abC45")); - this.writeToDb(new TestModelRoot().setFirstString("123abD45")); - - Map filter = expr2CouchDBFilter(containsIgnoreCase("3abc4", FIRST_STRING)); - List foundDocs = this.couchDbService.findDocuments(filter, ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttribute(foundDocs, FIRST_STRING), containsInAnyOrder("123Abc45", "123abC45")); - } - - @Test - public void findDocumentsTest_operatorsGreaterAndLess() throws IOException { - - this.writeToDb(new TestModelRoot().setFirstString("AAA")); - this.writeToDb(new TestModelRoot().setFirstString("BBB")); - this.writeToDb(new TestModelRoot().setFirstString("CCC")); - - OperatorExpression gteExpr = greaterOrEqual("BBB", FIRST_STRING); - List foundDocs = this.couchDbService.findDocuments(expr2CouchDBFilter(gteExpr), ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttribute(foundDocs, FIRST_STRING), containsInAnyOrder("BBB", "CCC")); - - OperatorExpression gtExpr = greater("BBB", FIRST_STRING); - foundDocs = this.couchDbService.findDocuments(expr2CouchDBFilter(gtExpr), ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttribute(foundDocs, FIRST_STRING), containsInAnyOrder("CCC")); - - OperatorExpression lteExpr = lessOrEqual("BBB", FIRST_STRING); - foundDocs = this.couchDbService.findDocuments(expr2CouchDBFilter(lteExpr), ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttribute(foundDocs, FIRST_STRING), containsInAnyOrder("AAA", "BBB")); - - OperatorExpression ltExpr = less("BBB", FIRST_STRING); - foundDocs = this.couchDbService.findDocuments(expr2CouchDBFilter(ltExpr), ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttribute(foundDocs, FIRST_STRING), containsInAnyOrder("AAA")); - } - - @Test - public void findDocumentsTest_numericValue() throws IOException { - - this.writeToDb(new TestModelRoot().setDecimalValue(12.55)); - this.writeToDb(new TestModelRoot().setDecimalValue(2374.332)); - - Map filter = expr2CouchDBFilter(equal(12.55, DECIMAL_VALUE)); - List foundDocs = this.couchDbService.findDocuments(filter, ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttributeDouble(foundDocs, DECIMAL_VALUE), containsInAnyOrder(12.55)); - } - - @Test - public void findDocumentsTest_booleanValue() throws IOException { - - this.writeToDb(new TestModelRoot().setBooleanValue(Boolean.TRUE)); - this.writeToDb(new TestModelRoot().setBooleanValue(Boolean.FALSE)); - - Map filter = expr2CouchDBFilter(equal(true, BOOLEAN_VALUE)); - List foundDocs = this.couchDbService.findDocuments(filter, ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttribute(foundDocs, BOOLEAN_VALUE), containsInAnyOrder(Boolean.TRUE)); - } - - @Test - public void findDocumentsTest_operatorAnd() throws IOException { - - this.writeToDb(new TestModelRoot().setFirstString("zzz").setSecondString("AAA").setDecimalValue(11.11)); - this.writeToDb(new TestModelRoot().setFirstString("zzz").setSecondString("AAA").setDecimalValue(22.22)); - this.writeToDb(new TestModelRoot().setFirstString("zzz").setSecondString("BBB").setDecimalValue(11.11)); - this.writeToDb(new TestModelRoot().setFirstString("zzz").setSecondString("BBB").setDecimalValue(22.22)); - this.writeToDb(new TestModelRoot().setFirstString("xxx").setSecondString("AAA").setDecimalValue(11.11)); - this.writeToDb(new TestModelRoot().setFirstString("xxx").setSecondString("AAA").setDecimalValue(22.22)); - this.writeToDb(new TestModelRoot().setFirstString("xxx").setSecondString("BBB").setDecimalValue(11.11)); - this.writeToDb(new TestModelRoot().setFirstString("xxx").setSecondString("BBB").setDecimalValue(22.22)); - - AndExpression andExpr = new AndExpression(equal("xxx", FIRST_STRING), - equal("AAA", SECOND_STRING), equal(22.22, DECIMAL_VALUE)); - Map filter = expr2CouchDBFilter(andExpr); - List foundDocs = this.couchDbService.findDocuments(filter, ROOT_PRIMITIVE_FIELDS); - assertThat(foundDocs.size(), equalTo(1)); - assertThat(foundDocs.get(0).get(FIRST_STRING), equalTo("xxx")); - assertThat(foundDocs.get(0).get(SECOND_STRING), equalTo("AAA")); - assertThat(((LazilyParsedNumber) foundDocs.get(0).get(DECIMAL_VALUE)).doubleValue(), equalTo(22.22)); - } - - @Test - public void findDocumentsTest_multLevelSelector() throws IOException { - - this.writeToDb(new TestModelRoot().setFirstString("AAA").setLevelValues("Lev1A", "Lev2A")); - this.writeToDb(new TestModelRoot().setFirstString("BBB").setLevelValues("Lev1B", "Lev2B")); - this.writeToDb(new TestModelRoot().setFirstString("CCC").setLevelValues("Lev1C", "Lev2A")); - this.writeToDb(new TestModelRoot().setFirstString("DDD").setLevelValues("Lev1D", "Lev2B")); - - Map filter = expr2CouchDBFilter(equal("Lev2B", FIRST_LEVEL, SECOND_LEVEL, LEVEL_2_VALUE)); - List foundDocs = this.couchDbService.findDocuments(filter, ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttribute(foundDocs, FIRST_STRING), containsInAnyOrder("BBB", "DDD")); - } - - @Test - public void findDocumentsTest_searchInArray() throws IOException { - - this.writeToDb(new TestModelRoot().setFirstString("AAA").addListValues("ListVal1", "ListVal2", "ListVal3")); - this.writeToDb(new TestModelRoot().setFirstString("BBB").addListValues("ListVal1", "ListVal5", "ListVal6")); - this.writeToDb(new TestModelRoot().setFirstString("CCC").addListValues("ListVal7", "ListVal8", "ListVal2")); - this.writeToDb(new TestModelRoot().setFirstString("DDD").addListValues("ListVal9", "ListVal3", "ListVal20")); - - Map filter = expr2CouchDBFilter(equal("ListVal2", ARRAY_VALUES, ENTRY_VALUE), - ARRAY_FIELD_SELECTOR); - List foundDocs = this.couchDbService.findDocuments(filter, ROOT_PRIMITIVE_FIELDS); - assertThat(mapAttribute(foundDocs, FIRST_STRING), containsInAnyOrder("AAA", "CCC")); - } - - - private List mapAttribute(Collection foundDocs, String attributeName) { - return foundDocs.stream() - .map(doc -> doc.get(attributeName)) - .collect(toList()); - } - - private List mapAttributeDouble(Collection foundDocs, String attributeName) { - return foundDocs.stream() - .map(doc -> ((LazilyParsedNumber) doc.get(attributeName)).doubleValue()) - .collect(toList()); - } - - - - private ObjectNode toAdvisoryJson(InputStream csafJsonStream, String owner) throws IOException { - - ObjectMapper jacksonMapper = new JsonMapper(); - - JsonNode csafRootNode = jacksonMapper.readValue(csafJsonStream, JsonNode.class); - - ObjectNode rootNode = jacksonMapper.createObjectNode(); - rootNode.put("workflowState", "Draft"); - rootNode.put("owner", owner); - rootNode.put("type", ObjectType.Advisory.name()); - rootNode.set("csaf", csafRootNode); - - return rootNode; - } - - public void writeToDb(Object objectToWrite) throws JacksonException { - final ObjectMapper jacksonMapper = new JsonMapper(); - ObjectWriter writer = jacksonMapper.writerWithDefaultPrettyPrinter(); - String createString = writer.writeValueAsString(objectToWrite); - this.couchDbService.writeDocument(UUID.randomUUID(), createString); - } -} diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapperTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapperTest.java index 63ca1cc9..b0860d73 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapperTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapperTest.java @@ -63,7 +63,7 @@ public void createNewFromCsafTest() throws IOException, CsafException { @Test @SuppressFBWarnings(value = "CE_CLASS_ENVY", justification = "Only for Test") - public void createFromCouchDbTest() throws IOException, CsafException { + public void createFromStreamTest() throws IOException, CsafException { var revision = "rev-aa-12"; var id = "id-aaa-bbb"; @@ -79,7 +79,7 @@ public void createFromCouchDbTest() throws IOException, CsafException { "_id": "%s"}""".formatted(revision, id); var advisoryStream = new ByteArrayInputStream(advisoryDbString.getBytes(StandardCharsets.UTF_8)); - var advisory = AdvisoryWrapper.createFromCouchDb(advisoryStream); + var advisory = AdvisoryWrapper.createFromStream(advisoryStream); assertThat(advisory.getWorkflowState(), equalTo(WorkflowState.Draft)); assertThat(advisory.getWorkflowStateString(), equalTo("Draft")); @@ -91,7 +91,7 @@ public void createFromCouchDbTest() throws IOException, CsafException { @Test @SuppressFBWarnings(value = "CE_CLASS_ENVY", justification = "Only for Test") - public void createFromCouchDbTest_noType() throws IOException, CsafException { + public void createFromStreamTest_noType() throws IOException, CsafException { var revision = "rev-aa-12"; var id = "id-aaa-bbb"; @@ -108,13 +108,13 @@ public void createFromCouchDbTest_noType() throws IOException, CsafException { var advisoryStream = new ByteArrayInputStream(advisoryDbString.getBytes(StandardCharsets.UTF_8)); CsafException exception = assertThrows(CsafException.class, - () -> AdvisoryWrapper.createFromCouchDb(advisoryStream)); + () -> AdvisoryWrapper.createFromStream(advisoryStream)); assertThat(exception.getRecommendedHttpState(), equalTo(HttpStatus.BAD_REQUEST)); } @Test @SuppressFBWarnings(value = "CE_CLASS_ENVY", justification = "Only for Test") - public void createFromCouchDbTest_wrongType() { + public void createFromStreamTest_wrongType() { var revision = "rev-aa-12"; var id = "id-aaa-bbb"; @@ -132,7 +132,7 @@ public void createFromCouchDbTest_wrongType() { var advisoryStream = new ByteArrayInputStream(advisoryDbString.getBytes(StandardCharsets.UTF_8)); CsafException exception = assertThrows(CsafException.class, - () -> AdvisoryWrapper.createFromCouchDb(advisoryStream)); + () -> AdvisoryWrapper.createFromStream(advisoryStream)); assertThat(exception.getRecommendedHttpState(), equalTo(HttpStatus.BAD_REQUEST)); } @@ -164,7 +164,7 @@ public void updateFromExistingTest() throws IOException, CsafException { var advisoryStream = new ByteArrayInputStream(advisoryDbString.getBytes(StandardCharsets.UTF_8)); - var advisory = AdvisoryWrapper.createFromCouchDb(advisoryStream); + var advisory = AdvisoryWrapper.createFromStream(advisoryStream); AdvisoryWrapper updatedWrapper = AdvisoryWrapper.updateFromExisting(advisory, csafToRequest(updateCsafJson)); assertThat(updatedWrapper.getWorkflowState(), equalTo(WorkflowState.Draft)); assertThat(updatedWrapper.getOwner(), equalTo("Musterfrau")); diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapperTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapperTest.java index a06ae4ef..f3ef5144 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapperTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapperTest.java @@ -93,7 +93,7 @@ public void createNewAnswerFromJsonTest() { } @Test - public void createFromCouchDbTest() throws IOException, CsafException { + public void createFromStreamTest() throws IOException, CsafException { String revision = "revision-abcd-1234"; String commentId = UUID.randomUUID().toString(); @@ -112,7 +112,7 @@ public void createFromCouchDbTest() throws IOException, CsafException { """, commentText, advisoryId, owner, revision, commentId); InputStream commentStream = new ByteArrayInputStream(commentDbString.getBytes(StandardCharsets.UTF_8)); - CommentWrapper comment = CommentWrapper.createFromCouchDb(commentStream); + CommentWrapper comment = CommentWrapper.createFromStream(commentStream); Assertions.assertEquals(commentText, comment.getText()); Assertions.assertEquals("nodeId123", comment.getCsafNodeId()); @@ -122,7 +122,7 @@ public void createFromCouchDbTest() throws IOException, CsafException { } @Test - public void createFromCouchDbTest_NoType() throws IOException, CsafException { + public void createFromStreamTest_NoType() throws IOException, CsafException { String revision = "revision-abcd-1234"; String commentId = UUID.randomUUID().toString(); @@ -142,13 +142,13 @@ public void createFromCouchDbTest_NoType() throws IOException, CsafException { InputStream commentStream = new ByteArrayInputStream(commentDbString.getBytes(StandardCharsets.UTF_8)); CsafException exception = assertThrows(CsafException.class, - () -> CommentWrapper.createFromCouchDb(commentStream)); + () -> CommentWrapper.createFromStream(commentStream)); assertThat(exception.getRecommendedHttpState(), equalTo(HttpStatus.BAD_REQUEST)); } @Test - public void createFromCouchDbTest_WrongType() throws IOException, CsafException { + public void createFromStreamTest_WrongType() throws IOException, CsafException { String revision = "revision-abcd-1234"; String commentId = UUID.randomUUID().toString(); @@ -169,7 +169,7 @@ public void createFromCouchDbTest_WrongType() throws IOException, CsafException InputStream commentStream = new ByteArrayInputStream(commentDbString.getBytes(StandardCharsets.UTF_8)); CsafException exception = assertThrows(CsafException.class, - () -> CommentWrapper.createFromCouchDb(commentStream)); + () -> CommentWrapper.createFromStream(commentStream)); assertThat(exception.getRecommendedHttpState(), equalTo(HttpStatus.BAD_REQUEST)); } diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/RemoveIdHelperTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/RemoveIdHelperTest.java index ca0be7a5..b0780eb9 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/RemoveIdHelperTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/RemoveIdHelperTest.java @@ -370,7 +370,7 @@ public void removeIds() throws IOException, CsafException { "_id": "id124214"}""".formatted(csafWithIds); var advisoryStream = new ByteArrayInputStream(advisoryDbString.getBytes(StandardCharsets.UTF_8)); - var advisory = AdvisoryWrapper.createFromCouchDb(advisoryStream); + var advisory = AdvisoryWrapper.createFromStream(advisoryStream); RemoveIdHelper.removeIds(advisory.getCsaf(), "nodeId"); assertThat(advisory.getCsaf().toString().replaceAll("\\s+", ""), is(csafWithoutIds.replaceAll("\\s+", ""))); } diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java index 925e6126..c70d6195 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java @@ -14,11 +14,19 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; -@SpringBootTest(properties = "csaf.document.templates.file=./src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/model/template/allTemplates.json") +import de.bsi.secvisogram.csaf_cms_backend.service.PostgresRepositoryService; + +@SpringBootTest(properties = { + "csaf.document.templates.file=./src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/model/template/allTemplates.json" +}) @SuppressFBWarnings(value = "CLI_CONSTANT_LIST_INDEX", justification = "for test it is ok") class DocumentTemplateServiceTest { + @MockitoBean + private PostgresRepositoryService postgresRepositoryService; + @Autowired private DocumentTemplateService templateService; diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterNoLogoTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterNoLogoTest.java index db5d02cb..1a8941f0 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterNoLogoTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterNoLogoTest.java @@ -6,11 +6,19 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.util.StringUtils; -@SpringBootTest(properties = "csaf.document.templates.companyLogoPath=") +import de.bsi.secvisogram.csaf_cms_backend.service.PostgresRepositoryService; + +@SpringBootTest(properties = { + "csaf.document.templates.companyLogoPath=" +}) class JavascriptExporterNoLogoTest { + @MockitoBean + private PostgresRepositoryService postgresRepositoryService; + private static final String json = """ { "document": { diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterTest.java index b01c60b2..32b7b25e 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterTest.java @@ -14,11 +14,19 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.util.StringUtils; -@SpringBootTest(properties = "csaf.document.templates.companyLogoPath=./src/test/resources/eXXcellent_solutions.png") +import de.bsi.secvisogram.csaf_cms_backend.service.PostgresRepositoryService; + +@SpringBootTest(properties = { + "csaf.document.templates.companyLogoPath=./src/test/resources/eXXcellent_solutions.png" +}) class JavascriptExporterTest { + @MockitoBean + private PostgresRepositoryService postgresRepositoryService; + private static final String json = """ { "document": { diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java index ce10e46d..ffef0a9e 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java @@ -5,7 +5,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItems; -import de.bsi.secvisogram.csaf_cms_backend.CouchDBExtension; +import de.bsi.secvisogram.csaf_cms_backend.PostgreSQLExtension; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; @@ -33,7 +33,7 @@ @SpringBootTest(properties = { "csaf.workflow.allowOwnDocumentsApproved=true", }) -@ExtendWith(CouchDBExtension.class) +@ExtendWith(PostgreSQLExtension.class) @DirtiesContext @SpringJUnitConfig public class AdvisorySearchUtilTest { diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceExportNoLogoTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceExportNoLogoTest.java index e3636d0a..f53fbe5e 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceExportNoLogoTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceExportNoLogoTest.java @@ -18,17 +18,17 @@ import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import de.bsi.secvisogram.csaf_cms_backend.CouchDBExtension; +import de.bsi.secvisogram.csaf_cms_backend.PostgreSQLExtension; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.model.ExportFormat; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** - * Test for the Advisory service. The required CouchDB container is started in the CouchDBExtension. + * Test for the Advisory service. The required PostgreSQL container is started in the PostgreSQLExtension. */ @SpringBootTest(properties = "csaf.document.templates.companyLogoPath=") -@ExtendWith(CouchDBExtension.class) +@ExtendWith(PostgreSQLExtension.class) @DirtiesContext @SpringJUnitConfig @SuppressFBWarnings(value = "VA_FORMAT_STRING_USES_NEWLINE", justification = "False positives on multiline format strings") diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java index 8abb278b..edca3d36 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java @@ -1,15 +1,20 @@ package de.bsi.secvisogram.csaf_cms_backend.service; -import de.bsi.secvisogram.csaf_cms_backend.CouchDBExtension; +import de.bsi.secvisogram.csaf_cms_backend.PostgreSQLExtension; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.*; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.IdNotFoundException; +import de.bsi.secvisogram.csaf_cms_backend.entity.AuditTrailDocumentEntity; +import de.bsi.secvisogram.csaf_cms_backend.entity.AuditTrailWorkflowEntity; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; +import de.bsi.secvisogram.csaf_cms_backend.exception.CsafExceptionKey; import de.bsi.secvisogram.csaf_cms_backend.json.AdvisoryWrapper; -import de.bsi.secvisogram.csaf_cms_backend.json.ObjectType; import de.bsi.secvisogram.csaf_cms_backend.json.TrackingIdCounter; import de.bsi.secvisogram.csaf_cms_backend.model.ChangeType; import de.bsi.secvisogram.csaf_cms_backend.model.ExportFormat; import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; +import de.bsi.secvisogram.csaf_cms_backend.repository.AuditTrailDocumentRepository; +import de.bsi.secvisogram.csaf_cms_backend.repository.AuditTrailWorkflowRepository; import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateAdvisoryRequest; import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateCommentRequest; import de.bsi.secvisogram.csaf_cms_backend.rest.response.*; @@ -25,6 +30,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; @@ -51,15 +57,9 @@ import java.time.temporal.ChronoUnit; import java.util.*; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisoryAuditTrailField.*; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.AuditTrailField.CHANGE_TYPE; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.AuditTrailField.CREATED_AT; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDBFilterCreator.expr2CouchDBFilter; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.TYPE_FIELD; import static de.bsi.secvisogram.csaf_cms_backend.fixture.CsafDocumentJsonCreator.csafToInputstream; import static de.bsi.secvisogram.csaf_cms_backend.fixture.CsafDocumentJsonCreator.csafToRequest; import static de.bsi.secvisogram.csaf_cms_backend.json.VersioningType.Semantic; -import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.equal; import static java.util.Comparator.comparing; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; @@ -69,7 +69,7 @@ import static org.mockito.Mockito.when; /** - * Test for the Advisory service. The required CouchDB container is started in the CouchDBExtension. + * Test for the Advisory service. The required PostgreSQL container is started in the PostgreSQLExtension. */ @SpringBootTest(properties = { "csaf.document.templates.companyLogoPath=./src/test/resources/eXXcellent_solutions.png", @@ -78,7 +78,7 @@ "csaf.trackingid.digits=7", "csaf.workflow.allowOwnDocumentsApproved=true", }) -@ExtendWith(CouchDBExtension.class) +@ExtendWith(PostgreSQLExtension.class) @DirtiesContext @SpringJUnitConfig @SuppressFBWarnings(value = "VA_FORMAT_STRING_USES_NEWLINE", justification = "False positives on multiline format strings") @@ -87,6 +87,12 @@ public class AdvisoryServiceTest { @Autowired private AdvisoryService advisoryService; + @Autowired + private AuditTrailDocumentRepository auditTrailDocumentRepository; + + @Autowired + private AuditTrailWorkflowRepository auditTrailWorkflowRepository; + @MockitoBean private PandocService pandocService; @@ -159,6 +165,28 @@ public void getAdvisoryIdsTest_empty() throws IOException, CsafException { assertEquals(0, ids.size()); } + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) + public void getAdvisoryInformationsTest_accessDenied() { + // Neither ROLE_REGISTERED nor ROLE_AUDITOR: @Secured must reject this. + assertThrows(org.springframework.security.access.AccessDeniedException.class, + () -> this.advisoryService.getAdvisoryInformations(null)); + } + + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) + public void getAdvisoryInformationsTest_invalidExpression() throws IOException, CsafException { + this.advisoryService.addAdvisory(csafToRequest(csafJson)); + + // Not valid Expression JSON: json2Expression must throw here. Matches the pre-migration + // (CouchDB-era) AdvisorySearchUtil.buildAdvisoryExpression behavior -- a malformed filter + // is a client error (400) + CsafException ex = assertThrows(CsafException.class, + () -> this.advisoryService.getAdvisoryInformations("not valid expression json")); + assertEquals(CsafExceptionKey.InvalidFilterExpression, ex.getExceptionKey()); + assertEquals(HttpStatus.BAD_REQUEST, ex.getRecommendedHttpState()); + } + @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) @SuppressFBWarnings(value = "PRMC_POSSIBLY_REDUNDANT_METHOD_CALLS", justification = "Ok for test") @@ -180,6 +208,23 @@ public void getAdvisoryIdsTest() throws IOException, CsafException { assertTrue(expectedRevisions.containsAll(revisions)); } + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) + public void getAdvisoryInformationsTest_orderedById() throws IOException, CsafException { + // Random UUIDs are not created in ascending order, so without an explicit ORDER BY the + // list would come back in insertion/storage order. CouchDB returned documents in _id order. + for (int i = 0; i < 8; i++) { + this.advisoryService.addAdvisory(csafToRequest(csafJson)); + } + + List ids = this.advisoryService.getAdvisoryInformations(null).stream() + .map(AdvisoryInformationResponse::getAdvisoryId) + .toList(); + + assertEquals(8, ids.size()); + assertEquals(ids.stream().sorted().toList(), ids); + } + @Test @WithMockUser(username = "editor", authorities = {CsafRoles.ROLE_AUTHOR}) public void addAdvisoryTest_invalidJson() { @@ -274,12 +319,22 @@ public void deleteAdvisoryTest_notPresent() { @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) - public void deleteAdvisoryTest_badRevision() throws IOException, CsafException { + public void deleteAdvisoryTest_notFound() { + // In PostgreSQL, deleting a non-existent advisory throws IdNotFoundException + UUID noAdvisoryId = UUID.randomUUID(); + assertThrows(IdNotFoundException.class, + () -> this.advisoryService.deleteAdvisory(noAdvisoryId.toString(), "any-revision")); + } + + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) + public void deleteAdvisoryTest_badRevision() throws IOException, DatabaseException, CsafException { IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJson)); - // creates advisory, 1 counter and 1 audit trail + CsafException ex = assertThrows(CsafException.class, + () -> advisoryService.deleteAdvisory(idRev.getId(), "not-the-real-revision")); + assertEquals(CsafExceptionKey.CsafHasWrongRevision, ex.getExceptionKey()); + // the advisory must still be there: assertEquals(3, advisoryService.getDocumentCount()); - String revision = "bad revision"; - assertThrows(DatabaseException.class, () -> this.advisoryService.deleteAdvisory(idRev.getId(), revision)); } @Test @@ -368,6 +423,21 @@ public void updateAdvisoryTest() throws IOException, DatabaseException, CsafExce } + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) + public void updateAdvisoryTest_badRevision() throws IOException, DatabaseException, CsafException { + + IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJson)); + CreateAdvisoryRequest request = csafToRequest(csafJson); + request.setSummary("An update with a stale revision"); + + CsafException ex = assertThrows(CsafException.class, + () -> advisoryService.updateAdvisory(idRev.getId(), "not-the-real-revision", request)); + assertEquals(CsafExceptionKey.CsafHasWrongRevision, ex.getExceptionKey()); + // an advisory, 1 counter and 1 audit trail: the rejected update must not have changed anything + assertEquals(3, advisoryService.getDocumentCount()); + } + @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) public void updateAdvisoryTest_auditTrail() throws IOException, DatabaseException, CsafException { @@ -380,39 +450,32 @@ public void updateAdvisoryTest_auditTrail() throws IOException, DatabaseExceptio assertEquals(6, advisoryService.getDocumentCount()); advisoryService.getAdvisory(idRev.getId()); - List auditTrails = readAllAuditTrailDocumentsFromDb(); + List auditTrails = + auditTrailDocumentRepository.findByAdvisoryId(UUID.fromString(idRev.getId())); assertEquals(4, auditTrails.size()); - auditTrails.sort(comparing(CREATED_AT::stringVal)); - assertThat(CHANGE_TYPE.stringVal(auditTrails.get(0)), equalTo(ChangeType.Create.name())); - assertThat(CHANGE_TYPE.stringVal(auditTrails.get(1)), equalTo(ChangeType.Update.name())); + auditTrails.sort(comparing(AuditTrailDocumentEntity::getCreatedAt)); + assertThat(auditTrails.get(0).getChangeType(), equalTo(ChangeType.Create.name())); + assertThat(auditTrails.get(1).getChangeType(), equalTo(ChangeType.Update.name())); + // recreate Advisory from diffs AdvisoryWrapper rootWrapper = AdvisoryWrapper.createNewFromCsaf(csafToRequest(AdvisoryWrapper.emptyCsafDocument), "", Semantic.name()); - JsonNode patch0 = auditTrails.get(0).get(DIFF.getDbName()); - AdvisoryWrapper node1 = rootWrapper.applyJsonPatch(patch0); - assertThat(node1.at(AdvisorySearchField.DOCUMENT_TITLE).asString(), equalTo("Title1")); - AdvisoryWrapper node2 = node1.applyJsonPatch(auditTrails.get(1).get(DIFF.getDbName())); - assertThat(node2.at(AdvisorySearchField.DOCUMENT_TITLE).asString(), equalTo("Title2")); - AdvisoryWrapper node3 = node2.applyJsonPatch(auditTrails.get(2).get(DIFF.getDbName())); - assertThat(node3.at(AdvisorySearchField.DOCUMENT_TITLE).asString(), equalTo("Title3")); - AdvisoryWrapper node4 = node2.applyJsonPatch(auditTrails.get(3).get(DIFF.getDbName())); - assertThat(node4.at(AdvisorySearchField.DOCUMENT_TITLE).asString(), equalTo("Title4")); - } - - private List readAllAuditTrailDocumentsFromDb() throws IOException { - - Collection fields = Arrays.asList(CouchDbField.ID_FIELD, ADVISORY_ID, CREATED_AT, - CHANGE_TYPE, DIFF, DOC_VERSION); - Map selector = expr2CouchDBFilter(equal(ObjectType.AuditTrailDocument.name(), TYPE_FIELD.getDbName())); - return advisoryService.findDocuments(selector, fields); + AdvisoryWrapper node1 = rootWrapper.applyJsonPatch(auditTrails.get(0).getDiff()); + assertThat(node1.at("/csaf/document/title").asString(), equalTo("Title1")); + AdvisoryWrapper node2 = node1.applyJsonPatch(auditTrails.get(1).getDiff()); + assertThat(node2.at("/csaf/document/title").asString(), equalTo("Title2")); + AdvisoryWrapper node3 = node2.applyJsonPatch(auditTrails.get(2).getDiff()); + assertThat(node3.at("/csaf/document/title").asString(), equalTo("Title3")); + AdvisoryWrapper node4 = node3.applyJsonPatch(auditTrails.get(3).getDiff()); + assertThat(node4.at("/csaf/document/title").asString(), equalTo("Title4")); } @Test public void updateAdvisoryTest_badData() { UUID noAdvisoryId = UUID.randomUUID(); - Exception expectedException = assertThrows(DatabaseException.class, () -> advisoryService.updateAdvisory(noAdvisoryId.toString(), + Exception expectedException = assertThrows(IdNotFoundException.class, () -> advisoryService.updateAdvisory(noAdvisoryId.toString(), "redundant", csafToRequest(advisoryJsonString))); - assertThat(expectedException.getMessage(), containsString("No element with such an ID")); + assertThat(expectedException.getMessage(), containsString("not found")); } @Test @@ -442,9 +505,10 @@ public void updateAdvisoryTest_invalidId() throws IOException, DatabaseException ((ObjectNode) readAdvisory.getCsaf().at("/document")).put("title", "UpdatedTitle"); CreateAdvisoryRequest request = csafToRequest(readAdvisory.getCsaf().toPrettyString()); request.setSummary("UpdateSummary"); + String nonExistentId = UUID.randomUUID().toString(); Exception expectedException = assertThrows(IdNotFoundException.class, - () -> advisoryService.updateAdvisory("InvalidId", idRev.getRevision(), request)); - assertThat(expectedException.getMessage(), containsString("No element with such an ID")); + () -> advisoryService.updateAdvisory(nonExistentId, idRev.getRevision(), request)); + assertThat(expectedException.getMessage(), containsString("not found")); } @Test @@ -489,9 +553,11 @@ public void updateAdvisoryTest_invalidType() throws IOException, DatabaseExcepti CreateAdvisoryRequest request = csafToRequest(readAdvisory.getCsaf().toPrettyString()); request.setSummary("UpdateSummary"); - Exception expectedException = assertThrows(CsafException.class, + // In PostgreSQL, advisories and comments are in separate tables, so trying to + // update a comment ID as an advisory simply results in IdNotFoundException + Exception expectedException = assertThrows(IdNotFoundException.class, () -> advisoryService.updateAdvisory(idRevComment.getId(), idRevComment.getRevision(), request)); - assertThat(expectedException.getMessage(), containsString("not of type Advisory")); + assertThat(expectedException.getMessage(), containsString("not found")); } @Test @@ -505,6 +571,19 @@ public void changeAdvisoryWorkflowStateTest() throws IOException, DatabaseExcept assertEquals(WorkflowState.Review, advisory.getWorkflowState()); } + @Test + @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER}) + public void changeAdvisoryWorkflowStateTest_badRevision() throws IOException, DatabaseException, CsafException { + IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJson)); + CsafException ex = assertThrows(CsafException.class, + () -> advisoryService.changeAdvisoryWorkflowState( + idRev.getId(), "not-the-real-revision", WorkflowState.Review, null, null)); + assertEquals(CsafExceptionKey.CsafHasWrongRevision, ex.getExceptionKey()); + AdvisoryResponse advisory = advisoryService.getAdvisory(idRev.getId()); + assertEquals(WorkflowState.Draft, advisory.getWorkflowState(), + "the rejected state change with a stale revision must not have taken effect"); + } + @Test @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_PUBLISHER}) @SuppressFBWarnings(value = "RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE", @@ -592,28 +671,27 @@ public void changeAdvisoryWorkflowStateTest_auditTrail() throws IOException, Dat String revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), idRev.getRevision(), WorkflowState.Review, null, null); advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Approved, null, null); - Collection auditTrailFields = Arrays.asList(CouchDbField.ID_FIELD, DOC_VERSION, OLD_DOC_VERSION, NEW_WORKFLOW_STATE, OLD_WORKFLOW_STATE); - - Map workflowAuditTrailsSelector = expr2CouchDBFilter(equal(ObjectType.AuditTrailWorkflow.name(), TYPE_FIELD.getDbName())); - List workflowAuditTrails = advisoryService.findDocuments(workflowAuditTrailsSelector, auditTrailFields); + List workflowAuditTrails = + auditTrailWorkflowRepository.findByAdvisoryId(UUID.fromString(idRev.getId())); assertEquals(2, workflowAuditTrails.size(), "There should be one audit trail for each workflow state change"); - Map toReviewSelector = expr2CouchDBFilter(equal(WorkflowState.Review.name(), NEW_WORKFLOW_STATE.getDbName())); - JsonNode toReviewWorkflowAuditTrail = advisoryService.findDocuments(toReviewSelector, auditTrailFields).get(0); - Map toApprovedSelector = expr2CouchDBFilter(equal(WorkflowState.Approved.name(), NEW_WORKFLOW_STATE.getDbName())); - JsonNode toApprovedWorkflowAuditTrail = advisoryService.findDocuments(toApprovedSelector, auditTrailFields).get(0); - - assertEquals("0.0.1", toReviewWorkflowAuditTrail.get(OLD_DOC_VERSION.getDbName()).asString()); - assertEquals("0.0.1", toReviewWorkflowAuditTrail.get(DOC_VERSION.getDbName()).asString()); - assertEquals("Draft", toReviewWorkflowAuditTrail.get(OLD_WORKFLOW_STATE.getDbName()).asString()); - assertEquals("Review", toReviewWorkflowAuditTrail.get(NEW_WORKFLOW_STATE.getDbName()).asString()); + AuditTrailWorkflowEntity toReview = workflowAuditTrails.stream() + .filter(e -> WorkflowState.Review.name().equals(e.getNewState())) + .findFirst().orElseThrow(); + AuditTrailWorkflowEntity toApproved = workflowAuditTrails.stream() + .filter(e -> WorkflowState.Approved.name().equals(e.getNewState())) + .findFirst().orElseThrow(); - assertEquals("0.0.1", toApprovedWorkflowAuditTrail.get(OLD_DOC_VERSION.getDbName()).asString()); - assertEquals("1.0.0-1.0", toApprovedWorkflowAuditTrail.get(DOC_VERSION.getDbName()).asString()); - assertEquals("Review", toApprovedWorkflowAuditTrail.get(OLD_WORKFLOW_STATE.getDbName()).asString()); - assertEquals("Approved", toApprovedWorkflowAuditTrail.get(NEW_WORKFLOW_STATE.getDbName()).asString()); + assertEquals("0.0.1", toReview.getOldDocVersion()); + assertEquals("0.0.1", toReview.getDocVersion()); + assertEquals("Draft", toReview.getOldState()); + assertEquals("Review", toReview.getNewState()); + assertEquals("0.0.1", toApproved.getOldDocVersion()); + assertEquals("1.0.0-1.0", toApproved.getDocVersion()); + assertEquals("Review", toApproved.getOldState()); + assertEquals("Approved", toApproved.getNewState()); } @Test @@ -728,6 +806,31 @@ public void createNewCsafDocumentVersionTest() throws IOException, DatabaseExcep } } + @Test + @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_PUBLISHER}) + @SuppressFBWarnings(value = "RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE", + justification = "Bug in SpotBugs: https://github.com/spotbugs/spotbugs/issues/1338") + public void createNewCsafDocumentVersionTest_badRevision() throws IOException, DatabaseException, CsafException { + + try (final MockedStatic validatorMock = Mockito.mockStatic(ValidatorServiceClient.class)) { + + validatorMock.when(() -> ValidatorServiceClient.isAdvisoryValid(any(), any())).thenReturn(Boolean.TRUE); + + IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJson)); + String revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), idRev.getRevision(), WorkflowState.Review, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Approved, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.RfPublication, null, null); + advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Published, null, null); + + CsafException ex = assertThrows(CsafException.class, + () -> advisoryService.createNewCsafDocumentVersion(idRev.getId(), "not-the-real-revision")); + assertEquals(CsafExceptionKey.CsafHasWrongRevision, ex.getExceptionKey()); + AdvisoryResponse advisory = advisoryService.getAdvisory(idRev.getId()); + assertEquals(WorkflowState.Published, advisory.getWorkflowState(), + "the rejected version-creation with a stale revision must not have taken effect"); + } + } + @Test @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_PUBLISHER}) @SuppressFBWarnings(value = "RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE", @@ -800,8 +903,9 @@ public void createNewCsafDocumentVersionTest_accessDenied() throws IOException, public void createNewCsafDocumentVersionTest_invalidId() throws IOException, CsafException { IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJson)); + String nonExistentId = UUID.randomUUID().toString(); assertThrows(DatabaseException.class, - () -> advisoryService.createNewCsafDocumentVersion("Invalid Id", idRev.getRevision())); + () -> advisoryService.createNewCsafDocumentVersion(nonExistentId, idRev.getRevision())); } @Test @@ -902,6 +1006,22 @@ void getCommentsTest() throws IOException, DatabaseException, CsafException { } + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) + void getCommentsTest_includesAnswers() throws IOException, DatabaseException, CsafException { + IdAndRevision idRevAdvisory = advisoryService.addAdvisory(csafToRequest(csafJson)); + CreateCommentRequest comment = new CreateCommentRequest("comment text", UUID.randomUUID().toString()); + IdAndRevision idRevComment = advisoryService.addComment(idRevAdvisory.getId(), comment); + IdAndRevision idRevAnswer = advisoryService.addAnswer(idRevAdvisory.getId(), idRevComment.getId(), answerText); + + List commentInfos = this.advisoryService.getComments(idRevAdvisory.getId()); + + List ids = commentInfos.stream().map(CommentInformationResponse::getCommentId).toList(); + assertTrue(ids.contains(idRevComment.getId()), "the top-level comment must be included"); + assertTrue(ids.contains(idRevAnswer.getId()), "answers must be included too, not just top-level comments"); + assertEquals(2, commentInfos.size()); + } + @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) public void addCommentTest_oneComment() throws DatabaseException, IOException, CsafException { @@ -984,21 +1104,33 @@ public void addCommentTest_twoComments() throws DatabaseException, IOException, @Test public void deleteComment_notPresent() { + UUID noCommentId = UUID.randomUUID(); assertThrows(IdNotFoundException.class, - () -> advisoryService.deleteComment("not present", "no revision")); + () -> advisoryService.deleteComment(noCommentId.toString(), "no revision")); + } + + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) + public void deleteComment_notFound() { + // In PostgreSQL, deleting a non-existent comment throws IdNotFoundException + UUID noCommentId = UUID.randomUUID(); + assertThrows(IdNotFoundException.class, + () -> advisoryService.deleteComment(noCommentId.toString(), "any-revision")); } @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) public void deleteComment_badRevision() throws IOException, DatabaseException, CsafException { - IdAndRevision idRevAdvisory = advisoryService.addAdvisory(csafToRequest(csafJson)); + IdAndRevision idRevAdvisory = advisoryService.addAdvisory(csafToRequest(csafJson)); CreateCommentRequest comment = new CreateCommentRequest("a comment", UUID.randomUUID().toString()); - IdAndRevision idRevComment = advisoryService.addComment(idRevAdvisory.getId(), comment); - assertThrows(DatabaseException.class, - () -> advisoryService.deleteComment(idRevComment.getId(), "bad revision")); + CsafException ex = assertThrows(CsafException.class, + () -> advisoryService.deleteComment(idRevComment.getId(), "not-the-real-revision")); + assertEquals(CsafExceptionKey.CsafHasWrongRevision, ex.getExceptionKey()); + // advisory, counter, comment and their audit trails must still all be there + assertEquals(5, advisoryService.getDocumentCount()); } @Test @@ -1034,11 +1166,11 @@ public void deleteCommentWithAnswer() throws IOException, DatabaseException, Csa Assertions.assertEquals(7, advisoryService.getDocumentCount(), "There should be 1 advisory, 1 counter, 1 comment, 1 answer and an audit trail entry for each before deletion"); + // Deleting the parent comment cascades to the answer (ON DELETE CASCADE in schema) advisoryService.deleteComment(idRevComment.getId(), idRevComment.getRevision()); - advisoryService.deleteComment(idRevAnswer.getId(), idRevAnswer.getRevision()); Assertions.assertEquals(3, advisoryService.getDocumentCount(), - "There should be 1 advisory and 1 audit trail entry left after deletion"); + "There should be 1 advisory and 1 audit trail entry left after deletion (answer cascade-deleted)"); } @@ -1065,6 +1197,24 @@ void updateCommentTest() throws IOException, DatabaseException, CsafException { Assertions.assertEquals("updated comment text", newComment.getCommentText()); } + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) + void updateCommentTest_badRevision() throws IOException, DatabaseException, CsafException { + + IdAndRevision idRevAdvisory = advisoryService.addAdvisory(csafToRequest(csafJson)); + CreateCommentRequest comment = new CreateCommentRequest("comment text", UUID.randomUUID().toString()); + IdAndRevision idRevComment = advisoryService.addComment(idRevAdvisory.getId(), comment); + + CsafException ex = assertThrows(CsafException.class, + () -> advisoryService.updateComment(idRevAdvisory.getId(), idRevComment.getId(), + "not-the-real-revision", "attempted update")); + assertEquals(CsafExceptionKey.CsafHasWrongRevision, ex.getExceptionKey()); + + CommentResponse unchanged = advisoryService.getComment(idRevComment.getId()); + assertEquals("comment text", unchanged.getCommentText(), + "the rejected update with a stale revision must not have taken effect"); + } + @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) void getCommentTest_accessDenied() throws IOException, DatabaseException, CsafException { @@ -1211,8 +1361,18 @@ public void addAnswerTest_accessDenied() throws IOException, DatabaseException, @Test public void deleteAnswer_notPresent() { + UUID noAnswerId = UUID.randomUUID(); assertThrows(IdNotFoundException.class, - () -> advisoryService.deleteAnswer("not present", "no revision")); + () -> advisoryService.deleteAnswer(noAnswerId.toString(), "no revision")); + } + + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) + public void deleteAnswer_notFound() { + // In PostgreSQL, deleting a non-existent answer throws IdNotFoundException + UUID noAnswerId = UUID.randomUUID(); + assertThrows(IdNotFoundException.class, + () -> advisoryService.deleteAnswer(noAnswerId.toString(), "any-revision")); } @Test @@ -1224,8 +1384,11 @@ public void deleteAnswer_badRevision() throws IOException, DatabaseException, Cs IdAndRevision idRevComment = advisoryService.addComment(idRevAdvisory.getId(), comment); IdAndRevision idRevAnswer = advisoryService.addAnswer(idRevAdvisory.getId(), idRevComment.getId(), answerText); - assertThrows(DatabaseException.class, - () -> advisoryService.deleteAnswer(idRevAnswer.getId(), "bad revision")); + CsafException ex = assertThrows(CsafException.class, + () -> advisoryService.deleteAnswer(idRevAnswer.getId(), "not-the-real-revision")); + assertEquals(CsafExceptionKey.CsafHasWrongRevision, ex.getExceptionKey()); + Assertions.assertEquals(7, advisoryService.getDocumentCount(), + "the rejected delete with a stale revision must not have taken effect"); } @Test diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowIntegerVersioningTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowIntegerVersioningTest.java index 6b8d7b8e..f9cbc5d6 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowIntegerVersioningTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowIntegerVersioningTest.java @@ -1,19 +1,6 @@ package de.bsi.secvisogram.csaf_cms_backend.service; -import static de.bsi.secvisogram.csaf_cms_backend.fixture.CsafDocumentJsonCreator.*; -import static de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus.Draft; -import static de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus.Final; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.mockito.ArgumentMatchers.any; - -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.json.JsonMapper; -import tools.jackson.databind.node.ObjectNode; -import de.bsi.secvisogram.csaf_cms_backend.CouchDBExtension; +import de.bsi.secvisogram.csaf_cms_backend.PostgreSQLExtension; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; @@ -22,10 +9,6 @@ import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryResponse; import de.bsi.secvisogram.csaf_cms_backend.validator.ValidatorServiceClient; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockedStatic; @@ -35,13 +18,31 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import static de.bsi.secvisogram.csaf_cms_backend.fixture.CsafDocumentJsonCreator.*; +import static de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus.Draft; +import static de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus.Final; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.mockito.ArgumentMatchers.any; /** - * Test for the workflow in the Advisory service. The required CouchDB container is started in the CouchDBExtension. + * Test for the workflow in the Advisory service. The required PostgreSQL container is started in the PostgreSQLExtension. */ @SpringBootTest(properties = { "csaf.document.versioning=Integer", "csaf.workflow.allowOwnDocumentsApproved=true"}) -@ExtendWith(CouchDBExtension.class) +@ExtendWith(PostgreSQLExtension.class) @DirtiesContext @SpringJUnitConfig public class AdvisoryWorkflowIntegerVersioningTest { diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowSemanticVersioningTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowSemanticVersioningTest.java index aa50b5af..b8351200 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowSemanticVersioningTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowSemanticVersioningTest.java @@ -14,7 +14,7 @@ import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.node.ObjectNode; -import de.bsi.secvisogram.csaf_cms_backend.CouchDBExtension; +import de.bsi.secvisogram.csaf_cms_backend.PostgreSQLExtension; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; @@ -50,10 +50,10 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; /** - * Test for the workflow in the Advisory service. The required CouchDB container is started in the CouchDBExtension. + * Test for the workflow in the Advisory service. The required PostgreSQL container is started in the PostgreSQLExtension. */ @SpringBootTest(properties = { "csaf.workflow.allowOwnDocumentsApproved=true"} ) -@ExtendWith(CouchDBExtension.class) +@ExtendWith(PostgreSQLExtension.class) @DirtiesContext @SpringJUnitConfig public class AdvisoryWorkflowSemanticVersioningTest { diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PandocServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PandocServiceTest.java index 4668e981..328054a2 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PandocServiceTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PandocServiceTest.java @@ -17,14 +17,10 @@ import org.junit.jupiter.api.Test; import org.mockito.MockedConstruction; import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest() public class PandocServiceTest { - @Autowired - private PandocService pandocService; + private final PandocService pandocService = new PandocService(); @Test public void convertTest() throws IOException, CsafException { diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/WeasyprintServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/WeasyprintServiceTest.java index 497c0328..ed1e8b78 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/WeasyprintServiceTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/WeasyprintServiceTest.java @@ -17,14 +17,10 @@ import org.junit.jupiter.api.Test; import org.mockito.MockedConstruction; import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest() public class WeasyprintServiceTest { - @Autowired - private WeasyprintService weasyprintService; + private final WeasyprintService weasyprintService = new WeasyprintService(); @Test public void convertTest() throws IOException, CsafException { diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 1af9c887..996c96c5 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,6 +1,12 @@ # Test overrides for application.properties # This file must repeat all required properties because it shadows the main application.properties on the test classpath. +# Disable JPA/DataSource auto-configuration for unit tests (MockMvc, etc.) +# Integration tests using @ExtendWith(PostgreSQLExtension.class) override this via System properties. +spring.autoconfigure.exclude=org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration,org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration,org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration + +spring.jpa.properties.hibernate.type.json_format_mapper=jackson3 + server.port=${CSAF_CMS_BACKEND_PORT:8081} springdoc.api-docs.path=/api-docs @@ -13,12 +19,6 @@ csaf.csrf.enabled=false spring.mvc.pathmatch.matching-strategy=ANT_PATH_MATCHER spring.config.import=optional:file:.env[.properties] -csaf.couchdb.host=${CSAF_COUCHDB_HOST:localhost} -csaf.couchdb.port=${CSAF_COUCHDB_PORT:5984} -csaf.couchdb.ssl=${CSAF_COUCHDB_SSL:false} -csaf.couchdb.dbname=${CSAF_COUCHDB_DBNAME:csaf} -csaf.couchdb.user=${CSAF_COUCHDB_USER:admin} -csaf.couchdb.password=${CSAF_COUCHDB_PASSWORD:admin} # Override JWT configuration: use jwk-set-uri instead of issuer-uri to avoid # OIDC discovery HTTP calls during test context startup (Spring Security 7+). From 440010a3e0f718a969efcd6a3e1194c4f79bb00a Mon Sep 17 00:00:00 2001 From: rschneider <97682836+rainer-exxcellent@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:51:54 +0200 Subject: [PATCH 2/5] feat(postgres): #226 improve performance --- .../entity/AdvisoryEntity.java | 33 ++++ .../repository/AdvisoryRepository.java | 14 +- .../service/AdvisoryListProjection.java | 15 ++ .../service/AdvisoryListQueryService.java | 175 ++++++++++++++++++ .../service/AdvisoryService.java | 54 ++++-- .../service/PostgresRepositoryService.java | 20 +- .../db/migration/V1__initial_schema.sql | 112 +++++------ .../template/DocumentTemplateServiceTest.java | 4 + .../JavascriptExporterNoLogoTest.java | 4 + .../mustache/JavascriptExporterTest.java | 4 + .../PostgresRepositoryServiceTest.java | 67 +++++++ 11 files changed, 417 insertions(+), 85 deletions(-) create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListProjection.java create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java create mode 100644 src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryServiceTest.java diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java index 476d4ab6..9b1e3f0f 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java @@ -34,6 +34,15 @@ public class AdvisoryEntity { @Column(columnDefinition = "jsonb", nullable = false) private JsonNode csaf; + @Column(name = "tracking_id") + private String trackingId; + + @Column(name = "document_title", columnDefinition = "text") + private String documentTitle; + + @Column(name = "current_release_date") + private Instant currentReleaseDate; + @Column(name = "versioning_type", nullable = false) private String versioningType = "Semantic"; @@ -84,6 +93,30 @@ public void setCsaf(JsonNode csaf) { this.csaf = csaf; } + public String getTrackingId() { + return trackingId; + } + + public void setTrackingId(String trackingId) { + this.trackingId = trackingId; + } + + public String getDocumentTitle() { + return documentTitle; + } + + public void setDocumentTitle(String documentTitle) { + this.documentTitle = documentTitle; + } + + public Instant getCurrentReleaseDate() { + return currentReleaseDate; + } + + public void setCurrentReleaseDate(Instant currentReleaseDate) { + this.currentReleaseDate = currentReleaseDate; + } + public String getVersioningType() { return versioningType; } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryRepository.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryRepository.java index bde39ff6..708cd8e5 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryRepository.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/repository/AdvisoryRepository.java @@ -19,17 +19,11 @@ public interface AdvisoryRepository extends JpaRepository, List findByOwner(String owner); - /** - * Find an advisory by CSAF tracking ID (inside JSONB). - */ - @Query(value = "SELECT * FROM advisories WHERE csaf -> 'document' -> 'tracking' ->> 'id' = :trackingId", - nativeQuery = true) + /** Find an advisory by its denormalized CSAF tracking ID. */ + @Query(value = "SELECT * FROM advisories WHERE tracking_id = :trackingId", nativeQuery = true) Optional findByTrackingId(@Param("trackingId") String trackingId); - /** - * Check if an advisory with a given CSAF tracking ID exists. - */ - @Query(value = "SELECT EXISTS(SELECT 1 FROM advisories WHERE csaf -> 'document' -> 'tracking' ->> 'id' = :trackingId)", - nativeQuery = true) + /** Check if an advisory with a given denormalized CSAF tracking ID exists. */ + @Query(value = "SELECT EXISTS(SELECT 1 FROM advisories WHERE tracking_id = :trackingId)", nativeQuery = true) boolean existsByTrackingId(@Param("trackingId") String trackingId); } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListProjection.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListProjection.java new file mode 100644 index 00000000..fc8c75fe --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListProjection.java @@ -0,0 +1,15 @@ +package de.bsi.secvisogram.csaf_cms_backend.service; + +/** + * Scalar values required to render an advisory in the list endpoint. + * The complete CSAF JSON document is deliberately not part of this projection. + */ +public record AdvisoryListProjection( + String advisoryId, + String workflowState, + String owner, + Long version, + String documentTrackingId, + String title, + String currentReleaseDate) { +} \ No newline at end of file diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java new file mode 100644 index 00000000..9e991d16 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java @@ -0,0 +1,175 @@ +package de.bsi.secvisogram.csaf_cms_backend.service; + +import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; +import de.bsi.secvisogram.csaf_cms_backend.model.filter.*; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** Executes the advisory list query without selecting the large {@code csaf} column. */ +@Service +public class AdvisoryListQueryService { + + private static final Logger LOG = LoggerFactory.getLogger(AdvisoryListQueryService.class); + @PersistenceContext + private EntityManager entityManager; + + public List findAdvisories(Expression filter, Authentication credentials) { + LOG.info("Start getAdvisoryInformations"); + SqlExpressionCompiler compiler = new SqlExpressionCompiler(); + String where = visibilityClause(credentials, compiler) + " AND " + compiler.compile(filter); + String sql = """ + SELECT id, workflow_state, owner, version, + tracking_id, + document_title, + current_release_date + FROM advisories + WHERE""" + " " + where + "\n" + """ + ORDER BY id + """; + + LOG.info("Start getAdvisoryInformations - createNativeQuery"); + var query = entityManager.createNativeQuery(sql); + compiler.bind(query); + LOG.info("Start getAdvisoryInformations - getResultList"); + @SuppressWarnings("unchecked") + List rows = query.getResultList(); + LOG.info("Start getAdvisoryInformations - stream"); + return rows.stream().map(row -> new AdvisoryListProjection( + row[0].toString(), (String) row[1], (String) row[2], ((Number) row[3]).longValue(), + (String) row[4], (String) row[5], row[6] == null ? null : row[6].toString())).toList(); + } + + private String visibilityClause(Authentication credentials, SqlExpressionCompiler compiler) { + if (hasRole(credentials, CsafRoles.ROLE_AUDITOR) || hasRole(credentials, CsafRoles.ROLE_EDITOR)) { + return "TRUE"; + } + List clauses = new ArrayList<>(); + clauses.add("(workflow_state = 'Published' AND " + + "current_release_date < " + compiler.bindText(Instant.now()) + ")"); + if (hasRole(credentials, CsafRoles.ROLE_AUTHOR)) { + clauses.add("owner = " + compiler.bindText(credentials.getName())); + } + if (hasRole(credentials, CsafRoles.ROLE_PUBLISHER)) { + clauses.add("workflow_state IN ('Draft', 'Approved', 'RfPublication')"); + } + if (hasRole(credentials, CsafRoles.ROLE_REVIEWER)) { + clauses.add("(workflow_state = 'Review' AND owner <> " + compiler.bindText(credentials.getName()) + ")"); + } + return "(" + String.join(" OR ", clauses) + ")"; + } + + private boolean hasRole(Authentication credentials, String role) { + return credentials.getAuthorities().stream().map(GrantedAuthority::getAuthority).anyMatch(role::equals); + } + + private static final class SqlExpressionCompiler { + private final List values = new ArrayList<>(); + + private String compile(Expression expression) { + if (expression == null) { + return "TRUE"; + } + if (expression instanceof AndExpression and) { + return join(and.getExpressions(), " AND ", "TRUE"); + } + if (expression instanceof OrExpression or) { + return join(or.getExpressions(), " OR ", "FALSE"); + } + if (expression instanceof OperatorExpression operator) { + return compileOperator(operator); + } + return "TRUE"; + } + + private String join(List expressions, String separator, String emptyValue) { + if (expressions.isEmpty()) { + return emptyValue; + } + return "(" + expressions.stream().map(this::compile).reduce((a, b) -> a + separator + b).orElse(emptyValue) + ")"; + } + + private String compileOperator(OperatorExpression expression) { + String[] selector = expression.getSelector(); + if (selector.length == 1 && "owner".equals(selector[0])) { + return columnComparison("owner", expression); + } + if (selector.length == 1 && "workflowState".equals(selector[0])) { + return columnComparison("workflow_state", expression); + } + int offset = selector.length > 0 && "csaf".equals(selector[0]) ? 1 : 0; + if (offset == selector.length) { + return "TRUE"; + } + String path = jsonPath(selector, offset); + String exists = "jsonb_path_exists(csaf, CAST(" + bindText(path) + " AS jsonpath))"; + String comparisonPath = path + " ? (" + predicate(expression) + ")"; + String matches = "jsonb_path_exists(csaf, CAST(" + bindText(comparisonPath) + + " AS jsonpath), jsonb_build_object('value', to_jsonb(" + bindValue(expression) + ")))"; + // Existing Java semantics intentionally let missing paths pass through. + return "(NOT " + exists + " OR " + matches + ")"; + } + + private String columnComparison(String column, OperatorExpression expression) { + String value = bindValue(expression); + return switch (expression.getOperatorType()) { + case Equal -> column + " = " + value; + case NotEqual -> column + " <> " + value; + case Greater -> column + " > " + value; + case GreaterOrEqual -> column + " >= " + value; + case Less -> column + " < " + value; + case LessOrEqual -> column + " <= " + value; + case ContainsIgnoreCase -> "LOWER(" + column + ") LIKE '%' || LOWER(" + value + ") || '%'"; + }; + } + + private String predicate(OperatorExpression expression) { + return switch (expression.getOperatorType()) { + case Equal -> "@ == $value"; + case NotEqual -> "@ != $value"; + case Greater -> "@ > $value"; + case GreaterOrEqual -> "@ >= $value"; + case Less -> "@ < $value"; + case LessOrEqual -> "@ <= $value"; + case ContainsIgnoreCase -> "@ like_regex $value flag \"i\""; + }; + } + + private String jsonPath(String[] selector, int offset) { + StringBuilder path = new StringBuilder("$"); + for (int index = offset; index < selector.length; index++) { + path.append(".\"").append(selector[index].replace("\\", "\\\\").replace("\"", "\\\"")).append("\""); + } + return path.toString(); + } + + private String bindValue(OperatorExpression expression) { + return switch (expression.getValueType()) { + case Decimal -> bindText(expression.getValue()) + "::numeric"; + case Boolean -> bindText(expression.getValue()) + "::boolean"; + case Text -> bindText(expression.getOperatorType() == TypeOfOperator.ContainsIgnoreCase + ? ".*" + expression.getValue().replace("\\", "\\\\").replace(".", "\\.") + ".*" + : expression.getValue()); + }; + } + + private String bindText(Object value) { + values.add(value); + return ":p" + values.size(); + } + + private void bind(jakarta.persistence.Query query) { + for (int index = 0; index < values.size(); index++) { + query.setParameter("p" + (index + 1), values.get(index)); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java index a2b87a38..2fcb6a2e 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java @@ -62,6 +62,9 @@ public class AdvisoryService { @Autowired private PostgresRepositoryService postgresService; + @Autowired + private AdvisoryListQueryService advisoryListQueryService; + @Autowired private JavascriptExporter javascriptExporter; @@ -139,6 +142,7 @@ public long getDocumentCount() { public List getAdvisoryInformations(String expression) throws IOException, CsafException { + LOG.info("Start getAdvisoryInformations"); Authentication credentials = getAuthentication(); Expression parsedExpr = parseFilterExpression(expression); @@ -158,6 +162,7 @@ public List getAdvisoryInformations(String expressi } allResponses.addAll(allAdvisoryVersions); } + LOG.info("End getAdvisoryInformations - readAllAdvisories"); return allResponses; } @@ -178,14 +183,24 @@ private List readAllAdvisories( Expression visibilityExpr, Authentication credentials) { - List entities = postgresService.findAllAdvisories(); - return entities.stream() - .filter(entity -> matchesCsafExpression(entity.getCsaf(), parsedExpr)) - .map(EntityConverter::toAdvisoryInfo) + return advisoryListQueryService.findAdvisories(parsedExpr, credentials).stream() + .map(this::toAdvisoryInformationResponse) .filter(info -> matchesVisibility(info, visibilityExpr, credentials)) + .toList(); } + private AdvisoryInformationResponse toAdvisoryInformationResponse(AdvisoryListProjection projection) { + AdvisoryInformationResponse response = new AdvisoryInformationResponse(projection.advisoryId()); + response.setWorkflowState(projection.workflowState()); + response.setOwner(projection.owner()); + response.setRevision(String.valueOf(projection.version())); + response.setDocumentTrackingId(projection.documentTrackingId()); + response.setTitle(projection.title()); + response.setCurrentReleaseDate(projection.currentReleaseDate()); + return response; + } + private List readAllAdvisoryVersions( Expression parsedExpr, Authentication credentials) { @@ -217,7 +232,6 @@ private boolean matchesVisibility(AdvisoryInformationResponse info, info.getCurrentReleaseDate() ); } - /** * Check whether the entity's full CSAF JSON matches an already-parsed filter expression. * This enables filtering by arbitrary deep paths (acknowledgments, vulnerabilities, @@ -365,7 +379,7 @@ private void enrichAdvisoryVersion(AdvisoryInformationResponse response) { } private List getAllowedStates(AdvisoryInformationResponse response, - Authentication credentials) { + Authentication credentials) { final var allowOwnDocumentsApproved = configuration.getWorkflow().isAllowOwnDocumentsApproved(); return Arrays.stream(WorkflowState.values()) @@ -826,8 +840,8 @@ public Path exportAdvisoryForAutoPublish( */ @Transactional public String changeAdvisoryWorkflowState(String advisoryId, String revision, - WorkflowState newWorkflowState, String proposedTime, - DocumentTrackingStatus documentTrackingStatus) + WorkflowState newWorkflowState, String proposedTime, + DocumentTrackingStatus documentTrackingStatus) throws IOException, DatabaseException, CsafException { Authentication credentials = getAuthentication(); @@ -894,19 +908,19 @@ public String changeAdvisoryWorkflowState(String advisoryId, String revision, if (newWorkflowState == WorkflowState.AutoPublish) { if (proposedTime == null) { - proposedTime = existingAdvisoryNode.getDocumentTrackingCurrentReleaseDate(); - if (proposedTime == null) { - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'.000000000Z"); - proposedTime = sdf.format(new Date()); - } + proposedTime = existingAdvisoryNode.getDocumentTrackingCurrentReleaseDate(); + if (proposedTime == null) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'.000000000Z"); + proposedTime = sdf.format(new Date()); + } } if (documentTrackingStatus == null) { - existingAdvisoryNode.setDocumentTrackingStatus(DocumentTrackingStatus.Final); + existingAdvisoryNode.setDocumentTrackingStatus(DocumentTrackingStatus.Final); } if (existingAdvisoryNode.getDocumentDistributionTlp() == null) { - throw new CsafException("TLP-Level missing", CsafExceptionKey.AdvisoryValidationError, BAD_REQUEST); + throw new CsafException("TLP-Level missing", CsafExceptionKey.AdvisoryValidationError, BAD_REQUEST); } //TODO: Check, if further checks for upload are needed @@ -965,7 +979,7 @@ void setFinalTrackingIdAndUrl(AdvisoryWrapper advisoryNode) throws CsafException } private AdvisoryWrapper createReleaseReadyAdvisoryAndValidate(AdvisoryWrapper advisory, - String releaseDate) throws CsafException, IOException { + String releaseDate) throws CsafException, IOException { AdvisoryWrapper advisoryCopy = AdvisoryWrapper.createCopy(advisory); @@ -1153,7 +1167,7 @@ public CommentResponse getComment(String commentId) throws DatabaseException, Cs * @return a list of information on all comments for the requested advisory */ @Secured({CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_AUDITOR}) - public List getComments(String advisoryId) throws IOException, CsafException { + public List getComments(String advisoryId) throws CsafException { Authentication credentials = getAuthentication(); AdvisoryInformationResponse advisoryInfo = getAdvisoryInfoForId(advisoryId); @@ -1174,7 +1188,7 @@ public List getComments(String advisoryId) throws IO * @param commentRevision the comment's revision for concurrent control */ @Transactional - void deleteComment(String commentId, String commentRevision) throws DatabaseException, IOException, CsafException { + void deleteComment(String commentId, String commentRevision) throws DatabaseException, CsafException { CommentEntity entity = findCommentEntityOrThrow(commentId); checkCommentRevision(entity, commentRevision); @@ -1262,7 +1276,7 @@ public IdAndRevision addAnswer(String advisoryId, String commentId, String comme */ @Secured({CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_AUDITOR}) public List getAnswers(String advisoryId, String commentId) - throws IOException, CsafException { + throws CsafException { Authentication credentials = getAuthentication(); AdvisoryInformationResponse advisoryInfo = getAdvisoryInfoForId(advisoryId); @@ -1283,7 +1297,7 @@ public List getAnswers(String advisoryId, String comm * @param answerRevision the comment's revision for concurrent control */ @Transactional - void deleteAnswer(String answerId, String answerRevision) throws DatabaseException, IOException, CsafException { + void deleteAnswer(String answerId, String answerRevision) throws DatabaseException, CsafException { CommentEntity entity = findCommentEntityOrThrow(answerId); checkCommentRevision(entity, answerRevision); diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java index 1b954bee..820ab0bb 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java @@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import tools.jackson.databind.JsonNode; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -27,7 +28,6 @@ public class PostgresRepositoryService { private static final Sort BY_ID = Sort.by("id"); - private final AdvisoryRepository advisoryRepository; private final AdvisoryVersionRepository advisoryVersionRepository; private final AuditTrailDocumentRepository auditTrailDocumentRepository; @@ -73,9 +73,27 @@ public PostgresRepositoryService( * @return the saved entity (with generated ID if new), with an up-to-date {@code version} */ public AdvisoryEntity saveAdvisory(AdvisoryEntity entity) { + updateAdvisoryListColumns(entity); return advisoryRepository.saveAndFlush(entity); } + private void updateAdvisoryListColumns(AdvisoryEntity entity) { + JsonNode csaf = entity.getCsaf(); + entity.setTrackingId(textAt(csaf, "/document/tracking/id")); + entity.setDocumentTitle(textAt(csaf, "/document/title")); + + String releaseDate = textAt(csaf, "/document/tracking/current_release_date"); + entity.setCurrentReleaseDate(releaseDate == null ? null : Instant.parse(releaseDate)); + } + + private String textAt(JsonNode node, String path) { + if (node == null) { + return null; + } + JsonNode value = node.at(path); + return value.isMissingNode() || value.isNull() ? null : value.asString(); + } + /** * Look up a single advisory by its primary key. * diff --git a/src/main/resources/db/migration/V1__initial_schema.sql b/src/main/resources/db/migration/V1__initial_schema.sql index 686022e1..f9f853be 100644 --- a/src/main/resources/db/migration/V1__initial_schema.sql +++ b/src/main/resources/db/migration/V1__initial_schema.sql @@ -4,89 +4,93 @@ -- Advisories (was ObjectType.Advisory) CREATE TABLE advisories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - workflow_state VARCHAR(20) NOT NULL DEFAULT 'Draft', - owner VARCHAR(255) NOT NULL, - csaf JSONB NOT NULL, - versioning_type VARCHAR(10) NOT NULL DEFAULT 'Semantic', - last_major_version VARCHAR(50), - tmp_tracking_id VARCHAR(255), - advisory_reference UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - version BIGINT NOT NULL DEFAULT 0 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_state VARCHAR(20) NOT NULL DEFAULT 'Draft', + owner VARCHAR(255) NOT NULL, + csaf JSONB NOT NULL, + tracking_id VARCHAR(255), + document_title TEXT, + current_release_date TIMESTAMPTZ, + versioning_type VARCHAR(10) NOT NULL DEFAULT 'Semantic', + last_major_version VARCHAR(50), + tmp_tracking_id VARCHAR(255), + advisory_reference UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + version BIGINT NOT NULL DEFAULT 0 ); -- Advisory version snapshots (was ObjectType.AdvisoryVersion) CREATE TABLE advisory_versions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, - workflow_state VARCHAR(20) NOT NULL, - owner VARCHAR(255) NOT NULL, - csaf JSONB NOT NULL, - versioning_type VARCHAR(10) NOT NULL, - last_major_version VARCHAR(50), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, + workflow_state VARCHAR(20) NOT NULL, + owner VARCHAR(255) NOT NULL, + csaf JSONB NOT NULL, + versioning_type VARCHAR(10) NOT NULL, + last_major_version VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Audit trail for document changes (was ObjectType.AuditTrailDocument) CREATE TABLE audit_trail_documents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - "user" VARCHAR(255) NOT NULL, - change_type VARCHAR(20) NOT NULL, - diff JSONB, - old_doc_version VARCHAR(50), - doc_version VARCHAR(50) + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + "user" VARCHAR(255) NOT NULL, + change_type VARCHAR(20) NOT NULL, + diff JSONB, + old_doc_version VARCHAR(50), + doc_version VARCHAR(50) ); -- Audit trail for workflow transitions (was ObjectType.AuditTrailWorkflow) CREATE TABLE audit_trail_workflows ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - "user" VARCHAR(255) NOT NULL, - change_type VARCHAR(20) NOT NULL, - old_state VARCHAR(20), - new_state VARCHAR(20), - old_doc_version VARCHAR(50), - doc_version VARCHAR(50) + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + "user" VARCHAR(255) NOT NULL, + change_type VARCHAR(20) NOT NULL, + old_state VARCHAR(20), + new_state VARCHAR(20), + old_doc_version VARCHAR(50), + doc_version VARCHAR(50) ); -- Comments and answers (was ObjectType.Comment) CREATE TABLE comments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, - owner VARCHAR(255) NOT NULL, - comment_text TEXT NOT NULL, - csaf_node_id VARCHAR(255), - field_name VARCHAR(255), - answer_to UUID REFERENCES comments(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - version BIGINT NOT NULL DEFAULT 0 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, + owner VARCHAR(255) NOT NULL, + comment_text TEXT NOT NULL, + csaf_node_id VARCHAR(255), + field_name VARCHAR(255), + answer_to UUID REFERENCES comments(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + version BIGINT NOT NULL DEFAULT 0 ); -- Audit trail for comment changes (was ObjectType.CommentAuditTrail) CREATE TABLE audit_trail_comments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - "user" VARCHAR(255) NOT NULL, - change_type VARCHAR(20) NOT NULL, - comment_text TEXT + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + "user" VARCHAR(255) NOT NULL, + change_type VARCHAR(20) NOT NULL, + comment_text TEXT ); -- Sequential counters (was ObjectType.Counter) CREATE TABLE counters ( - id VARCHAR(100) PRIMARY KEY, - count BIGINT NOT NULL DEFAULT 0 + id VARCHAR(100) PRIMARY KEY, + count BIGINT NOT NULL DEFAULT 0 ); -- Indexes for common query patterns CREATE INDEX idx_advisories_workflow_state ON advisories(workflow_state); CREATE INDEX idx_advisories_owner ON advisories(owner); -CREATE UNIQUE INDEX idx_advisories_tracking_id - ON advisories ((csaf -> 'document' -> 'tracking' ->> 'id')); +CREATE UNIQUE INDEX idx_advisories_tracking_id ON advisories(tracking_id); +CREATE INDEX idx_advisories_workflow_state_release_date + ON advisories(workflow_state, current_release_date); CREATE INDEX idx_advisories_csaf ON advisories USING GIN (csaf jsonb_path_ops); CREATE INDEX idx_advisory_versions_advisory_id ON advisory_versions(advisory_id); CREATE INDEX idx_audit_trail_documents_advisory_id ON audit_trail_documents(advisory_id); diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java index c70d6195..34c38d29 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java @@ -16,6 +16,7 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import de.bsi.secvisogram.csaf_cms_backend.service.AdvisoryListQueryService; import de.bsi.secvisogram.csaf_cms_backend.service.PostgresRepositoryService; @SpringBootTest(properties = { @@ -27,6 +28,9 @@ class DocumentTemplateServiceTest { @MockitoBean private PostgresRepositoryService postgresRepositoryService; + @MockitoBean + private AdvisoryListQueryService advisoryListQueryService; + @Autowired private DocumentTemplateService templateService; diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterNoLogoTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterNoLogoTest.java index 1a8941f0..28930284 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterNoLogoTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterNoLogoTest.java @@ -9,6 +9,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.util.StringUtils; +import de.bsi.secvisogram.csaf_cms_backend.service.AdvisoryListQueryService; import de.bsi.secvisogram.csaf_cms_backend.service.PostgresRepositoryService; @SpringBootTest(properties = { @@ -19,6 +20,9 @@ class JavascriptExporterNoLogoTest { @MockitoBean private PostgresRepositoryService postgresRepositoryService; + @MockitoBean + private AdvisoryListQueryService advisoryListQueryService; + private static final String json = """ { "document": { diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterTest.java index 32b7b25e..f37a4161 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/mustache/JavascriptExporterTest.java @@ -17,6 +17,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.util.StringUtils; +import de.bsi.secvisogram.csaf_cms_backend.service.AdvisoryListQueryService; import de.bsi.secvisogram.csaf_cms_backend.service.PostgresRepositoryService; @SpringBootTest(properties = { @@ -27,6 +28,9 @@ class JavascriptExporterTest { @MockitoBean private PostgresRepositoryService postgresRepositoryService; + @MockitoBean + private AdvisoryListQueryService advisoryListQueryService; + private static final String json = """ { "document": { diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryServiceTest.java new file mode 100644 index 00000000..b11173e6 --- /dev/null +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryServiceTest.java @@ -0,0 +1,67 @@ +package de.bsi.secvisogram.csaf_cms_backend.service; + +import de.bsi.secvisogram.csaf_cms_backend.entity.AdvisoryEntity; +import de.bsi.secvisogram.csaf_cms_backend.repository.*; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PostgresRepositoryServiceTest { + + @Test + void saveAdvisoryUpdatesDenormalizedListColumns() { + AdvisoryRepository advisoryRepository = mock(AdvisoryRepository.class); + when(advisoryRepository.saveAndFlush(any(AdvisoryEntity.class))).thenAnswer(invocation -> invocation.getArgument(0)); + PostgresRepositoryService service = new PostgresRepositoryService( + advisoryRepository, + mock(AdvisoryVersionRepository.class), + mock(AuditTrailDocumentRepository.class), + mock(AuditTrailWorkflowRepository.class), + mock(CommentRepository.class), + mock(AuditTrailCommentRepository.class), + mock(CounterRepository.class)); + + AdvisoryEntity entity = new AdvisoryEntity(); + entity.setCsaf(new JsonMapper().readTree(""" + { + "document": { + "title": "Initial title", + "tracking": { + "id": "CSAF-2026-0001", + "current_release_date": "2026-10-12T06:00:00Z" + } + } + } + """)); + + service.saveAdvisory(entity); + + assertEquals("CSAF-2026-0001", entity.getTrackingId()); + assertEquals("Initial title", entity.getDocumentTitle()); + assertEquals(Instant.parse("2026-10-12T06:00:00Z"), entity.getCurrentReleaseDate()); + + entity.setCsaf(new JsonMapper().readTree(""" + { + "document": { + "title": "Updated title", + "tracking": { + "id": "CSAF-2026-0002" + } + } + } + """)); + + service.saveAdvisory(entity); + + assertEquals("CSAF-2026-0002", entity.getTrackingId()); + assertEquals("Updated title", entity.getDocumentTitle()); + assertNull(entity.getCurrentReleaseDate()); + } +} \ No newline at end of file From 3224dbda7b935c01b59d5242e6d7433b1828a6d3 Mon Sep 17 00:00:00 2001 From: rschneider <97682836+rainer-exxcellent@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:44:24 +0200 Subject: [PATCH 3/5] feat(postgres): #226 refactor couchdb package name --- .../{couchdb => db}/DatabaseException.java | 2 +- .../{couchdb => db}/IdNotFoundException.java | 2 +- .../entity/AdvisoryEntity.java | 3 + .../entity/AdvisoryVersionEntity.java | 36 ++++ .../json/AdvisoryAuditTrailDiffWrapper.java | 2 +- .../AdvisoryAuditTrailWorkflowWrapper.java | 2 +- .../json/AdvisoryAuditTrailWrapper.java | 2 +- .../json/AdvisoryWrapper.java | 43 ++--- .../json/AuditTrailWrapper.java | 7 +- .../json/CommentAuditTrailWrapper.java | 2 +- .../csaf_cms_backend/json/CommentWrapper.java | 19 +-- .../LegacyDocumentField.java} | 14 +- .../field}/AdvisoryAuditTrailField.java | 2 +- .../field}/AdvisoryField.java | 2 +- .../field}/AdvisorySearchField.java | 2 +- .../field}/AuditTrailField.java | 2 +- .../field}/CommentAuditTrailField.java | 2 +- .../{couchdb => json/field}/CommentField.java | 2 +- .../{couchdb => json/field}/DbField.java | 7 +- .../rest/AdvisoryController.java | 4 +- .../service/AdvisoryListQueryService.java | 31 +++- .../service/AdvisoryService.java | 30 ++-- .../AdvisoryVersionListProjection.java | 14 ++ .../service/AdvisoryWorkflowUtil.java | 47 ++---- .../service/PostgresRepositoryService.java | 38 +++-- .../csaf_cms_backend/task/PublishJob.java | 3 +- .../db/migration/V1__initial_schema.sql | 154 ++++++++++-------- .../{couchdb => db}/AuditTrailTest.java | 2 +- .../fixture/TestModelField.java | 33 ---- .../template/DocumentTemplateServiceTest.java | 2 +- .../rest/AdvisoryControllerTest.java | 4 +- .../service/AdvisorySearchUtilTest.java | 3 +- .../service/AdvisoryServiceTest.java | 8 +- ...AdvisoryWorkflowIntegerVersioningTest.java | 2 +- ...dvisoryWorkflowSemanticVersioningTest.java | 59 ++++--- .../PostgresRepositoryServiceTest.java | 56 ++++++- .../{couchdb => db}/exxcellent-2021AB123.json | 0 .../{couchdb => db}/exxcellent-2022CC.json | 0 .../model/template/allTemplates.json | 0 .../model/template/template1.json | 0 40 files changed, 372 insertions(+), 271 deletions(-) rename src/main/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb => db}/DatabaseException.java (85%) rename src/main/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb => db}/IdNotFoundException.java (86%) rename src/main/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb/CouchDbField.java => json/LegacyDocumentField.java} (52%) rename src/main/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb => json/field}/AdvisoryAuditTrailField.java (91%) rename src/main/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb => json/field}/AdvisoryField.java (94%) rename src/main/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb => json/field}/AdvisorySearchField.java (95%) rename src/main/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb => json/field}/AuditTrailField.java (89%) rename src/main/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb => json/field}/CommentAuditTrailField.java (89%) rename src/main/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb => json/field}/CommentField.java (91%) rename src/main/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb => json/field}/DbField.java (54%) create mode 100644 src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryVersionListProjection.java rename src/test/java/de/bsi/secvisogram/csaf_cms_backend/{couchdb => db}/AuditTrailTest.java (96%) delete mode 100644 src/test/java/de/bsi/secvisogram/csaf_cms_backend/fixture/TestModelField.java rename src/test/resources/de/bsi/secvisogram/csaf_cms_backend/{couchdb => db}/exxcellent-2021AB123.json (100%) rename src/test/resources/de/bsi/secvisogram/csaf_cms_backend/{couchdb => db}/exxcellent-2022CC.json (100%) rename src/test/resources/de/bsi/secvisogram/csaf_cms_backend/{couchdb => db}/model/template/allTemplates.json (100%) rename src/test/resources/de/bsi/secvisogram/csaf_cms_backend/{couchdb => db}/model/template/template1.json (100%) diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/DatabaseException.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/db/DatabaseException.java similarity index 85% rename from src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/DatabaseException.java rename to src/main/java/de/bsi/secvisogram/csaf_cms_backend/db/DatabaseException.java index 34b93a31..96ee485c 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/DatabaseException.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/db/DatabaseException.java @@ -1,4 +1,4 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.db; public class DatabaseException extends Exception { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/IdNotFoundException.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/db/IdNotFoundException.java similarity index 86% rename from src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/IdNotFoundException.java rename to src/main/java/de/bsi/secvisogram/csaf_cms_backend/db/IdNotFoundException.java index 4c24fbfc..a63ec8fe 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/IdNotFoundException.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/db/IdNotFoundException.java @@ -1,4 +1,4 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.db; public class IdNotFoundException extends DatabaseException { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java index 9b1e3f0f..f44ea47f 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryEntity.java @@ -34,12 +34,15 @@ public class AdvisoryEntity { @Column(columnDefinition = "jsonb", nullable = false) private JsonNode csaf; + // Set from csaf.document.tracking.id. @Column(name = "tracking_id") private String trackingId; + // Set from csaf.document.title. @Column(name = "document_title", columnDefinition = "text") private String documentTitle; + // Set from csaf.document.tracking.current_release_date. @Column(name = "current_release_date") private Instant currentReleaseDate; diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryVersionEntity.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryVersionEntity.java index f2f1dec9..25499eb0 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryVersionEntity.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/entity/AdvisoryVersionEntity.java @@ -43,6 +43,18 @@ public class AdvisoryVersionEntity { @Column(columnDefinition = "jsonb", nullable = false) private JsonNode csaf; + // Set from csaf.document.tracking.id. + @Column(name = "tracking_id") + private String trackingId; + + // Set from csaf.document.title. + @Column(name = "document_title") + private String documentTitle; + + // Set from csaf.document.tracking.current_release_date. + @Column(name = "current_release_date") + private Instant currentReleaseDate; + @Column(name = "versioning_type", nullable = false) private String versioningType; @@ -92,6 +104,30 @@ public void setCsaf(JsonNode csaf) { this.csaf = csaf; } + public String getTrackingId() { + return trackingId; + } + + public void setTrackingId(String trackingId) { + this.trackingId = trackingId; + } + + public String getDocumentTitle() { + return documentTitle; + } + + public void setDocumentTitle(String documentTitle) { + this.documentTitle = documentTitle; + } + + public Instant getCurrentReleaseDate() { + return currentReleaseDate; + } + + public void setCurrentReleaseDate(Instant currentReleaseDate) { + this.currentReleaseDate = currentReleaseDate; + } + public String getVersioningType() { return versioningType; } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailDiffWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailDiffWrapper.java index 70dc430a..1f1fd1fe 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailDiffWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailDiffWrapper.java @@ -1,6 +1,6 @@ package de.bsi.secvisogram.csaf_cms_backend.json; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisoryAuditTrailField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.AdvisoryAuditTrailField; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.node.ObjectNode; diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailWorkflowWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailWorkflowWrapper.java index 3131e825..228a1356 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailWorkflowWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailWorkflowWrapper.java @@ -1,6 +1,6 @@ package de.bsi.secvisogram.csaf_cms_backend.json; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisoryAuditTrailField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.AdvisoryAuditTrailField; import de.bsi.secvisogram.csaf_cms_backend.model.ChangeType; import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; import tools.jackson.databind.json.JsonMapper; diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailWrapper.java index 0e783efd..70a8b409 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryAuditTrailWrapper.java @@ -1,6 +1,6 @@ package de.bsi.secvisogram.csaf_cms_backend.json; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisoryAuditTrailField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.AdvisoryAuditTrailField; import tools.jackson.databind.node.ObjectNode; public class AdvisoryAuditTrailWrapper extends AuditTrailWrapper { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java index 524e294c..6380e890 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java @@ -1,8 +1,8 @@ package de.bsi.secvisogram.csaf_cms_backend.json; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisoryField.CSAF; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.ID_FIELD; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.REVISION_FIELD; +import static de.bsi.secvisogram.csaf_cms_backend.json.field.AdvisoryField.CSAF; +import static de.bsi.secvisogram.csaf_cms_backend.json.LegacyDocumentField.ID_FIELD; +import static de.bsi.secvisogram.csaf_cms_backend.json.LegacyDocumentField.REVISION_FIELD; import static de.bsi.secvisogram.csaf_cms_backend.exception.CsafExceptionKey.InvalidObjectType; import static java.time.LocalDateTime.from; import static java.time.format.DateTimeFormatter.ISO_DATE_TIME; @@ -11,13 +11,15 @@ import com.flipkart.zjsonpatch.Jackson3JsonDiff; import com.flipkart.zjsonpatch.Jackson3JsonPatch; import com.vdurmont.semver4j.Semver; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.*; +import de.bsi.secvisogram.csaf_cms_backend.json.field.AdvisoryField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.AdvisorySearchField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.AuditTrailField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.DbField; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafExceptionKey; import de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus; import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateAdvisoryRequest; -import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryInformationResponse; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -28,8 +30,6 @@ import java.time.format.DateTimeParseException; import java.util.List; import java.util.Locale; -import java.util.Map; -import java.util.function.BiConsumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -40,7 +40,7 @@ import tools.jackson.databind.node.ObjectNode; /** - * Wrapper around JsonNode to read and write advisory objects from/to the CouchDB + * Wrapper around JsonNode to read and write advisory objects. */ public class AdvisoryWrapper { @@ -272,7 +272,7 @@ public AdvisoryWrapper setOwner(String newValue) { public ObjectType getType() { try { - String typeText = getTextFor(CouchDbField.TYPE_FIELD); + String typeText = getTextFor(LegacyDocumentField.TYPE_FIELD); return (typeText != null) ? ObjectType.valueOf(typeText) : null; } catch (IllegalArgumentException ex) { return null; // unknown type @@ -281,7 +281,7 @@ public ObjectType getType() { private AdvisoryWrapper setType(ObjectType newValue) { - this.advisoryNode.put(CouchDbField.TYPE_FIELD.getDbName(), newValue.name()); + this.advisoryNode.put(LegacyDocumentField.TYPE_FIELD.getDbName(), newValue.name()); return this; } @@ -772,29 +772,6 @@ public String advisoryAsString() { return this.advisoryNode.toString(); } - public static AdvisoryInformationResponse convertToAdvisoryInfo(JsonNode doc, Map> infoFields) { - String advisoryId = doc.get(ID_FIELD.getDbName()).asString(); - final AdvisoryInformationResponse response = new AdvisoryInformationResponse(advisoryId); - infoFields.forEach((key, value) -> setValueInResponse(response, key, doc, value)); - - return response; - } - - private static void setValueInResponse(AdvisoryInformationResponse response, DbField field, JsonNode doc, BiConsumer advisorySetter) { - - String value; - if (field.equals(ID_FIELD)) { - value = doc.get(ID_FIELD.getDbName()).asString(); - } else if (field.equals(REVISION_FIELD)) { - value = doc.get(REVISION_FIELD.getDbName()).asString(); - } else { - String jsonPtrExpr = String.join("/", field.getFieldPath()); - value = doc.at("/" + jsonPtrExpr).asString(); - } - advisorySetter.accept(response, value); - } - /** * This utility method checks if the current_release_date of the advisory is set and if it lies in the past * This is helpful for checking if the current_release_date must be updated or not diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AuditTrailWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AuditTrailWrapper.java index 2aa3eb0b..857e8450 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AuditTrailWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AuditTrailWrapper.java @@ -1,7 +1,6 @@ package de.bsi.secvisogram.csaf_cms_backend.json; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.AuditTrailField; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.AuditTrailField; import de.bsi.secvisogram.csaf_cms_backend.model.ChangeType; import java.time.Instant; import java.time.format.DateTimeFormatter; @@ -29,12 +28,12 @@ public String auditTrailAsString() { public String getType() { - return this.auditTrailNode.get(CouchDbField.TYPE_FIELD.getDbName()).asString(); + return this.auditTrailNode.get(LegacyDocumentField.TYPE_FIELD.getDbName()).asString(); } AuditTrailWrapper setType(ObjectType newValue) { - this.auditTrailNode.put(CouchDbField.TYPE_FIELD.getDbName(), newValue.name()); + this.auditTrailNode.put(LegacyDocumentField.TYPE_FIELD.getDbName(), newValue.name()); return this; } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentAuditTrailWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentAuditTrailWrapper.java index 6c2a6e89..c36f84b1 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentAuditTrailWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentAuditTrailWrapper.java @@ -1,6 +1,6 @@ package de.bsi.secvisogram.csaf_cms_backend.json; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.CommentAuditTrailField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.CommentAuditTrailField; import de.bsi.secvisogram.csaf_cms_backend.model.ChangeType; import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.node.ObjectNode; diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapper.java index 6f842a34..3a9c6463 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/CommentWrapper.java @@ -1,14 +1,13 @@ package de.bsi.secvisogram.csaf_cms_backend.json; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.ID_FIELD; -import static de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField.REVISION_FIELD; +import static de.bsi.secvisogram.csaf_cms_backend.json.LegacyDocumentField.ID_FIELD; +import static de.bsi.secvisogram.csaf_cms_backend.json.LegacyDocumentField.REVISION_FIELD; import static de.bsi.secvisogram.csaf_cms_backend.exception.CsafExceptionKey.InvalidObjectType; import static org.springframework.http.HttpStatus.BAD_REQUEST; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.AuditTrailField; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.CommentField; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DbField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.AuditTrailField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.CommentField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.DbField; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateCommentRequest; import de.bsi.secvisogram.csaf_cms_backend.rest.response.AnswerInformationResponse; @@ -24,13 +23,13 @@ import tools.jackson.databind.node.ObjectNode; /** - * Wrapper around JsonNode to read and write comment/answer objects from/to the CouchDB + * Wrapper around JsonNode to read and write comment/answer objects. */ public class CommentWrapper { /** - * Convert an input stream from the couch db to a CommentWrapper + * Convert an input stream to a CommentWrapper. * * @param commentStream the stream * @return the wrapper @@ -214,7 +213,7 @@ public CommentWrapper setAnswerTo(String newValue) { ObjectType getType() { try { - String typeText = getTextFor(CouchDbField.TYPE_FIELD); + String typeText = getTextFor(LegacyDocumentField.TYPE_FIELD); return (typeText != null) ? ObjectType.valueOf(typeText) : null; } catch (IllegalArgumentException ex) { return null; // unknown type @@ -229,7 +228,7 @@ String getTextFor(DbField dbField) { private CommentWrapper setType(ObjectType newValue) { - this.commentNode.put(CouchDbField.TYPE_FIELD.getDbName(), newValue.name()); + this.commentNode.put(LegacyDocumentField.TYPE_FIELD.getDbName(), newValue.name()); return this; } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbField.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/LegacyDocumentField.java similarity index 52% rename from src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbField.java rename to src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/LegacyDocumentField.java index a955b029..f151813e 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CouchDbField.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/LegacyDocumentField.java @@ -1,6 +1,12 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.json; -public enum CouchDbField implements DbField { +import de.bsi.secvisogram.csaf_cms_backend.json.field.DbField; + +/** + * Metadata used by JSON documents retained from the previous CouchDB-based + * representation. New persistence code must not depend on these fields. + */ +public enum LegacyDocumentField implements DbField { TYPE_FIELD("type"), REVISION_FIELD("_rev"), @@ -9,7 +15,7 @@ public enum CouchDbField implements DbField { private final String dbName; private final String[] fieldPath; - CouchDbField(String dbName) { + LegacyDocumentField(String dbName) { this.dbName = dbName; this.fieldPath = new String[] {dbName}; } @@ -23,4 +29,4 @@ public String getDbName() { public String[] getFieldPath() { return this.fieldPath.clone(); } -} +} \ No newline at end of file diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AdvisoryAuditTrailField.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AdvisoryAuditTrailField.java similarity index 91% rename from src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AdvisoryAuditTrailField.java rename to src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AdvisoryAuditTrailField.java index b84f54fc..feea0b9a 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AdvisoryAuditTrailField.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AdvisoryAuditTrailField.java @@ -1,4 +1,4 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.json.field; public enum AdvisoryAuditTrailField implements DbField { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AdvisoryField.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AdvisoryField.java similarity index 94% rename from src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AdvisoryField.java rename to src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AdvisoryField.java index 83a4785d..45c1d2f4 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AdvisoryField.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AdvisoryField.java @@ -1,4 +1,4 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.json.field; public enum AdvisoryField implements DbField { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AdvisorySearchField.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AdvisorySearchField.java similarity index 95% rename from src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AdvisorySearchField.java rename to src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AdvisorySearchField.java index 7baf374c..8cb355e3 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AdvisorySearchField.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AdvisorySearchField.java @@ -1,4 +1,4 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.json.field; public enum AdvisorySearchField implements DbField { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AuditTrailField.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AuditTrailField.java similarity index 89% rename from src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AuditTrailField.java rename to src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AuditTrailField.java index df60c61f..d9eccdd5 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AuditTrailField.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/AuditTrailField.java @@ -1,4 +1,4 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.json.field; public enum AuditTrailField implements DbField { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CommentAuditTrailField.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/CommentAuditTrailField.java similarity index 89% rename from src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CommentAuditTrailField.java rename to src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/CommentAuditTrailField.java index 3fb4f9e5..495901f9 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CommentAuditTrailField.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/CommentAuditTrailField.java @@ -1,4 +1,4 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.json.field; public enum CommentAuditTrailField implements DbField { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CommentField.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/CommentField.java similarity index 91% rename from src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CommentField.java rename to src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/CommentField.java index 2e51195c..3375d340 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/CommentField.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/CommentField.java @@ -1,4 +1,4 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.json.field; public enum CommentField implements DbField { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/DbField.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/DbField.java similarity index 54% rename from src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/DbField.java rename to src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/DbField.java index 33176734..c9e575e6 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/DbField.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/field/DbField.java @@ -1,10 +1,9 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.json.field; import tools.jackson.databind.JsonNode; /** - * Interface for database field metadata (field name and JSON path). - * Retained from the CouchDB era for compatibility with wrapper classes. + * Metadata for a JSON field, including its serialized name and path. */ public interface DbField { @@ -16,4 +15,4 @@ default String stringVal(JsonNode doc) { return doc.get(this.getDbName()).asString(); } -} +} \ No newline at end of file diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryController.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryController.java index 9893c9df..4ef3ac8c 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryController.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryController.java @@ -3,8 +3,8 @@ import de.bsi.secvisogram.csaf_cms_backend.SecvisogramApplication; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.IdNotFoundException; +import de.bsi.secvisogram.csaf_cms_backend.db.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.db.IdNotFoundException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus; import de.bsi.secvisogram.csaf_cms_backend.model.ExportFormat; diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java index 9e991d16..69941fb4 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java @@ -25,7 +25,9 @@ public class AdvisoryListQueryService { public List findAdvisories(Expression filter, Authentication credentials) { LOG.info("Start getAdvisoryInformations"); SqlExpressionCompiler compiler = new SqlExpressionCompiler(); - String where = visibilityClause(credentials, compiler) + " AND " + compiler.compile(filter); + String compiledExpr = compiler.compile(filter); + LOG.info("Compiled expression: {}", compiledExpr); + String where = visibilityClause(credentials, compiler) + " AND " + compiledExpr; String sql = """ SELECT id, workflow_state, owner, version, tracking_id, @@ -48,6 +50,28 @@ public List findAdvisories(Expression filter, Authentica (String) row[4], (String) row[5], row[6] == null ? null : row[6].toString())).toList(); } + /** Executes the advisory version list query without selecting the large {@code csaf} column. */ + public List findAdvisoryVersions(Expression filter) { + SqlExpressionCompiler compiler = new SqlExpressionCompiler(); + String sql = """ + SELECT advisory_id, workflow_state, owner, + tracking_id, + document_title, + current_release_date + FROM advisory_versions + WHERE""" + " " + compiler.compile(filter) + "\n" + """ + ORDER BY id + """; + + var query = entityManager.createNativeQuery(sql); + compiler.bind(query); + @SuppressWarnings("unchecked") + List rows = query.getResultList(); + return rows.stream().map(row -> new AdvisoryVersionListProjection( + row[0].toString(), (String) row[1], (String) row[2], + (String) row[3], (String) row[4], row[5] == null ? null : row[5].toString())).toList(); + } + private String visibilityClause(Authentication credentials, SqlExpressionCompiler compiler) { if (hasRole(credentials, CsafRoles.ROLE_AUDITOR) || hasRole(credentials, CsafRoles.ROLE_EDITOR)) { return "TRUE"; @@ -110,12 +134,9 @@ private String compileOperator(OperatorExpression expression) { return "TRUE"; } String path = jsonPath(selector, offset); - String exists = "jsonb_path_exists(csaf, CAST(" + bindText(path) + " AS jsonpath))"; String comparisonPath = path + " ? (" + predicate(expression) + ")"; - String matches = "jsonb_path_exists(csaf, CAST(" + bindText(comparisonPath) + return "jsonb_path_exists(csaf, CAST(" + bindText(comparisonPath) + " AS jsonpath), jsonb_build_object('value', to_jsonb(" + bindValue(expression) + ")))"; - // Existing Java semantics intentionally let missing paths pass through. - return "(NOT " + exists + " OR " + matches + ")"; } private String columnComparison(String column, OperatorExpression expression) { diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java index 2fcb6a2e..06a9a733 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java @@ -2,8 +2,8 @@ import de.bsi.secvisogram.csaf_cms_backend.config.CsafConfiguration; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.IdNotFoundException; +import de.bsi.secvisogram.csaf_cms_backend.db.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.db.IdNotFoundException; import de.bsi.secvisogram.csaf_cms_backend.entity.*; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafExceptionKey; @@ -156,7 +156,7 @@ public List getAdvisoryInformations(String expressi if (hasRole(AUDITOR, credentials)) { List allAdvisoryVersions = - readAllAdvisoryVersions(parsedExpr, credentials); + readAllAdvisoryVersions(parsedExpr); for (AdvisoryInformationResponse response : allAdvisoryVersions) { enrichAdvisoryVersion(response); } @@ -201,18 +201,27 @@ private AdvisoryInformationResponse toAdvisoryInformationResponse(AdvisoryListPr return response; } - private List readAllAdvisoryVersions( - Expression parsedExpr, - Authentication credentials) { + private List readAllAdvisoryVersions(Expression parsedExpr) { // Advisory versions are immutable snapshots in the advisory_versions table, // created each time a new version cycle begins via createNewCsafDocumentVersion. - return postgresService.findAllAdvisoryVersions().stream() - .filter(entity -> matchesCsafExpression(entity.getCsaf(), parsedExpr)) - .map(EntityConverter::toAdvisoryVersionInfo) + return advisoryListQueryService.findAdvisoryVersions(parsedExpr).stream() + .map(this::toAdvisoryVersionInformationResponse) .toList(); } + private AdvisoryInformationResponse toAdvisoryVersionInformationResponse( + AdvisoryVersionListProjection projection) { + + AdvisoryInformationResponse response = new AdvisoryInformationResponse(projection.advisoryId()); + response.setWorkflowState(projection.workflowState()); + response.setOwner(projection.owner()); + response.setDocumentTrackingId(projection.documentTrackingId()); + response.setTitle(projection.title()); + response.setCurrentReleaseDate(projection.currentReleaseDate()); + return response; + } + /** * Check whether an advisory info item passes the visibility expression filter. * When visibilityExpr is null the caller can see everything. @@ -299,8 +308,7 @@ private boolean evaluateCsafOperator( List values = collectJsonValues(csafJson, csafPath, 0); if (values.isEmpty()) { - // Path not found → cannot evaluate; pass through - return true; + return false; } return values.stream().anyMatch(value -> matchesOperator(opExpr.getOperatorType(), value, filterValue)); diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryVersionListProjection.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryVersionListProjection.java new file mode 100644 index 00000000..149a015a --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryVersionListProjection.java @@ -0,0 +1,14 @@ +package de.bsi.secvisogram.csaf_cms_backend.service; + +/** + * Scalar values required to render an advisory version snapshot in the list endpoint. + * The complete CSAF JSON document is deliberately not part of this projection. + */ +public record AdvisoryVersionListProjection( + String advisoryId, + String workflowState, + String owner, + String documentTrackingId, + String title, + String currentReleaseDate) { +} \ No newline at end of file diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java index 4fe7b773..fdc9bc9a 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java @@ -1,27 +1,14 @@ package de.bsi.secvisogram.csaf_cms_backend.service; -import static de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles.Role.*; -import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.equal; -import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.less; -import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.notEqual; - import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisoryField; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.AdvisorySearchField; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.CouchDbField; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DbField; import de.bsi.secvisogram.csaf_cms_backend.json.AdvisoryWrapper; +import de.bsi.secvisogram.csaf_cms_backend.json.field.AdvisoryField; +import de.bsi.secvisogram.csaf_cms_backend.json.field.AdvisorySearchField; import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; import de.bsi.secvisogram.csaf_cms_backend.model.filter.AndExpression; import de.bsi.secvisogram.csaf_cms_backend.model.filter.Expression; -import de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression; import de.bsi.secvisogram.csaf_cms_backend.model.filter.OrExpression; import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryInformationResponse; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.function.BiConsumer; import org.apache.commons.text.similarity.LevenshteinDetailedDistance; import org.apache.commons.text.similarity.LevenshteinResults; import org.slf4j.Logger; @@ -30,14 +17,22 @@ import org.springframework.security.core.GrantedAuthority; import tools.jackson.databind.JsonNode; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles.Role.*; +import static de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression.*; + /** * Helper Methods for workflow and permissions */ public class AdvisoryWorkflowUtil { - private static final Logger LOG = LoggerFactory.getLogger(AdvisoryWorkflowUtil.class); - - /** + /** * Check whether the given advisory info can be deleted with the given credentials * @param response the advisory info to check * @param credentials the credentials for the check @@ -379,20 +374,6 @@ public static boolean isInState(WorkflowState stateToCheck, WorkflowState ... al .anyMatch(state -> stateToCheck == state); } - public static Map> advisoryReadFields() { - - return Map.of( - AdvisoryField.WORKFLOW_STATE, AdvisoryInformationResponse::setWorkflowState, - AdvisoryField.OWNER, AdvisoryInformationResponse::setOwner, - AdvisorySearchField.DOCUMENT_TITLE, AdvisoryInformationResponse::setTitle, - AdvisorySearchField.DOCUMENT_TRACKING_ID, AdvisoryInformationResponse::setDocumentTrackingId, - CouchDbField.ID_FIELD, AdvisoryInformationResponse::setAdvisoryId, - CouchDbField.REVISION_FIELD, AdvisoryInformationResponse::setRevision, - AdvisorySearchField.DOCUMENT_TRACKING_CURRENT_RELEASE_DATE, AdvisoryInformationResponse::setCurrentReleaseDate - ); - } - - public static PatchType getChangeType(AdvisoryWrapper oldAdvisoryNode, AdvisoryWrapper newAdvisory, int maxLevenshteinDistance) { PatchType result = PatchType.PATCH; @@ -471,7 +452,7 @@ public static boolean timestampIsBefore(String timestamp1, String timestamp2) { } /** - * Build a CouchDB filter expression that corresponds to the visibility rules of + * Build a filter expression that corresponds to the visibility rules of * {@link #canViewAdvisory(String, WorkflowState, Authentication, String)}. *

* Returns {@code null} when the user can see every advisory (AUDITOR or EDITOR roles), diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java index 820ab0bb..92d301ee 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryService.java @@ -1,6 +1,6 @@ package de.bsi.secvisogram.csaf_cms_backend.service; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.IdNotFoundException; +import de.bsi.secvisogram.csaf_cms_backend.db.IdNotFoundException; import de.bsi.secvisogram.csaf_cms_backend.entity.*; import de.bsi.secvisogram.csaf_cms_backend.repository.*; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -13,6 +13,7 @@ import tools.jackson.databind.JsonNode; import java.time.Instant; +import java.time.format.DateTimeParseException; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -83,7 +84,7 @@ private void updateAdvisoryListColumns(AdvisoryEntity entity) { entity.setDocumentTitle(textAt(csaf, "/document/title")); String releaseDate = textAt(csaf, "/document/tracking/current_release_date"); - entity.setCurrentReleaseDate(releaseDate == null ? null : Instant.parse(releaseDate)); + entity.setCurrentReleaseDate(parseReleaseDate(releaseDate)); } private String textAt(JsonNode node, String path) { @@ -94,6 +95,19 @@ private String textAt(JsonNode node, String path) { return value.isMissingNode() || value.isNull() ? null : value.asString(); } + private Instant parseReleaseDate(String releaseDate) { + + if (releaseDate == null || releaseDate.isBlank()) { + return null; + } + + try { + return Instant.parse(releaseDate); + } catch (DateTimeParseException ignored) { + return null; + } + } + /** * Look up a single advisory by its primary key. * @@ -115,16 +129,6 @@ public List findAllAdvisories() { return advisoryRepository.findAll(BY_ID); } - /** - * Return all advisories that satisfy the given JPA Specification. - * - * @param spec the filtering specification - * @return matching advisory entities, ordered by id - */ - public List findAdvisories(Specification spec) { - return advisoryRepository.findAll(spec, BY_ID); - } - /** * Delete the advisory identified by the given UUID. * @@ -190,9 +194,19 @@ public long getTotalDocumentCount() { * @return the saved entity */ public AdvisoryVersionEntity saveAdvisoryVersion(AdvisoryVersionEntity entity) { + updateAdvisoryVersionListColumns(entity); return advisoryVersionRepository.save(entity); } + private void updateAdvisoryVersionListColumns(AdvisoryVersionEntity entity) { + JsonNode csaf = entity.getCsaf(); + entity.setTrackingId(textAt(csaf, "/document/tracking/id")); + entity.setDocumentTitle(textAt(csaf, "/document/title")); + + String releaseDate = textAt(csaf, "/document/tracking/current_release_date"); + entity.setCurrentReleaseDate(parseReleaseDate(releaseDate)); + } + /** * Return every advisory version snapshot stored in the database, ordered by id. * diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishJob.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishJob.java index 6d0db920..a96a1fa6 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishJob.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishJob.java @@ -16,7 +16,6 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,7 +34,7 @@ import org.springframework.web.client.RestClientResponseException; import de.bsi.secvisogram.csaf_cms_backend.config.CsafConfiguration; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.db.DatabaseException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.json.AdvisoryWrapper; import de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus; diff --git a/src/main/resources/db/migration/V1__initial_schema.sql b/src/main/resources/db/migration/V1__initial_schema.sql index f9f853be..c82dad0b 100644 --- a/src/main/resources/db/migration/V1__initial_schema.sql +++ b/src/main/resources/db/migration/V1__initial_schema.sql @@ -3,98 +3,108 @@ -- Replaces single-database CouchDB document model with normalized relational tables -- Advisories (was ObjectType.Advisory) -CREATE TABLE advisories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - workflow_state VARCHAR(20) NOT NULL DEFAULT 'Draft', - owner VARCHAR(255) NOT NULL, - csaf JSONB NOT NULL, - tracking_id VARCHAR(255), - document_title TEXT, - current_release_date TIMESTAMPTZ, - versioning_type VARCHAR(10) NOT NULL DEFAULT 'Semantic', - last_major_version VARCHAR(50), - tmp_tracking_id VARCHAR(255), - advisory_reference UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - version BIGINT NOT NULL DEFAULT 0 +CREATE TABLE advisories +( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_state VARCHAR(20) NOT NULL DEFAULT 'Draft', + owner VARCHAR(255) NOT NULL, + csaf JSONB NOT NULL, + tracking_id VARCHAR(255), + document_title TEXT, + current_release_date TIMESTAMPTZ, + versioning_type VARCHAR(10) NOT NULL DEFAULT 'Semantic', + last_major_version VARCHAR(50), + tmp_tracking_id VARCHAR(255), + advisory_reference UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + version BIGINT NOT NULL DEFAULT 0 ); -- Advisory version snapshots (was ObjectType.AdvisoryVersion) -CREATE TABLE advisory_versions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, - workflow_state VARCHAR(20) NOT NULL, - owner VARCHAR(255) NOT NULL, - csaf JSONB NOT NULL, - versioning_type VARCHAR(10) NOT NULL, - last_major_version VARCHAR(50), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() +CREATE TABLE advisory_versions +( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories (id) ON DELETE CASCADE, + workflow_state VARCHAR(20) NOT NULL, + owner VARCHAR(255) NOT NULL, + csaf JSONB NOT NULL, + tracking_id VARCHAR(255), + document_title TEXT, + current_release_date TIMESTAMPTZ, + versioning_type VARCHAR(10) NOT NULL, + last_major_version VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Audit trail for document changes (was ObjectType.AuditTrailDocument) -CREATE TABLE audit_trail_documents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - "user" VARCHAR(255) NOT NULL, - change_type VARCHAR(20) NOT NULL, - diff JSONB, - old_doc_version VARCHAR(50), - doc_version VARCHAR(50) +CREATE TABLE audit_trail_documents +( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + "user" VARCHAR(255) NOT NULL, + change_type VARCHAR(20) NOT NULL, + diff JSONB, + old_doc_version VARCHAR(50), + doc_version VARCHAR(50) ); -- Audit trail for workflow transitions (was ObjectType.AuditTrailWorkflow) -CREATE TABLE audit_trail_workflows ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - "user" VARCHAR(255) NOT NULL, - change_type VARCHAR(20) NOT NULL, - old_state VARCHAR(20), - new_state VARCHAR(20), - old_doc_version VARCHAR(50), - doc_version VARCHAR(50) +CREATE TABLE audit_trail_workflows +( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + "user" VARCHAR(255) NOT NULL, + change_type VARCHAR(20) NOT NULL, + old_state VARCHAR(20), + new_state VARCHAR(20), + old_doc_version VARCHAR(50), + doc_version VARCHAR(50) ); -- Comments and answers (was ObjectType.Comment) -CREATE TABLE comments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - advisory_id UUID NOT NULL REFERENCES advisories(id) ON DELETE CASCADE, - owner VARCHAR(255) NOT NULL, - comment_text TEXT NOT NULL, - csaf_node_id VARCHAR(255), - field_name VARCHAR(255), - answer_to UUID REFERENCES comments(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - version BIGINT NOT NULL DEFAULT 0 +CREATE TABLE comments +( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + advisory_id UUID NOT NULL REFERENCES advisories (id) ON DELETE CASCADE, + owner VARCHAR(255) NOT NULL, + comment_text TEXT NOT NULL, + csaf_node_id VARCHAR(255), + field_name VARCHAR(255), + answer_to UUID REFERENCES comments (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + version BIGINT NOT NULL DEFAULT 0 ); -- Audit trail for comment changes (was ObjectType.CommentAuditTrail) -CREATE TABLE audit_trail_comments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - "user" VARCHAR(255) NOT NULL, - change_type VARCHAR(20) NOT NULL, - comment_text TEXT +CREATE TABLE audit_trail_comments +( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + comment_id UUID NOT NULL REFERENCES comments (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + "user" VARCHAR(255) NOT NULL, + change_type VARCHAR(20) NOT NULL, + comment_text TEXT ); -- Sequential counters (was ObjectType.Counter) -CREATE TABLE counters ( - id VARCHAR(100) PRIMARY KEY, - count BIGINT NOT NULL DEFAULT 0 +CREATE TABLE counters +( + id VARCHAR(100) PRIMARY KEY, + count BIGINT NOT NULL DEFAULT 0 ); -- Indexes for common query patterns -CREATE INDEX idx_advisories_workflow_state ON advisories(workflow_state); -CREATE INDEX idx_advisories_owner ON advisories(owner); -CREATE UNIQUE INDEX idx_advisories_tracking_id ON advisories(tracking_id); +CREATE INDEX idx_advisories_workflow_state ON advisories (workflow_state); +CREATE INDEX idx_advisories_owner ON advisories (owner); +CREATE UNIQUE INDEX idx_advisories_tracking_id ON advisories (tracking_id); CREATE INDEX idx_advisories_workflow_state_release_date - ON advisories(workflow_state, current_release_date); + ON advisories (workflow_state, current_release_date); CREATE INDEX idx_advisories_csaf ON advisories USING GIN (csaf jsonb_path_ops); -CREATE INDEX idx_advisory_versions_advisory_id ON advisory_versions(advisory_id); -CREATE INDEX idx_audit_trail_documents_advisory_id ON audit_trail_documents(advisory_id); -CREATE INDEX idx_audit_trail_workflows_advisory_id ON audit_trail_workflows(advisory_id); -CREATE INDEX idx_comments_advisory_id ON comments(advisory_id); -CREATE INDEX idx_comments_answer_to ON comments(answer_to); -CREATE INDEX idx_audit_trail_comments_comment_id ON audit_trail_comments(comment_id); +CREATE INDEX idx_advisory_versions_advisory_id ON advisory_versions (advisory_id); +CREATE INDEX idx_audit_trail_documents_advisory_id ON audit_trail_documents (advisory_id); +CREATE INDEX idx_audit_trail_workflows_advisory_id ON audit_trail_workflows (advisory_id); +CREATE INDEX idx_comments_advisory_id ON comments (advisory_id); +CREATE INDEX idx_comments_answer_to ON comments (answer_to); +CREATE INDEX idx_audit_trail_comments_comment_id ON audit_trail_comments (comment_id); diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AuditTrailTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/db/AuditTrailTest.java similarity index 96% rename from src/test/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AuditTrailTest.java rename to src/test/java/de/bsi/secvisogram/csaf_cms_backend/db/AuditTrailTest.java index d935f399..fba211e3 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/couchdb/AuditTrailTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/db/AuditTrailTest.java @@ -1,4 +1,4 @@ -package de.bsi.secvisogram.csaf_cms_backend.couchdb; +package de.bsi.secvisogram.csaf_cms_backend.db; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/fixture/TestModelField.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/fixture/TestModelField.java deleted file mode 100644 index bab8e09b..00000000 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/fixture/TestModelField.java +++ /dev/null @@ -1,33 +0,0 @@ -package de.bsi.secvisogram.csaf_cms_backend.fixture; - -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DbField; - -public enum TestModelField implements DbField { - - FIRST_STRING("firstString"), - SECOND_STRING("secondString"), - DECIMAL_VALUE("decimalValue"), - - BOOLEAN_VALUE("booleanValue"), - - FIRST_LEVEL("firstLevel"), - ARRAY_VALUES("arrayValues"); - - private final String dbName; - private final String[] fieldPath; - - TestModelField(String dbName) { - this.dbName = dbName; - this.fieldPath = new String[] {dbName}; - } - - @Override - public String getDbName() { - return dbName; - } - - @Override - public String[] getFieldPath() { - return this.fieldPath.clone(); - } -} diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java index 34c38d29..6df00c34 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateServiceTest.java @@ -20,7 +20,7 @@ import de.bsi.secvisogram.csaf_cms_backend.service.PostgresRepositoryService; @SpringBootTest(properties = { - "csaf.document.templates.file=./src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/model/template/allTemplates.json" + "csaf.document.templates.file=./src/test/resources/de/bsi/secvisogram/csaf_cms_backend/db/model/template/allTemplates.json" }) @SuppressFBWarnings(value = "CLI_CONSTANT_LIST_INDEX", justification = "for test it is ok") class DocumentTemplateServiceTest { diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryControllerTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryControllerTest.java index e7421af3..35f85154 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryControllerTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryControllerTest.java @@ -21,8 +21,8 @@ import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.json.JsonMapper; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.IdNotFoundException; +import de.bsi.secvisogram.csaf_cms_backend.db.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.db.IdNotFoundException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafExceptionKey; import de.bsi.secvisogram.csaf_cms_backend.fixture.CsafDocumentJsonCreator; diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java index ffef0a9e..a92452e8 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java @@ -7,7 +7,7 @@ import de.bsi.secvisogram.csaf_cms_backend.PostgreSQLExtension; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.db.DatabaseException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateAdvisoryRequest; @@ -88,6 +88,7 @@ public void getAdvisoryInformationsTest_csafAcknowledgmentsNames() throws IOExce @WithMockUser(username = "editor", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) public void getAdvisoryInformationsTest_vulnerabilitiesCve() throws IOException, CsafException { + this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("title1"))); this.advisoryService.addAdvisory(csafToRequest(csafVulnerabilitiesCve("CVE-2021-44228"))); IdAndRevision idRev2 = this.advisoryService.addAdvisory(csafToRequest(csafVulnerabilitiesCve("CVE-2021-44999"))); List infos = diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java index edca3d36..c4ff8505 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java @@ -2,8 +2,8 @@ import de.bsi.secvisogram.csaf_cms_backend.PostgreSQLExtension; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.IdNotFoundException; +import de.bsi.secvisogram.csaf_cms_backend.db.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.db.IdNotFoundException; import de.bsi.secvisogram.csaf_cms_backend.entity.AuditTrailDocumentEntity; import de.bsi.secvisogram.csaf_cms_backend.entity.AuditTrailWorkflowEntity; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; @@ -328,7 +328,7 @@ public void deleteAdvisoryTest_notFound() { @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) - public void deleteAdvisoryTest_badRevision() throws IOException, DatabaseException, CsafException { + public void deleteAdvisoryTest_badRevision() throws IOException, CsafException { IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJson)); CsafException ex = assertThrows(CsafException.class, () -> advisoryService.deleteAdvisory(idRev.getId(), "not-the-real-revision")); @@ -425,7 +425,7 @@ public void updateAdvisoryTest() throws IOException, DatabaseException, CsafExce @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_AUTHOR}) - public void updateAdvisoryTest_badRevision() throws IOException, DatabaseException, CsafException { + public void updateAdvisoryTest_badRevision() throws IOException, CsafException { IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJson)); CreateAdvisoryRequest request = csafToRequest(csafJson); diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowIntegerVersioningTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowIntegerVersioningTest.java index f9cbc5d6..942bd302 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowIntegerVersioningTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowIntegerVersioningTest.java @@ -2,7 +2,7 @@ import de.bsi.secvisogram.csaf_cms_backend.PostgreSQLExtension; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.db.DatabaseException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateAdvisoryRequest; diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowSemanticVersioningTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowSemanticVersioningTest.java index b8351200..5318b504 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowSemanticVersioningTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowSemanticVersioningTest.java @@ -1,22 +1,8 @@ package de.bsi.secvisogram.csaf_cms_backend.service; -import static de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles.Role.AUTHOR; -import static de.bsi.secvisogram.csaf_cms_backend.fixture.CsafDocumentJsonCreator.*; -import static de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus.Draft; -import static de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus.Final; -import static java.util.Collections.singletonList; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; - -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.json.JsonMapper; -import tools.jackson.databind.node.ObjectNode; import de.bsi.secvisogram.csaf_cms_backend.PostgreSQLExtension; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; -import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.db.DatabaseException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateAdvisoryRequest; @@ -24,13 +10,6 @@ import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryResponse; import de.bsi.secvisogram.csaf_cms_backend.validator.ValidatorServiceClient; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.io.IOException; -import java.io.InputStream; -import java.time.Instant; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -48,6 +27,29 @@ import org.springframework.security.web.authentication.switchuser.SwitchUserGrantedAuthority; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles.Role.AUTHOR; +import static de.bsi.secvisogram.csaf_cms_backend.fixture.CsafDocumentJsonCreator.*; +import static de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus.Draft; +import static de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus.Final; +import static java.time.temporal.ChronoUnit.MILLIS; +import static java.util.Collections.singletonList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; /** * Test for the workflow in the Advisory service. The required PostgreSQL container is started in the PostgreSQLExtension. @@ -153,6 +155,10 @@ public void workflowTest() throws IOException, DatabaseException, CsafException revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.RfPublication, null, null); revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Published, null, null); + AdvisoryResponse publishedAdvisory = advisoryService.getAdvisory(idRev.getId()); + String publishedTrackingId = publishedAdvisory.getCsaf().at("/document/tracking/id").asString(); + String publishedReleaseDate = publishedAdvisory.getCsaf() + .at("/document/tracking/current_release_date").asString(); advisoryService.createNewCsafDocumentVersion(idRev.getId(), revision); List advisories = advisoryService.getAdvisoryInformations(null); assertThat(advisories.size(), is(1)); @@ -166,6 +172,15 @@ public void workflowTest() throws IOException, DatabaseException, CsafException // only auditor can see all versions List advisoriesAuditor = advisoryService.getAdvisoryInformations(null); assertThat(advisoriesAuditor.size(), is(2)); + AdvisoryInformationResponse version = advisoriesAuditor.stream() + .filter(advisory -> WorkflowState.Published == advisory.getWorkflowState()) + .filter(advisory -> publishedTrackingId.equals(advisory.getDocumentTrackingId())) + .findFirst() + .orElseThrow(); + assertThat(version.getTitle(), is("UpdatedTitle")); + assertThat(version.getDocumentTrackingId(), is(publishedTrackingId)); + assertThat(Instant.parse(version.getCurrentReleaseDate()).truncatedTo(MILLIS), + is(Instant.parse(publishedReleaseDate).truncatedTo(MILLIS))); } } diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryServiceTest.java index b11173e6..5ae9d692 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryServiceTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/PostgresRepositoryServiceTest.java @@ -1,6 +1,7 @@ package de.bsi.secvisogram.csaf_cms_backend.service; import de.bsi.secvisogram.csaf_cms_backend.entity.AdvisoryEntity; +import de.bsi.secvisogram.csaf_cms_backend.entity.AdvisoryVersionEntity; import de.bsi.secvisogram.csaf_cms_backend.repository.*; import org.junit.jupiter.api.Test; import tools.jackson.databind.json.JsonMapper; @@ -52,7 +53,8 @@ void saveAdvisoryUpdatesDenormalizedListColumns() { "document": { "title": "Updated title", "tracking": { - "id": "CSAF-2026-0002" + "id": "CSAF-2026-0002", + "current_release_date": " " } } } @@ -64,4 +66,54 @@ void saveAdvisoryUpdatesDenormalizedListColumns() { assertEquals("Updated title", entity.getDocumentTitle()); assertNull(entity.getCurrentReleaseDate()); } -} \ No newline at end of file + + @Test + void saveAdvisoryVersionUpdatesDenormalizedListColumns() { + AdvisoryVersionRepository advisoryVersionRepository = mock(AdvisoryVersionRepository.class); + when(advisoryVersionRepository.save(any(AdvisoryVersionEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + PostgresRepositoryService service = new PostgresRepositoryService( + mock(AdvisoryRepository.class), + advisoryVersionRepository, + mock(AuditTrailDocumentRepository.class), + mock(AuditTrailWorkflowRepository.class), + mock(CommentRepository.class), + mock(AuditTrailCommentRepository.class), + mock(CounterRepository.class)); + + AdvisoryVersionEntity entity = new AdvisoryVersionEntity(); + entity.setCsaf(new JsonMapper().readTree(""" + { + "document": { + "title": "Snapshot title", + "tracking": { + "id": "CSAF-2026-0003", + "current_release_date": "2026-10-13T06:00:00Z" + } + } + } + """)); + + service.saveAdvisoryVersion(entity); + + assertEquals("CSAF-2026-0003", entity.getTrackingId()); + assertEquals("Snapshot title", entity.getDocumentTitle()); + assertEquals(Instant.parse("2026-10-13T06:00:00Z"), entity.getCurrentReleaseDate()); + + entity.setCsaf(new JsonMapper().readTree(""" + { + "document": { + "title": "Snapshot title", + "tracking": { + "id": "CSAF-2026-0003", + "current_release_date": "not-a-date" + } + } + } + """)); + + service.saveAdvisoryVersion(entity); + + assertNull(entity.getCurrentReleaseDate()); + } +} diff --git a/src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/exxcellent-2021AB123.json b/src/test/resources/de/bsi/secvisogram/csaf_cms_backend/db/exxcellent-2021AB123.json similarity index 100% rename from src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/exxcellent-2021AB123.json rename to src/test/resources/de/bsi/secvisogram/csaf_cms_backend/db/exxcellent-2021AB123.json diff --git a/src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/exxcellent-2022CC.json b/src/test/resources/de/bsi/secvisogram/csaf_cms_backend/db/exxcellent-2022CC.json similarity index 100% rename from src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/exxcellent-2022CC.json rename to src/test/resources/de/bsi/secvisogram/csaf_cms_backend/db/exxcellent-2022CC.json diff --git a/src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/model/template/allTemplates.json b/src/test/resources/de/bsi/secvisogram/csaf_cms_backend/db/model/template/allTemplates.json similarity index 100% rename from src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/model/template/allTemplates.json rename to src/test/resources/de/bsi/secvisogram/csaf_cms_backend/db/model/template/allTemplates.json diff --git a/src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/model/template/template1.json b/src/test/resources/de/bsi/secvisogram/csaf_cms_backend/db/model/template/template1.json similarity index 100% rename from src/test/resources/de/bsi/secvisogram/csaf_cms_backend/couchdb/model/template/template1.json rename to src/test/resources/de/bsi/secvisogram/csaf_cms_backend/db/model/template/template1.json From 167863df28499dcd287d3b15dc4f539cc1e07008 Mon Sep 17 00:00:00 2001 From: rschneider <97682836+rainer-exxcellent@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:22:42 +0200 Subject: [PATCH 4/5] feat(postgres): #226 fix bug in AdvisoryListQueryService --- .../service/AdvisoryListQueryService.java | 50 +++++++++---- .../service/AdvisorySearchUtilTest.java | 70 +++++++++++++++++-- 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java index 69941fb4..9f309e8d 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java @@ -135,20 +135,23 @@ private String compileOperator(OperatorExpression expression) { } String path = jsonPath(selector, offset); String comparisonPath = path + " ? (" + predicate(expression) + ")"; - return "jsonb_path_exists(csaf, CAST(" + bindText(comparisonPath) - + " AS jsonpath), jsonb_build_object('value', to_jsonb(" + bindValue(expression) + ")))"; + String jsonPath = "jsonb_path_exists(csaf, CAST(" + bindText(comparisonPath) + " AS jsonpath)"; + if (expression.getOperatorType() == TypeOfOperator.ContainsIgnoreCase) { + return jsonPath + ")"; + } + return jsonPath + ", jsonb_build_object('value', to_jsonb(" + bindValue(expression) + ")))"; } private String columnComparison(String column, OperatorExpression expression) { - String value = bindValue(expression); return switch (expression.getOperatorType()) { - case Equal -> column + " = " + value; - case NotEqual -> column + " <> " + value; - case Greater -> column + " > " + value; - case GreaterOrEqual -> column + " >= " + value; - case Less -> column + " < " + value; - case LessOrEqual -> column + " <= " + value; - case ContainsIgnoreCase -> "LOWER(" + column + ") LIKE '%' || LOWER(" + value + ") || '%'"; + case Equal -> column + " = " + bindValue(expression); + case NotEqual -> column + " <> " + bindValue(expression); + case Greater -> column + " > " + bindValue(expression); + case GreaterOrEqual -> column + " >= " + bindValue(expression); + case Less -> column + " < " + bindValue(expression); + case LessOrEqual -> column + " <= " + bindValue(expression); + case ContainsIgnoreCase -> "LOWER(" + column + ") LIKE '%' || LOWER(" + + bindText(escapeLike(expression.getValue())) + ") || '%' ESCAPE E'\\\\'"; }; } @@ -160,7 +163,8 @@ private String predicate(OperatorExpression expression) { case GreaterOrEqual -> "@ >= $value"; case Less -> "@ < $value"; case LessOrEqual -> "@ <= $value"; - case ContainsIgnoreCase -> "@ like_regex $value flag \"i\""; + case ContainsIgnoreCase -> "@ like_regex " + jsonPathStringLiteral( + ".*" + escapeRegex(expression.getValue()) + ".*") + " flag \"i\""; }; } @@ -176,12 +180,30 @@ private String bindValue(OperatorExpression expression) { return switch (expression.getValueType()) { case Decimal -> bindText(expression.getValue()) + "::numeric"; case Boolean -> bindText(expression.getValue()) + "::boolean"; - case Text -> bindText(expression.getOperatorType() == TypeOfOperator.ContainsIgnoreCase - ? ".*" + expression.getValue().replace("\\", "\\\\").replace(".", "\\.") + ".*" - : expression.getValue()); + case Text -> bindText(expression.getValue()); }; } + private String jsonPathStringLiteral(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private String escapeRegex(String value) { + StringBuilder escaped = new StringBuilder(); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if ("\\\\.^$|?*+()[]{}".indexOf(character) >= 0) { + escaped.append('\\'); + } + escaped.append(character); + } + return escaped.toString(); + } + + private String escapeLike(String value) { + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } + private String bindText(Object value) { values.add(value); return ":p" + values.size(); diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java index a92452e8..5213e45c 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySearchUtilTest.java @@ -10,6 +10,7 @@ import de.bsi.secvisogram.csaf_cms_backend.db.DatabaseException; import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; +import de.bsi.secvisogram.csaf_cms_backend.model.filter.OperatorExpression; import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateAdvisoryRequest; import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryInformationResponse; import java.io.IOException; @@ -28,6 +29,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import de.bsi.secvisogram.csaf_cms_backend.validator.ValidatorServiceClient; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; @SpringBootTest(properties = { @@ -38,6 +40,8 @@ @SpringJUnitConfig public class AdvisorySearchUtilTest { + private static final String[] TITLE_SELECTOR = {"csaf", "document", "title"}; + @Autowired private AdvisoryService advisoryService; @@ -124,6 +128,61 @@ public void getAdvisoryInformationsTest_csafProductTreeBranchesCategory() throws assertThat(ids, hasItems(idRev2.getId())); } + + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) + public void getAdvisoryInformationsTest_equalOnCsafTitle() throws IOException, CsafException { + addAdvisoryWithTitle("Hello World"); + addAdvisoryWithTitle("Other"); + + assertEquals(1, getAdvisoryInformationsForFilter(OperatorExpression.equal("Hello World", TITLE_SELECTOR)).size()); + } + + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) + public void getAdvisoryInformationsTest_equalOnOwnerColumn() throws IOException, CsafException { + addAdvisoryWithTitle("Hello World"); + + assertEquals(1, getAdvisoryInformationsForFilter(OperatorExpression.equal("author1", "owner")).size()); + } + + // ---- contains ignore case ------------------------------------------------------------------------------------ + + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) + public void getAdvisoryInformationsTest_containsIgnoreCaseOnCsafTitle() throws IOException, CsafException { + addAdvisoryWithTitle("Hello World"); + addAdvisoryWithTitle("Other"); + + assertEquals(1, getAdvisoryInformationsForFilter(OperatorExpression.containsIgnoreCase("hello", TITLE_SELECTOR)).size()); + } + + /** Regex metacharacters in the search text must be matched literally. */ + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) + public void getAdvisoryInformationsTest_containsIgnoreCaseOnCsafTitle_specialCharacters() throws IOException, CsafException { + addAdvisoryWithTitle("Fix (urgent) now"); + addAdvisoryWithTitle("Fix urgent now"); + + assertEquals(1, getAdvisoryInformationsForFilter(OperatorExpression.containsIgnoreCase("(urgent)", TITLE_SELECTOR)).size()); + } + + @Test + @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) + public void getAdvisoryInformationsTest_containsIgnoreCaseOnOwnerColumn() throws IOException, CsafException { + addAdvisoryWithTitle("Hello World"); + + assertEquals(1, getAdvisoryInformationsForFilter(OperatorExpression.containsIgnoreCase("THOR", "owner")).size()); + } + + private void addAdvisoryWithTitle(String title) throws IOException, CsafException { + advisoryService.addAdvisory(csafToRequest(csafJsonTitle(title))); + } + + private List getAdvisoryInformationsForFilter(OperatorExpression filter) throws IOException, CsafException { + String json = filter == null ? null : AdvisorySearchUtil.expression2Json(filter); + return advisoryService.getAdvisoryInformations(json); + } // ----------------------------------------------------------------------- // Role-visibility tests // ----------------------------------------------------------------------- @@ -135,7 +194,7 @@ public void getAdvisoryInformationsTest_csafProductTreeBranchesCategory() throws @Test @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR}) - public void getAdvisoryInformationsTest_roleEditor_seesAll() throws IOException, CsafException, DatabaseException { + public void getAdvisoryInformationsTest_roleEditor_seesAll() throws IOException, CsafException { IdAndRevision idRev1 = this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("editorTitle1"))); IdAndRevision idRev2 = this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("editorTitle2"))); @@ -154,7 +213,7 @@ public void getAdvisoryInformationsTest_roleEditor_seesAll() throws IOException, @Test @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR}) - public void getAdvisoryInformationsTest_roleEditor_filterByTitle() throws IOException, CsafException, DatabaseException { + public void getAdvisoryInformationsTest_roleEditor_filterByTitle() throws IOException, CsafException { IdAndRevision idRev1 = this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("editorFilterTitle1"))); this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("editorFilterTitle2"))); @@ -173,7 +232,7 @@ public void getAdvisoryInformationsTest_roleEditor_filterByTitle() throws IOExce */ @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) - public void getAdvisoryInformationsTest_roleAuditor_seesAll() throws IOException, CsafException, DatabaseException { + public void getAdvisoryInformationsTest_roleAuditor_seesAll() throws IOException, CsafException { // Create advisories as an author IdAndRevision idRev1 = this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("auditorTitle1"))); @@ -199,7 +258,7 @@ public void getAdvisoryInformationsTest_roleAuditor_seesAll() throws IOException */ @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) - public void getAdvisoryInformationsTest_roleAuditor_filterByTitle() throws IOException, CsafException, DatabaseException { + public void getAdvisoryInformationsTest_roleAuditor_filterByTitle() throws IOException, CsafException { // Create advisories as an author IdAndRevision idRev1 = this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("auditorFilterTitle1"))); @@ -229,7 +288,6 @@ public void getAdvisoryInformationsTest_roleAuditor_filterByTitle() throws IOExc @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR}) public void getAdvisoryInformationsTest_roleReviewer_seesOnlyReviewState() throws IOException, CsafException, DatabaseException { - IdAndRevision idRevDraft = this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("reviewerDraftTitle"))); IdAndRevision idRevReview = this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("reviewerReviewTitle"))); // Move the second advisory to Review state this.advisoryService.changeAdvisoryWorkflowState( @@ -315,7 +373,7 @@ public void getAdvisoryInformationsTest_rolePublisher_seesApprovedAndDraft() thr @Test @WithMockUser(username = "author1", authorities = {CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER}) - public void getAdvisoryInformationsTest_rolePublisher_filterByTitle() throws IOException, CsafException, DatabaseException { + public void getAdvisoryInformationsTest_rolePublisher_filterByTitle() throws IOException, CsafException { IdAndRevision idRevDraft1 = this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("publisherFilterTitle1"))); this.advisoryService.addAdvisory(csafToRequest(csafJsonTitle("publisherFilterTitle2"))); From f56ce909adde3260b55698de6be1e611dda89c7e Mon Sep 17 00:00:00 2001 From: rschneider <97682836+rainer-exxcellent@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:46:17 +0200 Subject: [PATCH 5/5] feat(postgres): #226 changed logging informations to level debug --- .../service/AdvisoryListQueryService.java | 9 ++++----- .../csaf_cms_backend/service/AdvisoryService.java | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java index 9f309e8d..1762ff9e 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryListQueryService.java @@ -23,10 +23,9 @@ public class AdvisoryListQueryService { private EntityManager entityManager; public List findAdvisories(Expression filter, Authentication credentials) { - LOG.info("Start getAdvisoryInformations"); + LOG.debug("Start getAdvisoryInformations"); SqlExpressionCompiler compiler = new SqlExpressionCompiler(); String compiledExpr = compiler.compile(filter); - LOG.info("Compiled expression: {}", compiledExpr); String where = visibilityClause(credentials, compiler) + " AND " + compiledExpr; String sql = """ SELECT id, workflow_state, owner, version, @@ -38,13 +37,13 @@ public List findAdvisories(Expression filter, Authentica ORDER BY id """; - LOG.info("Start getAdvisoryInformations - createNativeQuery"); + LOG.debug("Start getAdvisoryInformations - createNativeQuery"); var query = entityManager.createNativeQuery(sql); compiler.bind(query); - LOG.info("Start getAdvisoryInformations - getResultList"); + LOG.debug("Start getAdvisoryInformations - getResultList"); @SuppressWarnings("unchecked") List rows = query.getResultList(); - LOG.info("Start getAdvisoryInformations - stream"); + LOG.debug("Start getAdvisoryInformations - stream"); return rows.stream().map(row -> new AdvisoryListProjection( row[0].toString(), (String) row[1], (String) row[2], ((Number) row[3]).longValue(), (String) row[4], (String) row[5], row[6] == null ? null : row[6].toString())).toList(); diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java index 06a9a733..8e36812c 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java @@ -142,7 +142,7 @@ public long getDocumentCount() { public List getAdvisoryInformations(String expression) throws IOException, CsafException { - LOG.info("Start getAdvisoryInformations"); + LOG.debug("Start getAdvisoryInformations"); Authentication credentials = getAuthentication(); Expression parsedExpr = parseFilterExpression(expression); @@ -162,7 +162,7 @@ public List getAdvisoryInformations(String expressi } allResponses.addAll(allAdvisoryVersions); } - LOG.info("End getAdvisoryInformations - readAllAdvisories"); + LOG.debug("End getAdvisoryInformations - readAllAdvisories"); return allResponses; }