diff --git a/README.md b/README.md index 79e3852d..f521f895 100644 --- a/README.md +++ b/README.md @@ -1512,7 +1512,7 @@ try { // Load the current authorization schema try { FGASchema schema = fs.loadSchema(); - // Do something with schema.getDsl() + // Do something with schema.getDsl(), schema.getVersion() and schema.getConditions() } catch (DescopeException de) { // Handle the error } @@ -1533,12 +1533,34 @@ try { try { List results = fs.check(relations); for (FGACheckResult result : results) { - // Do something with result.isAllowed() + // Do something with result.isAllowed(), result.getRelation() and result.getInfo() } } catch (DescopeException de) { // Handle the error } +// Check relations against a schema that uses conditions (ABAC), passing the values +// the conditions are evaluated with +Map context = new HashMap<>(); +context.put("role", "admin"); + +try { + List results = fs.check(relations, context); + for (FGACheckResult result : results) { + FGACheckInfo info = result.getInfo(); + // info.isConditional() - the result was decided by a condition + // info.getMissingContext() - context variables the conditions needed but did not get + // info.getConditionalErr() - the condition could not be evaluated + } +} catch (DescopeException de) { + // Handle the error +} + +// The same context can be passed to the authz queries that evaluate conditions +AuthzService authz = descopeClient.getManagementServices().getAuthzService(); +List targets = authz.whoCanAccess("doc1", "viewer", "document", context); +List relationsForTarget = authz.whatCanTargetAccess("user123", context); + // Delete relations try { fs.deleteRelations(relations); diff --git a/src/main/java/com/descope/model/fga/FGACheckInfo.java b/src/main/java/com/descope/model/fga/FGACheckInfo.java index 7b18db3c..0477ddba 100644 --- a/src/main/java/com/descope/model/fga/FGACheckInfo.java +++ b/src/main/java/com/descope/model/fga/FGACheckInfo.java @@ -1,5 +1,6 @@ package com.descope.model.fga; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -11,4 +12,8 @@ @AllArgsConstructor public class FGACheckInfo { private boolean direct; + private boolean conditional; + private List missingContext; + private String conditionalErr; + private boolean factUsed; } diff --git a/src/main/java/com/descope/model/fga/FGACheckResponse.java b/src/main/java/com/descope/model/fga/FGACheckResponse.java new file mode 100644 index 00000000..643d6781 --- /dev/null +++ b/src/main/java/com/descope/model/fga/FGACheckResponse.java @@ -0,0 +1,25 @@ +package com.descope.model.fga; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FGACheckResponse { + private List tuples; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FGACheckResponseTuple { + private boolean allowed; + private FGARelation tuple; + private FGACheckInfo info; + } +} diff --git a/src/main/java/com/descope/model/fga/FGACondition.java b/src/main/java/com/descope/model/fga/FGACondition.java new file mode 100644 index 00000000..f64e0128 --- /dev/null +++ b/src/main/java/com/descope/model/fga/FGACondition.java @@ -0,0 +1,17 @@ +package com.descope.model.fga; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FGACondition { + private String name; + private List params; + private String expression; +} diff --git a/src/main/java/com/descope/model/fga/FGAConditionParam.java b/src/main/java/com/descope/model/fga/FGAConditionParam.java new file mode 100644 index 00000000..9b175a4a --- /dev/null +++ b/src/main/java/com/descope/model/fga/FGAConditionParam.java @@ -0,0 +1,15 @@ +package com.descope.model.fga; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FGAConditionParam { + private String name; + private String type; +} diff --git a/src/main/java/com/descope/model/fga/FGALoadSchemaResponse.java b/src/main/java/com/descope/model/fga/FGALoadSchemaResponse.java new file mode 100644 index 00000000..0fdfb32e --- /dev/null +++ b/src/main/java/com/descope/model/fga/FGALoadSchemaResponse.java @@ -0,0 +1,25 @@ +package com.descope.model.fga; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FGALoadSchemaResponse { + private String dsl; + private String version; + private FGALoadSchemaConditions schema; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FGALoadSchemaConditions { + private List conditions; + } +} diff --git a/src/main/java/com/descope/model/fga/FGASchema.java b/src/main/java/com/descope/model/fga/FGASchema.java index 3378907b..6880299e 100644 --- a/src/main/java/com/descope/model/fga/FGASchema.java +++ b/src/main/java/com/descope/model/fga/FGASchema.java @@ -1,5 +1,6 @@ package com.descope.model.fga; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -11,4 +12,10 @@ @AllArgsConstructor public class FGASchema { private String dsl; + private List conditions; + private String version; + + public FGASchema(String dsl) { + this.dsl = dsl; + } } diff --git a/src/main/java/com/descope/sdk/mgmt/AuthzService.java b/src/main/java/com/descope/sdk/mgmt/AuthzService.java index ef161663..119eaf96 100644 --- a/src/main/java/com/descope/sdk/mgmt/AuthzService.java +++ b/src/main/java/com/descope/sdk/mgmt/AuthzService.java @@ -9,6 +9,7 @@ import com.descope.model.authz.Schema; import java.time.Instant; import java.util.List; +import java.util.Map; /** Provides ReBAC authorization service APIs. */ public interface AuthzService { @@ -126,6 +127,23 @@ void saveRelationDefinition(RelationDefinition relationDefinition, String namesp */ List whoCanAccess(String resource, String relationDefinition, String namespace) throws DescopeException; + /** + * List all the users that have the given relation definition to the given resource, evaluating + * schema conditions against the given context. + * + *

Context keys become variables available to the CEL conditions defined in the schema. + * Values must be JSON serializable. + * + * @param resource The resource we are checking + * @param relationDefinition The relation definition we are querying + * @param namespace The namespace for the relation definition + * @param context Extra context for condition evaluation, may be null or empty + * @return {@link List} of users who have the given relation definition + * @throws DescopeException If there occurs any exception, a subtype of this exception will be thrown. + */ + List whoCanAccess(String resource, String relationDefinition, String namespace, + Map context) throws DescopeException; + /** * Return the list of all defined relations (not recursive) on the given resource. * @@ -153,6 +171,20 @@ void saveRelationDefinition(RelationDefinition relationDefinition, String namesp */ List whatCanTargetAccess(String target) throws DescopeException; + /** + * Return the list of all relations for the given target including derived relations from the + * schema tree, evaluating schema conditions against the given context. + * + *

Context keys become variables available to the CEL conditions defined in the schema. + * Values must be JSON serializable. + * + * @param target The target to check relations for + * @param context Extra context for condition evaluation, may be null or empty + * @return {@link List} of {@link Relation} that exist for the given target + * @throws DescopeException If there occurs any exception, a subtype of this exception will be thrown. + */ + List whatCanTargetAccess(String target, Map context) throws DescopeException; + /** * Return the list of all resources for the given target and a given relation definition * including derived resources from the schema tree. diff --git a/src/main/java/com/descope/sdk/mgmt/FGAService.java b/src/main/java/com/descope/sdk/mgmt/FGAService.java index 6bb71525..bac6711c 100644 --- a/src/main/java/com/descope/sdk/mgmt/FGAService.java +++ b/src/main/java/com/descope/sdk/mgmt/FGAService.java @@ -8,6 +8,7 @@ import com.descope.model.fga.FGASchema; import com.descope.model.fga.FGASchemaDryRunResponse; import java.util.List; +import java.util.Map; /** * Provides functions for managing Fine-Grained Authorization (FGA) in a project. @@ -68,6 +69,20 @@ public interface FGAService { */ List check(List relations) throws DescopeException; + /** + * Checks if the given FGA relations are satisfied, evaluating schema conditions against + * the given context. + * + *

Context keys become variables available to the CEL conditions defined in the schema, + * on top of any attributes the backend already holds. Values must be JSON serializable. + * + * @param relations list of relations to check + * @param context extra context for condition evaluation, may be null or empty + * @return list of check results indicating whether each relation is allowed + * @throws DescopeException if the operation fails + */ + List check(List relations, Map context) throws DescopeException; + /** * Loads detailed information for the given resource identifiers. * diff --git a/src/main/java/com/descope/sdk/mgmt/impl/AuthzServiceImpl.java b/src/main/java/com/descope/sdk/mgmt/impl/AuthzServiceImpl.java index 7359ffcc..9c83b8e6 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/AuthzServiceImpl.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/AuthzServiceImpl.java @@ -190,6 +190,12 @@ public List hasRelations(List relationQueries) thr @Override public List whoCanAccess(String resource, String relationDefinition, String namespace) throws DescopeException { + return whoCanAccess(resource, relationDefinition, namespace, null); + } + + @Override + public List whoCanAccess(String resource, String relationDefinition, String namespace, + Map context) throws DescopeException { if (StringUtils.isBlank(resource)) { throw ServerCommonException.invalidArgument("resource"); } @@ -202,6 +208,9 @@ public List whoCanAccess(String resource, String relationDefinition, Str ApiProxy apiProxy = getApiProxy(); Map request = mapOf("resource", resource, "relationDefinition", relationDefinition, "namespace", namespace); + if (context != null && !context.isEmpty()) { + request.put("context", context); + } WhoCanAccessResponse resp = apiProxy.post(getUri(MANAGEMENT_AUTHZ_RE_WHO), request, WhoCanAccessResponse.class); return resp.getTargets(); } @@ -230,11 +239,19 @@ public List targetsRelations(List targets) throws DescopeExcep @Override public List whatCanTargetAccess(String target) throws DescopeException { + return whatCanTargetAccess(target, null); + } + + @Override + public List whatCanTargetAccess(String target, Map context) throws DescopeException { if (StringUtils.isBlank(target)) { throw ServerCommonException.invalidArgument("target"); } ApiProxy apiProxy = getApiProxy(); Map request = mapOf("target", target); + if (context != null && !context.isEmpty()) { + request.put("context", context); + } RelationsResponse resp = apiProxy.post(getUri(MANAGEMENT_AUTHZ_RE_TARGET_ALL), request, RelationsResponse.class); return resp.getRelations(); } diff --git a/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java b/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java index 0a3a82e1..2b15352e 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java @@ -12,7 +12,11 @@ import com.descope.exception.DescopeException; import com.descope.exception.ServerCommonException; import com.descope.model.client.Client; +import com.descope.model.fga.FGACheckInfo; +import com.descope.model.fga.FGACheckResponse; +import com.descope.model.fga.FGACheckResponse.FGACheckResponseTuple; import com.descope.model.fga.FGACheckResult; +import com.descope.model.fga.FGALoadSchemaResponse; import com.descope.model.fga.FGARelation; import com.descope.model.fga.FGAResourceDetails; import com.descope.model.fga.FGAResourceIdentifier; @@ -62,12 +66,17 @@ public FGASchemaDryRunResponse dryRunSchema(FGASchema schema) throws DescopeExce @Override public FGASchema loadSchema() throws DescopeException { ApiProxy apiProxy = getApiProxy(); - Map response = apiProxy.getArray(getUri(MANAGEMENT_FGA_LOAD_SCHEMA), - new TypeReference>() {}); + FGALoadSchemaResponse response = apiProxy.get(getUri(MANAGEMENT_FGA_LOAD_SCHEMA), FGALoadSchemaResponse.class); FGASchema schema = new FGASchema(); - if (response.containsKey("dsl")) { - schema.setDsl((String) response.get("dsl")); + schema.setConditions(new ArrayList<>()); + if (response == null) { + return schema; + } + schema.setDsl(response.getDsl()); + schema.setVersion(response.getVersion()); + if (response.getSchema() != null && response.getSchema().getConditions() != null) { + schema.setConditions(response.getSchema().getConditions()); } return schema; } @@ -100,32 +109,37 @@ public void deleteRelations(List relations) throws DescopeException @Override public List check(List relations) throws DescopeException { + return check(relations, null); + } + + @Override + public List check(List relations, Map context) + throws DescopeException { if (relations == null || relations.isEmpty()) { throw ServerCommonException.invalidArgument("relations list"); } Map requestBody = new HashMap<>(); requestBody.put("tuples", relations); + if (context != null && !context.isEmpty()) { + requestBody.put("context", context); + } ApiProxy apiProxy = getApiProxy(); - Map response = apiProxy.postAndGetArray(getUri(MANAGEMENT_FGA_CHECK), - requestBody, new TypeReference>() {}); + FGACheckResponse response = apiProxy.post(getUri(MANAGEMENT_FGA_CHECK), requestBody, FGACheckResponse.class); - if (response.containsKey("tuples")) { - // Convert the response tuples to FGACheckResult objects - @SuppressWarnings("unchecked") - List> tuples = (List>) response.get("tuples"); - List results = new ArrayList<>(); - for (Map tuple : tuples) { - FGACheckResult result = new FGACheckResult(); - if (tuple.containsKey("allowed")) { - result.setAllowed((Boolean) tuple.get("allowed")); - } - results.add(result); - } + List results = new ArrayList<>(); + if (response == null || response.getTuples() == null) { return results; } - return new ArrayList<>(); + for (FGACheckResponseTuple tuple : response.getTuples()) { + FGACheckInfo info = tuple.getInfo() == null ? new FGACheckInfo() : tuple.getInfo(); + if (info.getMissingContext() == null) { + info.setMissingContext(new ArrayList<>()); + } + results.add(new FGACheckResult(tuple.isAllowed(), tuple.getTuple(), info)); + } + return results; } @Override diff --git a/src/test/java/com/descope/sdk/mgmt/impl/AuthzServiceImplTest.java b/src/test/java/com/descope/sdk/mgmt/impl/AuthzServiceImplTest.java index 47093d86..a97a5f72 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/AuthzServiceImplTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/AuthzServiceImplTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import com.descope.exception.RateLimitExceededException; import com.descope.exception.ServerCommonException; @@ -36,10 +37,13 @@ import java.time.Instant; import java.time.Period; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.RetryingTest; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; public class AuthzServiceImplTest { @@ -340,6 +344,40 @@ void testWhoCanAccessForSuccess() { } } + @SuppressWarnings("unchecked") + @Test + void testWhoCanAccessWithContext() { + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new WhoCanAccessResponse(Arrays.asList("kuku"))).when(apiProxy).post(any(), any(), any()); + Map context = new HashMap<>(); + context.put("role", "admin"); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + authzService.whoCanAccess("kiki", "kuku", "kaka", context); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(apiProxy).post(any(), bodyCaptor.capture(), any()); + assertEquals(context, bodyCaptor.getValue().get("context")); + } + } + + @SuppressWarnings("unchecked") + @Test + void testWhoCanAccessWithEmptyContext() { + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new WhoCanAccessResponse(Arrays.asList("kuku"))).when(apiProxy).post(any(), any(), any()); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + authzService.whoCanAccess("kiki", "kuku", "kaka", new HashMap<>()); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(apiProxy).post(any(), bodyCaptor.capture(), any()); + assertFalse(bodyCaptor.getValue().containsKey("context")); + } + } + @Test void testResourceRelationsForNoResource() { ServerCommonException thrown = @@ -403,6 +441,24 @@ void testWhatCanTargetAccessForSuccess() { } } + @SuppressWarnings("unchecked") + @Test + void testWhatCanTargetAccessWithContext() { + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new RelationsResponse(Arrays.asList(new Relation()))).when(apiProxy).post(any(), any(), any()); + Map context = new HashMap<>(); + context.put("role", "admin"); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + authzService.whatCanTargetAccess("kiki", context); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(apiProxy).post(any(), bodyCaptor.capture(), any()); + assertEquals(context, bodyCaptor.getValue().get("context")); + } + } + @Test void testWhatCanTargetAccessWithRelationForNoTarget() { ServerCommonException thrown = diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java index 50fa5581..52387c34 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java @@ -1,6 +1,7 @@ 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; @@ -13,7 +14,14 @@ import com.descope.exception.RateLimitExceededException; import com.descope.exception.ServerCommonException; import com.descope.model.client.Client; +import com.descope.model.fga.FGACheckInfo; +import com.descope.model.fga.FGACheckResponse; +import com.descope.model.fga.FGACheckResponse.FGACheckResponseTuple; import com.descope.model.fga.FGACheckResult; +import com.descope.model.fga.FGACondition; +import com.descope.model.fga.FGAConditionParam; +import com.descope.model.fga.FGALoadSchemaResponse; +import com.descope.model.fga.FGALoadSchemaResponse.FGALoadSchemaConditions; import com.descope.model.fga.FGARelation; import com.descope.model.fga.FGAResourceDetails; import com.descope.model.fga.FGAResourceIdentifier; @@ -37,6 +45,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junitpioneer.jupiter.RetryingTest; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -117,20 +126,37 @@ void testDryRunSchema_EmptyDSL() { assertThrows(ServerCommonException.class, () -> fgaService.dryRunSchema(new FGASchema(""))); } - @SuppressWarnings("unchecked") @Test void testLoadSchema_Success() throws Exception { - Map response = new HashMap<>(); - response.put("dsl", "model AuthZ 1.0\ntype user"); + FGACondition condition = new FGACondition("IsAdmin", + Arrays.asList(new FGAConditionParam("role", "string")), "role == \"admin\""); + FGALoadSchemaResponse response = new FGALoadSchemaResponse("model AuthZ 1.0\ntype user", "v1", + new FGALoadSchemaConditions(Arrays.asList(condition))); try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); - when(apiProxy.getArray(any(), any(TypeReference.class))).thenReturn(response); + when(apiProxy.get(any(), eq(FGALoadSchemaResponse.class))).thenReturn(response); FGASchema result = fgaService.loadSchema(); assertNotNull(result); assertEquals("model AuthZ 1.0\ntype user", result.getDsl()); + assertEquals("v1", result.getVersion()); + assertEquals(1, result.getConditions().size()); + assertEquals("IsAdmin", result.getConditions().get(0).getName()); + assertEquals("role", result.getConditions().get(0).getParams().get(0).getName()); + } + } + + @Test + void testLoadSchema_ConditionsAreNeverNull() throws Exception { + FGALoadSchemaResponse response = new FGALoadSchemaResponse("model AuthZ 1.0\ntype user", "v1", null); + + try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { + mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + when(apiProxy.get(any(), eq(FGALoadSchemaResponse.class))).thenReturn(response); + + assertTrue(fgaService.loadSchema().getConditions().isEmpty()); } } @@ -169,28 +195,98 @@ void testDeleteRelations_Success() throws Exception { } } - @SuppressWarnings("unchecked") @Test void testCheck_Success() throws Exception { List relations = Arrays.asList( new FGARelation("doc1", "document", "owner", "user1", "user") ); - Map checkResultMap = new HashMap<>(); - checkResultMap.put("allowed", true); - - Map response = new HashMap<>(); - response.put("tuples", Arrays.asList(checkResultMap)); + FGACheckResponse response = new FGACheckResponse(Arrays.asList( + new FGACheckResponseTuple(true, relations.get(0), FGACheckInfo.builder().direct(true).build()))); try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); - when(apiProxy.postAndGetArray(any(), any(), any(TypeReference.class))).thenReturn(response); + when(apiProxy.post(any(), any(), eq(FGACheckResponse.class))).thenReturn(response); List results = fgaService.check(relations); assertNotNull(results); assertEquals(1, results.size()); - assertEquals(true, results.get(0).isAllowed()); + assertTrue(results.get(0).isAllowed()); + assertEquals(relations.get(0), results.get(0).getRelation()); + assertTrue(results.get(0).getInfo().isDirect()); + } + } + + @SuppressWarnings("unchecked") + @Test + void testCheck_WithContext() throws Exception { + List relations = Arrays.asList( + new FGARelation("doc1", "doc", "viewer", "user1", "user") + ); + Map context = new HashMap<>(); + context.put("role", "admin"); + + FGACheckInfo info = FGACheckInfo.builder().conditional(true).conditionalErr("bad type") + .missingContext(Arrays.asList("action")).factUsed(true).build(); + FGACheckResponse response = new FGACheckResponse(Arrays.asList( + new FGACheckResponseTuple(false, relations.get(0), info))); + + try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { + mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + when(apiProxy.post(any(), any(), eq(FGACheckResponse.class))).thenReturn(response); + + List results = fgaService.check(relations, context); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(apiProxy).post(any(), bodyCaptor.capture(), eq(FGACheckResponse.class)); + assertEquals(context, bodyCaptor.getValue().get("context")); + + FGACheckInfo resultInfo = results.get(0).getInfo(); + assertTrue(resultInfo.isConditional()); + assertTrue(resultInfo.isFactUsed()); + assertEquals("bad type", resultInfo.getConditionalErr()); + assertEquals(Arrays.asList("action"), resultInfo.getMissingContext()); + } + } + + @SuppressWarnings("unchecked") + @Test + void testCheck_EmptyContextIsNotSent() throws Exception { + List relations = Arrays.asList( + new FGARelation("doc1", "doc", "viewer", "user1", "user") + ); + + try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { + mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + when(apiProxy.post(any(), any(), eq(FGACheckResponse.class))) + .thenReturn(new FGACheckResponse(Arrays.asList())); + + fgaService.check(relations, new HashMap<>()); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(apiProxy).post(any(), bodyCaptor.capture(), eq(FGACheckResponse.class)); + assertFalse(bodyCaptor.getValue().containsKey("context")); + } + } + + @Test + void testCheck_MissingInfoIsNotNull() throws Exception { + List relations = Arrays.asList( + new FGARelation("doc1", "doc", "viewer", "user1", "user") + ); + FGACheckResponse response = new FGACheckResponse(Arrays.asList( + new FGACheckResponseTuple(true, relations.get(0), null))); + + try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { + mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + when(apiProxy.post(any(), any(), eq(FGACheckResponse.class))).thenReturn(response); + + List results = fgaService.check(relations); + + assertNotNull(results.get(0).getInfo()); + assertFalse(results.get(0).getInfo().isConditional()); + assertTrue(results.get(0).getInfo().getMissingContext().isEmpty()); } }