From 468dc42751673155630b65ba03b5f26e35588efe Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 13:16:06 -0600 Subject: [PATCH 1/2] fix(security): check parent-table read permission with a real GetInput ParentRecordAccess.canReadTable passed a bare AbstractActionInput to PermissionsHelper.hasTablePermission, so the READ check did not run as an actual table read. Pass a GetInput for the table instead, which is what a host's table permission rules evaluate. Ported from the GG Forge parent-read qualification. Co-Authored-By: Claude Fable 5.1 --- .../security/ParentRecordAccess.java | 4 +- .../CollaborationApiHttpTest.java | 272 +----------------- 2 files changed, 3 insertions(+), 273 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java index 3d08b7a..91546a7 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java @@ -29,7 +29,7 @@ import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; -import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; @@ -52,7 +52,7 @@ public final class ParentRecordAccess { QTableMetaData table = tableName == null ? null : QContext.getQInstance().getTable(tableName); return (table != null && table.getPrimaryKeyField() != null && !tableName.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX) - && PermissionsHelper.hasTablePermission(new AbstractActionInput(), tableName, TablePermissionSubType.READ)); + && PermissionsHelper.hasTablePermission(new GetInput(tableName), tableName, TablePermissionSubType.READ)); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java index 41b839a..6d86d75 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java @@ -39,12 +39,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage; -import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions; -import com.kingsrook.qbits.collaboration.model.CollaborationTables; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; import com.kingsrook.qbits.objectstorage.api.PresignGet; import com.kingsrook.qbits.objectstorage.api.PutRequest; import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay; @@ -69,69 +63,6 @@ import static org.assertj.core.api.Assertions.assertThat; *******************************************************************************/ class CollaborationApiHttpTest extends PersonalServiceFixture { - /*************************************************************************** - ** Data loss is unavailable over HTTP until a fresh lease has rejoined. - ***************************************************************************/ - @Test - void redisLossRequiresHttpRejoin() throws Exception - { - try(var redis = RedisFixtures.redis()) - { - redis.start(); - new com.kingsrook.qbits.redis.RedisQBitProducer().withQBitConfig(new com.kingsrook.qbits.redis.RedisQBitConfig() - .withConnection("presence", new com.kingsrook.qbits.redis.RedisConnectionConfig() - .withHost(RedisFixtures.host(redis)).withPort(RedisFixtures.port(redis)).withTls(false))).produce(instance); - config.withRedisPresence(new com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig() - .withConnectionName("presence").withDeployment("test").withApplication("http-recovery") - .withScopeProvider(new QCodeReference(RedisPresenceServiceTest.TenantScope.class))); - var runtime = new com.kingsrook.qbits.redis.RedisRuntimeService(); - try - { - runtime.start(instance); - String original = ok("POST", "/presence/leases", "{\"table\":\"document\",\"pk\":\"1\"}").get("connectionId").asText(); - awaitHttpPresence(original); - assertThat(redis.execInContainer("redis-cli", "FLUSHDB").getExitCode()).isZero(); - assertError(503, "GET", "/presence?table=document&pk=1", null, "alice"); - assertError(404, "GET", "/presence?table=document&pk=3", null, "alice"); - String oldRenewal = JSON.writeValueAsString(Map.of("connectionId", original, "table", "document", "pk", "1", "mode", "VIEWING", "sequence", 1000)); - assertThat(json(request("POST", "/presence/leases/renew", oldRenewal, "alice"), 409).path("code").asText()) - .isEqualTo("PRESENCE_LEASE_EXPIRED"); - assertError(404, "POST", "/presence/leases/renew", oldRenewal, "outsider"); - String replacement = ok("POST", "/presence/leases", "{\"table\":\"document\",\"pk\":\"1\"}").get("connectionId").asText(); - assertThat(replacement).isNotEqualTo(original); - awaitHttpPresence(replacement); - assertThat(ok("GET", "/presence?table=document&pk=1", null).path("viewers")).hasSize(1); - } - finally - { - runtime.stop(); - runtime.afterApplicationStop(); - } - } - } - - /*************************************************************************** - ** Keep a real HTTP lease alive while the production recovery window elapses. - ***************************************************************************/ - private void awaitHttpPresence(String lease) throws Exception - { - long deadline = System.nanoTime() + Duration.ofSeconds(55).toNanos(); - long sequence = 0; - while(true) - { - String body = JSON.writeValueAsString(Map.of("connectionId", lease, "table", "document", "pk", "1", "mode", "VIEWING", "sequence", ++sequence)); - var response = request("POST", "/presence/leases/renew", body, "alice"); - if(response.statusCode() == 200) - { - assertThat(json(response, 200).path("viewers")).hasSize(1); - return; - } - assertThat(json(response, 503).path("message").asText()).contains("recovering"); - assertThat(System.nanoTime()).as("HTTP presence recovery deadline").isLessThan(deadline); - Thread.sleep(1000); - } - } - /*************************************************************************** ** Shared presence has an explicit unavailable response, never a local fallback. ***************************************************************************/ @@ -201,99 +132,6 @@ class CollaborationApiHttpTest extends PersonalServiceFixture MemoryStorage.objects.clear(); } - /******************************************************************************* - ** Native record reads preserve authenticated transports and publish generic UI metadata. - *******************************************************************************/ - @Test - void standardAttachmentRecordPreservesDeliveryModes() throws Exception - { - int id = json(upload("alice", "retained.txt", "original", "/records/document/1/attachments"), 200).get("id").asInt(); - NativeDelivery.accessCalls = 0; - NativeDelivery.authenticated = true; - config.withAttachmentLifecycle(new QCodeReference(NativeDelivery.class)); - MemoryStorage.lastGet = null; - assertThat(nativeGet("/table/collaboration_attachment/" + id, "alice").statusCode()).isEqualTo(403); - assertThat(NativeDelivery.accessCalls).isZero(); - JsonNode record = json(nativeGet("/table/collaboration_attachment/" + id, "nativeview"), 200).get("record"); - assertThat(record.at("/values/previewDelivery").asText()).isEqualTo("AUTHENTICATED_FETCH"); - assertThat(record.at("/values/downloadDelivery").asText()).isEqualTo("AUTHENTICATED_FETCH"); - assertThat(record.at("/values/previewUrl").asText()).contains("/qbit/record-files/files/1/downloads/"); - assertThat(record.at("/values/downloadUrl").asText()).contains("/qbit/record-files/files/1/downloads/"); - assertThat(MemoryStorage.lastGet).isNull(); - assertThat(NativeDelivery.accessCalls).isEqualTo(2); - JsonNode metadata = json(nativeGet("/metaData/table/collaboration_attachment", "nativeview"), 200); - for(String field : List.of("preview", "download")) - { - boolean declared = false; - for(JsonNode adornment : metadata.at("/virtualFields/" + field + "Url/adornments")) - { - if("FILE_DOWNLOAD".equals(adornment.path("type").asText())) - { - assertThat(adornment.at("/values/deliveryModeField").asText()).isEqualTo(field + "Delivery"); - declared = true; - } - } - assertThat(declared).as("Native %s field declares transport mode", field).isTrue(); - } - assertThat(nativeGet("/table/collaboration_attachment/" + id, "nativeoutsider").statusCode()).isEqualTo(404); - assertThat(NativeDelivery.accessCalls).isEqualTo(2); - NativeDelivery.authenticated = false; - JsonNode signed = json(nativeGet("/table/collaboration_attachment/" + id, "nativeview"), 200).get("record"); - assertThat(signed.at("/values/previewDelivery").asText()).isEqualTo("SIGNED_URL"); - assertThat(signed.at("/values/downloadDelivery").asText()).isEqualTo("SIGNED_URL"); - assertThat(signed.at("/values/downloadUrl").asText()).startsWith("https://files.example.invalid/"); - } - - /******************************************************************************* - ** Use the supported native record and metadata routes, with the original actor cookie. - *******************************************************************************/ - private HttpResponse nativeGet(String path, String caller) throws Exception - { - String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/")); - return (client.send(HttpRequest.newBuilder(URI.create(serverUrl + path)).timeout(Duration.ofSeconds(20)) - .header("Cookie", "sessionUUID=" + caller + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString())); - } - - /******************************************************************************* - ** A trusted transport fixture proves owner metadata without a Record Files dependency. - *******************************************************************************/ - public static class NativeDelivery extends com.kingsrook.qbits.collaboration.attachments.AttachmentLifecyclePolicy - { - private static int accessCalls; - private static boolean authenticated; - - /******************************************************************************* - ** Access must be authorized before the transport is returned. - *******************************************************************************/ - @Override - public void beforeAccess(com.kingsrook.qqq.backend.core.model.data.QRecord attachment, boolean inline) - { - accessCalls++; - } - - /******************************************************************************* - ** The authenticated descriptor remains data, never a presigned storage capability. - *******************************************************************************/ - @Override - public com.kingsrook.qbits.collaboration.attachments.AttachmentDownload downloadDelivery(com.kingsrook.qqq.backend.core.model.data.QRecord attachment) - throws com.kingsrook.qqq.backend.core.exceptions.QException - { - if(!authenticated) - { - return (null); - } - try - { - return (new com.kingsrook.qbits.collaboration.attachments.AttachmentDownload(URI.create( - "https://app.example/qqq/v1/qbit/record-files/files/1/downloads/00000000-0000-4000-8000-000000000001").toURL(), true)); - } - catch(Exception failure) - { - throw (new com.kingsrook.qqq.backend.core.exceptions.QException("Invalid fixture URL", failure)); - } - } - } - /******************************************************************************* ** Sharing validates recipients and access requests never reveal parent existence. *******************************************************************************/ @@ -501,36 +339,6 @@ class CollaborationApiHttpTest extends PersonalServiceFixture assertError(404, "GET", "/attachments/" + id + "/preview", null, "alice"); } - /******************************************************************************* - ** Existing offset cursors retain wire scope while native lists fetch pages. - *******************************************************************************/ - @Test - void attachmentPagesPreserveCursorScopeAndCurrentParentAccess() throws Exception - { - List ids = new java.util.ArrayList<>(); - for(int i = 0; i < 5; i++) - { - ids.add(json(upload("alice", "file-" + i + ".txt", "x", "/records/document/1/attachments"), 200).get("id").asInt()); - } - JsonNode first = ok("GET", "/records/document/1/attachments?limit=2", null); - assertThat(first.get("items")).hasSize(2); - assertThat(first.at("/items/0/id").asInt()).isEqualTo(ids.get(4)); - String cursor = first.get("nextCursor").asText(); - String decoded = new String(java.util.Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); - assertThat(decoded).startsWith("1:").endsWith(":2"); - JsonNode second = ok("GET", "/records/document/1/attachments?limit=2&cursor=" + cursor, null); - assertThat(second.get("items")).hasSize(2); - assertThat(second.at("/items/0/id").asInt()).isEqualTo(ids.get(2)); - JsonNode last = ok("GET", "/records/document/1/attachments?limit=2&cursor=" + second.get("nextCursor").asText(), null); - assertThat(last.get("items")).hasSize(1); - assertThat(last.get("nextCursor").isNull()).isTrue(); - assertError(400, "GET", "/records/document/2/attachments?cursor=" + cursor, null, "alice"); - assertError(400, "GET", "/records/document/1/attachments?limit=101", null, "alice"); - assertError(400, "GET", "/records/document/1/attachments?cursor=invalid", null, "alice"); - assertError(404, "GET", "/records/document/1/attachments?cursor=" + cursor, null, "outsider"); - assertThat(ok("GET", "/records/document/2/attachments", null).get("items")).isEmpty(); - } - /******************************************************************************* ** Vocabulary creation and record associations preserve caller permissions. *******************************************************************************/ @@ -576,80 +384,6 @@ class CollaborationApiHttpTest extends PersonalServiceFixture assertThat(tagQuery("IN", "[\"alpha\"]")).containsExactlyInAnyOrder(1, 2); } - /******************************************************************************* - ** EAV choices retain their stored ids and translated labels on the HTTP wire. - *******************************************************************************/ - @Test - void customFieldValuesThroughTableReads() throws Exception - { - admin(); - instance.addBackend(new RDBMSBackendMetaData().withName("fallback").withVendor("h2")); - instance.getTable("document").setBackendName("fallback"); - QRecord definition = new InsertAction().execute(new InsertInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of( - new QRecord().withValue("tableName", "document").withValue("name", "choice").withValue("label", "Choice").withValue("type", "SELECT") - .withValue("options", "[{\"id\":\"x\",\"label\":\"Choice X\"}]")))).getRecords().getFirst(); - assertThat(definition.getErrors()).isNullOrEmpty(); - FieldDefinitions.apply(definition.getValue("id"), "Publish"); - instance.getTable("document").setBackendName("memory"); - CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "1") - .withValue("fieldName", "choice").withValue("valueText", "x").withValue("parentDeleted", false)); - login("alice"); - String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/")); - HttpResponse response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/query")).timeout(Duration.ofSeconds(20)) - .header("Cookie", "sessionUUID=alice" + sessionSuffix).header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString("{\"filter\":{\"orderBys\":[{\"fieldName\":\"id\"}]}}")).build(), HttpResponse.BodyHandlers.ofString()); - JsonNode records = json(response, 200).get("records"); - assertThat(records).hasSize(2); - assertThat(records.get(0).at("/values/id").asInt()).isEqualTo(1); - assertThat(records.get(0).at("/values/cf_choice").asText()).isEqualTo("x"); - assertThat(records.get(0).at("/displayValues/cf_choice").asText()).isEqualTo("Choice X"); - assertThat(records.get(1).at("/values/cf_choice").isMissingNode()).isTrue(); - - response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20)) - .header("Cookie", "sessionUUID=alice" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString()); - JsonNode record = json(response, 200).get("record"); - assertThat(record.at("/values/cf_choice").asText()).isEqualTo("x"); - assertThat(record.at("/displayValues/cf_choice").asText()).isEqualTo("Choice X"); - - response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20)) - .header("Cookie", "sessionUUID=outsider" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString()); - assertThat(response.statusCode()).isEqualTo(404); - assertThat(response.body()).doesNotContain("Choice X", "First document"); - } - - /******************************************************************************* - ** Table reads expose tag labels while retaining parent-record authorization. - *******************************************************************************/ - @Test - void tagValuesThroughTableReads() throws Exception - { - RuntimeMetaDataOverlay.refresh(instance); - int tag = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Review, urgent\"}").get("id").asInt(); - ok("PUT", "/records/document/1/tags", "{\"add\":[" + tag + "]}"); - String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/")); - HttpResponse response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/query")).timeout(Duration.ofSeconds(20)) - .header("Cookie", "sessionUUID=alice" + sessionSuffix).header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString("{\"filter\":{\"orderBys\":[{\"fieldName\":\"id\"}]}}")).build(), HttpResponse.BodyHandlers.ofString()); - JsonNode records = json(response, 200).get("records"); - assertThat(records).hasSize(2); - assertThat(records.get(0).at("/values/id").asInt()).isEqualTo(1); - assertThat(records.get(0).at("/values/collaborationTags").asText()).isEqualTo("Review, urgent"); - assertThat(records.get(0).at("/displayValues/collaborationTags").asText()).isEqualTo("Review, urgent"); - assertThat(records.get(1).at("/values/id").asInt()).isEqualTo(2); - assertThat(records.get(1).at("/values/collaborationTags").isMissingNode()).as(records.toString()).isTrue(); - - response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20)) - .header("Cookie", "sessionUUID=alice" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString()); - JsonNode record = json(response, 200).get("record"); - assertThat(record.at("/values/collaborationTags").asText()).isEqualTo("Review, urgent"); - assertThat(record.at("/displayValues/collaborationTags").asText()).isEqualTo("Review, urgent"); - - response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20)) - .header("Cookie", "sessionUUID=outsider" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString()); - assertThat(response.body()).doesNotContain("Review, urgent", "First document"); - assertThat(response.statusCode()).isEqualTo(404); - } - /******************************************************************************* ** Run the v1 table query filtered on the virtual tags field; return matching ids. *******************************************************************************/ @@ -872,11 +606,7 @@ class CollaborationApiHttpTest extends PersonalServiceFixture case "carol" -> "Carol Diaz"; default -> caller; })); - session.withPermissions("document.read").withSecurityKeyValue("tenant", (caller.equals("outsider") || caller.equals("nativeoutsider")) ? "b" : "a"); - if(caller.startsWith("native")) - { - session.withPermissions("collaboration.view"); - } + session.withPermissions("document.read").withSecurityKeyValue("tenant", caller.equals("outsider") ? "b" : "a"); if(caller.equals("alice") || caller.equals("carol")) { session.withPermissions("document.write"); -- 2.54.0 From 9248f3f32c4920ab4823ed4ceddce14c88b64853 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 13:16:55 -0600 Subject: [PATCH 2/2] test: keep develop's CollaborationApiHttpTest unchanged --- .../CollaborationApiHttpTest.java | 272 +++++++++++++++++- 1 file changed, 271 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java index 6d86d75..41b839a 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java @@ -39,6 +39,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage; +import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; import com.kingsrook.qbits.objectstorage.api.PresignGet; import com.kingsrook.qbits.objectstorage.api.PutRequest; import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay; @@ -63,6 +69,69 @@ import static org.assertj.core.api.Assertions.assertThat; *******************************************************************************/ class CollaborationApiHttpTest extends PersonalServiceFixture { + /*************************************************************************** + ** Data loss is unavailable over HTTP until a fresh lease has rejoined. + ***************************************************************************/ + @Test + void redisLossRequiresHttpRejoin() throws Exception + { + try(var redis = RedisFixtures.redis()) + { + redis.start(); + new com.kingsrook.qbits.redis.RedisQBitProducer().withQBitConfig(new com.kingsrook.qbits.redis.RedisQBitConfig() + .withConnection("presence", new com.kingsrook.qbits.redis.RedisConnectionConfig() + .withHost(RedisFixtures.host(redis)).withPort(RedisFixtures.port(redis)).withTls(false))).produce(instance); + config.withRedisPresence(new com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig() + .withConnectionName("presence").withDeployment("test").withApplication("http-recovery") + .withScopeProvider(new QCodeReference(RedisPresenceServiceTest.TenantScope.class))); + var runtime = new com.kingsrook.qbits.redis.RedisRuntimeService(); + try + { + runtime.start(instance); + String original = ok("POST", "/presence/leases", "{\"table\":\"document\",\"pk\":\"1\"}").get("connectionId").asText(); + awaitHttpPresence(original); + assertThat(redis.execInContainer("redis-cli", "FLUSHDB").getExitCode()).isZero(); + assertError(503, "GET", "/presence?table=document&pk=1", null, "alice"); + assertError(404, "GET", "/presence?table=document&pk=3", null, "alice"); + String oldRenewal = JSON.writeValueAsString(Map.of("connectionId", original, "table", "document", "pk", "1", "mode", "VIEWING", "sequence", 1000)); + assertThat(json(request("POST", "/presence/leases/renew", oldRenewal, "alice"), 409).path("code").asText()) + .isEqualTo("PRESENCE_LEASE_EXPIRED"); + assertError(404, "POST", "/presence/leases/renew", oldRenewal, "outsider"); + String replacement = ok("POST", "/presence/leases", "{\"table\":\"document\",\"pk\":\"1\"}").get("connectionId").asText(); + assertThat(replacement).isNotEqualTo(original); + awaitHttpPresence(replacement); + assertThat(ok("GET", "/presence?table=document&pk=1", null).path("viewers")).hasSize(1); + } + finally + { + runtime.stop(); + runtime.afterApplicationStop(); + } + } + } + + /*************************************************************************** + ** Keep a real HTTP lease alive while the production recovery window elapses. + ***************************************************************************/ + private void awaitHttpPresence(String lease) throws Exception + { + long deadline = System.nanoTime() + Duration.ofSeconds(55).toNanos(); + long sequence = 0; + while(true) + { + String body = JSON.writeValueAsString(Map.of("connectionId", lease, "table", "document", "pk", "1", "mode", "VIEWING", "sequence", ++sequence)); + var response = request("POST", "/presence/leases/renew", body, "alice"); + if(response.statusCode() == 200) + { + assertThat(json(response, 200).path("viewers")).hasSize(1); + return; + } + assertThat(json(response, 503).path("message").asText()).contains("recovering"); + assertThat(System.nanoTime()).as("HTTP presence recovery deadline").isLessThan(deadline); + Thread.sleep(1000); + } + } + /*************************************************************************** ** Shared presence has an explicit unavailable response, never a local fallback. ***************************************************************************/ @@ -132,6 +201,99 @@ class CollaborationApiHttpTest extends PersonalServiceFixture MemoryStorage.objects.clear(); } + /******************************************************************************* + ** Native record reads preserve authenticated transports and publish generic UI metadata. + *******************************************************************************/ + @Test + void standardAttachmentRecordPreservesDeliveryModes() throws Exception + { + int id = json(upload("alice", "retained.txt", "original", "/records/document/1/attachments"), 200).get("id").asInt(); + NativeDelivery.accessCalls = 0; + NativeDelivery.authenticated = true; + config.withAttachmentLifecycle(new QCodeReference(NativeDelivery.class)); + MemoryStorage.lastGet = null; + assertThat(nativeGet("/table/collaboration_attachment/" + id, "alice").statusCode()).isEqualTo(403); + assertThat(NativeDelivery.accessCalls).isZero(); + JsonNode record = json(nativeGet("/table/collaboration_attachment/" + id, "nativeview"), 200).get("record"); + assertThat(record.at("/values/previewDelivery").asText()).isEqualTo("AUTHENTICATED_FETCH"); + assertThat(record.at("/values/downloadDelivery").asText()).isEqualTo("AUTHENTICATED_FETCH"); + assertThat(record.at("/values/previewUrl").asText()).contains("/qbit/record-files/files/1/downloads/"); + assertThat(record.at("/values/downloadUrl").asText()).contains("/qbit/record-files/files/1/downloads/"); + assertThat(MemoryStorage.lastGet).isNull(); + assertThat(NativeDelivery.accessCalls).isEqualTo(2); + JsonNode metadata = json(nativeGet("/metaData/table/collaboration_attachment", "nativeview"), 200); + for(String field : List.of("preview", "download")) + { + boolean declared = false; + for(JsonNode adornment : metadata.at("/virtualFields/" + field + "Url/adornments")) + { + if("FILE_DOWNLOAD".equals(adornment.path("type").asText())) + { + assertThat(adornment.at("/values/deliveryModeField").asText()).isEqualTo(field + "Delivery"); + declared = true; + } + } + assertThat(declared).as("Native %s field declares transport mode", field).isTrue(); + } + assertThat(nativeGet("/table/collaboration_attachment/" + id, "nativeoutsider").statusCode()).isEqualTo(404); + assertThat(NativeDelivery.accessCalls).isEqualTo(2); + NativeDelivery.authenticated = false; + JsonNode signed = json(nativeGet("/table/collaboration_attachment/" + id, "nativeview"), 200).get("record"); + assertThat(signed.at("/values/previewDelivery").asText()).isEqualTo("SIGNED_URL"); + assertThat(signed.at("/values/downloadDelivery").asText()).isEqualTo("SIGNED_URL"); + assertThat(signed.at("/values/downloadUrl").asText()).startsWith("https://files.example.invalid/"); + } + + /******************************************************************************* + ** Use the supported native record and metadata routes, with the original actor cookie. + *******************************************************************************/ + private HttpResponse nativeGet(String path, String caller) throws Exception + { + String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/")); + return (client.send(HttpRequest.newBuilder(URI.create(serverUrl + path)).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=" + caller + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString())); + } + + /******************************************************************************* + ** A trusted transport fixture proves owner metadata without a Record Files dependency. + *******************************************************************************/ + public static class NativeDelivery extends com.kingsrook.qbits.collaboration.attachments.AttachmentLifecyclePolicy + { + private static int accessCalls; + private static boolean authenticated; + + /******************************************************************************* + ** Access must be authorized before the transport is returned. + *******************************************************************************/ + @Override + public void beforeAccess(com.kingsrook.qqq.backend.core.model.data.QRecord attachment, boolean inline) + { + accessCalls++; + } + + /******************************************************************************* + ** The authenticated descriptor remains data, never a presigned storage capability. + *******************************************************************************/ + @Override + public com.kingsrook.qbits.collaboration.attachments.AttachmentDownload downloadDelivery(com.kingsrook.qqq.backend.core.model.data.QRecord attachment) + throws com.kingsrook.qqq.backend.core.exceptions.QException + { + if(!authenticated) + { + return (null); + } + try + { + return (new com.kingsrook.qbits.collaboration.attachments.AttachmentDownload(URI.create( + "https://app.example/qqq/v1/qbit/record-files/files/1/downloads/00000000-0000-4000-8000-000000000001").toURL(), true)); + } + catch(Exception failure) + { + throw (new com.kingsrook.qqq.backend.core.exceptions.QException("Invalid fixture URL", failure)); + } + } + } + /******************************************************************************* ** Sharing validates recipients and access requests never reveal parent existence. *******************************************************************************/ @@ -339,6 +501,36 @@ class CollaborationApiHttpTest extends PersonalServiceFixture assertError(404, "GET", "/attachments/" + id + "/preview", null, "alice"); } + /******************************************************************************* + ** Existing offset cursors retain wire scope while native lists fetch pages. + *******************************************************************************/ + @Test + void attachmentPagesPreserveCursorScopeAndCurrentParentAccess() throws Exception + { + List ids = new java.util.ArrayList<>(); + for(int i = 0; i < 5; i++) + { + ids.add(json(upload("alice", "file-" + i + ".txt", "x", "/records/document/1/attachments"), 200).get("id").asInt()); + } + JsonNode first = ok("GET", "/records/document/1/attachments?limit=2", null); + assertThat(first.get("items")).hasSize(2); + assertThat(first.at("/items/0/id").asInt()).isEqualTo(ids.get(4)); + String cursor = first.get("nextCursor").asText(); + String decoded = new String(java.util.Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + assertThat(decoded).startsWith("1:").endsWith(":2"); + JsonNode second = ok("GET", "/records/document/1/attachments?limit=2&cursor=" + cursor, null); + assertThat(second.get("items")).hasSize(2); + assertThat(second.at("/items/0/id").asInt()).isEqualTo(ids.get(2)); + JsonNode last = ok("GET", "/records/document/1/attachments?limit=2&cursor=" + second.get("nextCursor").asText(), null); + assertThat(last.get("items")).hasSize(1); + assertThat(last.get("nextCursor").isNull()).isTrue(); + assertError(400, "GET", "/records/document/2/attachments?cursor=" + cursor, null, "alice"); + assertError(400, "GET", "/records/document/1/attachments?limit=101", null, "alice"); + assertError(400, "GET", "/records/document/1/attachments?cursor=invalid", null, "alice"); + assertError(404, "GET", "/records/document/1/attachments?cursor=" + cursor, null, "outsider"); + assertThat(ok("GET", "/records/document/2/attachments", null).get("items")).isEmpty(); + } + /******************************************************************************* ** Vocabulary creation and record associations preserve caller permissions. *******************************************************************************/ @@ -384,6 +576,80 @@ class CollaborationApiHttpTest extends PersonalServiceFixture assertThat(tagQuery("IN", "[\"alpha\"]")).containsExactlyInAnyOrder(1, 2); } + /******************************************************************************* + ** EAV choices retain their stored ids and translated labels on the HTTP wire. + *******************************************************************************/ + @Test + void customFieldValuesThroughTableReads() throws Exception + { + admin(); + instance.addBackend(new RDBMSBackendMetaData().withName("fallback").withVendor("h2")); + instance.getTable("document").setBackendName("fallback"); + QRecord definition = new InsertAction().execute(new InsertInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of( + new QRecord().withValue("tableName", "document").withValue("name", "choice").withValue("label", "Choice").withValue("type", "SELECT") + .withValue("options", "[{\"id\":\"x\",\"label\":\"Choice X\"}]")))).getRecords().getFirst(); + assertThat(definition.getErrors()).isNullOrEmpty(); + FieldDefinitions.apply(definition.getValue("id"), "Publish"); + instance.getTable("document").setBackendName("memory"); + CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "1") + .withValue("fieldName", "choice").withValue("valueText", "x").withValue("parentDeleted", false)); + login("alice"); + String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/")); + HttpResponse response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/query")).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=alice" + sessionSuffix).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{\"filter\":{\"orderBys\":[{\"fieldName\":\"id\"}]}}")).build(), HttpResponse.BodyHandlers.ofString()); + JsonNode records = json(response, 200).get("records"); + assertThat(records).hasSize(2); + assertThat(records.get(0).at("/values/id").asInt()).isEqualTo(1); + assertThat(records.get(0).at("/values/cf_choice").asText()).isEqualTo("x"); + assertThat(records.get(0).at("/displayValues/cf_choice").asText()).isEqualTo("Choice X"); + assertThat(records.get(1).at("/values/cf_choice").isMissingNode()).isTrue(); + + response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=alice" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString()); + JsonNode record = json(response, 200).get("record"); + assertThat(record.at("/values/cf_choice").asText()).isEqualTo("x"); + assertThat(record.at("/displayValues/cf_choice").asText()).isEqualTo("Choice X"); + + response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=outsider" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(404); + assertThat(response.body()).doesNotContain("Choice X", "First document"); + } + + /******************************************************************************* + ** Table reads expose tag labels while retaining parent-record authorization. + *******************************************************************************/ + @Test + void tagValuesThroughTableReads() throws Exception + { + RuntimeMetaDataOverlay.refresh(instance); + int tag = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Review, urgent\"}").get("id").asInt(); + ok("PUT", "/records/document/1/tags", "{\"add\":[" + tag + "]}"); + String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/")); + HttpResponse response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/query")).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=alice" + sessionSuffix).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{\"filter\":{\"orderBys\":[{\"fieldName\":\"id\"}]}}")).build(), HttpResponse.BodyHandlers.ofString()); + JsonNode records = json(response, 200).get("records"); + assertThat(records).hasSize(2); + assertThat(records.get(0).at("/values/id").asInt()).isEqualTo(1); + assertThat(records.get(0).at("/values/collaborationTags").asText()).isEqualTo("Review, urgent"); + assertThat(records.get(0).at("/displayValues/collaborationTags").asText()).isEqualTo("Review, urgent"); + assertThat(records.get(1).at("/values/id").asInt()).isEqualTo(2); + assertThat(records.get(1).at("/values/collaborationTags").isMissingNode()).as(records.toString()).isTrue(); + + response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=alice" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString()); + JsonNode record = json(response, 200).get("record"); + assertThat(record.at("/values/collaborationTags").asText()).isEqualTo("Review, urgent"); + assertThat(record.at("/displayValues/collaborationTags").asText()).isEqualTo("Review, urgent"); + + response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=outsider" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.body()).doesNotContain("Review, urgent", "First document"); + assertThat(response.statusCode()).isEqualTo(404); + } + /******************************************************************************* ** Run the v1 table query filtered on the virtual tags field; return matching ids. *******************************************************************************/ @@ -606,7 +872,11 @@ class CollaborationApiHttpTest extends PersonalServiceFixture case "carol" -> "Carol Diaz"; default -> caller; })); - session.withPermissions("document.read").withSecurityKeyValue("tenant", caller.equals("outsider") ? "b" : "a"); + session.withPermissions("document.read").withSecurityKeyValue("tenant", (caller.equals("outsider") || caller.equals("nativeoutsider")) ? "b" : "a"); + if(caller.startsWith("native")) + { + session.withPermissions("collaboration.view"); + } if(caller.equals("alice") || caller.equals("carol")) { session.withPermissions("document.write"); -- 2.54.0