From d8515e6cd0a0c435a4920c69f0a2caf7d952b32b Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 18:01:07 +0300 Subject: [PATCH 1/8] test(fga): live coverage for dry-run, ABAC and cache routing Mirrors integrationtests/tests/fga_test.go against a real project: the GDrive schema round trip with all its check expectations, the ABAC context cases (conditional allow and deny, missing context, condition on a permission), and loadSchema returning version and conditions. Adds coverage the Go suite does not have: dry-run, ABAC context through the authz queries, and FGA cache routing. Cache routing is covered three ways - pointing the cache URL at the API host (everything must keep working), pointing it at a dead port (only the six routed calls must fail, the rest must still succeed), and against a real cache when DESCOPE_FGA_CACHE_URL is set. Co-Authored-By: Claude Opus 5 (1M context) --- .../descope/sdk/mgmt/impl/FGALiveTest.java | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java new file mode 100644 index 00000000..e184434d --- /dev/null +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java @@ -0,0 +1,314 @@ +package com.descope.sdk.mgmt.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.descope.exception.RateLimitExceededException; +import com.descope.model.client.Client; +import com.descope.model.fga.FGACheckResult; +import com.descope.model.fga.FGARelation; +import com.descope.model.fga.FGAResourceDetails; +import com.descope.model.fga.FGAResourceIdentifier; +import com.descope.model.fga.FGASchema; +import com.descope.model.fga.FGASchemaDryRunResponse; +import com.descope.model.mgmt.ManagementServices; +import com.descope.sdk.TestUtils; +import com.descope.sdk.mgmt.AuthzService; +import com.descope.sdk.mgmt.FGAService; +import com.descope.utils.EnvironmentUtils; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junitpioneer.jupiter.RetryingTest; + +/** + * Live coverage for the FGA surface, mirroring integrationtests/tests/fga_test.go. + * Requires DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY. + */ +class FGALiveTest { + + private static final String GDRIVE_SCHEMA = "model AuthZ 1.0\n" + + "\n" + + "type user\n" + + "\n" + + "type group\n" + + " relation member: user\n" + + "\n" + + "type doc\n" + + " relation owner: user | group#member\n" + + " relation parent: folder\n" + + "\n" + + " permission can_create: owner | parent.owner\n" + + "\n" + + "type folder\n" + + " relation parent: folder\n" + + " relation owner: user | group#member\n" + + " relation editor: user\n" + + "\n" + + " permission can_create: owner | parent.owner\n" + + " permission can_edit: editor | parent.editor | can_create\n"; + + private static final String ABAC_SCHEMA = "model AuthZ 1.0\n" + + "\n" + + "condition IsAdmin(role string) { role == \"admin\" }\n" + + "condition CanEdit(action string) { action == \"write\" }\n" + + "\n" + + "type user\n" + + "\n" + + "type doc\n" + + " relation viewer: user with IsAdmin\n" + + " permission can_edit: viewer with CanEdit\n"; + + private static final String SIMPLE_SCHEMA = "model AuthZ 1.0\n" + + "\n" + + "type user\n" + + "\n" + + "type document\n" + + " relation viewer: user\n" + + "\n" + + " permission can_view: viewer\n"; + + private static final String REDUCED_SCHEMA = "model AuthZ 1.0\n" + + "\n" + + "type user\n"; + + private FGAService fgaService; + private AuthzService authzService; + + @BeforeEach + void setUp() { + ManagementServices services = ManagementServiceBuilder.buildServices(TestUtils.getClient()); + fgaService = services.getFgaService(); + authzService = services.getAuthzService(); + } + + @AfterEach + void tearDown() { + try { + authzService.deleteSchema(); + } catch (Exception ignored) { + // The schema may already be gone, nothing to clean up. + } + } + + @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + void testGdriveSchemaRelationsAndChecks() { + fgaService.saveSchema(new FGASchema(GDRIVE_SCHEMA)); + + List relations = Arrays.asList( + new FGARelation("folder1", "folder", "owner", "u1", "user"), + new FGARelation("folder1", "folder", "editor", "u2", "user"), + new FGARelation("folder1", "folder", "parent", "rootFolder", "folder"), + new FGARelation("rootFolder", "folder", "owner", "u9", "user"), + new FGARelation("group1", "group", "member", "ug1", "user"), + new FGARelation("group1", "group", "member", "ug2", "user"), + new FGARelation("folder2", "folder", "owner", "group1", "group#member"), + new FGARelation("folder2", "folder", "owner", "u1", "user"), + new FGARelation("folder2", "folder", "parent", "rootFolder", "folder")); + fgaService.createRelations(relations); + + List expected = Arrays.asList( + expect("folder1", "owner", "u1", true), + expect("folder1", "editor", "u2", true), + expect("folder1", "editor", "u1", false), + expect("folder1", "can_create", "u1", true), + expect("folder1", "can_create", "u2", false), + expect("folder1", "can_create", "u9", true), + expect("folder1", "can_edit", "u2", true), + expect("folder1", "can_edit", "u1", true), + expect("folder1", "can_edit", "u3", false), + expect("folder1", "can_edit", "u9", true), + expect("folder1", "owner", "u9", false), + expect("rootFolder", "can_edit", "u1", false), + expect("folder2", "owner", "ug1", true), + expect("folder2", "owner", "u1", true), + expect("folder2", "can_create", "ug1", true), + expect("folder2", "can_edit", "ug1", true), + expect("folder2", "can_edit", "u9", true)); + + List toCheck = new ArrayList<>(); + for (FGACheckResult check : expected) { + toCheck.add(check.getRelation()); + } + + List results = fgaService.check(toCheck); + + assertEquals(expected.size(), results.size()); + for (int i = 0; i < expected.size(); i++) { + FGARelation relation = expected.get(i).getRelation(); + assertEquals(expected.get(i).isAllowed(), results.get(i).isAllowed(), "check " + relation); + assertEquals(relation, results.get(i).getRelation()); + } + + fgaService.deleteRelations(relations); + } + + @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + void testAbacCheckWithContext() { + fgaService.saveSchema(new FGASchema(ABAC_SCHEMA)); + + FGARelation viewer = new FGARelation("doc1", "doc", "viewer", "u1", "user"); + fgaService.createRelations(Arrays.asList(viewer)); + + List allowed = fgaService.check(Arrays.asList(viewer), contextOf("role", "admin")); + assertEquals(1, allowed.size()); + assertTrue(allowed.get(0).isAllowed(), "admin role should be allowed"); + assertTrue(allowed.get(0).getInfo().isConditional(), "result should be marked conditional"); + assertEquals(viewer, allowed.get(0).getRelation()); + + List denied = fgaService.check(Arrays.asList(viewer), contextOf("role", "user")); + assertFalse(denied.get(0).isAllowed(), "non-admin role should be denied"); + assertTrue(denied.get(0).getInfo().isConditional()); + + List noContext = fgaService.check(Arrays.asList(viewer)); + assertFalse(noContext.get(0).isAllowed()); + assertTrue(noContext.get(0).getInfo().getMissingContext().contains("role"), + "role should be reported as missing context"); + + FGARelation canEdit = new FGARelation("doc1", "doc", "can_edit", "u1", "user"); + Map writeContext = contextOf("role", "admin"); + writeContext.put("action", "write"); + List editAllowed = fgaService.check(Arrays.asList(canEdit), writeContext); + assertTrue(editAllowed.get(0).isAllowed(), "admin with write action should be allowed via can_edit"); + assertTrue(editAllowed.get(0).getInfo().isConditional()); + + Map readContext = contextOf("role", "admin"); + readContext.put("action", "read"); + List editDenied = fgaService.check(Arrays.asList(canEdit), readContext); + assertFalse(editDenied.get(0).isAllowed(), "admin with read action should be denied via can_edit"); + assertTrue(editDenied.get(0).getInfo().isConditional()); + + fgaService.deleteRelations(Arrays.asList(viewer)); + } + + @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + void testAbacContextThroughAuthzQueries() { + fgaService.saveSchema(new FGASchema(ABAC_SCHEMA)); + + FGARelation viewer = new FGARelation("doc1", "doc", "viewer", "u1", "user"); + fgaService.createRelations(Arrays.asList(viewer)); + + assertTrue(authzService.whoCanAccess("doc1", "viewer", "doc", contextOf("role", "admin")).contains("u1"), + "admin role should satisfy the viewer condition"); + assertFalse(authzService.whoCanAccess("doc1", "viewer", "doc", contextOf("role", "user")).contains("u1"), + "non-admin role should not satisfy the viewer condition"); + + assertNotNull(authzService.whatCanTargetAccess("u1", contextOf("role", "admin"))); + + fgaService.deleteRelations(Arrays.asList(viewer)); + } + + @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + void testDryRunSchemaDoesNotSave() { + fgaService.saveSchema(new FGASchema(SIMPLE_SCHEMA)); + + FGASchemaDryRunResponse deleting = fgaService.dryRunSchema(new FGASchema(REDUCED_SCHEMA)); + assertNotNull(deleting.getDeletesPreview()); + assertTrue(deleting.getDeletesPreview().isHasDeletes(), "dropping the document type should report deletes"); + + FGASchemaDryRunResponse unchanged = fgaService.dryRunSchema(new FGASchema(SIMPLE_SCHEMA)); + assertFalse(unchanged.getDeletesPreview() != null && unchanged.getDeletesPreview().isHasDeletes(), + "an unchanged schema should report no deletes"); + + assertTrue(fgaService.loadSchema().getDsl().contains("document"), "dry run must not save the schema"); + } + + @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + void testLoadSchemaReturnsVersionAndConditions() { + fgaService.saveSchema(new FGASchema(ABAC_SCHEMA)); + + FGASchema loaded = fgaService.loadSchema(); + assertTrue(StringUtils.isNotBlank(loaded.getVersion()), "schema version should be returned"); + assertNotNull(loaded.getConditions()); + assertTrue(loaded.getConditions().stream().anyMatch(c -> "IsAdmin".equals(c.getName())), + "IsAdmin condition should be returned"); + } + + @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + void testFgaCacheUrlPointingAtTheApiHostStillWorks() { + Client client = TestUtils.getClient(); + // Not a real cache, but it proves the config reaches the request and the six routed calls + // keep working through it. The trailing slash covers the URL normalization. + client.setFgaCacheUri(client.getUri() + "/"); + FGAService cachedFga = ManagementServiceBuilder.buildServices(client).getFgaService(); + + cachedFga.saveSchema(new FGASchema(SIMPLE_SCHEMA)); + FGARelation relation = new FGARelation("doc1", "document", "viewer", "u1", "user"); + cachedFga.createRelations(Arrays.asList(relation)); + + List results = cachedFga.check(Arrays.asList(relation)); + assertEquals(1, results.size()); + assertTrue(results.get(0).isAllowed()); + + cachedFga.deleteRelations(Arrays.asList(relation)); + } + + @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + void testBadFgaCacheUrlFailsOnlyTheRoutedCalls() { + fgaService.saveSchema(new FGASchema(SIMPLE_SCHEMA)); + + Client client = TestUtils.getClient(); + client.setFgaCacheUri("http://localhost:1"); + ManagementServices services = ManagementServiceBuilder.buildServices(client); + FGAService badFga = services.getFgaService(); + AuthzService badAuthz = services.getAuthzService(); + + List relations = Arrays.asList(new FGARelation("doc1", "document", "viewer", "u1", "user")); + Map context = contextOf("role", "admin"); + + // Transport failures are not mapped into DescopeException, they propagate as-is. + assertThrows(Exception.class, () -> badFga.saveSchema(new FGASchema(SIMPLE_SCHEMA))); + assertThrows(Exception.class, () -> badFga.createRelations(relations)); + assertThrows(Exception.class, () -> badFga.deleteRelations(relations)); + assertThrows(Exception.class, () -> badFga.check(relations)); + assertThrows(Exception.class, () -> badFga.check(relations, context)); + assertThrows(Exception.class, () -> badAuthz.whoCanAccess("doc1", "viewer", "document")); + assertThrows(Exception.class, () -> badAuthz.whatCanTargetAccess("u1")); + + List details = Arrays.asList(new FGAResourceDetails("doc1", "document", "Doc One")); + assertNotNull(badFga.loadSchema().getDsl()); + assertNotNull(badFga.dryRunSchema(new FGASchema(SIMPLE_SCHEMA))); + badFga.saveResourcesDetails(details); + assertNotNull(badFga.loadResourcesDetails(Arrays.asList(new FGAResourceIdentifier("doc1", "document")))); + assertNotNull(badAuthz.resourceRelations("doc1")); + } + + @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + void testAgainstRealFgaCache() { + String fgaCacheUrl = EnvironmentUtils.getFgaCacheURL(); + assumeTrue(StringUtils.isNotBlank(fgaCacheUrl), "DESCOPE_FGA_CACHE_URL is not set"); + + Client client = TestUtils.getClient(); + client.setFgaCacheUri(fgaCacheUrl); + FGAService cachedFga = ManagementServiceBuilder.buildServices(client).getFgaService(); + + cachedFga.saveSchema(new FGASchema(SIMPLE_SCHEMA)); + FGARelation relation = new FGARelation("doc1", "document", "viewer", "u1", "user"); + cachedFga.createRelations(Arrays.asList(relation)); + + List results = cachedFga.check(Arrays.asList(relation)); + assertEquals(1, results.size()); + assertTrue(results.get(0).isAllowed()); + + cachedFga.deleteRelations(Arrays.asList(relation)); + } + + private static FGACheckResult expect(String resource, String relation, String target, boolean allowed) { + return new FGACheckResult(allowed, new FGARelation(resource, "folder", relation, target, "user"), null); + } + + private static Map contextOf(String key, Object value) { + Map context = new HashMap<>(); + context.put(key, value); + return context; + } +} From 81012b4d3177dc7fefde994aa929e707b4340d8d Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 18:14:07 +0300 Subject: [PATCH 2/8] test(fga): compare the echoed tuple without targetType The server normalizes targetType on the tuple it echoes back from check, so compare the fields that identify the relation instead of the whole object. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/descope/sdk/mgmt/impl/FGALiveTest.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java index e184434d..810ff546 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java @@ -146,7 +146,7 @@ void testGdriveSchemaRelationsAndChecks() { for (int i = 0; i < expected.size(); i++) { FGARelation relation = expected.get(i).getRelation(); assertEquals(expected.get(i).isAllowed(), results.get(i).isAllowed(), "check " + relation); - assertEquals(relation, results.get(i).getRelation()); + assertEchoes(relation, results.get(i).getRelation()); } fgaService.deleteRelations(relations); @@ -163,7 +163,7 @@ void testAbacCheckWithContext() { assertEquals(1, allowed.size()); assertTrue(allowed.get(0).isAllowed(), "admin role should be allowed"); assertTrue(allowed.get(0).getInfo().isConditional(), "result should be marked conditional"); - assertEquals(viewer, allowed.get(0).getRelation()); + assertEchoes(viewer, allowed.get(0).getRelation()); List denied = fgaService.check(Arrays.asList(viewer), contextOf("role", "user")); assertFalse(denied.get(0).isAllowed(), "non-admin role should be denied"); @@ -260,10 +260,10 @@ void testBadFgaCacheUrlFailsOnlyTheRoutedCalls() { client.setFgaCacheUri("http://localhost:1"); ManagementServices services = ManagementServiceBuilder.buildServices(client); FGAService badFga = services.getFgaService(); - AuthzService badAuthz = services.getAuthzService(); + final AuthzService badAuthz = services.getAuthzService(); List relations = Arrays.asList(new FGARelation("doc1", "document", "viewer", "u1", "user")); - Map context = contextOf("role", "admin"); + final Map context = contextOf("role", "admin"); // Transport failures are not mapped into DescopeException, they propagate as-is. assertThrows(Exception.class, () -> badFga.saveSchema(new FGASchema(SIMPLE_SCHEMA))); @@ -302,6 +302,15 @@ void testAgainstRealFgaCache() { cachedFga.deleteRelations(Arrays.asList(relation)); } + // The server echoes the tuple it evaluated, with targetType normalized, so compare the rest. + private static void assertEchoes(FGARelation requested, FGARelation echoed) { + assertNotNull(echoed); + assertEquals(requested.getResource(), echoed.getResource()); + assertEquals(requested.getResourceType(), echoed.getResourceType()); + assertEquals(requested.getRelation(), echoed.getRelation()); + assertEquals(requested.getTarget(), echoed.getTarget()); + } + private static FGACheckResult expect(String resource, String relation, String target, boolean allowed) { return new FGACheckResult(allowed, new FGARelation(resource, "folder", relation, target, "user"), null); } From 2091110baafbb41edb66bf10da47f2dad35513ab Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 18:26:23 +0300 Subject: [PATCH 3/8] test(fga): retry live tests on server errors These tests replace the project's FGA schema, which is shared with the other live tests, so a schema-dependent call can hit a transient server error. Retry the same way the suite already retries rate limits. Co-Authored-By: Claude Opus 5 (1M context) --- .../descope/sdk/mgmt/impl/FGALiveTest.java | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java index 810ff546..c35727c3 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.descope.exception.RateLimitExceededException; +import com.descope.exception.ServerCommonException; import com.descope.model.client.Client; import com.descope.model.fga.FGACheckResult; import com.descope.model.fga.FGARelation; @@ -32,7 +33,8 @@ /** * Live coverage for the FGA surface, mirroring integrationtests/tests/fga_test.go. - * Requires DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY. + * Requires DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY. Server errors are retried because these + * tests replace the project's schema, which is shared with the other live tests. */ class FGALiveTest { @@ -100,7 +102,8 @@ void tearDown() { } } - @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + @RetryingTest(value = 3, suspendForMs = 30000, + onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testGdriveSchemaRelationsAndChecks() { fgaService.saveSchema(new FGASchema(GDRIVE_SCHEMA)); @@ -152,7 +155,8 @@ void testGdriveSchemaRelationsAndChecks() { fgaService.deleteRelations(relations); } - @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + @RetryingTest(value = 3, suspendForMs = 30000, + onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testAbacCheckWithContext() { fgaService.saveSchema(new FGASchema(ABAC_SCHEMA)); @@ -190,7 +194,8 @@ void testAbacCheckWithContext() { fgaService.deleteRelations(Arrays.asList(viewer)); } - @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + @RetryingTest(value = 3, suspendForMs = 30000, + onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testAbacContextThroughAuthzQueries() { fgaService.saveSchema(new FGASchema(ABAC_SCHEMA)); @@ -207,7 +212,8 @@ void testAbacContextThroughAuthzQueries() { fgaService.deleteRelations(Arrays.asList(viewer)); } - @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + @RetryingTest(value = 3, suspendForMs = 30000, + onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testDryRunSchemaDoesNotSave() { fgaService.saveSchema(new FGASchema(SIMPLE_SCHEMA)); @@ -222,7 +228,8 @@ void testDryRunSchemaDoesNotSave() { assertTrue(fgaService.loadSchema().getDsl().contains("document"), "dry run must not save the schema"); } - @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + @RetryingTest(value = 3, suspendForMs = 30000, + onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testLoadSchemaReturnsVersionAndConditions() { fgaService.saveSchema(new FGASchema(ABAC_SCHEMA)); @@ -233,7 +240,8 @@ void testLoadSchemaReturnsVersionAndConditions() { "IsAdmin condition should be returned"); } - @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + @RetryingTest(value = 3, suspendForMs = 30000, + onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testFgaCacheUrlPointingAtTheApiHostStillWorks() { Client client = TestUtils.getClient(); // Not a real cache, but it proves the config reaches the request and the six routed calls @@ -252,7 +260,8 @@ void testFgaCacheUrlPointingAtTheApiHostStillWorks() { cachedFga.deleteRelations(Arrays.asList(relation)); } - @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + @RetryingTest(value = 3, suspendForMs = 30000, + onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testBadFgaCacheUrlFailsOnlyTheRoutedCalls() { fgaService.saveSchema(new FGASchema(SIMPLE_SCHEMA)); @@ -282,7 +291,8 @@ void testBadFgaCacheUrlFailsOnlyTheRoutedCalls() { assertNotNull(badAuthz.resourceRelations("doc1")); } - @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = RateLimitExceededException.class) + @RetryingTest(value = 3, suspendForMs = 30000, + onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testAgainstRealFgaCache() { String fgaCacheUrl = EnvironmentUtils.getFgaCacheURL(); assumeTrue(StringUtils.isNotBlank(fgaCacheUrl), "DESCOPE_FGA_CACHE_URL is not set"); From 0a83fbc334f31b844432aa628db90c9f30d292c7 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 18:39:57 +0300 Subject: [PATCH 4/8] test(fga): cut schema churn on the shared test project Delete the schema once for the class instead of after every test, and fold the authz-context and loadSchema assertions into the ABAC test that already saves that schema. Halves the schema mutations per run, which is what was making concurrent CI runs fail each other's schema-dependent live tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../descope/sdk/mgmt/impl/FGALiveTest.java | 40 ++++++------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java index c35727c3..36ece543 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java @@ -27,7 +27,7 @@ import java.util.List; import java.util.Map; import org.apache.commons.lang3.StringUtils; -import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junitpioneer.jupiter.RetryingTest; @@ -93,10 +93,12 @@ void setUp() { authzService = services.getAuthzService(); } - @AfterEach - void tearDown() { + // Cleaning up once, not per test: every test saves the schema it needs, and the project is + // shared with the other live tests, so deleting the schema between tests only adds churn. + @AfterAll + static void deleteSchema() { try { - authzService.deleteSchema(); + ManagementServiceBuilder.buildServices(TestUtils.getClient()).getAuthzService().deleteSchema(); } catch (Exception ignored) { // The schema may already be gone, nothing to clean up. } @@ -191,24 +193,18 @@ void testAbacCheckWithContext() { assertFalse(editDenied.get(0).isAllowed(), "admin with read action should be denied via can_edit"); assertTrue(editDenied.get(0).getInfo().isConditional()); - fgaService.deleteRelations(Arrays.asList(viewer)); - } - - @RetryingTest(value = 3, suspendForMs = 30000, - onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) - void testAbacContextThroughAuthzQueries() { - fgaService.saveSchema(new FGASchema(ABAC_SCHEMA)); - - FGARelation viewer = new FGARelation("doc1", "doc", "viewer", "u1", "user"); - fgaService.createRelations(Arrays.asList(viewer)); - + // Same context, through the authz queries that evaluate conditions. assertTrue(authzService.whoCanAccess("doc1", "viewer", "doc", contextOf("role", "admin")).contains("u1"), "admin role should satisfy the viewer condition"); assertFalse(authzService.whoCanAccess("doc1", "viewer", "doc", contextOf("role", "user")).contains("u1"), "non-admin role should not satisfy the viewer condition"); - assertNotNull(authzService.whatCanTargetAccess("u1", contextOf("role", "admin"))); + FGASchema loaded = fgaService.loadSchema(); + assertTrue(StringUtils.isNotBlank(loaded.getVersion()), "schema version should be returned"); + assertTrue(loaded.getConditions().stream().anyMatch(c -> "IsAdmin".equals(c.getName())), + "IsAdmin condition should be returned"); + fgaService.deleteRelations(Arrays.asList(viewer)); } @@ -228,18 +224,6 @@ void testDryRunSchemaDoesNotSave() { assertTrue(fgaService.loadSchema().getDsl().contains("document"), "dry run must not save the schema"); } - @RetryingTest(value = 3, suspendForMs = 30000, - onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) - void testLoadSchemaReturnsVersionAndConditions() { - fgaService.saveSchema(new FGASchema(ABAC_SCHEMA)); - - FGASchema loaded = fgaService.loadSchema(); - assertTrue(StringUtils.isNotBlank(loaded.getVersion()), "schema version should be returned"); - assertNotNull(loaded.getConditions()); - assertTrue(loaded.getConditions().stream().anyMatch(c -> "IsAdmin".equals(c.getName())), - "IsAdmin condition should be returned"); - } - @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testFgaCacheUrlPointingAtTheApiHostStillWorks() { From 4ddf34b0034c7e1d09128cff71d9a05ab77adb54 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 19:04:04 +0300 Subject: [PATCH 5/8] test(fga): stop deleting the shared project's schema Another live suite reads loadSchema().getName() without a null guard, so leaving the last schema in place is safer than deleting it, and it drops the remaining schema churn. Also assert the non-routed authz call does not throw rather than that its list is non-null, which the wire can legitimately omit. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/descope/sdk/mgmt/impl/FGALiveTest.java | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java index 36ece543..8a58f546 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java @@ -1,5 +1,6 @@ package com.descope.sdk.mgmt.impl; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -27,7 +28,6 @@ import java.util.List; import java.util.Map; import org.apache.commons.lang3.StringUtils; -import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junitpioneer.jupiter.RetryingTest; @@ -93,16 +93,8 @@ void setUp() { authzService = services.getAuthzService(); } - // Cleaning up once, not per test: every test saves the schema it needs, and the project is - // shared with the other live tests, so deleting the schema between tests only adds churn. - @AfterAll - static void deleteSchema() { - try { - ManagementServiceBuilder.buildServices(TestUtils.getClient()).getAuthzService().deleteSchema(); - } catch (Exception ignored) { - // The schema may already be gone, nothing to clean up. - } - } + // No schema cleanup on purpose: every test here saves the schema it needs, and the project is + // shared with the other live tests, some of which read the schema without a null guard. @RetryingTest(value = 3, suspendForMs = 30000, onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) @@ -272,7 +264,7 @@ void testBadFgaCacheUrlFailsOnlyTheRoutedCalls() { assertNotNull(badFga.dryRunSchema(new FGASchema(SIMPLE_SCHEMA))); badFga.saveResourcesDetails(details); assertNotNull(badFga.loadResourcesDetails(Arrays.asList(new FGAResourceIdentifier("doc1", "document")))); - assertNotNull(badAuthz.resourceRelations("doc1")); + assertDoesNotThrow(() -> badAuthz.resourceRelations("doc1")); } @RetryingTest(value = 3, suspendForMs = 30000, From f8daea4d7093fa42826f9748ac6bd9a8ac75f885 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 19:34:04 +0300 Subject: [PATCH 6/8] test(fga): cheaper retries and one cache round-trip helper Drop the retry suspend to 10s so a deterministic failure does not idle for minutes across the JDK matrix, guard the deny-path whoCanAccess against a null list from the wire, and share one round-trip helper between the two cache tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../descope/sdk/mgmt/impl/FGALiveTest.java | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java index 8a58f546..066ffcf7 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java @@ -96,7 +96,7 @@ void setUp() { // No schema cleanup on purpose: every test here saves the schema it needs, and the project is // shared with the other live tests, some of which read the schema without a null guard. - @RetryingTest(value = 3, suspendForMs = 30000, + @RetryingTest(value = 3, suspendForMs = 10000, onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testGdriveSchemaRelationsAndChecks() { fgaService.saveSchema(new FGASchema(GDRIVE_SCHEMA)); @@ -149,7 +149,7 @@ void testGdriveSchemaRelationsAndChecks() { fgaService.deleteRelations(relations); } - @RetryingTest(value = 3, suspendForMs = 30000, + @RetryingTest(value = 3, suspendForMs = 10000, onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testAbacCheckWithContext() { fgaService.saveSchema(new FGASchema(ABAC_SCHEMA)); @@ -188,7 +188,8 @@ void testAbacCheckWithContext() { // Same context, through the authz queries that evaluate conditions. assertTrue(authzService.whoCanAccess("doc1", "viewer", "doc", contextOf("role", "admin")).contains("u1"), "admin role should satisfy the viewer condition"); - assertFalse(authzService.whoCanAccess("doc1", "viewer", "doc", contextOf("role", "user")).contains("u1"), + List denied1 = authzService.whoCanAccess("doc1", "viewer", "doc", contextOf("role", "user")); + assertFalse(denied1 != null && denied1.contains("u1"), "non-admin role should not satisfy the viewer condition"); assertNotNull(authzService.whatCanTargetAccess("u1", contextOf("role", "admin"))); @@ -200,7 +201,7 @@ void testAbacCheckWithContext() { fgaService.deleteRelations(Arrays.asList(viewer)); } - @RetryingTest(value = 3, suspendForMs = 30000, + @RetryingTest(value = 3, suspendForMs = 10000, onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testDryRunSchemaDoesNotSave() { fgaService.saveSchema(new FGASchema(SIMPLE_SCHEMA)); @@ -216,27 +217,17 @@ void testDryRunSchemaDoesNotSave() { assertTrue(fgaService.loadSchema().getDsl().contains("document"), "dry run must not save the schema"); } - @RetryingTest(value = 3, suspendForMs = 30000, + @RetryingTest(value = 3, suspendForMs = 10000, onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testFgaCacheUrlPointingAtTheApiHostStillWorks() { Client client = TestUtils.getClient(); // Not a real cache, but it proves the config reaches the request and the six routed calls // keep working through it. The trailing slash covers the URL normalization. client.setFgaCacheUri(client.getUri() + "/"); - FGAService cachedFga = ManagementServiceBuilder.buildServices(client).getFgaService(); - - cachedFga.saveSchema(new FGASchema(SIMPLE_SCHEMA)); - FGARelation relation = new FGARelation("doc1", "document", "viewer", "u1", "user"); - cachedFga.createRelations(Arrays.asList(relation)); - - List results = cachedFga.check(Arrays.asList(relation)); - assertEquals(1, results.size()); - assertTrue(results.get(0).isAllowed()); - - cachedFga.deleteRelations(Arrays.asList(relation)); + assertRoundTripWorks(client); } - @RetryingTest(value = 3, suspendForMs = 30000, + @RetryingTest(value = 3, suspendForMs = 10000, onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testBadFgaCacheUrlFailsOnlyTheRoutedCalls() { fgaService.saveSchema(new FGASchema(SIMPLE_SCHEMA)); @@ -267,7 +258,7 @@ void testBadFgaCacheUrlFailsOnlyTheRoutedCalls() { assertDoesNotThrow(() -> badAuthz.resourceRelations("doc1")); } - @RetryingTest(value = 3, suspendForMs = 30000, + @RetryingTest(value = 3, suspendForMs = 10000, onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testAgainstRealFgaCache() { String fgaCacheUrl = EnvironmentUtils.getFgaCacheURL(); @@ -275,6 +266,12 @@ void testAgainstRealFgaCache() { Client client = TestUtils.getClient(); client.setFgaCacheUri(fgaCacheUrl); + assertRoundTripWorks(client); + } + + // Saves a schema, creates a relation and checks it, all through whatever FGA cache the client + // is configured with. + private static void assertRoundTripWorks(Client client) { FGAService cachedFga = ManagementServiceBuilder.buildServices(client).getFgaService(); cachedFga.saveSchema(new FGASchema(SIMPLE_SCHEMA)); From c5d2d21ce1a585a0eb7c82f9bb81af65fffbc304 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 20:09:32 +0300 Subject: [PATCH 7/8] test(fga): assert whatCanTargetAccess actually reflects the context Asserting non-null passed on an empty list, so it would have passed even if the context were ignored. Contrast admin and non-admin the way the whoCanAccess assertions above do. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/descope/sdk/mgmt/impl/FGALiveTest.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java index 066ffcf7..62aa9378 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java @@ -10,6 +10,7 @@ import com.descope.exception.RateLimitExceededException; import com.descope.exception.ServerCommonException; +import com.descope.model.authz.Relation; import com.descope.model.client.Client; import com.descope.model.fga.FGACheckResult; import com.descope.model.fga.FGARelation; @@ -191,7 +192,10 @@ void testAbacCheckWithContext() { List denied1 = authzService.whoCanAccess("doc1", "viewer", "doc", contextOf("role", "user")); assertFalse(denied1 != null && denied1.contains("u1"), "non-admin role should not satisfy the viewer condition"); - assertNotNull(authzService.whatCanTargetAccess("u1", contextOf("role", "admin"))); + assertTrue(reachesDoc1(authzService.whatCanTargetAccess("u1", contextOf("role", "admin"))), + "admin role should reach doc1"); + assertFalse(reachesDoc1(authzService.whatCanTargetAccess("u1", contextOf("role", "user"))), + "non-admin role should not reach doc1"); FGASchema loaded = fgaService.loadSchema(); assertTrue(StringUtils.isNotBlank(loaded.getVersion()), "schema version should be returned"); @@ -269,6 +273,10 @@ void testAgainstRealFgaCache() { assertRoundTripWorks(client); } + private static boolean reachesDoc1(List relations) { + return relations != null && relations.stream().anyMatch(r -> "doc1".equals(r.getResource())); + } + // Saves a schema, creates a relation and checks it, all through whatever FGA cache the client // is configured with. private static void assertRoundTripWorks(Client client) { From 9e73bbb902b2012ccb8904447122108cc960e51b Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Mon, 10 Aug 2026 11:29:19 +0300 Subject: [PATCH 8/8] test(fga): note that the missing cache URL skips rather than fails Co-Authored-By: Claude Opus 5 (1M context) --- src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java index 62aa9378..f6c0a8dc 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGALiveTest.java @@ -266,6 +266,7 @@ void testBadFgaCacheUrlFailsOnlyTheRoutedCalls() { onExceptions = {RateLimitExceededException.class, ServerCommonException.class}) void testAgainstRealFgaCache() { String fgaCacheUrl = EnvironmentUtils.getFgaCacheURL(); + // Reports as skipped, not failed, when no cache URL is configured. assumeTrue(StringUtils.isNotBlank(fgaCacheUrl), "DESCOPE_FGA_CACHE_URL is not set"); Client client = TestUtils.getClient();