diff --git a/CHANGELOG.md b/CHANGELOG.md index 19ded8e..a722b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ## [Unreleased] +### Changed: UI API 1.6.0 +- UI API extension secured-data 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, and retained SecuredDataErrorV1 schema deprecated since 1.1.0. + ### Changed — direct core field access and audit protection - Remove all #1172/#1182 stand-ins and reflective bridges; use core field-access, classified-value token, audit protection, wipe flag and shredded-event APIs directly. - Register the resolver, key source and audit protector through core supplemental metadata. diff --git a/README.md b/README.md index 88550a5..973031d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Phases (b), (c), (e) and (f) of [SECURED-DATA](https://github.com/QRun-IO/qqq/bl ## Compatibility -Java 21, Maven wrapper 3.9.11, and **QQQ 4.1.0-RC.7** or later (it ships field access #1172 and audit value protection #1182). +Java 21, Maven wrapper 3.9.11, and **QQQ 4.1.0-RC.15** or later (UI API 1.6.0, field access #1172 and audit value protection #1182). Core classifications, AI metadata, client channels, table-feature permissions and UI API extension points are used directly. The UI API and its contract test are part of the main build; the contract test calls `UiApiContract` directly. @@ -121,7 +121,7 @@ Configure `.withSchedulerName("yourScheduler")` to register the hidden `secured_ ## UI API extension -The `secured-data` extension is version **1.0.0**: +The `secured-data` extension is version **1.1.0**: - `POST /qqq/v1/qbit/secured-data/reveal` - `GET /qqq/v1/qbit/secured-data/reveal/options?tableName=&fieldName=` diff --git a/pom.xml b/pom.xml index b6b548a..25142e6 100644 --- a/pom.xml +++ b/pom.xml @@ -22,8 +22,8 @@ - - 4.1.0-RC.7 + + 4.1.0-RC.15 0.1.0-SNAPSHOT 21 21 diff --git a/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataExtension.java b/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataExtension.java index 4ef4755..9aaaafe 100644 --- a/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataExtension.java +++ b/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataExtension.java @@ -37,11 +37,11 @@ public final class SecuredDataExtension } /******************************************************************************* - ** The secured-data UI API, version 1.0.0. + ** The secured-data UI API, version 1.1.0. *******************************************************************************/ public static MiddlewareSpecExtension defineExtension() { - return (new MiddlewareSpecExtension().withName("secured-data").withSemanticVersion("1.0.0") + return (new MiddlewareSpecExtension().withName("secured-data").withSemanticVersion("1.1.0") .withDescription("Reveal grants and break-glass status") .withSpec(SecuredDataRevealSpecV1.class).withSpec(SecuredDataRevealOptionsSpecV1.class).withSpec(SecuredDataStatusSpecV1.class)); } diff --git a/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataRevealOptionsSpecV1.java b/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataRevealOptionsSpecV1.java index 87c041b..8549241 100644 --- a/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataRevealOptionsSpecV1.java +++ b/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataRevealOptionsSpecV1.java @@ -41,7 +41,7 @@ public class SecuredDataRevealOptionsSpecV1 extends AbstractJsonEndpointSpec public BasicOperation defineBasicOperation() { return (new BasicOperation().withPath("/qbit/secured-data/reveal/options").withHttpMethod(HttpMethod.GET) - .withTag(() -> "Secured data").withShortSummary("RevealOptions")); + .withTag(() -> "secured-data").withShortSummary("RevealOptions")); } /******************************************************************************* diff --git a/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataRevealSpecV1.java b/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataRevealSpecV1.java index aa0b467..37fef08 100644 --- a/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataRevealSpecV1.java +++ b/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataRevealSpecV1.java @@ -28,6 +28,7 @@ import com.kingsrook.qbits.secureddata.reveal.RevealService; import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation; import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec; +import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1; import com.kingsrook.qqq.openapi.model.HttpMethod; import com.kingsrook.qqq.openapi.model.Parameter; import com.kingsrook.qqq.openapi.model.Schema; @@ -44,7 +45,7 @@ public class SecuredDataRevealSpecV1 extends AbstractJsonEndpointSpec public BasicOperation defineBasicOperation() { return (new BasicOperation().withPath("/qbit/secured-data/reveal").withHttpMethod(HttpMethod.POST) - .withTag(() -> "Secured data").withShortSummary("Reveal")); + .withTag(() -> "secured-data").withShortSummary("Reveal")); } /******************************************************************************* @@ -81,10 +82,10 @@ public class SecuredDataRevealSpecV1 extends AbstractJsonEndpointSpec @Override public List defineAdditionalBasicResponses() { - return (List.of(new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.BAD_REQUEST, "Invalid reveal request", "SecuredDataErrorV1"), - new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.FORBIDDEN, "Reveal denied", "SecuredDataErrorV1"), - new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.NOT_FOUND, "Record not found or locked", "SecuredDataErrorV1"), - new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.TOO_MANY_REQUESTS, "Daily reveal cap reached", "SecuredDataErrorV1"))); + return (List.of(new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.BAD_REQUEST, "Invalid reveal request", BasicErrorResponseV1.class.getSimpleName()), + new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.FORBIDDEN, "Reveal denied", BasicErrorResponseV1.class.getSimpleName()), + new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.NOT_FOUND, "Record not found or locked", BasicErrorResponseV1.class.getSimpleName()), + new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.TOO_MANY_REQUESTS, "Daily reveal cap reached", BasicErrorResponseV1.class.getSimpleName()))); } /******************************************************************************* diff --git a/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataSchemas.java b/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataSchemas.java index 152dc0d..3634439 100644 --- a/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataSchemas.java +++ b/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataSchemas.java @@ -58,7 +58,8 @@ public final class SecuredDataSchemas schemas.put("SecuredDataRevealAllowanceV1", object("tableName", type(Type.STRING), "fieldName", type(Type.STRING), "remainingDailyAllowance", type(Type.INTEGER).withNullable(true))); schemas.put("SecuredDataStatusResponseV1", object("breakGlass", new Schema().withRefToSchema("SecuredDataBreakGlassV1").withNullable(true), "revealsUsedToday", type(Type.INTEGER), "revealAllowances", type(Type.ARRAY).withItems(new Schema().withRefToSchema("SecuredDataRevealAllowanceV1")))); - schemas.put("SecuredDataErrorV1", object("error", type(Type.STRING))); + schemas.put("SecuredDataErrorV1", object("error", type(Type.STRING)) + .withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.")); return (schemas); } diff --git a/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataStatusSpecV1.java b/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataStatusSpecV1.java index b858f43..02373d8 100644 --- a/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataStatusSpecV1.java +++ b/src/main/java/com/kingsrook/qbits/secureddata/uiapi/SecuredDataStatusSpecV1.java @@ -41,7 +41,7 @@ public class SecuredDataStatusSpecV1 extends AbstractJsonEndpointSpec public BasicOperation defineBasicOperation() { return (new BasicOperation().withPath("/qbit/secured-data/status").withHttpMethod(HttpMethod.GET) - .withTag(() -> "Secured data").withShortSummary("Status")); + .withTag(() -> "secured-data").withShortSummary("Status")); } /******************************************************************************* diff --git a/src/main/resources/openapi/qqq-v1/secured-data.yaml b/src/main/resources/openapi/qqq-v1/secured-data.yaml index bb9a63d..85f4c65 100644 --- a/src/main/resources/openapi/qqq-v1/secured-data.yaml +++ b/src/main/resources/openapi/qqq-v1/secured-data.yaml @@ -1,5 +1,44 @@ components: schemas: + BasicErrorResponseV1: + description: "The error body of every UI API operation (core and extensions)." + properties: + error: + description: "The error message (the same as message; kept for clients of\ + \ UI API 1.0 to 1.5)." + type: "string" + code: + description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\ + \ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\ + \ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\ + \ Extensions may add codes; treat an unknown code by the HTTP status.\ + \ Since 1.6.0." + type: "string" + message: + description: "A message to show the user. Since 1.6.0." + type: "string" + fieldErrors: + description: "Errors about particular request fields, when known. Since\ + \ 1.6.0." + items: + properties: + field: + description: "The field (or parameter) name." + type: "string" + message: + description: "The error about that field." + type: "string" + code: + description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\ + \ PATTERN." + type: "string" + type: "object" + type: "array" + traceId: + description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\ + \ response header); quote it when reporting a problem. Since 1.6.0." + type: "string" + type: "object" SecuredDataBreakGlassV1: properties: id: @@ -16,6 +55,7 @@ components: type: "string" type: "object" SecuredDataErrorV1: + description: "Deprecated since 1.1.0: errors use BasicErrorResponseV1." properties: error: type: "string" @@ -96,19 +136,53 @@ components: type: "object" securitySchemes: sessionUuidCookie: + description: "The session cookie set by POST /qqq/v1/manageSession (web clients)." in: "cookie" name: "sessionUUID" type: "apiKey" + bearerAuth: + bearerFormat: "JWT or session UUID" + description: "`Authorization: Bearer `, where the token is either\n-\ + \ an OAuth2 access token (a signed JWT) issued by the app's identity provider\ + \ to the app's client,\n or to its native client (`values.nativeClientId`\ + \ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\ + \ grant or auth-code + PKCE. It is verified against the\n provider's keys\ + \ (issuer, audience, expiry, signature) and gives the same user and permissions\n\ + \ as signing in on the web. When it expires (401), refresh it at the provider\ + \ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\ + \ which the server keeps refreshed." + scheme: "bearer" + type: "http" + oauth2: + description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\ + \ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\ + \ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\ + \ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\ + \ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\ + \ grant (`device_authorization_endpoint`, RFC 8628), then send the access\ + \ token\nas `Authorization: Bearer`." + flows: + authorizationCode: + authorizationUrl: "/authorize" + scopes: + email: "Email address" + offline_access: "A refresh token, to renew the access token without signing\ + \ in again" + openid: "OpenID Connect sign-in" + profile: "Name, username and groups (permissions come from the groups)" + tokenUrl: "/token" + type: "oauth2" info: contact: email: "contact@kingsrook.com" description: "Reveal grants and break-glass status" title: "QQQ Middleware API extension: secured-data" - version: "1.0.0" + version: "1.1.0" openapi: "3.0.3" paths: /qqq/v1/qbit/secured-data/reveal: post: + operationId: "postQbitSecuredDataReveal" requestBody: content: application/json: @@ -120,19 +194,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SecuredDataErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Invalid reveal request" 403: content: application/json: schema: - $ref: "#/components/schemas/SecuredDataErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Reveal denied" 404: content: application/json: schema: - $ref: "#/components/schemas/SecuredDataErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Record not found or locked" 200: content: @@ -144,16 +218,37 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SecuredDataErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Daily reveal cap reached" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Not authenticated, or the session or token is invalid or expired\ + \ (code UNAUTHENTICATED). Sign in again, or refresh the token." + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Conflict with the current state, e.g. an edit conflict or\ + \ an invalid transition (code CONFLICT)." security: - sessionUuidCookie: - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" summary: "Reveal" tags: - - "Secured data" + - "secured-data" /qqq/v1/qbit/secured-data/reveal/options: get: + operationId: "getQbitSecuredDataRevealOptions" parameters: - in: "query" name: "tableName" @@ -172,14 +267,61 @@ paths: schema: $ref: "#/components/schemas/SecuredDataRevealOptionsResponseV1" description: "Successful Response" + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bad request: a parameter or the body is invalid (code BAD_REQUEST);\ + \ fieldErrors may name the fields." + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Not authenticated, or the session or token is invalid or expired\ + \ (code UNAUTHENTICATED). Sign in again, or refresh the token." + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "The user (or the credential's scope) may not do this (code\ + \ PERMISSION_DENIED)." + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Not found, or not visible to this user (code NOT_FOUND)." + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Conflict with the current state, e.g. an edit conflict or\ + \ an invalid transition (code CONFLICT)." + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Too many requests (code RATE_LIMITED); retry later." security: - sessionUuidCookie: - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" summary: "RevealOptions" tags: - - "Secured data" + - "secured-data" /qqq/v1/qbit/secured-data/status: get: + operationId: "getQbitSecuredDataStatus" responses: 200: content: @@ -187,9 +329,61 @@ paths: schema: $ref: "#/components/schemas/SecuredDataStatusResponseV1" description: "Successful Response" + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Bad request: a parameter or the body is invalid (code BAD_REQUEST);\ + \ fieldErrors may name the fields." + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Not authenticated, or the session or token is invalid or expired\ + \ (code UNAUTHENTICATED). Sign in again, or refresh the token." + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "The user (or the credential's scope) may not do this (code\ + \ PERMISSION_DENIED)." + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Not found, or not visible to this user (code NOT_FOUND)." + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Conflict with the current state, e.g. an edit conflict or\ + \ an invalid transition (code CONFLICT)." + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Too many requests (code RATE_LIMITED); retry later." security: - sessionUuidCookie: - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" summary: "Status" tags: - - "Secured data" + - "secured-data" +servers: +- description: "The QQQ server that serves this document (paths include /qqq/v1)." + url: "/" +tags: +- description: "Operations of the secured-data extension." + name: "secured-data" diff --git a/src/test/java/com/kingsrook/qbits/secureddata/RetentionProofTest.java b/src/test/java/com/kingsrook/qbits/secureddata/RetentionProofTest.java index 682a1d8..7c942db 100644 --- a/src/test/java/com/kingsrook/qbits/secureddata/RetentionProofTest.java +++ b/src/test/java/com/kingsrook/qbits/secureddata/RetentionProofTest.java @@ -84,13 +84,23 @@ class RetentionProofTest + private static final String CANARY_PREFIX = "CANARY-PHI-"; + private static final int CANARY_RANDOM = 24; + private static final int MIN_LEAK_FRAGMENT = 12; + + + /******************************************************************************* - ** Letters g-v only, so a marker can never coincide with hex digests or tokens. + ** A fixed prefix and 24 random letters g-v (so it never coincides with hex + ** digests). Leaks are matched on the whole marker or on 12-letter windows of + ** its random part: 16^12 possibilities, so ordinary text, JSON words or + ** base64 tokens cannot match by chance, while a truncated or re-encoded + ** fragment of a real leak still does. *******************************************************************************/ private static String canary() { - StringBuilder marker = new StringBuilder("CANARY-PHI-"); - for(int i = 0; i < 16; i++) + StringBuilder marker = new StringBuilder(CANARY_PREFIX); + for(int i = 0; i < CANARY_RANDOM; i++) { marker.append((char) ('g' + RANDOM.nextInt(16))); } @@ -100,21 +110,25 @@ class RetentionProofTest /******************************************************************************* - ** The marker, or any substring of it longer than four characters. + ** The whole marker, or any MIN_LEAK_FRAGMENT-letter window of its random part. *******************************************************************************/ private static List leaks(String haystack, String... markers) { List found = new ArrayList<>(); for(String marker : markers) { - for(int length = 5; length <= marker.length(); length++) + if(haystack.contains(marker)) { - for(int start = 0; start + length <= marker.length(); start++) + found.add("whole marker"); + continue; + } + + String random = marker.substring(CANARY_PREFIX.length()); + for(int start = 0; start + MIN_LEAK_FRAGMENT <= random.length(); start++) + { + if(haystack.contains(random.substring(start, start + MIN_LEAK_FRAGMENT))) { - if(haystack.contains(marker.substring(start, start + length))) - { - found.add("length " + length + " at " + start); - } + found.add("fragment at " + start); } } } @@ -123,6 +137,23 @@ class RetentionProofTest + /******************************************************************************* + ** The leak scan: whole markers and 12-letter fragments are found; the shared + ** prefix and ordinary words made of the same letters are not. + *******************************************************************************/ + @Test + void leakScanHasNoFalsePositives() + { + String marker = canary(); + String random = marker.substring(CANARY_PREFIX.length()); + assertThat(leaks("x" + marker + "x", marker)).containsExactly("whole marker"); + assertThat(leaks("{\"v\":\"" + random.substring(5, 5 + MIN_LEAK_FRAGMENT) + "\"}", marker)).isNotEmpty(); + assertThat(leaks(CANARY_PREFIX + " options joins strings rows points", marker)).isEmpty(); + assertThat(leaks(random.substring(0, MIN_LEAK_FRAGMENT - 1), marker)).isEmpty(); + } + + + /******************************************************************************* ** Canary through CRUD, a failing validation, a wipe run, a masked export and ** the access log: it may appear only in the host table itself. diff --git a/src/test/java/com/kingsrook/qbits/secureddata/SecuredDataHttpTest.java b/src/test/java/com/kingsrook/qbits/secureddata/SecuredDataHttpTest.java index 640d0b4..4368a48 100644 --- a/src/test/java/com/kingsrook/qbits/secureddata/SecuredDataHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/secureddata/SecuredDataHttpTest.java @@ -109,11 +109,25 @@ class SecuredDataHttpTest var status = request("/qbit/secured-data/status", null, false); assertThat(status.statusCode()).as(status.body()).isEqualTo(200); assertThat(new JSONObject(status.body()).getInt("revealsUsedToday")).isEqualTo(1); - assertThat(request("/qbit/secured-data/reveal", body, false).statusCode()).isEqualTo(429); - assertThat(request("/qbit/secured-data/reveal", body, true).statusCode()).isEqualTo(403); - assertThat(request("/qbit/secured-data/reveal", "{}", false).statusCode()).isEqualTo(400); - assertThat(request("/qbit/secured-data/reveal", "{\"primaryKey\":1}", false).statusCode()).isEqualTo(400); - assertThat(request("/qbit/secured-data/reveal/options?tableName=person&fieldName=missing", null, false).statusCode()).isEqualTo(404); + assertErrorResponse(request("/qbit/secured-data/reveal", body, false), 429); + assertErrorResponse(request("/qbit/secured-data/reveal", body, true), 403); + assertErrorResponse(request("/qbit/secured-data/reveal", "{}", false), 400); + assertErrorResponse(request("/qbit/secured-data/reveal", "{\"primaryKey\":1}", false), 400); + assertErrorResponse(request("/qbit/secured-data/reveal/options?tableName=person&fieldName=missing", null, false), 404); + } + + /******************************************************************************* + ** Verify standard error bodies without changing endpoint status semantics. + *******************************************************************************/ + private void assertErrorResponse(HttpResponse response, int status) + { + assertThat(response.statusCode()).as(response.body()).isEqualTo(status); + JSONObject body = new JSONObject(response.body()); + assertThat(body.keySet()).contains("error", "code", "message", "traceId"); + assertThat(body.getString("error")).isNotBlank(); + assertThat(body.getString("code")).isNotBlank(); + assertThat(body.getString("message")).isNotBlank(); + assertThat(body.getString("traceId")).isNotBlank(); } @Test