diff --git a/README.md b/README.md index 7846c33f0..02c1bb719 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,16 @@ docker run --rm hugegraph/hugegraph-loader ./bin/hugegraph-loader.sh -f example. ## Module Overview +Before committing a new source or test file, run the same license-header check +used by CI (`license-eye` from `apache/skywalking-eyes` is required): + +```bash +./tools/check-license-header.sh +``` + +Do not use shortened Apache headers: the complete header configured in +`.licenserc.yaml` is required. + ### hugegraph-client **Purpose**: Official Java SDK for HugeGraph Server diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/api/auth/GroupAPI.java b/hugegraph-client/src/main/java/org/apache/hugegraph/api/auth/GroupAPI.java index c788d4a6f..d8144106e 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/api/auth/GroupAPI.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/api/auth/GroupAPI.java @@ -33,6 +33,10 @@ public GroupAPI(RestClient client) { super(client); } + public GroupAPI(RestClient client, String graphSpace) { + super(client, graphSpace); + } + @Override protected String type() { return HugeType.GROUP.string(); diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/api/auth/ManagerAPI.java b/hugegraph-client/src/main/java/org/apache/hugegraph/api/auth/ManagerAPI.java index ab4fd1925..c5f1ce76d 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/api/auth/ManagerAPI.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/api/auth/ManagerAPI.java @@ -35,7 +35,10 @@ public ManagerAPI(RestClient client, String graphSpace) { } public UserManager create(UserManager userManager) { - RestResult result = this.client.post(this.path(), userManager); + Map payload = new HashMap<>(); + payload.put("user", userManager.user()); + payload.put("type", userManager.type()); + RestResult result = this.client.post(this.path(), payload); return result.readObject(UserManager.class); } diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/AuthManager.java b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/AuthManager.java index 6d84fdb44..af69f518b 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/AuthManager.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/AuthManager.java @@ -48,8 +48,10 @@ public class AuthManager { + private final RestClient client; private final TargetAPI targetAPI; private final GroupAPI groupAPI; + private final GroupAPI graphSpaceGroupAPI; private final UserAPI userAPI; private final AccessAPI accessAPI; private final BelongAPI belongAPI; @@ -60,8 +62,10 @@ public class AuthManager { private final ManagerAPI managerAPI; public AuthManager(RestClient client, String graphSpace, String graph) { + this.client = client; this.targetAPI = new TargetAPI(client, graphSpace); this.groupAPI = new GroupAPI(client); + this.graphSpaceGroupAPI = new GroupAPI(client, graphSpace); this.userAPI = new UserAPI(client, graphSpace); this.accessAPI = new AccessAPI(client, graphSpace); this.projectAPI = new ProjectAPI(client, graphSpace); @@ -120,6 +124,30 @@ public void deleteGroup(Object id) { this.groupAPI.delete(id); } + public List listGraphSpaceGroups() { + return this.listGraphSpaceGroups(-1); + } + + public List listGraphSpaceGroups(int limit) { + return this.graphSpaceGroupAPI.list(limit); + } + + public Group getGraphSpaceGroup(Object id) { + return this.graphSpaceGroupAPI.get(id); + } + + public Group createGraphSpaceGroup(Group group) { + return this.graphSpaceGroupAPI.create(group); + } + + public Group updateGraphSpaceGroup(Group group) { + return this.graphSpaceGroupAPI.update(group); + } + + public void deleteGraphSpaceGroup(Object id) { + this.graphSpaceGroupAPI.delete(id); + } + public List listUsers() { return this.listUsers(-1); } @@ -305,7 +333,15 @@ public UserManager addSpaceAdmin(String user, String graphSpace) { userManager.type(HugePermission.SPACE); userManager.graphSpace(graphSpace); userManager.user(user); - return this.managerAPI.create(userManager); + return this.managerAPI(graphSpace).create(userManager); + } + + public UserManager addSpaceMember(String user, String graphSpace) { + UserManager userManager = new UserManager(); + userManager.type(HugePermission.SPACE_MEMBER); + userManager.graphSpace(graphSpace); + userManager.user(user); + return this.managerAPI(graphSpace).create(userManager); } public void delSuperAdmin(String user) { @@ -313,11 +349,27 @@ public void delSuperAdmin(String user) { } public void delSpaceAdmin(String user, String graphSpace) { - this.managerAPI.delete(user, HugePermission.SPACE, graphSpace); + this.managerAPI(graphSpace).delete(user, HugePermission.SPACE, + graphSpace); + } + + public void delSpaceMember(String user, String graphSpace) { + this.managerAPI(graphSpace).delete(user, HugePermission.SPACE_MEMBER, + graphSpace); } public List listSpaceAdmin(String graphSpace) { - return this.managerAPI.list(HugePermission.SPACE, graphSpace); + return this.managerAPI(graphSpace).list(HugePermission.SPACE, + graphSpace); + } + + public List listSpaceMember(String graphSpace) { + return this.managerAPI(graphSpace).list(HugePermission.SPACE_MEMBER, + graphSpace); + } + + private ManagerAPI managerAPI(String graphSpace) { + return new ManagerAPI(this.client, graphSpace); } public List listSuperAdmin() { @@ -329,14 +381,17 @@ public boolean isSuperAdmin() { } public boolean isSpaceAdmin(String graphSpace) { - return this.managerAPI.checkPermission(HugePermission.SPACE, graphSpace); + return this.managerAPI(graphSpace) + .checkPermission(HugePermission.SPACE, graphSpace); } public boolean checkDefaultRole(String graphSpace, String role) { - return this.managerAPI.checkDefaultRole(graphSpace, role, ""); + return this.managerAPI(graphSpace) + .checkDefaultRole(graphSpace, role, ""); } public boolean checkDefaultRole(String graphSpace, String role, String graph) { - return this.managerAPI.checkDefaultRole(graphSpace, role, graph); + return this.managerAPI(graphSpace) + .checkDefaultRole(graphSpace, role, graph); } } diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/GremlinManager.java b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/GremlinManager.java index b77a9d3a7..06d5420d2 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/GremlinManager.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/GremlinManager.java @@ -21,11 +21,15 @@ import org.apache.hugegraph.api.gremlin.GremlinRequest; import org.apache.hugegraph.api.job.GremlinJobAPI; import org.apache.hugegraph.client.RestClient; +import org.apache.hugegraph.exception.ServerException; import org.apache.hugegraph.structure.gremlin.Response; import org.apache.hugegraph.structure.gremlin.ResultSet; public class GremlinManager { + public static final String IDEMPOTENT_TRAVERSAL_FALLBACK_MARKER = + "// hugegraph-client:idempotent-traversal-fallback\n"; + private final GraphManager graphManager; private final GremlinAPI gremlinAPI; @@ -43,22 +47,49 @@ public GremlinManager(RestClient client, String graphSpace, String graph, } public ResultSet execute(GremlinRequest request) { - if (this.gremlinAPI.isSupportGs()) { + bindAliases(request, this.gremlinAPI.isSupportGs(), this.graphSpace, + this.graph); + + Response response; + try { + response = this.gremlinAPI.post(request); + } catch (ServerException e) { + if (!this.gremlinAPI.isSupportGs() || + !allowTraversalFallback(request, e.getMessage())) { + throw e; + } + prepareTraversalFallback(request); + response = this.gremlinAPI.post(request); + } + response.graphManager(this.graphManager); + // TODO: Can add some checks later + return response.result(); + } + + static void bindAliases(GremlinRequest request, boolean supportGraphSpace, + String graphSpace, String graph) { + if (supportGraphSpace) { // Bind "graph" and graph space to all graphs - request.aliases.put("graph", this.graphSpace + "-" + this.graph); + request.aliases.put("graph", graphSpace + "-" + graph); // Bind "g" and graph space to all graphs by custom rule which define in gremlin server. - request.aliases.put("g", "__g_" + this.graphSpace + "-" + this.graph); + request.aliases.put("g", "__g_" + graphSpace + "-" + graph); } else { // Bind "graph" to all graphs - request.aliases.put("graph", this.graph); + request.aliases.put("graph", graph); // Bind "g" to all graphs by custom rule which define in gremlin server. - request.aliases.put("g", "__g_" + this.graph); + request.aliases.put("g", "__g_" + graph); } + } - Response response = this.gremlinAPI.post(request); - response.graphManager(this.graphManager); - // TODO: Can add some checks later - return response.result(); + static void prepareTraversalFallback(GremlinRequest request) { + request.aliases.remove("g"); + request.gremlin = "g = graph.traversal();\n" + request.gremlin; + } + + static boolean allowTraversalFallback(GremlinRequest request, + String message) { + return request.gremlin.startsWith(IDEMPOTENT_TRAVERSAL_FALLBACK_MARKER) && + message != null && message.contains("Could not rebind [g]"); } public long executeAsTask(GremlinRequest request) { diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/factory/PDHugeClientFactory.java b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/factory/PDHugeClientFactory.java index eacc4f2a1..a9469ef19 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/factory/PDHugeClientFactory.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/factory/PDHugeClientFactory.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.driver.factory; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -185,6 +186,9 @@ public List getURLs(String cluster, String graphSpace, .build(); NodeInfos nodeInfos = client.getNodeInfos(query); + if (nodeInfos == null) { + return Collections.emptyList(); + } List urls = nodeInfos.getInfoList().stream() .map(nodeInfo -> nodeInfo.getAddress()) @@ -205,6 +209,9 @@ protected List getURLsWithConfig(String cluster, .build(); NodeInfos nodeInfos = client.getNodeInfos(query); + if (nodeInfos == null) { + return Collections.emptyList(); + } List urls = nodeInfos.getInfoList().stream() .map(nodeInfo -> nodeInfo.getAddress()) diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Access.java b/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Access.java index b8788dd00..ed86583cc 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Access.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Access.java @@ -26,7 +26,7 @@ public class Access extends AuthElement { - @JsonProperty(value = "graphspace", access = JsonProperty.Access.READ_ONLY) + @JsonProperty(value = "graphspace", access = JsonProperty.Access.WRITE_ONLY) protected String graphSpace; @JsonProperty("group") private Object group; diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Belong.java b/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Belong.java index 57ed3faa1..a2ac06451 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Belong.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Belong.java @@ -26,7 +26,7 @@ public class Belong extends AuthElement { - @JsonProperty(value = "graphspace", access = JsonProperty.Access.READ_ONLY) + @JsonProperty(value = "graphspace", access = JsonProperty.Access.WRITE_ONLY) protected String graphSpace; @JsonProperty("user") protected Object user; diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/HugePermission.java b/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/HugePermission.java index 0267f2677..ff0c4d4fe 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/HugePermission.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/HugePermission.java @@ -27,6 +27,7 @@ public enum HugePermission { EXECUTE(0x08), SPACE(0x1f), + SPACE_MEMBER(0x2f), ADMIN(0x7f); private final byte code; diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Target.java b/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Target.java index b606b4aad..02e049f01 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Target.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/structure/auth/Target.java @@ -33,7 +33,8 @@ public class Target extends AuthElement { @JsonProperty("target_name") protected String name; - @JsonProperty("graphspace") + @JsonProperty(value = "graphspace", + access = JsonProperty.Access.WRITE_ONLY) protected String graphSpace; @JsonProperty("target_graph") protected String graph; diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/structure/space/GraphSpace.java b/hugegraph-client/src/main/java/org/apache/hugegraph/structure/space/GraphSpace.java index 5120e7e46..b2a130515 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/structure/space/GraphSpace.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/structure/space/GraphSpace.java @@ -17,7 +17,6 @@ package org.apache.hugegraph.structure.space; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @@ -80,9 +79,9 @@ public class GraphSpace { @JsonProperty("dp_password") private String dpPassWord; - @JsonIgnore + @JsonProperty("create_time") private String createTime; - @JsonIgnore + @JsonProperty("update_time") private String updateTime; @JsonProperty("configs") diff --git a/hugegraph-client/src/test/java/org/apache/hugegraph/driver/GremlinManagerTest.java b/hugegraph-client/src/test/java/org/apache/hugegraph/driver/GremlinManagerTest.java new file mode 100644 index 000000000..29bd1c42c --- /dev/null +++ b/hugegraph-client/src/test/java/org/apache/hugegraph/driver/GremlinManagerTest.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.driver; + +import org.apache.hugegraph.api.gremlin.GremlinRequest; +import org.apache.hugegraph.testutil.Assert; +import org.junit.Test; + +public class GremlinManagerTest { + + @Test + public void testBindGraphSpaceAliasesForDefaultGraphSpace() { + GremlinRequest request = new GremlinRequest("g.V().count()"); + + GremlinManager.bindAliases(request, true, "DEFAULT", "hugegraph"); + + Assert.assertEquals("DEFAULT-hugegraph", + request.aliases.get("graph")); + Assert.assertEquals("__g_DEFAULT-hugegraph", + request.aliases.get("g")); + } + + @Test + public void testBindGraphSpaceAliasesForNamedGraphSpace() { + GremlinRequest request = new GremlinRequest("g.V().count()"); + + GremlinManager.bindAliases(request, true, "analytics", "hugegraph"); + + Assert.assertEquals("analytics-hugegraph", + request.aliases.get("graph")); + Assert.assertEquals("__g_analytics-hugegraph", + request.aliases.get("g")); + } + + @Test + public void testPrepareTraversalFallback() { + GremlinRequest request = new GremlinRequest( + GremlinManager.IDEMPOTENT_TRAVERSAL_FALLBACK_MARKER + + "g.V().count()"); + GremlinManager.bindAliases(request, true, "DEFAULT", "hugegraph"); + + GremlinManager.prepareTraversalFallback(request); + + Assert.assertEquals("DEFAULT-hugegraph", + request.aliases.get("graph")); + Assert.assertFalse(request.aliases.containsKey("g")); + Assert.assertEquals("g = graph.traversal();\n" + + GremlinManager.IDEMPOTENT_TRAVERSAL_FALLBACK_MARKER + + "g.V().count()", + request.gremlin); + } + + @Test + public void testTraversalFallbackRequiresExplicitIdempotentMarker() { + GremlinRequest ordinary = new GremlinRequest("g.V().count()"); + GremlinRequest idempotent = new GremlinRequest( + GremlinManager.IDEMPOTENT_TRAVERSAL_FALLBACK_MARKER + + "g.V().count()"); + + Assert.assertFalse(GremlinManager.allowTraversalFallback( + ordinary, "Could not rebind [g]")); + Assert.assertFalse(GremlinManager.allowTraversalFallback( + idempotent, null)); + Assert.assertFalse(GremlinManager.allowTraversalFallback( + idempotent, "runtime failure")); + Assert.assertTrue(GremlinManager.allowTraversalFallback( + idempotent, "Could not rebind [g]")); + } +} diff --git a/hugegraph-client/src/test/java/org/apache/hugegraph/unit/ManagerAPITest.java b/hugegraph-client/src/test/java/org/apache/hugegraph/unit/ManagerAPITest.java new file mode 100644 index 000000000..7fdf1aebd --- /dev/null +++ b/hugegraph-client/src/test/java/org/apache/hugegraph/unit/ManagerAPITest.java @@ -0,0 +1,232 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.unit; + +import java.util.Map; + +import org.apache.hugegraph.client.RestClient; +import org.apache.hugegraph.driver.AuthManager; +import org.apache.hugegraph.rest.RestResult; +import org.apache.hugegraph.structure.auth.Access; +import org.apache.hugegraph.structure.auth.Belong; +import org.apache.hugegraph.structure.auth.HugePermission; +import org.apache.hugegraph.structure.auth.Group; +import org.apache.hugegraph.structure.auth.Target; +import org.apache.hugegraph.structure.auth.UserManager; +import org.apache.hugegraph.testutil.Assert; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +public class ManagerAPITest extends BaseUnitTest { + + @Test + public void testScopedTargetGraphSpaceIsResponseOnly() { + Target target = new Target(); + target.name("target"); + target.graphSpace("SPACE_A"); + target.graph("hugegraph"); + target.description("description"); + + String payload = serialize(target); + + Assert.assertFalse(payload.contains("\"graphspace\"")); + Assert.assertContains("\"target_description\":\"description\"", + payload); + + Target response = deserialize("{\"graphspace\":\"SPACE_A\"}", + Target.class); + Assert.assertEquals("SPACE_A", response.graphSpace()); + + Access access = new Access(); + access.graphSpace("SPACE_A"); + Assert.assertFalse(serialize(access).contains("\"graphspace\"")); + Assert.assertEquals("SPACE_A", deserialize( + "{\"graphspace\":\"SPACE_A\"}", Access.class) + .graphSpace()); + + Belong belong = new Belong(); + belong.graphSpace("SPACE_A"); + Assert.assertFalse(serialize(belong).contains("\"graphspace\"")); + Assert.assertEquals("SPACE_A", deserialize( + "{\"graphspace\":\"SPACE_A\"}", Belong.class) + .graphSpace()); + } + + @Test + public void testGraphSpaceGroupsUseScopedPathWithoutChangingGlobalPath() { + RestClient client = Mockito.mock(RestClient.class); + RestResult result = Mockito.mock(RestResult.class); + Group group = new Group(); + group.name("role"); + Mockito.when(result.readObject(Group.class)).thenReturn(group); + Mockito.when(result.readList("groups", Group.class)) + .thenReturn(java.util.Collections.emptyList()); + + ArgumentCaptor postPaths = + ArgumentCaptor.forClass(String.class); + Mockito.when(client.post(postPaths.capture(), + Mockito.any(Group.class))) + .thenReturn(result); + ArgumentCaptor listPath = + ArgumentCaptor.forClass(String.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> params = + ArgumentCaptor.forClass(Map.class); + Mockito.when(client.get(listPath.capture(), params.capture())) + .thenReturn(result); + + AuthManager auth = new AuthManager(client, "SPACE_A", null); + auth.createGraphSpaceGroup(group); + auth.createGroup(group); + auth.listGraphSpaceGroups(10); + + Assert.assertEquals("graphspaces/SPACE_A/auth/groups", + postPaths.getAllValues().get(0)); + Assert.assertEquals("auth/groups", + postPaths.getAllValues().get(1)); + Assert.assertEquals("graphspaces/SPACE_A/auth/groups", + listPath.getValue()); + Assert.assertEquals(10, params.getValue().get("limit")); + } + + @Test + public void testSpaceAdminUsesPathGraphSpaceAndMinimalBody() { + RestClient client = Mockito.mock(RestClient.class); + RestResult result = Mockito.mock(RestResult.class); + Mockito.when(result.readObject(UserManager.class)) + .thenReturn(new UserManager()); + + ArgumentCaptor path = ArgumentCaptor.forClass(String.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> payload = + ArgumentCaptor.forClass(Map.class); + Mockito.when(client.post(path.capture(), payload.capture())) + .thenReturn(result); + + AuthManager auth = new AuthManager(client, "DEFAULT", null); + auth.addSpaceAdmin("alice", "demo_space"); + + Assert.assertEquals("graphspaces/demo_space/auth/managers", + path.getValue()); + Assert.assertEquals("alice", payload.getValue().get("user")); + Assert.assertEquals(HugePermission.SPACE, + payload.getValue().get("type")); + Assert.assertFalse(payload.getValue().containsKey("graphspace")); + } + + @Test + public void testSpaceChecksUseEachTargetGraphSpacePath() { + RestClient client = Mockito.mock(RestClient.class); + RestResult result = Mockito.mock(RestResult.class); + Mockito.when(result.readObject(Map.class)) + .thenReturn(java.util.Collections.singletonMap("check", true)); + + ArgumentCaptor path = ArgumentCaptor.forClass(String.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> params = + ArgumentCaptor.forClass(Map.class); + Mockito.when(client.get(path.capture(), params.capture())) + .thenReturn(result); + + AuthManager auth = new AuthManager(client, "DEFAULT", null); + Assert.assertTrue(auth.isSpaceAdmin("space_a")); + Assert.assertTrue(auth.checkDefaultRole("space_b", "analyst")); + + Assert.assertEquals("graphspaces/space_a/auth/managers/check", + path.getAllValues().get(0)); + Assert.assertEquals("graphspaces/space_b/auth/managers/default", + path.getAllValues().get(1)); + Assert.assertEquals(HugePermission.SPACE, + params.getAllValues().get(0).get("type")); + Assert.assertEquals("space_a", + params.getAllValues().get(0).get("graphspace")); + Assert.assertEquals("space_b", + params.getAllValues().get(1).get("graphspace")); + Assert.assertEquals("analyst", + params.getAllValues().get(1).get("role")); + Assert.assertFalse(params.getAllValues().get(1).containsKey("graph")); + } + + @Test + public void testAllSpaceOperationsUseTargetGraphSpacePath() { + RestClient client = Mockito.mock(RestClient.class); + RestResult result = Mockito.mock(RestResult.class); + Mockito.when(result.readList("admins", String.class)) + .thenReturn(java.util.Collections.singletonList("alice")); + Mockito.when(result.readObject(Map.class)) + .thenReturn(java.util.Collections.singletonMap("check", true)); + + ArgumentCaptor deletePath = + ArgumentCaptor.forClass(String.class); + ArgumentCaptor getPath = ArgumentCaptor.forClass(String.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> deleteParams = + ArgumentCaptor.forClass(Map.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> getParams = + ArgumentCaptor.forClass(Map.class); + Mockito.when(client.get(getPath.capture(), getParams.capture())) + .thenReturn(result); + Mockito.when(client.post(Mockito.anyString(), Mockito.anyMap())) + .thenReturn(result); + + AuthManager auth = new AuthManager(client, "DEFAULT", null); + auth.delSpaceAdmin("alice", "space_a"); + auth.addSpaceMember("bob", "space_a"); + auth.delSpaceMember("bob", "space_a"); + Assert.assertEquals(java.util.Collections.singletonList("alice"), + auth.listSpaceAdmin("space_b")); + Assert.assertEquals(java.util.Collections.singletonList("alice"), + auth.listSpaceMember("space_b")); + Assert.assertTrue(auth.checkDefaultRole("space_c", "analyst", + "graph_1")); + + Mockito.verify(client, Mockito.times(2)).delete(deletePath.capture(), + deleteParams.capture()); + Assert.assertEquals("graphspaces/space_a/auth/managers", + deletePath.getAllValues().get(0)); + Assert.assertEquals("space_a", + deleteParams.getAllValues().get(0) + .get("graphspace")); + Assert.assertEquals("alice", + deleteParams.getAllValues().get(0).get("user")); + Assert.assertEquals(HugePermission.SPACE, + deleteParams.getAllValues().get(0).get("type")); + Assert.assertEquals("bob", + deleteParams.getAllValues().get(1).get("user")); + Assert.assertEquals(HugePermission.SPACE_MEMBER, + deleteParams.getAllValues().get(1).get("type")); + Assert.assertEquals("graphspaces/space_b/auth/managers", + getPath.getAllValues().get(0)); + Assert.assertEquals("space_b", + getParams.getAllValues().get(0).get("graphspace")); + Assert.assertEquals(HugePermission.SPACE, + getParams.getAllValues().get(0).get("type")); + Assert.assertEquals(HugePermission.SPACE_MEMBER, + getParams.getAllValues().get(1).get("type")); + Assert.assertEquals("graphspaces/space_c/auth/managers/default", + getPath.getAllValues().get(2)); + Assert.assertEquals("space_c", + getParams.getAllValues().get(2).get("graphspace")); + Assert.assertEquals("analyst", + getParams.getAllValues().get(2).get("role")); + Assert.assertEquals("graph_1", + getParams.getAllValues().get(2).get("graph")); + } +} diff --git a/hugegraph-client/src/test/java/org/apache/hugegraph/unit/PDHugeClientFactoryTest.java b/hugegraph-client/src/test/java/org/apache/hugegraph/unit/PDHugeClientFactoryTest.java new file mode 100644 index 000000000..31cf21312 --- /dev/null +++ b/hugegraph-client/src/test/java/org/apache/hugegraph/unit/PDHugeClientFactoryTest.java @@ -0,0 +1,82 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.apache.hugegraph.driver.factory.PDHugeClientFactory; +import org.apache.hugegraph.pd.client.DiscoveryClient; +import org.apache.hugegraph.pd.grpc.discovery.Query; +import org.apache.hugegraph.testutil.Assert; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +public class PDHugeClientFactoryTest { + + private TestPDHugeClientFactory factory; + private DiscoveryClient discoveryClient; + + @Before + public void setup() throws Exception { + this.factory = new TestPDHugeClientFactory("127.0.0.1:8686"); + this.factory.close(); + this.discoveryClient = Mockito.mock(DiscoveryClient.class); + Field client = PDHugeClientFactory.class.getDeclaredField("client"); + client.setAccessible(true); + client.set(this.factory, this.discoveryClient); + Mockito.when(this.discoveryClient.getNodeInfos(Mockito.any(Query.class))) + .thenReturn(null); + } + + @After + public void teardown() { + this.factory.close(); + } + + @Test + public void testGetURLsReturnsEmptyWhenDiscoveryReturnsNull() { + List urls = this.factory.getURLs("cluster", "space", "service"); + + Assert.assertEquals(Collections.emptyList(), urls); + } + + @Test + public void testGetURLsWithConfigReturnsEmptyWhenDiscoveryReturnsNull() { + List urls = this.factory.urlsWithConfig( + "cluster", Collections.singletonMap("SERVICE_NAME", "service")); + + Assert.assertEquals(Collections.emptyList(), urls); + } + + private static class TestPDHugeClientFactory extends PDHugeClientFactory { + + TestPDHugeClientFactory(String pdAddrs) { + super(pdAddrs); + } + + List urlsWithConfig(String cluster, Map configs) { + return this.getURLsWithConfig(cluster, configs); + } + } +} diff --git a/hugegraph-client/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-client/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java index 45670a92f..ab4c18de3 100644 --- a/hugegraph-client/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-client/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -30,7 +30,9 @@ PropertyKeyTest.class, IndexLabelTest.class, GraphSpaceAPITest.class, + ManagerAPITest.class, GraphsAPITest.class, + PDHugeClientFactoryTest.class, CommonUtilTest.class, IdUtilTest.class, SplicingIdGeneratorTest.class diff --git a/hugegraph-hubble/README.md b/hugegraph-hubble/README.md index d9196a168..cbc13ad04 100644 --- a/hugegraph-hubble/README.md +++ b/hugegraph-hubble/README.md @@ -7,6 +7,44 @@ hugegraph-hubble is a graph management and analysis platform that provides features: graph data load, schema management, graph relationship analysis, and graphical display. +## Local development feedback loop + +Run the frontend with third-party source-map noise disabled: + +```bash +cd hubble-fe +yarn dev +``` + +Run the backend incrementally with Java 11 and the Maven daemon: + +```bash +export JAVA_HOME=$(/usr/libexec/java_home -v 11) +mvnd -pl hubble-be -DskipTests compile dependency:build-classpath \ + -Dmdep.outputFile=/tmp/hubble-be-classpath +mkdir -p /tmp/hubble-dev-home +cd hubble-be +"$JAVA_HOME/bin/java" -Dfile.encoding=UTF-8 \ + -Dhubble.home.path=/tmp/hubble-dev-home \ + -cp "target/classes:$( 0) { + update.executeBatch(); + } + } + if (updated > 0) { + log.info("Removed credentials from {} legacy load tasks", updated); + } + } + + private String removeCredentials(String options, int id) + throws SQLException { + try { + JsonNode parsed = JSON.readTree(options); + if (!(parsed instanceof ObjectNode)) { + throw new SQLException("Invalid load task options for task " + + id); + } + ObjectNode object = (ObjectNode) parsed; + boolean changed = false; + for (String credential : LOAD_OPTION_CREDENTIALS) { + changed |= object.remove(credential) != null; + } + return changed ? JSON.writeValueAsString(object) : null; + } catch (JsonProcessingException e) { + throw new SQLException("Failed to sanitize load task options for " + + "task " + id, e); + } + } + private boolean tableExists(Connection conn, String table) throws SQLException { DatabaseMetaData metaData = conn.getMetaData(); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java index a5767e92c..1d2fc210b 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java @@ -36,6 +36,7 @@ import org.apache.hugegraph.common.Constant; import org.apache.hugegraph.exception.ParameterizedException; +import org.apache.hugegraph.exception.ForbiddenException; import org.apache.hugegraph.service.HugeClientPoolService; import org.apache.hugegraph.common.Identifiable; import org.apache.hugegraph.common.Mergeable; @@ -56,7 +57,7 @@ public abstract class BaseController { private HugeConfig config; @Autowired - UserService userService; + protected UserService userService; public static final String ORDER_ASC = "asc"; public static final String ORDER_DESC = "desc"; @@ -107,24 +108,6 @@ protected void setUser(String username) { setSession(Constant.USERNAME_KEY, username); } - protected void setCredentialPassword(String password) { - // TODO: Stop retaining the plaintext login password after Vermeer migrates to - // token/service credentials and Loader/Ingest token-only paths are verified. - setSession(Constant.CREDENTIAL_PASSWORD_KEY, password); - setSession(Constant.CREDENTIAL_EXPIRES_AT_KEY, - System.currentTimeMillis() + Constant.CREDENTIAL_TTL_MILLIS); - } - - protected String getCredentialPassword() { - Long expiresAt = (Long) getSession(Constant.CREDENTIAL_EXPIRES_AT_KEY); - if (expiresAt == null || expiresAt <= System.currentTimeMillis()) { - delSession(Constant.CREDENTIAL_PASSWORD_KEY); - delSession(Constant.CREDENTIAL_EXPIRES_AT_KEY); - return null; - } - return (String) getSession(Constant.CREDENTIAL_PASSWORD_KEY); - } - protected void delSession(String key) { HttpServletRequest request = getRequest(); request.getSession().removeAttribute(key); @@ -150,8 +133,6 @@ protected void delToken() { protected void clearAuthSession() { this.delSession(Constant.TOKEN_KEY); this.delSession(Constant.USERNAME_KEY); - this.delSession(Constant.CREDENTIAL_PASSWORD_KEY); - this.delSession(Constant.CREDENTIAL_EXPIRES_AT_KEY); } protected HugeClient authClient(String graphSpace, String graph) { @@ -167,28 +148,37 @@ protected HugeClient authClient(String graphSpace, String graph) { return client; } - protected HugeClient authGremlinClient(String graphSpace, String graph) { - String username = this.getUser(); - String password = this.getCredentialPassword(); - if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) { - return this.authClient(graphSpace, graph); + protected HugeClient requireAccountManager() { + HugeClient client = this.authClient(null, null); + String level = this.userService.userLevel(client, this.getUser()); + if (!"ADMIN".equals(level)) { + throw new ForbiddenException("Permission denied: manage accounts"); } + return client; + } - HttpServletRequest request = getRequest(); - if (request.getAttribute("hugeClient") != null) { - HugeClient client = (HugeClient) request.getAttribute("hugeClient"); - client.close(); + protected HugeClient requireGraphSpaceManager(String graphSpace) { + HugeClient client = this.authClient(null, null); + if (!this.userService.isSuperAdmin(client) && + !this.userService.isAssignSpaceAdmin(client, graphSpace)) { + throw new ForbiddenException( + "Permission denied: manage graphspace members"); } - HugeClient client = this.createBasicClient(graphSpace, graph, username, - password); - request.setAttribute("hugeClient", client); + client.assignGraph(graphSpace, null); return client; } - protected HugeClient createBasicClient(String graphSpace, String graph, - String username, String password) { - return this.hugeClientPoolService.createBasicClient(graphSpace, graph, - username, password); + protected HugeClient requireGraphSpaceAdministrator() { + HugeClient client = this.authClient(null, null); + if (!this.userService.isSuperAdmin(client)) { + throw new ForbiddenException( + "Permission denied: manage graphspaces"); + } + return client; + } + + protected HugeClient authGremlinClient(String graphSpace, String graph) { + return this.authClient(graphSpace, graph); } protected HugeClient unauthClient() { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/algorithm/VermeerAlgoController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/algorithm/VermeerAlgoController.java index c9eca817c..c1a445b88 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/algorithm/VermeerAlgoController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/algorithm/VermeerAlgoController.java @@ -20,23 +20,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.apache.hugegraph.common.Constant; -import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.controller.BaseController; -import org.apache.hugegraph.driver.HugeClient; -import org.apache.hugegraph.loader.util.JsonUtil; -import org.apache.hugegraph.options.HubbleOptions; -import org.apache.hugegraph.service.space.VermeerService; -import org.apache.hugegraph.util.E; -import org.apache.hugegraph.util.HubbleUtil; -import org.springframework.beans.factory.annotation.Autowired; +import org.apache.hugegraph.exception.ServerCapabilityUnavailableException; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Arrays; -import java.util.HashMap; import java.util.Map; @RestController @@ -44,49 +35,17 @@ "/{graph}/algorithms/vermeer") public class VermeerAlgoController extends BaseController { - @Autowired - VermeerService vermeerService; - @Autowired - private HugeConfig config; - @PostMapping public Map olapView(@PathVariable("graphspace") String graphspace, @PathVariable("graph") String graph, @RequestBody VParams body) { - String vGraph = vermeerService.convert2VG(graphspace, graph); - HugeClient client = this.authClient(null, null); - - Map graphInfo = HubbleUtil.uncheckedCast( - client.vermeer().getGraphInfoByName(vGraph).get("graph")); - E.checkArgument(graphInfo != null && !graphInfo.isEmpty(), - "graph not loaded"); - - Map params = new HashMap<>(); - // default params - String pdPeers = config.get(HubbleOptions.PD_PEERS); - String pdJson = JsonUtil.toJson(Arrays.asList(pdPeers.split(","))); - params.put("output.parallel", "10"); - params.put("output.type", "hugegraph"); - params.put("output.hg_pd_peers", pdJson); - params.put("output.hugegraph_name", graphspace + "/" + graph + "/g"); - params.put("output.hugegraph_username", this.getUser()); - params.put("output.hugegraph_password", this.getCredentialPassword()); - params.put("output.hugegraph_property", body.params.get("compute" + - ".algorithm")); - // input params - params.putAll(body.analyze()); - return vermeerService.compute(client, graphspace, graph, params); + throw new ServerCapabilityUnavailableException( + "server.capability.vermeer-compute-token-auth.unavailable", + null); } private static class VParams { @JsonProperty("params") public Map params; - - public Map analyze() { - for (Map.Entry entry : params.entrySet()) { - entry.setValue(entry.getValue().toString()); - } - return params; - } } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java index dbd723be6..0f97026ed 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java @@ -47,28 +47,28 @@ public List list( @PathVariable("graphspace") String graphSpace, @RequestParam(value = "role_id", required = false) String roleId, @RequestParam(value = "target_id", required = false) String targetId) { - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); return this.accessService.list(client, graphSpace, roleId, targetId); } @GetMapping("{id}") public AccessEntity get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String accessId) { - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); return this.accessService.get(client, graphSpace, accessId); } @PostMapping public AccessEntity add(@PathVariable("graphspace") String graphSpace, @RequestBody AccessEntity accessEntity) { - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); return this.accessService.addOrUpdate(client, graphSpace, accessEntity); } @PutMapping public AccessEntity update(@PathVariable("graphspace") String graphSpace, @RequestBody AccessEntity accessEntity) { - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); return this.accessService.addOrUpdate(client, graphSpace, accessEntity); } @@ -76,7 +76,7 @@ public AccessEntity update(@PathVariable("graphspace") String graphSpace, public void delete(@PathVariable("graphspace") String graphSpace, @RequestParam("role_id") String roleId, @RequestParam("target_id") String targetId) { - HugeClient client = this.authClient(graphSpace, null); - this.accessService.delete(client, roleId, targetId); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + this.accessService.delete(client, graphSpace, roleId, targetId); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java index 16b0b9201..757a4152b 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java @@ -48,8 +48,8 @@ public List list( @PathVariable("graphspace") String graphSpace, @RequestParam(value = "role_id", required = false) String roleId, @RequestParam(value = "user_id", required = false) String userId) { - HugeClient client = this.authClient(graphSpace, null); - return this.belongService.list(client, roleId, userId); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.belongService.list(client, graphSpace, roleId, userId); } @GetMapping @@ -61,57 +61,58 @@ public IPage listPage( defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.authClient(graphSpace, null); - return this.belongService.listPage(client, roleId, userId, pageNo, - pageSize); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.belongService.listPage(client, graphSpace, roleId, userId, + pageNo, pageSize); } @GetMapping("{id}") public BelongEntity get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String belongId) { - HugeClient client = this.authClient(graphSpace, null); - return this.belongService.get(client, belongId); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.belongService.get(client, graphSpace, belongId); } @PostMapping public void create(@PathVariable("graphspace") String graphSpace, @RequestBody BelongEntity belongEntity) { - HugeClient client = this.authClient(graphSpace, null); - this.belongService.add(client, belongEntity.getRoleId(), + HugeClient client = this.requireGraphSpaceManager(graphSpace); + this.belongService.add(client, graphSpace, belongEntity.getRoleId(), belongEntity.getUserId()); } @PostMapping("ids") public void createMany(@PathVariable("graphspace") String graphSpace, @RequestBody BelongService.BelongsReq belongsReq) { - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); for (String userId : belongsReq.getUserIds()) { - this.belongService.add(client, belongsReq.getRoleId(), userId); + this.belongService.add(client, graphSpace, + belongsReq.getRoleId(), userId); } } @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String belongId) { - HugeClient client = this.authClient(graphSpace, null); - this.belongService.delete(client, belongId); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + this.belongService.deleteById(client, graphSpace, belongId); } @DeleteMapping public void delete(@PathVariable("graphspace") String graphSpace, @RequestParam("role_id") String roleId, @RequestParam("user_id") String userId) { - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); if (StringUtils.isNotEmpty(roleId) && StringUtils.isNotEmpty(userId)) { - this.belongService.delete(client, roleId, userId); + this.belongService.delete(client, graphSpace, roleId, userId); } } @PostMapping("delids") public void deleteMany(@PathVariable("graphspace") String graphSpace, @RequestBody DelIdsReq delIdsReq) { - HugeClient client = this.authClient(graphSpace, null); - this.belongService.deleteMany(client, + HugeClient client = this.requireGraphSpaceManager(graphSpace); + this.belongService.deleteMany(client, graphSpace, delIdsReq.ids.toArray(new String[0])); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java index 4464bc2f8..8ec43ae5a 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java @@ -52,8 +52,9 @@ public IPage querySpaceUserViews( defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.authClient(graphSpace, null); - return this.userService.queryPage(client, query, pageNo, pageSize); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.userService.queryPage(client, graphSpace, query, pageNo, + pageSize); } @GetMapping("spaceadmin") @@ -65,7 +66,7 @@ public IPage querySpaceAdmins( defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); return this.userService.querySpaceAdmins(client, graphSpace, query, pageNo, pageSize); } @@ -73,15 +74,15 @@ public IPage querySpaceAdmins( @GetMapping("{id}") public UserView get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String userId) { - HugeClient client = this.authClient(graphSpace, null); - return this.userService.getUser(client, userId); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.userService.getUser(client, graphSpace, userId); } - @GetMapping("spaceadmin/{id}") + @PostMapping("spaceadmin/{id}") public UserManager setGraphSpaceAdmin( @PathVariable("graphspace") String graphSpace, @PathVariable("id") String userId) { - HugeClient client = this.authClient(null, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); return client.auth().addSpaceAdmin(userId, graphSpace); } @@ -89,30 +90,30 @@ public UserManager setGraphSpaceAdmin( public void removeGraphSpaceAdmin( @PathVariable("graphspace") String graphSpace, @PathVariable("id") String userId) { - HugeClient client = this.authClient(null, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); client.auth().delSpaceAdmin(userId, graphSpace); } @PostMapping public UserView create(@PathVariable("graphspace") String graphSpace, @RequestBody UserView userView) { - HugeClient client = this.authClient(graphSpace, null); - return this.userService.createOrUpdate(client, userView); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.userService.createOrUpdate(client, graphSpace, userView); } @PutMapping("{id}") public UserView createOrUpdate(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String userId, @RequestBody UserView userView) { - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); userView.setId(userId); - return this.userService.createOrUpdate(client, userView); + return this.userService.createOrUpdate(client, graphSpace, userView); } @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String userId) { - HugeClient client = this.authClient(graphSpace, null); - this.userService.unauthUser(client, userId); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + this.userService.unauthUser(client, graphSpace, userId); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java index 967a78f12..b59b24b74 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java @@ -36,10 +36,13 @@ import com.google.common.collect.ImmutableMap; import org.apache.hugegraph.driver.factory.PDHugeClientFactory; import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.auth.AuthContextService; import org.apache.hugegraph.service.auth.UserService; import org.apache.hugegraph.service.auth.LoginAttemptGuard; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -67,6 +70,8 @@ public class LoginController extends BaseController { private HugeConfig config; @Autowired private LoginAttemptGuard loginAttemptGuard; + @Autowired + private AuthContextService authContextService; @PostMapping("/login") public Object login(@RequestBody Login login) { @@ -85,10 +90,8 @@ public Object login(@RequestBody Login login) { this.createLoginTokenClient(result.token())) { client.assignGraph(PDHugeClientFactory.DEFAULT_GRAPHSPACE, null); - UserEntity entity = this.userService.getUser(client, - login.name()); - entity.setSuperadmin( - this.userService.isSuperAdmin(client)); + UserEntity entity = this.userService.getpersonal( + client, login.name()); user = entity; } } @@ -96,7 +99,6 @@ public Object login(@RequestBody Login login) { this.getRequest().getSession(); this.getRequest().changeSessionId(); this.setUser(login.name()); - this.setCredentialPassword(login.password()); this.setToken(result.token()); return user; } catch (Throwable e) { @@ -226,11 +228,22 @@ public Object status() { HugeClient client = authClient(null, null); - String level = userService.userLevel(client); + String level = userService.userLevel(client, this.getUser()); return ImmutableMap.of("level", level); } + @GetMapping("/context") + public ResponseEntity context() { + HugeClient client = this.authClient(null, null); + Object context = this.authContextService.context(client, + this.getUser()); + HttpHeaders headers = new HttpHeaders(); + headers.setCacheControl("no-store"); + headers.setPragma("no-cache"); + return new ResponseEntity<>(context, headers, HttpStatus.OK); + } + // FIXME: Change logout to POST and add CSRF/Origin protection after coordinating // the API change with the frontend; explicitly harden the session cookie as well. @GetMapping("/logout") diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java index 386f1809b..06e0e268f 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java @@ -46,8 +46,9 @@ public class RoleController extends AuthController { @GetMapping("list") public List listName(@PathVariable("graphspace") String graphSpace) { - HugeClient client = this.authClient(graphSpace, null); - return this.roleService.list(client, graphSpace); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.roleService.list(client, graphSpace, + this.userService.isSuperAdmin(client)); } @GetMapping @@ -59,32 +60,36 @@ public IPage queryPage( defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.authClient(graphSpace, null); - return this.roleService.queryPage(client, graphSpace, query, pageNo, - pageSize); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.roleService.queryPage( + client, graphSpace, query, pageNo, pageSize, + this.userService.isSuperAdmin(client)); } @GetMapping("{id}") public Role get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String roleId) { - HugeClient client = this.authClient(graphSpace, null); - return this.roleService.get(client, graphSpace, roleId); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.roleService.get(client, graphSpace, roleId, + this.userService.isSuperAdmin(client)); } @PostMapping public Role add(@PathVariable("graphspace") String graphSpace, @RequestBody Role role) { - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireGraphSpaceManager(graphSpace); role.graphSpace(graphSpace); - return this.roleService.insert(client, role); + return this.roleService.insert(client, graphSpace, role); } @PutMapping("{id}") public Role update(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String id, @RequestBody Map body) { - HugeClient client = this.authClient(graphSpace, null); - Role current = this.roleService.get(client, graphSpace, id); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + boolean includeLegacy = this.userService.isSuperAdmin(client); + Role current = this.roleService.get(client, graphSpace, id, + includeLegacy); String name = firstNonBlank(body, "role_name", "group_name", "new_group_name"); if (name != null) { @@ -96,14 +101,16 @@ public Role update(@PathVariable("graphspace") String graphSpace, if (description != null) { current.description(description); } - return this.roleService.update(client, current); + return this.roleService.update(client, graphSpace, current, + includeLegacy); } @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String id) { - HugeClient client = this.authClient(graphSpace, null); - this.roleService.delete(client, id); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + this.roleService.delete(client, graphSpace, id, + this.userService.isSuperAdmin(client)); } private static String firstNonBlank(Map body, diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java index 5dbda66bf..a11368d11 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java @@ -45,8 +45,8 @@ public class TargetController extends AuthController { @GetMapping("list") public List list(@PathVariable("graphspace") String graphSpace) { - HugeClient client = this.authClient(graphSpace, null); - return this.targetService.list(client); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.targetService.list(client, graphSpace); } @GetMapping @@ -58,39 +58,40 @@ public IPage queryPage( defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.authClient(graphSpace, null); - return this.targetService.queryPage(client, query, pageNo, pageSize); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.targetService.queryPage(client, graphSpace, query, pageNo, + pageSize); } @GetMapping("{id}") public Target get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String targetId) { - HugeClient client = this.authClient(graphSpace, null); - return this.targetService.get(client, targetId); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.targetService.get(client, graphSpace, targetId); } @PostMapping public Target add(@PathVariable("graphspace") String graphSpace, @RequestBody Target target) { - HugeClient client = this.authClient(graphSpace, null); - return this.targetService.add(client, target); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + return this.targetService.add(client, graphSpace, target); } @PutMapping("{id}") public Target update(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String targetId, @RequestBody Target target) { - HugeClient client = this.authClient(graphSpace, null); - Target current = this.targetService.get(client, targetId); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + Target current = this.targetService.get(client, graphSpace, targetId); current.resources(target.resources()); current.description(target.description()); - return this.targetService.update(client, current); + return this.targetService.update(client, graphSpace, current); } @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String targetId) { - HugeClient client = this.authClient(graphSpace, null); - this.targetService.delete(client, targetId); + HugeClient client = this.requireGraphSpaceManager(graphSpace); + this.targetService.delete(client, graphSpace, targetId); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/UserController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/UserController.java index 8c69bc33a..55ff8e9a3 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/UserController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/UserController.java @@ -25,6 +25,8 @@ import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.auth.PasswordEntity; import org.apache.hugegraph.entity.auth.UserEntity; +import org.apache.hugegraph.exception.ForbiddenException; +import org.apache.hugegraph.exception.ParameterizedException; import org.apache.hugegraph.service.auth.UserService; import org.apache.hugegraph.exception.UnauthorizedException; import org.springframework.beans.factory.annotation.Autowired; @@ -39,7 +41,10 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import java.util.HashSet; import java.util.List; +import java.util.Objects; +import java.util.Set; @RestController @RequestMapping(Constant.API_VERSION + "auth/users") @@ -51,7 +56,7 @@ public class UserController extends BaseController { @GetMapping("list") public Object list() { List users = this.userService.listUsers( - this.authClient(null, null)); + this.requireAccountManager()); return ImmutableMap.of("users", users); } @@ -62,59 +67,123 @@ public Object queryPage(@RequestParam(name = "query", required = false, defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - return userService.queryPage(this.authClient(null, null), + return userService.queryPage(this.requireAccountManager(), query, pageNo, pageSize); } @PostMapping public void create(@RequestBody UserEntity userEntity) { - HugeClient client = this.authClient(null, null); + HugeClient client = this.requireAccountManager(); + this.checkAccountGrantScope(client, null, userEntity); userService.add(client, userEntity); } @PostMapping("batch") public void createbatch(@RequestParam("file") MultipartFile csvfile) { - HugeClient client = this.authClient(null, null); + HugeClient client = this.requireAccountManager(); userService.addbatch(client, csvfile); } @GetMapping("{id}") public Object get(@PathVariable("id") String id) { - return userService.get(this.authClient(null, null), - id); + return userService.get(this.requireAccountManager(), id); } @PutMapping("{id}") public void update(@PathVariable("id") String id, @RequestBody UserEntity userEntity) { - userEntity.setId(id); - userService.update(this.authClient(null, null), userEntity); + HugeClient client = this.requireAccountManager(); + UserEntity current = this.userService.get(client, id); + if (userEntity.getName() != null && + !Objects.equals(userEntity.getName(), current.getName())) { + throw new ParameterizedException( + "The body user_name(%s) does not match path user(%s)", + userEntity.getName(), current.getName()); + } + if (!this.userService.isSuperAdmin(client) && + current.isSuperadmin()) { + throw new ForbiddenException( + "Permission denied: modify superadmin"); + } + userEntity.setId(current.getId()); + userEntity.setName(current.getName()); + if (!userEntity.hasSuperadmin()) { + userEntity.setSuperadmin(current.isSuperadmin()); + } + if (userEntity.getAdminSpaces() == null) { + userEntity.setAdminSpaces(current.getAdminSpaces()); + } + this.checkAccountGrantScope(client, current.getName(), userEntity); + userService.update(client, userEntity); } @DeleteMapping("{id}") public void delete(@PathVariable("id") String id) { - userService.delete(this.authClient(null, null), id); + HugeClient client = this.requireAccountManager(); + UserEntity current = this.userService.get(client, id); + userService.delete(client, current.getId()); } @PostMapping("updatepwd") public Response updatepwd(@RequestBody PasswordEntity pwd) { + if (!Objects.equals(this.getUser(), pwd.getUsername())) { + throw new ForbiddenException( + "Permission denied: change another account password"); + } HugeClient client = this.authClient(null, null); return userService.updatepwd(client, pwd.getUsername(), pwd.getOldpwd(), pwd.getNewpwd()); } @GetMapping("listadminspace/{username}") public List listadminspace(@PathVariable("username") String username) { - HugeClient client = this.authClient(null, null); + HugeClient client = this.requireAccountManager(); return userService.listAdminSpace(client, username); } @PostMapping("updateadminspace/{username}") public void updateadminspace(@PathVariable("username") String username, @RequestBody List adminspaces) { - HugeClient client = this.authClient(null, null); + HugeClient client = this.requireAccountManager(); + this.checkAdminSpaceScope(client, username, adminspaces); userService.updateAdminSpace(client, username, adminspaces); } + private void checkAccountGrantScope(HugeClient client, String username, + UserEntity userEntity) { + if (this.userService.isSuperAdmin(client)) { + return; + } + if (userEntity.isSuperadmin()) { + throw new ForbiddenException( + "Permission denied: grant superadmin"); + } + this.checkAdminSpaceScope(client, username, + userEntity.getAdminSpaces()); + } + + private void checkAdminSpaceScope(HugeClient client, String username, + List requestedSpaces) { + if (this.userService.isSuperAdmin(client)) { + return; + } + Set managedSpaces = new HashSet<>( + this.userService.listAdminSpace(client, this.getUser())); + Set requested = requestedSpaces == null ? + new HashSet<>() : + new HashSet<>(requestedSpaces); + Set current = username == null ? new HashSet<>() : + new HashSet<>(this.userService.listAdminSpace( + client, username)); + Set changed = new HashSet<>(current); + changed.addAll(requested); + changed.removeIf(space -> current.contains(space) == + requested.contains(space)); + if (!managedSpaces.containsAll(changed)) { + throw new ForbiddenException( + "Permission denied: manage another graphspace"); + } + } + @PutMapping("personal") public void updatePersonal(@RequestBody PersonalProfile profile) { String username = this.getUser(); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graph/SampleGraphController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graph/SampleGraphController.java new file mode 100644 index 000000000..0e49ead22 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graph/SampleGraphController.java @@ -0,0 +1,448 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.controller.graph; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import lombok.extern.log4j.Log4j2; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; + +import org.apache.hugegraph.common.Constant; +import org.apache.hugegraph.controller.BaseController; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.driver.SchemaManager; +import org.apache.hugegraph.exception.ExternalException; +import org.apache.hugegraph.util.Ex; + +@RestController +@Log4j2 +@RequestMapping(Constant.API_VERSION + "graphspaces/{graphspace}/graphs/{graph}/sample") +public class SampleGraphController extends BaseController { + + private static final String IDEMPOTENT_TRAVERSAL_FALLBACK_MARKER = + "// hugegraph-client:idempotent-traversal-fallback\n"; + public static final String LOADER_SOURCE = "hugegraph-loader/example/file"; + public static final String RANK_SOURCE = + "hugegraph-doc/rank-api/neighbor-rank-example"; + public static final String HLM_SOURCE = + "hubble-be/src/main/resources/demo/hlm.txt"; + + public static final String LOADER_SCHEMA = + "schema = graph.schema();\n" + + "schema.propertyKey('name').asText().ifNotExist().create();\n" + + "schema.propertyKey('age').asInt().ifNotExist().create();\n" + + "schema.propertyKey('city').asText().ifNotExist().create();\n" + + "schema.propertyKey('weight').asDouble().ifNotExist().create();\n" + + "schema.propertyKey('lang').asText().ifNotExist().create();\n" + + "schema.propertyKey('date').asText().ifNotExist().create();\n" + + "schema.propertyKey('price').asDouble().ifNotExist().create();\n" + + "schema.vertexLabel('person').properties('name', 'age', 'city')" + + ".primaryKeys('name').nullableKeys('age', 'city')" + + ".ifNotExist().create();\n" + + "schema.vertexLabel('software').useCustomizeNumberId()" + + ".properties('name', 'lang', 'price').ifNotExist().create();\n" + + "schema.edgeLabel('knows').sourceLabel('person')" + + ".targetLabel('person').properties('date', 'weight')" + + ".ifNotExist().create();\n" + + "schema.edgeLabel('created').sourceLabel('person')" + + ".targetLabel('software').properties('date', 'weight')" + + ".ifNotExist().create();"; + + public static final String LOADER_DATA = + vertex("marko", "marko", 29, "Beijing") + + vertex("vadas", "vadas", 27, "Hongkong") + + vertex("josh", "josh", 32, "Beijing") + + vertex("peter", "peter", 35, "Shanghai") + + vertex("linary", "li,nary", 26, "Wu,han") + + nullableVertex("tom", "tom") + + software(1, "lop", 328) + software(2, "ripple", 199) + + edge("marko", "knows", "vadas", "20160110", 0.5) + + edge("marko", "knows", "josh", "20130220", 1.0) + + created("marko", 1, "2017-12-10", 0.4) + + created("josh", 1, "2009-11-11", 0.4) + + created("josh", 2, "2017-12-10", 1.0) + + created("peter", 1, "2017-03-24", 0.2); + + public static final String RANK_SCHEMA = + "schema = graph.schema();\n" + + "schema.propertyKey('name').asText().ifNotExist().create();\n" + + "schema.vertexLabel('person').properties('name')" + + ".useCustomizeStringId().ifNotExist().create();\n" + + "schema.vertexLabel('movie').properties('name')" + + ".useCustomizeStringId().ifNotExist().create();\n" + + "schema.edgeLabel('follow').sourceLabel('person')" + + ".targetLabel('person').ifNotExist().create();\n" + + "schema.edgeLabel('like').sourceLabel('person')" + + ".targetLabel('movie').ifNotExist().create();\n" + + "schema.edgeLabel('directedBy').sourceLabel('movie')" + + ".targetLabel('person').ifNotExist().create();"; + + public static final String RANK_DATA = + rankVertex("O", "person") + rankVertex("A", "person") + + rankVertex("B", "person") + rankVertex("C", "person") + + rankVertex("D", "person") + rankVertex("E", "movie") + + rankVertex("F", "movie") + rankVertex("G", "movie") + + rankVertex("H", "movie") + rankVertex("I", "movie") + + rankVertex("J", "movie") + rankVertex("K", "person") + + rankVertex("L", "person") + rankVertex("M", "person") + + rankEdge("O", "follow", "A") + rankEdge("O", "follow", "B") + + rankEdge("O", "follow", "C") + rankEdge("D", "follow", "O") + + rankEdge("A", "follow", "B") + rankEdge("A", "like", "E") + + rankEdge("A", "like", "F") + rankEdge("B", "like", "G") + + rankEdge("B", "like", "H") + rankEdge("C", "like", "I") + + rankEdge("C", "like", "J") + + rankEdge("E", "directedBy", "K") + + rankEdge("F", "directedBy", "B") + + rankEdge("F", "directedBy", "L") + + rankEdge("G", "directedBy", "M"); + + public static final String HLM_SCHEMA = + "schema = graph.schema();\n" + + "schema.propertyKey('name').asText().ifNotExist().create();\n" + + "schema.propertyKey('gender').asText().ifNotExist().create();\n" + + "schema.propertyKey('age').asInt().ifNotExist().create();\n" + + "schema.propertyKey('title').asText().ifNotExist().create();\n" + + "schema.propertyKey('feature').asText().ifNotExist().create();\n" + + "schema.propertyKey('intimacy').asText().ifNotExist().create();\n" + + "schema.vertexLabel('人物').properties('name','gender','age'," + + "'title','feature').primaryKeys('name')" + + ".ifNotExist().create();\n" + + "schema.edgeLabel('关系').sourceLabel('人物')" + + ".targetLabel('人物').properties('intimacy')" + + ".ifNotExist().create();"; + + @PostMapping + public Map load(@PathVariable("graphspace") String graphSpace, + @PathVariable("graph") String graph, + @RequestParam(name = "dataset", + defaultValue = "loader") + String dataset) { + boolean loader = "loader".equals(dataset); + boolean rank = "rank".equals(dataset); + boolean hlm = "hlm".equals(dataset); + Ex.check(loader || rank || hlm, "common.param.should-belong-to", + "dataset", "[loader, hlm, rank]"); + HugeClient client = this.authGremlinClient(graphSpace, graph); + try { + createSchema(client, dataset); + String script = IDEMPOTENT_TRAVERSAL_FALLBACK_MARKER + + data(dataset); + client.gremlin().gremlin(script).execute(); + } catch (RuntimeException e) { + log.warn("Failed to load sample dataset '{}' into {}/{}", + dataset, graphSpace, graph, e); + throw new ExternalException("graph.sample.load-failed", + dataset, graphSpace, graph); + } + + Map result = new LinkedHashMap<>(); + result.put("dataset", dataset); + result.put("source", source(dataset)); + result.put("graphspace", graphSpace); + result.put("graph", graph); + result.put("vertices", hlm ? 14 : rank ? 14 : 8); + result.put("edges", hlm ? 15 : rank ? 15 : 6); + result.put("idempotent", true); + result.put("clears_existing_data", false); + return result; + } + + private static void createSchema(HugeClient client, String dataset) { + SchemaManager schema = client.schema(); + Set propertyKeys = new HashSet<>(); + schema.getPropertyKeys().forEach(key -> propertyKeys.add(key.name())); + Set vertexLabels = new HashSet<>(); + schema.getVertexLabels().forEach(label -> vertexLabels.add(label.name())); + Set edgeLabels = new HashSet<>(); + schema.getEdgeLabels().forEach(label -> edgeLabels.add(label.name())); + if ("loader".equals(dataset)) { + createLoaderSchema(schema, propertyKeys, vertexLabels, edgeLabels); + } else if ("rank".equals(dataset)) { + createRankSchema(schema, propertyKeys, vertexLabels, edgeLabels); + } else { + createHlmSchema(schema, propertyKeys, vertexLabels, edgeLabels); + } + } + + private static void createLoaderSchema(SchemaManager schema, + Set propertyKeys, + Set vertexLabels, + Set edgeLabels) { + createIfMissing(propertyKeys, "name", () -> + schema.propertyKey("name").asText().ifNotExist().create()); + createIfMissing(propertyKeys, "age", () -> + schema.propertyKey("age").asInt().ifNotExist().create()); + createIfMissing(propertyKeys, "city", () -> + schema.propertyKey("city").asText().ifNotExist().create()); + createIfMissing(propertyKeys, "weight", () -> + schema.propertyKey("weight").asDouble().ifNotExist().create()); + createIfMissing(propertyKeys, "lang", () -> + schema.propertyKey("lang").asText().ifNotExist().create()); + createIfMissing(propertyKeys, "date", () -> + schema.propertyKey("date").asText().ifNotExist().create()); + createIfMissing(propertyKeys, "price", () -> + schema.propertyKey("price").asDouble().ifNotExist().create()); + createIfMissing(vertexLabels, "person", () -> + schema.vertexLabel("person") + .properties("name", "age", "city") + .primaryKeys("name") + .nullableKeys("age", "city") + .ifNotExist().create()); + createIfMissing(vertexLabels, "software", () -> + schema.vertexLabel("software") + .useCustomizeNumberId() + .properties("name", "lang", "price") + .ifNotExist().create()); + createIfMissing(edgeLabels, "knows", () -> + schema.edgeLabel("knows") + .sourceLabel("person").targetLabel("person") + .properties("date", "weight") + .ifNotExist().create()); + createIfMissing(edgeLabels, "created", () -> + schema.edgeLabel("created") + .sourceLabel("person").targetLabel("software") + .properties("date", "weight") + .ifNotExist().create()); + } + + private static void createRankSchema(SchemaManager schema, + Set propertyKeys, + Set vertexLabels, + Set edgeLabels) { + createIfMissing(propertyKeys, "name", () -> + schema.propertyKey("name").asText().ifNotExist().create()); + createIfMissing(vertexLabels, "person", () -> + schema.vertexLabel("person") + .properties("name").useCustomizeStringId() + .ifNotExist().create()); + createIfMissing(vertexLabels, "movie", () -> + schema.vertexLabel("movie") + .properties("name").useCustomizeStringId() + .ifNotExist().create()); + createIfMissing(edgeLabels, "follow", () -> + schema.edgeLabel("follow") + .sourceLabel("person").targetLabel("person") + .ifNotExist().create()); + createIfMissing(edgeLabels, "like", () -> + schema.edgeLabel("like") + .sourceLabel("person").targetLabel("movie") + .ifNotExist().create()); + createIfMissing(edgeLabels, "directedBy", () -> + schema.edgeLabel("directedBy") + .sourceLabel("movie").targetLabel("person") + .ifNotExist().create()); + } + + private static void createHlmSchema(SchemaManager schema, + Set propertyKeys, + Set vertexLabels, + Set edgeLabels) { + createIfMissing(propertyKeys, "name", () -> + schema.propertyKey("name").asText().ifNotExist().create()); + createIfMissing(propertyKeys, "gender", () -> + schema.propertyKey("gender").asText().ifNotExist().create()); + createIfMissing(propertyKeys, "age", () -> + schema.propertyKey("age").asInt().ifNotExist().create()); + createIfMissing(propertyKeys, "title", () -> + schema.propertyKey("title").asText().ifNotExist().create()); + createIfMissing(propertyKeys, "feature", () -> + schema.propertyKey("feature").asText().ifNotExist().create()); + createIfMissing(propertyKeys, "intimacy", () -> + schema.propertyKey("intimacy").asText().ifNotExist().create()); + createIfMissing(vertexLabels, "人物", () -> + schema.vertexLabel("人物") + .properties("name", "gender", "age", "title", "feature") + .primaryKeys("name") + .ifNotExist().create()); + createIfMissing(edgeLabels, "关系", () -> + schema.edgeLabel("关系") + .sourceLabel("人物").targetLabel("人物") + .properties("intimacy") + .ifNotExist().create()); + } + + private static void createIfMissing(Set existing, String name, + Runnable create) { + if (existing.contains(name)) { + return; + } + create.run(); + existing.add(name); + } + + private static String vertex(String variable, String name, int age, + String city) { + return String.format("%1$s = g.V().hasLabel('person').has('name','%2$s')" + + ".fold().coalesce(unfold(),addV('person')" + + ".property('name','%2$s').property('age',%3$d)" + + ".property('city','%4$s')).next();\n", + variable, name, age, city); + } + + private static String nullableVertex(String variable, String name) { + return String.format("%1$s = g.V().hasLabel('person').has('name','%2$s')" + + ".fold().coalesce(unfold(),addV('person')" + + ".property('name','%2$s')).next();\n", variable, name); + } + + private static String software(int id, String name, int price) { + return String.format("software%1$d = g.V(%1$d).hasLabel('software')" + + ".fold().coalesce(unfold(),addV('software')" + + ".property(T.id,%1$d).property('name','%2$s')" + + ".property('lang','java').property('price',%3$d))" + + ".next();\n", id, name, price); + } + + private static String edge(String source, String label, String target, + String date, double weight) { + return String.format("if (!g.V(%1$s.id()).outE('%2$s')" + + ".where(inV().hasId(%3$s.id())).hasNext()) { " + + "%1$s.addEdge('%2$s',%3$s,'date','%4$s'," + + "'weight',%5$s); };\n", + source, label, target, date, weight); + } + + private static String created(String source, int target, String date, + double weight) { + return edge(source, "created", "software" + target, date, weight); + } + + private static String rankVertex(String id, String label) { + return String.format("v%1$s = g.V('%1$s').hasLabel('%2$s').fold()" + + ".coalesce(unfold(),addV('%2$s')" + + ".property(T.id,'%1$s').property('name','%1$s'))" + + ".next();\n", id, label); + } + + private static String rankEdge(String source, String label, String target) { + return String.format("if (!g.V(v%1$s.id()).outE('%2$s')" + + ".where(inV().hasId(v%3$s.id())).hasNext()) { " + + "v%1$s.addEdge('%2$s',v%3$s); };\n", + source, label, target); + } + + private static String schema(String dataset) { + if ("hlm".equals(dataset)) { + return HLM_SCHEMA; + } + return "rank".equals(dataset) ? RANK_SCHEMA : LOADER_SCHEMA; + } + + private static String data(String dataset) { + if ("hlm".equals(dataset)) { + return hlmData(); + } + return "rank".equals(dataset) ? RANK_DATA : LOADER_DATA; + } + + public static String hlmData() { + return HlmDataHolder.DATA; + } + + private static class HlmDataHolder { + + private static final String DATA = loadHlmData(); + } + + private static String source(String dataset) { + if ("hlm".equals(dataset)) { + return HLM_SOURCE; + } + return "rank".equals(dataset) ? RANK_SOURCE : LOADER_SOURCE; + } + + private static String loadHlmData() { + Map vertices = new LinkedHashMap<>(); + List edges = new ArrayList<>(); + try (InputStream stream = SampleGraphController.class + .getResourceAsStream("/demo/hlm.txt")) { + if (stream == null) { + throw new IOException("Missing /demo/hlm.txt"); + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader( + stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + String[] fields = line.split(",", -1); + if (fields.length != 11) { + throw new IOException("Invalid Red Chamber record"); + } + vertices.putIfAbsent(fields[0], person(fields, 0)); + vertices.putIfAbsent(fields[5], person(fields, 5)); + edges.add(new String[]{fields[0], fields[5], fields[10]}); + } + } + } catch (IOException e) { + throw new ExceptionInInitializerError(e); + } + + StringBuilder gremlin = new StringBuilder(); + vertices.forEach((name, fields) -> gremlin.append(hlmVertex(fields))); + edges.forEach(edge -> gremlin.append(hlmEdge(edge))); + return gremlin.toString(); + } + + private static String[] person(String[] fields, int offset) { + return new String[]{fields[offset], fields[offset + 1], + fields[offset + 2], fields[offset + 3], + fields[offset + 4]}; + } + + private static String hlmVertex(String[] person) { + return String.format("g.V().hasLabel('人物').has('name','%1$s').fold()" + + ".coalesce(unfold(),addV('人物')" + + ".property('name','%1$s').property('gender','%2$s')" + + ".property('age',%3$s).property('title','%4$s')" + + ".property('feature','%5$s')).next();\n", + escape(person[0]), escape(person[1]), person[2], + escape(person[3]), escape(person[4])); + } + + private static String hlmEdge(String[] edge) { + return String.format("if (!g.V().hasLabel('人物')" + + ".has('name','%1$s').outE('关系')" + + ".where(inV().has('name','%2$s'))" + + ".has('intimacy','%3$s').hasNext()) { g.V()" + + ".hasLabel('人物').has('name','%1$s').next()" + + ".addEdge('关系',g.V().hasLabel('人物')" + + ".has('name','%2$s').next()," + + "'intimacy','%3$s'); };\n", + escape(edge[0]), escape(edge[1]), escape(edge[2])); + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("'", "\\'"); + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java index f61936a2a..8fb8ac697 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java @@ -79,9 +79,7 @@ public class GraphsController extends BaseController { HugeConfig config; public boolean isVermeerEnabled() { - String username = this.getUser(); - String password = this.getCredentialPassword(); - return vermeerService.isVermeerEnabled(username, password); + return vermeerService.isVermeerEnabled(this.getToken()); } public String getGraphFromVermeer(String vermeer) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java index 86809ccc7..d0d6c201c 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java @@ -246,12 +246,17 @@ public Response createTask(@RequestBody IngestTaskRequest request) { setting.setColumnValues(columns.values); } + Set vertexMappings = this.vertexMappings(struct.vertices); + Set edgeMappings = this.edgeMappings(struct.edges); + Ex.check(!vertexMappings.isEmpty() || !edgeMappings.isEmpty(), + "At least one vertex or edge mapping is required"); + JobManager job = JobManager.builder() .graphSpace(graphSpace) .graph(graph) .jobName(request.taskName) .jobSize(totalSize) - .jobStatus(JobStatus.SETTING) + .jobStatus(JobStatus.LOADING) .createTime(HubbleUtil.nowDate()) .updateTime(HubbleUtil.nowDate()) .build(); @@ -263,33 +268,18 @@ public Response createTask(@RequestBody IngestTaskRequest request) { mapping.setTotalLines(FileUtil.countLines(sourceFile.getPath())); mapping.setFileSetting(setting); mapping.setLoadParameter(new LoadParameter()); - mapping.setVertexMappings(this.vertexMappings(struct.vertices)); - mapping.setEdgeMappings(this.edgeMappings(struct.edges)); + mapping.setVertexMappings(vertexMappings); + mapping.setEdgeMappings(edgeMappings); - boolean started = false; - try { - this.jobManagerService.save(job); - mapping.setJobId(job.getId()); - this.fileMappingService.save(mapping); - - GraphConnection connection = this.graphConnection(graphSpace, graph); - HugeClient client = this.authClient(graphSpace, graph); - LoadTask task = this.loadTaskService.start(connection, mapping, - client); - started = true; - Map data = new HashMap<>(); - data.put("task_id", job.getId()); - data.put("job_id", task.getId()); - return Response.builder().status(Constant.STATUS_OK) - .data(data).build(); - } finally { - if (job.getId() != null) { - job.setJobStatus(started ? JobStatus.LOADING : - JobStatus.FAILED); - job.setUpdateTime(HubbleUtil.nowDate()); - this.jobManagerService.update(job); - } - } + GraphConnection connection = this.graphConnection(graphSpace, graph); + HugeClient client = this.authClient(graphSpace, graph); + LoadTask task = this.jobManagerService.createIngestTask( + job, mapping, connection, client); + Map data = new HashMap<>(); + data.put("task_id", job.getId()); + data.put("job_id", task.getId()); + return Response.builder().status(Constant.STATUS_OK) + .data(data).build(); } @GetMapping("/tasks/list") @@ -514,7 +504,6 @@ private GraphConnection graphConnection(String graphSpace, String graph) { connection.setGraph(graph); connection.setToken(this.getToken()); connection.setUsername(this.getUser()); - connection.setPassword(this.getCredentialPassword()); if (!config.get(HubbleOptions.PD_ENABLED)) { UrlUtil.Host host = UrlUtil.parseHost(config.get( HubbleOptions.SERVER_URL)); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java index 1db3ca327..e12146128 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java @@ -74,12 +74,16 @@ public JobManager create(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody JobManager entity) { synchronized (this.service) { + Ex.check(!StringUtils.isEmpty(entity.getJobName()), + "common.param.cannot-be-null-or-empty", "job_name"); Ex.check(entity.getJobName().length() <= 48, "job.manager.job-name.reached-limit"); - Ex.check(entity.getJobName() != null, () -> - Constant.COMMON_NAME_PATTERN.matcher( + Ex.check(Constant.COMMON_NAME_PATTERN.matcher( entity.getJobName()).matches(), "job.manager.job-name.unmatch-regex"); + if (entity.getJobRemarks() == null) { + entity.setJobRemarks(""); + } Ex.check(entity.getJobRemarks().length() <= 200, "job.manager.job-remarks.reached-limit"); Ex.check(!StringUtils.isEmpty(entity.getJobRemarks()), () -> @@ -151,12 +155,16 @@ public JobManager update(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("id") int id, @RequestBody JobManager newEntity) { + Ex.check(!StringUtils.isEmpty(newEntity.getJobName()), + "common.param.cannot-be-null-or-empty", "job_name"); Ex.check(newEntity.getJobName().length() <= 48, "job.manager.job-name.reached-limit"); - Ex.check(newEntity.getJobName() != null, () -> - Constant.COMMON_NAME_PATTERN.matcher( + Ex.check(Constant.COMMON_NAME_PATTERN.matcher( newEntity.getJobName()).matches(), "job.manager.job-name.unmatch-regex"); + if (newEntity.getJobRemarks() == null) { + newEntity.setJobRemarks(""); + } Ex.check(!StringUtils.isEmpty(newEntity.getJobRemarks()), () -> Constant.COMMON_REMARK_PATTERN.matcher( newEntity.getJobRemarks()).matches(), diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java index edc0cd819..d16cacc94 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java @@ -155,7 +155,6 @@ public List start(@PathVariable("graphspace") String graphSpace, connection.setGraph(graph); connection.setToken(this.getToken()); connection.setUsername(this.getUser()); - connection.setPassword(this.getCredentialPassword()); if (!config.get(HubbleOptions.PD_ENABLED)) { UrlUtil.Host host = UrlUtil.parseHost(config.get(HubbleOptions.SERVER_URL)); connection.setProtocol(host.getScheme()); @@ -227,7 +226,7 @@ public LoadTask resume(@PathVariable("graphspace") String graphSpace, Ex.check(jobEntity.getJobStatus() == JobStatus.LOADING, "load.task.pause.no-permission"); try { - return this.service.resume(taskId); + return this.service.resume(taskId, this.getToken()); } finally { jobEntity.setJobStatus(JobStatus.LOADING); jobEntity.setUpdateTime(HubbleUtil.nowDate()); @@ -267,7 +266,7 @@ public LoadTask retry(@PathVariable("graphspace") String graphSpace, Ex.check(jobEntity.getJobStatus() == JobStatus.LOADING, "load.task.pause.no-permission"); try { - return this.service.retry(taskId); + return this.service.retry(taskId, this.getToken()); } finally { jobEntity.setJobStatus(JobStatus.LOADING); jobEntity.setUpdateTime(HubbleUtil.nowDate()); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/DashboardController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/DashboardController.java index 96344ab32..715e6a647 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/DashboardController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/DashboardController.java @@ -18,6 +18,9 @@ package org.apache.hugegraph.controller.op; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; import java.util.HashMap; import java.util.Map; @@ -31,24 +34,48 @@ import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.controller.BaseController; import org.apache.hugegraph.options.HubbleOptions; -import org.apache.hugegraph.util.E; @RestController @RequestMapping(Constant.API_VERSION + "dashboard") public class DashboardController extends BaseController { + + private static final int HEALTH_TIMEOUT_MILLIS = 1500; + @Autowired private HugeConfig config; @GetMapping public Map listOperations() { String address = config.get(HubbleOptions.DASHBOARD_ADDRESS); - E.checkArgument(StringUtils.isNotEmpty(address), - "Please set 'dashboard.address' in config file " + - "conf/hugegraph-hubble.properties."); - Map result = new HashMap<>(); + result.put("configured", StringUtils.isNotEmpty(address)); + if (StringUtils.isEmpty(address)) { + return result; + } result.put("address", address); - result.put("protocol", config.get(HubbleOptions.SERVER_PROTOCOL)); + String protocol = config.get(HubbleOptions.SERVER_PROTOCOL); + result.put("protocol", protocol); + result.put("available", this.isAvailable(protocol, address)); return result; } + + private boolean isAvailable(String protocol, String address) { + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL( + protocol + "://" + address).openConnection(); + connection.setConnectTimeout(HEALTH_TIMEOUT_MILLIS); + connection.setReadTimeout(HEALTH_TIMEOUT_MILLIS); + connection.setRequestMethod("GET"); + connection.setInstanceFollowRedirects(false); + int status = connection.getResponseCode(); + return status >= 200 && status < 400; + } catch (IOException e) { + return false; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/K8sTokenController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/K8sTokenController.java deleted file mode 100644 index ce878b46f..000000000 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/K8sTokenController.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with this - * work for additional information regarding copyright ownership. The ASF - * licenses this file to You under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package org.apache.hugegraph.controller.op; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; - -import org.apache.hugegraph.controller.BaseController; -import org.apache.hugegraph.exception.InternalException; -import com.google.common.collect.ImmutableMap; -import lombok.extern.log4j.Log4j2; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.ApplicationArguments; - -@Log4j2 -// SECURITY: This controller is intentionally not registered or mapped. Do not expose -// Kubernetes credentials through Hubble without a privileged authorization design. -public class K8sTokenController extends BaseController { - - @Autowired - private ApplicationArguments arguments; - - private Path fileDir() { - String[] args = this.arguments.getSourceArgs(); - if (args.length == 1) { - return new File(args[0]).getAbsoluteFile().getParentFile().toPath(); - } - - return null; - } - - public Object getK8sToken() { - - Path configDir = fileDir(); - - if (null == configDir) { - throw new InternalException("k8s.token.file.not-exist"); - } - - Path tokenFile = Paths.get(configDir.toString(), "k8s.token"); - - if (Files.exists(tokenFile)) { - try { - List lines = Files.readAllLines(tokenFile, - StandardCharsets.UTF_8); - return ImmutableMap.of("token", String.join("", lines)); - } catch (IOException e) { - log.error("Failed to read the Kubernetes token file", e); - } - } - - throw new InternalException("k8s.token.file.not-exist"); - } - - public Object getK8sToken1() { - - Path configDir = fileDir(); - if (configDir == null) { - throw new InternalException("k8s.token.directory.unavailable"); - } - return ImmutableMap.of("token", configDir.toString()); - } -} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/OperationsController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/OperationsController.java new file mode 100644 index 000000000..a8eb01420 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/OperationsController.java @@ -0,0 +1,114 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.controller.op; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import org.apache.hugegraph.common.Constant; +import org.apache.hugegraph.controller.BaseController; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.exception.ForbiddenException; +import org.apache.hugegraph.service.op.OperationsCapabilityService; +import org.apache.hugegraph.service.op.OperationsDataService; + +@RestController +@RequestMapping(Constant.API_VERSION + "operations") +public class OperationsController extends BaseController { + + @Autowired + private OperationsDataService dataService; + + @GetMapping("capabilities") + public Map capabilities() { + Map response = new LinkedHashMap<>(); + response.put("capabilities", this.currentCapabilities()); + return response; + } + + @GetMapping("overview") + public Map overview( + @RequestParam(defaultValue = "false") boolean refresh) { + HugeClient client = this.authClient(null, null); + Set capabilities = this.currentCapabilities(client); + OperationsCapabilityService.requireHealth(capabilities); + return this.dataService.overview(client, capabilities, refresh); + } + + @GetMapping("nodes") + public Map nodes( + @RequestParam(required = false) String type, + @RequestParam(required = false) String status, + @RequestParam(required = false) String query, + @RequestParam(defaultValue = "1") int page, + @RequestParam(name = "page_size", defaultValue = "20") + int pageSize, + @RequestParam(defaultValue = "name") String sort, + @RequestParam(defaultValue = "asc") String order) { + HugeClient client = this.authClient(null, null); + Set capabilities = this.currentCapabilities(client); + this.requireTopology(capabilities); + if (page < 1 || pageSize < 1 || pageSize > 100 || + query != null && query.length() > 256 || + !sort.matches("name|type|status|observed_at") || + !order.matches("asc|desc")) { + throw new IllegalArgumentException("Invalid operations page"); + } + return this.dataService.nodes(client, capabilities, type, status, + query, page, pageSize, sort, order); + } + + @GetMapping("nodes/{nodeId}") + public Map node( + @PathVariable String nodeId, + @RequestParam(defaultValue = "false") boolean refresh) { + HugeClient client = this.authClient(null, null); + Set capabilities = this.currentCapabilities(client); + this.requireTopology(capabilities); + if (nodeId == null || !nodeId.matches("(server|pd|store)-[0-9a-f]{12}")) { + throw new IllegalArgumentException("Invalid operations node id"); + } + return this.dataService.node(client, capabilities, nodeId, refresh); + } + + private Set currentCapabilities() { + return this.currentCapabilities(this.authClient(null, null)); + } + + private Set currentCapabilities(HugeClient client) { + String level = this.userService.userLevel(client, this.getUser()); + return OperationsCapabilityService.forLevel(level); + } + + private void requireTopology(Set capabilities) { + if (!capabilities.contains( + OperationsCapabilityService.TOPOLOGY_READ)) { + throw new ForbiddenException( + "Permission denied: operations topology read"); + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java index ecfb8ceb3..b8c3bc712 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java @@ -33,9 +33,8 @@ import org.apache.hugegraph.service.auth.UserService; import org.apache.hugegraph.service.graphs.GraphsService; import org.apache.hugegraph.service.space.GraphSpaceService; -import org.apache.hugegraph.structure.space.GraphSpace; import org.apache.hugegraph.util.E; -import org.apache.hugegraph.util.Ex; +import org.apache.hugegraph.util.PageUtil; import org.apache.hugegraph.util.UrlUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.DeleteMapping; @@ -54,6 +53,9 @@ @RestController @RequestMapping(Constant.API_VERSION + "graphspaces") public class GraphSpaceController extends BaseController { + private static final int DEFAULT_MAX_GRAPH_NUMBER = 100; + private static final int DEFAULT_MAX_ROLE_NUMBER = 100; + private static final int DEFAULT_STORAGE_VALUE = 1000000; private static final int DEFAULT_MEMORY_VALUE = 128; private static final int DEFAULT_CPU_VALUE = 64; @@ -100,11 +102,19 @@ public Object queryPage(@RequestParam(name = "query", required = false, "total", 0); } if (all) { - return this.graphSpaceService.queryAllGs( - this.authClient(null, null), query, createTime); + HugeClient client = this.authClient(null, null); + return this.userService.isSuperAdmin(client) ? + this.graphSpaceService.queryAllGs(client, query, + createTime) : + this.graphSpaceService.queryAccessibleGs(client, query, + createTime); } - return graphSpaceService.queryPage(this.authClient(null, null), - query, createTime, pageNo, pageSize); + HugeClient client = this.authClient(null, null); + return this.userService.isSuperAdmin(client) ? + graphSpaceService.queryPage(client, query, createTime, + pageNo, pageSize) : + PageUtil.page(graphSpaceService.queryAccessibleGs( + client, query, createTime), pageNo, pageSize); } @GetMapping("{graphspace}/auth") @@ -118,25 +128,45 @@ public Object isAuth(@PathVariable("graphspace") String graphSpace) { } @GetMapping("{graphspace}") - public GraphSpaceEntity get(@PathVariable("graphspace") String graphspace) { + public Object get(@PathVariable("graphspace") String graphspace) { if (!isPdEnabled()) { // Return a minimal stub entity for non-PD mode GraphSpaceEntity stub = new GraphSpaceEntity(); stub.setName(graphspace); stub.setNickname(graphspace); - return stub; + return this.graphSpaceService.toView(stub); } HugeClient client = this.authClient(null, null); // Get GraphSpace Info - return graphSpaceService.getWithAdmins(client, graphspace); + return graphSpaceService.toView( + graphSpaceService.getWithAdmins(client, graphspace)); } @PostMapping public Object add(@RequestBody GraphSpaceEntity graphSpaceEntity) { E.checkArgument(isPdEnabled(), "GraphSpace management is not supported in standalone mode"); + HugeClient client = this.requireGraphSpaceAdministrator(); // Create GraphSpace - HugeClient client = this.authClient(null, null); + applyResourceDefaults(graphSpaceEntity); + + graphSpaceService.create(client, graphSpaceEntity.convertGraphSpace()); + + // Add GraphSpace Admin + graphSpaceEntity.graphspaceAdmin.forEach(u -> { + client.auth().addSpaceAdmin(u, graphSpaceEntity.getName()); + }); + + return get(graphSpaceEntity.getName()); + } + + static void applyResourceDefaults(GraphSpaceEntity graphSpaceEntity) { + if (graphSpaceEntity.getMaxGraphNumber() <= 0) { + graphSpaceEntity.setMaxGraphNumber(DEFAULT_MAX_GRAPH_NUMBER); + } + if (graphSpaceEntity.getMaxRoleNumber() <= 0) { + graphSpaceEntity.setMaxRoleNumber(DEFAULT_MAX_ROLE_NUMBER); + } if (graphSpaceEntity.getCpuLimit() <= 0) { graphSpaceEntity.setCpuLimit(DEFAULT_CPU_VALUE); } @@ -147,28 +177,22 @@ public Object add(@RequestBody GraphSpaceEntity graphSpaceEntity) { graphSpaceEntity.setComputeCpuLimit(DEFAULT_CPU_VALUE); } if (graphSpaceEntity.getComputeMemoryLimit() <= 0) { - graphSpaceEntity.setComputeCpuLimit(DEFAULT_MEMORY_VALUE); + graphSpaceEntity.setComputeMemoryLimit(DEFAULT_MEMORY_VALUE); + } + if (graphSpaceEntity.getStorageLimit() <= 0) { + graphSpaceEntity.setStorageLimit(DEFAULT_STORAGE_VALUE); } - - graphSpaceService.create(client, graphSpaceEntity.convertGraphSpace()); - - // Add GraphSpace Admin - graphSpaceEntity.graphspaceAdmin.forEach(u -> { - client.auth().addSpaceAdmin(u, graphSpaceEntity.getName()); - }); - - return get(graphSpaceEntity.getName()); } @PutMapping("{graphspace}") - public GraphSpace update(@PathVariable("graphspace") String graphspace, - @RequestBody GraphSpaceEntity graphSpaceEntity) { + public Object update(@PathVariable("graphspace") String graphspace, + @RequestBody GraphSpaceEntity graphSpaceEntity) { E.checkArgument(isPdEnabled(), "GraphSpace management is not supported in standalone mode"); + HugeClient client = this.requireGraphSpaceAdministrator(); graphSpaceEntity.setName(graphspace); - - HugeClient client = this.authClient(null, null); + applyResourceDefaults(graphSpaceEntity); // Update graphspace graphSpaceService.update(client, graphSpaceEntity.convertGraphSpace()); @@ -196,11 +220,10 @@ public GraphSpace update(@PathVariable("graphspace") String graphspace, public void delete(@PathVariable("graphspace") String graphspace) { E.checkArgument(isPdEnabled(), "GraphSpace management is not supported in standalone mode"); + HugeClient client = this.requireGraphSpaceAdministrator(); E.checkArgument(StringUtils.isNotEmpty(graphspace), "graphspace " + "must not null"); - HugeClient client = this.authClient(null, null); - // Delete graphspace admin userService.listGraphSpaceAdmin(client, graphspace).forEach(u -> { client.auth().delSpaceAdmin(u, graphspace); @@ -214,6 +237,7 @@ public void delete(@PathVariable("graphspace") String graphspace) { public Object initBuiltIn(@RequestBody BuiltInEntity entity) { E.checkArgument(isPdEnabled(), "Built-in initialization is not supported in standalone mode"); + HugeClient client = this.requireGraphSpaceAdministrator(); GraphConnection connection = new GraphConnection(); String url = this.getUrl(); @@ -228,8 +252,6 @@ public Object initBuiltIn(@RequestBody BuiltInEntity entity) { connection.setGraphSpace(Constant.BUILT_IN); connection.setToken(this.getToken()); - HugeClient client = this.authClient(null, null); - Ex.check(userService.isSuperAdmin(client), "仅限系统管理员操作"); if (entity.initSpace) { graphSpaceService.initBuiltIn(client); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/VermeerController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/VermeerController.java index 99b6b9b3d..27508d836 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/VermeerController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/VermeerController.java @@ -59,9 +59,7 @@ public Map getStatus(@RequestParam(value = "check", required = false, defaultValue = "false") boolean check) { - String username = this.getUser(); - String password = this.getCredentialPassword(); - return vermeerService.getVermeer(username, password, check); + return vermeerService.getVermeer(this.getToken(), check); } @PostMapping("task") @@ -89,9 +87,6 @@ public void load(@RequestBody JsonLoad body) { params.put("load.use_outedge", "1"); params.put("load.use_out_degree", "1"); params.put("load.hugegraph_name", graphspace + "/" + graph + "/g"); - params.put("load.hugegraph_username", this.getUser()); - params.put("load.hugegraph_password", this.getCredentialPassword()); - if (body.params != null) { params.putAll(body.analyze()); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/GraphConnection.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/GraphConnection.java index 21cf472e6..9a895f7e8 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/GraphConnection.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/GraphConnection.java @@ -28,6 +28,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; import org.apache.hugegraph.annotation.MergeProperty; import org.apache.hugegraph.common.Identifiable; import org.apache.hugegraph.common.Mergeable; @@ -88,10 +89,12 @@ public class GraphConnection implements Identifiable, Mergeable { @MergeProperty @JsonProperty("password") + @ToString.Exclude private String password; @MergeProperty @JsonProperty("token") + @ToString.Exclude private String token; @MergeProperty(useNew = false) @@ -119,6 +122,7 @@ public class GraphConnection implements Identifiable, Mergeable { @TableField(exist = false) @MergeProperty(useNew = false) @JsonProperty("truststore_password") + @ToString.Exclude private String trustStorePassword; public String getGraphId() { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/PasswordEntity.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/PasswordEntity.java index 1a4989918..e878d5b30 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/PasswordEntity.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/PasswordEntity.java @@ -23,6 +23,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; @Data @NoArgsConstructor @@ -34,8 +35,10 @@ public class PasswordEntity { private String username; @JsonProperty("oldpwd") + @ToString.Exclude private String oldpwd; @JsonProperty("newpwd") + @ToString.Exclude private String newpwd; } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java index e17cd22aa..9e3712819 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java @@ -19,10 +19,12 @@ package org.apache.hugegraph.entity.auth; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; import org.apache.hugegraph.common.Identifiable; @@ -47,7 +49,8 @@ public class UserEntity implements Identifiable { @JsonProperty("user_email") private String email; - @JsonProperty("user_password") + @JsonProperty(value = "user_password", access = JsonProperty.Access.WRITE_ONLY) + @ToString.Exclude private String password; @JsonProperty("user_phone") @@ -75,8 +78,23 @@ public class UserEntity implements Identifiable { @JsonProperty("spacenum") protected Integer spacenum; - @JsonProperty("is_superadmin") private boolean isSuperadmin; -} + @JsonIgnore + private boolean superadminSpecified; + + @JsonProperty("is_superadmin") + public boolean isSuperadmin() { + return this.isSuperadmin; + } + @JsonProperty("is_superadmin") + public void setSuperadmin(boolean superadmin) { + this.isSuperadmin = superadmin; + this.superadminSpecified = true; + } + + public boolean hasSuperadmin() { + return this.superadminSpecified; + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/load/LoadTask.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/load/LoadTask.java index 7c49ee653..30acc4c56 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/load/LoadTask.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/load/LoadTask.java @@ -26,6 +26,7 @@ import org.apache.hugegraph.annotation.MergeProperty; import org.apache.hugegraph.entity.GraphConnection; import org.apache.hugegraph.entity.enums.LoadStatus; +import org.apache.hugegraph.exception.InternalException; import org.apache.hugegraph.loader.HugeGraphLoader; import org.apache.hugegraph.loader.executor.LoadContext; import org.apache.hugegraph.loader.executor.LoadOptions; @@ -229,6 +230,24 @@ public void stop() { log.info("LoadTask {} stopped", this.id); } + public void reconnect(String token) { + Ex.check(token != null && !token.isEmpty(), + "A current authentication token is required to resume loading"); + Ex.check(this.options != null, "Load options shouldn't be null"); + try { + LoadOptions runtimeOptions = (LoadOptions) this.options.clone(); + runtimeOptions.password = null; + runtimeOptions.token = token; + runtimeOptions.pdToken = null; + runtimeOptions.trustStoreToken = null; + this.loader = new HugeGraphLoader(runtimeOptions); + this.finished = false; + } catch (CloneNotSupportedException e) { + throw new InternalException("Failed to prepare load task options", + e); + } + } + public LoadContext context() { Ex.check(this.loader != null, "loader shouldn't be null"); return this.loader.context(); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/exception/ForbiddenException.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/exception/ForbiddenException.java new file mode 100644 index 000000000..6f9e56e00 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/exception/ForbiddenException.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.exception; + +public class ForbiddenException extends RuntimeException { + + public ForbiddenException(String message) { + super(message); + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ExceptionAdvisor.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ExceptionAdvisor.java index ab75117d0..c64f42786 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ExceptionAdvisor.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ExceptionAdvisor.java @@ -18,15 +18,23 @@ package org.apache.hugegraph.handler; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import javax.servlet.http.HttpServletRequest; + import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.exception.ServerException; import org.apache.hugegraph.exception.ExternalException; +import org.apache.hugegraph.exception.ForbiddenException; import org.apache.hugegraph.exception.IllegalGremlinException; import org.apache.hugegraph.exception.InternalException; import org.apache.hugegraph.exception.LoginThrottledException; import org.apache.hugegraph.exception.ParameterizedException; import org.apache.hugegraph.exception.ServerCapabilityUnavailableException; import org.apache.hugegraph.exception.UnauthorizedException; +import org.apache.hugegraph.service.op.OperationsNodeNotFoundException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -44,11 +52,6 @@ import lombok.extern.log4j.Log4j2; -import java.util.List; -import java.util.Map; - -import javax.servlet.http.HttpServletRequest; - @Log4j2 @RestControllerAdvice public class ExceptionAdvisor { @@ -77,7 +80,7 @@ public ResponseEntity exceptionHandler(LoginThrottledException e) { @ExceptionHandler(InternalException.class) @ResponseStatus(HttpStatus.OK) public Response exceptionHandler(InternalException e) { - log.warn("Internal request failure"); + log.warn("hubble.internal_request_failed"); String message = this.handleMessage(e.getMessage(), e.args()); closeRequestClient(); return Response.builder() @@ -90,16 +93,48 @@ public Response exceptionHandler(InternalException e) { @ExceptionHandler(ExternalException.class) @ResponseStatus(HttpStatus.OK) public Response exceptionHandler(ExternalException e) { - log.debug("External request failure: {}", e.getMessage()); + log.debug("hubble.external_request_failed"); String message = this.handleMessage(e.getMessage(), e.args()); closeRequestClient(); return Response.builder() - .status(e.status()) + .status(errorStatus(e.status())) .message(message) .cause(null) .build(); } + static int errorStatus(int status) { + if (status >= HttpStatus.BAD_REQUEST.value() && + status <= HttpStatus.NETWORK_AUTHENTICATION_REQUIRED.value()) { + return status; + } + return Constant.STATUS_BAD_REQUEST; + } + + @ExceptionHandler(ForbiddenException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public Response exceptionHandler(ForbiddenException e) { + log.debug("hubble.forbidden_request"); + closeRequestClient(); + return Response.builder() + .status(HttpStatus.FORBIDDEN.value()) + .message(sanitize(e.getMessage())) + .cause(null) + .build(); + } + + @ExceptionHandler(OperationsNodeNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public Response exceptionHandler(OperationsNodeNotFoundException e) { + log.debug("Operations node was not found"); + closeRequestClient(); + return Response.builder() + .status(HttpStatus.NOT_FOUND.value()) + .message(e.getMessage()) + .cause(null) + .build(); + } + @ExceptionHandler(ParameterizedException.class) @ResponseStatus(HttpStatus.OK) public Response exceptionHandler(ParameterizedException e) { @@ -115,22 +150,56 @@ public Response exceptionHandler(ParameterizedException e) { @ExceptionHandler(ServerException.class) @ResponseStatus(HttpStatus.OK) public Response exceptionHandler(ServerException e) { - log.warn("HugeGraph Server request failed"); + boolean operations = this.isOperationsRequest(); + log.error("hubble.server_request_failed"); - String message = this.handleMessage(e.getMessage(), null); + String message = operations ? safeServerMessage(e) : + this.handleMessage(sanitize(e.getMessage()), null); closeRequestClient(); return Response.builder() - .status(Constant.STATUS_BAD_REQUEST) + .status(serverStatus(e.status())) .message(message) .cause(null) .build(); } + static int serverStatus(int status) { + if (status == HttpStatus.UNAUTHORIZED.value() || + status == HttpStatus.FORBIDDEN.value()) { + return status; + } + return Constant.STATUS_BAD_REQUEST; + } + + private static boolean transportFailure(ServerException exception) { + if (serverStatus(exception.status()) != Constant.STATUS_BAD_REQUEST) { + return false; + } + String message = exception.getMessage(); + return message != null && + (message.startsWith("Failed to connect to ") || + message.contains("Connection refused")); + } + + private static String safeServerMessage(ServerException exception) { + if (exception.status() == HttpStatus.UNAUTHORIZED.value()) { + return "upstream_unauthorized"; + } + if (exception.status() == HttpStatus.FORBIDDEN.value()) { + return "upstream_forbidden"; + } + return transportFailure(exception) ? "upstream_unavailable" : + "upstream_request_failed"; + } + @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.OK) public Response exceptionHandler(Exception e) { - log.error("Unexpected request failure", e); - String message = this.handleMessage(e.getMessage(), null); + boolean operations = this.isOperationsRequest(); + log.error("hubble.unexpected_request_failed"); + String message = operations ? + "unexpected_request_failure" : + this.handleMessage(sanitize(e.getMessage()), null); closeRequestClient(); return Response.builder() .status(Constant.STATUS_BAD_REQUEST) @@ -157,7 +226,7 @@ public Response exceptionHandler( @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) public Response exceptionHandler( ServerCapabilityUnavailableException e) { - log.warn("Required HugeGraph Server capability is unavailable", e); + log.warn("hubble.server_capability_unavailable"); String message = this.handleMessage(e.getMessage(), e.args()); closeRequestClient(); return Response.builder() @@ -170,7 +239,7 @@ public Response exceptionHandler( @ExceptionHandler(IllegalGremlinException.class) @ResponseStatus(HttpStatus.OK) public Response exceptionHandler(IllegalGremlinException e) { - log.debug("Illegal Gremlin request: {}", e.getMessage()); + log.debug("hubble.illegal_gremlin_request"); String message = this.handleMessage(e.getMessage(), e.args()); closeRequestClient(); return Response.builder() @@ -183,8 +252,8 @@ public Response exceptionHandler(IllegalGremlinException e) { @ExceptionHandler(UnauthorizedException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public Response exceptionHandler(UnauthorizedException e) { - log.debug("Unauthorized request: {}", e.getMessage()); - String message = e.getMessage(); + log.debug("hubble.unauthorized_request"); + String message = sanitize(e.getMessage()); closeRequestClient(); return Response.builder() .status(Constant.STATUS_UNAUTHORIZED) @@ -198,6 +267,70 @@ protected HttpServletRequest getRequest() { RequestContextHolder.getRequestAttributes()).getRequest(); } + private boolean isOperationsRequest() { + HttpServletRequest request = this.getRequest(); + return request != null && request.getRequestURI() != null && + request.getRequestURI().startsWith("/api/v1.3/operations"); + } + + private static final Pattern URL = Pattern.compile( + "(?i)https?://[^\\s]+", Pattern.CASE_INSENSITIVE); + private static final String SENSITIVE_KEY = + "(?:token|password|secret(?:[_-]?key)?|api[_-]?key|" + + "client[_-]?secret|service[_-]?secret|private[_-]?key|" + + "credential|endpoint)"; + private static final Pattern SECRET_PARAMETER = Pattern.compile( + "(?i)([?&\\s]" + SENSITIVE_KEY + "=)[^&\\s]+", + Pattern.CASE_INSENSITIVE); + private static final Pattern AUTHORIZATION_SECRET = Pattern.compile( + "(?i)(authorization\\s*[:=]\\s*(?:basic|bearer)\\s+)[^\\s,;]+", + Pattern.CASE_INSENSITIVE); + private static final Pattern JSON_SECRET = Pattern.compile( + "(?i)([\"']" + SENSITIVE_KEY + "[\"']" + + "\\s*:\\s*[\"'])[^\"']*([\"'])", + Pattern.CASE_INSENSITIVE); + private static final Pattern KEY_VALUE_SECRET = Pattern.compile( + "(?i)((?:" + SENSITIVE_KEY + ")\\s*[:=]\\s*)" + + "(?:\"[^\"]*\"|'[^']*'|[^\\s,;}&]+)", + Pattern.CASE_INSENSITIVE); + private static final Pattern NETWORK_ENDPOINT = Pattern.compile( + "(?i)(?:/)?(?:\\d{1,3}\\.){3}\\d{1,3}:\\d+(?:/[^\\s]*)?"); + private static final Pattern COOKIE_HEADER = Pattern.compile( + "(?im)((?:set-cookie|cookie)\\s*:\\s*).*?(?=\\r?$|\\\\[rn]|$)"); + private static final Pattern PRIVATE_KEY = Pattern.compile( + "(?is)-----BEGIN [^-\\r\\n]*PRIVATE KEY-----.*?" + + "-----END [^-\\r\\n]*PRIVATE KEY-----"); + private static final Pattern WINDOWS_PATH = Pattern.compile( + "(?i)\\b[a-z]:\\\\(?:[^\\s,;:\"'<>|]+\\\\)*" + + "[^\\s,;:\"'<>|]*"); + private static final Pattern UNIX_PATH = Pattern.compile( + "(?i)/(?:Users|home|root|private|var|etc|opt|tmp|srv|mnt|" + + "Volumes|usr/local)(?:/[^\\s,;:\"'<>]*)*"); + + static String sanitize(String value) { + if (value == null) { + return "request_failure"; + } + String sanitized = PRIVATE_KEY.matcher(value) + .replaceAll("[REDACTED]"); + sanitized = COOKIE_HEADER.matcher(sanitized) + .replaceAll("$1[REDACTED]"); + sanitized = AUTHORIZATION_SECRET.matcher(sanitized) + .replaceAll("$1[REDACTED]"); + sanitized = JSON_SECRET.matcher(sanitized) + .replaceAll("$1[REDACTED]$2"); + sanitized = KEY_VALUE_SECRET.matcher(sanitized) + .replaceAll("$1[REDACTED]"); + sanitized = SECRET_PARAMETER.matcher(sanitized) + .replaceAll("$1[REDACTED]"); + sanitized = URL.matcher(sanitized).replaceAll("[REDACTED]"); + sanitized = NETWORK_ENDPOINT.matcher(sanitized) + .replaceAll("[REDACTED]"); + sanitized = WINDOWS_PATH.matcher(sanitized) + .replaceAll("[REDACTED]"); + return UNIX_PATH.matcher(sanitized).replaceAll("[REDACTED]"); + } + public void closeRequestClient() { HttpServletRequest httpRequest = getRequest(); if (httpRequest.getAttribute("hugeClient") != null) { @@ -221,9 +354,9 @@ private String handleMessage(String message, Object[] args) { try { message = this.messageSourceHandler.getMessage(message, strArgs); } catch (Throwable e) { - log.error(e.getMessage(), e); + log.error("hubble.message_resolution_failed"); } - return message; + return sanitize(message); } private String handleErrorCode(String message) { @@ -241,10 +374,8 @@ private String handleErrorCode(String message) { attach.toArray()); } } catch (Exception e) { - throw new RuntimeException( - String.format("Fail to handle error code for message " + - "%s, error: %s", message, - e.getMessage())); + log.error("hubble.error_code_parse_failed"); + throw new RuntimeException("failed_to_handle_error_code"); } } return message; diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ResponseAdvisor.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ResponseAdvisor.java index a7befeb92..bdafc8c62 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ResponseAdvisor.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/ResponseAdvisor.java @@ -36,7 +36,8 @@ import lombok.extern.log4j.Log4j2; @Log4j2 -@RestControllerAdvice(basePackages = "org.apache.hugegraph.controller") +@RestControllerAdvice(basePackages = {"org.apache.hugegraph.controller", + "org.apache.hugegraph.handler"}) public class ResponseAdvisor implements ResponseBodyAdvice { @Override @@ -55,8 +56,13 @@ public Response beforeBodyWrite(Object body, MethodParameter returnType, ServerHttpResponse response) { closeRequestClient(request); if (body instanceof Response) { - // The exception response - return (Response) body; + Response result = (Response) body; + if (result.getStatus() >= HttpStatus.BAD_REQUEST.value()) { + HttpStatus status = HttpStatus.resolve(result.getStatus()); + response.setStatusCode(status != null ? status : + HttpStatus.BAD_REQUEST); + } + return result; } return Response.builder() .status(HttpStatus.OK.value()) diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java index 0522ac909..c04132c32 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java @@ -18,6 +18,8 @@ package org.apache.hugegraph.options; +import java.net.URI; + import static org.apache.hugegraph.config.OptionChecker.allowValues; import static org.apache.hugegraph.config.OptionChecker.disallowEmpty; import static org.apache.hugegraph.config.OptionChecker.positiveInt; @@ -119,6 +121,14 @@ public static synchronized HubbleOptions instance() { 60 ); + public static final ConfigOption CLIENT_URL_CACHE_MAX_ENTRIES = + new ConfigOption<>( + "client.url_cache_max_entries", + "Maximum discovered URL scopes retained for stale fallback.", + positiveInt(), + 1024 + ); + public static final ConfigOption GREMLIN_SUFFIX_LIMIT = new ConfigOption<>( "gremlin.suffix_limit", @@ -302,6 +312,129 @@ public static synchronized HubbleOptions instance() { "127.0.0.1:8620" ); + public static final ConfigOption OPERATIONS_CONNECT_TIMEOUT = + new ConfigOption<>( + "operations.connect_timeout_ms", + "Connection timeout for each operations upstream.", + positiveInt(), + 1500 + ); + + public static final ConfigOption OPERATIONS_READ_TIMEOUT = + new ConfigOption<>( + "operations.read_timeout_ms", + "Read timeout for each operations upstream.", + positiveInt(), + 2500 + ); + + public static final ConfigOption OPERATIONS_MAX_RESPONSE_BYTES = + new ConfigOption<>( + "operations.max_response_bytes", + "Maximum accepted body size for an operations upstream.", + positiveInt(), + 1024 * 1024 + ); + + public static final ConfigOption OPERATIONS_CACHE_TTL = + new ConfigOption<>( + "operations.cache_ttl_seconds", + "Fresh operations snapshot cache lifetime in seconds.", + positiveInt(), + 5 + ); + + public static final ConfigOption OPERATIONS_CACHE_MAX_ENTRIES = + new ConfigOption<>( + "operations.cache_max_entries", + "Maximum operations snapshots retained across credentials.", + positiveInt(), + 1024 + ); + + public static final ConfigOption OPERATIONS_STORE_THREADS = + new ConfigOption<>( + "operations.store_threads", + "Maximum concurrent Store metric collection tasks.", + positiveInt(), + 16 + ); + + public static final ConfigOption OPERATIONS_STORE_DEADLINE = + new ConfigOption<>( + "operations.store_deadline_ms", + "Total deadline for one Store metric collection pass.", + positiveInt(), + 5000 + ); + + public static final ConfigListOption OPERATIONS_STORE_ALLOWED_TARGETS = + new ConfigListOption<>( + "operations.store.allowed_targets", + "Operator-managed Store metric origin allowlist.", + input -> !CollectionUtils.isEmpty(input) && + input.stream().allMatch( + HubbleOptions::validOperationsStoreTarget), + "http://127.0.0.1:8520", "http://[::1]:8520" + ); + + private static boolean validOperationsStoreTarget(String value) { + if (value == null || value.trim().isEmpty()) { + return false; + } + String origin = value.trim(); + if (origin.contains("*") || origin.matches(".*\\s+.*")) { + return false; + } + try { + URI target = URI.create(origin); + String scheme = target.getScheme(); + if (!("http".equalsIgnoreCase(scheme) || + "https".equalsIgnoreCase(scheme)) || + target.getHost() == null || target.getPort() <= 0 || + target.getPort() > 65535 || target.getUserInfo() != null || + target.getQuery() != null || target.getFragment() != null) { + return false; + } + String path = target.getPath(); + return path == null || path.isEmpty(); + } catch (IllegalArgumentException e) { + return false; + } + } + + public static final ConfigOption OPERATIONS_PD_USERNAME = + new ConfigOption<>( + "operations.pd.username", + "PD service identity used only by Hubble Backend.", + disallowEmpty(), + "hubble" + ); + + public static final ConfigOption OPERATIONS_PD_PASSWORD = + new ConfigOption<>( + "operations.pd.password", + "PD service identity secret; never returned by an API.", + null, + "" + ); + + public static final ConfigOption OPERATIONS_STORE_USERNAME = + new ConfigOption<>( + "operations.store.username", + "Store service identity used only by Hubble Backend.", + disallowEmpty(), + "hubble" + ); + + public static final ConfigOption OPERATIONS_STORE_PASSWORD = + new ConfigOption<>( + "operations.store.password", + "Store service identity secret; never returned by an API.", + null, + "" + ); + public static final ConfigOption DASHBOARD_ADDRESS = new ConfigOption<>( "dashboard.address", diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java index 04904d73d..ccd3b7033 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java @@ -28,6 +28,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import org.apache.commons.collections.CollectionUtils; @@ -63,14 +64,20 @@ public final class HugeClientPoolService { private final Map clients = new ConcurrentHashMap<>(); - /** - * cache key format: {graphSpace}_{graph} - */ - private static final String CACHE_KEY_FORMAT = "%s_%s"; private Cache> urlCache = CacheBuilder.newBuilder() + .maximumSize(HubbleOptions.CLIENT_URL_CACHE_MAX_ENTRIES.defaultValue()) .expireAfterWrite(1, TimeUnit.HOURS) .build(); + @PostConstruct + private void initializeUrlCache() { + this.urlCache = CacheBuilder.newBuilder() + .maximumSize(this.config.get( + HubbleOptions.CLIENT_URL_CACHE_MAX_ENTRIES)) + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + } + @PreDestroy public void destroy() { log.info("Destroy HugeClient pool"); @@ -150,7 +157,8 @@ private HugeClient create(String url, String graphSpace, String graph, connection.setHost(host.getHost()); connection.setPort(host.getPort()); } catch (IllegalArgumentException e) { - throw new ParameterizedException("service.url.parse.error", e, url); + throw new ParameterizedException("service.url.parse.error", e, + "[REDACTED]"); } connection.setToken(token); @@ -188,8 +196,7 @@ private List allAvailableURLs(String graphSpace, String service) { if (StringUtils.isNotEmpty(graphSpace)) { if (StringUtils.isNotEmpty(service)) { // Get realtineurls From service - List urls = pdHugeClientFactory.getURLs(cluster, graphSpace, - service); + List urls = this.discoverURLs(graphSpace, service); if (!CollectionUtils.isEmpty(urls)) { // 打乱顺序 Collections.shuffle(urls); @@ -197,7 +204,7 @@ private List allAvailableURLs(String graphSpace, String service) { } } - List urls = pdHugeClientFactory.getURLs(cluster, graphSpace, null); + List urls = this.discoverURLs(graphSpace, null); if (!CollectionUtils.isEmpty(urls)) { // 打乱顺序 Collections.shuffle(urls); @@ -205,10 +212,8 @@ private List allAvailableURLs(String graphSpace, String service) { } } - List urls = pdHugeClientFactory.getURLs(cluster, DEFAULT_GRAPHSPACE, - DEFAULT_SERVICE); - String defaultCacheKey = String.format(CACHE_KEY_FORMAT, DEFAULT_GRAPHSPACE, - DEFAULT_SERVICE); + List urls = this.discoverURLs(DEFAULT_GRAPHSPACE, DEFAULT_SERVICE); + String defaultCacheKey = cacheKey(DEFAULT_GRAPHSPACE, DEFAULT_SERVICE); if (!CollectionUtils.isEmpty(urls)) { Collections.shuffle(urls); // 设置默认URL缓存 @@ -216,16 +221,18 @@ private List allAvailableURLs(String graphSpace, String service) { realtimeurls.addAll(urls); } - String cacheKey = String.format(CACHE_KEY_FORMAT, graphSpace, service); + String cacheKey = cacheKey(graphSpace, service); if (!CollectionUtils.isEmpty(realtimeurls)) { urlCache.put(cacheKey, realtimeurls); return realtimeurls; } else { List cacheKeys = new ArrayList<>(); - cacheKeys.add(String.format(CACHE_KEY_FORMAT, graphSpace, service)); - cacheKeys.add(String.format(CACHE_KEY_FORMAT, graphSpace, null)); - cacheKeys.add(defaultCacheKey); + cacheKeys.add(cacheKey(graphSpace, service)); + cacheKeys.add(cacheKey(graphSpace, null)); + if (StringUtils.isEmpty(graphSpace)) { + cacheKeys.add(defaultCacheKey); + } for (String ck : cacheKeys) { List r = urlCache.getIfPresent(ck); if (!CollectionUtils.isEmpty(r)) { @@ -240,6 +247,28 @@ private List allAvailableURLs(String graphSpace, String service) { } } + private List discoverURLs(String graphSpace, String service) { + try { + List urls = this.pdHugeClientFactory.getURLs( + this.cluster, graphSpace, service); + if (CollectionUtils.isEmpty(urls)) { + return Collections.emptyList(); + } + return new ArrayList<>(urls); + } catch (RuntimeException e) { + log.debug("PD service discovery failed for graph space/service"); + return Collections.emptyList(); + } + } + + private static String cacheKey(String graphSpace, String service) { + return cacheKeyPart(graphSpace) + cacheKeyPart(service); + } + + private static String cacheKeyPart(String value) { + return value == null ? "-1:" : value.length() + ":" + value; + } + private boolean checkHealth(HugeClient client) { try { client.versionManager().getApiVersion(); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AccessService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AccessService.java index 542b81642..c714186bb 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AccessService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AccessService.java @@ -56,17 +56,38 @@ public AccessEntity get(HugeClient client, String graphSpace, if (access == null) { throw new ExternalException("auth.access.not-exist.id", accessId); } - Group group = RoleService.getGroup(client.auth(), - access.group().toString()); - Target target = this.targetService.get(client, + requireGraphSpace(graphSpace, access.graphSpace(), "access"); + Group group = graphSpace == null ? + RoleService.getGroup(client.auth(), + access.group().toString()) : + RoleService.requireScopedGroup( + client.auth(), graphSpace, + access.group().toString()); + Target target = graphSpace == null ? + this.targetService.get(client, + access.target().toString()) : + this.targetService.get(client, graphSpace, access.target().toString()); return convert(graphSpace, access, group, target); } private List list0(HugeClient client, String roleId, String targetId) { + return this.list0(client, null, roleId, targetId, false); + } + + private List list0(HugeClient client, String graphSpace, + String roleId, String targetId, + boolean strict) { List result = new ArrayList<>(); client.auth().listAccessesByGroup(roleId, -1).forEach(access -> { + if (!belongsToGraphSpace(graphSpace, access.graphSpace())) { + if (strict) { + requireGraphSpace(graphSpace, access.graphSpace(), + "access"); + } + return; + } if (targetId == null || access.target().toString().equals(targetId)) { result.add(access); @@ -83,19 +104,31 @@ public List list(HugeClient client, String roleId, public List list(HugeClient client, String graphSpace, String roleId, String targetId) { List result = new ArrayList<>(); - List accesses = this.list0(client, roleId, targetId); + List accesses = this.list0(client, graphSpace, roleId, + targetId, false); Multimap, Access> grouped = ArrayListMultimap.create(); accesses.forEach(access -> { + if (access.group() == null || access.target() == null || + graphSpace != null && RoleService.isPdDefaultRoleId( + access.group().toString())) { + return; + } grouped.put(ImmutableList.of(access.group().toString(), access.target().toString()), access); }); for (ImmutableList key : grouped.keySet()) { try { - Group group = RoleService.getGroup(client.auth(), key.get(0)); - Target target = this.targetService.get(client, key.get(1)); + Group group = graphSpace == null ? + RoleService.getGroup(client.auth(), key.get(0)) : + RoleService.requireScopedGroup( + client.auth(), graphSpace, key.get(0)); + Target target = graphSpace == null ? + this.targetService.get(client, key.get(1)) : + this.targetService.get(client, graphSpace, + key.get(1)); result.add(convert(graphSpace, grouped.get(key), group, target)); } catch (Exception e) { @@ -112,8 +145,20 @@ public AccessEntity addOrUpdate(HugeClient client, public AccessEntity addOrUpdate(HugeClient client, String graphSpace, AccessEntity accessEntity) { - List accesses = this.list0(client, accessEntity.getRoleId(), - accessEntity.getTargetId()); + if (accessEntity.getGraphSpace() != null) { + requireGraphSpace(graphSpace, accessEntity.getGraphSpace(), + "access"); + } + accessEntity.setGraphSpace(graphSpace); + if (graphSpace != null) { + RoleService.requireScopedGroup(client.auth(), graphSpace, + accessEntity.getRoleId()); + this.targetService.get(client, graphSpace, + accessEntity.getTargetId()); + } + List accesses = this.list0(client, graphSpace, + accessEntity.getRoleId(), + accessEntity.getTargetId(), true); Set currentPermissions = accesses.stream().map(Access::permission) .collect(Collectors.toSet()); @@ -127,6 +172,7 @@ public AccessEntity addOrUpdate(HugeClient client, String graphSpace, accessEntity.getPermissions().forEach(permission -> { if (!currentPermissions.contains(permission)) { Access access = new Access(); + access.graphSpace(graphSpace); access.group(accessEntity.getRoleId()); access.target(accessEntity.getTargetId()); access.permission(permission); @@ -149,6 +195,14 @@ public void delete(HugeClient client, String roleId, String targetId) { }); } + public void delete(HugeClient client, String graphSpace, String roleId, + String targetId) { + RoleService.requireScopedGroup(client.auth(), graphSpace, roleId); + this.targetService.get(client, graphSpace, targetId); + this.list0(client, graphSpace, roleId, targetId, true) + .forEach(access -> client.auth().deleteAccess(access.id())); + } + protected AccessEntity convert(Access access, Group group, Target target) { return this.convert(null, access, group, target); } @@ -158,7 +212,7 @@ protected AccessEntity convert(String graphSpace, Access access, AccessEntity entity = new AccessEntity(target.id().toString(), target.name(), group.id().toString(), - group.name(), + RoleService.displayName(group), firstNonNull(graphSpace, access.graphSpace(), target.graphSpace()), @@ -181,7 +235,7 @@ protected AccessEntity convert(String graphSpace, AccessEntity entity = new AccessEntity(target.id().toString(), target.name(), group.id().toString(), - group.name(), + RoleService.displayName(group), firstNonNull(graphSpace, target.graphSpace()), target.graph(), diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java new file mode 100644 index 000000000..dca1c4beb --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java @@ -0,0 +1,218 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.auth; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.auth.UserEntity; +import org.apache.hugegraph.exception.InternalException; +import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.op.OperationsCapabilityService; + +@Service +public class AuthContextService { + + public static final String SUPERADMIN = "SUPERADMIN"; + public static final String SPACEADMIN = "SPACEADMIN"; + public static final String USER = "USER"; + + public static final String ACCOUNT_SELF_MANAGE = "account_self_manage"; + public static final String ACCOUNTS_MANAGE = "accounts_manage"; + public static final String GRAPHSPACES_READ = "graphspaces_read"; + public static final String GRAPHSPACES_MANAGE = "graphspaces_manage"; + public static final String GRAPHSPACE_MEMBERS_MANAGE = + "graphspace_members_manage"; + public static final String GRAPHSPACE_ROLES_MANAGE = + "graphspace_roles_manage"; + public static final String GRAPHSPACE_AUTHORIZATIONS_MANAGE = + "graphspace_authorizations_manage"; + public static final String GRAPH_RESOURCES_ACCESS = + "graph_resources_access"; + + private static final int SCHEMA_VERSION = 1; + private static final char[] HEX = "0123456789abcdef".toCharArray(); + private static final Set SELF_ACTIONS = set( + "read", "update", "change_password"); + private static final Set CRUD_ACTIONS = set( + "read", "create", "update", "delete"); + private static final Set MEMBER_ACTIONS = set( + "read", "add", "remove"); + private static final Set AUTHORIZATION_ACTIONS = set( + "read", "grant", "revoke"); + private static final Set OPERATIONS_ACTIONS = set( + "read_health", "read_topology", "read_metrics"); + private static final Set GRAPH_RESOURCE_ACTIONS = set( + "use_authorized"); + + private final HugeConfig config; + private final UserService users; + + @Autowired + public AuthContextService(HugeConfig config, UserService users) { + this.config = config; + this.users = users; + } + + public Map context(HugeClient client, String username) { + boolean pdEnabled = this.config.get(HubbleOptions.PD_ENABLED); + String mode = pdEnabled ? "PD" : "NON_PD"; + String role; + List adminGraphSpaces = Collections.emptyList(); + if (pdEnabled) { + UserEntity user = this.users.getpersonal(client, username); + adminGraphSpaces = sorted(user.getAdminSpaces()); + if (user.isSuperadmin()) { + role = SUPERADMIN; + } else if (!adminGraphSpaces.isEmpty()) { + role = SPACEADMIN; + } else { + role = USER; + } + } else { + String serverRole = this.users.userLevel(client, username); + role = "ADMIN".equals(serverRole) ? SUPERADMIN : USER; + } + + Set capabilities = this.capabilities(pdEnabled, role); + Map> actions = this.actions(pdEnabled, role); + Map scopes = this.scopes(pdEnabled, role, + adminGraphSpaces); + String version = version(mode, username, role, capabilities, + actions, scopes); + + Map context = new LinkedHashMap<>(); + context.put("schema_version", SCHEMA_VERSION); + context.put("context_version", version); + context.put("mode", mode); + context.put("username", username); + context.put("role", role); + context.put("capabilities", capabilities); + context.put("actions", actions); + context.put("scopes", scopes); + return Collections.unmodifiableMap(context); + } + + private Set capabilities(boolean pdEnabled, String role) { + Set capabilities = new LinkedHashSet<>(); + capabilities.add(ACCOUNT_SELF_MANAGE); + capabilities.add(GRAPH_RESOURCES_ACCESS); + if (pdEnabled) { + capabilities.add(GRAPHSPACES_READ); + } + if (SUPERADMIN.equals(role)) { + capabilities.add(ACCOUNTS_MANAGE); + if (pdEnabled) { + capabilities.add(GRAPHSPACES_MANAGE); + } + } + if (pdEnabled && (SUPERADMIN.equals(role) || + SPACEADMIN.equals(role))) { + capabilities.add(GRAPHSPACE_MEMBERS_MANAGE); + capabilities.add(GRAPHSPACE_ROLES_MANAGE); + capabilities.add(GRAPHSPACE_AUTHORIZATIONS_MANAGE); + } + if (SUPERADMIN.equals(role)) { + capabilities.addAll(OperationsCapabilityService.forLevel("ADMIN")); + } + return Collections.unmodifiableSet(capabilities); + } + + private Map> actions(boolean pdEnabled, String role) { + Map> actions = new LinkedHashMap<>(); + boolean superAdmin = SUPERADMIN.equals(role); + boolean spaceManager = pdEnabled && + (superAdmin || SPACEADMIN.equals(role)); + actions.put("account", SELF_ACTIONS); + actions.put("accounts", superAdmin ? CRUD_ACTIONS : emptySet()); + actions.put("graphspaces", pdEnabled ? + (superAdmin ? CRUD_ACTIONS : set("read")) : emptySet()); + actions.put("members", spaceManager ? MEMBER_ACTIONS : emptySet()); + actions.put("roles", spaceManager ? CRUD_ACTIONS : emptySet()); + actions.put("authorizations", spaceManager ? + AUTHORIZATION_ACTIONS : emptySet()); + actions.put("operations", superAdmin ? + OPERATIONS_ACTIONS : emptySet()); + actions.put("graph_resources", GRAPH_RESOURCE_ACTIONS); + return Collections.unmodifiableMap(actions); + } + + private Map scopes(boolean pdEnabled, String role, + List adminGraphSpaces) { + Map scopes = new LinkedHashMap<>(); + scopes.put("all_graphspaces", + pdEnabled && SUPERADMIN.equals(role)); + scopes.put("admin_graphspaces", adminGraphSpaces); + scopes.put("graph_resources", "SERVER_AUTHORIZED"); + return Collections.unmodifiableMap(scopes); + } + + private static List sorted(Collection values) { + if (values == null || values.isEmpty()) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(new ArrayList<>( + new TreeSet<>(values))); + } + + private static Set set(String... values) { + return Collections.unmodifiableSet(new LinkedHashSet<>( + Arrays.asList(values))); + } + + private static Set emptySet() { + return Collections.emptySet(); + } + + private static String version(String mode, String username, String role, + Set capabilities, + Map> actions, + Map scopes) { + String fingerprint = mode + '|' + username + '|' + role + '|' + + capabilities + '|' + actions + '|' + scopes; + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest( + fingerprint.getBytes(StandardCharsets.UTF_8)); + char[] result = new char[16]; + for (int i = 0; i < 8; i++) { + result[i * 2] = HEX[(hash[i] >>> 4) & 0x0f]; + result[i * 2 + 1] = HEX[hash[i] & 0x0f]; + } + return new String(result); + } catch (NoSuchAlgorithmException e) { + throw new InternalException("SHA-256 is unavailable", e); + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthService.java index cc8dca1e6..34cc49158 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthService.java @@ -18,5 +18,23 @@ package org.apache.hugegraph.service.auth; +import java.util.Objects; + +import org.apache.hugegraph.exception.ForbiddenException; + public class AuthService { + + protected static void requireGraphSpace(String expected, String actual, + String resource) { + if (expected != null && !Objects.equals(expected, actual)) { + throw new ForbiddenException(String.format( + "Permission denied: %s belongs to graphspace %s", + resource, actual)); + } + } + + protected static boolean belongsToGraphSpace(String expected, + String actual) { + return expected == null || Objects.equals(expected, actual); + } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/BelongService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/BelongService.java index eca7734b6..544e2314b 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/BelongService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/BelongService.java @@ -53,6 +53,16 @@ public void add(HugeClient client, String roleId, String userId) { this.add(client, belong); } + public void add(HugeClient client, String graphSpace, String roleId, + String userId) { + RoleService.requireScopedGroup(client.auth(), graphSpace, roleId); + Belong belong = new Belong(); + belong.graphSpace(graphSpace); + belong.user(userId); + belong.group(roleId); + this.add(client, belong); + } + public void add(HugeClient client, Belong belong) { client.auth().createBelong(belong); } @@ -135,6 +145,46 @@ public IPage listPage(HugeClient client, String roleId, pageSize); } + public List list(HugeClient client, String graphSpace, + String roleId, String userId) { + List belongs; + if (StringUtils.isEmpty(userId) && StringUtils.isEmpty(roleId)) { + belongs = client.auth().listBelongs(); + } else if (StringUtils.isEmpty(userId)) { + RoleService.requireScopedGroup(client.auth(), graphSpace, roleId); + belongs = client.auth().listBelongsByGroup(roleId, -1); + } else if (StringUtils.isEmpty(roleId)) { + belongs = client.auth().listBelongsByUser(userId, -1); + } else { + belongs = client.auth().listBelongsByGroup(roleId, -1); + } + List result = new ArrayList<>(); + belongs.forEach(belong -> { + if (!belongsToGraphSpace(graphSpace, belong.graphSpace())) { + return; + } + if (belong.group() == null || belong.user() == null || + graphSpace != null && RoleService.isPdDefaultRoleId( + belong.group().toString())) { + return; + } + BelongEntity entity = this.convert(client, graphSpace, belong); + if (entity != null && + (StringUtils.isEmpty(userId) || + userId.equals(entity.getUserId()))) { + result.add(entity); + } + }); + return result; + } + + public IPage listPage(HugeClient client, String graphSpace, + String roleId, String userId, + int pageNo, int pageSize) { + return PageUtil.page(this.list(client, graphSpace, roleId, userId), + pageNo, pageSize); + } + public BelongEntity get(HugeClient client, String belongId) { Belong belong = client.auth().getBelong(belongId); if (belong == null) { @@ -144,15 +194,42 @@ public BelongEntity get(HugeClient client, String belongId) { return this.convert(client, belong); } + public BelongEntity get(HugeClient client, String graphSpace, + String belongId) { + return this.convert(client, graphSpace, this.requireBelong( + client, graphSpace, belongId)); + } + + private Belong requireBelong(HugeClient client, String graphSpace, + String belongId) { + Belong belong = client.auth().getBelong(belongId); + if (belong == null) { + throw new InternalException("auth.belong.get.%s Not Exits", + belongId); + } + requireGraphSpace(graphSpace, belong.graphSpace(), "belong"); + return belong; + } + protected BelongEntity convert(HugeClient client, Belong belong) { + return this.convert(client, null, belong); + } + + private BelongEntity convert(HugeClient client, String graphSpace, + Belong belong) { try { - Group group = RoleService.getGroup(client.auth(), - belong.group().toString()); + Group group = graphSpace == null ? + RoleService.getGroup( + client.auth(), belong.group().toString()) : + RoleService.requireScopedGroup( + client.auth(), graphSpace, + belong.group().toString()); UserEntity user = this.userService.getUser(client, belong.user().toString()); return new BelongEntity(belong.id().toString(), user.getId(), user.getName(), - group.id().toString(), group.name(), + group.id().toString(), + RoleService.displayName(group), user.getDescription(), user.getCreate()); } catch (Exception e) { log.warn("convert belong error", e); @@ -166,6 +243,27 @@ public void deleteMany(HugeClient client, String[] ids) { }); } + public void deleteById(HugeClient client, String graphSpace, + String belongId) { + this.requireBelong(client, graphSpace, belongId); + this.delete(client, belongId); + } + + public void delete(HugeClient client, String graphSpace, String roleId, + String userId) { + RoleService.requireScopedGroup(client.auth(), graphSpace, roleId); + this.list(client, graphSpace, roleId, userId).forEach(belong -> { + client.auth().deleteBelong(belong.getId()); + }); + } + + public void deleteMany(HugeClient client, String graphSpace, + String[] ids) { + Arrays.stream(ids).forEach(id -> this.requireBelong( + client, graphSpace, id)); + Arrays.stream(ids).forEach(id -> client.auth().deleteBelong(id)); + } + public boolean exists(HugeClient client, String roleId, String userId) { return !this.list(client, roleId, userId).isEmpty(); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java index 61a78d301..cfdd501cf 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java @@ -32,7 +32,6 @@ import org.apache.hugegraph.entity.auth.BelongEntity; import org.apache.hugegraph.entity.auth.RoleEntity; import org.apache.hugegraph.entity.auth.UserView; -import org.apache.hugegraph.structure.auth.Belong; import org.apache.hugegraph.structure.auth.User; import org.apache.hugegraph.util.E; import org.apache.hugegraph.util.PageUtil; @@ -46,16 +45,10 @@ public class GraphSpaceUserService extends AuthService { @Autowired private BelongService belongService; - @Autowired - private RoleService roleService; - - public List listUsers(HugeClient client) { + public List listUsers(HugeClient client, String graphSpace) { List users = new ArrayList<>(); - List belongs = new ArrayList<>(); - this.roleService.list(client).forEach(role -> { - belongs.addAll(this.belongService.listByRole(client, - role.id().toString())); - }); + List belongs = this.belongService.list( + client, graphSpace, null, null); Multimap grouped = ArrayListMultimap.create(); belongs.forEach(belong -> { @@ -75,9 +68,10 @@ public List listUsers(HugeClient client) { return users; } - public UserView getUser(HugeClient client, String userId) { - List belongs = this.belongService.listByUser(client, - userId); + public UserView getUser(HugeClient client, String graphSpace, + String userId) { + List belongs = this.belongService.list( + client, graphSpace, null, userId); UserView user = new UserView(null, null, new ArrayList<>(belongs.size())); belongs.forEach(belong -> { @@ -89,17 +83,18 @@ public UserView getUser(HugeClient client, String userId) { return user; } - public IPage queryPage(HugeClient client, String query, - int pageNo, int pageSize) { + public IPage queryPage(HugeClient client, String graphSpace, + String query, int pageNo, int pageSize) { List results = - this.listUsers(client).stream() + this.listUsers(client, graphSpace).stream() .filter(user -> user.getName().contains(query)) .sorted(Comparator.comparing(UserView::getName)) .collect(Collectors.toList()); return PageUtil.page(results, pageNo, pageSize); } - public UserView createOrUpdate(HugeClient client, UserView userView) { + public UserView createOrUpdate(HugeClient client, String graphSpace, + UserView userView) { E.checkNotNull(userView.getId(), "User Id Not Null"); E.checkArgument(userView.getRoles() != null && !userView.getRoles().isEmpty(), @@ -109,32 +104,48 @@ public UserView createOrUpdate(HugeClient client, UserView userView) { userView.getRoles().stream() .map(RoleEntity::getId) .collect(Collectors.toSet()); - this.belongService.listByUser(client, userView.getId()) - .forEach(belong -> { - if (!newRoles.contains(belong.getRoleId())) { - client.auth().deleteBelong(belong.getId()); - } - }); + newRoles.forEach(roleId -> RoleService.requireScopedGroup( + client.auth(), graphSpace, roleId)); + User account = client.auth().getUser(userView.getId()); + E.checkNotNull(account, "User"); + String username = account.name(); + E.checkArgument(username != null && !username.isEmpty(), + "The user name is empty"); + List current = this.belongService.list( + client, graphSpace, null, userView.getId()); + if (!client.auth().listSpaceMember(graphSpace).contains(username)) { + client.auth().addSpaceMember(username, graphSpace); + } + current.forEach(belong -> { + if (!newRoles.contains(belong.getRoleId())) { + this.belongService.deleteById(client, graphSpace, + belong.getId()); + } + }); + Set currentRoles = current.stream() + .map(BelongEntity::getRoleId) + .collect(Collectors.toSet()); userView.getRoles().forEach(role -> { - if (!this.belongService.exists(client, role.getId(), - userView.getId())) { - Belong belong = new Belong(); - belong.user(userView.getId()); - belong.group(role.getId()); - this.belongService.add(client, belong); + if (!currentRoles.contains(role.getId())) { + this.belongService.add(client, graphSpace, role.getId(), + userView.getId()); } }); - return this.getUser(client, userView.getId()); + return this.getUser(client, graphSpace, userView.getId()); } - public void unauthUser(HugeClient client, String userId) { - List belongs = this.belongService.listByUser(client, - userId); + public void unauthUser(HugeClient client, String graphSpace, + String userId) { + User account = client.auth().getUser(userId); + E.checkNotNull(account, "User"); + List belongs = this.belongService.list( + client, graphSpace, null, userId); E.checkState(!belongs.isEmpty(), "The user: (%s) not exists", userId); belongs.forEach(belong -> { - this.belongService.delete(client, belong.getId()); + this.belongService.deleteById(client, graphSpace, belong.getId()); }); + client.auth().delSpaceMember(account.name(), graphSpace); } public IPage querySpaceAdmins(HugeClient client, String graphSpace, diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/RoleService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/RoleService.java index 71e30d3c6..d982e3b10 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/RoleService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/RoleService.java @@ -19,14 +19,22 @@ package org.apache.hugegraph.service.auth; import java.util.ArrayList; +import java.util.Base64; import java.util.List; +import java.util.UUID; +import java.nio.charset.StandardCharsets; import com.baomidou.mybatisplus.core.metadata.IPage; import lombok.extern.log4j.Log4j2; import org.apache.hugegraph.driver.AuthManager; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.exception.ExternalException; +import org.apache.hugegraph.exception.ForbiddenException; +import org.apache.hugegraph.exception.ParameterizedException; +import org.apache.hugegraph.structure.auth.Access; +import org.apache.hugegraph.structure.auth.Belong; import org.apache.hugegraph.structure.auth.Group; +import org.apache.hugegraph.structure.auth.HugePermission; import org.apache.hugegraph.structure.auth.Role; import org.apache.hugegraph.util.PageUtil; import org.springframework.stereotype.Service; @@ -35,28 +43,60 @@ @Service public class RoleService extends AuthService { - public Role get(HugeClient client, String roleId) { - return this.get(client, null, roleId); - } + private static final String SCOPED_PREFIX = "~hubble_role:v1:"; + private static final String METADATA_PREFIX = "~hubble_role_meta:v1:"; + private static final Base64.Encoder ENCODER = + Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); - public Role get(HugeClient client, String graphSpace, String roleId) { + public Role get(HugeClient client, String roleId) { AuthManager auth = client.auth(); Group group = auth.getGroup(roleId); if (group == null) { throw new ExternalException("auth.role.get.not-exist", roleId); } - return toRole(graphSpace, group); + return toRole(null, group); + } + + public Role get(HugeClient client, String graphSpace, String roleId) { + return this.get(client, graphSpace, roleId, false); + } + + public Role get(HugeClient client, String graphSpace, String roleId, + boolean includeLegacy) { + Group group = requireGroup(client.auth(), graphSpace, roleId, + includeLegacy); + return toRole(scopeOf(group), group); } public List list(HugeClient client) { - return this.list(client, null); + List roles = new ArrayList<>(); + client.auth().listGroups().forEach(group -> { + roles.add(toRole(null, group)); + }); + return roles; } public List list(HugeClient client, String graphSpace) { + return this.list(client, graphSpace, false); + } + + public List list(HugeClient client, String graphSpace, + boolean includeLegacy) { List roles = new ArrayList<>(); - client.auth().listGroups().forEach(group -> { - roles.add(toRole(graphSpace, group)); + client.auth().listGraphSpaceGroups().forEach(group -> { + String scope = scopeOf(group); + if (graphSpace.equals(scope)) { + roles.add(toRole(scope, group)); + } }); + if (includeLegacy) { + client.auth().listGroups().forEach(group -> { + if (scopeOf(group) == null && isVisibleLegacy(group)) { + roles.add(toRole(null, group)); + } + }); + } return roles; } @@ -67,9 +107,17 @@ public IPage queryPage(HugeClient client, String query, public IPage queryPage(HugeClient client, String graphSpace, String query, int pageNo, int pageSize) { + return this.queryPage(client, graphSpace, query, pageNo, pageSize, + false); + } + + public IPage queryPage(HugeClient client, String graphSpace, + String query, int pageNo, int pageSize, + boolean includeLegacy) { ArrayList results = new ArrayList<>(); - this.list(client, graphSpace).stream() - .filter(role -> role.nickname().contains(query)) + this.list(client, graphSpace, includeLegacy).stream() + .filter(role -> role.nickname() != null && + role.nickname().contains(query)) .forEach(results::add); return PageUtil.page(results, pageNo, pageSize); } @@ -83,14 +131,45 @@ public Role update(HugeClient client, Role role) { } group.name(firstNonNull(role.name(), role.nickname(), group.name())); group.description(role.description()); - return toRole(role.graphSpace(), auth.updateGroup(group)); + return toRole(null, auth.updateGroup(group)); } public Role insert(HugeClient client, Role role) { Group group = new Group(); group.name(firstNonNull(role.name(), role.nickname())); group.description(role.description()); - return toRole(role.graphSpace(), client.auth().createGroup(group)); + return toRole(null, client.auth().createGroup(group)); + } + + public Role insert(HugeClient client, String graphSpace, Role role) { + String name = firstNonNull(role.name(), role.nickname()); + checkScopedRole(graphSpace, name); + Group group = new Group(); + group.name(scopedGroupName(graphSpace)); + group.description(metadata(name, role.description())); + Group created = client.auth().createGraphSpaceGroup(group); + return toRole(graphSpace, created); + } + + public Role update(HugeClient client, String graphSpace, Role role, + boolean includeLegacy) { + AuthManager auth = client.auth(); + Group group = requireGroup(auth, graphSpace, role.id().toString(), + includeLegacy); + String scope = scopeOf(group); + if (scope == null) { + if (role.name() != null && !role.name().equals(group.name())) { + throw new ParameterizedException( + "Legacy role names cannot be updated"); + } + group.description(role.description()); + return toRole(null, auth.updateGroup(group)); + } + String name = firstNonNull(role.name(), role.nickname(), + displayName(group)); + checkScopedRole(graphSpace, name); + group.description(metadata(name, role.description())); + return toRole(scope, auth.updateGraphSpaceGroup(group)); } public void delete(HugeClient client, String roleId) { @@ -105,6 +184,28 @@ public void delete(HugeClient client, String roleId) { auth.deleteGroup(roleId); } + public void delete(HugeClient client, String graphSpace, String roleId, + boolean includeLegacy) { + AuthManager auth = client.auth(); + Group group = requireGroup(auth, graphSpace, roleId, includeLegacy); + String scope = scopeOf(group); + List accesses = auth.listAccessesByGroup(group, -1); + List belongs = auth.listBelongsByGroup(group, -1); + if (scope != null) { + accesses.forEach(access -> requireGraphSpace( + scope, access.graphSpace(), "access")); + belongs.forEach(belong -> requireGraphSpace( + scope, belong.graphSpace(), "belong")); + } + accesses.forEach(access -> auth.deleteAccess(access.id())); + belongs.forEach(belong -> auth.deleteBelong(belong.id())); + if (scope == null) { + auth.deleteGroup(roleId); + } else { + auth.deleteGraphSpaceGroup(roleId); + } + } + protected static Group getGroup(AuthManager auth, String roleId) { Group group = auth.getGroup(roleId); if (group == null) { @@ -113,16 +214,134 @@ protected static Group getGroup(AuthManager auth, String roleId) { return group; } + static Group requireScopedGroup(AuthManager auth, String graphSpace, + String roleId) { + return requireGroup(auth, graphSpace, roleId, false); + } + + private static Group requireGroup(AuthManager auth, String graphSpace, + String roleId, boolean includeLegacy) { + Group group = includeLegacy ? getGroup(auth, roleId) : + getGraphSpaceGroup(auth, roleId); + String scope = scopeOf(group); + if (scope == null) { + if (!includeLegacy || !isVisibleLegacy(group)) { + throw forbiddenRole(); + } + return group; + } + if (!graphSpace.equals(scope)) { + throw forbiddenRole(); + } + return group; + } + + private static Group getGraphSpaceGroup(AuthManager auth, + String roleId) { + Group group = auth.getGraphSpaceGroup(roleId); + if (group == null) { + throw new ExternalException("auth.role.not-exist", roleId); + } + return group; + } + protected static Role toRole(String graphSpace, Group group) { Role role = new Role(); role.setId(group.id()); role.graphSpace(graphSpace); - role.name(group.name()); - role.nickname(group.name()); - role.description(group.description()); + String name = graphSpace == null ? group.name() : displayName(group); + role.name(name); + role.nickname(name); + role.description(graphSpace == null ? group.description() : + displayDescription(group)); return role; } + static String displayName(Group group) { + String[] metadata = metadata(group); + return metadata == null ? group.name() : metadata[0]; + } + + static boolean isPdDefaultRoleId(String roleId) { + return HugePermission.SPACE.string().equalsIgnoreCase(roleId) || + HugePermission.SPACE_MEMBER.string().equalsIgnoreCase(roleId) || + HugePermission.ADMIN.string().equalsIgnoreCase(roleId); + } + + private static String displayDescription(Group group) { + String[] metadata = metadata(group); + return metadata == null ? "" : metadata[1]; + } + + private static String scopedGroupName(String graphSpace) { + return SCOPED_PREFIX + encode(graphSpace) + ":" + + UUID.randomUUID().toString().replace("-", ""); + } + + private static String metadata(String name, String description) { + return METADATA_PREFIX + encode(name) + ":" + + encode(description == null ? "" : description); + } + + private static String[] metadata(Group group) { + String value = group.description(); + if (value == null || !value.startsWith(METADATA_PREFIX)) { + return null; + } + String[] parts = value.substring(METADATA_PREFIX.length()) + .split(":", -1); + if (parts.length != 2) { + return null; + } + try { + return new String[]{decode(parts[0]), decode(parts[1])}; + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private static String scopeOf(Group group) { + String name = group.name(); + if (name == null || !name.startsWith(SCOPED_PREFIX)) { + return null; + } + String[] parts = name.substring(SCOPED_PREFIX.length()) + .split(":", -1); + if (parts.length != 2 || !parts[1].matches("[0-9a-f]{32}")) { + return null; + } + try { + return decode(parts[0]); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private static boolean isVisibleLegacy(Group group) { + return group.name() != null && !group.name().startsWith("~"); + } + + private static String encode(String value) { + return ENCODER.encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String decode(String value) { + return new String(DECODER.decode(value), StandardCharsets.UTF_8); + } + + private static void checkScopedRole(String graphSpace, String name) { + if (graphSpace == null || graphSpace.isEmpty() || name == null || + name.isEmpty()) { + throw new ParameterizedException( + "Graphspace and role name cannot be empty"); + } + } + + private static ForbiddenException forbiddenRole() { + return new ForbiddenException( + "Permission denied: role belongs to another graphspace"); + } + private static String firstNonNull(String value, String fallback) { return value != null ? value : fallback; } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/TargetService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/TargetService.java index fee098bc1..5c9c8be11 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/TargetService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/TargetService.java @@ -26,6 +26,7 @@ import lombok.extern.log4j.Log4j2; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.exception.ExternalException; +import org.apache.hugegraph.exception.ForbiddenException; import org.apache.hugegraph.structure.auth.Target; import org.apache.hugegraph.util.PageUtil; import org.springframework.stereotype.Service; @@ -34,14 +35,31 @@ @Service public class TargetService extends AuthService { + private static final String PD_DEFAULT_TARGET = "DEFAULT_SPACE_TARGET"; + public List list(HugeClient client) { - return client.auth().listTargets(); + return client.auth().listTargets().stream() + .filter(target -> !isPdDefaultTarget(target)) + .collect(Collectors.toList()); + } + + public List list(HugeClient client, String graphSpace) { + return this.list(client).stream() + .filter(target -> belongsToGraphSpace( + graphSpace, target.graphSpace())) + .collect(Collectors.toList()); } public IPage queryPage(HugeClient client, String query, int pageNo, int pageSize) { + return this.queryPage(client, null, query, pageNo, pageSize); + } + + public IPage queryPage(HugeClient client, String graphSpace, + String query, int pageNo, int pageSize) { List results = - this.list(client).stream() + (graphSpace == null ? this.list(client) : + this.list(client, graphSpace)).stream() .filter(target -> target.name().toLowerCase() .contains(query.toLowerCase())) .sorted(Comparator.comparing(Target::name)) @@ -54,18 +72,62 @@ public Target get(HugeClient client, String targetId) { if (target == null) { throw new ExternalException("auth.target.not-exist.id", targetId); } + requireCustomTarget(target); return target; } + public Target get(HugeClient client, String graphSpace, String targetId) { + Target target = this.get(client, targetId); + requireGraphSpace(graphSpace, target.graphSpace(), "target"); + return target; + } + + public Target add(HugeClient client, String graphSpace, Target target) { + requireCustomTarget(target); + if (target.graphSpace() != null) { + requireGraphSpace(graphSpace, target.graphSpace(), "target"); + } + target.graphSpace(graphSpace); + return this.add(client, target); + } + public Target add(HugeClient client, Target target) { return client.auth().createTarget(target); } public Target update(HugeClient client, Target target) { + requireCustomTarget(target); return client.auth().updateTarget(target); } + public Target update(HugeClient client, String graphSpace, Target target) { + requireGraphSpace(graphSpace, target.graphSpace(), "target"); + target.graphSpace(graphSpace); + return this.update(client, target); + } + public void delete(HugeClient client, String targetId) { client.auth().deleteTarget(targetId); } + + public void delete(HugeClient client, String graphSpace, String targetId) { + this.get(client, graphSpace, targetId); + this.delete(client, targetId); + } + + static boolean isPdDefaultTarget(Target target) { + return target != null && isPdDefaultTargetName(target.name()); + } + + private static boolean isPdDefaultTargetName(String name) { + return name != null && (PD_DEFAULT_TARGET.equals(name) || + name.endsWith("_" + PD_DEFAULT_TARGET)); + } + + private static void requireCustomTarget(Target target) { + if (isPdDefaultTarget(target)) { + throw new ForbiddenException( + "Permission denied: manage PD default target"); + } + } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java index 8b67a8c9b..035ba359b 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java @@ -79,7 +79,7 @@ public List listUsers(HugeClient hugeClient) { if (isPdEnabled()) { ue.setSuperadmin(isSuperAdmin(hugeClient, ue.getId())); } else { - ue.setSuperadmin(false); + ue.setSuperadmin(isStandaloneAdmin(ue.getName())); } ues.add(ue); }); @@ -125,6 +125,10 @@ public Object queryPage(HugeClient hugeClient, String query, user.setAdminSpaces(spaceMap.get(user.getName())); user.setSuperadmin(isSuperAdmin(hugeClient, user.getId())); } + } else { + for (UserEntity user : results) { + user.setSuperadmin(isStandaloneAdmin(user.getName())); + } } return PageUtil.page(results, pageNo, pageSize); } @@ -155,7 +159,7 @@ public UserEntity get(HugeClient hugeClient, String userId) { userEntity.setSpacenum(adminSpaces.size()); userEntity.setResSpaces(resSpaces); } else { - userEntity.setSuperadmin(false); + userEntity.setSuperadmin(isStandaloneAdmin(user.name())); userEntity.setAdminSpaces(new ArrayList<>()); userEntity.setSpacenum(0); userEntity.setResSpaces(new ArrayList<>()); @@ -189,7 +193,7 @@ public UserEntity getpersonal(HugeClient hugeClient, String username) { userEntity.setSpacenum(adminSpaces.size()); userEntity.setResSpaces(resSpaces); } else { - userEntity.setSuperadmin(false); + userEntity.setSuperadmin(isStandaloneAdmin(username)); userEntity.setAdminSpaces(new ArrayList<>()); userEntity.setSpacenum(0); userEntity.setResSpaces(new ArrayList<>()); @@ -205,7 +209,9 @@ public void add(HugeClient client, UserEntity ue) { user.email(ue.getEmail()); user.avatar(ue.getAvatar()); user.description(ue.getDescription()); - user.nickname(ue.getNickname()); + if (isPdEnabled()) { + user.nickname(ue.getNickname()); + } User newUser = client.auth().createUser(user); if (ue.getAdminSpaces() != null) { @@ -377,7 +383,9 @@ public void update(HugeClient hugeClient, UserEntity userEntity) { user.phone(userEntity.getPhone()); user.email(userEntity.getEmail()); user.description(userEntity.getDescription()); - user.nickname(userEntity.getNickname()); + if (isPdEnabled()) { + user.nickname(userEntity.getNickname()); + } updateAdminSpace(hugeClient, userEntity.getName(), userEntity.getAdminSpaces()); // 设置超级管理员权限 @@ -396,7 +404,11 @@ public void updatePersonal(HugeClient hugeClient, String username, String nickname, String description) { AuthManager auth = hugeClient.auth(); User user = auth.getUserByName(username); - user.nickname(nickname); + if (isPdEnabled()) { + user.nickname(nickname); + } else { + user.nickname(null); + } user.description(description); user.password(null); hugeClient.auth().updateUser(user); @@ -464,11 +476,9 @@ public void updateAdminSpace(HugeClient hugeClient, String username, } } - public String userLevel(HugeClient client) { + public String userLevel(HugeClient client, String username) { if (!isPdEnabled()) { - // In non-PD mode, Manager/GraphSpace APIs are not available. - // Treat the logged-in user as ADMIN for full access. - return "ADMIN"; + return standaloneUserLevel(username); } if (isSuperAdmin(client)) { @@ -483,6 +493,16 @@ public String userLevel(HugeClient client) { return "USER"; } + static String standaloneUserLevel(String username) { + // StandardAuthenticator reserves the configured administrator account + // name. Other authenticated users still depend on explicit role grants. + return "admin".equals(username) ? "ADMIN" : "USER"; + } + + private static boolean isStandaloneAdmin(String username) { + return "ADMIN".equals(standaloneUserLevel(username)); + } + public boolean isSuperAdmin(HugeClient client, String uid) { if (!isPdEnabled()) { return false; diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java index e17146804..63d280868 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java @@ -40,6 +40,7 @@ import org.apache.hugegraph.entity.query.ExecuteHistory; import org.apache.hugegraph.entity.query.GremlinQuery; import org.apache.hugegraph.entity.space.BuiltInEntity; +import org.apache.hugegraph.exception.ServerException; import org.apache.hugegraph.loader.util.JsonUtil; import org.apache.hugegraph.service.algorithm.AsyncTaskService; import org.apache.hugegraph.service.auth.UserService; @@ -47,6 +48,7 @@ import org.apache.hugegraph.service.query.ExecuteHistoryService; import org.apache.hugegraph.service.query.QueryService; import org.apache.hugegraph.service.schema.SchemaService; +import org.apache.hugegraph.structure.GraphElement; import org.apache.hugegraph.structure.Task; import org.apache.hugegraph.structure.constant.GraphReadMode; import org.apache.hugegraph.structure.gremlin.ResultSet; @@ -61,10 +63,12 @@ import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -75,6 +79,10 @@ @Service public class GraphsService { + private static final Pattern GRAPH_NAME_PATTERN = Pattern.compile( + "[A-Za-z][A-Za-z0-9_]{0,47}" + ); + @Autowired private SchemaService schemaService; @@ -98,6 +106,8 @@ public class GraphsService { "g.V().groupCount().by(label)"; private static final String GREMLIN_STATISTICS_EDGE = "g.E().groupCount().by(label)"; + private static final int SMALL_STATISTICS_LIMIT = 10000; + private static final int SMALL_STATISTICS_PAGE_SIZE = 1000; private static final String GRAPH_HLM = "hlm"; private static final String GRAPH_COVID19 = "covid19"; @@ -154,7 +164,7 @@ public List> sortedGraphsProfile(HugeClient client, boolean isVermeerEnabled, Map vermeerInfo) { // Get authorized graphs - List> graphs = client.graphs().listProfile(query); + List> graphs = listGraphProfiles(client.graphs(), query); log.info("Query all graphs in '{}' ", graphSpace); for (Map info : graphs) { String name = info.get("name").toString(); @@ -219,6 +229,34 @@ public List> sortedGraphsProfile(HugeClient client, } + private List> listGraphProfiles(GraphsManager graphs, + String query) { + try { + return graphs.listProfile(query); + } catch (RuntimeException e) { + if (e instanceof ServerException) { + int status = ((ServerException) e).status(); + if (status == 401 || status == 403) { + throw e; + } + } + log.warn("Graph profiles are unavailable; using standalone graph metadata"); + } + + String prefix = query == null ? "" : query; + List> profiles = new ArrayList<>(); + for (String name : graphs.listGraph()) { + if (!name.startsWith(prefix)) { + continue; + } + Map profile = new HashMap<>(); + profile.putAll(graphs.getGraph(name)); + profile.put("name", name); + profiles.add(profile); + } + return profiles; + } + public Set listGraphNames(HugeClient client, String graphSpace, String uid) { @@ -254,8 +292,12 @@ public Map create(HugeClient client, String graph, public Map create(HugeClient client, String nickname, String graph, String schemaTemplate) { + Ex.check(graph != null && GRAPH_NAME_PATTERN.matcher(graph).matches(), + "graph-connection.graph.unmatch-regex"); Map conf = new HashMap<>(); + conf.put("gremlin.graph", + "org.apache.hugegraph.auth.HugeFactoryAuthProxy"); conf.put("store", graph); boolean pdEnabled = config.get(org.apache.hugegraph.options.HubbleOptions.PD_ENABLED); if (pdEnabled) { @@ -264,6 +306,8 @@ public Map create(HugeClient client, String nickname, } else { conf.put("backend", "rocksdb"); conf.put("task.scheduler_type", "local"); + conf.put("rocksdb.data_path", "rocksdb-data/data_" + graph); + conf.put("rocksdb.wal_path", "rocksdb-data/wal_" + graph); } conf.put("serializer", "binary"); conf.put("nickname", nickname); @@ -551,35 +595,97 @@ public GraphStatisticsEntity updateCacheFromTask(HugeClient client, public GraphStatisticsEntity postSmallStatistics(HugeClient client, String graphSpace, String graph) { - GraphStatisticsEntity result = GraphStatisticsEntity.emptyEntity(); + try { + return this.postSmallGremlinStatistics(client); + } catch (RuntimeException e) { + log.warn("Gremlin statistics failed for {}/{}, falling back " + + "to bounded graph reads", graphSpace, graph, e); + return this.postSmallGraphStatistics(client); + } + } + + private GraphStatisticsEntity postSmallGremlinStatistics(HugeClient client) { ResultSet vertexResult = queryService.executeQueryCount(client, GREMLIN_STATISTICS_VERTEX); ResultSet edgeResult = queryService.executeQueryCount(client, GREMLIN_STATISTICS_EDGE); - if (vertexResult.data() != null && vertexResult.data().size() != 0) { - Map vertices = - HubbleUtil.uncheckedCast(vertexResult.data().get(0)); - result.setVertices(vertices); - result.setVertexCount(getCountFromLabels(vertices)); - } - if (edgeResult.data() != null && edgeResult.data().size() != 0) { - Map edges = - HubbleUtil.uncheckedCast(edgeResult.data().get(0)); - result.setEdges(edges); - result.setEdgeCount(getCountFromLabels(edges)); - } + Map vertices = extractCountMap(vertexResult, + "vertices"); + Map edges = extractCountMap(edgeResult, "edges"); + GraphStatisticsEntity result = GraphStatisticsEntity.emptyEntity(); + result.setVertices(vertices); + result.setVertexCount(getCountFromLabels(vertices)); + result.setEdges(edges); + result.setEdgeCount(getCountFromLabels(edges)); + result.setUpdateTime(HubbleUtil.dateFormat()); + return result; + } + + private static Map extractCountMap(ResultSet result, + String elementType) { + List data = HubbleUtil.uncheckedCast(result.data()); + Ex.check(data != null && data.size() == 1 && + data.get(0) instanceof Map, + "Malformed %s statistics response", elementType); + return HubbleUtil.uncheckedCast(data.get(0)); + } + + private GraphStatisticsEntity postSmallGraphStatistics(HugeClient client) { + Map vertexCounts = new HashMap<>(); + int vertexCount = countSmallElements( + client.graph().iterateVertices(SMALL_STATISTICS_PAGE_SIZE), + vertexCounts); + Map edgeCounts = new HashMap<>(); + int edgeCount = countSmallElements( + client.graph().iterateEdges(SMALL_STATISTICS_PAGE_SIZE), + edgeCounts); + + GraphStatisticsEntity result = GraphStatisticsEntity.emptyEntity(); + result.setVertices(vertexCounts); + result.setVertexCount(String.valueOf(vertexCount)); + result.setEdges(edgeCounts); + result.setEdgeCount(String.valueOf(edgeCount)); result.setUpdateTime(HubbleUtil.dateFormat()); return result; } + private static int countSmallElements( + Iterator elements, + Map counts) { + int count = 0; + while (elements.hasNext()) { + GraphElement element = elements.next(); + count++; + Ex.check(count <= SMALL_STATISTICS_LIMIT, + "Small graph statistics fallback exceeds %s elements", + SMALL_STATISTICS_LIMIT); + incrementLabel(counts, element.label()); + } + return count; + } + + private static void incrementLabel(Map counts, String label) { + Number count = (Number) counts.getOrDefault(label, 0L); + counts.put(label, Math.addExact(count.longValue(), 1L)); + } + public String getCountFromLabels(Map labels) { - Integer count = 0; + long count = 0L; for (Map.Entry entry: labels.entrySet()) { - count += (Integer) entry.getValue(); + Object value = entry.getValue(); + Ex.check(value instanceof Number, + "Malformed statistics count for label '%s'", + entry.getKey()); + Number number = (Number) value; + long labelCount = number.longValue(); + Ex.check(labelCount >= 0L && number.doubleValue() == labelCount, + "Malformed statistics count for label '%s'", + entry.getKey()); + count = Math.addExact(count, labelCount); } - return count.toString(); + return String.valueOf(count); } public long executeAsyncTask(HugeClient client, String graphSpace, @@ -674,8 +780,8 @@ public Map evCount(HugeClient client, String graphSpace, String graph) { Map res = new HashMap<>(); - long edgeCount = 0L; - long vertexCount = 0L; + Long edgeCount = null; + Long vertexCount = null; String statisticDate = HubbleUtil.dateFormatDay(HubbleUtil.nowDate()); client.assignGraph(graphSpace, graph); GraphMetricsAPI.ElementCount statistic = @@ -687,7 +793,22 @@ public Map evCount(HugeClient client, if (statistic != null) { vertexCount = statistic.getVertices(); - edgeCount += statistic.getEdges(); + edgeCount = statistic.getEdges(); + } else { + statisticDate = null; + try { + GraphStatisticsEntity live = + this.postSmallGraphStatistics(client); + vertexCount = Long.valueOf(live.getVertexCount()); + edgeCount = Long.valueOf(live.getEdgeCount()); + statisticDate = HubbleUtil.dateFormatDay(HubbleUtil.nowDate()); + } catch (RuntimeException e) { + vertexCount = null; + edgeCount = null; + statisticDate = null; + log.warn("Live element counts are unavailable for {}/{}", + graphSpace, graph, e); + } } res.put("date", statisticDate); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java index 9d19a9523..b724c8e9f 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java @@ -29,6 +29,8 @@ import com.google.common.collect.ImmutableMap; import lombok.extern.log4j.Log4j2; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.GraphConnection; import org.apache.hugegraph.entity.enums.JobStatus; import org.apache.hugegraph.entity.enums.LoadStatus; import org.apache.hugegraph.entity.load.FileMapping; @@ -161,6 +163,16 @@ public void save(JobManager entity) { } } + @Transactional(isolation = Isolation.READ_COMMITTED) + public LoadTask createIngestTask(JobManager job, FileMapping mapping, + GraphConnection connection, + HugeClient client) { + this.save(job); + mapping.setJobId(job.getId()); + this.fileMappingService.save(mapping); + return this.taskService.start(connection, mapping, client); + } + @Transactional(isolation = Isolation.READ_COMMITTED) public void update(JobManager entity) { if (this.mapper.updateById(entity) != 1) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/LoadTaskService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/LoadTaskService.java index 76f3af7ca..12678e378 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/LoadTaskService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/LoadTaskService.java @@ -29,10 +29,12 @@ import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.GraphConnection; +import org.apache.hugegraph.entity.enums.JobStatus; import org.apache.hugegraph.entity.enums.LoadStatus; import org.apache.hugegraph.entity.load.EdgeMapping; import org.apache.hugegraph.entity.load.FileMapping; import org.apache.hugegraph.entity.load.FileSetting; +import org.apache.hugegraph.entity.load.JobManager; import org.apache.hugegraph.entity.load.ListFormat; import org.apache.hugegraph.entity.load.LoadParameter; import org.apache.hugegraph.entity.load.LoadTask; @@ -51,16 +53,23 @@ import org.apache.hugegraph.loader.source.file.FileSource; import org.apache.hugegraph.loader.util.MappingUtil; import org.apache.hugegraph.loader.util.Printer; +import org.apache.hugegraph.mapper.load.JobManagerMapper; import org.apache.hugegraph.mapper.load.LoadTaskMapper; import org.apache.hugegraph.service.schema.EdgeLabelService; import org.apache.hugegraph.service.schema.VertexLabelService; import org.apache.hugegraph.util.Ex; +import org.apache.hugegraph.util.HubbleUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import java.io.File; import java.io.IOException; @@ -82,6 +91,8 @@ public class LoadTaskService { @Autowired private LoadTaskMapper mapper; @Autowired + private JobManagerMapper jobManagerMapper; + @Autowired private VertexLabelService vlService; @Autowired private EdgeLabelService elService; @@ -89,6 +100,8 @@ public class LoadTaskService { private LoadTaskExecutor taskExecutor; @Autowired private HugeConfig config; + @Autowired + private PlatformTransactionManager transactionManager; private Map runningTaskContainer; @@ -153,15 +166,44 @@ public List taskListByJob(int jobId) { @Transactional(isolation = Isolation.READ_COMMITTED) public void save(LoadTask entity) { - if (this.mapper.insert(entity) != 1) { - throw new InternalException("entity.insert.failed", entity); + LoadOptions runtimeOptions = entity.getOptions(); + entity.setOptions(withoutCredentials(runtimeOptions)); + try { + if (this.mapper.insert(entity) != 1) { + throw new InternalException("entity.insert.failed"); + } + } finally { + entity.setOptions(runtimeOptions); } } @Transactional(isolation = Isolation.READ_COMMITTED) public void update(LoadTask entity) { - if (this.mapper.updateById(entity) != 1) { - throw new InternalException("entity.update.failed", entity); + LoadOptions runtimeOptions = entity.getOptions(); + entity.setOptions(withoutCredentials(runtimeOptions)); + try { + if (this.mapper.updateById(entity) != 1) { + throw new InternalException("entity.update.failed"); + } + } finally { + entity.setOptions(runtimeOptions); + } + } + + private static LoadOptions withoutCredentials(LoadOptions options) { + if (options == null) { + return null; + } + try { + LoadOptions persisted = (LoadOptions) options.clone(); + persisted.password = null; + persisted.token = null; + persisted.pdToken = null; + persisted.trustStoreToken = null; + return persisted; + } catch (CloneNotSupportedException e) { + throw new InternalException("Failed to prepare load task options", + e); } } @@ -183,11 +225,80 @@ public LoadTask start(GraphConnection connection, FileMapping fileMapping, HugeClient client) { LoadTask task = this.buildLoadTask(connection, fileMapping, client); this.save(task); + this.executeAfterCommit(task); + return task; + } + + protected void executeAfterCommit(LoadTask task) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + this.executeSafely(task); + return; + } + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + executeSafely(task); + } + }); + } + + private void executeSafely(LoadTask task) { + try { + this.execute(task); + } catch (RuntimeException e) { + this.markDispatchFailed(task, e); + } + } + + private void markDispatchFailed(LoadTask task, RuntimeException cause) { + log.error("Failed to dispatch load task {}, marking task and job " + + "as FAILED", task.getId(), cause); + task.setStatus(LoadStatus.FAILED); + this.runningTaskContainer.remove(task.getId()); + + try { + TransactionTemplate transaction = + new TransactionTemplate(this.transactionManager); + transaction.setPropagationBehavior( + TransactionDefinition.PROPAGATION_REQUIRES_NEW); + transaction.execute(status -> { + int updated = this.mapper.update( + null, Wrappers.update() + .eq("id", task.getId()) + .set("load_status", + LoadStatus.FAILED.getValue())); + if (updated != 1) { + throw new InternalException("entity.update.failed", task); + } + if (task.getJobId() == null) { + return null; + } + JobManager job = this.jobManagerMapper.selectById( + task.getJobId()); + if (job == null) { + return null; + } + job.setJobStatus(JobStatus.FAILED); + job.setUpdateTime(HubbleUtil.nowDate()); + if (this.jobManagerMapper.updateById(job) != 1) { + throw new InternalException("entity.update.failed", job); + } + return null; + }); + } catch (RuntimeException e) { + // The ingest transaction is already committed. Never turn a + // dispatch failure into a retryable request failure. + log.error("Failed to persist dispatch failure for load task {}", + task.getId(), e); + } + } + + private void execute(LoadTask task) { // Executed in other threads this.taskExecutor.execute(task, () -> this.update(task)); // Save current load task this.runningTaskContainer.put(task.getId(), task); - return task; } public LoadTask pause(int taskId) { @@ -209,7 +320,7 @@ public LoadTask pause(int taskId) { return task; } - public LoadTask resume(int taskId) { + public LoadTask resume(int taskId, String token) { LoadTask task = this.get(taskId); Ex.check(task.getStatus() == LoadStatus.PAUSED || task.getStatus() == LoadStatus.FAILED, @@ -218,6 +329,7 @@ public LoadTask resume(int taskId) { try { // Set work mode in incrental mode, load from last breakpoint task.getOptions().incrementalMode = true; + task.reconnect(token); task.setStatus(LoadStatus.RUNNING); this.update(task); this.taskExecutor.execute(task, () -> this.update(task)); @@ -252,7 +364,7 @@ public LoadTask stop(int taskId) { return task; } - public LoadTask retry(int taskId) { + public LoadTask retry(int taskId, String token) { LoadTask task = this.get(taskId); Ex.check(task.getStatus() == LoadStatus.FAILED || task.getStatus() == LoadStatus.STOPPED, @@ -261,6 +373,7 @@ public LoadTask retry(int taskId) { try { // Set work mode in normal mode, load from begin task.getOptions().incrementalMode = false; + task.reconnect(token); task.setStatus(LoadStatus.RUNNING); task.setLastDuration(0L); task.setCurrDuration(0L); @@ -393,11 +506,8 @@ private LoadOptions buildLoadOptions(GraphConnection connection, options.port = connection.getPort(); } options.username = connection.getUsername(); - if (StringUtils.isNotEmpty(connection.getPassword())) { - options.password = connection.getPassword(); - } else { - options.token = connection.getToken(); - } + options.password = null; + options.token = connection.getToken(); options.protocol = StringUtils.isNotEmpty(connection.getProtocol()) ? connection.getProtocol() : "http"; // Load parameters diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java new file mode 100644 index 000000000..ba81f484b --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java @@ -0,0 +1,414 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.op.OperationsModels.Node; +import org.apache.hugegraph.service.op.OperationsModels.MetricStatus; +import org.apache.hugegraph.service.op.OperationsModels.Snapshot; +import org.apache.hugegraph.service.op.OperationsModels.SourceStatus; + +@Service +public class DefaultOperationsDataService implements OperationsDataService { + + private final OperationsCollector collector; + private final long ttlMillis; + private final Clock clock; + private final Cache cache; + private final Map> inFlight; + + @Autowired + public DefaultOperationsDataService(OperationsCollector collector, + HugeConfig config) { + this(collector, config.get(HubbleOptions.OPERATIONS_CACHE_TTL), + config.get(HubbleOptions.OPERATIONS_CACHE_MAX_ENTRIES), + Clock.systemUTC()); + } + + DefaultOperationsDataService(OperationsCollector collector, int ttlSeconds, + Clock clock) { + this(collector, ttlSeconds, + HubbleOptions.OPERATIONS_CACHE_MAX_ENTRIES.defaultValue(), clock); + } + + DefaultOperationsDataService(OperationsCollector collector, int ttlSeconds, + int maxEntries, Clock clock) { + this.collector = collector; + this.ttlMillis = ttlSeconds * 1000L; + this.clock = clock; + this.cache = CacheBuilder.newBuilder() + .maximumSize(maxEntries) + .build(); + this.inFlight = new ConcurrentHashMap<>(); + } + + @Override + public Map overview(HugeClient client, + Set capabilities, + boolean refresh) { + Snapshot snapshot = this.snapshot(client, capabilities, refresh); + boolean topology = capabilities.contains( + OperationsCapabilityService.TOPOLOGY_READ); + return this.overview(snapshot, topology); + } + + @Override + public Map nodes(HugeClient client, + Set capabilities, + String type, String status, String query, + int page, int pageSize, String sort, + String order) { + Snapshot snapshot = this.snapshot(client, capabilities, false); + String normalizedType = normalize(type); + String normalizedStatus = normalize(status); + String normalizedQuery = normalize(query); + List filtered = snapshot.getNodes().stream() + .filter(node -> normalizedType == null || + normalizedType.equals(node.getType())) + .filter(node -> normalizedStatus == null || + normalizedStatus.equals(node.getStatus())) + .filter(node -> normalizedQuery == null || + node.getName().toUpperCase(Locale.ROOT) + .contains(normalizedQuery)) + .sorted(this.nodeComparator(sort, order)) + .collect(Collectors.toList()); + long requestedFrom = (long) (page - 1) * pageSize; + int from = (int) Math.min(requestedFrom, filtered.size()); + int to = Math.min(from + pageSize, filtered.size()); + Map result = new LinkedHashMap<>(); + result.put("items", new ArrayList<>(filtered.subList(from, to))); + result.put("total", filtered.size()); + result.put("page", page); + result.put("page_size", pageSize); + result.put("observed_at", snapshot.getObservedAt()); + result.put("stale", snapshot.isStale()); + return result; + } + + private Comparator nodeComparator(String sort, String order) { + Comparator strings = Comparator.nullsLast( + String.CASE_INSENSITIVE_ORDER); + Comparator numbers = Comparator.nullsLast(Long::compareTo); + Comparator comparator; + switch (sort) { + case "type": + comparator = Comparator.comparing(Node::getType, strings); + break; + case "status": + comparator = Comparator.comparing(Node::getStatus, strings); + break; + case "observed_at": + comparator = Comparator.comparing(Node::getObservedAt, + numbers); + break; + case "name": + default: + comparator = Comparator.comparing(Node::getName, strings); + break; + } + if ("desc".equals(order)) { + comparator = comparator.reversed(); + } + return comparator.thenComparing(Node::getId, strings); + } + + @Override + public Map node(HugeClient client, + Set capabilities, + String nodeId, boolean refresh) { + Snapshot snapshot = this.snapshot(client, capabilities, refresh); + Node found = snapshot.getNodes().stream() + .filter(node -> node.getId().equals(nodeId)) + .findFirst().orElseThrow(OperationsNodeNotFoundException::new); + Map result = new LinkedHashMap<>(); + result.put("node", found); + result.put("sources", snapshot.getSources()); + result.put("observed_at", snapshot.getObservedAt()); + result.put("stale", snapshot.isStale()); + return result; + } + + private Snapshot snapshot(HugeClient client, Set capabilities, + boolean refresh) { + boolean includeMetrics = capabilities.contains( + OperationsCapabilityService.METRICS_READ); + String key = this.cacheKey(client.getAuthContext(), includeMetrics); + long now = this.clock.millis(); + CacheEntry current = this.cache.getIfPresent(key); + if (!refresh && current != null && now - current.createdAt < + this.ttlMillis) { + return current.snapshot; + } + CompletableFuture future = new CompletableFuture<>(); + CompletableFuture existing = this.inFlight.putIfAbsent(key, + future); + if (existing != null) { + return await(existing); + } + try { + current = this.cache.getIfPresent(key); + now = this.clock.millis(); + if (!refresh && current != null && now - current.createdAt < + this.ttlMillis) { + future.complete(current.snapshot); + return current.snapshot; + } + Snapshot collected = this.collector.collect(client, includeMetrics); + if (current != null) { + collected = this.mergeFailedSources(collected, current.snapshot); + } + this.cache.put(key, new CacheEntry(collected, now)); + future.complete(collected); + return collected; + } catch (RuntimeException e) { + if (current != null) { + Snapshot stale = current.snapshot.stale("refresh_failed"); + this.cache.put(key, new CacheEntry(stale, now)); + future.complete(stale); + return stale; + } + future.completeExceptionally(e); + throw e; + } finally { + this.inFlight.remove(key, future); + } + } + + private static Snapshot await(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException e) { + if (e.getCause() instanceof RuntimeException) { + throw (RuntimeException) e.getCause(); + } + throw e; + } + } + + private Snapshot mergeFailedSources(Snapshot current, Snapshot previous) { + Map sources = new LinkedHashMap<>( + current.getSources()); + List nodes = new ArrayList<>(current.getNodes()); + Map facts = new LinkedHashMap<>(current.getFacts()); + boolean stale = false; + for (Map.Entry entry : sources.entrySet()) { + String sourceName = entry.getKey(); + SourceStatus source = entry.getValue(); + SourceStatus old = previous.getSources().get(sourceName); + if ("PARTIAL".equals(source.getAvailability())) { + String nodeType = nodeType(sourceName); + boolean groupStale = nodeType != null && + this.mergeFailedMetricGroups( + nodes, previous.getNodes(), nodeType); + if (groupStale) { + stale = true; + sources.put(sourceName, new SourceStatus( + source.getAvailability(), source.getStatus(), + source.getObservedAt(), source.getLastSuccessAt(), + false, true, source.getReason())); + } + continue; + } + if (!failed(source) || old == null || + old.getLastSuccessAt() == null) { + continue; + } + stale = true; + sources.put(sourceName, new SourceStatus( + source.getAvailability(), old.getStatus(), + source.getObservedAt(), old.getLastSuccessAt(), false, + true, source.getReason())); + String nodeType = nodeType(sourceName); + if (nodeType != null) { + nodes.removeIf(node -> nodeType.equals(node.getType())); + previous.getNodes().stream() + .filter(node -> nodeType.equals(node.getType())) + .map(node -> staleNode(node, source)) + .forEach(nodes::add); + } + if ("pd".equals(sourceName)) { + facts.clear(); + facts.putAll(previous.getFacts()); + } + } + if (!stale) { + return current; + } + String status = "DOWN".equals(current.getStatus()) ? "DOWN" : + "DEGRADED"; + return new Snapshot(status, current.getObservedAt(), true, + "partial_refresh_failed", sources, nodes, facts); + } + + private boolean mergeFailedMetricGroups(List current, + List previous, + String nodeType) { + boolean stale = false; + for (int i = 0; i < current.size(); i++) { + Node node = current.get(i); + if (!nodeType.equals(node.getType())) { + continue; + } + Node old = previous.stream() + .filter(candidate -> candidate.getId().equals(node.getId())) + .findFirst().orElse(null); + if (old == null) { + continue; + } + Map metrics = new LinkedHashMap<>(node.getMetrics()); + Map statuses = new LinkedHashMap<>( + node.getMetricStatuses()); + boolean nodeStale = false; + for (Map.Entry statusEntry : + node.getMetricStatuses().entrySet()) { + String group = statusEntry.getKey(); + MetricStatus status = statusEntry.getValue(); + MetricStatus oldStatus = old.getMetricStatuses().get(group); + if (!metricFailed(status) || oldStatus == null || + oldStatus.getLastSuccessAt() == null || + !old.getMetrics().containsKey(group)) { + continue; + } + metrics.put(group, old.getMetrics().get(group)); + statuses.put(group, new MetricStatus( + status.getAvailability(), status.getObservedAt(), + oldStatus.getLastSuccessAt(), false, true, + status.getReason())); + nodeStale = true; + } + if (nodeStale) { + current.set(i, copyNode(node, metrics, statuses)); + stale = true; + } + } + return stale; + } + + private static boolean metricFailed(MetricStatus status) { + return "UNAVAILABLE".equals(status.getAvailability()) || + "MALFORMED".equals(status.getAvailability()); + } + + private static Node staleNode(Node node, SourceStatus source) { + Map statuses = new LinkedHashMap<>(); + node.getMetricStatuses().forEach((group, status) -> { + MetricStatus staleStatus = status.getLastSuccessAt() == null ? + status : new MetricStatus( + source.getAvailability(), source.getObservedAt(), + status.getLastSuccessAt(), false, true, source.getReason()); + statuses.put(group, staleStatus); + }); + return copyNode(node, node.getMetrics(), statuses); + } + + private static Node copyNode(Node node, Map metrics, + Map statuses) { + return new Node(node.getId(), node.getType(), node.getName(), + node.getRole(), node.getVersion(), node.getStatus(), + node.getObservedAt(), metrics, statuses); + } + + private static boolean failed(SourceStatus source) { + return "UNAVAILABLE".equals(source.getAvailability()) || + "MALFORMED".equals(source.getAvailability()); + } + + private static String nodeType(String source) { + if ("server".equals(source)) { + return "SERVER"; + } + if ("pd".equals(source)) { + return "PD"; + } + if ("stores".equals(source)) { + return "STORE"; + } + return null; + } + + private Map overview(Snapshot snapshot, boolean topology) { + Map result = new LinkedHashMap<>(); + result.put("status", snapshot.getStatus()); + result.put("observed_at", snapshot.getObservedAt()); + result.put("stale", snapshot.isStale()); + result.put("reason", snapshot.getReason()); + result.put("sources", snapshot.getSources()); + if (topology) { + result.put("nodes", snapshot.getNodes()); + result.put("facts", snapshot.getFacts()); + } + return result; + } + + private String cacheKey(String credential, boolean metrics) { + String secret = credential == null ? "" : credential; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(secret.getBytes(StandardCharsets.UTF_8)); + StringBuilder key = new StringBuilder(metrics ? "m:" : "h:"); + for (int i = 0; i < 12; i++) { + key.append(String.format("%02x", bytes[i])); + } + return key.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + private static String normalize(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return value.trim().toUpperCase(Locale.ROOT); + } + + private static final class CacheEntry { + + private final Snapshot snapshot; + private final long createdAt; + + private CacheEntry(Snapshot snapshot, long createdAt) { + this.snapshot = snapshot; + this.createdAt = createdAt; + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java new file mode 100644 index 000000000..74c8c1bf4 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java @@ -0,0 +1,877 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import javax.annotation.PreDestroy; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.op.OperationsModels.Node; +import org.apache.hugegraph.service.op.OperationsModels.MetricStatus; +import org.apache.hugegraph.service.op.OperationsModels.Snapshot; +import org.apache.hugegraph.service.op.OperationsModels.SourceStatus; +import org.apache.hugegraph.service.op.OperationsModels.Topology; + +@Service +public class LiveOperationsCollector implements OperationsCollector { + + private static final String EMPTY_CLUSTER = + "{\"status\":0,\"data\":{\"pdList\":[],\"stores\":[]}}"; + private static final String EMPTY_STORES = + "{\"status\":0,\"data\":{\"stores\":[]}}"; + private static final List STORE_METRIC_GROUPS = + Arrays.asList("system", "drive", "raft"); + private static final AtomicInteger THREAD_SEQUENCE = new AtomicInteger(); + + private final boolean pdEnabled; + private final String pdBase; + private final String pdUsername; + private final String pdPassword; + private final String storeUsername; + private final String storePassword; + private final String serverIdentity; + private final OperationsHttpClient http; + private final OperationsPayloadParser parser; + private final Clock clock; + private final ExecutorService storeExecutor; + private final int storeDeadlineMillis; + private final Set storeAllowedTargets; + + @Autowired + public LiveOperationsCollector(HugeConfig config, ObjectMapper mapper) { + this(config.get(HubbleOptions.PD_ENABLED), + pdBase(config.get(HubbleOptions.SERVER_PROTOCOL), + config.get(HubbleOptions.PD_SERVER)), + config.get(HubbleOptions.OPERATIONS_PD_USERNAME), + config.get(HubbleOptions.OPERATIONS_PD_PASSWORD), + config.get(HubbleOptions.OPERATIONS_STORE_USERNAME), + config.get(HubbleOptions.OPERATIONS_STORE_PASSWORD), + config.get(HubbleOptions.PD_ENABLED) ? + "pd-discovered-server" : config.get(HubbleOptions.SERVER_URL), + new OperationsHttpClient( + config.get(HubbleOptions.OPERATIONS_CONNECT_TIMEOUT), + config.get(HubbleOptions.OPERATIONS_READ_TIMEOUT), + config.get(HubbleOptions.OPERATIONS_MAX_RESPONSE_BYTES)), + new OperationsPayloadParser(mapper), Clock.systemUTC(), + config.get(HubbleOptions.OPERATIONS_STORE_THREADS), + config.get(HubbleOptions.OPERATIONS_STORE_DEADLINE), + new java.util.LinkedHashSet<>(config.get( + HubbleOptions.OPERATIONS_STORE_ALLOWED_TARGETS))); + } + + LiveOperationsCollector(boolean pdEnabled, String pdBase, + String pdUsername, String pdPassword, + String storeUsername, String storePassword, + String serverIdentity, OperationsHttpClient http, + OperationsPayloadParser parser, Clock clock) { + this(pdEnabled, pdBase, pdUsername, pdPassword, storeUsername, + storePassword, serverIdentity, http, parser, clock, 16, 5000, + defaultStoreAllowedTargets()); + } + + LiveOperationsCollector(boolean pdEnabled, String pdBase, + String pdUsername, String pdPassword, + String storeUsername, String storePassword, + String serverIdentity, OperationsHttpClient http, + OperationsPayloadParser parser, Clock clock, + int storeThreads, int storeDeadlineMillis) { + this(pdEnabled, pdBase, pdUsername, pdPassword, storeUsername, + storePassword, serverIdentity, http, parser, clock, storeThreads, + storeDeadlineMillis, defaultStoreAllowedTargets()); + } + + LiveOperationsCollector(boolean pdEnabled, String pdBase, + String pdUsername, String pdPassword, + String storeUsername, String storePassword, + String serverIdentity, OperationsHttpClient http, + OperationsPayloadParser parser, Clock clock, + int storeThreads, int storeDeadlineMillis, + Set storeAllowedTargets) { + if (storeThreads <= 0 || storeDeadlineMillis <= 0) { + throw new IllegalArgumentException( + "Store metric collection limits must be positive"); + } + this.pdEnabled = pdEnabled; + this.pdBase = pdBase; + this.pdUsername = pdUsername; + this.pdPassword = pdPassword; + this.storeUsername = storeUsername; + this.storePassword = storePassword; + this.serverIdentity = serverIdentity; + this.http = http; + this.parser = parser; + this.clock = clock; + this.storeDeadlineMillis = storeDeadlineMillis; + if (storeAllowedTargets == null || storeAllowedTargets.isEmpty()) { + throw new IllegalArgumentException( + "Store metric allowed targets must not be empty"); + } + this.storeAllowedTargets = storeAllowedTargets.stream() + .map(URI::create) + .map(OperationsHttpClient::origin) + .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + this.storeExecutor = Executors.newFixedThreadPool( + storeThreads, runnable -> { + Thread thread = new Thread( + runnable, "hubble-store-metrics-" + + THREAD_SEQUENCE.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); + } + + @PreDestroy + public void close() { + this.storeExecutor.shutdownNow(); + try { + this.storeExecutor.awaitTermination(1L, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public Snapshot collect(HugeClient client, boolean includeMetrics) { + long now = this.clock.millis(); + Map sources = new LinkedHashMap<>(); + List nodes = new ArrayList<>(); + Map facts = new LinkedHashMap<>(); + this.collectServer(client, includeMetrics, now, sources, nodes); + if (this.pdEnabled) { + this.collectPd(includeMetrics, now, sources, nodes, facts); + } else { + sources.put("pd", unsupported()); + sources.put("stores", unsupported()); + } + return new Snapshot(this.overallStatus(sources), now, false, null, + sources, nodes, facts); + } + + private void collectServer(HugeClient client, boolean includeMetrics, + long now, Map sources, + List nodes) { + try { + String version = client.versionManager().getCoreVersion(); + Map metrics = Collections.emptyMap(); + Map metricStatuses = Collections.emptyMap(); + String availability = "AVAILABLE"; + String reason = null; + if (includeMetrics) { + metrics = new LinkedHashMap<>(); + metricStatuses = new LinkedHashMap<>(); + try { + Map system = this.safeSystemMetrics( + client.metrics().system()); + metrics.put("system", system); + metricStatuses.put("system", availableMetric(now)); + } catch (RuntimeException e) { + availability = "PARTIAL"; + reason = metricReason(e); + metricStatuses.put("system", metricStatus(e, now, false)); + } + try { + metrics.put("backend", this.safeBackendMetrics( + client.metrics().backend())); + metricStatuses.put("backend", availableMetric(now)); + } catch (RuntimeException e) { + availability = "PARTIAL"; + reason = mergeReason(reason, metricReason(e)); + metricStatuses.put("backend", metricStatus(e, now, false)); + } + } + String id = stableId("server", this.serverIdentity); + nodes.add(new Node(id, "SERVER", "HugeGraph Server", null, + version, "UP", now, metrics, metricStatuses)); + sources.put("server", new SourceStatus(availability, "UP", now, + now, true, false, reason)); + } catch (RuntimeException e) { + sources.put("server", unavailable("upstream_unavailable", now)); + } + } + + private void collectPd(boolean includeMetrics, long now, + Map sources, + List nodes, Map facts) { + String cluster = null; + String stores = null; + SourceStatus pdStatus; + SourceStatus storesStatus; + try { + cluster = this.get("/v1/cluster"); + pdStatus = available("UP", now); + } catch (RuntimeException e) { + pdStatus = unavailable(reason(e), now); + } + try { + stores = this.get("/v1/stores"); + storesStatus = available("UP", now); + } catch (RuntimeException e) { + storesStatus = unavailable(reason(e), now); + } + boolean clusterParsed = false; + boolean storesParsed = false; + if (cluster != null) { + try { + Topology topology = this.parser.parseTopology(cluster, + EMPTY_STORES, + now); + this.mergeNodes(nodes, topology.getNodes()); + facts.putAll(topology.getFacts()); + pdStatus = available(topology.getStatus(), now); + clusterParsed = true; + } catch (MalformedUpstreamException e) { + pdStatus = malformed(now); + } + } + if (stores != null) { + try { + Topology topology = this.parser.parseTopology(EMPTY_CLUSTER, + stores, now); + this.mergeNodes(nodes, topology.getNodes()); + storesParsed = true; + } catch (MalformedUpstreamException e) { + storesStatus = malformed(now); + } + } + if (includeMetrics && clusterParsed) { + pdStatus = this.collectPdMetrics(now, pdStatus, nodes); + } + if (includeMetrics && storesParsed) { + storesStatus = this.collectStoreMetrics(stores, now, storesStatus, + nodes); + } else if (includeMetrics) { + this.applyStoreMetricStatus(nodes, this.metricStatus(storesStatus, + now)); + } + sources.put("pd", pdStatus); + sources.put("stores", storesStatus); + } + + private void mergeNodes(List nodes, List additions) { + for (Node addition : additions) { + int existingIndex = -1; + for (int i = 0; i < nodes.size(); i++) { + if (nodes.get(i).getId().equals(addition.getId())) { + existingIndex = i; + break; + } + } + if (existingIndex < 0) { + nodes.add(addition); + continue; + } + Node existing = nodes.get(existingIndex); + Map metrics = new LinkedHashMap<>( + existing.getMetrics()); + metrics.putAll(addition.getMetrics()); + Map statuses = new LinkedHashMap<>( + existing.getMetricStatuses()); + addition.getMetricStatuses().forEach((group, metricStatus) -> { + if (!addition.getMetrics().containsKey(group) && + existing.getMetrics().containsKey(group)) { + return; + } + statuses.put(group, metricStatus); + }); + String version = addition.getVersion() == null ? + existing.getVersion() : addition.getVersion(); + String nodeStatus = "UNKNOWN".equals(addition.getStatus()) ? + existing.getStatus() : addition.getStatus(); + nodes.set(existingIndex, new Node( + addition.getId(), addition.getType(), addition.getName(), + addition.getRole(), version, nodeStatus, + addition.getObservedAt(), metrics, statuses)); + } + } + + private SourceStatus collectPdMetrics(long now, SourceStatus status, + List nodes) { + int index = this.pdNodeIndex(nodes); + if (index < 0) { + return new SourceStatus("PARTIAL", status.getStatus(), now, + status.getLastSuccessAt(), false, false, + "pd_metrics_unmapped"); + } + Map system = null; + MetricStatus selectedStatus; + String failureReason = null; + try { + system = this.parser.parsePdPrometheusMetrics( + this.http.get(URI.create(this.pdBase + + "/actuator/prometheus"), + this.pdUsername, this.pdPassword, + Collections.emptySet(), "text/plain")); + selectedStatus = availableMetric(now); + } catch (RuntimeException e) { + selectedStatus = metricStatus(e, now, true); + failureReason = selectedStatus.getReason(); + } + for (int i = 0; i < nodes.size(); i++) { + Node node = nodes.get(i); + if (!"PD".equals(node.getType())) { + continue; + } + Map metrics = new LinkedHashMap<>(node.getMetrics()); + Map statuses = new LinkedHashMap<>( + node.getMetricStatuses()); + if (i == index) { + if (system != null) { + metrics.put("system", system); + } + statuses.put("system", selectedStatus); + } else { + statuses.put("system", unsupportedMetric( + now, "metrics_not_collected")); + } + nodes.set(i, new Node(node.getId(), node.getType(), node.getName(), + node.getRole(), node.getVersion(), + node.getStatus(), node.getObservedAt(), metrics, + statuses)); + } + if (failureReason != null) { + return new SourceStatus("PARTIAL", status.getStatus(), now, + status.getLastSuccessAt(), false, false, + failureReason); + } + return status; + } + + private int pdNodeIndex(List nodes) { + String configuredId = stableId("pd", this.pdBase); + int first = -1; + for (int i = 0; i < nodes.size(); i++) { + Node node = nodes.get(i); + if (!"PD".equals(node.getType())) { + continue; + } + if (configuredId.equals(node.getId())) { + return i; + } + if ("LEADER".equals(node.getRole())) { + first = i; + } else if (first < 0) { + first = i; + } + } + return first; + } + + private SourceStatus collectStoreMetrics(String stores, long now, + SourceStatus status, + List nodes) { + try { + Map restAddresses = + this.parser.parseStoreRestAddresses(stores); + Map targets = this.parser.parseStoreMetricTargets( + this.get("/v1/prom/targets-all")); + Set allowedTargets = new java.util.LinkedHashSet<>(); + boolean partial = false; + int successfulGroups = 0; + String failureReason = null; + List jobs = new ArrayList<>(); + for (int i = 0; i < nodes.size(); i++) { + Node node = nodes.get(i); + if (!"STORE".equals(node.getType())) { + continue; + } + StoreTarget target = storeTarget(node.getId(), restAddresses, + targets, + this.storeAllowedTargets); + if (target.getUri() == null) { + partial = true; + failureReason = mergeReason(failureReason, + target.getReason()); + Map statuses = new LinkedHashMap<>( + node.getMetricStatuses()); + for (String group : STORE_METRIC_GROUPS) { + statuses.put(group, unavailableMetric( + now, target.getReason())); + } + nodes.set(i, copyNode(node, node.getMetrics(), statuses)); + continue; + } + allowedTargets.add(OperationsHttpClient.origin( + target.getUri())); + jobs.add(new StoreMetricJob(i, node, target.getUri())); + } + if (allowedTargets.isEmpty()) { + allowedTargets.add("no_trusted_store_target"); + } + List> futures = this.storeExecutor.invokeAll( + jobs.stream().map(job -> (java.util.concurrent.Callable< + StoreMetricResult>) () -> this.collectStoreMetric( + job, allowedTargets, now)) + .collect(Collectors.toList()), + this.storeDeadlineMillis, TimeUnit.MILLISECONDS); + for (int i = 0; i < jobs.size(); i++) { + StoreMetricJob job = jobs.get(i); + StoreMetricResult result; + try { + result = futures.get(i).get(); + } catch (CancellationException e) { + result = StoreMetricResult.failure(job, + "upstream_deadline", now); + } catch (ExecutionException e) { + result = StoreMetricResult.failure( + job, "upstream_unavailable", now); + } + nodes.set(job.getNodeIndex(), result.getNode()); + successfulGroups += result.getSuccessfulGroups(); + if (result.getFailureReason() != null) { + partial = true; + failureReason = mergeReason(failureReason, + result.getFailureReason()); + } + } + if (partial) { + return metricFailureStatus(status, now, successfulGroups, + failureReason); + } + return status; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + this.applyStoreMetricStatus(nodes, + unavailableMetric(now, "upstream_interrupted")); + return metricFailureStatus(status, now, 0, + "upstream_interrupted"); + } catch (RuntimeException e) { + this.applyStoreFailureStatuses(nodes, now, e); + return metricFailureStatus(status, now, 0, metricReason(e)); + } + } + + private StoreMetricResult collectStoreMetric(StoreMetricJob job, + Set allowedTargets, + long now) { + Map metrics = new LinkedHashMap<>( + job.getNode().getMetrics()); + Map statuses = new LinkedHashMap<>( + job.getNode().getMetricStatuses()); + int successfulGroups = 0; + String failureReason = null; + for (String group : STORE_METRIC_GROUPS) { + if (Thread.currentThread().isInterrupted()) { + failureReason = mergeReason(failureReason, + "upstream_deadline"); + statuses.put(group, unavailableMetric(now, + "upstream_deadline")); + continue; + } + try { + URI endpoint = job.getTarget().resolve("/metrics/" + group); + String payload = this.http.get(endpoint, this.storeUsername, + this.storePassword, + allowedTargets); + metrics.put(group, this.parser.parseStoreMetrics(group, payload)); + statuses.put(group, availableMetric(now)); + successfulGroups++; + } catch (RuntimeException e) { + failureReason = mergeReason(failureReason, metricReason(e)); + statuses.put(group, metricStatus(e, now, true)); + } + } + return new StoreMetricResult(copyNode(job.getNode(), metrics, statuses), + successfulGroups, failureReason); + } + + private static StoreTarget storeTarget(String nodeId, + Map restAddresses, + Map targets, + Set allowedTargets) { + String restAddress = restAddresses.get(nodeId); + if (restAddress != null) { + URI exact = targets.get(restAddress); + if (exact != null && !allowedTargets.contains( + OperationsHttpClient.origin(exact))) { + return new StoreTarget(null, "metrics_target_untrusted"); + } + return exact == null ? + new StoreTarget(null, "metrics_target_missing") : + new StoreTarget(exact, null); + } + return new StoreTarget(null, "metrics_target_missing"); + } + + private static Set defaultStoreAllowedTargets() { + return new java.util.LinkedHashSet<>(Arrays.asList( + "http://127.0.0.1:8520", "http://[::1]:8520")); + } + + private void applyStoreFailureStatuses(List nodes, long now, + RuntimeException error) { + this.applyStoreMetricStatus(nodes, metricStatus(error, now, true)); + } + + private void applyStoreMetricStatus(List nodes, + MetricStatus failure) { + for (int i = 0; i < nodes.size(); i++) { + Node node = nodes.get(i); + if (!"STORE".equals(node.getType())) { + continue; + } + Map statuses = new LinkedHashMap<>( + node.getMetricStatuses()); + for (String group : STORE_METRIC_GROUPS) { + statuses.put(group, failure); + } + nodes.set(i, copyNode(node, node.getMetrics(), statuses)); + } + } + + private MetricStatus metricStatus(SourceStatus source, long now) { + if ("MALFORMED".equals(source.getAvailability())) { + return new MetricStatus("MALFORMED", now, null, false, false, + source.getReason()); + } + if ("UNSUPPORTED".equals(source.getAvailability())) { + return unsupportedMetric(now, source.getReason()); + } + return unavailableMetric(now, source.getReason()); + } + + private static SourceStatus metricFailureStatus(SourceStatus status, + long now, + int successfulGroups, + String failureReason) { + String availability = successfulGroups == 0 && + "unsupported_version".equals(failureReason) ? + "UNSUPPORTED" : "PARTIAL"; + return new SourceStatus(availability, status.getStatus(), now, + status.getLastSuccessAt(), false, false, + failureReason == null ? + "store_metrics_partial" : failureReason); + } + + private static String mergeReason(String current, String addition) { + if (current == null || current.equals(addition)) { + return addition; + } + return "store_metrics_partial"; + } + + private static String metricReason(RuntimeException error) { + if (error instanceof UpstreamRequestException && + "upstream_http_status_404".equals(error.getMessage())) { + return "unsupported_version"; + } + return reason(error); + } + + private static MetricStatus metricStatus(RuntimeException error, long now, + boolean versionedEndpoint) { + String reason = metricReason(error); + if (versionedEndpoint && "unsupported_version".equals(reason)) { + return unsupportedMetric(now, reason); + } + if (error instanceof MalformedUpstreamException) { + return new MetricStatus("MALFORMED", now, null, false, false, + reason); + } + return unavailableMetric(now, reason); + } + + private static MetricStatus availableMetric(long now) { + return new MetricStatus("AVAILABLE", now, now, true, false, null); + } + + private static MetricStatus unavailableMetric(long now, String reason) { + return new MetricStatus("UNAVAILABLE", now, null, false, false, reason); + } + + private static MetricStatus unsupportedMetric(long now, String reason) { + return new MetricStatus("UNSUPPORTED", now, null, false, false, reason); + } + + private static Node copyNode(Node node, Map metrics, + Map statuses) { + return new Node(node.getId(), node.getType(), node.getName(), + node.getRole(), node.getVersion(), node.getStatus(), + node.getObservedAt(), metrics, statuses); + } + + private String get(String path) { + URI target = URI.create(this.pdBase + path); + return this.http.get(target, this.pdUsername, this.pdPassword); + } + + private Map safeSystemMetrics( + Map> upstream) { + Map result = new LinkedHashMap<>(); + this.copyGroup(upstream, result, "basic", + "mem_total", "mem_used", "processors", "uptime", + "systemload_average"); + this.copyGroup(upstream, result, "heap", "used", "max", "committed"); + this.copyGroup(upstream, result, "nonheap", "used", "max", + "committed"); + this.copyGroup(upstream, result, "thread", "count", "daemon", "peak"); + this.copyGroup(upstream, result, "garbage_collector", + "g1_young_generation_count", + "g1_young_generation_time", + "g1_old_generation_count", + "g1_old_generation_time", "time_unit"); + return result; + } + + private Map safeBackendMetrics( + Map> upstream) { + long graphs = 0L; + long nodes = 0L; + Map backends = new LinkedHashMap<>(); + if (upstream != null) { + for (Map graph : upstream.values()) { + if (graph == null) { + continue; + } + graphs++; + Object nodeCount = graph.get("nodes"); + if (nodeCount instanceof Number) { + nodes += ((Number) nodeCount).longValue(); + } + Object backend = graph.get("backend"); + if (backend instanceof String && + !((String) backend).trim().isEmpty()) { + String name = ((String) backend).trim(); + backends.put(name, backends.getOrDefault(name, 0L) + 1L); + } + } + } + Map result = new LinkedHashMap<>(); + result.put("graphs", graphs); + result.put("nodes", nodes); + result.put("backend_counts", backends); + return result; + } + + private void copyGroup(Map> upstream, + Map target, String group, + String... allowedFields) { + Map values = upstream == null ? null : + upstream.get(group); + if (values == null) { + return; + } + Map safe = new LinkedHashMap<>(); + Arrays.stream(allowedFields).forEach(field -> { + Object value = values.get(field); + if (value instanceof Number || value instanceof Boolean || + value instanceof String) { + safe.put(field, value); + } + }); + if (!safe.isEmpty()) { + target.put(group, safe); + } + } + + private String overallStatus(Map sources) { + List supported = sources.values().stream() + .filter(source -> !"UNSUPPORTED".equals( + source.getAvailability())) + .collect(java.util.stream.Collectors.toList()); + if (!supported.isEmpty() && supported.stream().allMatch( + LiveOperationsCollector::failedSource)) { + return "DOWN"; + } + boolean degraded = supported.stream().anyMatch(source -> { + return !"AVAILABLE".equals(source.getAvailability()) || + "DEGRADED".equals(source.getStatus()) || + "DOWN".equals(source.getStatus()) || + "UNKNOWN".equals(source.getStatus()); + }); + return degraded ? "DEGRADED" : "UP"; + } + + private static boolean failedSource(SourceStatus source) { + return "UNAVAILABLE".equals(source.getAvailability()) || + "MALFORMED".equals(source.getAvailability()) || + "DOWN".equals(source.getStatus()); + } + + private static SourceStatus available(String status, long now) { + return new SourceStatus("AVAILABLE", status, now, now, true, false, + null); + } + + private static SourceStatus unavailable(String reason, long now) { + return new SourceStatus("UNAVAILABLE", "UNKNOWN", now, null, false, + false, reason); + } + + private static SourceStatus malformed(long now) { + return new SourceStatus("MALFORMED", "UNKNOWN", now, null, false, + false, "malformed_response"); + } + + private static SourceStatus unsupported() { + return new SourceStatus("UNSUPPORTED", "UNKNOWN", null, null, false, + false, "deployment_mode_unsupported"); + } + + private static String reason(RuntimeException error) { + if (error instanceof UpstreamResponseTooLargeException) { + return "response_too_large"; + } + if (error instanceof MalformedUpstreamException) { + return "malformed_response"; + } + String message = error.getMessage(); + if ("upstream_timeout".equals(message)) { + return "upstream_timeout"; + } + if (message != null && message.startsWith("upstream_http_status_")) { + return "upstream_rejected"; + } + return "upstream_unavailable"; + } + + private static final class StoreTarget { + + private final URI uri; + private final String reason; + + private StoreTarget(URI uri, String reason) { + this.uri = uri; + this.reason = reason; + } + + private URI getUri() { + return this.uri; + } + + private String getReason() { + return this.reason; + } + } + + private static final class StoreMetricJob { + + private final int nodeIndex; + private final Node node; + private final URI target; + + private StoreMetricJob(int nodeIndex, Node node, URI target) { + this.nodeIndex = nodeIndex; + this.node = node; + this.target = target; + } + + private int getNodeIndex() { + return this.nodeIndex; + } + + private Node getNode() { + return this.node; + } + + private URI getTarget() { + return this.target; + } + } + + private static final class StoreMetricResult { + + private final Node node; + private final int successfulGroups; + private final String failureReason; + + private StoreMetricResult(Node node, int successfulGroups, + String failureReason) { + this.node = node; + this.successfulGroups = successfulGroups; + this.failureReason = failureReason; + } + + private static StoreMetricResult failure(StoreMetricJob job, + String reason, long now) { + Map statuses = new LinkedHashMap<>( + job.getNode().getMetricStatuses()); + for (String group : STORE_METRIC_GROUPS) { + statuses.put(group, unavailableMetric(now, reason)); + } + return new StoreMetricResult(copyNode(job.getNode(), + job.getNode().getMetrics(), + statuses), 0, reason); + } + + private Node getNode() { + return this.node; + } + + private int getSuccessfulGroups() { + return this.successfulGroups; + } + + private String getFailureReason() { + return this.failureReason; + } + } + + private static String pdBase(String protocol, String server) { + if (server == null || server.trim().isEmpty()) { + throw new IllegalArgumentException("PD operations target is empty"); + } + String value = server.trim(); + String base = value.contains("://") ? value : protocol + "://" + value; + URI target = URI.create(base); + OperationsHttpClient.validateTarget(target, Collections.emptySet()); + String normalized = target.toString(); + return normalized.endsWith("/") ? + normalized.substring(0, normalized.length() - 1) : normalized; + } + + private static String stableId(String type, String identity) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest( + (type + ':' + identity).getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(type).append('-'); + for (int i = 0; i < 6; i++) { + result.append(String.format("%02x", digest[i])); + } + return result.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/MalformedUpstreamException.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/MalformedUpstreamException.java new file mode 100644 index 000000000..6a15e6664 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/MalformedUpstreamException.java @@ -0,0 +1,32 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +public class MalformedUpstreamException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public MalformedUpstreamException(String source) { + super("Malformed upstream response: " + source); + } + + public MalformedUpstreamException(String source, Throwable cause) { + super("Malformed upstream response: " + source, cause); + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsCapabilityService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsCapabilityService.java new file mode 100644 index 000000000..01da50d80 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsCapabilityService.java @@ -0,0 +1,52 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.apache.hugegraph.exception.ForbiddenException; + +public final class OperationsCapabilityService { + + public static final String HEALTH_READ = "operations_health_read"; + public static final String TOPOLOGY_READ = "operations_topology_read"; + public static final String METRICS_READ = "operations_metrics_read"; + + private OperationsCapabilityService() { + } + + public static Set forLevel(String level) { + Set capabilities = new LinkedHashSet<>(); + if ("ADMIN".equals(level)) { + capabilities.add(HEALTH_READ); + capabilities.add(TOPOLOGY_READ); + capabilities.add(METRICS_READ); + } + return Collections.unmodifiableSet(capabilities); + } + + public static void requireHealth(Set capabilities) { + if (!capabilities.contains(HEALTH_READ)) { + throw new ForbiddenException( + "Permission denied: operations health read"); + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsCollector.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsCollector.java new file mode 100644 index 000000000..7efd41226 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsCollector.java @@ -0,0 +1,27 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.service.op.OperationsModels.Snapshot; + +public interface OperationsCollector { + + Snapshot collect(HugeClient client, boolean includeMetrics); +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsDataService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsDataService.java new file mode 100644 index 000000000..de4c8a971 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsDataService.java @@ -0,0 +1,38 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import java.util.Map; +import java.util.Set; + +import org.apache.hugegraph.driver.HugeClient; + +public interface OperationsDataService { + + Map overview(HugeClient client, Set capabilities, + boolean refresh); + + Map nodes(HugeClient client, Set capabilities, + String type, String status, String query, + int page, int pageSize, String sort, + String order); + + Map node(HugeClient client, Set capabilities, + String nodeId, boolean refresh); +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsHttpClient.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsHttpClient.java new file mode 100644 index 000000000..4b1205ffd --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsHttpClient.java @@ -0,0 +1,243 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.Proxy; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import okhttp3.Dns; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +public class OperationsHttpClient { + + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + private final int maxResponseBytes; + private final OkHttpClient client; + + public OperationsHttpClient(int connectTimeoutMillis, + int readTimeoutMillis, + int maxResponseBytes) { + if (connectTimeoutMillis <= 0 || readTimeoutMillis <= 0 || + maxResponseBytes <= 0) { + throw new IllegalArgumentException( + "Operations HTTP limits must be positive"); + } + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + this.maxResponseBytes = maxResponseBytes; + this.client = new OkHttpClient.Builder() + .connectTimeout(this.connectTimeoutMillis, + TimeUnit.MILLISECONDS) + .readTimeout(this.readTimeoutMillis, TimeUnit.MILLISECONDS) + .followRedirects(false) + .followSslRedirects(false) + .proxy(Proxy.NO_PROXY) + .build(); + } + + public String get(URI target, String username, String password) { + return this.get(target, username, password, Set.of()); + } + + public String get(URI target, String username, String password, + Set allowedOrigins) { + return this.get(target, username, password, allowedOrigins, + "application/json"); + } + + public String get(URI target, String username, String password, + Set allowedOrigins, String accept) { + validateTarget(target, allowedOrigins); + if (!"application/json".equals(accept) && + !"text/plain".equals(accept)) { + throw new IllegalArgumentException("Invalid operations media type"); + } + try { + InetAddress[] addresses = InetAddress.getAllByName(target.getHost()); + validateResolvedAddresses(target.getHost(), addresses); + OkHttpClient requestClient = this.client.newBuilder() + .dns(pinnedDns(target.getHost(), addresses)) + .build(); + Request.Builder request = new Request.Builder() + .url(target.toString()) + .get() + .header("Accept", accept); + if (username != null && !username.trim().isEmpty()) { + String credential = username + ':' + + (password == null ? "" : password); + String encoded = Base64.getEncoder().encodeToString( + credential.getBytes(StandardCharsets.UTF_8)); + request.header("Authorization", "Basic " + encoded); + } + try (Response response = requestClient.newCall(request.build()) + .execute()) { + int status = response.code(); + if (status < 200 || status >= 300) { + throw new UpstreamRequestException( + "upstream_http_status_" + status); + } + ResponseBody body = response.body(); + if (body == null) { + throw new UpstreamRequestException("upstream_empty_body"); + } + if (body.contentLength() > this.maxResponseBytes) { + throw new UpstreamResponseTooLargeException(); + } + try (InputStream input = body.byteStream()) { + return this.readLimited(input); + } + } + } catch (UpstreamRequestException e) { + throw e; + } catch (SocketTimeoutException e) { + throw new UpstreamRequestException("upstream_timeout", e); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("Unresolvable operations target", + e); + } catch (IOException e) { + throw new UpstreamRequestException("upstream_unavailable", e); + } + } + + public static void validateTarget(URI target, Set allowedOrigins) { + if (target == null || target.getScheme() == null || + target.getHost() == null || target.getUserInfo() != null || + target.getFragment() != null) { + throw new IllegalArgumentException("Invalid operations target"); + } + String scheme = target.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw new IllegalArgumentException("Invalid operations protocol"); + } + int port = target.getPort(); + if (port < 0) { + port = "https".equals(scheme) ? 443 : 80; + } + String origin = scheme + "://" + normalizedHost(target) + ':' + port; + if (allowedOrigins != null && !allowedOrigins.isEmpty() && + !allowedOrigins.contains(origin)) { + throw new IllegalArgumentException("Untrusted operations target"); + } + } + + public static String authority(URI target) { + validateTarget(target, Set.of()); + String scheme = target.getScheme().toLowerCase(Locale.ROOT); + int port = target.getPort(); + if (port < 0) { + port = "https".equals(scheme) ? 443 : 80; + } + return normalizedHost(target) + ':' + port; + } + + public static String origin(URI target) { + validateTarget(target, Set.of()); + return target.getScheme().toLowerCase(Locale.ROOT) + "://" + + authority(target); + } + + static URI resolveTarget(URI target) { + validateTarget(target, Set.of()); + try { + InetAddress[] addresses = InetAddress.getAllByName(target.getHost()); + return resolveTarget(target, addresses); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("Unresolvable operations target", + e); + } + } + + static URI resolveTarget(URI target, InetAddress[] addresses) { + validateTarget(target, Set.of()); + validateResolvedAddresses(target.getHost(), addresses); + return target; + } + + static Dns pinnedDns(String expectedHost, InetAddress[] addresses) { + List pinned = Arrays.asList(addresses.clone()); + return hostname -> { + if (!expectedHost.equalsIgnoreCase(hostname)) { + throw new UnknownHostException("Unexpected operations host"); + } + return pinned; + }; + } + + static void validateResolvedAddresses(String requestedHost, + InetAddress[] addresses) { + if (addresses == null || addresses.length == 0) { + throw new IllegalArgumentException("Unresolvable operations target"); + } + boolean literal = isIpLiteral(requestedHost); + for (InetAddress address : addresses) { + if (address.isAnyLocalAddress() || address.isLinkLocalAddress() || + address.isMulticastAddress() || + address.isLoopbackAddress() && !literal) { + throw new IllegalArgumentException("Untrusted operations address"); + } + } + } + + private static boolean isIpLiteral(String host) { + if (host.indexOf(':') >= 0) { + return true; + } + return host.matches("(?:\\d{1,3}\\.){3}\\d{1,3}"); + } + + private static String normalizedHost(URI target) { + String host = target.getHost().toLowerCase(Locale.ROOT); + if (host.indexOf(':') < 0 || host.startsWith("[")) { + return host; + } + return '[' + host + ']'; + } + + private String readLimited(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[Math.min(4096, this.maxResponseBytes + 1)]; + int total = 0; + int read; + while ((read = input.read(buffer)) >= 0) { + total += read; + if (total > this.maxResponseBytes) { + throw new UpstreamResponseTooLargeException(); + } + output.write(buffer, 0, read); + } + return output.toString(StandardCharsets.UTF_8.name()); + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsModels.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsModels.java new file mode 100644 index 000000000..2f7b42dbc --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsModels.java @@ -0,0 +1,325 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +public final class OperationsModels { + + private OperationsModels() { + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static final class Node { + + private final String id; + private final String type; + private final String name; + private final String role; + private final String version; + private final String status; + private final Long observedAt; + private final Map metrics; + private final Map metricStatuses; + + public Node(String id, String type, String name, String role, + String version, String status, Long observedAt, + Map metrics) { + this(id, type, name, role, version, status, observedAt, metrics, + Collections.emptyMap()); + } + + public Node(String id, String type, String name, String role, + String version, String status, Long observedAt, + Map metrics, + Map metricStatuses) { + this.id = id; + this.type = type; + this.name = name; + this.role = role; + this.version = version; + this.status = status; + this.observedAt = observedAt; + this.metrics = immutableMap(metrics); + this.metricStatuses = immutableMap(metricStatuses); + } + + public String getId() { + return this.id; + } + + public String getType() { + return this.type; + } + + public String getName() { + return this.name; + } + + public String getRole() { + return this.role; + } + + public String getVersion() { + return this.version; + } + + public String getStatus() { + return this.status; + } + + public Long getObservedAt() { + return this.observedAt; + } + + public Map getMetrics() { + return this.metrics; + } + + public Map getMetricStatuses() { + return this.metricStatuses; + } + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static final class MetricStatus { + + private final String availability; + private final Long observedAt; + private final Long lastSuccessAt; + private final boolean fresh; + private final boolean stale; + private final String reason; + + public MetricStatus(String availability, Long observedAt, + Long lastSuccessAt, boolean fresh, boolean stale, + String reason) { + this.availability = availability; + this.observedAt = observedAt; + this.lastSuccessAt = lastSuccessAt; + this.fresh = fresh; + this.stale = stale; + this.reason = reason; + } + + public String getAvailability() { + return this.availability; + } + + public Long getObservedAt() { + return this.observedAt; + } + + public Long getLastSuccessAt() { + return this.lastSuccessAt; + } + + public boolean isFresh() { + return this.fresh; + } + + public boolean isStale() { + return this.stale; + } + + public String getReason() { + return this.reason; + } + + public MetricStatus stale(String staleReason) { + return new MetricStatus(this.availability, this.observedAt, + this.lastSuccessAt, false, true, + staleReason); + } + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static final class Topology { + + private final String status; + private final List nodes; + private final Map facts; + + public Topology(String status, List nodes, + Map facts) { + this.status = status; + this.nodes = Collections.unmodifiableList(nodes); + this.facts = Collections.unmodifiableMap(facts); + } + + public String getStatus() { + return this.status; + } + + public List getNodes() { + return this.nodes; + } + + public Map getFacts() { + return this.facts; + } + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static final class SourceStatus { + + private final String availability; + private final String status; + private final Long observedAt; + private final Long lastSuccessAt; + private final boolean fresh; + private final boolean stale; + private final String reason; + + public SourceStatus(String availability, String status, Long observedAt, + Long lastSuccessAt, boolean fresh, boolean stale, + String reason) { + this.availability = availability; + this.status = status; + this.observedAt = observedAt; + this.lastSuccessAt = lastSuccessAt; + this.fresh = fresh; + this.stale = stale; + this.reason = reason; + } + + public String getAvailability() { + return this.availability; + } + + public String getStatus() { + return this.status; + } + + public Long getObservedAt() { + return this.observedAt; + } + + public Long getLastSuccessAt() { + return this.lastSuccessAt; + } + + public boolean isFresh() { + return this.fresh; + } + + public boolean isStale() { + return this.stale; + } + + public String getReason() { + return this.reason; + } + + public SourceStatus stale(String staleReason) { + return new SourceStatus(this.availability, this.status, + this.observedAt, this.lastSuccessAt, + false, true, staleReason); + } + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static final class Snapshot { + + private final String status; + private final long observedAt; + private final boolean stale; + private final String reason; + private final Map sources; + private final List nodes; + private final Map facts; + + public Snapshot(String status, long observedAt, boolean stale, + String reason, Map sources, + List nodes, Map facts) { + this.status = status; + this.observedAt = observedAt; + this.stale = stale; + this.reason = reason; + this.sources = Collections.unmodifiableMap(sources); + this.nodes = Collections.unmodifiableList(nodes); + this.facts = Collections.unmodifiableMap(facts); + } + + public String getStatus() { + return this.status; + } + + public long getObservedAt() { + return this.observedAt; + } + + public boolean isStale() { + return this.stale; + } + + public String getReason() { + return this.reason; + } + + public Map getSources() { + return this.sources; + } + + public List getNodes() { + return this.nodes; + } + + public Map getFacts() { + return this.facts; + } + + public Snapshot stale(String staleReason) { + Map staleSources = new java.util.LinkedHashMap<>(); + this.sources.forEach((name, source) -> + staleSources.put(name, source.stale(staleReason))); + List staleNodes = new ArrayList<>(); + for (Node node : this.nodes) { + Map statuses = new LinkedHashMap<>(); + node.getMetricStatuses().forEach((group, status) -> { + MetricStatus staleStatus = status.getLastSuccessAt() == null ? + status : new MetricStatus( + "UNAVAILABLE", status.getObservedAt(), + status.getLastSuccessAt(), false, true, staleReason); + statuses.put(group, staleStatus); + }); + staleNodes.add(new Node( + node.getId(), node.getType(), node.getName(), + node.getRole(), node.getVersion(), node.getStatus(), + node.getObservedAt(), node.getMetrics(), statuses)); + } + return new Snapshot("DEGRADED", this.observedAt, true, staleReason, + staleSources, staleNodes, this.facts); + } + } + + private static Map immutableMap(Map values) { + if (values == null || values.isEmpty()) { + return Collections.emptyMap(); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsNodeNotFoundException.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsNodeNotFoundException.java new file mode 100644 index 000000000..c451546ed --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsNodeNotFoundException.java @@ -0,0 +1,32 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class OperationsNodeNotFoundException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public OperationsNodeNotFoundException() { + super("operations_node_not_found"); + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsPayloadParser.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsPayloadParser.java new file mode 100644 index 000000000..231244c11 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsPayloadParser.java @@ -0,0 +1,574 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.hugegraph.service.op.OperationsModels.Node; +import org.apache.hugegraph.service.op.OperationsModels.MetricStatus; +import org.apache.hugegraph.service.op.OperationsModels.Topology; + +public class OperationsPayloadParser { + + private final ObjectMapper mapper; + + public OperationsPayloadParser(ObjectMapper mapper) { + this.mapper = mapper; + } + + public Topology parseTopology(String clusterPayload, String storesPayload) { + return this.parseTopology(clusterPayload, storesPayload, null); + } + + public Topology parseTopology(String clusterPayload, String storesPayload, + Long observedAt) { + JsonNode cluster = this.data(clusterPayload, "pd_cluster"); + JsonNode stores = this.data(storesPayload, "pd_stores"); + List nodes = new ArrayList<>(); + Map pdNodes = new LinkedHashMap<>(); + JsonNode leader = cluster.get("pdLeader"); + if (leader != null && !leader.isNull()) { + this.requireObject(leader, "pd_cluster"); + } + String leaderId = leader != null && leader.isObject() ? + this.pdId(leader) : null; + JsonNode pdList = this.requiredArray(cluster, "pdList", "pd_cluster"); + for (JsonNode pd : pdList) { + this.requireObject(pd, "pd_cluster"); + this.addPd(pdNodes, pd, + leaderId != null && leaderId.equals(this.pdId(pd)), + leaderId != null, observedAt); + } + if (leader != null && leader.isObject()) { + this.addPd(pdNodes, leader, true, true, observedAt); + } + nodes.addAll(pdNodes.values()); + + Map storeNodes = new LinkedHashMap<>(); + JsonNode clusterStores = cluster.get("stores"); + if (clusterStores != null) { + this.indexStores(storeNodes, this.requiredArray( + cluster, "stores", "pd_cluster")); + } + this.indexStores(storeNodes, this.requiredArray( + stores, "stores", "pd_stores")); + for (Map.Entry entry : storeNodes.entrySet()) { + nodes.add(this.store(entry.getKey(), entry.getValue(), observedAt)); + } + + Map facts = new LinkedHashMap<>(); + this.putLong(facts, "graphs", cluster.get("graphSize")); + this.putLong(facts, "partitions", cluster.get("partitionSize")); + this.putLong(facts, "replicas", cluster.get("shardCount")); + this.putLong(facts, "stores", cluster.get("storeSize")); + this.putLong(facts, "stores_up", cluster.get("onlineStoreSize")); + return new Topology(this.clusterStatus(this.text(cluster, "state")), + nodes, facts); + } + + public Map parseStoreHosts(String storesPayload) { + JsonNode stores = this.data(storesPayload, "pd_stores"); + Map result = new LinkedHashMap<>(); + JsonNode values = this.requiredArray(stores, "stores", "pd_stores"); + for (JsonNode store : values) { + this.requireObject(store, "pd_stores"); + String rawId = this.identity(store, "storeId"); + String address = this.text(store, "address"); + if (rawId == null || address == null) { + continue; + } + String host = host(address); + if (host != null) { + result.put(this.stableId("store", rawId), host); + } + } + return result; + } + + public Map parseStoreRestAddresses(String storesPayload) { + JsonNode stores = this.data(storesPayload, "pd_stores"); + Map result = new LinkedHashMap<>(); + JsonNode values = this.requiredArray(stores, "stores", "pd_stores"); + for (JsonNode store : values) { + this.requireObject(store, "pd_stores"); + String rawId = this.identity(store, "storeId"); + String restAddress = this.text(store, "restAddress"); + if (rawId == null || restAddress == null) { + continue; + } + try { + URI uri = URI.create("http://" + restAddress); + OperationsHttpClient.validateTarget(uri, Collections.emptySet()); + result.put(this.stableId("store", rawId), + OperationsHttpClient.authority(uri)); + } catch (RuntimeException e) { + throw new MalformedUpstreamException("pd_store_rest_address", e); + } + } + return result; + } + + public Map parseStoreMetricTargets(String payload) { + try { + JsonNode root = this.mapper.readTree(payload); + if (!root.isArray()) { + throw new MalformedUpstreamException("pd_prom_targets"); + } + Map result = new LinkedHashMap<>(); + for (JsonNode group : root) { + this.requireObject(group, "pd_prom_targets"); + JsonNode labels = group.get("labels"); + this.requireObject(labels, "pd_prom_targets"); + if (!"store".equals(this.text(labels, "__app_name"))) { + continue; + } + String scheme = this.text(labels, "__scheme__"); + if (scheme == null) { + scheme = "http"; + } + JsonNode targets = this.requiredArray(group, "targets", + "pd_prom_targets"); + for (JsonNode target : targets) { + if (!target.isTextual()) { + throw new MalformedUpstreamException( + "pd_prom_targets"); + } + String authority = target.textValue().trim(); + URI uri = URI.create(scheme + "://" + authority); + OperationsHttpClient.validateTarget(uri, + Collections.emptySet()); + result.put(OperationsHttpClient.authority(uri), uri); + } + } + return result; + } catch (MalformedUpstreamException e) { + throw e; + } catch (Exception e) { + throw new MalformedUpstreamException("pd_prom_targets", e); + } + } + + public Map parseStoreMetrics(String group, + String payload) { + try { + JsonNode root = this.mapper.readTree(payload); + if (!root.isObject()) { + throw new MalformedUpstreamException("store_" + group); + } + if ("system".equals(group)) { + return this.systemMetrics(root); + } + if ("drive".equals(group)) { + return this.driveMetrics(root); + } + if ("raft".equals(group)) { + return this.raftMetrics(root); + } + throw new IllegalArgumentException("Unknown Store metric group"); + } catch (MalformedUpstreamException | IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new MalformedUpstreamException("store_" + group, e); + } + } + + public Map parsePdPrometheusMetrics(String payload) { + Map result = new LinkedHashMap<>(); + double heap = 0D; + double nonheap = 0D; + boolean hasHeap = false; + boolean hasNonheap = false; + for (String rawLine : payload.split("\\r?\\n")) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + Double value = prometheusValue(line); + if (value == null) { + continue; + } + if (line.startsWith("process_uptime_seconds{")) { + result.put("uptime_seconds", value); + } else if (line.startsWith("system_cpu_count{")) { + result.put("cpu_count", value); + } else if (line.startsWith("jvm_threads_live_threads{")) { + result.put("threads_live", value); + } else if (line.startsWith("process_cpu_usage{")) { + result.put("process_cpu_usage", value); + } else if (line.startsWith("system_cpu_usage{")) { + result.put("system_cpu_usage", value); + } else if (line.startsWith("jvm_memory_used_bytes{") && + line.contains("area=\"heap\"")) { + heap += value; + hasHeap = true; + } else if (line.startsWith("jvm_memory_used_bytes{") && + line.contains("area=\"nonheap\"")) { + nonheap += value; + hasNonheap = true; + } + } + if (hasHeap) { + result.put("heap_used_bytes", heap); + } + if (hasNonheap) { + result.put("nonheap_used_bytes", nonheap); + } + if (result.isEmpty()) { + throw new MalformedUpstreamException("pd_prometheus"); + } + return result; + } + + private JsonNode data(String payload, String source) { + try { + JsonNode root = this.mapper.readTree(payload); + JsonNode status = root.get("status"); + JsonNode data = root.get("data"); + if (status == null || status.asInt(-1) != 0 || data == null || + !data.isObject()) { + throw new MalformedUpstreamException(source); + } + return data; + } catch (MalformedUpstreamException e) { + throw e; + } catch (Exception e) { + throw new MalformedUpstreamException(source, e); + } + } + + private void addPd(Map nodes, JsonNode pd, boolean leader, + boolean authoritativeLeader, Long observedAt) { + String id = this.pdId(pd); + if (id == null) { + return; + } + String role = leader ? "LEADER" : authoritativeLeader ? "FOLLOWER" : + this.role(this.text(pd, "role")); + nodes.put(id, new Node(id, "PD", "PD " + this.shortId(id), role, + this.text(pd, "serviceVersion"), + this.health(this.text(pd, "state")), + observedAt, + Collections.emptyMap())); + } + + private String pdId(JsonNode pd) { + String identity = this.text(pd, "restUrl"); + if (identity == null) { + identity = this.text(pd, "raftUrl"); + } + if (identity == null) { + return null; + } + return this.stableId("pd", identity); + } + + private void indexStores(Map stores, JsonNode values) { + for (JsonNode store : values) { + this.requireObject(store, "pd_stores"); + String id = this.identity(store, "storeId"); + if (id != null) { + stores.put(id, store); + } + } + } + + private Node store(String rawId, JsonNode store, Long observedAt) { + String id = this.stableId("store", rawId); + Map metrics = new LinkedHashMap<>(); + this.putMetric(metrics, "capacity_bytes", store.get("capacity")); + this.putMetric(metrics, "available_bytes", store.get("available")); + this.putMetric(metrics, "partitions", store.get("partitionCount")); + this.putMetric(metrics, "leaders", store.get("leaderCount")); + Map groups = metrics.isEmpty() ? + Collections.emptyMap() : + Collections.singletonMap("backend", + metrics); + Map statuses = new LinkedHashMap<>(); + if (metrics.isEmpty()) { + statuses.put("backend", new MetricStatus( + "UNSUPPORTED", observedAt, null, false, false, + "topology_fields_unavailable")); + } else { + statuses.put("backend", new MetricStatus( + "AVAILABLE", observedAt, observedAt, true, false, + null)); + } + return new Node(id, "STORE", "Store " + this.shortId(id), null, + this.firstText(store, "version", "serviceVersion"), + this.health(this.text(store, "state")), observedAt, + groups, statuses); + } + + private Map systemMetrics(JsonNode root) { + Map result = new LinkedHashMap<>(); + this.copyMetricGroup(root, result, "basic", "mem_total", "mem_used", + "processors", "uptime", "systemload_average"); + this.copyMetricGroup(root, result, "heap", "used", "max", + "committed"); + this.copyMetricGroup(root, result, "nonheap", "used", "max", + "committed"); + this.copyMetricGroup(root, result, "thread", "count", "daemon", + "peak"); + return result; + } + + private Map driveMetrics(JsonNode root) { + long total = 0L; + long usable = 0L; + long free = 0L; + boolean hasTotal = false; + boolean hasUsable = false; + boolean hasFree = false; + String unit = null; + for (JsonNode drive : root) { + Long value = this.longValue(drive.get("total_space")); + if (value != null) { + total += value; + hasTotal = true; + } + value = this.longValue(drive.get("usable_space")); + if (value != null) { + usable += value; + hasUsable = true; + } + value = this.longValue(drive.get("free_space")); + if (value != null) { + free += value; + hasFree = true; + } + String currentUnit = this.text(drive, "size_unit"); + if (unit == null) { + unit = currentUnit; + } else if (currentUnit != null && !unit.equals(currentUnit)) { + unit = null; + } + } + Map result = new LinkedHashMap<>(); + if (hasTotal) { + result.put("total_space", total); + } + if (hasUsable) { + result.put("usable_space", usable); + } + if (hasFree) { + result.put("free_space", free); + } + if (unit != null) { + result.put("size_unit", unit); + } + return result; + } + + private Map raftMetrics(JsonNode root) { + long groups = 0L; + long enabled = 0L; + for (JsonNode value : root) { + groups++; + if (value.path("enabled").asBoolean(false)) { + enabled++; + } + } + Map result = new LinkedHashMap<>(); + result.put("groups", groups); + result.put("enabled_groups", enabled); + return result; + } + + private void copyMetricGroup(JsonNode root, Map result, + String group, String... fields) { + JsonNode values = root.path(group); + if (!values.isObject()) { + return; + } + Map safe = new LinkedHashMap<>(); + for (String field : fields) { + JsonNode value = values.get(field); + if (value != null && value.isValueNode() && !value.isNull()) { + safe.put(field, value.isNumber() ? value.numberValue() : + value.asText()); + } + } + if (!safe.isEmpty()) { + result.put(group, safe); + } + } + + private static String host(String address) { + try { + URI uri = URI.create(address.contains("://") ? address : + "tcp://" + address); + return uri.getHost() == null ? null : + uri.getHost().toLowerCase(Locale.ROOT); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static Double prometheusValue(String line) { + int split = line.lastIndexOf(' '); + if (split < 0 || split == line.length() - 1) { + return null; + } + try { + double value = Double.parseDouble(line.substring(split + 1)); + return Double.isFinite(value) ? value : null; + } catch (NumberFormatException e) { + return null; + } + } + + private void putLong(Map target, String key, JsonNode value) { + Long number = this.longValue(value); + if (number != null) { + target.put(key, number); + } + } + + private void putMetric(Map target, String key, + JsonNode value) { + Long number = this.longValue(value); + if (number != null) { + target.put(key, number); + } + } + + private Long longValue(JsonNode value) { + if (value == null || value.isNull() || !value.canConvertToLong()) { + return null; + } + return value.longValue(); + } + + private String firstText(JsonNode node, String... fields) { + for (String field : fields) { + String value = this.text(node, field); + if (value != null) { + return value; + } + } + return null; + } + + private String text(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isTextual()) { + throw new MalformedUpstreamException("upstream_field_" + field); + } + String text = value.asText().trim(); + return text.isEmpty() ? null : text; + } + + private String identity(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isTextual() && !value.isIntegralNumber()) { + throw new MalformedUpstreamException("upstream_field_" + field); + } + String identity = value.asText().trim(); + return identity.isEmpty() ? null : identity; + } + + private JsonNode requiredArray(JsonNode parent, String field, + String source) { + JsonNode value = parent.get(field); + if (value == null || !value.isArray()) { + throw new MalformedUpstreamException(source); + } + return value; + } + + private void requireObject(JsonNode value, String source) { + if (value == null || !value.isObject()) { + throw new MalformedUpstreamException(source); + } + } + + private String clusterStatus(String state) { + if (state == null) { + return "UNKNOWN"; + } + String value = state.toUpperCase(Locale.ROOT); + if (value.contains("OK") || value.equals("UP")) { + return "UP"; + } + if (value.contains("WARN") || value.contains("DEGRADED")) { + return "DEGRADED"; + } + if (value.contains("DOWN") || value.contains("FAULT")) { + return "DOWN"; + } + return "UNKNOWN"; + } + + private String health(String state) { + if (state == null) { + return "UNKNOWN"; + } + String value = state.toUpperCase(Locale.ROOT); + if (value.equals("UP") || value.equals("ONLINE") || + value.equals("OK")) { + return "UP"; + } + if (value.equals("DOWN") || value.equals("OFFLINE") || + value.equals("EXITING") || value.equals("TOMBSTONE")) { + return "DOWN"; + } + return "UNKNOWN"; + } + + private String role(String role) { + return role == null ? null : role.toUpperCase(Locale.ROOT); + } + + private String stableId(String type, String identity) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] value = digest.digest((type + ':' + identity) + .getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(type).append('-'); + for (int i = 0; i < 6; i++) { + result.append(String.format("%02x", value[i])); + } + return result.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + private String shortId(String id) { + return id.substring(id.indexOf('-') + 1, id.indexOf('-') + 7); + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/UpstreamRequestException.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/UpstreamRequestException.java new file mode 100644 index 000000000..cdae97da1 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/UpstreamRequestException.java @@ -0,0 +1,32 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +public class UpstreamRequestException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public UpstreamRequestException(String reason) { + super(reason); + } + + public UpstreamRequestException(String reason, Throwable cause) { + super(reason, cause); + } +} diff --git a/hugegraph-hubble/hubble-fe/src/pages/Role/index.module.scss b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/UpstreamResponseTooLargeException.java similarity index 74% rename from hugegraph-hubble/hubble-fe/src/pages/Role/index.module.scss rename to hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/UpstreamResponseTooLargeException.java index ad68b4368..873026bb5 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Role/index.module.scss +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/UpstreamResponseTooLargeException.java @@ -1,4 +1,4 @@ -/*! +/* * * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with this @@ -16,26 +16,14 @@ * under the License. */ -.role_name { - padding-left: 5px; - cursor: pointer; -} +package org.apache.hugegraph.service.op; -.current { - span { - color: #1890ff; - } -} +public class UpstreamResponseTooLargeException + extends UpstreamRequestException { -div.item { - margin: 0; -} + private static final long serialVersionUID = 1L; -.rolelist { - :global { - .ant-spin-container .ant-list-item { - border-bottom: 0; - padding-top: 0; - } + public UpstreamResponseTooLargeException() { + super("upstream_response_too_large"); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/QueryService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/QueryService.java index f99461e98..67ae3fb41 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/QueryService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/QueryService.java @@ -54,6 +54,7 @@ import org.apache.hugegraph.util.Ex; import org.apache.hugegraph.util.GremlinUtil; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; @@ -100,7 +101,7 @@ public class QueryService { public ResultSet executeQueryCount(HugeClient client, String query) { - log.debug("The original gremlin ==> {}", query); + log.debug("Execute Gremlin count query"); // Execute gremlin query ResultSet resultSet = this.executeGremlin(query, client); return resultSet; @@ -108,9 +109,9 @@ public ResultSet executeQueryCount(HugeClient client, String query) { public GremlinResult executeGremlinQuery(HugeClient client, GremlinQuery query) { - log.debug("The original gremlin ==> {}", query.getContent()); + log.debug("Execute Gremlin query"); String gremlin = this.optimize(query.getContent()); - log.debug("The optimized gremlin ==> {}", gremlin); + log.debug("Gremlin query optimized"); // Execute gremlin query ResultSet resultSet = this.executeGremlin(gremlin, client); // Scan data, vote the result type @@ -131,9 +132,9 @@ public GremlinResult executeGremlinQuery(HugeClient client, GremlinQuery query) public JsonView executeSingleGremlinQuery(HugeClient client, GremlinQuery query) { - log.debug("The original gremlin ==> {}", query.getContent()); + log.debug("Execute single Gremlin query"); String gremlin = this.optimize(query.getContent()); - log.debug("The optimized gremlin ==> {}", gremlin); + log.debug("Single Gremlin query optimized"); // Execute gremlin query ResultSet resultSet = this.executeGremlin(gremlin, client); // Scan data, vote the result type @@ -167,12 +168,12 @@ private ResultSet executeCypher(String cypher, HugeClient client) { return client.cypher().cypher(cypher); } catch (ServerException e) { String exception = e.exception(); - log.error("Gremlin execute failed: {}", exception); + log.error("Cypher execute failed with HTTP status {}", e.status()); if (ILLEGAL_GREMLIN_EXCEPTIONS.contains(exception)) { throw new IllegalGremlinException("gremlin.illegal-statemnt", e, e.message()); } - throw new ExternalException("gremlin.execute.failed", e, e.message()); + throw this.serverQueryException(e); } catch (ClientException e) { Throwable cause = e.getCause(); if (cause != null) { @@ -188,7 +189,8 @@ private ResultSet executeCypher(String cypher, HugeClient client) { } throw e; } catch (Exception e) { - log.error("Gremlin execute failed", e); + log.error("Cypher execute failed with {}", + e.getClass().getSimpleName()); throw new ExternalException("gremlin.execute.failed", e, e.getMessage()); } @@ -196,24 +198,32 @@ private ResultSet executeCypher(String cypher, HugeClient client) { public Long executeGremlinAsyncTask(HugeClient client, GremlinQuery query) { - log.debug("The async gremlin ==> {}", query.getContent()); + log.debug("Execute asynchronous Gremlin query"); // Execute optimized gremlin query GremlinRequest request = new GremlinRequest(query.getContent()); - return client.gremlin().executeAsTask(request); + try { + return client.gremlin().executeAsTask(request); + } catch (ServerException e) { + throw this.serverQueryException(e); + } } public Long executeCypherAsyncTask(HugeClient client, String query) { - log.debug("The async gremlin ==> {}", query); + log.debug("Execute asynchronous Cypher query"); // Execute optimized gremlin query - return client.cypher().executeAsTask(query); + try { + return client.cypher().executeAsTask(query); + } catch (ServerException e) { + throw this.serverQueryException(e); + } } public GremlinResult expandVertex(HugeClient client, AdjacentQuery query) { // Build gremlin query String gremlin = this.buildGremlinQuery(client, query); - log.debug("expand vertex gremlin ==> {}", gremlin); + log.debug("Execute vertex expansion query"); // Execute gremlin query ResultSet resultSet = this.executeGremlin(gremlin, client); @@ -260,12 +270,12 @@ private ResultSet executeGremlin(String gremlin, HugeClient client) { return client.gremlin().gremlin(gremlin).execute(); } catch (ServerException e) { String exception = e.exception(); - log.error("Gremlin execute failed: {}", exception); + log.error("Gremlin execute failed with HTTP status {}", e.status()); if (ILLEGAL_GREMLIN_EXCEPTIONS.contains(exception)) { throw new IllegalGremlinException("gremlin.illegal-statemnt", e, e.message()); } - throw new ExternalException("gremlin.execute.failed", e, e.message()); + throw this.serverQueryException(e); } catch (ClientException e) { Throwable cause = e.getCause(); if (cause != null) { @@ -281,12 +291,38 @@ private ResultSet executeGremlin(String gremlin, HugeClient client) { } throw e; } catch (Exception e) { - log.error("Gremlin execute failed", e); + log.error("Gremlin execute failed with {}", + e.getClass().getSimpleName()); throw new ExternalException("gremlin.execute.failed", e, e.getMessage()); } } + private ExternalException serverQueryException(ServerException exception) { + int status = exception.status() > 0 ? exception.status() : 400; + if (status == HttpStatus.UNAUTHORIZED.value()) { + return new ExternalException(HttpStatus.BAD_GATEWAY.value(), + "gremlin.server.authentication-failed", + exception); + } + if (status == 503) { + return new ExternalException(status, "gremlin.server.unavailable", + exception); + } + String detail = exception.message(); + if (StringUtils.isBlank(detail)) { + detail = exception.cause(); + } + if (StringUtils.isBlank(detail)) { + detail = exception.exception(); + } + if (StringUtils.isBlank(detail)) { + detail = "HTTP " + status; + } + return new ExternalException(status, "gremlin.execute.failed", + exception, detail); + } + private TypedResult parseResults(ResultSet resultSet) { if (resultSet == null) { return new TypedResult(Type.EMPTY, null); @@ -551,7 +587,11 @@ private Map edgesOfVertex(TypedResult result, // The edges count for per vertex Map degrees = new HashMap<>(resultSet.size()); for (Iterator iter = resultSet.iterator(); iter.hasNext();) { - Edge edge = iter.next().getEdge(); + Object object = iter.next().getObject(); + if (!(object instanceof Edge)) { + continue; + } + Edge edge = (Edge) object; Object source = edge.sourceId(); Object target = edge.targetId(); // only add the interconnected edges of the found vertices diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java index 7428944a6..3b3813fb8 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java @@ -33,6 +33,7 @@ import org.apache.hugegraph.structure.space.SchemaTemplate; import org.apache.hugegraph.util.GremlinUtil; import org.apache.hugegraph.util.HubbleUtil; +import org.apache.hugegraph.util.JsonUtil; import org.apache.hugegraph.util.Log; import org.apache.hugegraph.util.PageUtil; import org.slf4j.Logger; @@ -46,6 +47,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -84,11 +86,11 @@ public Map metrics(HugeClient client) { Map elVl = elAndVlCount(client, gs); Map task = preDayTaskCount(client, gs); - vCount += (Long) ev.get("vertex"); - eCount += (Long) ev.get("edge"); - vlCount += (Long) elVl.get("vertexlabel"); - elCount += (Long) elVl.get("edgelabel"); - preDayTaskCount += (Long) task.get("task"); + vCount += ((Number) ev.get("vertex")).longValue(); + eCount += ((Number) ev.get("edge")).longValue(); + vlCount += ((Number) elVl.get("vertexlabel")).longValue(); + elCount += ((Number) elVl.get("edgelabel")).longValue(); + preDayTaskCount += ((Number) task.get("task")).longValue(); gCount += Long.valueOf( graphsService.listGraphNames(client, gs, "").size()); @@ -129,6 +131,7 @@ public List> queryAllGs(HugeClient client, String query, new BuiltInFirst().compare(a.get("name").toString(), b.get("name").toString())); for (Map info : results) { + removeSensitiveFields(info); String name = info.get("name").toString(); info.put("graphspace_admin", userService.listGraphSpaceAdmin(client, name)); @@ -138,27 +141,92 @@ public List> queryAllGs(HugeClient client, String query, return results; } + public List> queryAccessibleGs(HugeClient client, + String query, + String createTime) { + String prefix = query == null ? "" : query; + String after = createTime == null ? "" : createTime; + List> results = client.graphSpace() + .listGraphSpace().stream() + .map(client.graphSpace()::getGraphSpace) + .filter(space -> space != null && + (space.getName().contains(prefix) || + space.getNickname() != null && + space.getNickname().contains(prefix))) + .filter(space -> space.getCreateTime() == null || + space.getCreateTime().compareTo(after) > 0) + .filter(space -> !space.isAuth() || + client.auth().isSpaceAdmin(space.getName()) || + client.auth().checkDefaultRole( + space.getName(), "analyst")) + .map(space -> { + GraphSpaceEntity entity = + GraphSpaceEntity.fromGraphSpace(space); + entity.setStatistic(evCount(client, space.getName())); + Map info = toView(entity); + info.put("authed", true); + info.put("default", false); + return info; + }) + .collect(Collectors.toList()); + Collections.sort(results, (a, b) -> + new BuiltInFirst().compare(a.get("name").toString(), + b.get("name").toString())); + return results; + } + + public Map toView(GraphSpaceEntity entity) { + Map info = HubbleUtil.uncheckedCast( + JsonUtil.fromJson(JsonUtil.toJson(entity), Map.class)); + removeSensitiveFields(info); + return info; + } + + private static void removeSensitiveFields(Map info) { + info.keySet().removeIf(key -> { + String normalized = key.replace("_", "") + .toLowerCase(Locale.ROOT); + return "dpusername".equals(normalized) || + "dppassword".equals(normalized) || + "configs".equals(normalized); + }); + } + /** * 统计指定图空间下的顶点总数和边总数 * @param client * @param graphSpace * @return */ - private Map evCount(HugeClient client, String graphSpace) { + Map evCount(HugeClient client, String graphSpace) { long vertexTotal = 0L; long edgeTotal = 0L; Map statisticTotal = new HashMap<>(); client.assignGraph(graphSpace, ""); Set graphs = graphsService.listGraphNames(client, graphSpace, ""); - String statisticDate = HubbleUtil.dateFormatDay(HubbleUtil.nowDate()); + String statisticDate = null; + boolean statisticDateInitialized = false; for (String graph : graphs) { Map graphEvCount = graphsService.evCount(client, graphSpace, graph); - vertexTotal += (long) graphEvCount.get("vertex"); - edgeTotal += (long) graphEvCount.get("edge"); + String graphStatisticDate = (String) graphEvCount.get("date"); + if (!statisticDateInitialized) { + statisticDate = graphStatisticDate; + statisticDateInitialized = true; + } else if (!java.util.Objects.equals(statisticDate, + graphStatisticDate)) { + statisticDate = null; + } + + vertexTotal += ((Number) graphEvCount.get("vertex")).longValue(); + edgeTotal += ((Number) graphEvCount.get("edge")).longValue(); + } + if (graphs.isEmpty()) { + statisticDate = HubbleUtil.dateFormatDay(HubbleUtil.nowDate()); } - statisticTotal.put("date", HubbleUtil.dateFormatDay(statisticDate)); + statisticTotal.put("date", statisticDate == null ? null : + HubbleUtil.dateFormatDay(statisticDate)); statisticTotal.put("vertex", vertexTotal); statisticTotal.put("edge", edgeTotal); return statisticTotal; diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/VermeerService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/VermeerService.java index 0c34efec3..1fafecab2 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/VermeerService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/VermeerService.java @@ -42,16 +42,15 @@ public class VermeerService { private static final String GET_SYS_CFG = "api/v1.0/memt_clu/config/getsyscfg"; - public boolean isVermeerEnabled(String username, String password) { - Map vermeer = this.getVermeer(username, password, - false); + public boolean isVermeerEnabled(String token) { + Map vermeer = this.getVermeer(token, false); if (vermeer != null) { return (Boolean) vermeer.get("enable"); } return false; } - public Map getVermeer(String username, String password, + public Map getVermeer(String token, boolean throwIfNoConfig) { String dashboard = config.get(HubbleOptions.DASHBOARD_ADDRESS); String protocol = config.get(HubbleOptions.SERVER_PROTOCOL); @@ -66,7 +65,7 @@ public Map getVermeer(String username, String password, null, null, true) - .configUser(username, password); + .configToken(token); RestClient client = new RestClient(builder.url(), builder.token(), builder.timeout(), builder.maxConns(), diff --git a/hugegraph-hubble/hubble-be/src/main/resources/demo/hlm.txt b/hugegraph-hubble/hubble-be/src/main/resources/demo/hlm.txt new file mode 100644 index 000000000..52794159f --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/resources/demo/hlm.txt @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with this +# work for additional information regarding copyright ownership. The ASF +# licenses this file to You under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This small original demo was authored for Apache HugeGraph from +# public-domain characters and relationships in Dream of the Red Chamber. +贾宝玉,男,19,怡红公子,衔玉而生,林黛玉,女,17,潇湘妃子,敏感聪慧,知己 +贾宝玉,男,19,怡红公子,衔玉而生,薛宝钗,女,18,蘅芜君,端庄博学,夫妻 +贾宝玉,男,19,怡红公子,衔玉而生,贾母,女,70,老祖宗,慈爱威严,祖孙 +贾宝玉,男,19,怡红公子,衔玉而生,王夫人,女,45,荣国府夫人,持家守礼,母子 +贾宝玉,男,19,怡红公子,衔玉而生,贾政,男,50,工部员外郎,严谨端方,父子 +林黛玉,女,17,潇湘妃子,敏感聪慧,贾母,女,70,老祖宗,慈爱威严,外祖孙 +薛宝钗,女,18,蘅芜君,端庄博学,薛姨妈,女,45,薛家夫人,慈和稳重,母女 +薛宝钗,女,18,蘅芜君,端庄博学,薛蟠,男,24,薛家公子,豪爽任性,兄妹 +王熙凤,女,25,凤辣子,精明能干,贾琏,男,28,荣府公子,善于交际,夫妻 +王熙凤,女,25,凤辣子,精明能干,贾母,女,70,老祖宗,慈爱威严,孙媳 +贾元春,女,26,贤德妃,端庄仁厚,贾宝玉,男,19,怡红公子,衔玉而生,姐弟 +贾探春,女,18,蕉下客,果断有才,贾宝玉,男,19,怡红公子,衔玉而生,兄妹 +贾迎春,女,20,菱洲,温柔沉静,贾宝玉,男,19,怡红公子,衔玉而生,兄妹 +贾惜春,女,16,藕榭,清冷善画,贾宝玉,男,19,怡红公子,衔玉而生,兄妹 +贾母,女,70,老祖宗,慈爱威严,贾政,男,50,工部员外郎,严谨端方,母子 diff --git a/hugegraph-hubble/hubble-be/src/main/resources/hugegraph-hubble.properties b/hugegraph-hubble/hubble-be/src/main/resources/hugegraph-hubble.properties index ec77a0cf6..75c2a1920 100644 --- a/hugegraph-hubble/hubble-be/src/main/resources/hugegraph-hubble.properties +++ b/hugegraph-hubble/hubble-be/src/main/resources/hugegraph-hubble.properties @@ -28,6 +28,7 @@ graph_connection.ip_white_list=[*] graph_connection.port_white_list=[-1] client.request_timeout=310 +client.url_cache_max_entries=1024 gremlin.suffix_limit=250 gremlin.vertex_degree_limit=100 @@ -56,6 +57,24 @@ server.direct_url=http://127.0.0.1:8080 pd.peers=127.0.0.1:8686 pd.server=127.0.0.1:8620 +# Native operations aggregation. Keep PD/Store passwords outside source control +# in production. PD/Store 1.7 also require a trusted management network until +# strong upstream service-identity validation is enabled. +operations.connect_timeout_ms=1500 +operations.read_timeout_ms=2500 +operations.max_response_bytes=1048576 +operations.cache_ttl_seconds=5 +operations.cache_max_entries=1024 +operations.store_threads=16 +operations.store_deadline_ms=5000 +# Operator-managed trust root for Store metrics. The defaults are local-test +# only; production must list every trusted Store scheme, host, and port. +operations.store.allowed_targets=[http://127.0.0.1:8520,http://[::1]:8520] +operations.pd.username=hubble +operations.pd.password= +operations.store.username=hubble +operations.store.password= + # dashboard dashboard.address=127.0.0.1:8092 # BOTH, NODE_PORT, DDS diff --git a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties index 9f39cf5e0..3986e567e 100644 --- a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties +++ b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties @@ -27,11 +27,10 @@ common.time-order.invalid=The param time_order either not set or set to [asc, de request.parameter.required=The request parameter ''{0}'' is required server.capability.pd-status.unavailable=PD status is unavailable because this HugeGraph Server does not expose the required API server.capability.hstore-status.unavailable=HStore status is unavailable because this HugeGraph Server does not expose the required API +server.capability.vermeer-compute-token-auth.unavailable=Vermeer compute is unavailable until token-based HugeGraph output authentication is supported auth.user.batch-create.failed=Failed to create users: {0} auth.user.import-file.failed=Failed to prepare the uploaded user import file auth.user.import-csv.failed=Failed to parse the uploaded user CSV file -k8s.token.file.not-exist=The Kubernetes token file does not exist -k8s.token.directory.unavailable=The Kubernetes token directory is unavailable common.name-time-order.conflict=The param name_order and time_order cannot set at same time common.param.path-id-should-same-as-body=The id in path({0}) must be same as request body({1}) if it exists @@ -68,6 +67,7 @@ graph.import.invalid-json=The import file is not valid JSON graph.import.file.exceed-limit=The import file size {0} exceeds limit {1} graph.import.missing-field=The import field {0} is required graph.import.field-should-array=The import field {0} should be an array +graph.sample.load-failed=Failed to load sample {0} into {1}/{2}. Existing data was not cleared. Check schema or ID conflicts, then retry. graph.import.vertex.duplicate-id=The import vertex id {0} is duplicated gremlin-collection.name.unmatch-regex=Invalid gremlin statement name, valid name is up to 48 alpha-numeric characters and underscores @@ -80,6 +80,10 @@ gremlin-collection.not-exist.id=No gremlin statement exists with id {0} gremlin.illegal-statemnt=Illegal gremlin statement, the details: {0} gremlin.statement.exceed-limit=The gremlin statement is too long, max length is {0} gremlin.execute.failed=Gremlin execute failed, the details: {0} +gremlin.server.authentication-failed=HugeGraph Server rejected query authentication. \ + Your Hubble session is still active; ask an administrator to check whether this Server \ + version supports token authentication for Gremlin/Cypher queries. +gremlin.server.unavailable=HugeGraph Server is temporarily unavailable (HTTP 503). It may be busy or low on memory; please retry later. gremlin.execute.timeout=Gremlin execute timeout, the details: {0} gremlin.expand.failed=Expand vertex failed, the details: {0} gremlin.connection.refused=Can't connect to HugeGraphServer, please ensure it's available diff --git a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties index ef9fc53a7..e412e5f1f 100644 --- a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties +++ b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties @@ -27,11 +27,10 @@ common.time-order.invalid=参数 time_order 要么不设置,要么设置为 as request.parameter.required=缺少必填请求参数“{0}” server.capability.pd-status.unavailable=当前 HugeGraph Server 未提供所需接口,无法获取 PD 状态 server.capability.hstore-status.unavailable=当前 HugeGraph Server 未提供所需接口,无法获取 HStore 状态 +server.capability.vermeer-compute-token-auth.unavailable=Vermeer 计算需上游支持 token 认证的结果写回 auth.user.batch-create.failed=以下用户创建失败:{0} auth.user.import-file.failed=无法准备上传的用户导入文件 auth.user.import-csv.failed=无法解析上传的用户 CSV 文件 -k8s.token.file.not-exist=Kubernetes token 文件不存在 -k8s.token.directory.unavailable=Kubernetes token 目录不可用 common.name-time-order.conflict=参数 name_order 和 time_order 不能同时设置 common.param.path-id-should-same-as-body=当请求体中的 id({1}) 存在时,必须与路径中的 ({0}) 相同 @@ -68,6 +67,7 @@ graph.import.invalid-json=导入文件不是合法 JSON graph.import.file.exceed-limit=导入文件大小 {0} 超过限制 {1} graph.import.missing-field=导入字段 {0} 必填 graph.import.field-should-array=导入字段 {0} 应为数组 +graph.sample.load-failed=示例 {0} 无法导入 {1}/{2};既有数据未被清空。请检查 Schema 或 ID 冲突后重试。 graph.import.vertex.duplicate-id=导入顶点 id {0} 重复 gremlin-collection.name.unmatch-regex=语句名不合法,语句名允许字母、数字、中文、下划线,最多 48 个字符 @@ -80,6 +80,10 @@ gremlin-collection.not-exist.id=不存在 id 为 {0} 的 gremlin 语句 gremlin.illegal-statemnt=非法的 Gremlin 语句,详细信息: {0} gremlin.statement.exceed-limit=Gremlin 语句太长, 最大长度为 {0} gremlin.execute.failed=Gremlin 执行失败,详细信息: {0} +gremlin.server.authentication-failed=HugeGraph Server 拒绝了图查询认证。\ + 当前 Hubble 会话仍然有效,请联系管理员检查当前 Server 版本是否支持 \ + Gremlin/Cypher 查询的 Token 认证。 +gremlin.server.unavailable=HugeGraph Server 暂时不可用(HTTP 503),可能正忙或内存余量不足,请稍后重试。 gremlin.execute.timeout=Gremlin 执行超时,详细信息: {0} gremlin.expand.failed=扩展顶点失败, 详细信息: {0} gremlin.connection.refused=无法连接到 HugeGraphServer, 请检查服务是否可用 diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/AccountMutationAuthorizationTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/AccountMutationAuthorizationTest.java new file mode 100644 index 000000000..2225fcfdb --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/AccountMutationAuthorizationTest.java @@ -0,0 +1,647 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.controller.auth; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collections; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.multipart.MultipartFile; + +import org.apache.hugegraph.controller.BaseController; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.auth.UserEntity; +import org.apache.hugegraph.entity.auth.UserView; +import org.apache.hugegraph.entity.auth.PasswordEntity; +import org.apache.hugegraph.exception.ForbiddenException; +import org.apache.hugegraph.exception.ParameterizedException; +import org.apache.hugegraph.handler.ExceptionAdvisor; +import org.apache.hugegraph.service.auth.GraphSpaceUserService; +import org.apache.hugegraph.service.auth.UserService; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class AccountMutationAuthorizationTest { + + private HugeClient client; + private UserService authorizationService; + + @Before + public void setup() { + this.client = Mockito.mock(HugeClient.class); + this.authorizationService = Mockito.mock(UserService.class); + } + + @Test + public void testOrdinaryUserCannotCreateAccount() { + TestUserController controller = new TestUserController(this.client, + "alice"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "alice")) + .thenReturn("USER"); + + ForbiddenException failure = assertForbidden( + () -> controller.create(new UserEntity())); + + org.junit.Assert.assertTrue(failure.getMessage() + .contains("manage accounts")); + Mockito.verify(this.authorizationService, Mockito.never()) + .add(Mockito.any(), Mockito.any()); + } + + @Test + public void testOnlySuperadminCanReadGlobalAccountDirectory() { + TestUserController ordinary = accountController("alice", "USER"); + assertGlobalAccountReadsForbidden(ordinary); + + TestUserController spaceAdmin = accountController("manager", + "SPACEADMIN"); + assertGlobalAccountReadsForbidden(spaceAdmin); + + TestUserController superadmin = accountController("admin", "ADMIN"); + superadmin.list(); + superadmin.queryPage("", 1, 10); + superadmin.get("bob-id"); + superadmin.listadminspace("bob"); + } + + @Test + public void testPasswordUpdateIsBoundToCurrentSessionIdentity() { + TestUserController controller = accountController("alice", "USER"); + PasswordEntity foreign = PasswordEntity.builder() + .username("bob") + .oldpwd("old") + .newpwd("new") + .build(); + + assertForbidden(() -> controller.updatepwd(foreign)); + Mockito.verify(this.authorizationService, Mockito.never()) + .updatepwd(Mockito.any(), Mockito.anyString(), + Mockito.anyString(), Mockito.anyString()); + + PasswordEntity own = PasswordEntity.builder() + .username("alice") + .oldpwd("old") + .newpwd("new") + .build(); + controller.updatepwd(own); + Mockito.verify(this.authorizationService) + .updatepwd(this.client, "alice", "old", "new"); + } + + @Test + public void testForbiddenAccountMutationUsesHttpAndBody403() + throws Exception { + TestUserController controller = new TestUserController(this.client, + "alice"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "alice")) + .thenReturn("USER"); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice( + new ExceptionAdvisor()) + .build(); + + mvc.perform(post("/api/v1.3/auth/users") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"user_name\":\"bob\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value(403)); + + Mockito.verify(this.authorizationService, Mockito.never()) + .add(Mockito.any(), Mockito.any()); + } + + @Test + public void testSpaceAdminCannotCreateGlobalAccount() { + TestUserController controller = new TestUserController(this.client, + "manager"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "manager")) + .thenReturn("SPACEADMIN"); + UserEntity account = new UserEntity(); + + ForbiddenException failure = assertForbidden( + () -> controller.create(account)); + + org.junit.Assert.assertTrue(failure.getMessage() + .contains("manage accounts")); + Mockito.verify(this.authorizationService, Mockito.never()) + .add(this.client, account); + } + + @Test + public void testOrdinaryUserCannotModifyDeleteOrGrantAccounts() { + TestUserController controller = new TestUserController(this.client, + "alice"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "alice")) + .thenReturn("USER"); + UserEntity account = new UserEntity(); + account.setName("bob"); + + assertForbidden(() -> controller.update("bob", account)); + assertForbidden(() -> controller.delete("bob")); + assertForbidden(() -> controller.updateadminspace( + "bob", Collections.singletonList("SPACE"))); + + Mockito.verify(this.authorizationService, Mockito.never()) + .update(Mockito.any(), Mockito.any()); + Mockito.verify(this.authorizationService, Mockito.never()) + .delete(Mockito.any(), Mockito.anyString()); + Mockito.verify(this.authorizationService, Mockito.never()) + .updateAdminSpace(Mockito.any(), Mockito.anyString(), + Mockito.anyList()); + } + + @Test + public void testSpaceAdminCannotCreateSuperadminAccount() { + TestUserController controller = new TestUserController(this.client, + "manager"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "manager")) + .thenReturn("SPACEADMIN"); + UserEntity account = new UserEntity(); + account.setSuperadmin(true); + + ForbiddenException failure = assertForbidden( + () -> controller.create(account)); + + org.junit.Assert.assertTrue(failure.getMessage() + .contains("manage accounts")); + } + + @Test + public void testSpaceAdminCannotChangeAnotherGraphSpaceGrant() { + TestUserController controller = new TestUserController(this.client, + "manager"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "manager")) + .thenReturn("SPACEADMIN"); + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn(false); + Mockito.when(this.authorizationService.listAdminSpace( + this.client, "manager")) + .thenReturn(Collections.singletonList("SPACE_A")); + Mockito.when(this.authorizationService.listAdminSpace( + this.client, "bob")) + .thenReturn(Collections.singletonList("SPACE_B")); + + ForbiddenException failure = assertForbidden( + () -> controller.updateadminspace( + "bob", Collections.singletonList("SPACE_A"))); + + org.junit.Assert.assertTrue(failure.getMessage() + .contains("manage accounts")); + } + + @Test + public void testSpaceAdminCannotUseGlobalAdminSpaceGrantEndpoint() { + TestUserController controller = new TestUserController(this.client, + "manager"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "manager")) + .thenReturn("SPACEADMIN"); + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn(false); + Mockito.when(this.authorizationService.listAdminSpace( + this.client, "manager")) + .thenReturn(Collections.singletonList("SPACE_A")); + Mockito.when(this.authorizationService.listAdminSpace( + this.client, "bob")) + .thenReturn(Collections.singletonList("SPACE_B")); + java.util.List requested = Arrays.asList("SPACE_A", "SPACE_B"); + + assertForbidden(() -> controller.updateadminspace("bob", requested)); + + Mockito.verify(this.authorizationService, Mockito.never()) + .updateAdminSpace(Mockito.any(), Mockito.anyString(), + Mockito.anyList()); + } + + @Test + public void testUpdateRejectsBodyUsernameDifferentFromPathIdentity() { + TestUserController controller = new TestUserController(this.client, + "admin"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "admin")) + .thenReturn("ADMIN"); + UserEntity current = account("canonical-id", "bob", false); + Mockito.when(this.authorizationService.get(this.client, + "canonical-id")) + .thenReturn(current); + UserEntity update = new UserEntity(); + update.setName("mallory"); + + try { + controller.update("canonical-id", update); + org.junit.Assert.fail("Expected mismatched username rejection"); + } catch (ParameterizedException ignored) { + // Expected: path identity is authoritative. + } + + Mockito.verify(this.authorizationService, Mockito.never()) + .update(Mockito.any(), Mockito.any()); + } + + @Test + public void testMissingSuperadminFieldPreservesCurrentGrant() { + TestUserController controller = new TestUserController(this.client, + "admin"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "admin")) + .thenReturn("ADMIN"); + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn(true); + UserEntity current = account("canonical-id", "bob", true); + Mockito.when(this.authorizationService.get(this.client, + "canonical-id")) + .thenReturn(current); + UserEntity update = new UserEntity(); + + controller.update("canonical-id", update); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(UserEntity.class); + Mockito.verify(this.authorizationService) + .update(Mockito.eq(this.client), captor.capture()); + org.junit.Assert.assertTrue(captor.getValue().isSuperadmin()); + org.junit.Assert.assertEquals("bob", captor.getValue().getName()); + org.junit.Assert.assertEquals("canonical-id", + captor.getValue().getId()); + } + + @Test + public void testDeleteUsesFetchedCanonicalUserId() { + TestUserController controller = accountController("admin", "ADMIN"); + UserEntity current = account("canonical-id", "bob", false); + Mockito.when(this.authorizationService.get(this.client, "lookup-id")) + .thenReturn(current); + + controller.delete("lookup-id"); + + Mockito.verify(this.authorizationService) + .delete(this.client, "canonical-id"); + } + + @Test + public void testExplicitFalseSuperadminFieldRevokesCurrentGrant() { + TestUserController controller = new TestUserController(this.client, + "admin"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "admin")) + .thenReturn("ADMIN"); + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn(true); + UserEntity current = account("canonical-id", "bob", true); + Mockito.when(this.authorizationService.get(this.client, + "canonical-id")) + .thenReturn(current); + UserEntity update = new UserEntity(); + update.setSuperadmin(false); + + controller.update("canonical-id", update); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(UserEntity.class); + Mockito.verify(this.authorizationService) + .update(Mockito.eq(this.client), captor.capture()); + org.junit.Assert.assertFalse(captor.getValue().isSuperadmin()); + } + + @Test + public void testSuperadminPresenceTracksJsonField() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + + UserEntity omitted = mapper.readValue("{\"user_name\":\"bob\"}", + UserEntity.class); + UserEntity explicitFalse = mapper.readValue( + "{\"user_name\":\"bob\",\"is_superadmin\":false}", + UserEntity.class); + + org.junit.Assert.assertFalse(omitted.hasSuperadmin()); + org.junit.Assert.assertTrue(explicitFalse.hasSuperadmin()); + org.junit.Assert.assertFalse(explicitFalse.isSuperadmin()); + org.junit.Assert.assertFalse(mapper.writeValueAsString(explicitFalse) + .contains("superadminSpecified")); + } + + @Test + public void testSpaceAdminCannotModifyExistingSuperadmin() { + TestUserController controller = new TestUserController(this.client, + "manager"); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + "manager")) + .thenReturn("SPACEADMIN"); + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn(false); + UserEntity current = account("canonical-id", "admin", true); + Mockito.when(this.authorizationService.get(this.client, + "canonical-id")) + .thenReturn(current); + UserEntity update = new UserEntity(); + update.setName("admin"); + + ForbiddenException failure = assertForbidden( + () -> controller.update("canonical-id", update)); + + org.junit.Assert.assertTrue(failure.getMessage() + .contains("manage accounts")); + Mockito.verify(this.authorizationService, Mockito.never()) + .update(Mockito.any(), Mockito.any()); + } + + @Test + public void testSpaceAdminCannotUpdateSharedGlobalAccount() { + TestUserController controller = accountController("manager", + "SPACEADMIN"); + UserEntity current = account("bob-id", "bob", false); + current.setAdminSpaces(Collections.emptyList()); + current.setResSpaces(Arrays.asList("SPACE_A", "SPACE_B")); + Mockito.when(this.authorizationService.get(this.client, "bob-id")) + .thenReturn(current); + Mockito.when(this.authorizationService.listAdminSpace( + this.client, "manager")) + .thenReturn(Collections.singletonList("SPACE_A")); + + assertForbidden(() -> controller.update("bob-id", new UserEntity())); + + Mockito.verify(this.authorizationService, Mockito.never()) + .update(Mockito.any(), Mockito.any()); + } + + @Test + public void testSpaceAdminCannotUpdateOrDeleteAccountOutsideManagedSpace() { + TestUserController controller = accountController("manager", + "SPACEADMIN"); + UserEntity current = account("bob-id", "bob", false); + current.setAdminSpaces(Collections.emptyList()); + current.setResSpaces(Collections.singletonList("SPACE_B")); + Mockito.when(this.authorizationService.get(this.client, "bob-id")) + .thenReturn(current); + Mockito.when(this.authorizationService.listAdminSpace( + this.client, "manager")) + .thenReturn(Collections.singletonList("SPACE_A")); + + assertForbidden(() -> controller.update("bob-id", new UserEntity())); + assertForbidden(() -> controller.delete("bob-id")); + + Mockito.verify(this.authorizationService, Mockito.never()) + .update(Mockito.any(), Mockito.any()); + Mockito.verify(this.authorizationService, Mockito.never()) + .delete(Mockito.any(), Mockito.anyString()); + } + + @Test + public void testSpaceAdminCannotDeleteAfterAddingManagedMembership() { + TestUserController controller = accountController("manager", + "SPACEADMIN"); + UserEntity current = account("bob-id", "bob", false); + current.setAdminSpaces(Collections.emptyList()); + current.setResSpaces(Arrays.asList("SPACE_A", "SPACE_B")); + Mockito.when(this.authorizationService.get(this.client, "bob-id")) + .thenReturn(current); + Mockito.when(this.authorizationService.listAdminSpace( + this.client, "manager")) + .thenReturn(Collections.singletonList("SPACE_A")); + + assertForbidden(() -> controller.delete("bob-id")); + + Mockito.verify(this.authorizationService, Mockito.never()) + .delete(Mockito.any(), Mockito.anyString()); + } + + @Test + public void testOnlyGlobalAdminCanBatchCreateAccounts() { + MultipartFile file = Mockito.mock(MultipartFile.class); + TestUserController spaceAdmin = accountController("manager", + "SPACEADMIN"); + + assertForbidden(() -> spaceAdmin.createbatch(file)); + + TestUserController admin = accountController("admin", "ADMIN"); + admin.createbatch(file); + Mockito.verify(this.authorizationService).addbatch(this.client, file); + } + + @Test + public void testOrdinaryUserCannotAssignGraphSpaceMembership() { + TestGraphSpaceUserController controller = + new TestGraphSpaceUserController(this.client, "alice"); + GraphSpaceUserService memberService = + Mockito.mock(GraphSpaceUserService.class); + ReflectionTestUtils.setField(controller, "userService", memberService); + this.setBaseUserService(controller, this.authorizationService); + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn(false); + Mockito.when(this.authorizationService.isAssignSpaceAdmin( + this.client, "SPACE")) + .thenReturn(false); + UserView member = new UserView("bob", "bob", + Collections.emptyList()); + + ForbiddenException failure = assertForbidden( + () -> controller.create("SPACE", member)); + + org.junit.Assert.assertTrue(failure.getMessage() + .contains("graphspace members")); + Mockito.verifyZeroInteractions(memberService); + } + + @Test + public void testCurrentSpaceAdminCanAssignGraphSpaceMembership() { + TestGraphSpaceUserController controller = + new TestGraphSpaceUserController(this.client, "manager"); + GraphSpaceUserService memberService = + Mockito.mock(GraphSpaceUserService.class); + ReflectionTestUtils.setField(controller, "userService", memberService); + this.setBaseUserService(controller, this.authorizationService); + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn(false); + Mockito.when(this.authorizationService.isAssignSpaceAdmin( + this.client, "SPACE")) + .thenReturn(true); + UserView member = new UserView("bob", "bob", + Collections.emptyList()); + Mockito.when(memberService.createOrUpdate(this.client, "SPACE", + member)) + .thenReturn(member); + + org.junit.Assert.assertSame(member, + controller.create("SPACE", member)); + } + + @Test + public void testOrdinaryUserCannotChangeAnyGraphSpaceMembership() { + TestGraphSpaceUserController controller = + new TestGraphSpaceUserController(this.client, "alice"); + GraphSpaceUserService memberService = + Mockito.mock(GraphSpaceUserService.class); + ReflectionTestUtils.setField(controller, "userService", memberService); + this.setBaseUserService(controller, this.authorizationService); + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn(false); + Mockito.when(this.authorizationService.isAssignSpaceAdmin( + this.client, "SPACE")) + .thenReturn(false); + UserView member = new UserView("bob", "bob", + Collections.emptyList()); + + assertForbidden(() -> controller.createOrUpdate("SPACE", "bob", + member)); + assertForbidden(() -> controller.delete("SPACE", "bob")); + assertForbidden(() -> controller.setGraphSpaceAdmin("SPACE", "bob")); + assertForbidden(() -> controller.removeGraphSpaceAdmin("SPACE", + "bob")); + + Mockito.verifyZeroInteractions(memberService); + } + + private void setBaseUserService(BaseController controller, + UserService service) { + try { + Field field = BaseController.class.getDeclaredField("userService"); + field.setAccessible(true); + field.set(controller, service); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private TestUserController accountController(String username, + String level) { + TestUserController controller = new TestUserController(this.client, + username); + this.setBaseUserService(controller, this.authorizationService); + controller.userService = this.authorizationService; + Mockito.when(this.authorizationService.userLevel(this.client, + username)) + .thenReturn(level); + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn("ADMIN".equals(level)); + return controller; + } + + private static void assertGlobalAccountReadsForbidden( + TestUserController controller) { + assertForbidden(controller::list); + assertForbidden(() -> controller.queryPage("", 1, 10)); + assertForbidden(() -> controller.get("bob-id")); + assertForbidden(() -> controller.listadminspace("bob")); + } + + private static ForbiddenException assertForbidden(Action action) { + try { + action.run(); + org.junit.Assert.fail("Expected forbidden response"); + return null; + } catch (ForbiddenException e) { + return e; + } + } + + private static UserEntity account(String id, String name, + boolean superadmin) { + UserEntity account = new UserEntity(); + account.setId(id); + account.setName(name); + account.setSuperadmin(superadmin); + return account; + } + + @FunctionalInterface + private interface Action { + + void run(); + } + + private static class TestUserController extends UserController { + + private final HugeClient client; + private final String username; + + TestUserController(HugeClient client, String username) { + this.client = client; + this.username = username; + } + + @Override + protected HugeClient authClient(String graphSpace, String graph) { + return this.client; + } + + @Override + protected String getUser() { + return this.username; + } + } + + private static class TestGraphSpaceUserController + extends GraphSpaceUserController { + + private final HugeClient client; + private final String username; + + TestGraphSpaceUserController(HugeClient client, String username) { + this.client = client; + this.username = username; + } + + @Override + protected HugeClient authClient(String graphSpace, String graph) { + return this.client; + } + + @Override + protected String getUser() { + return this.username; + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java new file mode 100644 index 000000000..177b04147 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java @@ -0,0 +1,358 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.controller.auth; + +import java.lang.reflect.Field; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import org.apache.hugegraph.controller.BaseController; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.handler.ExceptionAdvisor; +import org.apache.hugegraph.service.auth.AccessService; +import org.apache.hugegraph.service.auth.BelongService; +import org.apache.hugegraph.service.auth.GraphSpaceUserService; +import org.apache.hugegraph.service.auth.RoleService; +import org.apache.hugegraph.service.auth.TargetService; +import org.apache.hugegraph.service.auth.UserService; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class GraphSpaceAuthMutationAuthorizationTest { + + private HugeClient client; + private UserService authorizationService; + + @Before + public void setup() { + this.client = Mockito.mock(HugeClient.class); + this.authorizationService = Mockito.mock(UserService.class); + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn(false); + Mockito.when(this.authorizationService.isAssignSpaceAdmin( + this.client, "SPACE")) + .thenReturn(false); + } + + @Test + public void testOrdinaryUserCannotMutateBelongs() throws Exception { + BelongController controller = this.prepare(new TestBelongController( + this.client), "belongService", Mockito.mock(BelongService.class)); + MockMvc mvc = mvc(controller); + + assertForbidden(mvc, post("/api/v1.3/graphspaces/SPACE/auth/belongs") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role_id\":\"r\",\"user_id\":\"u\"}")); + assertForbidden(mvc, + post("/api/v1.3/graphspaces/SPACE/auth/belongs/ids") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role_id\":\"r\",\"user_ids\":[\"u\"]}")); + assertForbidden(mvc, + delete("/api/v1.3/graphspaces/SPACE/auth/belongs/b")); + assertForbidden(mvc, + delete("/api/v1.3/graphspaces/SPACE/auth/belongs") + .param("role_id", "r").param("user_id", "u")); + assertForbidden(mvc, + post("/api/v1.3/graphspaces/SPACE/auth/belongs/delids") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"ids\":[\"b\"]}")); + } + + @Test + public void testOrdinaryUserCannotReadScopedAuthorizationResources() + throws Exception { + this.assertScopedReadsForbidden(); + } + + @Test + public void testOrdinaryUserCannotMutateRoles() throws Exception { + RoleController controller = this.prepare(new TestRoleController( + this.client), "roleService", Mockito.mock(RoleService.class)); + MockMvc mvc = mvc(controller); + + assertForbidden(mvc, post("/api/v1.3/graphspaces/SPACE/auth/roles") + .contentType(MediaType.APPLICATION_JSON).content("{}")); + assertForbidden(mvc, + put("/api/v1.3/graphspaces/SPACE/auth/roles/r") + .contentType(MediaType.APPLICATION_JSON).content("{}")); + assertForbidden(mvc, + delete("/api/v1.3/graphspaces/SPACE/auth/roles/r")); + } + + @Test + public void testOrdinaryUserCannotMutateAccesses() throws Exception { + AccessController controller = this.prepare(new TestAccessController( + this.client), "accessService", Mockito.mock(AccessService.class)); + MockMvc mvc = mvc(controller); + + assertForbidden(mvc, + post("/api/v1.3/graphspaces/SPACE/auth/accesses") + .contentType(MediaType.APPLICATION_JSON).content("{}")); + assertForbidden(mvc, + put("/api/v1.3/graphspaces/SPACE/auth/accesses") + .contentType(MediaType.APPLICATION_JSON).content("{}")); + assertForbidden(mvc, + delete("/api/v1.3/graphspaces/SPACE/auth/accesses") + .param("role_id", "r").param("target_id", "t")); + } + + @Test + public void testOrdinaryUserCannotMutateTargets() throws Exception { + TargetController controller = this.prepare(new TestTargetController( + this.client), "targetService", Mockito.mock(TargetService.class)); + MockMvc mvc = mvc(controller); + + assertForbidden(mvc, + post("/api/v1.3/graphspaces/SPACE/auth/targets") + .contentType(MediaType.APPLICATION_JSON).content("{}")); + assertForbidden(mvc, + put("/api/v1.3/graphspaces/SPACE/auth/targets/t") + .contentType(MediaType.APPLICATION_JSON).content("{}")); + assertForbidden(mvc, + delete("/api/v1.3/graphspaces/SPACE/auth/targets/t")); + } + + @Test + public void testCurrentSpaceAdminCanManageScopedAuthorizationResources() + throws Exception { + Mockito.when(this.authorizationService.isAssignSpaceAdmin( + this.client, "SPACE")) + .thenReturn(true); + Mockito.when(this.authorizationService.userLevel( + Mockito.eq(this.client), Mockito.any())) + .thenReturn("SPACEADMIN"); + + this.assertScopedReadsAllowed(); + this.assertScopedCreatesAllowed(); + } + + @Test + public void testSuperadminCanMutateEachResource() throws Exception { + Mockito.when(this.authorizationService.isSuperAdmin(this.client)) + .thenReturn(true); + Mockito.when(this.authorizationService.userLevel( + Mockito.eq(this.client), Mockito.any())) + .thenReturn("ADMIN"); + + this.assertScopedCreatesAllowed(); + RoleController role = this.prepare(new TestRoleController( + this.client), "roleService", Mockito.mock(RoleService.class)); + mvc(role).perform(post("/api/v1.3/graphspaces/SPACE/auth/roles") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()); + } + + @Test + public void testAnotherSpaceAdminCannotMutateCurrentSpace() + throws Exception { + Mockito.when(this.authorizationService.isAssignSpaceAdmin( + this.client, "OTHER")) + .thenReturn(true); + RoleController controller = this.prepare(new TestRoleController( + this.client), "roleService", Mockito.mock(RoleService.class)); + + assertForbidden(mvc(controller), + post("/api/v1.3/graphspaces/SPACE/auth/roles") + .contentType(MediaType.APPLICATION_JSON).content("{}")); + this.assertScopedReadsForbidden(); + } + + private void assertScopedReadsAllowed() throws Exception { + for (ReadRoute route : this.scopedReadRoutes()) { + mvc(route.controller).perform(route.request) + .andExpect(status().isOk()); + } + } + + private void assertScopedReadsForbidden() throws Exception { + for (ReadRoute route : this.scopedReadRoutes()) { + assertForbidden(mvc(route.controller), route.request); + } + } + + private ReadRoute[] scopedReadRoutes() { + return new ReadRoute[]{ + new ReadRoute(this.prepare(new TestBelongController( + this.client), "belongService", + Mockito.mock(BelongService.class)), + get("/api/v1.3/graphspaces/SPACE/auth/belongs")), + new ReadRoute(this.prepare(new TestRoleController( + this.client), "roleService", + Mockito.mock(RoleService.class)), + get("/api/v1.3/graphspaces/SPACE/auth/roles")), + new ReadRoute(this.prepare(new TestAccessController( + this.client), "accessService", + Mockito.mock(AccessService.class)), + get("/api/v1.3/graphspaces/SPACE/auth/accesses")), + new ReadRoute(this.prepare(new TestTargetController( + this.client), "targetService", + Mockito.mock(TargetService.class)), + get("/api/v1.3/graphspaces/SPACE/auth/targets")), + new ReadRoute(this.prepare(new TestGraphSpaceUserController( + this.client), "userService", + Mockito.mock(GraphSpaceUserService.class)), + get("/api/v1.3/graphspaces/SPACE/auth/users")) + }; + } + + private void assertScopedCreatesAllowed() throws Exception { + BelongController belong = this.prepare(new TestBelongController( + this.client), "belongService", Mockito.mock(BelongService.class)); + AccessController access = this.prepare(new TestAccessController( + this.client), "accessService", Mockito.mock(AccessService.class)); + TargetController target = this.prepare(new TestTargetController( + this.client), "targetService", Mockito.mock(TargetService.class)); + + mvc(belong).perform(post("/api/v1.3/graphspaces/SPACE/auth/belongs") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role_id\":\"r\",\"user_id\":\"u\"}")) + .andExpect(status().isOk()); + mvc(access).perform(post("/api/v1.3/graphspaces/SPACE/auth/accesses") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()); + mvc(target).perform(post("/api/v1.3/graphspaces/SPACE/auth/targets") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()); + } + + private T prepare(T controller, + String serviceField, + Object service) { + setBaseUserService(controller, this.authorizationService); + ReflectionTestUtils.setField(controller, serviceField, service); + return controller; + } + + private static MockMvc mvc(Object controller) { + return MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new ExceptionAdvisor()) + .build(); + } + + private static void assertForbidden(MockMvc mvc, RequestBuilder request) + throws Exception { + mvc.perform(request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value(403)); + } + + private static void setBaseUserService(BaseController controller, + UserService service) { + try { + Field field = BaseController.class.getDeclaredField("userService"); + field.setAccessible(true); + field.set(controller, service); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private static class TestBelongController extends BelongController { + + private final HugeClient client; + + TestBelongController(HugeClient client) { + this.client = client; + } + + @Override + protected HugeClient authClient(String graphSpace, String graph) { + return this.client; + } + } + + private static class TestRoleController extends RoleController { + + private final HugeClient client; + + TestRoleController(HugeClient client) { + this.client = client; + } + + @Override + protected HugeClient authClient(String graphSpace, String graph) { + return this.client; + } + } + + private static class TestAccessController extends AccessController { + + private final HugeClient client; + + TestAccessController(HugeClient client) { + this.client = client; + } + + @Override + protected HugeClient authClient(String graphSpace, String graph) { + return this.client; + } + } + + private static class TestTargetController extends TargetController { + + private final HugeClient client; + + TestTargetController(HugeClient client) { + this.client = client; + } + + @Override + protected HugeClient authClient(String graphSpace, String graph) { + return this.client; + } + } + + private static class TestGraphSpaceUserController + extends GraphSpaceUserController { + + private final HugeClient client; + + TestGraphSpaceUserController(HugeClient client) { + this.client = client; + } + + @Override + protected HugeClient authClient(String graphSpace, String graph) { + return this.client; + } + } + + private static class ReadRoute { + + private final Object controller; + private final RequestBuilder request; + + ReadRoute(Object controller, RequestBuilder request) { + this.controller = controller; + this.request = request; + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthOwnershipTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthOwnershipTest.java new file mode 100644 index 000000000..4516f1286 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthOwnershipTest.java @@ -0,0 +1,646 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.controller.auth; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import org.apache.hugegraph.controller.BaseController; +import org.apache.hugegraph.driver.AuthManager; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.auth.AccessEntity; +import org.apache.hugegraph.entity.auth.BelongEntity; +import org.apache.hugegraph.entity.auth.RoleEntity; +import org.apache.hugegraph.entity.auth.UserEntity; +import org.apache.hugegraph.entity.auth.UserView; +import org.apache.hugegraph.exception.ForbiddenException; +import org.apache.hugegraph.service.auth.AccessService; +import org.apache.hugegraph.service.auth.BelongService; +import org.apache.hugegraph.service.auth.GraphSpaceUserService; +import org.apache.hugegraph.service.auth.RoleService; +import org.apache.hugegraph.service.auth.TargetService; +import org.apache.hugegraph.service.auth.UserService; +import org.apache.hugegraph.structure.auth.Access; +import org.apache.hugegraph.structure.auth.Belong; +import org.apache.hugegraph.structure.auth.Group; +import org.apache.hugegraph.structure.auth.HugePermission; +import org.apache.hugegraph.structure.auth.Role; +import org.apache.hugegraph.structure.auth.Target; +import org.apache.hugegraph.structure.auth.User; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class GraphSpaceAuthOwnershipTest { + + private HugeClient client; + private AuthManager auth; + + @Before + public void setup() { + this.client = Mockito.mock(HugeClient.class); + this.auth = Mockito.mock(AuthManager.class); + Mockito.when(this.client.auth()).thenReturn(this.auth); + } + + @Test + public void testScopedRolesPersistOwnershipAndHideInternalMarker() { + Mockito.when(this.auth.createGraphSpaceGroup( + Mockito.any(Group.class))) + .thenAnswer(invocation -> { + Group group = invocation.getArgument(0); + group.setId("role-id"); + return group; + }); + Role request = new Role(); + request.name("operators"); + request.description("Operate this space"); + + Role created = new RoleService().insert(this.client, "SPACE_A", + request); + + ArgumentCaptor persisted = ArgumentCaptor.forClass(Group.class); + Mockito.verify(this.auth).createGraphSpaceGroup(persisted.capture()); + Assert.assertNotEquals("operators", persisted.getValue().name()); + Assert.assertTrue(persisted.getValue().name() + .startsWith("~hubble_role:v1:")); + Assert.assertEquals("SPACE_A", created.graphSpace()); + Assert.assertEquals("operators", created.name()); + Assert.assertEquals("Operate this space", created.description()); + } + + @Test + public void testScopedRoleListsFilterForeignAndLegacyGroups() { + List created = this.createScopedGroups("SPACE_A", "SPACE_B"); + Group legacy = group("legacy-id", "legacy-role"); + Mockito.when(this.auth.listGraphSpaceGroups()) + .thenReturn(Arrays.asList(created.get(0), created.get(1), + legacy)); + Mockito.when(this.auth.listGroups()) + .thenReturn(Arrays.asList(created.get(0), created.get(1), + legacy)); + RoleService service = new RoleService(); + + List scoped = service.list(this.client, "SPACE_A", false); + List superadmin = service.list(this.client, "SPACE_A", true); + + Assert.assertEquals(1, scoped.size()); + Assert.assertEquals("SPACE_A", scoped.get(0).graphSpace()); + Assert.assertEquals(2, superadmin.size()); + Assert.assertNull(superadmin.get(1).graphSpace()); + } + + @Test + public void testScopedRoleGetRejectsForeignAndLegacyForSpaceAdmin() { + List created = this.createScopedGroups("SPACE_A", "SPACE_B"); + Mockito.when(this.auth.getGraphSpaceGroup("foreign-id")) + .thenReturn(created.get(1)); + Mockito.when(this.auth.getGraphSpaceGroup("legacy-id")) + .thenReturn(group("legacy-id", "legacy-role")); + Mockito.when(this.auth.getGroup("legacy-id")) + .thenReturn(group("legacy-id", "legacy-role")); + RoleService service = new RoleService(); + + assertForbidden(() -> service.get(this.client, "SPACE_A", + "foreign-id", false)); + assertForbidden(() -> service.get(this.client, "SPACE_A", + "legacy-id", false)); + Assert.assertNull(service.get(this.client, "SPACE_A", "legacy-id", + true).graphSpace()); + } + + @Test + public void testScopedRoleUpdateKeepsImmutableGroupName() { + Group group = this.createScopedGroups("SPACE_A").get(0); + Mockito.when(this.auth.getGraphSpaceGroup("role-id")) + .thenReturn(group); + Mockito.when(this.auth.updateGraphSpaceGroup( + Mockito.any(Group.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + Role update = new Role(); + update.setId("role-id"); + update.name("renamed"); + update.description("updated"); + String persistedName = group.name(); + + Role result = new RoleService().update(this.client, "SPACE_A", + update, false); + + Assert.assertEquals(persistedName, group.name()); + Assert.assertEquals("renamed", result.name()); + Assert.assertEquals("updated", result.description()); + } + + @Test + public void testScopedRoleDeletePreflightsEveryReference() { + Group group = this.createScopedGroups("SPACE_A").get(0); + Mockito.when(this.auth.getGraphSpaceGroup("role-id")) + .thenReturn(group); + Access local = access("local-access", "SPACE_A"); + Access foreign = access("foreign-access", "SPACE_B"); + Mockito.when(this.auth.listAccessesByGroup(group, -1)) + .thenReturn(Arrays.asList(local, foreign)); + Mockito.when(this.auth.listBelongsByGroup(group, -1)) + .thenReturn(Collections.emptyList()); + + assertForbidden(() -> new RoleService().delete( + this.client, "SPACE_A", "role-id", false)); + + Mockito.verify(this.auth, Mockito.never()).deleteAccess( + Mockito.anyString()); + Mockito.verify(this.auth, Mockito.never()).deleteGraphSpaceGroup( + Mockito.anyString()); + } + + @Test + public void testAccessAndBelongRejectForeignRoleBeforeMutation() { + Group foreign = this.createScopedGroups("SPACE_B").get(0); + Mockito.when(this.auth.getGraphSpaceGroup("foreign-role")) + .thenReturn(foreign); + Target target = target("target-id", "SPACE_A"); + TargetService targets = Mockito.mock(TargetService.class); + Mockito.when(targets.get(this.client, "SPACE_A", "target-id")) + .thenReturn(target); + AccessService accessService = new AccessService(); + ReflectionTestUtils.setField(accessService, "targetService", targets); + AccessEntity access = new AccessEntity(); + access.setRoleId("foreign-role"); + access.setTargetId("target-id"); + access.setPermissions(Collections.singleton(HugePermission.READ)); + + assertForbidden(() -> accessService.addOrUpdate( + this.client, "SPACE_A", access)); + assertForbidden(() -> new BelongService().add( + this.client, "SPACE_A", "foreign-role", "user-id")); + + Mockito.verify(this.auth, Mockito.never()).createAccess( + Mockito.any(Access.class)); + Mockito.verify(this.auth, Mockito.never()).createBelong( + Mockito.any(Belong.class)); + } + + @Test + public void testTargetRejectsMismatchedResponseGraphSpace() { + Target target = target("target-id", "SPACE_B"); + Mockito.when(this.auth.getTarget("target-id")).thenReturn(target); + + assertForbidden(() -> new TargetService().get( + this.client, "SPACE_A", "target-id")); + } + + @Test + public void testTargetCreateUsesCanonicalPathGraphSpace() { + Target request = target(null, null); + Mockito.when(this.auth.createTarget(Mockito.any(Target.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + Target created = new TargetService().add(this.client, "SPACE_A", + request); + + ArgumentCaptor persisted = ArgumentCaptor.forClass( + Target.class); + Mockito.verify(this.auth).createTarget(persisted.capture()); + Assert.assertEquals("SPACE_A", persisted.getValue().graphSpace()); + Assert.assertEquals("SPACE_A", created.graphSpace()); + } + + @Test + public void testTargetCreateRejectsMismatchedBodyGraphSpace() { + Target request = target(null, "SPACE_B"); + + assertForbidden(() -> new TargetService().add( + this.client, "SPACE_A", request)); + + Mockito.verify(this.auth, Mockito.never()) + .createTarget(Mockito.any(Target.class)); + } + + @Test + public void testScopedGetsRejectNullGraphSpaceOwnership() { + Target legacyTarget = target("target-id", null); + Mockito.when(this.auth.getTarget("target-id")) + .thenReturn(legacyTarget); + assertForbidden(() -> new TargetService().get( + this.client, "SPACE_A", "target-id")); + + Access legacyAccess = access("access-id", null); + Mockito.when(this.auth.getAccess("access-id")) + .thenReturn(legacyAccess); + AccessService accessService = accessService(legacyTarget); + assertForbidden(() -> accessService.get( + this.client, "SPACE_A", "access-id")); + + Belong legacyBelong = new Belong(); + legacyBelong.setId("belong-id"); + Mockito.when(this.auth.getBelong("belong-id")) + .thenReturn(legacyBelong); + assertForbidden(() -> new BelongService().get( + this.client, "SPACE_A", "belong-id")); + } + + @Test + public void testScopedListsFilterMismatchedGraphSpace() { + Target target = target("target-id", "SPACE_B"); + Mockito.when(this.auth.listTargets()) + .thenReturn(Collections.singletonList(target)); + + Access access = new Access(); + access.graphSpace("SPACE_B"); + Mockito.when(this.auth.listAccessesByGroup(null, -1)) + .thenReturn(Collections.singletonList(access)); + + Belong belong = new Belong(); + belong.graphSpace("SPACE_B"); + Mockito.when(this.auth.listBelongs()) + .thenReturn(Collections.singletonList(belong)); + + Assert.assertTrue(new TargetService().list(this.client, "SPACE_A") + .isEmpty()); + Assert.assertTrue(new AccessService().list(this.client, "SPACE_A", + null, null).isEmpty()); + Assert.assertTrue(new BelongService().list(this.client, "SPACE_A", + null, null).isEmpty()); + } + + @Test + public void testScopedListsHideNullGraphSpaceOwnership() { + Target legacyTarget = target("target-id", null); + Mockito.when(this.auth.listTargets()) + .thenReturn(Collections.singletonList(legacyTarget)); + + Access legacyAccess = access("access-id", null); + Mockito.when(this.auth.listAccessesByGroup(null, -1)) + .thenReturn(Collections.singletonList(legacyAccess)); + AccessService accessService = accessService(legacyTarget); + + Belong legacyBelong = new Belong(); + legacyBelong.setId("belong-id"); + legacyBelong.graphSpace(null); + legacyBelong.group("group-id"); + legacyBelong.user("user-id"); + Mockito.when(this.auth.listBelongs()) + .thenReturn(Collections.singletonList(legacyBelong)); + BelongService belongService = new BelongService(); + UserService users = Mockito.mock(UserService.class); + UserEntity user = new UserEntity(); + user.setId("user-id"); + user.setName("user"); + Mockito.when(users.getUser(this.client, "user-id")).thenReturn(user); + ReflectionTestUtils.setField(belongService, "userService", users); + + Assert.assertTrue(new TargetService().list(this.client, "SPACE_A") + .isEmpty()); + Assert.assertTrue(accessService.list(this.client, "SPACE_A", + null, null).isEmpty()); + Assert.assertTrue(belongService.list(this.client, "SPACE_A", + null, null).isEmpty()); + } + + @Test + public void testScopedListsIgnorePdDefaultAuthorizationRecords() { + Target defaultTarget = target("DEFAULT_SPACE_TARGET", "SPACE_A"); + defaultTarget.name("DEFAULT_SPACE_TARGET"); + Mockito.when(this.auth.listTargets()) + .thenReturn(Collections.singletonList(defaultTarget)); + Mockito.when(this.auth.getTarget("DEFAULT_SPACE_TARGET")) + .thenReturn(defaultTarget); + + Access defaultAccess = access("default-access", "SPACE_A"); + defaultAccess.group("space_member"); + defaultAccess.target("DEFAULT_SPACE_TARGET"); + Mockito.when(this.auth.listAccessesByGroup(null, -1)) + .thenReturn(Collections.singletonList(defaultAccess)); + + Belong defaultBelong = new Belong(); + defaultBelong.setId("default-belong"); + defaultBelong.graphSpace("SPACE_A"); + defaultBelong.user("user-id"); + defaultBelong.role("space_member"); + Mockito.when(this.auth.listBelongs()) + .thenReturn(Collections.singletonList(defaultBelong)); + + TargetService targetService = new TargetService(); + Assert.assertTrue(targetService.list(this.client, "SPACE_A") + .isEmpty()); + assertForbidden(() -> targetService.get( + this.client, "SPACE_A", "DEFAULT_SPACE_TARGET")); + assertForbidden(() -> targetService.delete( + this.client, "SPACE_A", "DEFAULT_SPACE_TARGET")); + Mockito.verify(this.auth, Mockito.never()).deleteTarget( + "DEFAULT_SPACE_TARGET"); + Assert.assertTrue(new AccessService().list(this.client, "SPACE_A", + null, null).isEmpty()); + Assert.assertTrue(new BelongService().list(this.client, "SPACE_A", + null, null).isEmpty()); + Mockito.verify(this.auth, Mockito.never()).getGraphSpaceGroup( + Mockito.anyString()); + } + + @Test + public void testAccessCreatePersistsAndReturnsGraphSpace() { + Target scopedTarget = target("target-id", "SPACE_A"); + Access created = access("access-id", "SPACE_A"); + Mockito.when(this.auth.listAccessesByGroup("group-id", -1)) + .thenReturn(Collections.emptyList()) + .thenReturn(Collections.singletonList(created)); + Mockito.when(this.auth.createAccess(Mockito.any(Access.class))) + .thenReturn(created); + AccessService service = accessService(scopedTarget); + TargetService targets = (TargetService) ReflectionTestUtils.getField( + service, "targetService"); + Mockito.when(targets.get(this.client, "SPACE_A", "target-id")) + .thenReturn(scopedTarget); + AccessEntity request = new AccessEntity(); + request.setRoleId("group-id"); + request.setTargetId("target-id"); + request.setPermissions(Collections.singleton(HugePermission.READ)); + + AccessEntity result = service.addOrUpdate(this.client, "SPACE_A", + request); + + ArgumentCaptor persisted = ArgumentCaptor.forClass( + Access.class); + Mockito.verify(this.auth).createAccess(persisted.capture()); + Assert.assertEquals("SPACE_A", persisted.getValue().graphSpace()); + Assert.assertEquals("SPACE_A", result.getGraphSpace()); + } + + @Test + public void testAccessRejectsMismatchedResponseGraphSpace() { + Access access = new Access(); + access.setId("access-id"); + access.graphSpace("SPACE_B"); + access.group("group-id"); + access.target("target-id"); + Mockito.when(this.auth.getAccess("access-id")).thenReturn(access); + Group group = new Group(); + group.setId("group-id"); + Mockito.when(this.auth.getGroup("group-id")).thenReturn(group); + AccessService service = new AccessService(); + TargetService targets = Mockito.mock(TargetService.class); + Mockito.when(targets.get(this.client, "target-id")) + .thenReturn(target("target-id", "SPACE_B")); + ReflectionTestUtils.setField(service, "targetService", targets); + + assertForbidden(() -> service.get(this.client, "SPACE_A", + "access-id")); + } + + @Test + public void testBelongRejectsMismatchedResponseGraphSpace() { + Belong belong = new Belong(); + belong.setId("belong-id"); + belong.graphSpace("SPACE_B"); + Mockito.when(this.auth.getBelong("belong-id")).thenReturn(belong); + BelongService service = new BelongService(); + + assertForbidden(() -> service.get(this.client, "SPACE_A", + "belong-id")); + } + + @Test + public void testBelongBatchDeleteValidatesAllBeforeMutation() { + Belong first = new Belong(); + first.setId("first"); + first.graphSpace("SPACE_A"); + Belong second = new Belong(); + second.setId("second"); + second.graphSpace("SPACE_B"); + Mockito.when(this.auth.getBelong("first")).thenReturn(first); + Mockito.when(this.auth.getBelong("second")).thenReturn(second); + BelongService service = new BelongService(); + + assertForbidden(() -> service.deleteMany( + this.client, "SPACE_A", new String[]{"first", "second"})); + + Mockito.verify(this.auth, Mockito.never()).deleteBelong( + Mockito.anyString()); + } + + @Test + public void testGraphSpaceUserRemovalDeletesOnlyScopedBelongs() { + BelongService belongs = Mockito.mock(BelongService.class); + User account = new User(); + account.name("graph-user"); + Mockito.when(this.auth.getUser("user-id")).thenReturn(account); + BelongEntity scoped = BelongEntity.builder() + .id("belong-a") + .userId("user-id") + .build(); + Mockito.when(belongs.list(this.client, "SPACE_A", null, "user-id")) + .thenReturn(Collections.singletonList(scoped)); + GraphSpaceUserService service = new GraphSpaceUserService(); + ReflectionTestUtils.setField(service, "belongService", belongs); + + service.unauthUser(this.client, "SPACE_A", "user-id"); + + Mockito.verify(belongs).deleteById(this.client, "SPACE_A", + "belong-a"); + Mockito.verify(belongs, Mockito.never()).delete( + Mockito.eq(this.client), Mockito.anyString()); + Mockito.verify(this.auth).delSpaceMember("graph-user", "SPACE_A"); + } + + @Test + public void testGraphSpaceUserRoleUpdatePreflightsAllRoles() { + List groups = this.createScopedGroups("SPACE_A", "SPACE_B"); + Mockito.when(this.auth.getGraphSpaceGroup("local-role")) + .thenReturn(groups.get(0)); + Mockito.when(this.auth.getGraphSpaceGroup("foreign-role")) + .thenReturn(groups.get(1)); + BelongService belongs = Mockito.mock(BelongService.class); + GraphSpaceUserService service = new GraphSpaceUserService(); + ReflectionTestUtils.setField(service, "belongService", belongs); + UserView user = new UserView( + "user-id", "user", + Arrays.asList(new RoleEntity("local-role", "local"), + new RoleEntity("foreign-role", "foreign"))); + + assertForbidden(() -> service.createOrUpdate( + this.client, "SPACE_A", user)); + + Mockito.verifyZeroInteractions(belongs); + Mockito.verify(this.auth, Mockito.never()) + .addSpaceMember(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void testGraphSpaceUserCreationAddsPdMemberBeforeBelong() { + Group group = this.createScopedGroups("SPACE_A").get(0); + Mockito.when(this.auth.getGraphSpaceGroup("local-role")) + .thenReturn(group); + User account = new User(); + account.name("graph-user"); + Mockito.when(this.auth.getUser("user-id")).thenReturn(account); + Mockito.when(this.auth.listSpaceMember("SPACE_A")) + .thenReturn(Collections.emptyList()); + BelongService belongs = Mockito.mock(BelongService.class); + Mockito.when(belongs.list(this.client, "SPACE_A", null, "user-id")) + .thenReturn(Collections.emptyList()); + GraphSpaceUserService service = new GraphSpaceUserService(); + ReflectionTestUtils.setField(service, "belongService", belongs); + UserView user = new UserView( + "user-id", "graph-user", + Collections.singletonList(new RoleEntity("local-role", + "local"))); + + service.createOrUpdate(this.client, "SPACE_A", user); + + InOrder order = Mockito.inOrder(this.auth, belongs); + order.verify(this.auth).addSpaceMember("graph-user", "SPACE_A"); + order.verify(belongs).add(this.client, "SPACE_A", "local-role", + "user-id"); + } + + @Test + public void testSpaceAdminAssignmentUsesPostOnly() throws Exception { + UserService authorization = Mockito.mock(UserService.class); + Mockito.when(authorization.isAssignSpaceAdmin(this.client, "SPACE")) + .thenReturn(true); + TestGraphSpaceUserController controller = + new TestGraphSpaceUserController(this.client); + setBaseUserService(controller, authorization); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .build(); + + mvc.perform(get("/api/v1.3/graphspaces/SPACE/auth/users/" + + "spaceadmin/user-id")) + .andExpect(status().isMethodNotAllowed()); + mvc.perform(put("/api/v1.3/graphspaces/SPACE/auth/users/" + + "spaceadmin/user-id") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isMethodNotAllowed()); + mvc.perform(post("/api/v1.3/graphspaces/SPACE/auth/users/" + + "spaceadmin/user-id") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + private static Target target(String id, String graphSpace) { + Target target = new Target(); + target.setId(id); + target.graphSpace(graphSpace); + return target; + } + + private static Access access(String id, String graphSpace) { + Access access = new Access(); + access.setId(id); + access.graphSpace(graphSpace); + access.group("group-id"); + access.target("target-id"); + access.permission(HugePermission.READ); + return access; + } + + private AccessService accessService(Target target) { + Group group = target.graphSpace() == null ? + group("group-id", "group") : + this.createScopedGroups(target.graphSpace()).get(0); + group.setId("group-id"); + Mockito.when(this.auth.getGraphSpaceGroup("group-id")) + .thenReturn(group); + TargetService targets = Mockito.mock(TargetService.class); + Mockito.when(targets.get(this.client, "target-id")) + .thenReturn(target); + AccessService service = new AccessService(); + ReflectionTestUtils.setField(service, "targetService", targets); + return service; + } + + private List createScopedGroups(String... graphSpaces) { + java.util.ArrayList groups = new java.util.ArrayList<>(); + Mockito.when(this.auth.createGraphSpaceGroup( + Mockito.any(Group.class))) + .thenAnswer(invocation -> { + Group group = invocation.getArgument(0); + group.setId("role-id"); + groups.add(group); + return group; + }); + for (String graphSpace : graphSpaces) { + Role role = new Role(); + role.name("role-" + graphSpace); + new RoleService().insert(this.client, graphSpace, role); + } + return groups; + } + + private static Group group(String id, String name) { + Group group = new Group(); + group.setId(id); + group.name(name); + return group; + } + + private static void setBaseUserService(BaseController controller, + UserService service) { + try { + Field field = BaseController.class.getDeclaredField("userService"); + field.setAccessible(true); + field.set(controller, service); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private static void assertForbidden(Action action) { + try { + action.run(); + Assert.fail("Expected forbidden response"); + } catch (ForbiddenException ignored) { + // Expected. + } + } + + @FunctionalInterface + private interface Action { + + void run(); + } + + private static class TestGraphSpaceUserController + extends GraphSpaceUserController { + + private final HugeClient client; + + TestGraphSpaceUserController(HugeClient client) { + this.client = client; + } + + @Override + protected HugeClient authClient(String graphSpace, String graph) { + return this.client; + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/ingest/IngestControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/ingest/IngestControllerTest.java index e055a71ed..79a03d224 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/ingest/IngestControllerTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/ingest/IngestControllerTest.java @@ -48,6 +48,7 @@ import org.apache.hugegraph.entity.load.FileMapping; import org.apache.hugegraph.entity.load.JobManager; import org.apache.hugegraph.entity.load.LoadTask; +import org.apache.hugegraph.exception.ExternalException; import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.service.load.DatasourceService; import org.apache.hugegraph.service.load.FileMappingService; @@ -93,26 +94,29 @@ public void testCreateFileTaskSavesMappingAndStartsLoader() Mockito.when(datasourceService.get(1)).thenReturn(datasource); Mockito.when(fileMappingService.requirePathUnderUploadRoot( dataFile.toString())).thenReturn(dataFile.toFile()); - Mockito.doAnswer(invocation -> { - invocation.getArgument(0, JobManager.class).setId(7); - return null; - }).when(jobService).save(Mockito.any(JobManager.class)); - Mockito.doAnswer(invocation -> { - invocation.getArgument(0, FileMapping.class).setId(8); - return null; - }).when(fileMappingService).save(Mockito.any(FileMapping.class)); - Mockito.when(loadTaskService.start(Mockito.any(GraphConnection.class), - Mockito.any(FileMapping.class), - Mockito.any(HugeClient.class))) - .thenReturn(LoadTask.builder().id(9).build()); - this.bindRequestSession(); + Mockito.when(jobService.createIngestTask( + Mockito.any(JobManager.class), Mockito.any(FileMapping.class), + Mockito.any(GraphConnection.class), Mockito.any(HugeClient.class))) + .thenAnswer(invocation -> { + JobManager job = invocation.getArgument(0); + FileMapping mapping = invocation.getArgument(1); + job.setId(7); + mapping.setJobId(7); + mapping.setId(8); + return LoadTask.builder().id(9).build(); + }); + this.bindRequestSession("alice"); Response response = controller.createTask(this.request(dataFile)); Assert.assertEquals(Constant.STATUS_OK, response.getStatus()); + ArgumentCaptor jobCaptor = + ArgumentCaptor.forClass(JobManager.class); ArgumentCaptor mappingCaptor = ArgumentCaptor.forClass(FileMapping.class); - Mockito.verify(fileMappingService).save(mappingCaptor.capture()); + Mockito.verify(jobService).createIngestTask( + jobCaptor.capture(), mappingCaptor.capture(), + Mockito.any(GraphConnection.class), Mockito.any(HugeClient.class)); FileMapping mapping = mappingCaptor.getValue(); Assert.assertEquals(7, mapping.getJobId().intValue()); Assert.assertEquals(FileMappingStatus.COMPLETED, @@ -122,16 +126,71 @@ public void testCreateFileTaskSavesMappingAndStartsLoader() Assert.assertEquals(Collections.singletonList("name"), mapping.getVertexMappings().iterator().next() .getIdFields()); - Mockito.verify(loadTaskService).start(Mockito.any(GraphConnection.class), - Mockito.eq(mapping), - Mockito.any(HugeClient.class)); - ArgumentCaptor jobCaptor = - ArgumentCaptor.forClass(JobManager.class); - Mockito.verify(jobService).update(jobCaptor.capture()); Assert.assertEquals(JobStatus.LOADING, jobCaptor.getValue() .getJobStatus()); } + @Test + public void testCreateFileTaskRejectsEmptyMappingBeforePersistence() + throws Exception { + Path uploadRoot = Files.createTempDirectory("hubble-ingest-empty"); + Path dataFile = uploadRoot.resolve("data.csv"); + Files.write(dataFile, Collections.singletonList("marko")); + + TestIngestController controller = new TestIngestController(); + DatasourceService datasourceService = Mockito.mock(DatasourceService.class); + JobManagerService jobService = Mockito.mock(JobManagerService.class); + FileMappingService fileMappingService = Mockito.mock(FileMappingService.class); + this.setField(controller, "config", this.mockConfig(uploadRoot)); + this.setField(controller, "datasourceService", datasourceService); + this.setField(controller, "jobManagerService", jobService); + this.setField(controller, "fileMappingService", fileMappingService); + + Datasource datasource = new Datasource(); + datasource.setId(1); + Map datasourceConfig = new HashMap<>(); + datasourceConfig.put("type", "FILE"); + datasourceConfig.put("path", dataFile.toString()); + datasourceConfig.put("format", "CSV"); + datasourceConfig.put("header", Collections.singletonList("name")); + datasource.setDatasourceConfig(datasourceConfig); + Mockito.when(datasourceService.get(1)).thenReturn(datasource); + Mockito.when(fileMappingService.requirePathUnderUploadRoot( + dataFile.toString())).thenReturn(dataFile.toFile()); + + IngestController.IngestTaskRequest request = this.request(dataFile); + IngestController.IngestStruct struct = + request.ingestionMapping.structs.get(0); + struct.vertices = Collections.emptyList(); + + Assert.assertThrows(ExternalException.class, () -> { + controller.createTask(request); + }); + Mockito.verify(jobService, Mockito.never()).createIngestTask( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()); + } + + @Test + public void testOrdinaryAuthenticatedUserCanCreateDatasource() + throws Exception { + IngestController controller = new IngestController(); + DatasourceService datasourceService = + Mockito.mock(DatasourceService.class); + this.setField(controller, "datasourceService", datasourceService); + Datasource datasource = new Datasource(); + datasource.setDatasourceName("alice-file"); + Mockito.doAnswer(invocation -> { + invocation.getArgument(0, Datasource.class).setId(1); + return null; + }).when(datasourceService).save(Mockito.any(Datasource.class)); + this.bindRequestSession("alice"); + + Response response = controller.datasourceCreate(datasource); + + Assert.assertEquals(Constant.STATUS_OK, response.getStatus()); + Mockito.verify(datasourceService).save(datasource); + } + @Test public void testProxyServletSkipsPlaceholderTarget() throws Exception { ProxyServletConfiguration proxy = new ProxyServletConfiguration(); @@ -216,13 +275,10 @@ private HugeConfig mockConfig(Path uploadRoot) { return config; } - private void bindRequestSession() { + private void bindRequestSession(String username) { MockHttpServletRequest request = new MockHttpServletRequest(); request.getSession().setAttribute(Constant.TOKEN_KEY, "token"); - request.getSession().setAttribute(Constant.USERNAME_KEY, "admin"); - request.getSession().setAttribute(Constant.CREDENTIAL_PASSWORD_KEY, "pa"); - request.getSession().setAttribute(Constant.CREDENTIAL_EXPIRES_AT_KEY, - System.currentTimeMillis() + 10000L); + request.getSession().setAttribute(Constant.USERNAME_KEY, username); RequestContextHolder.setRequestAttributes( new ServletRequestAttributes(request)); } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java new file mode 100644 index 000000000..cc256318b --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java @@ -0,0 +1,246 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.controller.space; + +import java.lang.reflect.Field; +import java.util.Map; + +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.util.ReflectionTestUtils; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.controller.BaseController; +import org.apache.hugegraph.driver.AuthManager; +import org.apache.hugegraph.driver.GraphSpaceManager; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.space.BuiltInEntity; +import org.apache.hugegraph.entity.space.GraphSpaceEntity; +import org.apache.hugegraph.exception.ForbiddenException; +import org.apache.hugegraph.handler.ExceptionAdvisor; +import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.auth.UserService; +import org.apache.hugegraph.service.graphs.GraphsService; +import org.apache.hugegraph.service.space.GraphSpaceService; +import org.apache.hugegraph.structure.space.GraphSpace; +import org.apache.hugegraph.testutil.Assert; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class GraphSpaceControllerTest { + + @Test + public void testStandaloneDetailNeverContainsDataPlaneSecrets() { + GraphSpaceController controller = new GraphSpaceController(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(false); + controller.config = config; + ReflectionTestUtils.setField(controller, "graphSpaceService", + new GraphSpaceService()); + + @SuppressWarnings("unchecked") + Map detail = + (Map) controller.get("DEFAULT"); + + Assert.assertEquals("DEFAULT", detail.get("name")); + Assert.assertFalse(detail.containsKey("dp_username")); + Assert.assertFalse(detail.containsKey("dp_password")); + Assert.assertFalse(detail.containsKey("configs")); + } + + @Test + public void testPdDetailResponseNeverContainsDataPlaneSecrets() { + HugeClient client = Mockito.mock(HugeClient.class); + GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class); + AuthManager auth = Mockito.mock(AuthManager.class); + GraphsService graphsService = Mockito.mock(GraphsService.class); + UserService userService = Mockito.mock(UserService.class); + GraphSpace graphSpace = new GraphSpace("public"); + graphSpace.setDpUserName("dp-user"); + graphSpace.setDpPassWord("dp-secret"); + graphSpace.setConfigs(new java.util.HashMap<>()); + Mockito.when(client.graphSpace()).thenReturn(manager); + Mockito.when(client.auth()).thenReturn(auth); + Mockito.when(manager.getGraphSpace("public")).thenReturn(graphSpace); + Mockito.when(auth.isSuperAdmin()).thenReturn(false); + Mockito.when(graphsService.listGraphNames(client, "public", "")) + .thenReturn(java.util.Collections.emptySet()); + + GraphSpaceService service = new GraphSpaceService(); + ReflectionTestUtils.setField(service, "graphsService", graphsService); + ReflectionTestUtils.setField(service, "userService", userService); + TestGraphSpaceController controller = + new TestGraphSpaceController(client); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + controller.config = config; + ReflectionTestUtils.setField(controller, "graphSpaceService", service); + + @SuppressWarnings("unchecked") + Map detail = + (Map) controller.get("public"); + + Assert.assertEquals("public", detail.get("name")); + Assert.assertFalse(detail.containsKey("dp_username")); + Assert.assertFalse(detail.containsKey("dp_password")); + Assert.assertFalse(detail.containsKey("configs")); + } + + @Test + public void testApplyDefaultsForOptionalResourceLimits() { + GraphSpaceEntity graphSpace = new GraphSpaceEntity(); + + GraphSpaceController.applyResourceDefaults(graphSpace); + + Assert.assertEquals(100, graphSpace.getMaxGraphNumber()); + Assert.assertEquals(100, graphSpace.getMaxRoleNumber()); + Assert.assertEquals(64, graphSpace.getCpuLimit()); + Assert.assertEquals(128, graphSpace.getMemoryLimit()); + Assert.assertEquals(64, graphSpace.getComputeCpuLimit()); + Assert.assertEquals(128, graphSpace.getComputeMemoryLimit()); + Assert.assertEquals(1000000, graphSpace.getStorageLimit()); + } + + @Test + public void testApplyDefaultsPreservesExplicitResourceLimits() { + GraphSpaceEntity graphSpace = new GraphSpaceEntity(); + graphSpace.setMaxGraphNumber(2); + graphSpace.setMaxRoleNumber(3); + graphSpace.setCpuLimit(4); + graphSpace.setMemoryLimit(5); + graphSpace.setComputeCpuLimit(6); + graphSpace.setComputeMemoryLimit(7); + graphSpace.setStorageLimit(8); + + GraphSpaceController.applyResourceDefaults(graphSpace); + + Assert.assertEquals(2, graphSpace.getMaxGraphNumber()); + Assert.assertEquals(3, graphSpace.getMaxRoleNumber()); + Assert.assertEquals(4, graphSpace.getCpuLimit()); + Assert.assertEquals(5, graphSpace.getMemoryLimit()); + Assert.assertEquals(6, graphSpace.getComputeCpuLimit()); + Assert.assertEquals(7, graphSpace.getComputeMemoryLimit()); + Assert.assertEquals(8, graphSpace.getStorageLimit()); + } + + @Test + public void testOnlySuperadminCanMutateGraphSpaces() throws Exception { + HugeClient client = Mockito.mock(HugeClient.class); + UserService userService = Mockito.mock(UserService.class); + GraphSpaceService graphSpaceService = Mockito.mock( + GraphSpaceService.class); + TestGraphSpaceController controller = controller( + client, userService, + graphSpaceService); + Mockito.when(userService.isSuperAdmin(client)).thenReturn(false); + + assertForbidden(() -> controller.add(new GraphSpaceEntity())); + assertForbidden(() -> controller.update("foreign", + new GraphSpaceEntity())); + assertForbidden(() -> controller.delete("foreign")); + assertForbidden(() -> controller.initBuiltIn(new BuiltInEntity())); + + Mockito.verifyZeroInteractions(graphSpaceService); + Mockito.when(userService.isSuperAdmin(client)).thenReturn(true); + Assert.assertSame(client, controller.requireGlobalManager()); + } + + @Test + public void testForbiddenGraphSpaceMutationUsesHttpAndBody403() + throws Exception { + HugeClient client = Mockito.mock(HugeClient.class); + UserService userService = Mockito.mock(UserService.class); + GraphSpaceService graphSpaceService = Mockito.mock( + GraphSpaceService.class); + TestGraphSpaceController controller = controller( + client, userService, + graphSpaceService); + Mockito.when(userService.isSuperAdmin(client)).thenReturn(false); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice( + new ExceptionAdvisor()) + .build(); + + mvc.perform(post("/api/v1.3/graphspaces") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value(403)); + + Mockito.verifyZeroInteractions(graphSpaceService); + } + + private static TestGraphSpaceController controller( + HugeClient client, UserService userService, + GraphSpaceService graphSpaceService) throws Exception { + TestGraphSpaceController controller = + new TestGraphSpaceController(client); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + controller.config = config; + ReflectionTestUtils.setField(controller, "graphSpaceService", + graphSpaceService); + ReflectionTestUtils.setField(controller, "userService", userService); + Field baseUserService = BaseController.class.getDeclaredField( + "userService"); + baseUserService.setAccessible(true); + baseUserService.set(controller, userService); + return controller; + } + + private static ForbiddenException assertForbidden(Action action) { + try { + action.run(); + Assert.fail("Expected ForbiddenException"); + return null; + } catch (ForbiddenException e) { + return e; + } + } + + private static class TestGraphSpaceController + extends GraphSpaceController { + + private final HugeClient client; + + TestGraphSpaceController(HugeClient client) { + this.client = client; + } + + @Override + protected HugeClient authClient(String graphSpace, String graph) { + return this.client; + } + + private HugeClient requireGlobalManager() { + return this.requireGraphSpaceAdministrator(); + } + } + + @FunctionalInterface + private interface Action { + + void run(); + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/handler/ExceptionAdvisorStatusTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/handler/ExceptionAdvisorStatusTest.java new file mode 100644 index 000000000..b7a0b7541 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/handler/ExceptionAdvisorStatusTest.java @@ -0,0 +1,238 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.handler; + +import org.apache.hugegraph.common.Constant; +import org.apache.hugegraph.common.Response; +import org.apache.hugegraph.exception.ServerException; +import org.apache.hugegraph.service.op.OperationsNodeNotFoundException; +import org.apache.hugegraph.testutil.Assert; +import org.junit.After; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +public class ExceptionAdvisorStatusTest { + + @After + public void tearDown() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + public void testPreserveServerAuthenticationAndPermissionStatus() { + Assert.assertEquals(HttpStatus.UNAUTHORIZED.value(), + ExceptionAdvisor.serverStatus(401)); + Assert.assertEquals(HttpStatus.FORBIDDEN.value(), + ExceptionAdvisor.serverStatus(403)); + Assert.assertEquals(Constant.STATUS_BAD_REQUEST, + ExceptionAdvisor.serverStatus(500)); + } + + @Test + public void testOperationsMissingNodeIsAQuietNotFound() { + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes( + new MockHttpServletRequest())); + + Response response = new ExceptionAdvisor().exceptionHandler( + new OperationsNodeNotFoundException()); + + Assert.assertEquals(HttpStatus.NOT_FOUND.value(), + response.getStatus()); + Assert.assertEquals("operations_node_not_found", + response.getMessage()); + Assert.assertNull(response.getCause()); + } + + @Test + public void testServerConnectionFailureDoesNotExposeAddress() { + operationsRequest(); + ServerException failure = new ServerException( + "Failed to connect to /127.0.0.1:8080/private"); + failure.status(HttpStatus.INTERNAL_SERVER_ERROR.value()); + + Response response = advisor().exceptionHandler(failure); + + Assert.assertEquals(Constant.STATUS_BAD_REQUEST, + response.getStatus()); + Assert.assertEquals("upstream_unavailable", response.getMessage()); + Assert.assertFalse(response.getMessage().contains("127.0.0.1")); + Assert.assertFalse(response.getMessage().contains("8080")); + Assert.assertFalse(response.getMessage().contains("private")); + } + + @Test + public void testServerAuthenticationFailureKeepsStatusWithoutBody() { + operationsRequest(); + ServerException failure = new ServerException( + "Authentication failed secret-canary"); + failure.status(HttpStatus.UNAUTHORIZED.value()); + + Response response = advisor().exceptionHandler(failure); + + Assert.assertEquals(HttpStatus.UNAUTHORIZED.value(), + response.getStatus()); + Assert.assertEquals("upstream_unauthorized", response.getMessage()); + Assert.assertFalse(response.getMessage().contains("secret-canary")); + } + + @Test + public void testServerPermissionFailureKeepsStatusWithoutBody() { + operationsRequest(); + ServerException failure = new ServerException( + "Forbidden http://user:secret@host/private"); + failure.status(HttpStatus.FORBIDDEN.value()); + + Response response = advisor().exceptionHandler(failure); + + Assert.assertEquals(HttpStatus.FORBIDDEN.value(), response.getStatus()); + Assert.assertEquals("upstream_forbidden", response.getMessage()); + Assert.assertFalse(response.getMessage().contains("secret")); + Assert.assertFalse(response.getMessage().contains("private")); + } + + @Test + public void testUnexpectedFailureUsesStableSafeMessage() { + operationsRequest(); + RuntimeException failure = new RuntimeException( + "http://user:secret@127.0.0.1:8080/private secret-canary"); + + Response response = advisor().exceptionHandler(failure); + + Assert.assertEquals(Constant.STATUS_BAD_REQUEST, response.getStatus()); + Assert.assertEquals("unexpected_request_failure", + response.getMessage()); + Assert.assertFalse(response.getMessage().contains("127.0.0.1")); + Assert.assertFalse(response.getMessage().contains("secret-canary")); + } + + @Test + public void testNonOperationsBusinessErrorPreservesSafeMessage() { + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes( + new MockHttpServletRequest("GET", "/api/v1.3/graphs"))); + ServerException failure = new ServerException("graph_not_found"); + failure.status(HttpStatus.NOT_FOUND.value()); + + Response response = advisor().exceptionHandler(failure); + + Assert.assertEquals("graph_not_found", response.getMessage()); + } + + @Test + public void testDiagnosticStackTraceRedactsCredentialsAndEndpoints() { + RuntimeException failure = new RuntimeException( + "http://user:secret@host/private?token=abc&password=xyz&" + + "endpoint=10.0.0.1:8080"); + + String diagnostic = ExceptionAdvisor.sanitize(failure.toString()); + + Assert.assertFalse(diagnostic.contains("secret")); + Assert.assertFalse(diagnostic.contains("abc")); + Assert.assertFalse(diagnostic.contains("xyz")); + Assert.assertFalse(diagnostic.contains("10.0.0.1")); + Assert.assertTrue(diagnostic.contains("[REDACTED]")); + } + + @Test + public void testDiagnosticRedactsNestedStructuredSecrets() { + RuntimeException failure = new RuntimeException( + "Authorization: Basic YmFzaWMtY2FuYXJ5 " + + "{\"apiKey\":\"json-canary\"," + + "\"secretKey\":\"secret-key-canary\"," + + "\"client_secret\":\"client-secret-canary\"," + + "\"secret\":\"secret-canary\"}", + new IllegalStateException( + "token=token-canary credential=credential-canary")); + failure.addSuppressed(new IllegalArgumentException( + "password: password-canary, api_key=key-canary, " + + "endpoint: endpoint-canary")); + + String diagnostic = ExceptionAdvisor.sanitize( + failure + " " + failure.getCause() + " " + + failure.getSuppressed()[0]); + + Assert.assertFalse(diagnostic.contains("YmFzaWMtY2FuYXJ5")); + Assert.assertFalse(diagnostic.contains("json-canary")); + Assert.assertFalse(diagnostic.contains("secret-canary")); + Assert.assertFalse(diagnostic.contains("secret-key-canary")); + Assert.assertFalse(diagnostic.contains("client-secret-canary")); + Assert.assertFalse(diagnostic.contains("token-canary")); + Assert.assertFalse(diagnostic.contains("credential-canary")); + Assert.assertFalse(diagnostic.contains("password-canary")); + Assert.assertFalse(diagnostic.contains("key-canary")); + Assert.assertFalse(diagnostic.contains("endpoint-canary")); + Assert.assertTrue(diagnostic.contains("[REDACTED]")); + } + + @Test + public void testDiagnosticRedactsHeadersPrivateKeysAndAbsolutePaths() { + RuntimeException failure = new RuntimeException( + "Cookie: session=cookie-canary\n" + + "Set-Cookie: auth=set-cookie-canary; HttpOnly\n" + + "-----BEGIN PRIVATE KEY-----\nprivate-key-canary\n" + + "-----END PRIVATE KEY-----\n" + + "failed at /Users/operator/private/config.yaml and " + + "C:\\Users\\operator\\private\\config.yaml"); + + String diagnostic = ExceptionAdvisor.sanitize(failure.getMessage()); + + Assert.assertFalse(diagnostic.contains("cookie-canary")); + Assert.assertFalse(diagnostic.contains("set-cookie-canary")); + Assert.assertFalse(diagnostic.contains("private-key-canary")); + Assert.assertFalse(diagnostic.contains("/Users/operator")); + Assert.assertFalse(diagnostic.contains("C:\\Users\\operator")); + Assert.assertTrue(diagnostic.contains("[REDACTED]")); + } + + @Test + public void testNonOperationsResponseRedactsStructuredSecrets() { + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes( + new MockHttpServletRequest("GET", "/api/v1.3/graphs"))); + ServerException failure = new ServerException( + "request failed {\"token\":\"json-token-canary\"} " + + "Cookie: session=cookie-canary\n" + + "at /Users/operator/private/config.yaml"); + failure.status(HttpStatus.INTERNAL_SERVER_ERROR.value()); + + Response response = advisor().exceptionHandler(failure); + + Assert.assertFalse(response.getMessage().contains("json-token-canary")); + Assert.assertFalse(response.getMessage().contains("cookie-canary")); + Assert.assertFalse(response.getMessage().contains("/Users/operator")); + } + + private static void operationsRequest() { + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes( + new MockHttpServletRequest("GET", "/api/v1.3/operations/nodes"))); + } + + private static ExceptionAdvisor advisor() { + MessageSourceHandler messages = Mockito.mock( + MessageSourceHandler.class); + Mockito.when(messages.getMessage(Mockito.anyString(), + Mockito.nullable(Object[].class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + ExceptionAdvisor advisor = new ExceptionAdvisor(); + ReflectionTestUtils.setField(advisor, "messageSourceHandler", messages); + return advisor; + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/handler/ResponseAdvisorStatusTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/handler/ResponseAdvisorStatusTest.java new file mode 100644 index 000000000..ca7da731b --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/handler/ResponseAdvisorStatusTest.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.handler; + +import org.apache.hugegraph.common.Constant; +import org.apache.hugegraph.common.Response; +import org.apache.hugegraph.testutil.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +public class ResponseAdvisorStatusTest { + + @Test + public void testErrorResponseSetsMatchingHttpStatus() { + MockHttpServletResponse servletResponse = write( + Response.builder() + .status(HttpStatus.NOT_FOUND.value()) + .message("not found") + .build()); + + Assert.assertEquals(HttpStatus.NOT_FOUND.value(), + servletResponse.getStatus()); + } + + @Test + public void testCustomErrorResponseUsesBadRequestHttpStatus() { + MockHttpServletResponse servletResponse = write( + Response.builder() + .status(Constant.STATUS_ILLEGAL_GREMLIN) + .message("illegal gremlin") + .build()); + + Assert.assertEquals(HttpStatus.BAD_REQUEST.value(), + servletResponse.getStatus()); + } + + @Test + public void testSuccessResponseKeepsSuccessfulHttpStatus() { + MockHttpServletResponse servletResponse = write( + Response.builder() + .status(HttpStatus.OK.value()) + .data("ok") + .build()); + + Assert.assertEquals(HttpStatus.OK.value(), servletResponse.getStatus()); + } + + @Test + public void testExternalExceptionCannotExposeSuccessfulStatus() { + Assert.assertEquals(HttpStatus.BAD_REQUEST.value(), + ExceptionAdvisor.errorStatus( + HttpStatus.CREATED.value())); + Assert.assertEquals(HttpStatus.BAD_GATEWAY.value(), + ExceptionAdvisor.errorStatus( + HttpStatus.BAD_GATEWAY.value())); + } + + @SuppressWarnings("unchecked") + private static MockHttpServletResponse write(Response body) { + ResponseAdvisor advisor = new ResponseAdvisor(); + MockHttpServletRequest servletRequest = new MockHttpServletRequest(); + MockHttpServletResponse servletResponse = new MockHttpServletResponse(); + advisor.beforeBodyWrite( + body, + Mockito.mock(MethodParameter.class), + MediaType.APPLICATION_JSON, + (Class>) (Class) + HttpMessageConverter.class, + new ServletServerHttpRequest(servletRequest), + new ServletServerHttpResponse(servletResponse)); + return servletResponse; + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java new file mode 100644 index 000000000..d5a68defb --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java @@ -0,0 +1,200 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.auth; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.Test; +import org.mockito.Mockito; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.auth.UserEntity; +import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.testutil.Assert; + +public class AuthContextServiceTest { + + @Test + public void testPdSuperAdminGetsGlobalActionsAndOperations() throws Exception { + Fixture fixture = new Fixture(true); + UserEntity user = user(true, Arrays.asList("space-b", "space-a")); + user.setPassword("password-canary"); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user); + + Map context = fixture.service.context(fixture.client, + "alice"); + + Assert.assertEquals(1, context.get("schema_version")); + Assert.assertEquals("PD", context.get("mode")); + Assert.assertEquals("SUPERADMIN", context.get("role")); + Assert.assertEquals("alice", context.get("username")); + Assert.assertTrue(capabilities(context).contains("accounts_manage")); + Assert.assertTrue(capabilities(context).contains("graphspaces_manage")); + Assert.assertTrue(capabilities(context).contains( + "operations_metrics_read")); + Assert.assertTrue(actions(context, "accounts").contains("delete")); + Assert.assertTrue((Boolean) scopes(context).get("all_graphspaces")); + Assert.assertTrue(((String) context.get("context_version")) + .matches("[0-9a-f]{16}")); + + String json = new ObjectMapper().writeValueAsString(context); + Assert.assertFalse(json.contains("password-canary")); + Assert.assertFalse(json.contains("\"password\":")); + Assert.assertFalse(json.contains("\"token\":")); + Assert.assertFalse(json.contains("\"authorization\":")); + Assert.assertFalse(json.contains("\"cookie\":")); + } + + @Test + public void testPdSpaceAdminOnlyGetsScopedManagementActions() { + Fixture fixture = new Fixture(true); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user(false, + Arrays.asList("space-b", "space-a", "space-a"))); + + Map context = fixture.service.context(fixture.client, + "alice"); + + Assert.assertEquals("SPACEADMIN", context.get("role")); + Assert.assertFalse(capabilities(context).contains("accounts_manage")); + Assert.assertFalse(capabilities(context).contains( + "operations_health_read")); + Assert.assertTrue(capabilities(context).contains( + "graphspace_members_manage")); + Assert.assertTrue(actions(context, "members").contains("add")); + Assert.assertTrue(actions(context, "roles").contains("update")); + Assert.assertTrue(actions(context, "authorizations").contains("grant")); + Assert.assertEquals(Arrays.asList("space-a", "space-b"), + scopes(context).get("admin_graphspaces")); + Assert.assertFalse((Boolean) scopes(context).get("all_graphspaces")); + } + + @Test + public void testPdUserOnlyGetsSelfActions() { + Fixture fixture = new Fixture(true); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user(false, Collections.emptyList())); + + Map context = fixture.service.context(fixture.client, + "alice"); + + Assert.assertEquals("USER", context.get("role")); + Assert.assertEquals(Set.of("account_self_manage", + "graph_resources_access", + "graphspaces_read"), + capabilities(context)); + Assert.assertEquals(Set.of("read", "update", "change_password"), + actions(context, "account")); + Assert.assertTrue(actions(context, "accounts").isEmpty()); + Assert.assertTrue(actions(context, "members").isEmpty()); + } + + @Test + public void testNonPdAdminMapsToCanonicalSuperAdminWithoutPdActions() { + Fixture fixture = new Fixture(false); + Mockito.when(fixture.users.userLevel(fixture.client, "admin")) + .thenReturn("ADMIN"); + + Map context = fixture.service.context(fixture.client, + "admin"); + + Assert.assertEquals("NON_PD", context.get("mode")); + Assert.assertEquals("SUPERADMIN", context.get("role")); + Assert.assertTrue(capabilities(context).contains("accounts_manage")); + Assert.assertTrue(capabilities(context).contains( + "operations_topology_read")); + Assert.assertFalse(capabilities(context).contains( + "graphspaces_manage")); + Assert.assertTrue(actions(context, "graphspaces").isEmpty()); + Assert.assertFalse((Boolean) scopes(context).get("all_graphspaces")); + Mockito.verify(fixture.users, Mockito.never()) + .getpersonal(Mockito.any(), Mockito.anyString()); + } + + @Test + public void testContextVersionIsStableButChangesWithScope() { + Fixture fixture = new Fixture(true); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user(false, Arrays.asList("space-b", "space-a"))); + String first = (String) fixture.service.context(fixture.client, "alice") + .get("context_version"); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user(false, Arrays.asList("space-a", "space-b"))); + String reordered = (String) fixture.service.context(fixture.client, + "alice") + .get("context_version"); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user(false, Collections.singletonList("space-a"))); + String downgraded = (String) fixture.service.context(fixture.client, + "alice") + .get("context_version"); + + Assert.assertEquals(first, reordered); + Assert.assertNotEquals(first, downgraded); + } + + private static UserEntity user(boolean superadmin, + List adminSpaces) { + UserEntity user = new UserEntity(); + user.setName("alice"); + user.setSuperadmin(superadmin); + user.setAdminSpaces(adminSpaces); + return user; + } + + @SuppressWarnings("unchecked") + private static Set capabilities(Map context) { + return (Set) context.get("capabilities"); + } + + @SuppressWarnings("unchecked") + private static Set actions(Map context, + String resource) { + Map> actions = + (Map>) context.get("actions"); + return actions.get(resource); + } + + @SuppressWarnings("unchecked") + private static Map scopes(Map context) { + return (Map) context.get("scopes"); + } + + private static class Fixture { + + private final HugeClient client = Mockito.mock(HugeClient.class); + private final HugeConfig config = Mockito.mock(HugeConfig.class); + private final UserService users = Mockito.mock(UserService.class); + private final AuthContextService service; + + private Fixture(boolean pdEnabled) { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)) + .thenReturn(pdEnabled); + this.service = new AuthContextService(this.config, this.users); + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/UserServiceLevelTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/UserServiceLevelTest.java new file mode 100644 index 000000000..ea1a6c58c --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/UserServiceLevelTest.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.service.auth; + +import org.apache.hugegraph.testutil.Assert; +import org.junit.Test; + +public class UserServiceLevelTest { + + @Test + public void testStandaloneUserLevelDoesNotPromoteEveryUser() { + Assert.assertEquals("ADMIN", UserService.standaloneUserLevel("admin")); + Assert.assertEquals("USER", + UserService.standaloneUserLevel("ux3_viewer")); + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/load/IngestTransactionIntegrationTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/load/IngestTransactionIntegrationTest.java new file mode 100644 index 000000000..1a57d89a4 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/load/IngestTransactionIntegrationTest.java @@ -0,0 +1,363 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.load; + +import java.util.Collections; +import java.util.Date; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.sql.DataSource; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.GraphConnection; +import org.apache.hugegraph.entity.enums.FileMappingStatus; +import org.apache.hugegraph.entity.enums.JobStatus; +import org.apache.hugegraph.entity.enums.LoadStatus; +import org.apache.hugegraph.entity.load.FileMapping; +import org.apache.hugegraph.entity.load.FileSetting; +import org.apache.hugegraph.entity.load.JobManager; +import org.apache.hugegraph.entity.load.ListFormat; +import org.apache.hugegraph.entity.load.LoadParameter; +import org.apache.hugegraph.entity.load.LoadTask; +import org.apache.hugegraph.handler.LoadTaskExecutor; +import org.apache.hugegraph.loader.executor.LoadOptions; +import org.apache.hugegraph.service.schema.EdgeLabelService; +import org.apache.hugegraph.service.schema.VertexLabelService; +import org.apache.hugegraph.testutil.Assert; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = IngestTransactionIntegrationTest.TestConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.datasource.url=jdbc:h2:mem:ingest-tx;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.schema=" + + "classpath:database/ingest-transaction-schema.sql", + "spring.datasource.initialization-mode=always", + "spring.datasource.hikari.maximum-pool-size=2", + "spring.autoconfigure.exclude=" + + "org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration", + "mybatis.configuration.map-underscore-to-camel-case=true", + "mybatis.configuration.use-generated-keys=true", + "mybatis-plus.type-enums-package=org.apache.hugegraph.entity.enums" + }) +public class IngestTransactionIntegrationTest { + + @Autowired + private JobManagerService jobService; + @Autowired + private TestLoadTaskService taskService; + @Autowired + private RecordingLoadTaskExecutor executor; + @Autowired + private DataSource dataSource; + @MockBean + private VertexLabelService vertexLabelService; + @MockBean + private EdgeLabelService edgeLabelService; + + private JdbcTemplate jdbc; + + @Before + public void setup() { + this.jdbc = new JdbcTemplate(this.dataSource); + this.cleanup(); + this.executor.reset(); + this.taskService.failTaskInsert(false); + } + + @After + public void teardown() { + this.cleanup(); + } + + @Test + public void testMappingInsertFailureRollsBackAllRowsAndDoesNotDispatch() { + FileMapping mapping = this.mapping(); + mapping.setGraphSpace(null); + + Assert.assertThrows(RuntimeException.class, () -> { + this.jobService.createIngestTask(this.job(), mapping, + this.connection(), this.client()); + }); + + this.assertTableCounts(0, 0, 0); + Assert.assertEquals(0, this.executor.executions()); + } + + @Test + public void testTaskInsertFailureRollsBackAllRowsAndDoesNotDispatch() { + this.taskService.failTaskInsert(true); + + Assert.assertThrows(RuntimeException.class, () -> { + this.jobService.createIngestTask(this.job(), this.mapping(), + this.connection(), this.client()); + }); + + this.assertTableCounts(0, 0, 0); + Assert.assertEquals(0, this.executor.executions()); + } + + @Test + public void testCommitDispatchesExactlyOnce() { + LoadTask task = this.jobService.createIngestTask( + this.job(), this.mapping(), this.connection(), this.client()); + + this.assertTableCounts(1, 1, 1); + Assert.assertEquals(1, this.executor.executions()); + Assert.assertEquals(LoadStatus.RUNNING, this.taskService.get(task.getId()) + .getStatus()); + Assert.assertEquals(JobStatus.LOADING, + this.jobService.get(task.getJobId()).getJobStatus()); + } + + @Test + public void testServerTokenIsNotPersistedInLoadOptions() { + GraphConnection connection = this.connection(); + connection.setToken("canary-server-token"); + + LoadTask task = this.jobService.createIngestTask( + this.job(), this.mapping(), connection, this.client()); + + String options = this.jdbc.queryForObject( + "SELECT options FROM load_task WHERE id = ?", + String.class, task.getId()); + Assert.assertFalse(options.contains("canary-server-token")); + Assert.assertEquals("canary-server-token", task.getOptions().token); + } + + @Test + public void testCredentialsAreNotPersistedWhenLoadTaskIsUpdated() { + LoadTask task = this.jobService.createIngestTask( + this.job(), this.mapping(), this.connection(), this.client()); + task.getOptions().password = "canary-password"; + task.getOptions().token = "canary-server-token"; + task.getOptions().pdToken = "canary-pd-token"; + task.getOptions().trustStoreToken = "canary-truststore-token"; + + this.taskService.update(task); + + String options = this.jdbc.queryForObject( + "SELECT options FROM load_task WHERE id = ?", + String.class, task.getId()); + Assert.assertFalse(options.contains("canary-")); + Assert.assertEquals("canary-server-token", task.getOptions().token); + } + + @Test + public void testRejectedDispatchCommitsFailedTaskAndJobWithoutThrowing() { + this.executor.reject(true); + + LoadTask task = this.jobService.createIngestTask( + this.job(), this.mapping(), this.connection(), this.client()); + + this.assertTableCounts(1, 1, 1); + Assert.assertEquals(1, this.executor.executions()); + Assert.assertEquals(LoadStatus.FAILED, this.taskService.get(task.getId()) + .getStatus()); + Assert.assertEquals(JobStatus.FAILED, + this.jobService.get(task.getJobId()).getJobStatus()); + } + + private JobManager job() { + Date now = new Date(); + return JobManager.builder() + .graphSpace("DEFAULT") + .graph("hugegraph") + .jobName("tx-job") + .jobStatus(JobStatus.LOADING) + .createTime(now) + .updateTime(now) + .build(); + } + + private FileMapping mapping() { + FileMapping mapping = new FileMapping(); + mapping.setGraphSpace("DEFAULT"); + mapping.setGraph("hugegraph"); + mapping.setName("people.csv"); + mapping.setPath("/tmp/people.csv"); + mapping.setTotalLines(1L); + mapping.setTotalSize(8L); + mapping.setFileStatus(FileMappingStatus.COMPLETED); + mapping.setFileSetting(FileSetting.builder() + .columnNames(Collections.singletonList("name")) + .format("CSV") + .delimiter(",") + .charset("UTF-8") + .dateFormat("yyyy-MM-dd HH:mm:ss") + .timeZone("GMT+8") + .skippedLine("(^#|^//).*") + .listFormat(new ListFormat()) + .build()); + mapping.setVertexMappings(Collections.emptySet()); + mapping.setEdgeMappings(Collections.emptySet()); + mapping.setLoadParameter(new LoadParameter()); + mapping.setCreateTime(new Date()); + mapping.setUpdateTime(new Date()); + return mapping; + } + + private GraphConnection connection() { + GraphConnection connection = new GraphConnection(); + connection.setGraphSpace("DEFAULT"); + connection.setGraph("hugegraph"); + return connection; + } + + private HugeClient client() { + return Mockito.mock(HugeClient.class); + } + + private void cleanup() { + JdbcTemplate template = this.jdbc == null ? + new JdbcTemplate(this.dataSource) : this.jdbc; + template.update("DELETE FROM load_task"); + template.update("DELETE FROM file_mapping"); + template.update("DELETE FROM job_manager"); + } + + private void assertTableCounts(int jobs, int mappings, int tasks) { + Assert.assertEquals(jobs, this.count("job_manager")); + Assert.assertEquals(mappings, this.count("file_mapping")); + Assert.assertEquals(tasks, this.count("load_task")); + } + + private int count(String table) { + return this.jdbc.queryForObject("SELECT COUNT(*) FROM " + table, + Integer.class); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EnableTransactionManagement + @MapperScan("org.apache.hugegraph.mapper.load") + public static class TestConfiguration { + + @Bean + public JobManagerService jobManagerService() { + return new JobManagerService(); + } + + @Bean + public TestLoadTaskService loadTaskService() { + return new TestLoadTaskService(); + } + + @Bean + public FileMappingService fileMappingService() { + return new FileMappingService(); + } + + @Bean + public RecordingLoadTaskExecutor loadTaskExecutor() { + return new RecordingLoadTaskExecutor(); + } + + @Bean + public HugeConfig hugeConfig() { + return Mockito.mock(HugeConfig.class); + } + + } + + public static class TestLoadTaskService extends LoadTaskService { + + private boolean failTaskInsert; + + public void failTaskInsert(boolean fail) { + this.failTaskInsert = fail; + } + + @Override + public LoadTask start(GraphConnection connection, FileMapping mapping, + HugeClient client) { + String graph = this.failTaskInsert ? null : connection.getGraph(); + LoadOptions options = new LoadOptions(); + options.token = connection.getToken(); + LoadTask task = LoadTask.builder() + .graphSpace(connection.getGraphSpace()) + .graph(graph) + .jobId(mapping.getJobId()) + .fileId(mapping.getId()) + .fileName(mapping.getName()) + .options(options) + .vertices(Collections.emptySet()) + .edges(Collections.emptySet()) + .fileTotalLines(mapping.getTotalLines()) + .status(LoadStatus.RUNNING) + .fileReadLines(0L) + .lastDuration(0L) + .currDuration(0L) + .createTime(new Date()) + .build(); + this.save(task); + this.executeAfterCommit(task); + return task; + } + } + + public static class RecordingLoadTaskExecutor extends LoadTaskExecutor { + + private final AtomicInteger executions = new AtomicInteger(); + private volatile boolean reject; + + @Override + public void execute(LoadTask task, Runnable callback) { + this.executions.incrementAndGet(); + if (this.reject) { + throw new org.springframework.core.task.TaskRejectedException( + "test executor queue is full"); + } + } + + public int executions() { + return this.executions.get(); + } + + public void reject(boolean reject) { + this.reject = reject; + } + + public void reset() { + this.executions.set(0); + this.reject = false; + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java new file mode 100644 index 000000000..393b6c27b --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java @@ -0,0 +1,524 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import com.google.common.cache.Cache; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.service.op.OperationsModels.Node; +import org.apache.hugegraph.service.op.OperationsModels.MetricStatus; +import org.apache.hugegraph.service.op.OperationsModels.Snapshot; +import org.apache.hugegraph.service.op.OperationsModels.SourceStatus; +import org.apache.hugegraph.testutil.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class DefaultOperationsDataServiceTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.ofEpochMilli(2000L), ZoneOffset.UTC); + + @Test + public void testSpaceAdminOverviewContainsOnlyRedactedHealth() { + DefaultOperationsDataService service = service(snapshot(), 5); + HugeClient client = client("token-a"); + + Map overview = service.overview(client, + Set.of(OperationsCapabilityService.HEALTH_READ), false); + + Assert.assertFalse(overview.containsKey("nodes")); + Assert.assertFalse(overview.containsKey("facts")); + Assert.assertTrue(overview.containsKey("sources")); + } + + @Test + public void testAdminNodesAreFilteredAndPaginated() { + DefaultOperationsDataService service = service(snapshot(), 5); + HugeClient client = client("token-a"); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ, + OperationsCapabilityService.TOPOLOGY_READ, + OperationsCapabilityService.METRICS_READ); + + Map result = service.nodes(client, capabilities, + "STORE", "UP", "store", + 1, 1, "name", "asc"); + + Assert.assertEquals(2, result.get("total")); + Assert.assertEquals(1, ((java.util.List) result.get("items")).size()); + } + + @Test + public void testNodesUseStableGlobalSortingBeforePagination() { + DefaultOperationsDataService service = service(snapshot(), 5); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ, + OperationsCapabilityService.TOPOLOGY_READ); + + Map first = service.nodes(client("token-a"), capabilities, + null, null, null, 1, 1, + "name", "desc"); + Map second = service.nodes(client("token-a"), capabilities, + null, null, null, 2, 1, + "name", "desc"); + + @SuppressWarnings("unchecked") + java.util.List firstItems = + (java.util.List) first.get("items"); + @SuppressWarnings("unchecked") + java.util.List secondItems = + (java.util.List) second.get("items"); + Assert.assertTrue(firstItems.get(0).getName().compareToIgnoreCase( + secondItems.get(0).getName()) >= 0); + } + + @Test + public void testCacheIsIsolatedByCurrentServerCredential() { + AtomicInteger calls = new AtomicInteger(); + OperationsCollector collector = (client, metrics) -> { + calls.incrementAndGet(); + return snapshot(); + }; + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ); + + service.overview(client("token-a"), capabilities, false); + service.overview(client("token-a"), capabilities, false); + service.overview(client("token-b"), capabilities, false); + + Assert.assertEquals(2, calls.get()); + } + + @Test + public void testCacheEvictsEntriesBeyondDefaultBound() { + AtomicInteger calls = new AtomicInteger(); + OperationsCollector collector = (client, metrics) -> { + calls.incrementAndGet(); + return snapshot(); + }; + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ); + + for (int i = 0; i <= 1024; i++) { + service.overview(client("token-" + i), capabilities, false); + } + @SuppressWarnings("unchecked") + Cache cache = (Cache) ReflectionTestUtils.getField( + service, "cache"); + cache.cleanUp(); + + Assert.assertTrue(cache.size() <= 1024L); + Assert.assertEquals(1025, calls.get()); + } + + @Test + public void testCollectionFailureDoesNotLeaveInFlightEntry() { + AtomicInteger calls = new AtomicInteger(); + OperationsCollector collector = (client, metrics) -> { + if (calls.getAndIncrement() == 0) { + throw new UpstreamRequestException("upstream_timeout"); + } + return snapshot(); + }; + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ); + + Assert.assertThrows(UpstreamRequestException.class, () -> + service.overview(client("token-a"), capabilities, false)); + Map result = service.overview( + client("token-a"), capabilities, false); + + Assert.assertEquals("UP", result.get("status")); + Assert.assertEquals(2, calls.get()); + } + + @Test + public void testRefreshFailurePreservesLastSuccessAsStale() { + AtomicInteger calls = new AtomicInteger(); + OperationsCollector collector = (client, metrics) -> { + if (calls.getAndIncrement() == 0) { + return fullSnapshot(); + } + throw new UpstreamRequestException("upstream_timeout"); + }; + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + HugeClient client = client("token-a"); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ, + OperationsCapabilityService.TOPOLOGY_READ); + + service.overview(client, capabilities, false); + Map stale = service.overview(client, capabilities, true); + + Assert.assertEquals("DEGRADED", stale.get("status")); + Assert.assertEquals(true, stale.get("stale")); + Assert.assertEquals(3, ((java.util.List) stale.get("nodes")).size()); + Assert.assertEquals("refresh_failed", stale.get("reason")); + @SuppressWarnings("unchecked") + java.util.List staleNodes = + (java.util.List) stale.get("nodes"); + MetricStatus backend = staleNodes.stream() + .filter(node -> "STORE".equals(node.getType())) + .findFirst().orElseThrow(AssertionError::new) + .getMetricStatuses().get("backend"); + Assert.assertEquals("UNAVAILABLE", backend.getAvailability()); + Assert.assertEquals("refresh_failed", backend.getReason()); + Assert.assertTrue(backend.isStale()); + } + + @SuppressWarnings("unchecked") + @Test + public void testPartialRefreshPreservesOnlyFailedSourceDataAsStale() { + AtomicInteger calls = new AtomicInteger(); + OperationsCollector collector = (client, metrics) -> { + if (calls.getAndIncrement() == 0) { + return fullSnapshot(); + } + return partialSnapshot(); + }; + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + HugeClient client = client("token-a"); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ, + OperationsCapabilityService.TOPOLOGY_READ); + + service.overview(client, capabilities, false); + Map result = service.overview(client, capabilities, + true); + + Assert.assertEquals("DEGRADED", result.get("status")); + Assert.assertEquals(true, result.get("stale")); + java.util.List nodes = (java.util.List) result.get("nodes"); + Assert.assertEquals(3, nodes.size()); + @SuppressWarnings("unchecked") + Map sources = + (Map) result.get("sources"); + Assert.assertEquals("UNAVAILABLE", + sources.get("stores").getAvailability()); + Assert.assertTrue(sources.get("stores").isStale()); + Assert.assertEquals(Long.valueOf(1000L), + sources.get("stores").getLastSuccessAt()); + Node staleStore = ((java.util.List) nodes).stream() + .filter(node -> "STORE".equals(node.getType())) + .findFirst().orElseThrow(AssertionError::new); + MetricStatus backend = staleStore.getMetricStatuses().get("backend"); + Assert.assertEquals("UNAVAILABLE", backend.getAvailability()); + Assert.assertEquals("upstream_unavailable", backend.getReason()); + Assert.assertTrue(backend.isStale()); + } + + @Test + public void testPartialRefreshReusesOnlyFailedMetricGroup() { + AtomicInteger calls = new AtomicInteger(); + OperationsCollector collector = (client, metrics) -> + calls.getAndIncrement() == 0 ? metricSnapshot(false) : + metricSnapshot(true); + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ, + OperationsCapabilityService.TOPOLOGY_READ, + OperationsCapabilityService.METRICS_READ); + + service.overview(client("token-a"), capabilities, false); + Map result = service.overview(client("token-a"), + capabilities, true); + + @SuppressWarnings("unchecked") + java.util.List nodes = (java.util.List) result.get("nodes"); + Node store = nodes.get(0); + Assert.assertEquals("DOWN", result.get("status")); + Assert.assertEquals("fresh-store", store.getName()); + Assert.assertEquals("DOWN", store.getStatus()); + Assert.assertEquals(Collections.singletonMap("used", 10L), + store.getMetrics().get("system")); + Assert.assertEquals(Collections.singletonMap("total", 30L), + store.getMetrics().get("drive")); + MetricStatus system = store.getMetricStatuses().get("system"); + Assert.assertEquals("UNAVAILABLE", system.getAvailability()); + Assert.assertEquals(Long.valueOf(1000L), system.getLastSuccessAt()); + Assert.assertTrue(system.isStale()); + Assert.assertTrue(store.getMetricStatuses().get("drive").isFresh()); + } + + @Test + public void testPartialMetricsKeepFreshLeaderTopology() { + AtomicInteger calls = new AtomicInteger(); + OperationsCollector collector = (client, metrics) -> { + if (calls.getAndIncrement() == 0) { + return leaderSnapshot("pd-000000000001", "LEADER", "AVAILABLE"); + } + return leaderSnapshot("pd-000000000002", "LEADER", "PARTIAL"); + }; + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ, + OperationsCapabilityService.TOPOLOGY_READ, + OperationsCapabilityService.METRICS_READ); + + service.overview(client("token-a"), capabilities, false); + Map result = service.overview(client("token-a"), + capabilities, true); + + @SuppressWarnings("unchecked") + java.util.List nodes = (java.util.List) result.get("nodes"); + Assert.assertTrue(nodes.stream().anyMatch( + node -> "pd-000000000002".equals(node.getId()) && + "LEADER".equals(node.getRole()))); + Assert.assertFalse((Boolean) result.get("stale")); + } + + @Test + public void testConcurrentForcedRefreshesShareInFlightCollection() + throws Exception { + AtomicInteger calls = new AtomicInteger(); + CountDownLatch collectorEntered = new CountDownLatch(1); + CountDownLatch releaseCollector = new CountDownLatch(1); + CountDownLatch secondCollection = new CountDownLatch(1); + OperationsCollector collector = (client, metrics) -> { + int call = calls.incrementAndGet(); + if (call > 1) { + secondCollection.countDown(); + } + collectorEntered.countDown(); + try { + releaseCollector.await(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + return snapshot(); + }; + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ); + ExecutorService executor = Executors.newFixedThreadPool(2); + CyclicBarrier start = new CyclicBarrier(2); + AtomicReference firstThread = new AtomicReference<>(); + AtomicReference secondThread = new AtomicReference<>(); + try { + Future first = executor.submit(() -> { + firstThread.set(Thread.currentThread()); + forcedRefresh(service, capabilities, start); + }); + Future second = executor.submit(() -> { + secondThread.set(Thread.currentThread()); + forcedRefresh(service, capabilities, start); + }); + Assert.assertTrue(collectorEntered.await(1, TimeUnit.SECONDS)); + Assert.assertTrue(awaitInFlightJoin(firstThread.get(), + secondThread.get())); + Assert.assertEquals(1L, secondCollection.getCount()); + releaseCollector.countDown(); + first.get(2, TimeUnit.SECONDS); + second.get(2, TimeUnit.SECONDS); + } finally { + releaseCollector.countDown(); + executor.shutdownNow(); + } + + Assert.assertEquals(1, calls.get()); + } + + private static boolean awaitInFlightJoin(Thread first, Thread second) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1L); + while (System.nanoTime() < deadline) { + if (joiningInFlight(first) || joiningInFlight(second)) { + return true; + } + Thread.yield(); + } + return false; + } + + private static boolean joiningInFlight(Thread thread) { + for (StackTraceElement frame : thread.getStackTrace()) { + if (DefaultOperationsDataService.class.getName() + .equals(frame.getClassName()) && + "await".equals(frame.getMethodName())) { + return true; + } + } + return false; + } + + private static void forcedRefresh(DefaultOperationsDataService service, + Set capabilities, + CyclicBarrier start) { + try { + start.await(1, TimeUnit.SECONDS); + service.overview(client("token-a"), capabilities, true); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static DefaultOperationsDataService service(Snapshot snapshot, + int ttlSeconds) { + return new DefaultOperationsDataService((client, metrics) -> snapshot, + ttlSeconds, CLOCK); + } + + private static HugeClient client(String token) { + HugeClient client = Mockito.mock(HugeClient.class); + Mockito.when(client.getAuthContext()).thenReturn(token); + return client; + } + + private static Snapshot snapshot() { + Map sources = new LinkedHashMap<>(); + sources.put("server", new SourceStatus("AVAILABLE", "UP", 1000L, + 1000L, true, false, null)); + Map facts = new LinkedHashMap<>(); + facts.put("stores", 2L); + return new Snapshot("UP", 1000L, false, null, sources, + Arrays.asList(node("pd-000000000001", "PD", "pd", "UP"), + node("store-000000000001", "STORE", "store-a", + "UP"), + node("store-000000000002", "STORE", "store-b", + "UP")), facts); + } + + private static Snapshot fullSnapshot() { + Map sources = new LinkedHashMap<>(); + sources.put("server", available()); + sources.put("pd", available()); + sources.put("stores", available()); + Map facts = new LinkedHashMap<>(); + facts.put("stores", 1L); + return new Snapshot("UP", 1000L, false, null, sources, + Arrays.asList(node("server-000000000001", "SERVER", "server", + "UP"), + node("pd-000000000001", "PD", "pd", "UP"), + metricNode("store-000000000001", "store", "UP", + "backend", 1L, 1000L)), facts); + } + + private static Snapshot partialSnapshot() { + Map sources = new LinkedHashMap<>(); + sources.put("server", available()); + sources.put("pd", available()); + sources.put("stores", new SourceStatus("UNAVAILABLE", "UNKNOWN", + 2000L, null, false, false, + "upstream_unavailable")); + return new Snapshot("DEGRADED", 2000L, false, null, sources, + Arrays.asList(node("server-000000000001", "SERVER", "server", + "UP"), + node("pd-000000000001", "PD", "pd", "UP")), + Collections.emptyMap()); + } + + private static Snapshot leaderSnapshot(String id, String role, + String availability) { + Map sources = new LinkedHashMap<>(); + sources.put("pd", new SourceStatus(availability, "UP", 2000L, + 2000L, true, false, + "PARTIAL".equals(availability) ? + "metrics_unavailable" : null)); + Node pd = new Node(id, "PD", "pd", role, "1.7.0", "UP", 2000L, + Collections.emptyMap()); + return new Snapshot("PARTIAL".equals(availability) ? "DEGRADED" : "UP", + 2000L, false, null, sources, + Collections.singletonList(pd), + Collections.emptyMap()); + } + + private static Snapshot metricSnapshot(boolean partial) { + long observedAt = partial ? 2000L : 1000L; + Map sources = new LinkedHashMap<>(); + sources.put("stores", new SourceStatus( + partial ? "PARTIAL" : "AVAILABLE", partial ? "DOWN" : "UP", + observedAt, observedAt, !partial, false, + partial ? "upstream_timeout" : null)); + Map metrics = new LinkedHashMap<>(); + Map statuses = new LinkedHashMap<>(); + if (partial) { + metrics.put("drive", Collections.singletonMap("total", 30L)); + statuses.put("system", new MetricStatus( + "UNAVAILABLE", observedAt, null, false, false, + "upstream_timeout")); + statuses.put("drive", new MetricStatus( + "AVAILABLE", observedAt, observedAt, true, false, null)); + } else { + metrics.put("system", Collections.singletonMap("used", 10L)); + metrics.put("drive", Collections.singletonMap("total", 20L)); + statuses.put("system", new MetricStatus( + "AVAILABLE", observedAt, observedAt, true, false, null)); + statuses.put("drive", new MetricStatus( + "AVAILABLE", observedAt, observedAt, true, false, null)); + } + Node node = new Node("store-000000000001", "STORE", + partial ? "fresh-store" : "old-store", null, + "1.7.0", partial ? "DOWN" : "UP", observedAt, + metrics, statuses); + return new Snapshot(partial ? "DOWN" : "UP", observedAt, false, + null, sources, Collections.singletonList(node), + Collections.emptyMap()); + } + + private static SourceStatus available() { + return new SourceStatus("AVAILABLE", "UP", 1000L, 1000L, true, + false, null); + } + + private static Node node(String id, String type, String name, + String status) { + return new Node(id, type, name, null, "1.7.0", status, 1000L, + Collections.emptyMap()); + } + + private static Node metricNode(String id, String name, String status, + String group, long value, long observedAt) { + Map metrics = Collections.singletonMap( + group, Collections.singletonMap("value", + value)); + Map statuses = Collections.singletonMap( + group, new MetricStatus("AVAILABLE", observedAt, observedAt, + true, false, null)); + return new Node(id, "STORE", name, null, "1.7.0", status, observedAt, + metrics, statuses); + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java new file mode 100644 index 000000000..9640678af --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java @@ -0,0 +1,961 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.driver.MetricsManager; +import org.apache.hugegraph.driver.VersionManager; +import org.apache.hugegraph.service.op.OperationsModels.Snapshot; +import org.apache.hugegraph.service.op.OperationsModels.MetricStatus; +import org.apache.hugegraph.testutil.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +public class LiveOperationsCollectorTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.ofEpochMilli(2000L), ZoneOffset.UTC); + + @Test + public void testNonPdModeReturnsServerAndUnsupportedPdSources() { + HugeClient client = serverClient(); + LiveOperationsCollector collector = collector(false, null); + + Snapshot snapshot = collector.collect(client, true); + + Assert.assertEquals("UP", snapshot.getStatus()); + Assert.assertEquals(1, snapshot.getNodes().size()); + Assert.assertEquals("SERVER", snapshot.getNodes().get(0).getType()); + Assert.assertTrue(snapshot.getNodes().get(0).getMetrics() + .containsKey("system")); + Assert.assertTrue(snapshot.getNodes().get(0).getMetrics() + .containsKey("backend")); + Assert.assertEquals("AVAILABLE", snapshot.getNodes().get(0) + .getMetricStatuses().get("system").getAvailability()); + Assert.assertEquals("AVAILABLE", snapshot.getNodes().get(0) + .getMetricStatuses().get("backend").getAvailability()); + Assert.assertEquals("UNSUPPORTED", + snapshot.getSources().get("pd").getAvailability()); + Assert.assertEquals("UNSUPPORTED", + snapshot.getSources().get("stores").getAvailability()); + } + + @Test + public void testHealthOnlyDoesNotCallServerMetrics() { + HugeClient client = serverClient(); + LiveOperationsCollector collector = collector(false, null); + + collector.collect(client, false); + + Mockito.verify(client, Mockito.never()).metrics(); + } + + @Test + public void testNonPdModeIsDownWhenOnlySupportedSourceFails() { + Snapshot snapshot = collector(false, null).collect( + unavailableServerClient(), false); + + Assert.assertEquals("DOWN", snapshot.getStatus()); + Assert.assertEquals("UNAVAILABLE", + snapshot.getSources().get("server") + .getAvailability()); + } + + @Test + public void testPdModeIsDownWhenAllSupportedSourcesFail() + throws IOException { + HttpServer pd = pdServer(200, cluster(), 200, stores()); + LiveOperationsCollector collector = collector(true, pd); + pd.stop(0); + + Snapshot snapshot = collector.collect(unavailableServerClient(), false); + + Assert.assertEquals("DOWN", snapshot.getStatus()); + Assert.assertEquals("UNAVAILABLE", + snapshot.getSources().get("server") + .getAvailability()); + Assert.assertEquals("UNAVAILABLE", + snapshot.getSources().get("pd").getAvailability()); + Assert.assertEquals("UNAVAILABLE", + snapshot.getSources().get("stores") + .getAvailability()); + } + + @Test + public void testServerFailureWithAvailablePdIsDegraded() + throws IOException { + HttpServer pd = pdServer(200, cluster(), 200, stores()); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(unavailableServerClient(), + false); + } finally { + pd.stop(0); + } + + Assert.assertEquals("DEGRADED", snapshot.getStatus()); + Assert.assertEquals("AVAILABLE", + snapshot.getSources().get("pd").getAvailability()); + } + + @Test + public void testPdStoresFailureKeepsServerAndClusterTopology() + throws IOException { + HttpServer pd = pdServer(200, cluster(), 500, "failure"); + HugeClient client = serverClient(); + LiveOperationsCollector collector = collector(true, pd); + Snapshot snapshot; + try { + snapshot = collector.collect(client, true); + } finally { + pd.stop(0); + } + + Assert.assertEquals("DEGRADED", snapshot.getStatus()); + Assert.assertEquals("AVAILABLE", + snapshot.getSources().get("pd").getAvailability()); + Assert.assertEquals("UNAVAILABLE", + snapshot.getSources().get("stores").getAvailability()); + Assert.assertTrue(snapshot.getNodes().stream() + .anyMatch(node -> "PD".equals(node.getType()))); + Assert.assertTrue(snapshot.getNodes().stream() + .anyMatch(node -> "SERVER".equals(node.getType()))); + OperationsModels.Node store = store(snapshot); + Assert.assertEquals("AVAILABLE", store.getMetricStatuses() + .get("backend").getAvailability()); + for (String group : new String[]{"system", "drive", "raft"}) { + Assert.assertEquals("UNAVAILABLE", store.getMetricStatuses() + .get(group) + .getAvailability()); + } + } + + @Test + public void testPdDegradedStatusMakesOverallSnapshotDegraded() + throws IOException { + String degraded = cluster().replace("Cluster_OK", "Cluster_Warn"); + HttpServer pd = pdServer(200, degraded, 200, stores()); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), false); + } finally { + pd.stop(0); + } + + Assert.assertEquals("DEGRADED", snapshot.getStatus()); + Assert.assertEquals("DEGRADED", + snapshot.getSources().get("pd").getStatus()); + } + + @Test + public void testPdUnknownStatusMakesOverallSnapshotDegraded() + throws IOException { + String unknown = cluster().replace("Cluster_OK", "Cluster_Starting"); + HttpServer pd = pdServer(200, unknown, 200, stores()); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), false); + } finally { + pd.stop(0); + } + + Assert.assertEquals("DEGRADED", snapshot.getStatus()); + Assert.assertEquals("UNKNOWN", + snapshot.getSources().get("pd").getStatus()); + } + + @Test + public void testStoreMetricsUseOnlyPdDiscoveredTarget() throws IOException { + HttpServer pd = pdServer(200, cluster(), 200, stores()); + String base = "127.0.0.1:" + pd.getAddress().getPort(); + context(pd, "/v1/prom/targets-all", 200, + "[{\"targets\":[\"" + base + "\"],\"labels\":{" + + "\"__app_name\":\"store\",\"__scheme__\":\"http\"}}]"); + context(pd, "/metrics/system", 200, + "{\"heap\":{\"used\":64,\"secret\":\"drop\"}}"); + context(pd, "/metrics/drive", 200, + "{\"/secret\":{\"total_space\":10," + + "\"usable_space\":4,\"size_unit\":\"MB\"}}"); + context(pd, "/metrics/raft", 200, + "{\"0\":{\"enabled\":true,\"metrics\":{}}}"); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), true); + } finally { + pd.stop(0); + } + + OperationsModels.Node store = snapshot.getNodes().stream() + .filter(node -> "STORE".equals(node.getType())) + .findFirst().orElseThrow(AssertionError::new); + Assert.assertTrue(store.getMetrics().containsKey("system")); + Assert.assertTrue(store.getMetrics().containsKey("drive")); + Assert.assertTrue(store.getMetrics().containsKey("raft")); + for (String group : new String[]{"system", "drive", "raft", + "backend"}) { + Assert.assertEquals("AVAILABLE", store.getMetricStatuses() + .get(group).getAvailability()); + } + Assert.assertEquals(Long.valueOf(2000L), store.getObservedAt()); + Assert.assertFalse(store.getMetrics().toString().contains("secret")); + OperationsModels.Node pdNode = snapshot.getNodes().stream() + .filter(node -> "PD".equals(node.getType())) + .findFirst().orElseThrow(AssertionError::new); + Assert.assertTrue(pdNode.getMetrics().containsKey("system")); + Assert.assertEquals(Long.valueOf(2000L), pdNode.getObservedAt()); + } + + @Test + public void testMalformedStoresKeepValidPdTopologyAndFacts() + throws IOException { + HttpServer pd = pdServer(200, cluster(), 200, "{bad"); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), false); + } finally { + pd.stop(0); + } + + Assert.assertEquals("MALFORMED", + snapshot.getSources().get("stores").getAvailability()); + Assert.assertTrue(snapshot.getNodes().stream() + .anyMatch(node -> "PD".equals(node.getType()))); + Assert.assertEquals(2L, snapshot.getFacts().get("graphs")); + } + + @Test + public void testAllMissingStoreMetricEndpointsAreUnsupported() + throws IOException { + HttpServer pd = pdServer(200, cluster(), 200, stores()); + String base = "127.0.0.1:" + pd.getAddress().getPort(); + context(pd, "/v1/prom/targets-all", 200, + "[{\"targets\":[\"" + base + "\"],\"labels\":{" + + "\"__app_name\":\"store\",\"__scheme__\":\"http\"}}]"); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), true); + } finally { + pd.stop(0); + } + + Assert.assertEquals("UNSUPPORTED", + snapshot.getSources().get("stores").getAvailability()); + Assert.assertEquals("unsupported_version", + snapshot.getSources().get("stores").getReason()); + OperationsModels.Node store = store(snapshot); + for (String group : new String[]{"system", "drive", "raft"}) { + MetricStatus metric = store.getMetricStatuses().get(group); + Assert.assertEquals("UNSUPPORTED", metric.getAvailability()); + Assert.assertEquals("unsupported_version", metric.getReason()); + } + } + + @Test + public void testStoreMetricFailureKeepsSpecificSafeReason() + throws IOException { + HttpServer pd = pdServer(200, cluster(), 200, stores()); + String base = "127.0.0.1:" + pd.getAddress().getPort(); + context(pd, "/v1/prom/targets-all", 200, + "[{\"targets\":[\"" + base + "\"],\"labels\":{" + + "\"__app_name\":\"store\",\"__scheme__\":\"http\"}}]"); + context(pd, "/metrics/system", 200, "x".repeat(9000)); + context(pd, "/metrics/drive", 200, + "{\"disk\":{\"total_space\":10}}"); + context(pd, "/metrics/raft", 200, + "{\"0\":{\"enabled\":true}}"); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), true); + } finally { + pd.stop(0); + } + + Assert.assertEquals("PARTIAL", + snapshot.getSources().get("stores").getAvailability()); + Assert.assertEquals("response_too_large", + snapshot.getSources().get("stores").getReason()); + OperationsModels.Node store = store(snapshot); + Assert.assertEquals("UNAVAILABLE", store.getMetricStatuses() + .get("system").getAvailability()); + Assert.assertEquals("response_too_large", store.getMetricStatuses() + .get("system").getReason()); + Assert.assertEquals("AVAILABLE", store.getMetricStatuses() + .get("drive").getAvailability()); + Assert.assertEquals("AVAILABLE", store.getMetricStatuses() + .get("raft").getAvailability()); + } + + @Test + public void testMalformedSingleStoreMetricKeepsOtherGroupsAvailable() + throws IOException { + HttpServer pd = pdServer(200, cluster(), 200, stores()); + String base = "127.0.0.1:" + pd.getAddress().getPort(); + context(pd, "/v1/prom/targets-all", 200, + "[{\"targets\":[\"" + base + "\"],\"labels\":{" + + "\"__app_name\":\"store\",\"__scheme__\":\"http\"}}]"); + context(pd, "/metrics/system", 200, "{bad"); + context(pd, "/metrics/drive", 200, + "{\"disk\":{\"total_space\":10}}"); + context(pd, "/metrics/raft", 200, + "{\"0\":{\"enabled\":true}}"); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), true); + } finally { + pd.stop(0); + } + + OperationsModels.Node store = store(snapshot); + Assert.assertEquals("MALFORMED", store.getMetricStatuses() + .get("system").getAvailability()); + Assert.assertEquals("malformed_response", store.getMetricStatuses() + .get("system").getReason()); + Assert.assertEquals("AVAILABLE", store.getMetricStatuses() + .get("drive").getAvailability()); + Assert.assertEquals("AVAILABLE", store.getMetricStatuses() + .get("raft").getAvailability()); + } + + @Test + public void testTimedOutSingleStoreMetricKeepsOtherGroupsAvailable() + throws IOException { + HttpServer pd = pdServer(200, cluster(), 200, stores()); + String base = "127.0.0.1:" + pd.getAddress().getPort(); + context(pd, "/v1/prom/targets-all", 200, + "[{\"targets\":[\"" + base + "\"],\"labels\":{" + + "\"__app_name\":\"store\",\"__scheme__\":\"http\"}}]"); + context(pd, "/metrics/system", 200, + "{\"heap\":{\"used\":1}}"); + context(pd, "/metrics/drive", 200, + "{\"disk\":{\"total_space\":10}}"); + delayedContext(pd, "/metrics/raft", 200, + "{\"0\":{\"enabled\":true}}", 200L); + Snapshot snapshot; + try { + snapshot = collector(true, pd, 50, 8192) + .collect(serverClient(), true); + } finally { + pd.stop(0); + } + + OperationsModels.Node store = store(snapshot); + Assert.assertEquals("UNAVAILABLE", store.getMetricStatuses() + .get("raft").getAvailability()); + Assert.assertEquals("upstream_timeout", store.getMetricStatuses() + .get("raft").getReason()); + Assert.assertEquals("AVAILABLE", store.getMetricStatuses() + .get("system").getAvailability()); + Assert.assertEquals("AVAILABLE", store.getMetricStatuses() + .get("drive").getAvailability()); + } + + @Test + public void testRestAddressDisambiguatesStoresOnSameHost() { + String stores = storesWithDifferentRestAddresses(); + RecordingHttpClient http = new RecordingHttpClient(stores, + targets("127.0.0.1:8520", + "127.0.0.1:9520")); + LiveOperationsCollector collector = collector(http, 4, 1000); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertEquals(2L, snapshot.getNodes().stream() + .filter(node -> "STORE".equals( + node.getType())).count()); + Assert.assertEquals(6, http.metricRequests()); + Assert.assertEquals(new java.util.HashSet<>(java.util.Arrays.asList( + "127.0.0.1:8520", "127.0.0.1:9520")), + http.metricAuthorities()); + Assert.assertEquals("AVAILABLE", + snapshot.getSources().get("stores") + .getAvailability()); + } + + @Test + public void testLegacyStoreWithoutRestAddressIsNeverRead() { + RecordingHttpClient http = new RecordingHttpClient( + storesWithoutRestAddress(), targets( + "127.0.0.1:8520", + "127.0.0.1:9520")); + LiveOperationsCollector collector = collector(http, 4, 1000); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertEquals(0, http.metricRequests()); + Assert.assertEquals("PARTIAL", + snapshot.getSources().get("stores") + .getAvailability()); + Assert.assertEquals("metrics_target_missing", + snapshot.getSources().get("stores").getReason()); + Assert.assertEquals("metrics_target_missing", + store(snapshot).getMetricStatuses().get("system") + .getReason()); + } + + @Test + public void testChangedRestPortRejectsStalePdTarget() { + RecordingHttpClient http = new RecordingHttpClient( + storesWithRestAddresses(1, 9520), + targets("127.0.0.1:8520")); + LiveOperationsCollector collector = collector(http, 4, 1000); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertEquals(0, http.metricRequests()); + Assert.assertEquals("metrics_target_missing", + snapshot.getSources().get("stores").getReason()); + } + + @Test + public void testOperatorAllowlistRejectsWrongOrigin() { + RecordingHttpClient http = new RecordingHttpClient( + storesWithRestAddresses(1, 8520), + targets("127.0.0.1:8520")); + LiveOperationsCollector collector = new LiveOperationsCollector( + true, "http://pd:8620", "hubble", "secret", + "store-hubble", "store-secret", "server-under-test", http, + new OperationsPayloadParser(new ObjectMapper()), CLOCK, + 4, 1000, Collections.singleton( + "http://store.internal:8520")); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertEquals(0, http.metricRequests()); + Assert.assertEquals("metrics_target_untrusted", + snapshot.getSources().get("stores").getReason()); + } + + @Test + public void testOperatorAllowlistAcceptsExactPrivateStoreOrigin() { + String stores = "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":\"1\",\"address\":" + + "\"store.internal:8500\",\"restAddress\":" + + "\"store.internal:8520\",\"state\":\"Up\"}]}}"; + RecordingHttpClient http = new RecordingHttpClient( + stores, targets("store.internal:8520")); + LiveOperationsCollector collector = new LiveOperationsCollector( + true, "http://pd:8620", "hubble", "secret", + "store-hubble", "store-secret", "server-under-test", http, + new OperationsPayloadParser(new ObjectMapper()), CLOCK, + 4, 1000, Collections.singleton( + "http://store.internal:8520")); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertEquals(3, http.metricRequests()); + Assert.assertEquals("AVAILABLE", + snapshot.getSources().get("stores") + .getAvailability()); + } + + @Test + public void testOperatorAllowlistRejectsWrongPort() { + RecordingHttpClient http = new RecordingHttpClient( + storesWithRestAddresses(1, 8520), + targets("127.0.0.1:8520")); + LiveOperationsCollector collector = new LiveOperationsCollector( + true, "http://pd:8620", "hubble", "secret", + "store-hubble", "store-secret", "server-under-test", http, + new OperationsPayloadParser(new ObjectMapper()), CLOCK, + 4, 1000, Collections.singleton("http://127.0.0.1:9520")); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertEquals(0, http.metricRequests()); + Assert.assertEquals("metrics_target_untrusted", + snapshot.getSources().get("stores").getReason()); + } + + @Test + public void testOperatorAllowlistAcceptsConfiguredHttpsOrigin() { + RecordingHttpClient http = new RecordingHttpClient( + storesWithRestAddresses(1, 8520), + targetsWithScheme("https", "127.0.0.1:8520")); + LiveOperationsCollector collector = new LiveOperationsCollector( + true, "http://pd:8620", "hubble", "secret", + "store-hubble", "store-secret", "server-under-test", http, + new OperationsPayloadParser(new ObjectMapper()), CLOCK, + 4, 1000, Collections.singleton("https://127.0.0.1:8520")); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertEquals(3, http.metricRequests()); + Assert.assertEquals("AVAILABLE", + snapshot.getSources().get("stores") + .getAvailability()); + } + + @Test + public void testDefaultOperatorAllowlistAcceptsIpv6Loopback() { + String stores = "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":\"1\",\"address\":" + + "\"[::1]:8500\",\"restAddress\":\"[::1]:8520\"," + + "\"state\":\"Up\"}]}}"; + RecordingHttpClient http = new RecordingHttpClient( + stores, targets("[::1]:8520")); + LiveOperationsCollector collector = collector(http, 4, 1000); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertEquals(3, http.metricRequests()); + Assert.assertEquals("AVAILABLE", + snapshot.getSources().get("stores") + .getAvailability()); + } + + @Test + public void testStoreFanoutSupportsThreeThirtyAndThreeHundredNodes() { + for (int stores : new int[]{3, 30, 300}) { + RecordingHttpClient http = new RecordingHttpClient( + storesWithRestAddresses(stores, 8520), + targets("127.0.0.1:8520")); + LiveOperationsCollector collector = collector(http, 8, 5000); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + Assert.assertEquals(stores * 3, http.metricRequests()); + Assert.assertEquals(stores, snapshot.getNodes().stream() + .filter(node -> "STORE".equals( + node.getType())).count()); + } + } + + @Test + public void testStoreFanoutIsBoundedAndPreservesCompletedResults() { + RecordingHttpClient http = new RecordingHttpClient( + storesWithRestAddresses(30, 8520), + targets("127.0.0.1:8520")); + http.delayMillis(10L); + LiveOperationsCollector collector = collector(http, 4, 5000); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertTrue(http.maxConcurrentRequests() > 1); + Assert.assertTrue(http.maxConcurrentRequests() <= 4); + Assert.assertEquals("AVAILABLE", + snapshot.getSources().get("stores") + .getAvailability()); + } + + @Test + public void testStoreFanoutDeadlineCancelsPendingNodesAndReleasesThreads() + throws InterruptedException { + RecordingHttpClient http = new RecordingHttpClient( + storesWithRestAddresses(30, 8520), + targets("127.0.0.1:8520")); + http.delayMillis(10000L); + LiveOperationsCollector collector = collector(http, 4, 50); + long started = System.nanoTime(); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + + Assert.assertTrue(elapsed < 3000L); + Assert.assertEquals("PARTIAL", + snapshot.getSources().get("stores") + .getAvailability()); + Assert.assertEquals("upstream_deadline", + snapshot.getSources().get("stores").getReason()); + Assert.assertEquals(0, http.activeRequests()); + } + + @Test + public void testStoreFanoutDeadlinePreservesFastNodeResults() { + RecordingHttpClient http = new RecordingHttpClient( + storesWithDifferentRestAddresses(), + targets("127.0.0.1:8520", + "127.0.0.1:9520")); + http.delayAuthority("127.0.0.1:9520", 10000L); + LiveOperationsCollector collector = collector(http, 2, 250); + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertEquals(1L, snapshot.getNodes().stream() + .filter(node -> "STORE".equals(node.getType())) + .filter(node -> "AVAILABLE".equals(node.getMetricStatuses() + .get("system") + .getAvailability())) + .count()); + Assert.assertEquals(1L, snapshot.getNodes().stream() + .filter(node -> "STORE".equals(node.getType())) + .filter(node -> "upstream_deadline".equals( + node.getMetricStatuses().get("system").getReason())) + .count()); + } + + @Test + public void testMalformedPdIsClassifiedWithoutLeakingPayload() + throws IOException { + HttpServer pd = pdServer(200, "{bad", 200, stores()); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), false); + } finally { + pd.stop(0); + } + + Assert.assertEquals("MALFORMED", + snapshot.getSources().get("pd").getAvailability()); + Assert.assertEquals("malformed_response", + snapshot.getSources().get("pd").getReason()); + } + + private static LiveOperationsCollector collector(boolean pdEnabled, + HttpServer pd) { + return collector(pdEnabled, pd, 1000, 8192); + } + + private static LiveOperationsCollector collector(boolean pdEnabled, + HttpServer pd, + int readTimeout, + int maxResponseBytes) { + String pdBase = pd == null ? null : "http://127.0.0.1:" + + pd.getAddress().getPort(); + OperationsHttpClient http = new OperationsHttpClient( + 1000, readTimeout, maxResponseBytes); + return new LiveOperationsCollector(pdEnabled, pdBase, "hubble", "secret", + "store-hubble", "store-secret", + "server-under-test", http, + new OperationsPayloadParser(new ObjectMapper()), CLOCK, + 16, 5000, pd == null ? Collections.singleton( + "http://127.0.0.1:8520") : Collections.singleton( + "http://127.0.0.1:" + pd.getAddress().getPort())); + } + + private static LiveOperationsCollector collector(RecordingHttpClient http, + int threads, + int deadlineMillis) { + return new LiveOperationsCollector(true, "http://pd:8620", "hubble", + "secret", "store-hubble", "store-secret", + "server-under-test", http, + new OperationsPayloadParser(new ObjectMapper()), CLOCK, + threads, deadlineMillis, new java.util.LinkedHashSet<>( + java.util.Arrays.asList("http://127.0.0.1:8520", + "http://127.0.0.1:9520", + "http://[::1]:8520"))); + } + + private static HugeClient serverClient() { + HugeClient client = Mockito.mock(HugeClient.class); + VersionManager version = Mockito.mock(VersionManager.class); + Mockito.when(version.getCoreVersion()).thenReturn("1.7.0"); + Mockito.when(client.versionManager()).thenReturn(version); + MetricsManager metrics = Mockito.mock(MetricsManager.class); + Map> system = new LinkedHashMap<>(); + system.put("heap", Collections.singletonMap("used", 128L)); + Mockito.when(metrics.system()).thenReturn(system); + Map> backend = new LinkedHashMap<>(); + Map graph = new LinkedHashMap<>(); + graph.put("backend", "hstore"); + graph.put("nodes", 1L); + backend.put("secret-graph-name", graph); + Mockito.when(metrics.backend()).thenReturn(backend); + Mockito.when(client.metrics()).thenReturn(metrics); + return client; + } + + private static HugeClient unavailableServerClient() { + HugeClient client = Mockito.mock(HugeClient.class); + VersionManager version = Mockito.mock(VersionManager.class); + Mockito.when(version.getCoreVersion()) + .thenThrow(new IllegalStateException("unavailable")); + Mockito.when(client.versionManager()).thenReturn(version); + return client; + } + + private static HttpServer pdServer(int clusterStatus, String cluster, + int storesStatus, String stores) + throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + stores = stores.replace("PD_TEST_PORT", + String.valueOf(server.getAddress().getPort())); + context(server, "/v1/cluster", clusterStatus, cluster); + context(server, "/v1/stores", storesStatus, stores); + context(server, "/actuator/prometheus", 200, + "process_uptime_seconds{hg=\"pd\"} 12\n" + + "system_cpu_count{hg=\"pd\"} 2\n"); + server.start(); + return server; + } + + private static void context(HttpServer server, String path, int status, + String body) { + server.createContext(path, exchange -> { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + } + + private static void delayedContext(HttpServer server, String path, + int status, String body, long delayMillis) { + server.createContext(path, exchange -> { + try { + Thread.sleep(delayMillis); + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + } + + private static OperationsModels.Node store(Snapshot snapshot) { + return snapshot.getNodes().stream() + .filter(node -> "STORE".equals(node.getType())) + .findFirst().orElseThrow(AssertionError::new); + } + + private static String cluster() { + return "{\"status\":0,\"data\":{\"state\":\"Cluster_OK\"," + + "\"graphSize\":2," + + "\"pdList\":[{\"restUrl\":\"http://pd:8620\"," + + "\"state\":\"Up\",\"role\":\"Leader\"}]," + + "\"pdLeader\":{\"restUrl\":\"http://pd:8620\"," + + "\"state\":\"Up\",\"role\":\"Leader\"}," + + "\"stores\":[{\"storeId\":1,\"state\":\"Up\"," + + "\"capacity\":100,\"available\":40}]}}"; + } + + private static String stores() { + return "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":\"1\",\"address\":\"127.0.0.1:8500\"," + + "\"restAddress\":\"127.0.0.1:PD_TEST_PORT\"," + + "\"state\":\"Up\"}]}}"; + } + + private static String storesWithoutRestAddress() { + return "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":\"1\",\"address\":\"127.0.0.1:8500\"," + + "\"state\":\"Up\"}]}}"; + } + + private static String storesWithRestAddresses(int count, int port) { + StringBuilder builder = new StringBuilder( + "{\"status\":0,\"data\":{\"stores\":["); + for (int i = 1; i <= count; i++) { + if (i > 1) { + builder.append(','); + } + builder.append("{\"storeId\":\"").append(i) + .append("\",\"address\":\"127.0.0.1:8500\",") + .append("\"restAddress\":\"127.0.0.1:") + .append(port).append("\",\"state\":\"Up\"}"); + } + return builder.append("]}}").toString(); + } + + private static String storesWithDifferentRestAddresses() { + return "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":\"1\",\"address\":\"127.0.0.1:8500\"," + + "\"restAddress\":\"127.0.0.1:8520\",\"state\":\"Up\"},{" + + "\"storeId\":\"2\",\"address\":\"127.0.0.1:9500\"," + + "\"restAddress\":\"127.0.0.1:9520\",\"state\":\"Up\"}]}}"; + } + + private static String targets(String... authorities) { + return targetsWithScheme("http", authorities); + } + + private static String targetsWithScheme(String scheme, + String... authorities) { + StringBuilder builder = new StringBuilder("[{\"targets\":["); + for (int i = 0; i < authorities.length; i++) { + if (i > 0) { + builder.append(','); + } + builder.append('\"').append(authorities[i]).append('\"'); + } + return builder.append("],\"labels\":{" + + "\"__app_name\":\"store\"," + + "\"__scheme__\":\"").append(scheme) + .append("\"}}]").toString(); + } + + private static final class RecordingHttpClient + extends OperationsHttpClient { + + private final String stores; + private final String targets; + private final AtomicInteger active; + private final AtomicInteger maximum; + private final AtomicInteger metrics; + private final Set metricAuthorities; + private volatile long delayMillis; + private volatile String delayAuthority; + + private RecordingHttpClient(String stores, String targets) { + super(1000, 1000, 8192); + this.stores = stores; + this.targets = targets; + this.active = new AtomicInteger(); + this.maximum = new AtomicInteger(); + this.metrics = new AtomicInteger(); + this.metricAuthorities = java.util.concurrent.ConcurrentHashMap + .newKeySet(); + } + + @Override + public String get(java.net.URI target, String username, String password, + Set allowedTargets) { + return this.response(target); + } + + @Override + public String get(java.net.URI target, String username, String password) { + return this.response(target); + } + + @Override + public String get(java.net.URI target, String username, String password, + Set allowedTargets, String accept) { + return this.response(target); + } + + private String response(java.net.URI target) { + String path = target.getPath(); + if ("/v1/cluster".equals(path)) { + return cluster(); + } + if ("/v1/stores".equals(path)) { + return this.stores; + } + if ("/v1/prom/targets-all".equals(path)) { + return this.targets; + } + if ("/actuator/prometheus".equals(path)) { + return "process_uptime_seconds{hg=\"pd\"} 12\n"; + } + this.metrics.incrementAndGet(); + this.metricAuthorities.add(OperationsHttpClient.authority(target)); + int current = this.active.incrementAndGet(); + this.maximum.accumulateAndGet(current, Math::max); + try { + if (this.delayMillis > 0L && + (this.delayAuthority == null || this.delayAuthority.equals( + OperationsHttpClient.authority(target)))) { + Thread.sleep(this.delayMillis); + } + if (path.endsWith("/drive")) { + return "{\"disk\":{\"total_space\":10}}"; + } + if (path.endsWith("/raft")) { + return "{\"0\":{\"enabled\":true}}"; + } + return "{\"heap\":{\"used\":1}}"; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new UpstreamRequestException("upstream_timeout", e); + } finally { + this.active.decrementAndGet(); + } + } + + private void delayMillis(long delayMillis) { + this.delayMillis = delayMillis; + } + + private void delayAuthority(String authority, long delayMillis) { + this.delayAuthority = authority; + this.delayMillis = delayMillis; + } + + private int metricRequests() { + return this.metrics.get(); + } + + private int activeRequests() { + return this.active.get(); + } + + private int maxConcurrentRequests() { + return this.maximum.get(); + } + + private Set metricAuthorities() { + return this.metricAuthorities; + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsCapabilityServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsCapabilityServiceTest.java new file mode 100644 index 000000000..d8248a1b2 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsCapabilityServiceTest.java @@ -0,0 +1,56 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.apache.hugegraph.service.op; + +import java.util.Set; + +import org.junit.Test; + +import org.apache.hugegraph.exception.ForbiddenException; +import org.apache.hugegraph.testutil.Assert; + +public class OperationsCapabilityServiceTest { + + @Test + public void testAdminReceivesAllReadCapabilities() { + Set capabilities = OperationsCapabilityService.forLevel("ADMIN"); + + Assert.assertTrue(capabilities.contains("operations_health_read")); + Assert.assertTrue(capabilities.contains("operations_topology_read")); + Assert.assertTrue(capabilities.contains("operations_metrics_read")); + } + + @Test + public void testSpaceAdminHasNoGlobalOperationsCapability() { + Set capabilities = OperationsCapabilityService.forLevel( + "SPACEADMIN"); + + Assert.assertTrue(capabilities.isEmpty()); + } + + @Test + public void testUserHasNoGlobalOperationsCapability() { + Assert.assertTrue(OperationsCapabilityService.forLevel("USER").isEmpty()); + } + + @Test(expected = ForbiddenException.class) + public void testHealthAccessRejectsOrdinaryUser() { + OperationsCapabilityService.requireHealth( + OperationsCapabilityService.forLevel("USER")); + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsHttpClientTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsHttpClientTest.java new file mode 100644 index 000000000..1665e6ee1 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsHttpClientTest.java @@ -0,0 +1,227 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; + +import com.sun.net.httpserver.HttpServer; +import org.junit.Test; + +import org.apache.hugegraph.testutil.Assert; + +public class OperationsHttpClientTest { + + @Test(expected = IllegalArgumentException.class) + public void testRejectsNonHttpTarget() { + OperationsHttpClient.validateTarget(URI.create("file:///etc/passwd"), + Collections.emptySet()); + } + + @Test(expected = IllegalArgumentException.class) + public void testRejectsTargetOutsideDiscoverySet() { + OperationsHttpClient.validateTarget(URI.create("http://127.0.0.1:9"), + Collections.singleton("http://127.0.0.1:10")); + } + + @Test(expected = IllegalArgumentException.class) + public void testRejectsSameAuthorityWithWrongScheme() { + OperationsHttpClient.validateTarget( + URI.create("https://store.internal:8520"), + Collections.singleton("http://store.internal:8520")); + } + + @Test + public void testHttpsResolutionPreservesHostnameForTlsVerification() + throws IOException { + URI target = URI.create( + "https://store.internal:9443/metrics/system"); + + URI resolved = OperationsHttpClient.resolveTarget( + target, new InetAddress[]{ + InetAddress.getByName("10.0.0.8")}); + + Assert.assertEquals(target, resolved); + } + + @Test + public void testPinnedDnsReturnsOnlyPrevalidatedAddresses() + throws IOException { + InetAddress pinned = InetAddress.getByName("10.0.0.8"); + + java.util.List resolved = OperationsHttpClient.pinnedDns( + "store.internal", new InetAddress[]{pinned}) + .lookup("store.internal"); + + Assert.assertEquals(Collections.singletonList(pinned), resolved); + Assert.assertThrows(UnknownHostException.class, () -> + OperationsHttpClient.pinnedDns( + "store.internal", new InetAddress[]{pinned}) + .lookup("other.internal")); + } + + @Test(expected = IllegalArgumentException.class) + public void testRejectsMetadataAddress() { + OperationsHttpClient.resolveTarget( + URI.create("http://169.254.169.254/latest/meta-data")); + } + + @Test(expected = IllegalArgumentException.class) + public void testRejectsIpv6LinkLocalAddress() { + OperationsHttpClient.resolveTarget(URI.create("http://[fe80::1]:8520")); + } + + @Test(expected = IllegalArgumentException.class) + public void testRejectsMixedSafeAndLinkLocalDnsAnswers() throws IOException { + OperationsHttpClient.validateResolvedAddresses("store.internal", + new InetAddress[]{ + InetAddress.getByName("127.0.0.1"), + InetAddress.getByName("169.254.169.254") + }); + } + + @Test(expected = IllegalArgumentException.class) + public void testRejectsHostnameResolvingToLoopback() { + OperationsHttpClient.resolveTarget( + URI.create("http://localhost:8520/metrics/system")); + } + + @Test + public void testAllowsConfiguredPrivateHostnameResolution() throws IOException { + OperationsHttpClient.validateResolvedAddresses("store.internal", + new InetAddress[]{InetAddress.getByName("10.0.0.8")}); + } + + @Test(expected = UpstreamRequestException.class) + public void testDoesNotFollowRedirects() throws IOException { + HttpServer server = server(302, "redirect", "/elsewhere", null); + OperationsHttpClient client = new OperationsHttpClient(1000, 1000, 64); + try { + client.get(uri(server, "/"), null, null); + } finally { + server.stop(0); + } + } + + @Test(expected = UpstreamResponseTooLargeException.class) + public void testCapsResponseBody() throws IOException { + HttpServer server = server(200, "0123456789", null, null); + OperationsHttpClient client = new OperationsHttpClient(1000, 1000, 8); + try { + client.get(uri(server, "/"), null, null); + } finally { + server.stop(0); + } + } + + @Test + public void testSendsBasicIdentityWithoutReturningIt() throws IOException { + AtomicReference authorization = new AtomicReference<>(); + HttpServer server = server(200, "ok", null, authorization); + OperationsHttpClient client = new OperationsHttpClient(1000, 1000, 64); + String response; + try { + response = client.get(uri(server, "/"), "hubble", "s3cret"); + } finally { + server.stop(0); + } + + Assert.assertEquals("ok", response); + Assert.assertTrue(authorization.get().startsWith("Basic ")); + Assert.assertFalse(response.contains("s3cret")); + } + + @Test + public void testSupportsExplicitPrometheusAcceptType() throws IOException { + AtomicReference accept = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/", exchange -> { + accept.set(exchange.getRequestHeaders().getFirst("Accept")); + byte[] bytes = "metric 1".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + OperationsHttpClient client = new OperationsHttpClient(1000, 1000, 64); + try { + client.get(uri(server, "/"), "hubble", "secret", + Collections.emptySet(), "text/plain"); + } finally { + server.stop(0); + } + + Assert.assertEquals("text/plain", accept.get()); + } + + @Test + public void testClassifiesReadTimeout() throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/", exchange -> { + try { + Thread.sleep(500L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + exchange.close(); + }); + server.start(); + OperationsHttpClient client = new OperationsHttpClient(1000, 50, 64); + try { + UpstreamRequestException error = (UpstreamRequestException) + Assert.assertThrows(UpstreamRequestException.class, () -> + client.get(uri(server, "/"), null, null)); + Assert.assertEquals("upstream_timeout", error.getMessage()); + } finally { + server.stop(0); + } + } + + private static HttpServer server(int status, String body, String location, + AtomicReference authorization) + throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/", exchange -> { + if (authorization != null) { + authorization.set(exchange.getRequestHeaders() + .getFirst("Authorization")); + } + if (location != null) { + exchange.getResponseHeaders().set("Location", location); + } + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + return server; + } + + private static URI uri(HttpServer server, String path) { + return URI.create("http://127.0.0.1:" + + server.getAddress().getPort() + path); + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsIdentityContractTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsIdentityContractTest.java new file mode 100644 index 000000000..089b765ee --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsIdentityContractTest.java @@ -0,0 +1,178 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.service.op; + +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.driver.MetricsManager; +import org.apache.hugegraph.driver.VersionManager; +import org.apache.hugegraph.testutil.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +public class OperationsIdentityContractTest { + + private static final String PD_USER = "pd-identity"; + private static final String PD_SECRET = "pd-secret-canary"; + private static final String STORE_USER = "store-identity"; + private static final String STORE_SECRET = "store-secret-canary"; + private static final Clock CLOCK = Clock.fixed( + Instant.ofEpochMilli(2000L), ZoneOffset.UTC); + + @Test + public void testPdAndStoreRequestsUseOnlyTheirOwnIdentities() { + IdentityCapturingHttpClient http = new IdentityCapturingHttpClient(); + LiveOperationsCollector collector = new LiveOperationsCollector( + true, "http://pd.internal:8620", PD_USER, PD_SECRET, + STORE_USER, STORE_SECRET, "server-under-test", http, + new OperationsPayloadParser(new ObjectMapper()), CLOCK, + 2, 1000, Set.of("http://store.internal:8520")); + try { + collector.collect(serverClient(), true); + } finally { + collector.close(); + } + + Assert.assertTrue(http.pdRequests > 0); + Assert.assertEquals(3, http.storeRequests); + Assert.assertFalse(http.captures.isEmpty()); + for (Capture capture : http.captures) { + if (capture.store) { + Assert.assertEquals(STORE_USER, capture.username); + Assert.assertEquals(STORE_SECRET, capture.password); + Assert.assertFalse(PD_USER.equals(capture.username)); + Assert.assertFalse(PD_SECRET.equals(capture.password)); + } else { + Assert.assertEquals(PD_USER, capture.username); + Assert.assertEquals(PD_SECRET, capture.password); + Assert.assertFalse(STORE_USER.equals(capture.username)); + Assert.assertFalse(STORE_SECRET.equals(capture.password)); + } + } + } + + private static HugeClient serverClient() { + HugeClient client = Mockito.mock(HugeClient.class); + VersionManager versions = Mockito.mock(VersionManager.class); + Mockito.when(versions.getCoreVersion()).thenReturn("1.7.0"); + Mockito.when(client.versionManager()).thenReturn(versions); + Mockito.when(client.metrics()).thenReturn(Mockito.mock( + MetricsManager.class)); + return client; + } + + private static final class IdentityCapturingHttpClient + extends OperationsHttpClient { + + private final List captures = new ArrayList<>(); + private int pdRequests; + private int storeRequests; + + private IdentityCapturingHttpClient() { + super(1000, 1000, 8192); + } + + @Override + public synchronized String get(URI target, String username, + String password) { + return this.capture(target, username, password); + } + + @Override + public synchronized String get(URI target, String username, + String password, + Set allowedTargets) { + return this.capture(target, username, password); + } + + @Override + public synchronized String get(URI target, String username, + String password, + Set allowedTargets, + String accept) { + return this.capture(target, username, password); + } + + private String capture(URI target, String username, String password) { + boolean store = "store.internal".equals(target.getHost()); + this.captures.add(new Capture(store, username, password)); + if (store) { + this.storeRequests++; + if (target.getPath().endsWith("/drive")) { + return "{\"disk\":{\"total_space\":10}}"; + } + if (target.getPath().endsWith("/raft")) { + return "{\"0\":{\"enabled\":true}}"; + } + return "{\"heap\":{\"used\":1}}"; + } + this.pdRequests++; + switch (target.getPath()) { + case "/v1/cluster": + return cluster(); + case "/v1/stores": + return stores(); + case "/v1/prom/targets-all": + return targets(); + case "/actuator/prometheus": + return "process_uptime_seconds 1\n"; + default: + throw new AssertionError("Unexpected PD path"); + } + } + + private static String cluster() { + return "{\"status\":0,\"data\":{\"state\":\"Cluster_OK\"," + + "\"pdList\":[],\"stores\":[]}}"; + } + + private static String stores() { + return "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":\"1\",\"address\":\"store.internal:8500\"," + + "\"restAddress\":\"store.internal:8520\"," + + "\"state\":\"Up\"}]}}"; + } + + private static String targets() { + return "[{\"targets\":[\"store.internal:8520\"],\"labels\":{" + + "\"__app_name\":\"store\",\"__scheme__\":\"http\"}}]"; + } + } + + private static final class Capture { + + private final boolean store; + private final String username; + private final String password; + + private Capture(boolean store, String username, String password) { + this.store = store; + this.username = username; + this.password = password; + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsPayloadParserTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsPayloadParserTest.java new file mode 100644 index 000000000..94ae333c0 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsPayloadParserTest.java @@ -0,0 +1,281 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.apache.hugegraph.service.op; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import org.apache.hugegraph.service.op.OperationsModels.Node; +import org.apache.hugegraph.service.op.OperationsModels.Topology; +import org.apache.hugegraph.testutil.Assert; + +public class OperationsPayloadParserTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + public void testParsesPdWrapperAndRedactsInfrastructureFields() + throws Exception { + String cluster = "{\"status\":0,\"data\":{" + + "\"state\":\"Cluster_OK\"," + + "\"pdList\":[{\"restUrl\":\"http://pd-a:8620\"," + + "\"dataPath\":\"/secret/pd\",\"role\":\"Follower\"," + + "\"serviceVersion\":\"1.7.0\"}]," + + "\"pdLeader\":{\"restUrl\":\"http://pd-b:8620\"," + + "\"dataPath\":\"/secret/leader\",\"role\":\"Leader\"," + + "\"serviceVersion\":\"1.7.0\"}," + + "\"graphSize\":2,\"partitionSize\":12," + + "\"shardCount\":3}}"; + String stores = "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":\"7\",\"address\":\"store-a:8500\"," + + "\"deployPath\":\"/secret/bin\"," + + "\"dataPath\":\"/secret/data\",\"state\":\"Up\"," + + "\"version\":\"1.7.0\",\"capacity\":1000," + + "\"available\":400,\"partitionCount\":12," + + "\"leaderCount\":4,\"lastHeartBeat\":100}]}}"; + + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + Topology topology = parser.parseTopology(cluster, stores); + + Assert.assertEquals("UP", topology.getStatus()); + Assert.assertEquals(2L, topology.getFacts().get("graphs")); + Assert.assertEquals(12L, topology.getFacts().get("partitions")); + Assert.assertEquals(3L, topology.getFacts().get("replicas")); + List nodes = topology.getNodes(); + Assert.assertEquals(3, nodes.size()); + Assert.assertEquals("LEADER", nodes.get(1).getRole()); + Assert.assertTrue(nodes.get(2).getId().startsWith("store-")); + Assert.assertTrue(nodes.get(2).getName().matches("Store [0-9a-f]{6}")); + Assert.assertNotEquals("Store 7", nodes.get(2).getName()); + Assert.assertTrue(nodes.get(2).getMetrics().containsKey("backend")); + String serialized = MAPPER.writeValueAsString(topology); + Assert.assertTrue(serialized.contains("\"metric_statuses\"")); + Assert.assertTrue(serialized.contains("\"last_success_at\"")); + Assert.assertFalse(serialized.contains("store-a:8500")); + Assert.assertFalse(serialized.contains("pd-a:8620")); + Assert.assertFalse(serialized.contains("/secret")); + } + + @Test + public void testParsesOnlyStoreMetricTargetsAndInternalHostMap() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + String stores = "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":\"7\",\"address\":\"store-a:8500\"," + + "\"restAddress\":\"store-a:8520\"}]}}"; + String targets = "[{\"targets\":[\"store-a:8520\"]," + + "\"labels\":{\"__app_name\":\"store\"," + + "\"__scheme__\":\"http\"}},{\"targets\":[" + + "\"pd-a:8620\"],\"labels\":{" + + "\"__app_name\":\"pd\",\"__scheme__\":\"http\"}}]"; + + Map hosts = parser.parseStoreHosts(stores); + Map restAddresses = + parser.parseStoreRestAddresses(stores); + Map metricTargets = parser.parseStoreMetricTargets(targets); + + Assert.assertEquals("store-a", hosts.values().iterator().next()); + Assert.assertEquals("store-a:8520", + restAddresses.values().iterator().next()); + Assert.assertEquals(1, metricTargets.size()); + Assert.assertEquals(URI.create("http://store-a:8520"), + metricTargets.get("store-a:8520")); + } + + @Test + public void testKeepsSameHostStoreTargetsDistinctByAuthority() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + String targets = "[{\"targets\":[\"store-a:8520\"," + + "\"store-a:9520\"],\"labels\":{" + + "\"__app_name\":\"store\"," + + "\"__scheme__\":\"http\"}}]"; + + Map metricTargets = parser.parseStoreMetricTargets(targets); + + Assert.assertEquals(2, metricTargets.size()); + Assert.assertEquals(URI.create("http://store-a:8520"), + metricTargets.get("store-a:8520")); + Assert.assertEquals(URI.create("http://store-a:9520"), + metricTargets.get("store-a:9520")); + } + + @Test + public void testParsesIpv6StoreRestAddressAuthority() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + String stores = "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":7,\"address\":\"[2001:db8::1]:8500\"," + + "\"restAddress\":\"[2001:db8::1]:8520\"}]}}"; + + Map restAddresses = + parser.parseStoreRestAddresses(stores); + + Assert.assertEquals("[2001:db8::1]:8520", + restAddresses.values().iterator().next()); + } + + @Test + public void testPdLeaderOverridesContradictoryPdListRoles() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + String cluster = "{\"status\":0,\"data\":{" + + "\"pdList\":[{" + + "\"restUrl\":\"http://pd-a:8620\"," + + "\"role\":\"Leader\"},{" + + "\"restUrl\":\"http://pd-b:8620\"," + + "\"role\":\"Follower\"}]," + + "\"pdLeader\":{" + + "\"restUrl\":\"http://pd-b:8620\"}}}"; + String stores = "{\"status\":0,\"data\":{\"stores\":[]}}"; + + Topology topology = parser.parseTopology(cluster, stores); + + Assert.assertEquals(1L, topology.getNodes().stream() + .filter(node -> "LEADER".equals( + node.getRole())) + .count()); + Node leader = topology.getNodes().stream() + .filter(node -> "LEADER".equals(node.getRole())) + .findFirst().orElseThrow(AssertionError::new); + String expectedId = parser.parseTopology( + "{\"status\":0,\"data\":{\"pdList\":[]," + + "\"pdLeader\":{\"restUrl\":\"http://pd-b:8620\"}}}", + stores).getNodes().get(0).getId(); + Assert.assertEquals(expectedId, leader.getId()); + } + + @Test + public void testRedactsDrivePathsAndSummarizesRaftMetrics() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + + Map drive = parser.parseStoreMetrics("drive", + "{\"/secret/data\":{\"usable_space\":4," + + "\"total_space\":10,\"free_space\":5," + + "\"size_unit\":\"MB\"}}"); + Map raft = parser.parseStoreMetrics("raft", + "{\"0\":{\"enabled\":true,\"metricRegistry\":{" + + "\"secret.path\":1},\"metrics\":{}}," + + "\"1\":{\"enabled\":false,\"metrics\":{}}}"); + + Assert.assertEquals(10L, drive.get("total_space")); + Assert.assertFalse(drive.toString().contains("secret")); + Assert.assertEquals(2L, raft.get("groups")); + Assert.assertEquals(1L, raft.get("enabled_groups")); + Assert.assertFalse(raft.toString().contains("metricRegistry")); + } + + @Test + public void testMissingDriveValuesRemainUnknownInsteadOfZero() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + + Map drive = parser.parseStoreMetrics("drive", + "{\"disk\":{}}"); + + Assert.assertFalse(drive.containsKey("total_space")); + Assert.assertFalse(drive.containsKey("usable_space")); + Assert.assertFalse(drive.containsKey("free_space")); + } + + @Test + public void testParsesWhitelistedPdPrometheusMetrics() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + String payload = "process_uptime_seconds{hg=\"pd\"} 12\n" + + "system_cpu_count{hg=\"pd\"} 2\n" + + "jvm_threads_live_threads{hg=\"pd\"} 8\n" + + "jvm_memory_used_bytes{area=\"heap\"," + + "id=\"secret-pool\"} 100\n" + + "jvm_memory_used_bytes{area=\"nonheap\"} 20\n" + + "untrusted_secret_metric{path=\"/secret\"} 99\n"; + + Map metrics = parser.parsePdPrometheusMetrics(payload); + + Assert.assertEquals(12D, metrics.get("uptime_seconds")); + Assert.assertEquals(100D, metrics.get("heap_used_bytes")); + Assert.assertEquals(20D, metrics.get("nonheap_used_bytes")); + Assert.assertFalse(metrics.toString().contains("secret")); + } + + @Test(expected = MalformedUpstreamException.class) + public void testRejectsPrometheusWithoutRecognizedMetrics() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + + parser.parsePdPrometheusMetrics("garbage\nuntrusted_metric 1\n"); + } + + @Test + public void testAcceptsNumericAndStringStoreIds() throws Exception { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + String cluster = "{\"status\":0,\"data\":{\"pdList\":[]," + + "\"stores\":[{\"storeId\":7,\"state\":\"Up\"}]}}"; + String stores = "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":\"7\",\"state\":\"Up\"}]}}"; + + Topology topology = parser.parseTopology(cluster, stores); + + Assert.assertEquals(1, topology.getNodes().size()); + } + + @Test(expected = MalformedUpstreamException.class) + public void testRejectsSuccessfulWrapperWithoutObjectData() throws Exception { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + parser.parseTopology("{\"status\":0,\"data\":[]}", + "{\"status\":0,\"data\":{\"stores\":[]}}"); + } + + @Test(expected = MalformedUpstreamException.class) + public void testRejectsNonArrayPdList() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + + parser.parseTopology("{\"status\":0,\"data\":{\"pdList\":{}}}", + "{\"status\":0,\"data\":{\"stores\":[]}}"); + } + + @Test(expected = MalformedUpstreamException.class) + public void testRejectsNonArrayStores() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + + parser.parseTopology("{\"status\":0,\"data\":{\"pdList\":[]}}", + "{\"status\":0,\"data\":{\"stores\":{}}}"); + } + + @Test(expected = MalformedUpstreamException.class) + public void testRejectsNonObjectTopologyElement() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + + parser.parseTopology("{\"status\":0,\"data\":{\"pdList\":[1]}}", + "{\"status\":0,\"data\":{\"stores\":[]}}"); + } + + @Test(expected = MalformedUpstreamException.class) + public void testRejectsBooleanTextField() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + + parser.parseTopology("{\"status\":0,\"data\":{\"pdList\":[{" + + "\"restUrl\":true}]}}", + "{\"status\":0,\"data\":{\"stores\":[]}}"); + } + + @Test(expected = MalformedUpstreamException.class) + public void testRejectsNumericPrometheusTarget() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + + parser.parseStoreMetricTargets("[{\"targets\":[8520],\"labels\":{" + + "\"__app_name\":\"store\"}}]"); + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java new file mode 100644 index 000000000..72df7ebae --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java @@ -0,0 +1,227 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.service.space; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +import org.apache.hugegraph.driver.GraphSpaceManager; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.space.GraphSpaceEntity; +import org.apache.hugegraph.service.auth.UserService; +import org.apache.hugegraph.service.graphs.GraphsService; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class GraphSpaceServiceTest { + + private GraphSpaceService service; + private GraphsService graphsService; + private UserService userService; + private HugeClient client; + + @Before + public void setup() { + this.service = new GraphSpaceService(); + this.graphsService = Mockito.mock(GraphsService.class); + this.userService = Mockito.mock(UserService.class); + this.client = Mockito.mock(HugeClient.class); + ReflectionTestUtils.setField(this.service, "graphsService", + this.graphsService); + ReflectionTestUtils.setField(this.service, "userService", + this.userService); + } + + @Test + public void testViewNeverContainsDataPlaneSecrets() { + GraphSpaceEntity entity = new GraphSpaceEntity(); + entity.setName("public"); + entity.setDpUserName("dp-user"); + entity.setDpPassWord("dp-secret"); + entity.setConfigs(new HashMap<>()); + + Map view = this.service.toView(entity); + + Assert.assertEquals("public", view.get("name")); + Assert.assertFalse(view.containsKey("dp_username")); + Assert.assertFalse(view.containsKey("dp_password")); + Assert.assertFalse(view.containsKey("dpUserName")); + Assert.assertFalse(view.containsKey("dpPassWord")); + Assert.assertFalse(view.containsKey("configs")); + } + + @Test + public void testPdListResponseNeverContainsDataPlaneSecrets() { + GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class); + Map profile = new HashMap<>(); + profile.put("name", "public"); + profile.put("create_time", "20260712"); + profile.put("dp_username", "dp-user"); + profile.put("dp_password", "dp-secret"); + profile.put("dpUserName", "dp-user-camel"); + profile.put("dpPassWord", "dp-secret-camel"); + profile.put("configs", new HashMap<>()); + Mockito.when(this.client.graphSpace()).thenReturn(manager); + Mockito.when(manager.listProfile("")).thenReturn( + java.util.Collections.singletonList(profile)); + Mockito.when(this.graphsService.listGraphNames(this.client, + "public", "")) + .thenReturn(java.util.Collections.emptySet()); + Mockito.when(this.userService.listGraphSpaceAdmin(this.client, + "public")) + .thenReturn(java.util.Collections.emptyList()); + + List> response = + this.service.queryAllGs(this.client, "", ""); + + Assert.assertEquals(1, response.size()); + Assert.assertEquals("public", response.get(0).get("name")); + Assert.assertFalse(response.get(0).containsKey("dp_username")); + Assert.assertFalse(response.get(0).containsKey("dp_password")); + Assert.assertFalse(response.get(0).containsKey("dpUserName")); + Assert.assertFalse(response.get(0).containsKey("dpPassWord")); + Assert.assertFalse(response.get(0).containsKey("configs")); + } + + @Test + public void testStatisticUsesActualFallbackDate() { + Mockito.when(this.graphsService.listGraphNames(this.client, "space", "")) + .thenReturn(new LinkedHashSet<>(java.util.Collections + .singletonList("g1"))); + Mockito.when(this.graphsService.evCount(this.client, "space", "g1")) + .thenReturn(statistic("20260712", Integer.valueOf(2), + Long.valueOf(3L))); + + Map result = this.service.evCount(this.client, "space"); + + Assert.assertEquals("2026-07-12", result.get("date")); + Assert.assertEquals(2L, result.get("vertex")); + Assert.assertEquals(3L, result.get("edge")); + } + + @Test + public void testMetricsAcceptsIntegerAndLongCountValues() { + GraphSpaceService spy = Mockito.spy(this.service); + Mockito.doReturn(java.util.Collections.singletonList("space")) + .when(spy).listAll(this.client); + Map counts = new HashMap<>(); + counts.put("vertex", Integer.valueOf(2)); + counts.put("edge", Long.valueOf(3L)); + Mockito.doReturn(counts).when(spy).evCount(this.client, "space"); + Mockito.when(this.graphsService.listGraphNames(this.client, "space", "")) + .thenReturn(java.util.Collections.emptySet()); + + Map result = spy.metrics(this.client); + + Assert.assertEquals(Long.valueOf(2L), result.get("vCount")); + Assert.assertEquals(Long.valueOf(3L), result.get("eCount")); + } + + @Test + public void testStatisticDoesNotClaimMixedDates() { + LinkedHashSet graphs = new LinkedHashSet<>(); + graphs.add("g1"); + graphs.add("g2"); + Mockito.when(this.graphsService.listGraphNames(this.client, "space", "")) + .thenReturn(graphs); + Mockito.when(this.graphsService.evCount(this.client, "space", "g1")) + .thenReturn(statistic("20260712", 2L, 3L)); + Mockito.when(this.graphsService.evCount(this.client, "space", "g2")) + .thenReturn(statistic("20260713", 5L, 7L)); + + Map result = this.service.evCount(this.client, "space"); + + Assert.assertNull(result.get("date")); + Assert.assertEquals(7L, result.get("vertex")); + Assert.assertEquals(10L, result.get("edge")); + } + + @Test + public void testStatisticDoesNotDependOnUnknownDateOrder() { + LinkedHashSet graphs = new LinkedHashSet<>(); + graphs.add("g1"); + graphs.add("g2"); + LinkedHashSet reversedGraphs = new LinkedHashSet<>(); + reversedGraphs.add("g2"); + reversedGraphs.add("g1"); + Mockito.when(this.graphsService.listGraphNames(this.client, "space", "")) + .thenReturn(graphs); + Mockito.when(this.graphsService.evCount(this.client, "space", "g1")) + .thenReturn(statistic(null, 2L, 3L)); + Mockito.when(this.graphsService.evCount(this.client, "space", "g2")) + .thenReturn(statistic("20260713", 5L, 7L)); + + Map forward = this.service.evCount(this.client, "space"); + Mockito.when(this.graphsService.listGraphNames(this.client, "space", "")) + .thenReturn(reversedGraphs); + Map reversed = this.service.evCount(this.client, "space"); + + Assert.assertNull(forward.get("date")); + Assert.assertNull(reversed.get("date")); + Assert.assertEquals(7L, forward.get("vertex")); + Assert.assertEquals(10L, forward.get("edge")); + Assert.assertEquals(7L, reversed.get("vertex")); + Assert.assertEquals(10L, reversed.get("edge")); + } + + @Test + public void testStatisticKeepsUnknownDateWhenAllDatesAreUnknown() { + LinkedHashSet graphs = new LinkedHashSet<>(); + graphs.add("g1"); + graphs.add("g2"); + Mockito.when(this.graphsService.listGraphNames(this.client, "space", "")) + .thenReturn(graphs); + Mockito.when(this.graphsService.evCount(this.client, "space", "g1")) + .thenReturn(statistic(null, 2L, 3L)); + Mockito.when(this.graphsService.evCount(this.client, "space", "g2")) + .thenReturn(statistic(null, 5L, 7L)); + + Map result = this.service.evCount(this.client, "space"); + + Assert.assertNull(result.get("date")); + Assert.assertEquals(7L, result.get("vertex")); + Assert.assertEquals(10L, result.get("edge")); + } + + @Test + public void testStatisticFormatsCurrentDateForEmptyGraphSpace() { + Mockito.when(this.graphsService.listGraphNames(this.client, "space", "")) + .thenReturn(java.util.Collections.emptySet()); + + Map result = this.service.evCount(this.client, "space"); + + Assert.assertTrue(((String) result.get("date")) + .matches("\\d{4}-\\d{2}-\\d{2}")); + Assert.assertEquals(0L, result.get("vertex")); + Assert.assertEquals(0L, result.get("edge")); + } + + private static Map statistic(String date, Number vertex, + Number edge) { + Map statistic = new HashMap<>(); + statistic.put("date", date); + statistic.put("vertex", vertex); + statistic.put("edge", edge); + return statistic; + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java index 11f2b4c47..2675fc1a1 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java @@ -28,8 +28,10 @@ import java.net.InetSocketAddress; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.sun.net.httpserver.HttpServer; import org.apache.http.HttpResponse; import org.apache.http.client.methods.HttpGet; @@ -40,6 +42,7 @@ import org.mockito.Mockito; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.util.ReflectionTestUtils; @@ -56,6 +59,8 @@ import org.apache.hugegraph.controller.auth.LoginController; import org.apache.hugegraph.driver.AuthManager; import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.GraphConnection; +import org.apache.hugegraph.entity.auth.PasswordEntity; import org.apache.hugegraph.entity.auth.UserEntity; import org.apache.hugegraph.exception.ExternalException; import org.apache.hugegraph.exception.InternalException; @@ -68,6 +73,7 @@ import org.apache.hugegraph.handler.LoginInterceptor; import org.apache.hugegraph.handler.MessageSourceHandler; import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.auth.AuthContextService; import org.apache.hugegraph.service.auth.LoginAttemptGuard; import org.apache.hugegraph.service.auth.UserService; import org.apache.hugegraph.structure.auth.Login; @@ -80,6 +86,38 @@ public void tearDown() { RequestContextHolder.resetRequestAttributes(); } + @Test + public void testUserPasswordIsWriteOnlyInJson() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + UserEntity user = mapper.readValue( + "{\"user_name\":\"alice\",\"user_password\":\"secret\"}", + UserEntity.class); + + Assert.assertEquals("secret", user.getPassword()); + String json = mapper.writeValueAsString(user); + Assert.assertFalse(json.contains("user_password")); + Assert.assertFalse(json.contains("secret")); + } + + @Test + public void testCredentialDtosDoNotExposeSecretsInLogs() { + UserEntity user = new UserEntity(); + user.setPassword("user-secret-canary"); + PasswordEntity password = PasswordEntity.builder() + .username("alice") + .oldpwd("old-secret-canary") + .newpwd("new-secret-canary") + .build(); + GraphConnection connection = new GraphConnection(); + connection.setPassword("connection-secret-canary"); + connection.setToken("token-secret-canary"); + connection.setTrustStorePassword("trust-secret-canary"); + + String diagnostic = user + " " + password + " " + connection; + + Assert.assertFalse(diagnostic.contains("secret-canary")); + } + @Test public void testLoginInterceptorRejectsMissingSessionAuth() { LoginInterceptor interceptor = new LoginInterceptor(); @@ -444,26 +482,7 @@ public void testIngestionProxyRejectsUsernameWithoutToken() } @Test - public void testCredentialPasswordIsShortLivedAndNotLegacySessionKey() { - MockHttpServletRequest request = new MockHttpServletRequest(); - RequestContextHolder.setRequestAttributes( - new ServletRequestAttributes(request)); - TestBaseController controller = new TestBaseController(); - - controller.savePassword("pa"); - - Assert.assertEquals("pa", controller.readPassword()); - Assert.assertNull(request.getSession().getAttribute("password")); - - request.getSession().setAttribute(Constant.CREDENTIAL_EXPIRES_AT_KEY, - System.currentTimeMillis() - 1L); - Assert.assertNull(controller.readPassword()); - Assert.assertNull(request.getSession().getAttribute( - Constant.CREDENTIAL_PASSWORD_KEY)); - } - - @Test - public void testClearAuthSessionClearsCredentialState() { + public void testClearAuthSessionClearsIdentityAndToken() { MockHttpServletRequest request = new MockHttpServletRequest(); RequestContextHolder.setRequestAttributes( new ServletRequestAttributes(request)); @@ -471,16 +490,11 @@ public void testClearAuthSessionClearsCredentialState() { request.getSession().setAttribute(Constant.TOKEN_KEY, "token"); request.getSession().setAttribute(Constant.USERNAME_KEY, "admin"); - controller.savePassword("pa"); controller.clearAuth(); Assert.assertNull(request.getSession().getAttribute(Constant.TOKEN_KEY)); Assert.assertNull(request.getSession().getAttribute(Constant.USERNAME_KEY)); - Assert.assertNull(request.getSession().getAttribute( - Constant.CREDENTIAL_PASSWORD_KEY)); - Assert.assertNull(request.getSession().getAttribute( - Constant.CREDENTIAL_EXPIRES_AT_KEY)); } @Test @@ -550,6 +564,34 @@ public void testLoginCommitsAuthAndRotatesSessionAfterValidation() Constant.USERNAME_KEY)); Assert.assertEquals("server-token", request.getSession().getAttribute( Constant.TOKEN_KEY)); + Assert.assertNull(request.getSession().getAttribute("auth_password")); + Assert.assertNull(request.getSession().getAttribute( + "auth_password_expire_at")); + } + + @Test + public void testAuthContextUsesCurrentSessionIdentity() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession().setAttribute(Constant.USERNAME_KEY, "alice"); + request.getSession().setAttribute(Constant.TOKEN_KEY, "server-token"); + HugeClient client = Mockito.mock(HugeClient.class); + request.setAttribute("hugeClient", client); + RequestContextHolder.setRequestAttributes( + new ServletRequestAttributes(request)); + TestLoginController controller = new TestLoginController(); + AuthContextService contexts = Mockito.mock(AuthContextService.class); + Map expected = Map.of("role", "USER"); + Mockito.when(contexts.context(client, "alice")).thenReturn(expected); + setField(controller, "authContextService", contexts); + + ResponseEntity actual = controller.context(); + + Assert.assertSame(expected, actual.getBody()); + Assert.assertEquals("no-store", actual.getHeaders() + .getCacheControl()); + Assert.assertEquals("no-cache", actual.getHeaders() + .getPragma()); + Mockito.verify(contexts).context(client, "alice"); } @Test @@ -576,7 +618,7 @@ public void testPdLoginValidatesUserWithFreshLoginToken() controller.userClient = userClient; UserService users = Mockito.mock(UserService.class); UserEntity entity = new UserEntity(); - Mockito.when(users.getUser(userClient, "admin")).thenReturn(entity); + Mockito.when(users.getpersonal(userClient, "admin")).thenReturn(entity); ReflectionTestUtils.setField(controller, "userService", users); Login login = new Login(); login.name("admin"); @@ -641,7 +683,7 @@ public void testPdLoginClosesClientsWhenUserValidationFails() controller.loginClient = loginClient; controller.userClient = userClient; UserService users = Mockito.mock(UserService.class); - Mockito.when(users.getUser(userClient, "admin")) + Mockito.when(users.getpersonal(userClient, "admin")) .thenThrow(new ExternalException(HttpStatus.UNAUTHORIZED.value(), "expected validation failure")); ReflectionTestUtils.setField(controller, "userService", users); @@ -696,14 +738,6 @@ public HttpResponse execute(MockHttpServletRequest request) private static class TestBaseController extends BaseController { - public void savePassword(String password) { - this.setCredentialPassword(password); - } - - public String readPassword() { - return this.getCredentialPassword(); - } - public void clearAuth() { this.clearAuthSession(); } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthzRouteRegistrationTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthzRouteRegistrationTest.java index e62b86fc5..9e3cb9c9c 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthzRouteRegistrationTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthzRouteRegistrationTest.java @@ -34,6 +34,8 @@ import org.junit.Assert; import org.junit.Test; import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -55,6 +57,17 @@ public void testGraphspaceAuthzControllersAreRegistered() throws Exception { "graphspaces/{graphspace}/auth/users"); } + @Test + public void testSpaceAdminCreationUsesPost() throws Exception { + Class type = Class.forName( + "org.apache.hugegraph.controller.auth.GraphSpaceUserController"); + java.lang.reflect.Method method = type.getMethod( + "setGraphSpaceAdmin", String.class, String.class); + + Assert.assertNotNull(method.getAnnotation(PostMapping.class)); + Assert.assertNull(method.getAnnotation(PutMapping.class)); + } + @Test public void testGraphspaceAuthzServicesAreRegistered() throws Exception { assertService("org.apache.hugegraph.service.auth.TargetService"); diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/BaseControllerGremlinClientTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/BaseControllerGremlinClientTest.java index 0320847c0..0fcff79d0 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/BaseControllerGremlinClientTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/BaseControllerGremlinClientTest.java @@ -38,9 +38,8 @@ public void tearDown() { } @Test - public void testGremlinClientUsesShortLivedBasicCredential() { + public void testGremlinClientIgnoresLegacyPasswordAndUsesToken() { HugeClient tokenClient = Mockito.mock(HugeClient.class); - HugeClient basicClient = Mockito.mock(HugeClient.class); MockHttpServletRequest request = this.requestWithAuth(); request.setAttribute("hugeClient", tokenClient); @@ -48,19 +47,16 @@ public void testGremlinClientUsesShortLivedBasicCredential() { new ServletRequestAttributes(request)); TestController controller = new TestController(); - controller.basicClient = basicClient; + controller.authClient = tokenClient; HugeClient client = controller.gremlinClient("DEFAULT", "hugegraph"); - Assert.assertSame(basicClient, client); - Assert.assertSame(basicClient, request.getAttribute("hugeClient")); - Assert.assertTrue(controller.basicClientCreated); - Assert.assertFalse(controller.authClientCreated); + Assert.assertSame(tokenClient, client); + Assert.assertSame(tokenClient, request.getAttribute("hugeClient")); + Assert.assertTrue(controller.authClientCreated); Assert.assertEquals("DEFAULT", controller.graphSpace); Assert.assertEquals("hugegraph", controller.graph); - Assert.assertEquals("admin", controller.username); - Assert.assertEquals("pa", controller.password); - Mockito.verify(tokenClient).close(); + Mockito.verify(tokenClient, Mockito.never()).close(); } @Test @@ -80,7 +76,6 @@ public void testGremlinClientFallsBackToAuthClientWithoutCredential() { Assert.assertSame(tokenClient, client); Assert.assertSame(tokenClient, request.getAttribute("hugeClient")); - Assert.assertFalse(controller.basicClientCreated); Assert.assertTrue(controller.authClientCreated); Assert.assertEquals("DEFAULT", controller.graphSpace); Assert.assertEquals("hugegraph", controller.graph); @@ -90,40 +85,23 @@ private MockHttpServletRequest requestWithAuth() { MockHttpServletRequest request = new MockHttpServletRequest(); request.getSession().setAttribute(Constant.USERNAME_KEY, "admin"); request.getSession().setAttribute(Constant.TOKEN_KEY, "jwt"); - request.getSession().setAttribute(Constant.CREDENTIAL_PASSWORD_KEY, "pa"); - request.getSession().setAttribute( - Constant.CREDENTIAL_EXPIRES_AT_KEY, - System.currentTimeMillis() + Constant.CREDENTIAL_TTL_MILLIS); + request.getSession().setAttribute("auth_password", "pa"); + request.getSession().setAttribute("auth_password_expire_at", + System.currentTimeMillis() + 10000L); return request; } private static class TestController extends BaseController { - private HugeClient basicClient; private HugeClient authClient; - private boolean basicClientCreated; private boolean authClientCreated; private String graphSpace; private String graph; - private String username; - private String password; HugeClient gremlinClient(String graphSpace, String graph) { return this.authGremlinClient(graphSpace, graph); } - @Override - protected HugeClient createBasicClient(String graphSpace, String graph, - String username, - String password) { - this.basicClientCreated = true; - this.graphSpace = graphSpace; - this.graph = graph; - this.username = username; - this.password = password; - return this.basicClient; - } - @Override protected HugeClient authClient(String graphSpace, String graph) { this.authClientCreated = true; diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/DashboardControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/DashboardControllerTest.java index bd8cbc171..d43adab73 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/DashboardControllerTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/DashboardControllerTest.java @@ -18,8 +18,11 @@ package org.apache.hugegraph.unit; +import java.io.IOException; +import java.net.InetSocketAddress; import java.util.Map; +import com.sun.net.httpserver.HttpServer; import org.junit.Test; import org.mockito.Mockito; import org.springframework.test.util.ReflectionTestUtils; @@ -32,21 +35,76 @@ public class DashboardControllerTest { @Test - public void testReturnsConfiguredProtocolAndHostPortWithoutProbing() { + public void testReturnsConfiguredHealthyDashboard() throws IOException { + HttpServer server = dashboardServer(204); HugeConfig config = Mockito.mock(HugeConfig.class); Mockito.when(config.get(HubbleOptions.DASHBOARD_ADDRESS)) - .thenReturn("127.0.0.1:8092"); + .thenReturn("127.0.0.1:" + server.getAddress().getPort()); Mockito.when(config.get(HubbleOptions.SERVER_PROTOCOL)) - .thenReturn("https"); + .thenReturn("http"); DashboardController controller = new DashboardController(); ReflectionTestUtils.setField(controller, "config", config); - Map result = controller.listOperations(); + Map result; + try { + result = controller.listOperations(); + } finally { + server.stop(0); + } - Assert.assertEquals("127.0.0.1:8092", result.get("address")); - Assert.assertEquals("https", result.get("protocol")); + Assert.assertEquals(true, result.get("configured")); + Assert.assertEquals(true, result.get("available")); + Assert.assertEquals("http", result.get("protocol")); Mockito.verify(config).get(HubbleOptions.DASHBOARD_ADDRESS); Mockito.verify(config).get(HubbleOptions.SERVER_PROTOCOL); Mockito.verifyNoMoreInteractions(config); } + + @Test + public void testReportsHttpFailureAsUnavailable() throws IOException { + HttpServer server = dashboardServer(500); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.DASHBOARD_ADDRESS)) + .thenReturn("127.0.0.1:" + server.getAddress().getPort()); + Mockito.when(config.get(HubbleOptions.SERVER_PROTOCOL)) + .thenReturn("http"); + DashboardController controller = new DashboardController(); + ReflectionTestUtils.setField(controller, "config", config); + + Map result; + try { + result = controller.listOperations(); + } finally { + server.stop(0); + } + + Assert.assertEquals(true, result.get("configured")); + Assert.assertEquals(false, result.get("available")); + } + + @Test + public void testReturnsUnconfiguredAsNormalCapabilityState() { + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.DASHBOARD_ADDRESS)).thenReturn(""); + DashboardController controller = new DashboardController(); + ReflectionTestUtils.setField(controller, "config", config); + + Map result = controller.listOperations(); + + Assert.assertEquals(false, result.get("configured")); + Assert.assertFalse(result.containsKey("address")); + Assert.assertFalse(result.containsKey("protocol")); + Mockito.verify(config).get(HubbleOptions.DASHBOARD_ADDRESS); + Mockito.verifyNoMoreInteractions(config); + } + + private static HttpServer dashboardServer(int status) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/", exchange -> { + exchange.sendResponseHeaders(status, -1L); + exchange.close(); + }); + server.start(); + return server; + } } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingSchemaTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingSchemaTest.java index c90c9687e..0327cae3c 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingSchemaTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingSchemaTest.java @@ -92,6 +92,40 @@ public void testSchemaMigratorAddsExecuteHistoryFailureReasonIdempotently() } } + @Test + public void testSchemaMigratorRemovesLegacyLoadTaskCredentials() + throws Exception { + String url = "jdbc:h2:mem:load_task_credentials;DB_CLOSE_DELAY=-1"; + try (Connection conn = DriverManager.getConnection(url); + Statement statement = conn.createStatement()) { + statement.execute("CREATE TABLE `load_task` (" + + "`id` INT NOT NULL AUTO_INCREMENT, " + + "`options` VARCHAR(65535) NOT NULL, " + + "PRIMARY KEY (`id`))"); + statement.execute("INSERT INTO `load_task` (`options`) VALUES (" + + "'{\"graph\":\"hugegraph\"," + + "\"password\":\"canary-password\"," + + "\"token\":\"canary-token\"," + + "\"pdToken\":\"canary-pd-token\"," + + "\"trustStoreToken\":" + + "\"canary-truststore-token\"," + + "\"futureOption\":\"preserved\"}')"); + + DatabaseSchemaMigrator migrator = new DatabaseSchemaMigrator(); + migrator.migrate(conn); + migrator.migrate(conn); + + try (ResultSet rows = statement.executeQuery( + "SELECT `options` FROM `load_task`")) { + Assert.assertTrue(rows.next()); + String options = rows.getString(1); + Assert.assertFalse(options.contains("canary-")); + Assert.assertContains("futureOption", options); + Assert.assertContains("preserved", options); + } + } + } + private void insertDeepPath(Connection conn, String deepPath) throws Exception { try (PreparedStatement insert = conn.prepareStatement( diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java index 341cc80b3..b381e293c 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java @@ -20,18 +20,31 @@ import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; +import org.apache.hugegraph.api.graph.GraphMetricsAPI; +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.driver.GraphManager; import org.apache.hugegraph.driver.GraphsManager; import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.graphs.GraphStatisticsEntity; import org.apache.hugegraph.exception.ExternalException; +import org.apache.hugegraph.exception.ServerException; +import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.service.graphs.GraphsService; +import org.apache.hugegraph.service.query.QueryService; +import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.Vertex; +import org.apache.hugegraph.structure.gremlin.ResultSet; public class GraphsServiceDefaultTest { @@ -137,4 +150,242 @@ public void testSetFailureDoesNotClearExistingDefault() { Mockito.verify(this.graphs, Mockito.never()).unSetDefault(Mockito.anyString()); } + + @Test + public void testCreateStandaloneGraphIncludesRequiredFactory() { + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(false); + ReflectionTestUtils.setField(this.service, "config", config); + + this.service.create(this.client, "Demo", "demo", null); + + ArgumentCaptor configJson = ArgumentCaptor.forClass(String.class); + Mockito.verify(this.graphs).createGraph(Mockito.eq("demo"), + configJson.capture()); + Assert.assertTrue(configJson.getValue().contains( + "\"gremlin.graph\":\"org.apache.hugegraph.auth." + + "HugeFactoryAuthProxy\"")); + Assert.assertTrue(configJson.getValue().contains( + "\"backend\":\"rocksdb\"")); + Assert.assertTrue(configJson.getValue().contains( + "\"task.scheduler_type\":\"local\"")); + Assert.assertTrue(configJson.getValue().contains( + "\"rocksdb.data_path\":\"rocksdb-data/data_demo\"")); + Assert.assertTrue(configJson.getValue().contains( + "\"rocksdb.wal_path\":\"rocksdb-data/wal_demo\"")); + } + + @Test + public void testCreateStandaloneGraphRejectsUnsafeName() { + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(false); + ReflectionTestUtils.setField(this.service, "config", config); + + try { + this.service.create(this.client, "Unsafe", "../unsafe", null); + Assert.fail("Expected unsafe graph name to be rejected"); + } catch (ExternalException ignored) { + // Expected. + } + Mockito.verify(this.graphs, Mockito.never()) + .createGraph(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void testSmallStatisticsFallsBackToBoundedGraphReads() { + QueryService query = Mockito.mock(QueryService.class); + Mockito.when(query.executeQueryCount(Mockito.eq(this.client), + Mockito.anyString())) + .thenThrow(new ExternalException("gremlin.execute.failed")); + ReflectionTestUtils.setField(this.service, "queryService", query); + + GraphManager graph = Mockito.mock(GraphManager.class); + Mockito.when(this.client.graph()).thenReturn(graph); + Vertex personA = Mockito.mock(Vertex.class); + Vertex personB = Mockito.mock(Vertex.class); + Edge relation = Mockito.mock(Edge.class); + Mockito.when(personA.label()).thenReturn("人物"); + Mockito.when(personB.label()).thenReturn("人物"); + Mockito.when(relation.label()).thenReturn("关系"); + Mockito.when(graph.iterateVertices(1000)) + .thenReturn(Arrays.asList(personA, personB).iterator()); + Mockito.when(graph.iterateEdges(1000)) + .thenReturn(Collections.singletonList(relation).iterator()); + + GraphStatisticsEntity result = + this.service.postSmallStatistics(this.client, "DEFAULT", "demo"); + + Assert.assertEquals("2", result.getVertexCount()); + Assert.assertEquals("1", result.getEdgeCount()); + Assert.assertEquals(2L, result.getVertices().get("人物")); + Assert.assertEquals(1L, result.getEdges().get("关系")); + } + + @Test + public void testSmallStatisticsStopsBeforeLoadingEdgesAboveLimit() { + QueryService query = Mockito.mock(QueryService.class); + Mockito.when(query.executeQueryCount(Mockito.eq(this.client), + Mockito.anyString())) + .thenThrow(new ExternalException("gremlin.execute.failed")); + ReflectionTestUtils.setField(this.service, "queryService", query); + + GraphManager graph = Mockito.mock(GraphManager.class); + Mockito.when(this.client.graph()).thenReturn(graph); + Vertex vertex = Mockito.mock(Vertex.class); + Mockito.when(vertex.label()).thenReturn("person"); + Mockito.when(graph.iterateVertices(1000)) + .thenReturn(Collections.nCopies(10001, vertex).iterator()); + + try { + this.service.postSmallStatistics(this.client, "DEFAULT", "demo"); + Assert.fail("Expected bounded statistics failure"); + } catch (ExternalException ignored) { + // Expected: the fallback stops as soon as the limit is exceeded. + } + + Mockito.verify(graph, Mockito.never()).iterateEdges(Mockito.anyInt()); + } + + @Test + public void testSmallStatisticsRejectsEmptyGremlinCountResponse() { + QueryService query = Mockito.mock(QueryService.class); + ResultSet empty = Mockito.mock(ResultSet.class); + Mockito.when(empty.data()).thenReturn(Collections.emptyList()); + Mockito.when(query.executeQueryCount(Mockito.eq(this.client), + Mockito.anyString())) + .thenReturn(empty); + ReflectionTestUtils.setField(this.service, "queryService", query); + + GraphManager graph = Mockito.mock(GraphManager.class); + Mockito.when(this.client.graph()).thenReturn(graph); + Vertex vertex = Mockito.mock(Vertex.class); + Edge edge = Mockito.mock(Edge.class); + Mockito.when(vertex.label()).thenReturn("person"); + Mockito.when(edge.label()).thenReturn("knows"); + Mockito.when(graph.iterateVertices(1000)) + .thenReturn(Collections.singletonList(vertex).iterator()); + Mockito.when(graph.iterateEdges(1000)) + .thenReturn(Collections.singletonList(edge).iterator()); + + GraphStatisticsEntity result = + this.service.postSmallStatistics(this.client, "DEFAULT", "demo"); + + Assert.assertEquals("1", result.getVertexCount()); + Assert.assertEquals("1", result.getEdgeCount()); + } + + @Test + public void testElementCountPrefersAvailableDailySnapshot() { + GraphManager graph = Mockito.mock(GraphManager.class); + Mockito.when(this.client.graph()).thenReturn(graph); + GraphMetricsAPI.ElementCount snapshot = new GraphMetricsAPI.ElementCount(); + snapshot.setVertices(12L); + snapshot.setEdges(8L); + Mockito.when(graph.getEVCount(Mockito.anyString())).thenReturn(snapshot); + + Map result = + this.service.evCount(this.client, "DEFAULT", "demo"); + + Assert.assertEquals(12L, result.get("vertex")); + Assert.assertEquals(8L, result.get("edge")); + Assert.assertNotNull(result.get("date")); + Mockito.verify(graph, Mockito.never()).iterateVertices(Mockito.anyInt()); + Mockito.verify(graph, Mockito.never()).iterateEdges(Mockito.anyInt()); + } + + @Test + public void testElementCountFallsBackToLiveSmallGraph() { + QueryService query = Mockito.mock(QueryService.class); + ReflectionTestUtils.setField(this.service, "queryService", query); + + GraphManager graph = Mockito.mock(GraphManager.class); + Mockito.when(this.client.graph()).thenReturn(graph); + Mockito.when(graph.getEVCount(Mockito.anyString())).thenReturn(null); + Vertex vertex = Mockito.mock(Vertex.class); + Edge edge = Mockito.mock(Edge.class); + Mockito.when(vertex.label()).thenReturn("person"); + Mockito.when(edge.label()).thenReturn("knows"); + Mockito.when(graph.iterateVertices(1000)) + .thenReturn(Collections.nCopies(7, vertex).iterator()); + Mockito.when(graph.iterateEdges(1000)) + .thenReturn(Collections.nCopies(6, edge).iterator()); + + Map result = + this.service.evCount(this.client, "DEFAULT", "demo"); + + Assert.assertEquals(7L, result.get("vertex")); + Assert.assertEquals(6L, result.get("edge")); + Assert.assertNotNull(result.get("date")); + Mockito.verifyZeroInteractions(query); + } + + @Test + public void testElementCountReturnsUnavailableWhenLiveFallbackFails() { + QueryService query = Mockito.mock(QueryService.class); + Mockito.when(query.executeQueryCount(Mockito.eq(this.client), + Mockito.anyString())) + .thenThrow(new ExternalException("gremlin.execute.failed")); + ReflectionTestUtils.setField(this.service, "queryService", query); + + GraphManager graph = Mockito.mock(GraphManager.class); + Mockito.when(this.client.graph()).thenReturn(graph); + Mockito.when(graph.getEVCount(Mockito.anyString())).thenReturn(null); + Mockito.when(graph.iterateVertices(1000)) + .thenThrow(new ExternalException("paging.unsupported")); + + Map result = + this.service.evCount(this.client, "DEFAULT", "demo"); + + Assert.assertNull(result.get("vertex")); + Assert.assertNull(result.get("edge")); + Assert.assertNull(result.get("date")); + } + + @Test + public void testGraphProfilesFallBackForStandaloneServer() { + Mockito.when(this.graphs.listProfile("huge")) + .thenThrow(new ServerException("profile unsupported")); + Mockito.when(this.graphs.listGraph()) + .thenReturn(Arrays.asList("hugegraph", "other")); + Mockito.when(this.graphs.getGraph("hugegraph")) + .thenReturn(Collections.singletonMap("backend", "rocksdb")); + Mockito.when(this.client.assignGraph("DEFAULT", "hugegraph")) + .thenReturn(this.client); + GraphManager graph = Mockito.mock(GraphManager.class); + Mockito.when(this.client.graph()).thenReturn(graph); + GraphMetricsAPI.ElementCount snapshot = new GraphMetricsAPI.ElementCount(); + snapshot.setVertices(2L); + snapshot.setEdges(1L); + Mockito.when(graph.getEVCount(Mockito.anyString())).thenReturn(snapshot); + + List> result = + this.service.sortedGraphsProfile(this.client, "DEFAULT", "huge", + "", false, + Collections.emptyMap()); + + Assert.assertEquals(1, result.size()); + Assert.assertEquals("hugegraph", result.get(0).get("name")); + Assert.assertEquals("rocksdb", result.get(0).get("backend")); + Assert.assertEquals("DEFAULT", result.get(0).get("graphspace")); + Mockito.verify(this.graphs).getGraph("hugegraph"); + Mockito.verify(this.graphs, Mockito.never()).getGraph("other"); + } + + @Test + public void testGraphProfilesDoNotMaskForbiddenResponse() { + ServerException forbidden = new ServerException("forbidden"); + forbidden.status(403); + Mockito.when(this.graphs.listProfile("")) + .thenThrow(forbidden); + + try { + this.service.sortedGraphsProfile(this.client, "DEFAULT", "", "", + false, Collections.emptyMap()); + Assert.fail("Expected forbidden response"); + } catch (ServerException actual) { + Assert.assertSame(forbidden, actual); + } + + Mockito.verify(this.graphs, Mockito.never()).listGraph(); + } } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HubbleOptionsTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HubbleOptionsTest.java index 453408899..3fe67f923 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HubbleOptionsTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HubbleOptionsTest.java @@ -18,6 +18,8 @@ package org.apache.hugegraph.unit; +import org.apache.hugegraph.config.ConfigException; +import org.apache.hugegraph.config.ConfigOption; import org.junit.Test; import org.apache.hugegraph.options.HubbleOptions; @@ -32,4 +34,90 @@ public void testUploadFormatDefaultIncludesCsvAndTxt() { Assert.assertTrue(HubbleOptions.UPLOAD_FILE_FORMAT_LIST.defaultValue() .contains("txt")); } + + @Test + public void testOperationsCollectionUsesBoundedDefaults() { + Assert.assertEquals(1500, + HubbleOptions.OPERATIONS_CONNECT_TIMEOUT.defaultValue()); + Assert.assertEquals(2500, + HubbleOptions.OPERATIONS_READ_TIMEOUT.defaultValue()); + Assert.assertEquals(1024 * 1024, + HubbleOptions.OPERATIONS_MAX_RESPONSE_BYTES.defaultValue()); + Assert.assertEquals(5, + HubbleOptions.OPERATIONS_CACHE_TTL.defaultValue()); + Assert.assertEquals(1024, + HubbleOptions.OPERATIONS_CACHE_MAX_ENTRIES + .defaultValue()); + Assert.assertEquals(16, + HubbleOptions.OPERATIONS_STORE_THREADS.defaultValue()); + Assert.assertEquals(5000, + HubbleOptions.OPERATIONS_STORE_DEADLINE.defaultValue()); + Assert.assertEquals(java.util.Arrays.asList( + "http://127.0.0.1:8520", + "http://[::1]:8520"), + HubbleOptions.OPERATIONS_STORE_ALLOWED_TARGETS + .defaultValue()); + Assert.assertEquals("hubble", + HubbleOptions.OPERATIONS_PD_USERNAME.defaultValue()); + Assert.assertEquals("", + HubbleOptions.OPERATIONS_PD_PASSWORD.defaultValue()); + Assert.assertEquals("hubble", + HubbleOptions.OPERATIONS_STORE_USERNAME.defaultValue()); + Assert.assertEquals("", + HubbleOptions.OPERATIONS_STORE_PASSWORD.defaultValue()); + } + + @Test + public void testOperationsCacheEntryBoundIsPositive() { + ConfigOption option = + HubbleOptions.OPERATIONS_CACHE_MAX_ENTRIES; + + Assert.assertEquals(1024, option.defaultValue()); + Assert.assertThrows(ConfigException.class, + () -> option.parseConvert("0")); + Assert.assertThrows(ConfigException.class, + () -> option.parseConvert("-1")); + Assert.assertEquals(1, option.parseConvert("1")); + } + + @Test + public void testOperationsStoreFanoutLimitsArePositive() { + assertPositive(HubbleOptions.OPERATIONS_STORE_THREADS); + assertPositive(HubbleOptions.OPERATIONS_STORE_DEADLINE); + } + + @Test + public void testOperationsStoreAllowedTargetsRequireExactOrigins() { + Assert.assertThrows(ConfigException.class, () -> + HubbleOptions.OPERATIONS_STORE_ALLOWED_TARGETS.parseConvert( + "[store.internal]")); + Assert.assertThrows(ConfigException.class, () -> + HubbleOptions.OPERATIONS_STORE_ALLOWED_TARGETS.parseConvert( + "[http://*.internal:8520]")); + Assert.assertThrows(ConfigException.class, () -> + HubbleOptions.OPERATIONS_STORE_ALLOWED_TARGETS.parseConvert( + "[http://store.internal]")); + Assert.assertThrows(ConfigException.class, () -> + HubbleOptions.OPERATIONS_STORE_ALLOWED_TARGETS.parseConvert( + "[http://store.internal:8520/metrics]")); + Assert.assertEquals(java.util.Arrays.asList( + "http://store.internal:8520", + "https://store.internal:9443"), + HubbleOptions.OPERATIONS_STORE_ALLOWED_TARGETS.parseConvert( + "[http://store.internal:8520," + + "https://store.internal:9443]")); + Assert.assertEquals(java.util.Arrays.asList( + "http://127.0.0.1:8520", + "http://[::1]:8520"), + HubbleOptions.OPERATIONS_STORE_ALLOWED_TARGETS.parseConvert( + "[http://127.0.0.1:8520,http://[::1]:8520]")); + } + + private static void assertPositive(ConfigOption option) { + Assert.assertThrows(ConfigException.class, + () -> option.parseConvert("0")); + Assert.assertThrows(ConfigException.class, + () -> option.parseConvert("-1")); + Assert.assertEquals(1, option.parseConvert("1")); + } } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HugeClientPoolServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HugeClientPoolServiceTest.java new file mode 100644 index 000000000..e4b147c44 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HugeClientPoolServiceTest.java @@ -0,0 +1,240 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.config.ConfigException; +import org.apache.hugegraph.driver.factory.PDHugeClientFactory; +import org.apache.hugegraph.exception.ParameterizedException; +import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.HugeClientPoolService; +import org.apache.hugegraph.testutil.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class HugeClientPoolServiceTest { + + private static final String CLUSTER = "cluster"; + private static final String GRAPH_SPACE = "space"; + private static final String SERVICE = "service"; + private static final String URL = "http://127.0.0.1:8080"; + + private HugeClientPoolService service; + private PDHugeClientFactory factory; + + @Before + public void setup() { + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(config.get(HubbleOptions.CLIENT_URL_CACHE_MAX_ENTRIES)) + .thenReturn(1024); + this.factory = Mockito.mock(PDHugeClientFactory.class); + this.service = new HugeClientPoolService(); + ReflectionTestUtils.setField(this.service, "config", config); + ReflectionTestUtils.setField(this.service, "cluster", CLUSTER); + ReflectionTestUtils.setField(this.service, "pdHugeClientFactory", + this.factory); + ReflectionTestUtils.invokeMethod(this.service, "initializeUrlCache"); + } + + @Test + public void testUseWarmCacheWhenDiscoveryThrows() { + this.stubSuccessfulDiscovery(GRAPH_SPACE, SERVICE, URL); + Assert.assertEquals(Collections.singletonList(URL), + this.allAvailableURLs(GRAPH_SPACE, SERVICE)); + + Mockito.reset(this.factory); + Mockito.when(this.factory.getURLs(Mockito.anyString(), + Mockito.anyString(), + Mockito.nullable(String.class))) + .thenThrow(new IllegalStateException("PD unavailable")); + + Assert.assertEquals(Collections.singletonList(URL), + this.allAvailableURLs(GRAPH_SPACE, SERVICE)); + } + + @Test + public void testFailClosedWhenColdDiscoveryThrows() { + Mockito.when(this.factory.getURLs(Mockito.anyString(), + Mockito.anyString(), + Mockito.nullable(String.class))) + .thenThrow(new IllegalStateException("PD unavailable")); + + Assert.assertThrows(ParameterizedException.class, () -> + this.service.create(null, GRAPH_SPACE, SERVICE, "token")); + } + + @Test + public void testDoNotReuseCacheAcrossGraphSpaces() { + this.stubSuccessfulDiscovery(GRAPH_SPACE, SERVICE, URL); + Assert.assertEquals(Collections.singletonList(URL), + this.allAvailableURLs(GRAPH_SPACE, SERVICE)); + + Mockito.reset(this.factory); + Mockito.when(this.factory.getURLs(Mockito.anyString(), + Mockito.anyString(), + Mockito.nullable(String.class))) + .thenThrow(new IllegalStateException("PD unavailable")); + + Assert.assertEquals(Collections.emptyList(), + this.allAvailableURLs("other-space", SERVICE)); + } + + @Test + public void testDoNotReuseCacheAcrossServices() { + this.stubSuccessfulDiscovery(GRAPH_SPACE, SERVICE, URL); + Assert.assertEquals(Collections.singletonList(URL), + this.allAvailableURLs(GRAPH_SPACE, SERVICE)); + + Mockito.reset(this.factory); + Mockito.when(this.factory.getURLs(Mockito.anyString(), + Mockito.anyString(), + Mockito.nullable(String.class))) + .thenThrow(new IllegalStateException("PD unavailable")); + + Assert.assertEquals(Collections.emptyList(), + this.allAvailableURLs(GRAPH_SPACE, "other-service")); + } + + @Test + public void testDoNotReuseCacheAcrossAmbiguousScopeNames() { + this.stubSuccessfulDiscovery("space_a", "service", URL); + Assert.assertEquals(Collections.singletonList(URL), + this.allAvailableURLs("space_a", "service")); + + Mockito.reset(this.factory); + Mockito.when(this.factory.getURLs(Mockito.anyString(), + Mockito.anyString(), + Mockito.nullable(String.class))) + .thenThrow(new IllegalStateException("PD unavailable")); + + Assert.assertEquals(Collections.emptyList(), + this.allAvailableURLs("space", "a_service")); + } + + @Test + public void testExplicitSpaceDoesNotFallbackToDefaultCache() { + String defaultUrl = "http://127.0.0.1:8081"; + Mockito.when(this.factory.getURLs( + CLUSTER, PDHugeClientFactory.DEFAULT_GRAPHSPACE, + PDHugeClientFactory.DEFAULT_SERVICE)) + .thenReturn(Collections.singletonList(defaultUrl)); + Assert.assertEquals(Collections.singletonList(defaultUrl), + this.allAvailableURLs(null, null)); + + this.stubSuccessfulDiscovery("space-a", SERVICE, URL); + Assert.assertEquals(Collections.singletonList(URL), + this.allAvailableURLs("space-a", SERVICE)); + + Mockito.reset(this.factory); + Mockito.when(this.factory.getURLs(Mockito.anyString(), + Mockito.anyString(), + Mockito.nullable(String.class))) + .thenThrow(new IllegalStateException("PD unavailable")); + + Assert.assertEquals(Collections.emptyList(), + this.allAvailableURLs("space-b", SERVICE)); + } + + @Test + public void testUrlCacheBoundEvictsOldScopesAndKeepsWarmFallback() { + HugeConfig config = (HugeConfig) ReflectionTestUtils.getField( + this.service, "config"); + Mockito.when(config.get(HubbleOptions.CLIENT_URL_CACHE_MAX_ENTRIES)) + .thenReturn(2); + ReflectionTestUtils.invokeMethod(this.service, "initializeUrlCache"); + + this.stubSuccessfulDiscovery("space-a", SERVICE, + "http://127.0.0.1:8081"); + this.allAvailableURLs("space-a", SERVICE); + this.stubSuccessfulDiscovery("space-b", SERVICE, + "http://127.0.0.1:8082"); + this.allAvailableURLs("space-b", SERVICE); + this.stubSuccessfulDiscovery("space-c", SERVICE, + "http://127.0.0.1:8083"); + this.allAvailableURLs("space-c", SERVICE); + + Mockito.reset(this.factory); + Mockito.when(this.factory.getURLs(Mockito.anyString(), + Mockito.anyString(), + Mockito.nullable(String.class))) + .thenThrow(new IllegalStateException("PD unavailable")); + + Assert.assertEquals(Collections.emptyList(), + this.allAvailableURLs("space-a", SERVICE)); + Assert.assertEquals(Collections.singletonList( + "http://127.0.0.1:8083"), + this.allAvailableURLs("space-c", SERVICE)); + } + + @Test + public void testUrlCacheMaximumHasPositiveDefaultContract() { + Assert.assertEquals(1024, + HubbleOptions.CLIENT_URL_CACHE_MAX_ENTRIES + .defaultValue()); + Assert.assertThrows(ConfigException.class, () -> + HubbleOptions.CLIENT_URL_CACHE_MAX_ENTRIES.parseConvert("0")); + Assert.assertThrows(ConfigException.class, () -> + HubbleOptions.CLIENT_URL_CACHE_MAX_ENTRIES.parseConvert("-1")); + } + + @Test + public void testInvalidServiceUrlDoesNotExposeRawValue() { + String raw = "http://user:secret@[malformed/private"; + + try { + this.service.create(raw, GRAPH_SPACE, SERVICE, "token"); + Assert.fail("Expected invalid service URL to be rejected"); + } catch (ParameterizedException e) { + Assert.assertEquals("service.url.parse.error", e.getMessage()); + Assert.assertEquals(1, e.args().length); + Assert.assertEquals("[REDACTED]", e.args()[0]); + Assert.assertFalse(e.toString().contains("user")); + Assert.assertFalse(e.toString().contains("secret")); + Assert.assertFalse(e.toString().contains("malformed")); + Assert.assertFalse(e.toString().contains("private")); + } + } + + private void stubSuccessfulDiscovery(String graphSpace, String service, + String url) { + List urls = new ArrayList<>(); + urls.add(url); + Mockito.when(this.factory.getURLs(CLUSTER, graphSpace, service)) + .thenReturn(urls); + Mockito.when(this.factory.getURLs(CLUSTER, graphSpace, null)) + .thenReturn(Collections.emptyList()); + Mockito.when(this.factory.getURLs( + CLUSTER, PDHugeClientFactory.DEFAULT_GRAPHSPACE, + PDHugeClientFactory.DEFAULT_SERVICE)) + .thenReturn(Collections.emptyList()); + } + + @SuppressWarnings("unchecked") + private List allAvailableURLs(String graphSpace, String service) { + return (List) ReflectionTestUtils.invokeMethod( + this.service, "allAvailableURLs", graphSpace, service); + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/JobManagerServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/JobManagerServiceTest.java index 20439feae..6d06e4812 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/JobManagerServiceTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/JobManagerServiceTest.java @@ -27,10 +27,14 @@ import org.apache.hugegraph.entity.enums.JobStatus; import org.apache.hugegraph.entity.enums.LoadStatus; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.GraphConnection; +import org.apache.hugegraph.entity.load.FileMapping; import org.apache.hugegraph.entity.load.JobManager; import org.apache.hugegraph.entity.load.LoadTask; import org.apache.hugegraph.mapper.load.JobManagerMapper; import org.apache.hugegraph.service.load.JobManagerService; +import org.apache.hugegraph.service.load.FileMappingService; import org.apache.hugegraph.service.load.LoadTaskService; import org.apache.hugegraph.testutil.Assert; @@ -119,6 +123,59 @@ public void testRefreshStatusKeepsLoadingWhenAnyLoadTaskRuns() Mockito.verify(mapper, Mockito.never()).updateById(Mockito.any()); } + @Test + public void testCreateIngestTaskPersistsOneAtomicUnit() throws Exception { + JobManagerService service = this.service(); + JobManagerMapper mapper = Mockito.mock(JobManagerMapper.class); + FileMappingService mappingService = Mockito.mock(FileMappingService.class); + LoadTaskService taskService = Mockito.mock(LoadTaskService.class); + this.setField(service, "mapper", mapper); + this.setField(service, "fileMappingService", mappingService); + this.setField(service, "taskService", taskService); + + JobManager job = JobManager.builder().id(7).build(); + FileMapping mapping = new FileMapping(); + GraphConnection connection = new GraphConnection(); + HugeClient client = Mockito.mock(HugeClient.class); + LoadTask task = LoadTask.builder().id(9).build(); + Mockito.when(mapper.insert(job)).thenReturn(1); + Mockito.when(taskService.start(connection, mapping, client)) + .thenReturn(task); + + Assert.assertSame(task, service.createIngestTask(job, mapping, + connection, client)); + Assert.assertEquals(7, mapping.getJobId().intValue()); + Mockito.verify(mapper).insert(job); + Mockito.verify(mappingService).save(mapping); + Mockito.verify(taskService).start(connection, mapping, client); + } + + @Test + public void testCreateIngestTaskPropagatesLoadBuildFailure() + throws Exception { + JobManagerService service = this.service(); + JobManagerMapper mapper = Mockito.mock(JobManagerMapper.class); + FileMappingService mappingService = Mockito.mock(FileMappingService.class); + LoadTaskService taskService = Mockito.mock(LoadTaskService.class); + this.setField(service, "mapper", mapper); + this.setField(service, "fileMappingService", mappingService); + this.setField(service, "taskService", taskService); + + JobManager job = JobManager.builder().id(7).build(); + FileMapping mapping = new FileMapping(); + GraphConnection connection = new GraphConnection(); + HugeClient client = Mockito.mock(HugeClient.class); + Mockito.when(mapper.insert(job)).thenReturn(1); + Mockito.when(taskService.start(connection, mapping, client)) + .thenThrow(new IllegalArgumentException("invalid mapping")); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + service.createIngestTask(job, mapping, connection, client); + }); + Mockito.verify(mappingService).save(mapping); + Mockito.verify(taskService).start(connection, mapping, client); + } + private JobManagerService service() { return new JobManagerService(); } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/K8sTokenEndpointSecurityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/K8sTokenEndpointSecurityTest.java index ffe4a08fe..5a2ad1b7d 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/K8sTokenEndpointSecurityTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/K8sTokenEndpointSecurityTest.java @@ -18,27 +18,19 @@ package org.apache.hugegraph.unit; -import java.lang.reflect.Method; - -import org.apache.hugegraph.controller.op.K8sTokenController; import org.junit.Assert; import org.junit.Test; -import org.springframework.core.annotation.AnnotatedElementUtils; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; public class K8sTokenEndpointSecurityTest { @Test - public void testK8sTokenEndpointHasNoWebMappings() { - Assert.assertNull(K8sTokenController.class.getDeclaredAnnotation( - RestController.class)); - Assert.assertNull(K8sTokenController.class.getDeclaredAnnotation( - RequestMapping.class)); - - for (Method method : K8sTokenController.class.getDeclaredMethods()) { - Assert.assertFalse(AnnotatedElementUtils.hasAnnotation( - method, RequestMapping.class)); + public void testK8sTokenReaderIsNotPackaged() { + try { + Class.forName("org.apache.hugegraph.controller.op." + + "K8sTokenController"); + Assert.fail("Kubernetes credentials must not be exposed by Hubble"); + } catch (ClassNotFoundException ignored) { + // Expected: Hubble must not package a Kubernetes credential reader. } } } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoadTaskServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoadTaskServiceTest.java index 616c811c9..287e31429 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoadTaskServiceTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoadTaskServiceTest.java @@ -21,18 +21,24 @@ import java.lang.reflect.Method; import java.lang.reflect.Field; import java.util.Arrays; +import java.util.concurrent.ConcurrentHashMap; import org.junit.Test; import org.apache.hugegraph.entity.GraphConnection; +import org.apache.hugegraph.entity.enums.LoadStatus; import org.apache.hugegraph.entity.load.FileMapping; import org.apache.hugegraph.entity.load.LoadParameter; +import org.apache.hugegraph.entity.load.LoadTask; +import org.apache.hugegraph.handler.LoadTaskExecutor; import org.apache.hugegraph.loader.executor.LoadOptions; import org.apache.hugegraph.service.load.LoadTaskService; import org.apache.hugegraph.mapper.load.LoadTaskMapper; import org.apache.hugegraph.testutil.Assert; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; @@ -56,7 +62,7 @@ public void testListByIdsAppliesGraphScope() throws Exception { } @Test - public void testLoadOptionsPreferBasicCredentialPassword() + public void testLoadOptionsIgnorePasswordAndUseToken() throws Exception { GraphConnection connection = this.connection("admin-pass", "session-token"); @@ -64,8 +70,110 @@ public void testLoadOptionsPreferBasicCredentialPassword() LoadOptions options = this.buildLoadOptions(connection); Assert.assertEquals("admin", options.username); - Assert.assertEquals("admin-pass", options.password); - Assert.assertNull(options.token); + Assert.assertNull(options.password); + Assert.assertEquals("session-token", options.token); + } + + @Test + public void testLoadExecutionWaitsForOuterTransactionCommit() + throws Exception { + LoadTaskService service = new LoadTaskService(); + LoadTaskExecutor executor = Mockito.mock(LoadTaskExecutor.class); + this.setField(service, "taskExecutor", executor); + this.setField(service, "runningTaskContainer", new ConcurrentHashMap<>()); + LoadTask task = LoadTask.builder().id(9).build(); + Method method = LoadTaskService.class.getDeclaredMethod( + "executeAfterCommit", LoadTask.class); + method.setAccessible(true); + + TransactionSynchronizationManager.initSynchronization(); + try { + method.invoke(service, task); + Mockito.verify(executor, Mockito.never()) + .execute(Mockito.any(), Mockito.any()); + + TransactionSynchronization synchronization = + TransactionSynchronizationManager.getSynchronizations() + .get(0); + synchronization.afterCommit(); + Mockito.verify(executor).execute(Mockito.eq(task), Mockito.any()); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + public void testResumeRehydratesLoaderWithCurrentSessionToken() + throws Exception { + LoadTaskService service = new LoadTaskService(); + LoadTaskMapper mapper = Mockito.mock(LoadTaskMapper.class); + LoadTaskExecutor executor = Mockito.mock(LoadTaskExecutor.class); + LoadTask task = Mockito.mock(LoadTask.class); + LoadOptions options = new LoadOptions(); + Mockito.when(mapper.selectById(9)).thenReturn(task); + Mockito.when(mapper.updateById(task)).thenReturn(1); + Mockito.when(task.getStatus()).thenReturn(LoadStatus.PAUSED); + Mockito.when(task.getOptions()).thenReturn(options); + this.setField(service, "mapper", mapper); + this.setField(service, "taskExecutor", executor); + this.setField(service, "runningTaskContainer", + new ConcurrentHashMap<>()); + + LoadTask resumed = service.resume(9, "current-session-token"); + + Assert.assertSame(task, resumed); + Mockito.verify(task).reconnect("current-session-token"); + Mockito.verify(executor).execute(Mockito.eq(task), Mockito.any()); + } + + @Test + public void testResumeDoesNotPersistRunningStateWhenReconnectFails() + throws Exception { + LoadTaskService service = new LoadTaskService(); + LoadTaskMapper mapper = Mockito.mock(LoadTaskMapper.class); + LoadTaskExecutor executor = Mockito.mock(LoadTaskExecutor.class); + LoadTask task = Mockito.mock(LoadTask.class); + Mockito.when(mapper.selectById(9)).thenReturn(task); + Mockito.when(mapper.updateById(task)).thenReturn(1); + Mockito.when(task.getStatus()).thenReturn(LoadStatus.PAUSED); + Mockito.when(task.getOptions()).thenReturn(new LoadOptions()); + Mockito.doThrow(new IllegalArgumentException("missing token")) + .when(task).reconnect("current-session-token"); + this.setField(service, "mapper", mapper); + this.setField(service, "taskExecutor", executor); + this.setField(service, "runningTaskContainer", + new ConcurrentHashMap<>()); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + service.resume(9, "current-session-token"); + }); + + Mockito.verify(mapper, Mockito.never()).updateById(task); + Mockito.verify(executor, Mockito.never()) + .execute(Mockito.any(), Mockito.any()); + } + + @Test + public void testRetryRehydratesLoaderWithCurrentSessionToken() + throws Exception { + LoadTaskService service = new LoadTaskService(); + LoadTaskMapper mapper = Mockito.mock(LoadTaskMapper.class); + LoadTaskExecutor executor = Mockito.mock(LoadTaskExecutor.class); + LoadTask task = Mockito.mock(LoadTask.class); + Mockito.when(mapper.selectById(9)).thenReturn(task); + Mockito.when(mapper.updateById(task)).thenReturn(1); + Mockito.when(task.getStatus()).thenReturn(LoadStatus.STOPPED); + Mockito.when(task.getOptions()).thenReturn(new LoadOptions()); + this.setField(service, "mapper", mapper); + this.setField(service, "taskExecutor", executor); + this.setField(service, "runningTaskContainer", + new ConcurrentHashMap<>()); + + LoadTask retried = service.retry(9, "current-session-token"); + + Assert.assertSame(task, retried); + Mockito.verify(task).reconnect("current-session-token"); + Mockito.verify(executor).execute(Mockito.eq(task), Mockito.any()); } @Test @@ -93,8 +201,8 @@ public void testLoadOptionsKeepPdRoutingWithoutHostPort() Assert.assertEquals("cluster-a", options.cluster); Assert.assertEquals("BOTH", options.routeType); Assert.assertEquals("admin", options.username); - Assert.assertEquals("admin-pass", options.password); - Assert.assertNull(options.token); + Assert.assertNull(options.password); + Assert.assertEquals("session-token", options.token); Assert.assertEquals("localhost", options.host); Assert.assertEquals(8080, options.port); } @@ -116,7 +224,8 @@ public void testLoadOptionsPreferDirectServerOverPdPeers() Assert.assertEquals(8080, options.port); Assert.assertEquals("DEFAULT", options.graphSpace); Assert.assertEquals("hugegraph", options.graph); - Assert.assertEquals("admin-pass", options.password); + Assert.assertNull(options.password); + Assert.assertEquals("session-token", options.token); } private LoadOptions buildLoadOptions(GraphConnection connection) @@ -129,6 +238,13 @@ private LoadOptions buildLoadOptions(GraphConnection connection) this.fileMapping()); } + private void setField(Object object, String name, Object value) + throws Exception { + Field field = object.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(object, value); + } + private GraphConnection connection(String password, String token) { GraphConnection connection = new GraphConnection(); connection.setGraph("hugegraph"); diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java index 2382e49ad..3a918e49d 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java @@ -26,6 +26,7 @@ import org.apache.hugegraph.entity.load.FileMapping; import org.apache.hugegraph.entity.load.JobManager; import org.apache.hugegraph.entity.load.LoadTask; +import org.apache.hugegraph.exception.ExternalException; import org.apache.hugegraph.service.load.FileMappingService; import org.apache.hugegraph.service.load.JobManagerService; import org.apache.hugegraph.service.load.LoadTaskService; @@ -35,6 +36,32 @@ public class LoaderScopeControllerTest { + @Test + public void testJobCreateRejectsMissingNameAsParameterError() { + JobManagerService service = Mockito.mock(JobManagerService.class); + JobManagerController controller = new JobManagerController(service); + + try { + controller.create("space-a", "graph-a", JobManager.builder().build()); + org.junit.Assert.fail("Expected a parameter error for missing job_name"); + } catch (ExternalException expected) { + // Expected: the controller must not leak a NullPointerException. + } + Mockito.verifyZeroInteractions(service); + } + + @Test + public void testJobCreateNormalizesOptionalNullRemarks() { + JobManagerService service = Mockito.mock(JobManagerService.class); + JobManager entity = JobManager.builder().jobName("task_1").build(); + JobManagerController controller = new JobManagerController(service); + + controller.create("space-a", "graph-a", entity); + + org.junit.Assert.assertEquals("", entity.getJobRemarks()); + Mockito.verify(service).save(entity); + } + @Test public void testJobLookupUsesGraphScope() { JobManagerService service = Mockito.mock(JobManagerService.class); diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/OperationsAccessContractTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/OperationsAccessContractTest.java new file mode 100644 index 000000000..3833322a2 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/OperationsAccessContractTest.java @@ -0,0 +1,231 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import org.apache.hugegraph.common.Constant; +import org.apache.hugegraph.controller.op.OperationsController; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.handler.ExceptionAdvisor; +import org.apache.hugegraph.handler.LoginInterceptor; +import org.apache.hugegraph.handler.MessageSourceHandler; +import org.apache.hugegraph.handler.ResponseAdvisor; +import org.apache.hugegraph.service.auth.UserService; +import org.apache.hugegraph.service.op.OperationsDataService; +import org.apache.hugegraph.service.op.OperationsNodeNotFoundException; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +public class OperationsAccessContractTest { + + private static final String NODE_ID = "store-0123456789ab"; + + private MockMvc mvc; + private HugeClient superClient; + private HugeClient otherClient; + + @Before + public void setup() { + this.superClient = client("token-a"); + this.otherClient = client("token-b"); + UserService users = Mockito.mock(UserService.class); + Mockito.when(users.userLevel(Mockito.any(), Mockito.anyString())) + .thenAnswer(invocation -> level(invocation.getArgument(1))); + OperationsDataService data = dataService(); + OperationsController controller = new OperationsController(); + ReflectionTestUtils.setField(controller, "userService", users); + ReflectionTestUtils.setField(controller, "dataService", data); + + this.mvc = MockMvcBuilders.standaloneSetup(controller) + .addInterceptors(new LoginInterceptor()) + .setControllerAdvice(advisor(), new ResponseAdvisor()) + .build(); + } + + @Test + public void testAllDirectOperationsUrlsRequireAuthentication() + throws Exception { + for (String path : new String[]{"/api/v1.3/operations/capabilities", + "/api/v1.3/operations/overview", + "/api/v1.3/operations/nodes", + "/api/v1.3/operations/nodes/" + NODE_ID}) { + this.mvc.perform(get(path)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value(401)); + } + } + + @Test + public void testCapabilitiesAreTrimmedForThreeRoles() throws Exception { + this.mvc.perform(authGet("/api/v1.3/operations/capabilities", + "superadmin", "token-a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.capabilities.length()").value(3)); + this.mvc.perform(authGet("/api/v1.3/operations/capabilities", + "spaceadmin", "token-space")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.capabilities.length()").value(0)); + this.mvc.perform(authGet("/api/v1.3/operations/capabilities", + "ordinary", "token-user")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.capabilities.length()").value(0)); + } + + @Test + public void testDirectUrlsEnforceRoleCapabilities() throws Exception { + this.mvc.perform(authGet("/api/v1.3/operations/overview", + "ordinary", "token-user")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value(403)); + this.mvc.perform(authGet("/api/v1.3/operations/nodes", + "spaceadmin", "token-space")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value(403)); + this.mvc.perform(authGet("/api/v1.3/operations/nodes/" + NODE_ID, + "spaceadmin", "token-space")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value(403)); + this.mvc.perform(authGet("/api/v1.3/operations/overview", + "spaceadmin", "token-space")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value(403)); + } + + @Test + public void testNodeIdsAreScopedAndRejectedSafely() throws Exception { + this.mvc.perform(authGet("/api/v1.3/operations/nodes/" + NODE_ID, + "superadmin", "token-a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.node.id").value(NODE_ID)); + this.mvc.perform(authGet("/api/v1.3/operations/nodes/" + NODE_ID, + "superadmin", "token-b")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value( + "operations_node_not_found")); + this.mvc.perform(authGet( + "/api/v1.3/operations/nodes/store-ffffffffffff", + "superadmin", "token-a")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value( + "operations_node_not_found")); + String invalid = this.mvc.perform(authGet( + "/api/v1.3/operations/nodes/http:%2F%2Fsecret-canary@host", + "superadmin", "token-a")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andReturn().getResponse().getContentAsString(); + org.apache.hugegraph.testutil.Assert.assertFalse( + invalid.contains("secret-canary")); + org.apache.hugegraph.testutil.Assert.assertFalse( + invalid.contains("host")); + } + + @Test + public void testSuccessfulPayloadDoesNotExposeInfrastructureCanaries() + throws Exception { + String content = this.mvc.perform(authGet("/api/v1.3/operations/nodes", + "superadmin", "token-a")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + org.apache.hugegraph.testutil.Assert.assertFalse( + content.contains("127.0.0.1")); + org.apache.hugegraph.testutil.Assert.assertFalse( + content.contains("secret-canary")); + org.apache.hugegraph.testutil.Assert.assertFalse( + content.contains("/private")); + } + + private OperationsDataService dataService() { + OperationsDataService data = Mockito.mock(OperationsDataService.class); + Mockito.when(data.overview(Mockito.any(), Mockito.anySet(), + Mockito.anyBoolean())) + .thenReturn(Collections.singletonMap("status", "UP")); + Mockito.when(data.nodes(Mockito.any(), Mockito.anySet(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.anyInt(), + Mockito.anyInt(), Mockito.anyString(), + Mockito.anyString())) + .thenReturn(Collections.singletonMap("nodes", + Collections.emptyList())); + Mockito.when(data.node(Mockito.any(), Mockito.anySet(), + Mockito.anyString(), Mockito.anyBoolean())) + .thenAnswer(invocation -> { + HugeClient client = invocation.getArgument(0); + String nodeId = invocation.getArgument(2); + if (client != this.superClient || !NODE_ID.equals(nodeId)) { + throw new OperationsNodeNotFoundException(); + } + Map node = new LinkedHashMap<>(); + node.put("id", NODE_ID); + return Collections.singletonMap("node", node); + }); + return data; + } + + private MockHttpServletRequestBuilder authGet(String path, + String username, + String token) { + MockHttpSession session = new MockHttpSession(); + session.setAttribute(Constant.USERNAME_KEY, username); + session.setAttribute(Constant.TOKEN_KEY, token); + HugeClient client = "token-a".equals(token) ? + this.superClient : this.otherClient; + return get(path).session(session).requestAttr("hugeClient", client); + } + + private static HugeClient client(String authContext) { + HugeClient client = Mockito.mock(HugeClient.class); + Mockito.when(client.getAuthContext()).thenReturn(authContext); + return client; + } + + private static String level(String username) { + if ("superadmin".equals(username)) { + return "ADMIN"; + } + if ("spaceadmin".equals(username)) { + return "SPACEADMIN"; + } + return "USER"; + } + + private static ExceptionAdvisor advisor() { + MessageSourceHandler messages = Mockito.mock( + MessageSourceHandler.class); + Mockito.when(messages.getMessage(Mockito.anyString(), + Mockito.nullable(Object[].class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + ExceptionAdvisor advisor = new ExceptionAdvisor(); + ReflectionTestUtils.setField(advisor, "messageSourceHandler", messages); + return advisor; + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/OperationsControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/OperationsControllerTest.java new file mode 100644 index 000000000..ed89bdf1a --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/OperationsControllerTest.java @@ -0,0 +1,115 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit; + +import java.util.Collections; +import java.util.Map; + +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.exception.ForbiddenException; +import org.apache.hugegraph.service.auth.UserService; +import org.apache.hugegraph.service.op.OperationsDataService; +import org.apache.hugegraph.testutil.Assert; +import org.junit.After; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import org.apache.hugegraph.common.Constant; +import org.apache.hugegraph.controller.op.OperationsController; + +public class OperationsControllerTest { + + @After + public void tearDown() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + public void testCapabilitiesAreDerivedByBackend() { + Fixture fixture = fixture("SPACEADMIN"); + + Map response = fixture.controller.capabilities(); + + Assert.assertEquals(Collections.emptySet(), + response.get("capabilities")); + } + + @Test(expected = ForbiddenException.class) + public void testDirectOverviewUrlRejectsOrdinaryUser() { + fixture("USER").controller.overview(false); + } + + @Test(expected = ForbiddenException.class) + public void testSpaceAdminCannotReadNodeTopology() { + fixture("SPACEADMIN").controller.nodes(null, null, null, 1, 20, + "name", "asc"); + } + + @Test + public void testAdminCanReadNodes() { + Fixture fixture = fixture("ADMIN"); + Mockito.when(fixture.dataService.nodes(Mockito.any(), Mockito.anySet(), + Mockito.isNull(), Mockito.isNull(), + Mockito.isNull(), Mockito.eq(1), + Mockito.eq(20), Mockito.eq("name"), + Mockito.eq("asc"))) + .thenReturn(Collections.singletonMap("total", 0)); + + Map response = fixture.controller.nodes(null, null, null, + 1, 20, + "name", "asc"); + + Assert.assertEquals(0, response.get("total")); + } + + private static Fixture fixture(String level) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession().setAttribute(Constant.USERNAME_KEY, "operator"); + request.getSession().setAttribute(Constant.TOKEN_KEY, "token"); + HugeClient client = Mockito.mock(HugeClient.class); + request.setAttribute("hugeClient", client); + RequestContextHolder.setRequestAttributes( + new ServletRequestAttributes(request)); + + UserService userService = Mockito.mock(UserService.class); + Mockito.when(userService.userLevel(client, "operator")).thenReturn(level); + OperationsDataService dataService = Mockito.mock( + OperationsDataService.class); + OperationsController controller = new OperationsController(); + ReflectionTestUtils.setField(controller, "userService", userService); + ReflectionTestUtils.setField(controller, "dataService", dataService); + return new Fixture(controller, dataService); + } + + private static final class Fixture { + + private final OperationsController controller; + private final OperationsDataService dataService; + + private Fixture(OperationsController controller, + OperationsDataService dataService) { + this.controller = controller; + this.dataService = dataService; + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/QueryServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/QueryServiceTest.java index 48f262a47..66c765b61 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/QueryServiceTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/QueryServiceTest.java @@ -27,22 +27,43 @@ import org.apache.hugegraph.api.gremlin.GremlinRequest; import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.driver.CypherManager; import org.apache.hugegraph.driver.GraphManager; import org.apache.hugegraph.driver.GremlinManager; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.query.AdjacentQuery; +import org.apache.hugegraph.entity.query.GremlinQuery; +import org.apache.hugegraph.entity.query.GremlinResult; import org.apache.hugegraph.entity.schema.VertexLabelEntity; import org.apache.hugegraph.exception.ExternalException; +import org.apache.hugegraph.exception.ServerException; import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.service.query.QueryService; import org.apache.hugegraph.service.schema.VertexLabelService; import org.apache.hugegraph.structure.constant.Direction; import org.apache.hugegraph.structure.constant.IdStrategy; +import org.apache.hugegraph.structure.graph.Vertex; import org.apache.hugegraph.structure.gremlin.ResultSet; import org.apache.hugegraph.testutil.Assert; public class QueryServiceTest { + @Test + public void testQueryIgnoresNonEdgeAdjacentResults() throws Exception { + Vertex vertex = new Vertex("person"); + vertex.id("isolated"); + GremlinManager gremlin = this.mockGremlin(this.resultSet(vertex), + this.resultSet(vertex)); + HugeClient client = this.mockClient(gremlin); + QueryService service = this.serviceWithConfig(); + + GremlinResult result = service.executeGremlinQuery( + client, new GremlinQuery("g.V('isolated')")); + + Assert.assertEquals(1, result.getGraphView().getVertices().size()); + Assert.assertEquals(0, result.getGraphView().getEdges().size()); + } + @Test public void testExpandVertexEscapesGremlinLiterals() throws Exception { GremlinManager gremlin = this.mockGremlin(); @@ -123,11 +144,132 @@ public void testExpandVertexRejectsStructuredConditionValue() Mockito.verify(gremlin, Mockito.never()).gremlin(Mockito.anyString()); } + @Test + public void testGremlinPreservesServerUnavailableStatus() throws Exception { + ServerException server = new ServerException((String) null); + server.status(503); + GremlinManager gremlin = this.mockGremlinFailure(server); + QueryService service = this.serviceWithConfig(); + + ExternalException error = (ExternalException) + Assert.assertThrows(ExternalException.class, + () -> { + service.executeGremlinQuery(this.mockClient(gremlin), + new GremlinQuery("g.V()")); + }); + + Assert.assertEquals(503, error.status()); + Assert.assertEquals("gremlin.server.unavailable", error.getMessage()); + } + + @Test + public void testGremlinHidesServerUnavailableDetail() throws Exception { + String detail = "The server is too busy to process the request"; + ServerException server = new ServerException(detail); + server.status(503); + GremlinManager gremlin = this.mockGremlinFailure(server); + QueryService service = this.serviceWithConfig(); + + ExternalException error = (ExternalException) + Assert.assertThrows(ExternalException.class, + () -> { + service.executeGremlinQuery(this.mockClient(gremlin), + new GremlinQuery("g.V()")); + }); + + Assert.assertEquals(503, error.status()); + Assert.assertEquals("gremlin.server.unavailable", error.getMessage()); + Assert.assertEquals(0, error.args().length); + } + + @Test + public void testGremlinSeparatesUpstreamUnauthorizedFromHubbleSession() + throws Exception { + ServerException server = new ServerException("Unauthorized"); + server.status(401); + GremlinManager gremlin = this.mockGremlinFailure(server); + QueryService service = this.serviceWithConfig(); + + ExternalException error = (ExternalException) + Assert.assertThrows(ExternalException.class, + () -> { + service.executeGremlinQuery(this.mockClient(gremlin), + new GremlinQuery("g.V()")); + }); + + Assert.assertEquals(502, error.status()); + Assert.assertEquals("gremlin.server.authentication-failed", + error.getMessage()); + Assert.assertEquals(0, error.args().length); + } + + @Test + public void testCypherSeparatesUpstreamUnauthorizedFromHubbleSession() + throws Exception { + ServerException server = new ServerException("Unauthorized"); + server.status(401); + CypherManager cypher = Mockito.mock(CypherManager.class); + Mockito.when(cypher.cypher(Mockito.anyString())).thenThrow(server); + HugeClient client = this.mockClient(this.mockGremlin()); + Mockito.when(client.cypher()).thenReturn(cypher); + QueryService service = this.serviceWithConfig(); + + ExternalException error = (ExternalException) + Assert.assertThrows(ExternalException.class, + () -> { + service.executeCypherQuery(client, "MATCH (n) RETURN n"); + }); + + Assert.assertEquals(502, error.status()); + Assert.assertEquals("gremlin.server.authentication-failed", + error.getMessage()); + Assert.assertEquals(0, error.args().length); + } + + @Test + public void testAsyncQueriesSeparateUnauthorizedFromHubbleSession() + throws Exception { + ServerException server = new ServerException("Unauthorized"); + server.status(401); + GremlinManager gremlin = Mockito.mock(GremlinManager.class); + Mockito.when(gremlin.executeAsTask(Mockito.any())).thenThrow(server); + CypherManager cypher = Mockito.mock(CypherManager.class); + Mockito.when(cypher.executeAsTask(Mockito.anyString())) + .thenThrow(server); + HugeClient client = this.mockClient(gremlin); + Mockito.when(client.cypher()).thenReturn(cypher); + QueryService service = this.serviceWithConfig(); + + ExternalException gremlinError = (ExternalException) + Assert.assertThrows( + ExternalException.class, () -> { + service.executeGremlinAsyncTask(client, new GremlinQuery("g.V()")); + }); + ExternalException cypherError = (ExternalException) + Assert.assertThrows( + ExternalException.class, () -> { + service.executeCypherAsyncTask(client, "MATCH (n) RETURN n"); + }); + + Assert.assertEquals(502, gremlinError.status()); + Assert.assertEquals("gremlin.server.authentication-failed", + gremlinError.getMessage()); + Assert.assertEquals(502, cypherError.status()); + Assert.assertEquals("gremlin.server.authentication-failed", + cypherError.getMessage()); + } + private QueryService serviceWithConfig() throws Exception { QueryService service = new QueryService(); HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.GREMLIN_SUFFIX_LIMIT)) + .thenReturn(250); Mockito.when(config.get(HubbleOptions.GREMLIN_VERTEX_DEGREE_LIMIT)) .thenReturn(100); + Mockito.when(config.get(HubbleOptions.GREMLIN_BATCH_QUERY_IDS)) + .thenReturn(100); + Mockito.when(config.get(HubbleOptions.GREMLIN_EDGES_TOTAL_LIMIT)) + .thenReturn(500); this.setField(service, "config", config); VertexLabelService vlService = Mockito.mock(VertexLabelService.class); Mockito.when(vlService.get(Mockito.eq("person"), Mockito.any())) @@ -146,18 +288,32 @@ private HugeClient mockClient(GremlinManager gremlin) { } private GremlinManager mockGremlin() throws Exception { + return this.mockGremlin(this.resultSet()); + } + + private GremlinManager mockGremlin(ResultSet... resultSets) throws Exception { GremlinManager gremlin = Mockito.mock(GremlinManager.class); Mockito.when(gremlin.gremlin(Mockito.anyString())) .thenAnswer(invocation -> new GremlinRequest.Builder( invocation.getArgument(0), gremlin)); Mockito.when(gremlin.execute(Mockito.any())) - .thenReturn(this.resultSet()); + .thenReturn(resultSets[0], Arrays.copyOfRange(resultSets, 1, + resultSets.length)); + return gremlin; + } + + private GremlinManager mockGremlinFailure(ServerException failure) { + GremlinManager gremlin = Mockito.mock(GremlinManager.class); + Mockito.when(gremlin.gremlin(Mockito.anyString())) + .thenAnswer(invocation -> new GremlinRequest.Builder( + invocation.getArgument(0), gremlin)); + Mockito.when(gremlin.execute(Mockito.any())).thenThrow(failure); return gremlin; } - private ResultSet resultSet() throws Exception { + private ResultSet resultSet(Object... data) throws Exception { ResultSet resultSet = new ResultSet(); - this.setField(resultSet, "data", Arrays.asList()); + this.setField(resultSet, "data", Arrays.asList(data)); resultSet.graphManager(Mockito.mock(GraphManager.class)); return resultSet; } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/SampleGraphControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/SampleGraphControllerTest.java new file mode 100644 index 000000000..12a28d32a --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/SampleGraphControllerTest.java @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit; + +import java.util.Map; + +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import org.apache.hugegraph.controller.graph.SampleGraphController; +import org.apache.hugegraph.api.gremlin.GremlinRequest; +import org.apache.hugegraph.driver.GremlinManager; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.driver.SchemaManager; +import org.apache.hugegraph.structure.schema.VertexLabel; +import org.apache.hugegraph.testutil.Assert; + +public class SampleGraphControllerTest { + + @Test + public void testRedChamberDataIsNotEagerControllerState() { + boolean eagerField = java.util.Arrays.stream( + SampleGraphController.class.getDeclaredFields()) + .anyMatch(field -> "HLM_DATA".equals(field.getName())); + + Assert.assertFalse(eagerField); + } + + @Test + public void testLoadExecutesSchemaBeforeIdempotentData() { + HugeClient client = Mockito.mock(HugeClient.class); + GremlinManager gremlin = Mockito.mock(GremlinManager.class); + SchemaManager schema = Mockito.mock(SchemaManager.class, + Mockito.RETURNS_DEEP_STUBS); + Mockito.when(client.gremlin()).thenReturn(gremlin); + Mockito.when(client.schema()).thenReturn(schema); + Mockito.when(gremlin.gremlin(Mockito.anyString())) + .thenAnswer(invocation -> new GremlinRequest.Builder( + invocation.getArgument(0), gremlin)); + SampleGraphController controller = new TestController(client); + + Map result = controller.load("DEFAULT", "hugegraph", + "loader"); + + ArgumentCaptor requests = + ArgumentCaptor.forClass(GremlinRequest.class); + Mockito.verify(schema).propertyKey("name"); + Mockito.verify(schema).vertexLabel("person"); + Mockito.verify(schema).edgeLabel("knows"); + Mockito.verify(schema.propertyKey("name").asText()).ifNotExist(); + Mockito.verify(schema.vertexLabel("person") + .properties("name", "age", "city") + .primaryKeys("name") + .nullableKeys("age", "city")).ifNotExist(); + Mockito.verify(schema.edgeLabel("knows") + .sourceLabel("person") + .targetLabel("person") + .properties("date", "weight")).ifNotExist(); + Mockito.verify(gremlin).execute(requests.capture()); + Assert.assertTrue(requests.getValue().gremlin.startsWith( + "// hugegraph-client:idempotent-traversal-fallback\n")); + Assert.assertTrue(requests.getValue().gremlin.endsWith( + SampleGraphController.LOADER_DATA)); + Assert.assertEquals("hugegraph", result.get("graph")); + Assert.assertEquals(true, result.get("idempotent")); + Assert.assertEquals(false, result.get("clears_existing_data")); + } + + @Test + public void testSampleContractIsRetrySafeAndNonDestructive() { + String schema = SampleGraphController.LOADER_SCHEMA + + SampleGraphController.HLM_SCHEMA; + String data = SampleGraphController.LOADER_DATA + + SampleGraphController.hlmData(); + + Assert.assertTrue(schema.contains("ifNotExist()")); + Assert.assertTrue(data.contains("fold().coalesce(unfold(),addV")); + Assert.assertTrue(data.contains(".addEdge(")); + Assert.assertTrue(data.contains(".hasNext()")); + Assert.assertTrue(SampleGraphController.HLM_SCHEMA.contains( + ".primaryKeys('name')")); + Assert.assertTrue(SampleGraphController.hlmData().contains( + ".has('name','贾宝玉')")); + Assert.assertFalse(SampleGraphController.hlmData().contains( + "property(T.id")); + Assert.assertFalse((schema + data).contains("clear")); + Assert.assertFalse((schema + data).contains("drop(")); + Assert.assertFalse((schema + data).contains("remove(")); + } + + @Test + public void testSampleMatchesLoaderExampleCardinality() { + Assert.assertEquals("hugegraph-loader/example/file", + SampleGraphController.LOADER_SOURCE); + Assert.assertEquals(8, occurrences(SampleGraphController.LOADER_DATA, + "coalesce(unfold(),addV")); + Assert.assertEquals(6, occurrences(SampleGraphController.LOADER_DATA, + ".addEdge(")); + Assert.assertEquals(14, occurrences(SampleGraphController.hlmData(), + "coalesce(unfold(),addV")); + Assert.assertEquals(15, occurrences(SampleGraphController.hlmData(), + ".addEdge(")); + } + + @Test + public void testLoadRedChamberDataset() { + HugeClient client = Mockito.mock(HugeClient.class); + GremlinManager gremlin = Mockito.mock(GremlinManager.class); + SchemaManager schema = Mockito.mock(SchemaManager.class, + Mockito.RETURNS_DEEP_STUBS); + VertexLabel.Builder vertex = Mockito.mock(VertexLabel.Builder.class); + Mockito.when(schema.vertexLabel("人物")).thenReturn(vertex); + Mockito.when(vertex.properties("name", "gender", "age", "title", + "feature")).thenReturn(vertex); + Mockito.when(vertex.primaryKeys("name")).thenReturn(vertex); + Mockito.when(vertex.ifNotExist()).thenReturn(vertex); + Mockito.when(client.gremlin()).thenReturn(gremlin); + Mockito.when(client.schema()).thenReturn(schema); + Mockito.when(gremlin.gremlin(Mockito.anyString())) + .thenAnswer(invocation -> new GremlinRequest.Builder( + invocation.getArgument(0), gremlin)); + SampleGraphController controller = new TestController(client); + + Map result = controller.load("DEFAULT", "hugegraph", + "hlm"); + + ArgumentCaptor requests = + ArgumentCaptor.forClass(GremlinRequest.class); + Mockito.verify(schema).propertyKey("name"); + Mockito.verify(schema).propertyKey("age"); + Mockito.verify(vertex).properties("name", "gender", "age", "title", + "feature"); + Mockito.verify(vertex).primaryKeys("name"); + Mockito.verify(vertex, Mockito.never()).useCustomizeStringId(); + Mockito.verify(schema).edgeLabel("关系"); + Mockito.verify(gremlin).execute(requests.capture()); + Assert.assertTrue(requests.getValue().gremlin.startsWith( + "// hugegraph-client:idempotent-traversal-fallback\n")); + Assert.assertTrue(requests.getValue().gremlin.endsWith( + SampleGraphController.hlmData())); + Assert.assertEquals(14, result.get("vertices")); + Assert.assertEquals(15, result.get("edges")); + } + + private static int occurrences(String value, String token) { + int count = 0; + int offset = 0; + while ((offset = value.indexOf(token, offset)) >= 0) { + count++; + offset += token.length(); + } + return count; + } + + private static class TestController extends SampleGraphController { + + private final HugeClient client; + + TestController(HugeClient client) { + this.client = client; + } + + @Override + protected HugeClient authGremlinClient(String graphSpace, String graph) { + return this.client; + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java index 58d819f04..6d8f611db 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -18,14 +18,30 @@ package org.apache.hugegraph.unit; +import org.apache.hugegraph.controller.auth.AccountMutationAuthorizationTest; +import org.apache.hugegraph.controller.auth.GraphSpaceAuthMutationAuthorizationTest; +import org.apache.hugegraph.controller.auth.GraphSpaceAuthOwnershipTest; import org.apache.hugegraph.controller.ingest.IngestControllerTest; import org.apache.hugegraph.controller.langchain.LangChainControllerSecurityTest; import org.apache.hugegraph.controller.schema.SchemaControllerSecurityTest; +import org.apache.hugegraph.controller.space.GraphSpaceControllerTest; +import org.apache.hugegraph.handler.ResponseAdvisorStatusTest; +import org.apache.hugegraph.service.load.IngestTransactionIntegrationTest; +import org.apache.hugegraph.service.auth.AuthContextServiceTest; +import org.apache.hugegraph.service.space.GraphSpaceServiceTest; +import org.apache.hugegraph.service.op.DefaultOperationsDataServiceTest; +import org.apache.hugegraph.service.op.LiveOperationsCollectorTest; +import org.apache.hugegraph.service.op.OperationsCapabilityServiceTest; +import org.apache.hugegraph.service.op.OperationsHttpClientTest; +import org.apache.hugegraph.service.op.OperationsIdentityContractTest; +import org.apache.hugegraph.service.op.OperationsPayloadParserTest; import org.junit.runner.RunWith; import org.junit.runners.Suite; @RunWith(Suite.class) @Suite.SuiteClasses({ + AccountMutationAuthorizationTest.class, + AuthContextServiceTest.class, AuthSecurityTest.class, AppTypeTest.class, AuthzRouteRegistrationTest.class, @@ -38,10 +54,16 @@ FileUtilTest.class, GraphServiceImportTest.class, GraphMetricsControllerTest.class, + HugeClientPoolServiceTest.class, + GraphSpaceControllerTest.class, + GraphSpaceAuthMutationAuthorizationTest.class, + GraphSpaceAuthOwnershipTest.class, + GraphSpaceServiceTest.class, GraphsControllerCanonicalTest.class, GremlinHistoryFailureTest.class, HubbleOptionsTest.class, IngestControllerTest.class, + IngestTransactionIntegrationTest.class, LangChainControllerSecurityTest.class, LegacyFacadeRemovalTest.class, MessageSourceHandlerTest.class, @@ -54,8 +76,17 @@ LoginAttemptGuardTest.class, OltpAlgoControllerTest.class, OltpAlgoServiceTest.class, + OperationsCapabilityServiceTest.class, + OperationsControllerTest.class, + OperationsAccessContractTest.class, + OperationsHttpClientTest.class, + OperationsIdentityContractTest.class, + OperationsPayloadParserTest.class, + DefaultOperationsDataServiceTest.class, + LiveOperationsCollectorTest.class, PriorityFixTest.class, QueryServiceTest.class, + ResponseAdvisorStatusTest.class, UrlUtilTest.class }) public class UnitTestSuite { diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java new file mode 100644 index 000000000..ae0319de2 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.unit; + +import java.util.Arrays; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.driver.AuthManager; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.auth.UserEntity; +import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.auth.UserService; +import org.apache.hugegraph.structure.auth.User; + +public class UserServiceCompatibilityTest { + + private HugeConfig config; + private HugeClient client; + private AuthManager auth; + private UserService service; + + @Before + public void setup() { + this.config = Mockito.mock(HugeConfig.class); + this.client = Mockito.mock(HugeClient.class); + this.auth = Mockito.mock(AuthManager.class); + Mockito.when(this.client.auth()).thenReturn(this.auth); + Mockito.when(this.auth.createUser(Mockito.any(User.class))) + .thenReturn(new User()); + this.service = new UserService(); + ReflectionTestUtils.setField(this.service, "config", this.config); + } + + @Test + public void testStandaloneUserCreationOmitsPdOnlyNickname() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(false); + + this.service.add(this.client, userEntity("display-name")); + + ArgumentCaptor request = ArgumentCaptor.forClass(User.class); + Mockito.verify(this.auth).createUser(request.capture()); + Assert.assertNull(request.getValue().nickname()); + } + + @Test + public void testPdUserCreationKeepsNickname() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + + this.service.add(this.client, userEntity("display-name")); + + ArgumentCaptor request = ArgumentCaptor.forClass(User.class); + Mockito.verify(this.auth).createUser(request.capture()); + Assert.assertEquals("display-name", request.getValue().nickname()); + } + + @Test + public void testStandaloneAccountLevelsMatchServerRoles() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(false); + Mockito.when(this.auth.listUsers()) + .thenReturn(Arrays.asList(user("admin"), user("hubbleuser"))); + + @SuppressWarnings("unchecked") + IPage result = (IPage) + this.service.queryPage(this.client, "", 1, 10); + + Assert.assertTrue(result.getRecords().get(0).isSuperadmin()); + Assert.assertFalse(result.getRecords().get(1).isSuperadmin()); + } + + @Test + public void testStandaloneUserUpdateOmitsPdOnlyNickname() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(false); + UserEntity user = UserEntity.builder() + .id("user-id") + .name("user") + .nickname("display-name") + .build(); + + this.service.update(this.client, user); + + ArgumentCaptor request = ArgumentCaptor.forClass(User.class); + Mockito.verify(this.auth).updateUser(request.capture()); + Assert.assertNull(request.getValue().nickname()); + } + + @Test + public void testStandalonePersonalUpdateOmitsPdOnlyNickname() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(false); + Mockito.when(this.auth.getUserByName("user")) + .thenReturn(user("user")); + + this.service.updatePersonal(this.client, "user", "display-name", + "description"); + + ArgumentCaptor request = ArgumentCaptor.forClass(User.class); + Mockito.verify(this.auth).updateUser(request.capture()); + Assert.assertNull(request.getValue().nickname()); + Assert.assertEquals("description", request.getValue().description()); + } + + private static UserEntity userEntity(String nickname) { + return UserEntity.builder() + .name("user") + .nickname(nickname) + .password("password") + .build(); + } + + private static User user(String name) { + User user = new User(); + user.setId(name); + user.name(name); + return user; + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/VermeerCredentialSafetyTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/VermeerCredentialSafetyTest.java new file mode 100644 index 000000000..17055525a --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/VermeerCredentialSafetyTest.java @@ -0,0 +1,98 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit; + +import java.io.OutputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import com.sun.net.httpserver.HttpServer; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.controller.algorithm.VermeerAlgoController; +import org.apache.hugegraph.exception.ServerCapabilityUnavailableException; +import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.space.VermeerService; + +public class VermeerCredentialSafetyTest { + + @Test + public void testVermeerStatusUsesServerToken() throws Exception { + AtomicReference authorization = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", + 0), 0); + server.createContext("/api/v1.0/memt_clu/config/getsyscfg", exchange -> { + authorization.set(exchange.getRequestHeaders() + .getFirst("Authorization")); + byte[] body = "{\"data\":{\"cfgvalue\":\"true\"}}".getBytes( + StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(body); + } + }); + server.start(); + try { + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.DASHBOARD_ADDRESS)) + .thenReturn("127.0.0.1:" + server.getAddress().getPort()); + Mockito.when(config.get(HubbleOptions.SERVER_PROTOCOL)) + .thenReturn("http"); + VermeerService service = new VermeerService(); + ReflectionTestUtils.setField(service, "config", config); + + Map status = service.getVermeer("server-token", + true); + + Assert.assertEquals(Boolean.TRUE, status.get("enable")); + Assert.assertEquals("Bearer server-token", authorization.get()); + } finally { + server.stop(0); + } + } + + @Test + public void testVermeerComputeFailsBeforeBuildingPasswordParams() + throws Exception { + Method method = VermeerAlgoController.class.getDeclaredMethods()[0]; + for (Method candidate : VermeerAlgoController.class.getDeclaredMethods()) { + if ("olapView".equals(candidate.getName())) { + method = candidate; + break; + } + } + + try { + method.invoke(new VermeerAlgoController(), "DEFAULT", "hugegraph", + null); + Assert.fail("Expected unavailable token-auth capability"); + } catch (InvocationTargetException e) { + Assert.assertTrue(e.getCause() instanceof + ServerCapabilityUnavailableException); + } + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/resources/database/ingest-transaction-schema.sql b/hugegraph-hubble/hubble-be/src/test/resources/database/ingest-transaction-schema.sql new file mode 100644 index 000000000..9fd2a925a --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/resources/database/ingest-transaction-schema.sql @@ -0,0 +1,74 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +CREATE TABLE IF NOT EXISTS `job_manager` ( + `id` INT NOT NULL AUTO_INCREMENT, + `conn_id` INT DEFAULT 0, + `graphspace` VARCHAR(48) NOT NULL, + `graph` VARCHAR(48) NOT NULL, + `job_name` VARCHAR(100) NOT NULL DEFAULT '', + `job_remarks` VARCHAR(200) NOT NULL DEFAULT '', + `job_size` LONG NOT NULL DEFAULT 0, + `job_status` TINYINT NOT NULL DEFAULT 0, + `job_duration` LONG NOT NULL DEFAULT 0, + `update_time` DATETIME(6) NOT NULL, + `create_time` DATETIME(6) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE (`job_name`, `graphspace`, `graph`) +); + +CREATE TABLE IF NOT EXISTS `file_mapping` ( + `id` INT NOT NULL AUTO_INCREMENT, + `conn_id` INT, + `graphspace` VARCHAR(48) NOT NULL, + `graph` VARCHAR(48) NOT NULL, + `job_id` INT NOT NULL DEFAULT 0, + `name` VARCHAR(128) NOT NULL, + `path` VARCHAR(2048) NOT NULL, + `total_lines` LONG NOT NULL, + `total_size` LONG NOT NULL, + `file_status` TINYINT NOT NULL DEFAULT 0, + `file_setting` VARCHAR(65535) NOT NULL, + `vertex_mappings` VARCHAR(65535) NOT NULL, + `edge_mappings` VARCHAR(65535) NOT NULL, + `load_parameter` VARCHAR(65535) NOT NULL, + `create_time` DATETIME(6) NOT NULL, + `update_time` DATETIME(6) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE (`conn_id`, `job_id`, `name`) +); + +CREATE TABLE IF NOT EXISTS `load_task` ( + `id` INT NOT NULL AUTO_INCREMENT, + `conn_id` INT, + `graphspace` VARCHAR(48) NOT NULL, + `graph` VARCHAR(48) NOT NULL, + `job_id` INT NOT NULL DEFAULT 0, + `file_id` INT NOT NULL, + `file_name` VARCHAR(128) NOT NULL, + `options` VARCHAR(65535) NOT NULL, + `vertices` VARCHAR(512) NOT NULL, + `edges` VARCHAR(512) NOT NULL, + `file_total_lines` LONG NOT NULL, + `load_status` TINYINT NOT NULL, + `file_read_lines` LONG NOT NULL, + `last_duration` LONG NOT NULL, + `curr_duration` LONG NOT NULL, + `create_time` DATETIME(6) NOT NULL, + PRIMARY KEY (`id`) +); diff --git a/hugegraph-hubble/hubble-dist/assembly/static/conf/hugegraph-hubble.properties b/hugegraph-hubble/hubble-dist/assembly/static/conf/hugegraph-hubble.properties index 1faf8a13b..ffe6f7f72 100644 --- a/hugegraph-hubble/hubble-dist/assembly/static/conf/hugegraph-hubble.properties +++ b/hugegraph-hubble/hubble-dist/assembly/static/conf/hugegraph-hubble.properties @@ -25,6 +25,7 @@ gremlin.batch_query_ids=100 cluster=hg idc=bddwd +client.url_cache_max_entries=1024 # ===== Deployment Mode ===== # Set to false for standalone RocksDB mode (no PD dependency) @@ -36,6 +37,24 @@ server.direct_url=http://127.0.0.1:8080 pd.peers=127.0.0.1:8686 pd.server=127.0.0.1:8620 +# Native operations aggregation. Supply PD/Store passwords through a protected +# deployment configuration. PD/Store 1.7 additionally require management +# network isolation because legacy authentication is not a strong boundary. +operations.connect_timeout_ms=1500 +operations.read_timeout_ms=2500 +operations.max_response_bytes=1048576 +operations.cache_ttl_seconds=5 +operations.cache_max_entries=1024 +operations.store_threads=16 +operations.store_deadline_ms=5000 +# Operator-managed exact Store metric origins. Local-test defaults only; +# production must list every trusted Store scheme, host, and port. +operations.store.allowed_targets=[http://127.0.0.1:8520,http://[::1]:8520] +operations.pd.username=hubble +operations.pd.password= +operations.store.username=hubble +operations.store.password= + # dashboard dashboard.address=127.0.0.1:8092 # BOTH, NODE_PORT, DDS diff --git a/hugegraph-hubble/hubble-dist/assembly/travis/run_ui_browser_smoke.js b/hugegraph-hubble/hubble-dist/assembly/travis/run_ui_browser_smoke.js index 1429578b4..87fc5fcb1 100755 --- a/hugegraph-hubble/hubble-dist/assembly/travis/run_ui_browser_smoke.js +++ b/hugegraph-hubble/hubble-dist/assembly/travis/run_ui_browser_smoke.js @@ -134,6 +134,7 @@ async function main() { const routes = [ { name: 'graphspace', path: '/graphspace', requiredApis: ['/api/v1.3/graphspaces'], + readySelector: '[data-testid="graphspace-page-title"]', textPattern: /图空间|Graph Space/ }, { name: 'gremlin', path: '/gremlin', requiredApis: ['/api/v1.3/graphspaces/list'], @@ -172,12 +173,15 @@ async function main() { entry.businessStatus === 200)) }; }); + const routeTextMatched = route.readySelector + ? await page.locator(route.readySelector).isVisible({ timeout: 5000 }) + : route.textPattern.test(text); results.push({ route: route.path, screenshot, matchedApis, rawI18nKeyFound: rawKeyPattern.test(text), - routeTextMatched: route.textPattern.test(text), + routeTextMatched, notFoundPage: /404|页面不存在|Not Found/.test(text), requestCount: network.length }); diff --git a/hugegraph-hubble/hubble-dist/assembly/travis/run_ui_i18n_switch_smoke.js b/hugegraph-hubble/hubble-dist/assembly/travis/run_ui_i18n_switch_smoke.js index ca940b2fa..392a0307f 100755 --- a/hugegraph-hubble/hubble-dist/assembly/travis/run_ui_i18n_switch_smoke.js +++ b/hugegraph-hubble/hubble-dist/assembly/travis/run_ui_i18n_switch_smoke.js @@ -91,9 +91,7 @@ async function captureLanguage(page, hubbleUrl, screenshot) { } async function switchToEnglish(page) { - await page.locator('.ant-layout-header .ant-select-selector') - .click({ timeout: 5000 }); - await page.locator('.ant-select-item-option[title="English"]') + await page.locator('[data-testid="language-toggle"]') .click({ timeout: 5000 }); await page.waitForFunction( () => window.localStorage.getItem('languageType') === 'en-US', @@ -127,13 +125,14 @@ async function main() { const auth = await authenticateUi(context, page, hubbleUrl, username, password); let zhText; let enText; - let selectorTextAfterSwitch = ''; + let englishSelected = false; try { zhText = await captureLanguage(page, hubbleUrl, path.join(outputDir, 'i18n-zh-CN.png')); await switchToEnglish(page); - selectorTextAfterSwitch = await page.locator('.ant-select').first() - .innerText({ timeout: 5000 }); + englishSelected = await page.locator( + '[data-testid="language-toggle"]' + ).isVisible({ timeout: 5000 }); await page.screenshot({ path: path.join(outputDir, 'i18n-en-US.png'), fullPage: true @@ -152,7 +151,7 @@ async function main() { authenticatedUser: auth.user.user_name, authLevel: auth.level, zhContainsChinese: /[\u4e00-\u9fff]/.test(zhText), - enSelectorVisible: /English/.test(selectorTextAfterSwitch), + enSelectorVisible: englishSelected, textChanged: zhText !== enText, rawI18nKeyFound: rawKeyPattern.test(zhText) || rawKeyPattern.test(enText), notFoundPage: /404|页面不存在|Not Found/.test(zhText + enText), diff --git a/hugegraph-hubble/hubble-fe/package.json b/hugegraph-hubble/hubble-fe/package.json index 5beb432fe..f2f347b9f 100644 --- a/hugegraph-hubble/hubble-fe/package.json +++ b/hugegraph-hubble/hubble-fe/package.json @@ -3,22 +3,19 @@ "version": "0.1.0", "private": true, "dependencies": { - "i18next": "^19.5.3", - "react-i18next": "^11.7.3", - "@types/lodash-es": "^4.17.3", + "3d-force-graph": "^1.71.2", "@ant-design/icons": "^4.7.0", "@antv/g6": "^4.6.15", "@antv/graphin": "^2.7.12", - "@antv/graphin-components": "^2.4.0", "@antv/graphin-icons": "^1.0.0", "@antv/x6": "^1.34.6", "@antv/x6-react-components": "^1.1.20", - "@antv/x6-react-shape": "^1.6.3", "@babel/eslint-plugin": "^7.18.10", + "@codemirror/legacy-modes": "^6.5.3", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^13.5.0", - "3d-force-graph": "^1.71.2", + "@types/lodash-es": "^4.17.3", "ajv": "8.17.1", "antd": "^4.23.1", "axios": "^0.27.2", @@ -27,28 +24,30 @@ "date-fns": "^2.29.3", "echarts": "^5.4.1", "http-proxy-middleware": "^2.0.6", + "i18next": "^19.5.3", "install": "^0.13.0", "json-bigint": "^1.0.0", "lodash": "^4.17.21", "moment": "^2.29.4", "node-gyp": "^9.4.0", "qs": "^6.13.0", - "sass": "^1.54.0", "react": "18.2.0", "react-color": "^2.19.3", "react-dom": "18.2.0", "react-highlight-words": "^0.18.0", + "react-i18next": "^11.7.3", "react-json-view": "^1.21.3", "react-router-dom": "^6.3.0", "react-scripts": "5.0.1", + "sass": "^1.54.0", "screenfull": "^6.0.2", "typescript": "^4.7.4", "validator": "^13.7.0", - "vis-network": "^9.1.2", "web-vitals": "^2.1.4" }, "scripts": { "start": "react-scripts start", + "dev": "GENERATE_SOURCEMAP=false react-scripts start", "build": "node scripts/check-i18n.js && react-scripts build", "lint": "eslint src --ext .js,.jsx,.ts,.tsx --max-warnings=0", "i18n:check": "node scripts/check-i18n.js", diff --git a/hugegraph-hubble/hubble-fe/src/App.css b/hugegraph-hubble/hubble-fe/src/App.css index 4d3da7a78..2dfcbb67c 100644 --- a/hugegraph-hubble/hubble-fe/src/App.css +++ b/hugegraph-hubble/hubble-fe/src/App.css @@ -88,5 +88,10 @@ } .ant-table-tbody .ant-empty-normal { - margin: 200px 0; + display: flex; + min-height: var(--workbench-table-empty-min-height); + margin: 0; + flex-direction: column; + align-items: center; + justify-content: center; } diff --git a/hugegraph-hubble/hubble-fe/src/App.js b/hugegraph-hubble/hubble-fe/src/App.js index a879fbdf2..cf669d538 100644 --- a/hugegraph-hubble/hubble-fe/src/App.js +++ b/hugegraph-hubble/hubble-fe/src/App.js @@ -20,13 +20,17 @@ import Route from './routes'; import 'antd/dist/antd.css'; import './App.scss'; import './App.css'; +import './styles/workbench.scss'; import Layout from './layout.ant'; +import {AuthContextProvider} from './auth/AuthContext'; function App() { return (
- } /> + + } /> +
); }; diff --git a/hugegraph-hubble/hubble-fe/src/App.scss b/hugegraph-hubble/hubble-fe/src/App.scss index e68fdb445..5095c82f7 100644 --- a/hugegraph-hubble/hubble-fe/src/App.scss +++ b/hugegraph-hubble/hubble-fe/src/App.scss @@ -21,20 +21,22 @@ iframe { } .main { - min-height: calc(100vh - 60px); + min-height: calc(100vh - var(--workbench-header-height)); .content { display: flex; flex-direction: column; - padding: 10px; + min-width: 0; + padding: 18px; + background: var(--workbench-color-canvas); .container { - background: #fff; - padding-left: 25px; - margin-top: 10px; - - padding: 10px; + padding: 20px; flex: 1 auto; + border: 1px solid var(--workbench-color-border); + border-radius: var(--workbench-radius-lg); + background: var(--workbench-color-surface); + box-shadow: var(--workbench-shadow-surface); } } diff --git a/hugegraph-hubble/hubble-fe/src/App.test.js b/hugegraph-hubble/hubble-fe/src/App.test.js index 5473c0387..11805599a 100644 --- a/hugegraph-hubble/hubble-fe/src/App.test.js +++ b/hugegraph-hubble/hubble-fe/src/App.test.js @@ -17,6 +17,7 @@ */ import {render, screen} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; import App from './App'; jest.mock('./routes', () => ({element}) => ( @@ -26,7 +27,14 @@ jest.mock('./routes', () => ({element}) => ( jest.mock('./layout.ant', () => () =>
Hubble layout
); test('wires the Hubble layout into the application router', () => { - render(); + sessionStorage.clear(); + render( + + + + ); expect(screen.getByTestId('app-route')).toBeInTheDocument(); expect(screen.getByText('Hubble layout')).toBeInTheDocument(); }); diff --git a/hugegraph-hubble/hubble-fe/src/accessible-click-actions.test.js b/hugegraph-hubble/hubble-fe/src/accessible-click-actions.test.js index f9a95a7c1..5e8b5465a 100644 --- a/hugegraph-hubble/hubble-fe/src/accessible-click-actions.test.js +++ b/hugegraph-hubble/hubble-fe/src/accessible-click-actions.test.js @@ -38,6 +38,18 @@ const collectSourceFiles = directory => fs.readdirSync(directory, {withFileTypes ? [target] : []; }); +const hasDirectIconClick = source => { + const iconImports = [...source.matchAll( + /import\s*\{([^}]+)\}\s*from\s*['"]@ant-design\/icons['"]/gs + )].flatMap(match => match[1].split(',').map(name => name.trim())) + .filter(name => /^[A-Z][A-Za-z]+(Outlined|Filled|TwoTone)$/.test(name)); + + return iconImports.some(iconName => { + const iconPattern = new RegExp(`<${iconName}\\b[^>]*\\bonClick=[^>]*>`, 'gs'); + return iconPattern.test(source); + }); +}; + test('reachable workbench actions do not use click-only anchors or containers', () => { const offenders = roots.flatMap(root => collectSourceFiles(path.join(__dirname, root))) .filter(file => { @@ -45,12 +57,28 @@ test('reachable workbench actions do not use click-only anchors or containers', if (/]*\bonClick=/s.test(source)) { return true; } - return [...source.matchAll(/<(?:span|div)\b[^>]*\bonClick=[^>]*>/gs)] + const inaccessibleContainer = [...source.matchAll( + /<(?:span|div)\b[^>]*\bonClick=[^>]*>/gs + )] .some(match => !/\bonKeyDown=/.test(match[0]) || !/\brole=/.test(match[0]) || !/\btabIndex=/.test(match[0])); + return inaccessibleContainer || hasDirectIconClick(source); }) .map(file => path.relative(__dirname, file)); expect(offenders).toEqual([]); }); + +test('task-name columns constrain long values and preserve the full name', () => { + const taskList = fs.readFileSync(path.join(__dirname, 'pages/Task/index.js'), 'utf8'); + const asyncList = fs.readFileSync( + path.join(__dirname, 'modules/asyncTasks/Detail/index.js'), + 'utf8' + ); + + [taskList, asyncList].forEach(source => { + expect(source).toMatch(/dataIndex:\s*['"]task_name['"][\s\S]{0,200}width:\s*\d+/); + expect(source).toMatch(/ellipsis=\{\{tooltip:\s*task_name\}\}/); + }); +}); diff --git a/hugegraph-hubble/hubble-fe/src/api/analysis-context-contract.test.js b/hugegraph-hubble/hubble-fe/src/api/analysis-context-contract.test.js new file mode 100644 index 000000000..003fce194 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/api/analysis-context-contract.test.js @@ -0,0 +1,78 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import request from './request'; +import * as analysis from './analysis'; + +jest.mock('./request', () => ({ + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), +})); + +beforeEach(() => jest.clearAllMocks()); + +test('forwards inline error ownership for graph context reads', () => { + const config = {suppressBusinessErrorToast: true}; + + analysis.getGraphSpaceList(config); + analysis.getGraphList('DEFAULT', config); + analysis.getOlapMode('DEFAULT', 'g', config); + + expect(request.get).toHaveBeenNthCalledWith(1, '/graphspaces/list', config); + expect(request.get).toHaveBeenNthCalledWith( + 2, '/graphspaces/DEFAULT/graphs/list', config + ); + expect(request.get).toHaveBeenNthCalledWith( + 3, '/graphspaces/DEFAULT/graphs/g/graph_read_mode', config + ); +}); + +test('keeps OLAP and Vermeer configs outside mutation bodies', () => { + const config = {suppressBusinessErrorToast: true}; + + analysis.switchOlapMode('DEFAULT', 'g', 0, config); + analysis.loadVermeerTask({graphspace: 'DEFAULT', graph: 'g'}, config); + + expect(request.put).toHaveBeenCalledWith( + '/graphspaces/DEFAULT/graphs/g/graph_read_mode', 0, config + ); + expect(request.post).toHaveBeenCalledWith( + '/vermeer/task', {graphspace: 'DEFAULT', graph: 'g'}, config + ); +}); + +test('lets the async query page own business error feedback', () => { + const params = {content: 'g.V()'}; + + analysis.getExecutionTask('DEFAULT', 'g', params); + analysis.getCypherTask('DEFAULT', 'g', params); + + expect(request.post).toHaveBeenNthCalledWith( + 1, + '/graphspaces/DEFAULT/graphs/g/gremlin-query/async-task', + params, + {suppressBusinessErrorToast: true} + ); + expect(request.post).toHaveBeenNthCalledWith( + 2, + '/graphspaces/DEFAULT/graphs/g/cypher/async-task', + params, + {suppressBusinessErrorToast: true} + ); +}); diff --git a/hugegraph-hubble/hubble-fe/src/api/analysis.js b/hugegraph-hubble/hubble-fe/src/api/analysis.js index fb0347b30..eddbf1abf 100644 --- a/hugegraph-hubble/hubble-fe/src/api/analysis.js +++ b/hugegraph-hubble/hubble-fe/src/api/analysis.js @@ -20,20 +20,25 @@ import request from './request'; import qs from 'qs'; // 图分析通用 -const getGraphSpaceList = () => { - return request.get('/graphspaces/list'); +const getGraphSpaceList = config => { + return config + ? request.get('/graphspaces/list', config) + : request.get('/graphspaces/list'); }; -const getGraphList = graphSpace => { - return request.get(`/graphspaces/${graphSpace}/graphs/list`); +const getGraphList = (graphSpace, config) => { + const url = `/graphspaces/${graphSpace}/graphs/list`; + return config ? request.get(url, config) : request.get(url); }; -const getOlapMode = (graphSpace, graph) => { - return request.get(`/graphspaces/${graphSpace}/graphs/${graph}/graph_read_mode`); +const getOlapMode = (graphSpace, graph, config) => { + const url = `/graphspaces/${graphSpace}/graphs/${graph}/graph_read_mode`; + return config ? request.get(url, config) : request.get(url); }; -const switchOlapMode = (graphSpace, graph, params) => { - return request.put(`/graphspaces/${graphSpace}/graphs/${graph}/graph_read_mode`, params); +const switchOlapMode = (graphSpace, graph, params, config) => { + const url = `/graphspaces/${graphSpace}/graphs/${graph}/graph_read_mode`; + return config ? request.put(url, params, config) : request.put(url, params); }; const getUploadList = (graphspace, graph) => { @@ -92,11 +97,19 @@ const getCypherExecutionQuery = (graphspace, graph, params) => { }; const getExecutionTask = (graphspace, graph, params) => { - return request.post(`/graphspaces/${graphspace}/graphs/${graph}/gremlin-query/async-task`, params); + return request.post( + `/graphspaces/${graphspace}/graphs/${graph}/gremlin-query/async-task`, + params, + {suppressBusinessErrorToast: true} + ); }; const getCypherTask = (graphspace, graph, params) => { - return request.post(`/graphspaces/${graphspace}/graphs/${graph}/cypher/async-task`, params); + return request.post( + `/graphspaces/${graphspace}/graphs/${graph}/cypher/async-task`, + params, + {suppressBusinessErrorToast: true} + ); }; const fetchManageTaskList = (graphspace, graph, params) => { @@ -193,8 +206,10 @@ const getExecuteAsyncTaskList = (graphspace, graph, params) => { return request.get(`/graphspaces/${graphspace}/graphs/${graph}/execute-histories`, {params}); }; -const loadVermeerTask = params => { - return request.post('/vermeer/task', {...params}); +const loadVermeerTask = (params, config) => { + return config + ? request.post('/vermeer/task', {...params}, config) + : request.post('/vermeer/task', {...params}); }; export { diff --git a/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js b/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js index e4041ab7e..8c435cbc6 100644 --- a/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js +++ b/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js @@ -40,6 +40,26 @@ describe('auth API contract', () => { expect(mockRequest.put).toHaveBeenCalledWith('/auth/users/personal', profile); }); + it('requests the no-store server authorization context', () => { + auth.context(); + + expect(mockRequest.get).toHaveBeenCalledWith('/auth/context', { + suppressBusinessErrorToast: true, + headers: { + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + }, + }); + }); + + it('forwards page-owned Vermeer status error controls', () => { + const config = {suppressBusinessErrorToast: true}; + + auth.getVermeer(config); + + expect(mockRequest.get).toHaveBeenCalledWith('/vermeer', config); + }); + it.each([ ['getAllUserList', [{page_no: 1}], 'get', '/auth/users'], ['getUserInfo', ['user'], 'get', '/auth/users/user'], @@ -81,4 +101,60 @@ describe('auth API contract', () => { expect(auth.addUuapUser).toBeUndefined(); expect(auth.getUUapList).toBeUndefined(); }); + + it.each([ + ['getSpaceMembers', ['A/B', {page_no: 1}], 'get', + '/graphspaces/A%2FB/auth/users'], + ['addSpaceMember', ['A/B', {user_id: 'u'}], 'post', + '/graphspaces/A%2FB/auth/users'], + ['updateSpaceMember', ['A/B', 'u/1', {roles: []}], 'put', + '/graphspaces/A%2FB/auth/users/u%2F1'], + ['deleteSpaceMember', ['A/B', 'u/1'], 'delete', + '/graphspaces/A%2FB/auth/users/u%2F1'], + ['getSpaceRoles', ['A/B', {page_no: 1}], 'get', + '/graphspaces/A%2FB/auth/roles'], + ['addSpaceRole', ['A/B', {role_name: 'reader'}], 'post', + '/graphspaces/A%2FB/auth/roles'], + ['updateSpaceRole', ['A/B', 'r/1', {role_name: 'reader'}], 'put', + '/graphspaces/A%2FB/auth/roles/r%2F1'], + ['deleteSpaceRole', ['A/B', 'r/1'], 'delete', + '/graphspaces/A%2FB/auth/roles/r%2F1'], + ['getSpaceTargets', ['A/B', {page_no: 1}], 'get', + '/graphspaces/A%2FB/auth/targets'], + ['addSpaceTarget', ['A/B', {target_name: 'all'}], 'post', + '/graphspaces/A%2FB/auth/targets'], + ['updateSpaceTarget', ['A/B', 't/1', {target_description: 'all'}], + 'put', '/graphspaces/A%2FB/auth/targets/t%2F1'], + ['deleteSpaceTarget', ['A/B', 't/1'], 'delete', + '/graphspaces/A%2FB/auth/targets/t%2F1'], + ['getSpaceAccesses', ['A/B', {role_id: 'r'}], 'get', + '/graphspaces/A%2FB/auth/accesses'], + ['saveSpaceAccess', ['A/B', {role_id: 'r'}], 'put', + '/graphspaces/A%2FB/auth/accesses'], + ['deleteSpaceAccess', ['A/B', 'r/1', 't/1'], 'delete', + '/graphspaces/A%2FB/auth/accesses'], + ])('%s keeps graphspace authorization requests path-scoped', ( + method, args, verb, route + ) => { + const config = {suppressBusinessErrorToast: true}; + auth[method](...args, config); + + if (verb === 'get') { + expect(mockRequest.get).toHaveBeenCalledWith( + route, {params: args.at(-1), ...config} + ); + } + else if (verb === 'delete') { + const expectedParams = method === 'deleteSpaceAccess' + ? {role_id: args[1], target_id: args[2]} : undefined; + expect(mockRequest.delete).toHaveBeenCalledWith( + route, expectedParams, config + ); + } + else { + expect(mockRequest[verb]).toHaveBeenCalledWith( + route, args.at(-1), config + ); + } + }); }); diff --git a/hugegraph-hubble/hubble-fe/src/api/auth.js b/hugegraph-hubble/hubble-fe/src/api/auth.js index 3987b2042..18a5dee05 100644 --- a/hugegraph-hubble/hubble-fe/src/api/auth.js +++ b/hugegraph-hubble/hubble-fe/src/api/auth.js @@ -31,6 +31,16 @@ const status = () => { return request.get('/auth/status', {suppressBusinessErrorToast: true}); }; +const context = () => { + return request.get('/auth/context', { + suppressBusinessErrorToast: true, + headers: { + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + }, + }); +}; + const getUserList = (params, config = {}) => { return request.get('/auth/users/list', {...config, params}); }; @@ -65,117 +75,114 @@ const updatePwd = (username, oldpwd, newpwd) => { const importUserUrl = '/api/v1.3/auth/users/batch'; -export {login, logout, status, getUserList, getAllUserList, getUserInfo, delUser, +export {login, logout, status, context, getUserList, getAllUserList, getUserInfo, delUser, updateUser, addUser, updatePwd, importUserUrl, updateAdminspace}; -// resource - -const getResourceList = (graphspace, params) => { - return request.get(`/graphspaces/${graphspace}/auth/targets`, {params}); +const getPersonal = config => { + return request.get('/auth/users/getpersonal', config); }; -const addResource = (graphspace, data) => { - return request.post(`/graphspaces/${graphspace}/auth/targets`, data); +const updatePersonal = data => { + return request.put('/auth/users/personal', data); }; -const updateResource = (graphspace, id, data) => { - return request.put(`/graphspaces/${graphspace}/auth/targets/${id}`, data); -}; +export {getPersonal, updatePersonal}; -const getResource = (graphspace, id) => { - return request.get(`/graphspaces/${graphspace}/auth/targets/${id}`); +const scopedAuthPath = (graphspace, resource, id) => { + const base = `/graphspaces/${encodeURIComponent(graphspace)}/auth/${resource}`; + return id === undefined ? base : `${base}/${encodeURIComponent(id)}`; }; -const delResource = (graphspace, id) => { - return request.delete(`/graphspaces/${graphspace}/auth/targets/${id}`); +const getSpaceMembers = (graphspace, params, config = {}) => { + return request.get(scopedAuthPath(graphspace, 'users'), {...config, params}); }; -export {getResourceList, addResource, updateResource, getResource, delResource}; - -// role - -const getRoleList = (graphspace, params) => { - return request.get(`/graphspaces/${graphspace}/auth/roles`, {params}); +const addSpaceMember = (graphspace, data, config) => { + return request.post(scopedAuthPath(graphspace, 'users'), data, config); }; -const getAllRoleList = graphspace => { - return request.get(`/graphspaces/${graphspace}/auth/roles/list`); +const updateSpaceMember = (graphspace, id, data, config) => { + return request.put(scopedAuthPath(graphspace, 'users', id), data, config); }; -const addRole = (graphspace, data) => { - return request.post(`/graphspaces/${graphspace}/auth/roles`, data); +const deleteSpaceMember = (graphspace, id, config) => { + return request.delete(scopedAuthPath(graphspace, 'users', id), + undefined, config); }; -const updateRole = (graphspace, id, data) => { - return request.put(`/graphspaces/${graphspace}/auth/roles/${id}`, data); +const getSpaceRoles = (graphspace, params, config = {}) => { + return request.get(scopedAuthPath(graphspace, 'roles'), {...config, params}); }; -const delRole = (graphspace, id) => { - return request.delete(`/graphspaces/${graphspace}/auth/roles/${id}`); +const addSpaceRole = (graphspace, data, config) => { + return request.post(scopedAuthPath(graphspace, 'roles'), data, config); }; -const delRoleBatch = (graphspace, data) => { - return request.delete(`/graphspaces/${graphspace}/auth/roles/`, data); +const updateSpaceRole = (graphspace, id, data, config) => { + return request.put(scopedAuthPath(graphspace, 'roles', id), data, config); }; -const getRoleResourceList = (graphspace, params) => { - return request.get(`/graphspaces/${graphspace}/auth/accesses`, {params}); +const deleteSpaceRole = (graphspace, id, config) => { + return request.delete(scopedAuthPath(graphspace, 'roles', id), + undefined, config); }; -const addRoleResource = (graphspace, data) => { - return request.post(`/graphspaces/${graphspace}/auth/accesses`, data); +const getSpaceTargets = (graphspace, params, config = {}) => { + return request.get(scopedAuthPath(graphspace, 'targets'), {...config, params}); }; -const updateRoleResource = (graphspace, data) => { - return request.put(`/graphspaces/${graphspace}/auth/accesses`, data); +const addSpaceTarget = (graphspace, data, config) => { + return request.post(scopedAuthPath(graphspace, 'targets'), data, config); }; -const delRoleResource = (graphspace, data) => { - return request.delete(`/graphspaces/${graphspace}/auth/accesses`, data); +const updateSpaceTarget = (graphspace, id, data, config) => { + return request.put(scopedAuthPath(graphspace, 'targets', id), data, config); }; -const getRoleUser = (graphspace, params) => { - return request.get(`/graphspaces/${graphspace}/auth/belongs`, {params}); +const deleteSpaceTarget = (graphspace, id, config) => { + return request.delete(scopedAuthPath(graphspace, 'targets', id), + undefined, config); }; -const addRoleUser = (graphspace, data) => { - return request.post(`/graphspaces/${graphspace}/auth/belongs`, data); +const getSpaceAccesses = (graphspace, params, config = {}) => { + return request.get(scopedAuthPath(graphspace, 'accesses'), {...config, params}); }; -const addRoleUserBatch = (graphspace, data) => { - return request.post(`/graphspaces/${graphspace}/auth/belongs/ids`, data); +const saveSpaceAccess = (graphspace, data, config) => { + return request.put(scopedAuthPath(graphspace, 'accesses'), data, config); }; -const delRoleUser = (graphspace, id) => { - return request.delete(`/graphspaces/${graphspace}/auth/belongs/${id}`); -}; - -const delRoleUserBatch = (graphspace, data) => { - return request.post(`/graphspaces/${graphspace}/auth/belongs/delids`, data); +const deleteSpaceAccess = (graphspace, roleId, targetId, config) => { + return request.delete(scopedAuthPath(graphspace, 'accesses'), { + role_id: roleId, + target_id: targetId, + }, config); }; export { - getRoleList, getAllRoleList, addRole, updateRole, delRole, delRoleBatch, - getRoleResourceList, addRoleResource, updateRoleResource, delRoleResource, - getRoleUser, addRoleUser, addRoleUserBatch, delRoleUser, delRoleUserBatch, -}; - -const getPersonal = config => { - return request.get('/auth/users/getpersonal', config); + getSpaceMembers, + addSpaceMember, + updateSpaceMember, + deleteSpaceMember, + getSpaceRoles, + addSpaceRole, + updateSpaceRole, + deleteSpaceRole, + getSpaceTargets, + addSpaceTarget, + updateSpaceTarget, + deleteSpaceTarget, + getSpaceAccesses, + saveSpaceAccess, + deleteSpaceAccess, }; -const updatePersonal = data => { - return request.put('/auth/users/personal', data); -}; - -export {getPersonal, updatePersonal}; - const getDashboard = () => { return request.get('/dashboard'); }; -const getVermeer = () => { - return request.get('/vermeer'); +const getVermeer = config => { + return config ? request.get('/vermeer', config) : request.get('/vermeer'); }; export {getDashboard, getVermeer}; diff --git a/hugegraph-hubble/hubble-fe/src/api/languageHeader.js b/hugegraph-hubble/hubble-fe/src/api/languageHeader.js index 8aa429c7f..04b8236a1 100644 --- a/hugegraph-hubble/hubble-fe/src/api/languageHeader.js +++ b/hugegraph-hubble/hubble-fe/src/api/languageHeader.js @@ -16,12 +16,9 @@ * under the License. */ -const DEFAULT_LANGUAGE = 'zh-CN'; -const ACCEPT_LANGUAGE = 'Accept-Language'; +import {getCurrentLanguage} from '../utils/language'; -export const getCurrentLanguage = () => { - return localStorage.getItem('languageType') || DEFAULT_LANGUAGE; -}; +const ACCEPT_LANGUAGE = 'Accept-Language'; export const withLanguageHeader = (headers = {}) => { const hasLanguageHeader = Object.keys(headers).some( diff --git a/hugegraph-hubble/hubble-fe/src/api/manage-contract.test.js b/hugegraph-hubble/hubble-fe/src/api/manage-contract.test.js index 70c4a33a0..daf0a7c49 100644 --- a/hugegraph-hubble/hubble-fe/src/api/manage-contract.test.js +++ b/hugegraph-hubble/hubble-fe/src/api/manage-contract.test.js @@ -31,21 +31,73 @@ beforeEach(() => { }); test('updates a graph with PUT JSON on the canonical route', () => { - manage.updateGraph('DEFAULT', 'g', {nickname: 'nick'}); + const config = {suppressBusinessErrorToast: true}; + manage.updateGraph('DEFAULT', 'g', {nickname: 'nick'}, config); expect(request.put).toHaveBeenCalledWith( '/graphspaces/DEFAULT/graphs/g', - {nickname: 'nick'} + {nickname: 'nick'}, + config + ); +}); + +test('creates a property with request error ownership controls', () => { + const config = {suppressBusinessErrorToast: true}; + manage.addMetaProperty('DEFAULT', 'g', {name: 'created_at'}, config); + + expect(request.post).toHaveBeenCalledWith( + '/graphspaces/DEFAULT/graphs/g/schema/propertykeys', + {name: 'created_at'}, + config + ); +}); + +test('applies Groovy Schema to an existing graph with page-owned errors', () => { + const config = {suppressBusinessErrorToast: true}; + const data = {'schema-groovy': 'graph.schema().propertyKey("name").create()'}; + + manage.addGraphSchema('DEFAULT', 'g', data, config); + + expect(request.post).toHaveBeenCalledWith( + '/graphspaces/DEFAULT/graphs/g/schema/groovy', + data, + config ); }); test('clears graph data with POST on the canonical route', () => { - manage.clearGraphData('DEFAULT', 'g'); + manage.clearGraph('DEFAULT', 'g'); expect(request.post).toHaveBeenCalledWith( '/graphspaces/DEFAULT/graphs/g/clear' ); expect(request.get).not.toHaveBeenCalled(); - expect(manage.clearGraphDataAndSchema).toBeUndefined(); + expect(manage.clearGraphData).toBeUndefined(); +}); + +test('loads the non-destructive sample through the target graph route', () => { + const config = {suppressBusinessErrorToast: true}; + manage.loadSampleGraph('DEFAULT', 'g', 'loader', config); + + expect(request.post).toHaveBeenCalledWith( + '/graphspaces/DEFAULT/graphs/g/sample', + undefined, + {...config, params: {dataset: 'loader'}} + ); +}); + +test('keeps task-run error ownership controls out of query parameters', () => { + manage.getJobsList( + {taskid: '42'}, + {suppressBusinessErrorToast: true} + ); + + expect(request.get).toHaveBeenCalledWith( + '/ingest/jobs/list', + { + params: {taskid: '42'}, + suppressBusinessErrorToast: true, + } + ); }); test('reads the default graph from the canonical route', () => { diff --git a/hugegraph-hubble/hubble-fe/src/api/manage.js b/hugegraph-hubble/hubble-fe/src/api/manage.js index 863c97480..02c144074 100644 --- a/hugegraph-hubble/hubble-fe/src/api/manage.js +++ b/hugegraph-hubble/hubble-fe/src/api/manage.js @@ -55,14 +55,22 @@ const addSchema = (graphspace, data, config) => { return request.post(`/graphspaces/${graphspace}/schematemplates`, data, config); }; -const getSchema = (graphspace, name) => { - return request.get(`graphspaces/${graphspace}/schematemplates/${name}`); +const getSchema = (graphspace, name, config) => { + return request.get(`graphspaces/${graphspace}/schematemplates/${name}`, config); }; const getGraphSchema = (graphspace, graph) => { return request.get(`/graphspaces/${graphspace}/graphs/${graph}/schema/groovy`); }; +const addGraphSchema = (graphspace, graph, data, config) => { + return request.post( + `/graphspaces/${graphspace}/graphs/${graph}/schema/groovy`, + data, + config + ); +}; + const exportSchema = (graphspace, graph) => { return request.get(`graphspaces/${graphspace}/graphs/${graph}/schema/groovy/export`); }; @@ -75,7 +83,8 @@ const delSchema = (graphspace, name, config) => { return request.delete(`graphspaces/${graphspace}/schematemplates/${name}`, undefined, config); }; -export {getSchemaList, addSchema, updateSchema, getSchema, getGraphSchema, exportSchema, delSchema}; +export {getSchemaList, addSchema, updateSchema, getSchema, getGraphSchema, + addGraphSchema, exportSchema, delSchema}; // 图 const getGraphList = (graphspace, params, config = {}) => { @@ -90,8 +99,8 @@ const addGraph = (graphspace, data) => { }); }; -const updateGraph = (graphspace, graph, params) => { - return request.put(`/graphspaces/${graphspace}/graphs/${graph}`, params); +const updateGraph = (graphspace, graph, params, config) => { + return request.put(`/graphspaces/${graphspace}/graphs/${graph}`, params, config); }; const getGraph = (graphspace, graph, config) => { @@ -116,10 +125,17 @@ const getDefaultGraph = (graphspace, config) => { return config ? request.get(path, config) : request.get(path); }; -const clearGraphData = (graphspace, graph) => { +const clearGraph = (graphspace, graph) => { return request.post(`/graphspaces/${graphspace}/graphs/${graph}/clear`); }; +const loadSampleGraph = (graphspace, graph, dataset, config = {}) => { + return request.post(`/graphspaces/${graphspace}/graphs/${graph}/sample`, undefined, { + ...config, + params: {dataset}, + }); +}; + const getGraphStatistic = (graphspace, graph, config) => { return request.get(`/graphspaces/${graphspace}/graphs/${graph}/statistics`, config); }; @@ -133,7 +149,7 @@ const cloneGraph = (graphspace, graph, params) => { }; export {getGraphList, getGraph, addGraph, updateGraph, delGraph, getDefaultGraph, - getGraphView, setDefaultGraph, clearGraphData, + getGraphView, setDefaultGraph, clearGraph, loadSampleGraph, getGraphStatistic, updateGraphStatistic, cloneGraph}; // meta property @@ -141,8 +157,9 @@ const getMetaPropertyList = (graphspace, graph, params) => { return request.get(`/graphspaces/${graphspace}/graphs/${graph}/schema/propertykeys`, {params}); }; -const addMetaProperty = (graphspace, graph, data) => { - return request.post(`/graphspaces/${graphspace}/graphs/${graph}/schema/propertykeys`, data); +const addMetaProperty = (graphspace, graph, data, config) => { + return request.post( + `/graphspaces/${graphspace}/graphs/${graph}/schema/propertykeys`, data, config); }; const checkMetaProperty = (graphspace, graph, data, config) => { @@ -341,8 +358,8 @@ const getMetricsTask = () => { export {addTask, getTaskList, getTaskDetail, deleteTask, disableTask, enableTask, updateTask, getMetricsTask}; // job -const getJobsList = params => { - return request.get(`${testhost}/jobs/list`, {params}); +const getJobsList = (params, config = {}) => { + return request.get(`${testhost}/jobs/list`, {...config, params}); }; const getJobsDetail = id => { diff --git a/hugegraph-hubble/hubble-fe/src/api/operations.js b/hugegraph-hubble/hubble-fe/src/api/operations.js new file mode 100644 index 000000000..59c90e6ef --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/api/operations.js @@ -0,0 +1,76 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import request from './request'; + +const QUIET = { + suppressBusinessErrorToast: true, + headers: { + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + }, +}; + +const unwrap = response => { + if (response?.status !== 200) { + const error = new Error(`operations_request_${response?.status ?? 'failed'}`); + error.status = response?.status; + throw error; + } + return response.data; +}; + +const requestOperation = async requestPromise => { + try { + return unwrap(await requestPromise); + } + catch (error) { + error.status = error.data?.status + ?? error.response?.data?.status + ?? error.response?.status + ?? error.status; + throw error; + } +}; + +const getCapabilities = async () => requestOperation( + request.get('/operations/capabilities', QUIET) +); + +const getOverview = async (refresh = false) => requestOperation( + request.get('/operations/overview', { + ...QUIET, + params: {refresh}, + }) +); + +const getNodes = async params => requestOperation( + request.get('/operations/nodes', { + ...QUIET, + params, + }) +); + +const getNode = async (nodeId, refresh = false) => requestOperation( + request.get(`/operations/nodes/${encodeURIComponent(nodeId)}`, { + ...QUIET, + params: {refresh}, + }) +); + +export {getCapabilities, getOverview, getNodes, getNode}; diff --git a/hugegraph-hubble/hubble-fe/src/api/operations.test.js b/hugegraph-hubble/hubble-fe/src/api/operations.test.js new file mode 100644 index 000000000..3324ace89 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/api/operations.test.js @@ -0,0 +1,88 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import request from './request'; +import {getCapabilities, getNode, getNodes, getOverview} from './operations'; + +jest.mock('./request'); + +test('unwraps the Hubble response envelope for operations pages', async () => { + request.get.mockResolvedValueOnce({ + status: 200, + data: {capabilities: ['operations_health_read']}, + }).mockResolvedValueOnce({ + status: 200, + data: {status: 'UP', nodes: []}, + }); + + await expect(getCapabilities()).resolves.toEqual({ + capabilities: ['operations_health_read'], + }); + expect(request.get).toHaveBeenNthCalledWith(1, '/operations/capabilities', { + suppressBusinessErrorToast: true, + headers: {'Cache-Control': 'no-store', Pragma: 'no-cache'}, + }); + await expect(getOverview()).resolves.toEqual({status: 'UP', nodes: []}); +}); + +test('rejects a failed operations business response', async () => { + request.get.mockResolvedValue({status: 403, message: 'Forbidden'}); + + await expect(getCapabilities()).rejects.toMatchObject({ + message: 'operations_request_403', + status: 403, + }); +}); + +test('preserves an HTTP authorization status for stale-data cleanup', async () => { + const error = new Error('Request failed with status code 403'); + error.response = {status: 403}; + request.get.mockRejectedValue(error); + + await expect(getNodes({page: 1})).rejects.toMatchObject({status: 403}); +}); + +test('preserves a business authorization status from an HTTP 200 envelope', async () => { + const error = new Error('Unauthorized'); + error.status = 200; + error.data = {status: 401, message: 'Unauthorized'}; + request.get.mockRejectedValue(error); + + await expect(getOverview()).rejects.toMatchObject({status: 401}); +}); + +test('preserves the snake case metric status contract for node details', async () => { + const metricStatuses = { + system: { + availability: 'UNAVAILABLE', + observed_at: 1000, + last_success_at: 900, + fresh: false, + stale: true, + reason: 'refresh_failed', + }, + }; + request.get.mockResolvedValue({ + status: 200, + data: {node: {id: 'server-safe', metric_statuses: metricStatuses}}, + }); + + await expect(getNode('server-safe')).resolves.toEqual({ + node: {id: 'server-safe', metric_statuses: metricStatuses}, + }); +}); diff --git a/hugegraph-hubble/hubble-fe/src/api/request-error-semantics.test.js b/hugegraph-hubble/hubble-fe/src/api/request-error-semantics.test.js index 761f23ce0..5acd878fc 100644 --- a/hugegraph-hubble/hubble-fe/src/api/request-error-semantics.test.js +++ b/hugegraph-hubble/hubble-fe/src/api/request-error-semantics.test.js @@ -74,8 +74,9 @@ describe.each(['./request'])('%s error semantics', modulePath => { beforeEach(() => { delete window.location; window.location = { - pathname: '/navigation', - search: '?from=test', + pathname: '/gremlin/DEFAULT/hugegraph', + search: '?x=1', + hash: '#result', href: '', }; }); @@ -89,32 +90,89 @@ describe.each(['./request'])('%s error semantics', modulePath => { sessionStorage.clear(); }); - it('keeps non-401 HTTP errors rejected after showing the error message', async () => { + it('adapts non-auth HTTP errors to legacy response data after showing the error', + async () => { + const {reject, messageError} = loadResponseHandlers(modulePath); + const error = { + config: {}, + response: { + status: 500, + data: { + status: 500, + message: 'boom', + path: '/api/v1.3/graphs', + }, + }, + }; + + expect(reject(error)).toBe(error.response); + expect(messageError).toHaveBeenCalledWith('request.error'); + }); + + it('keeps network errors rejected after showing the fallback message', async () => { const {reject, messageError} = loadResponseHandlers(modulePath); - const error = { + const error = new Error('Network Error'); + + await expect(reject(error)).rejects.toBe(error); + expect(messageError).toHaveBeenCalledWith('request.error'); + }); + + it('redacts secrets and absolute paths before errors reach the DOM', async () => { + const {resolve, reject, messageError} = loadResponseHandlers(modulePath); + const business = { + status: 200, + config: {}, + data: { + status: 500, + message: 'Cookie: first=canary-one; JSESSIONID=canary-two\n' + + 'Authorization: Bearer canary-token at /Users/alice/key.pem', + }, + }; + + expect(resolve(business)).toBe(business); + expect(messageError).toHaveBeenLastCalledWith(expect.not.stringContaining( + 'canary-token' + )); + expect(business.data.message).not.toContain('canary-two'); + expect(business.data.message).not.toContain('/Users/alice'); + + const transport = { + config: {}, + message: 'token=transport-canary', response: { status: 500, data: { - message: 'boom', - path: '/api/v1.3/graphs', + message: 'password=transport-canary', + path: 'C:\\secrets\\private.key', }, }, }; - - await expect(reject(error)).rejects.toBe(error); - expect(messageError).toHaveBeenCalledWith('request.error'); + expect(reject(transport)).toBe(transport.response); + expect(transport.message).not.toContain('transport-canary'); + expect(transport.response.data.message).not.toContain('transport-canary'); + expect(transport.response.data.path).not.toContain('C:\\secrets'); }); - it('keeps network errors rejected after showing the fallback message', async () => { - const {reject, messageError} = loadResponseHandlers(modulePath); - const error = new Error('Network Error'); + it('requests authorization revalidation after an HTTP 403', async () => { + const {reject} = loadResponseHandlers(modulePath); + const revalidate = jest.fn(); + window.addEventListener('hubble:auth-revalidate', revalidate); + const error = { + config: {url: '/operations/nodes'}, + response: { + status: 403, + data: {status: 403, message: 'Forbidden'}, + }, + }; await expect(reject(error)).rejects.toBe(error); - expect(messageError).toHaveBeenCalledWith('request.error'); + + expect(revalidate).toHaveBeenCalledTimes(1); + window.removeEventListener('hubble:auth-revalidate', revalidate); }); it('rejects HTTP 401 and redirects to login', async () => { - const {reject, clearLogin} = loadResponseHandlers(modulePath); + const {reject, clearLogin, instance} = loadResponseHandlers(modulePath); const error = { response: { status: 401, @@ -127,12 +185,19 @@ describe.each(['./request'])('%s error semantics', modulePath => { await expect(reject(error)).rejects.toBe(error); expect(clearLogin).toHaveBeenCalledTimes(1); - expect(sessionStorage.getItem('redirect')).toBe('/navigation?from=test'); - expect(window.location.href).toBe('/login?redirect=%2Fnavigation%3Ffrom%3Dtest'); + expect(sessionStorage.getItem('redirect')) + .toBe('/gremlin/DEFAULT/hugegraph?x=1#result'); + expect(window.location.href).toBe( + '/login?redirect=%2Fgremlin%2FDEFAULT%2Fhugegraph%3Fx%3D1%23result' + ); + expect(instance.get).not.toHaveBeenCalled(); + expect(instance.post).not.toHaveBeenCalled(); + expect(instance.put).not.toHaveBeenCalled(); + expect(instance.delete).not.toHaveBeenCalled(); }); it('rejects business 401 and redirects to login', async () => { - const {resolve, clearLogin} = loadResponseHandlers(modulePath); + const {resolve, clearLogin, instance} = loadResponseHandlers(modulePath); const response = { status: 200, data: { @@ -143,7 +208,105 @@ describe.each(['./request'])('%s error semantics', modulePath => { await expect(resolve(response)).rejects.toBe(response); expect(clearLogin).toHaveBeenCalledTimes(1); - expect(window.location.href).toBe('/login?redirect=%2Fnavigation%3Ffrom%3Dtest'); + expect(sessionStorage.getItem('redirect')) + .toBe('/gremlin/DEFAULT/hugegraph?x=1#result'); + expect(window.location.href).toBe( + '/login?redirect=%2Fgremlin%2FDEFAULT%2Fhugegraph%3Fx%3D1%23result' + ); + expect(instance.get).not.toHaveBeenCalled(); + expect(instance.post).not.toHaveBeenCalled(); + expect(instance.put).not.toHaveBeenCalled(); + expect(instance.delete).not.toHaveBeenCalled(); + }); + + it.each([401, 403])( + 'shows business %s from login without redirecting', + async status => { + window.location.pathname = '/login'; + const {resolve, messageError, clearLogin} + = loadResponseHandlers(modulePath); + const response = { + status: 200, + config: {url: '/auth/login'}, + data: { + status, + message: '用户名或密码不正确', + }, + }; + + await expect(resolve(response)).rejects.toBe(response); + + expect(messageError).toHaveBeenCalledTimes(1); + expect(messageError).toHaveBeenCalledWith('用户名或密码不正确'); + expect(clearLogin).not.toHaveBeenCalled(); + expect(sessionStorage.getItem('redirect')).toBeNull(); + expect(window.location.href).toBe(''); + } + ); + + it.each([401, 403])( + 'shows HTTP %s from login without redirecting', + async status => { + window.location.pathname = '/login'; + const {reject, messageError, clearLogin} + = loadResponseHandlers(modulePath); + const error = { + config: {url: '/auth/login'}, + response: { + status, + data: { + status, + message: '用户名或密码不正确', + }, + }, + }; + + await expect(reject(error)).rejects.toBe(error); + + expect(messageError).toHaveBeenCalledTimes(1); + expect(messageError).toHaveBeenCalledWith('用户名或密码不正确'); + expect(clearLogin).not.toHaveBeenCalled(); + expect(sessionStorage.getItem('redirect')).toBeNull(); + expect(window.location.href).toBe(''); + } + ); + + it.each([ + [401, 'an empty body', undefined], + [401, 'a non-JSON body', 'Unauthorized'], + [403, 'an empty body', undefined], + [403, 'a non-JSON body', 'Forbidden'], + ])('shows one localized fallback for login HTTP %s with %s', + async (status, description, data) => { + window.location.pathname = '/login'; + const {reject, messageError, clearLogin} + = loadResponseHandlers(modulePath); + const error = { + config: {url: '/auth/login'}, + response: {status, data}, + }; + + await expect(reject(error)).rejects.toBe(error); + + expect(messageError).toHaveBeenCalledTimes(1); + expect(messageError).toHaveBeenCalledWith('request.failed'); + expect(clearLogin).not.toHaveBeenCalled(); + }); + + it('keeps the session for an upstream query authentication failure', () => { + const {resolve, clearLogin} = loadResponseHandlers(modulePath); + const response = { + status: 200, + config: {suppressBusinessErrorToast: true}, + data: { + status: 502, + message: 'The graph server rejected query authentication.', + }, + }; + + expect(resolve(response)).toBe(response); + expect(clearLogin).not.toHaveBeenCalled(); + expect(window.location.href).toBe(''); }); it('shows a modal warning for throttled login attempts', () => { @@ -175,8 +338,8 @@ describe.each(['./request'])('%s error semantics', modulePath => { }, }; - await expect(reject(error)).rejects.toBe(error); - await expect(reject(error)).rejects.toBe(error); + expect(reject(error)).toBe(error.response); + expect(reject(error)).toBe(error.response); expect(modalWarning).toHaveBeenCalledTimes(1); expect(messageError).not.toHaveBeenCalled(); }); @@ -206,7 +369,7 @@ describe.each(['./request'])('%s error semantics', modulePath => { }, }; - await expect(reject(error)).rejects.toBe(error); + expect(reject(error)).toBe(error.response); expect(messageError).not.toHaveBeenCalled(); }); diff --git a/hugegraph-hubble/hubble-fe/src/api/request-language-header.test.js b/hugegraph-hubble/hubble-fe/src/api/request-language-header.test.js index d69c20025..00b9cabf3 100644 --- a/hugegraph-hubble/hubble-fe/src/api/request-language-header.test.js +++ b/hugegraph-hubble/hubble-fe/src/api/request-language-header.test.js @@ -59,6 +59,13 @@ describe('request language header', () => { localStorage.clear(); }); + it('defaults fresh sessions to English', () => { + const intercept = loadRequestInterceptor('./request'); + const config = intercept({headers: {}, data: {}}); + + expect(config.headers['Accept-Language']).toBe('en-US'); + }); + it('adds the selected language to JSON requests', () => { localStorage.setItem('languageType', 'en-US'); const intercept = loadRequestInterceptor('./request'); diff --git a/hugegraph-hubble/hubble-fe/src/api/request.js b/hugegraph-hubble/hubble-fe/src/api/request.js index 0b2aacaab..8ce3355fc 100644 --- a/hugegraph-hubble/hubble-fe/src/api/request.js +++ b/hugegraph-hubble/hubble-fe/src/api/request.js @@ -24,6 +24,8 @@ import i18n from '../i18n'; import * as user from '../utils/user'; import {withLanguageHeader} from './languageHeader'; import {showThrottleWarning} from './throttleWarning'; +import {AUTH_REVALIDATE_EVENT} from '../utils/authEvents'; +import {sanitizePublicError} from '../utils/publicError'; const isJsonResponse = headers => { const contentType = headers?.['content-type'] || headers?.['Content-Type'] || ''; @@ -40,7 +42,8 @@ const parseResponse = (data, headers) => { const redirectToLogin = () => { user.clearLogin(); if (window.location.pathname !== '/login') { - const redirect = `${window.location.pathname}${window.location.search}`; + const redirect = `${window.location.pathname}${window.location.search}` + + window.location.hash; sessionStorage.setItem('redirect', redirect); window.location.href = `/login?redirect=${encodeURIComponent(redirect)}`; } @@ -53,12 +56,38 @@ const showRequestError = res => { })); }; +const sanitizeResponseError = response => { + const data = response?.data; + if (!data || typeof data !== 'object') { + return; + } + if (typeof data.message === 'string') { + data.message = sanitizePublicError(data.message); + } + if (typeof data.path === 'string') { + data.path = sanitizePublicError(data.path); + } +}; + const isUnauthorizedError = error => { return error.response?.status === 401 || error.response?.data?.status === 401 || error.message?.includes('status code 401'); }; +const isLoginRequest = config => config?.url?.endsWith('/auth/login'); + +const showLoginAuthError = response => { + const errorMessage = response?.data?.message; + message.error(!_.isEmpty(errorMessage) ? errorMessage : i18n.t('request.failed')); +}; + +const notifyForbidden = config => { + if (!config?.url?.includes('/auth/context')) { + window.dispatchEvent(new CustomEvent(AUTH_REVALIDATE_EVENT)); + } +}; + const instance = axios.create({ baseURL: '/api/v1.3', withCredentials: true, @@ -88,13 +117,26 @@ instance.interceptors.request.use( instance.interceptors.response.use( response => { + sanitizeResponseError(response); if (response.status === 401 || response.data?.status === 401) { - redirectToLogin(); + if (isLoginRequest(response.config)) { + showLoginAuthError(response); + } + else { + redirectToLogin(); + } return Promise.reject(response); } else if (response.data?.status === 429) { showThrottleWarning(response.data.message); } + else if (response.status === 403 || response.data?.status === 403) { + if (isLoginRequest(response.config)) { + showLoginAuthError(response); + return Promise.reject(response); + } + notifyForbidden(response.config); + } else if (response.data?.status !== 200 && !response.config?.suppressBusinessErrorToast) { if (!_.isEmpty(response.data.message)) { @@ -104,19 +146,41 @@ instance.interceptors.response.use( return response; }, error => { + sanitizeResponseError(error.response); + if (typeof error.message === 'string') { + error.message = sanitizePublicError(error.message); + } if (isUnauthorizedError(error)) { - redirectToLogin(); + if (isLoginRequest(error.config)) { + showLoginAuthError(error.response); + } + else { + redirectToLogin(); + } return Promise.reject(error); } if (error.response?.status === 429 || error.response?.data?.status === 429) { showThrottleWarning(error.response?.data?.message); + return error.response; + } + if (error.response?.status === 403 + || error.response?.data?.status === 403) { + if (isLoginRequest(error.config)) { + showLoginAuthError(error.response); + return Promise.reject(error); + } + notifyForbidden(error.config); return Promise.reject(error); } if (!error.config?.suppressBusinessErrorToast) { const res = error.response?.data; showRequestError(res); } + if (error.response) { + // Keep legacy form callers settled while the server returns real HTTP errors. + return error.response; + } return Promise.reject(error); } ); diff --git a/hugegraph-hubble/hubble-fe/src/assets/hugegraph-mark.svg b/hugegraph-hubble/hubble-fe/src/assets/hugegraph-mark.svg new file mode 100644 index 000000000..53dba268a --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/assets/hugegraph-mark.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js b/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js new file mode 100644 index 000000000..1d9bc0a8d --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js @@ -0,0 +1,172 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import {useLocation} from 'react-router-dom'; +import * as api from '../api/index'; +import {AUTH_REVALIDATE_EVENT} from '../utils/authEvents'; +import {getUser, USER_CHANGE_EVENT} from '../utils/user'; + +const REFRESH_INTERVAL_MS = 60_000; +const MIN_REFRESH_INTERVAL_MS = 5_000; +const emptyState = {loading: false, context: null, error: null}; +const AuthContext = createContext({ + ...emptyState, + identityEpoch: 0, + hasCapability: () => false, + refresh: () => Promise.resolve(), +}); + +const isSignedIn = () => Boolean(getUser()?.id); + +const unwrapContext = response => { + if (response?.status !== 200 || !response.data + || !Array.isArray(response.data.capabilities)) { + const error = new Error(`auth_context_${response?.status ?? 'invalid'}`); + error.status = response?.status; + throw error; + } + return response.data; +}; + +const AuthContextProvider = ({children}) => { + const location = useLocation(); + const epochRef = useRef(0); + const latestRequestRef = useRef(0); + const inFlightRef = useRef(null); + const lastSuccessRef = useRef(0); + const [identityEpoch, setIdentityEpoch] = useState(0); + const [state, setState] = useState(() => ( + isSignedIn() ? {...emptyState, loading: true} : emptyState + )); + + const load = useCallback(({force = false} = {}) => { + if (!isSignedIn()) { + setState(emptyState); + return Promise.resolve(null); + } + const epoch = epochRef.current; + const now = Date.now(); + if (!force && lastSuccessRef.current > 0 + && now - lastSuccessRef.current < MIN_REFRESH_INTERVAL_MS) { + return Promise.resolve(null); + } + if (!force && inFlightRef.current?.epoch === epoch) { + return inFlightRef.current.promise; + } + + const requestId = ++latestRequestRef.current; + if (force) { + setState({loading: true, context: null, error: null}); + } + const promise = api.auth.context() + .then(unwrapContext) + .then(context => { + if (epoch === epochRef.current + && requestId === latestRequestRef.current + && isSignedIn()) { + lastSuccessRef.current = Date.now(); + setState({loading: false, context, error: null}); + } + return context; + }) + .catch(error => { + if (epoch === epochRef.current + && requestId === latestRequestRef.current) { + setState({loading: false, context: null, error}); + } + throw error; + }) + .finally(() => { + if (inFlightRef.current?.requestId === requestId) { + inFlightRef.current = null; + } + }); + inFlightRef.current = {epoch, requestId, promise}; + return promise; + }, []); + + useEffect(() => { + const identityChanged = () => { + epochRef.current += 1; + latestRequestRef.current += 1; + inFlightRef.current = null; + lastSuccessRef.current = 0; + setIdentityEpoch(epochRef.current); + if (isSignedIn()) { + load({force: true}).catch(() => undefined); + } + else { + setState(emptyState); + } + }; + window.addEventListener(USER_CHANGE_EVENT, identityChanged); + return () => window.removeEventListener( + USER_CHANGE_EVENT, + identityChanged + ); + }, [load]); + + useEffect(() => { + load().catch(() => undefined); + }, [load, location.pathname, location.search]); + + useEffect(() => { + const revalidate = () => load({force: true}).catch(() => undefined); + const onFocus = () => load().catch(() => undefined); + const onVisibility = () => { + if (document.visibilityState === 'visible') { + onFocus(); + } + }; + window.addEventListener(AUTH_REVALIDATE_EVENT, revalidate); + window.addEventListener('focus', onFocus); + document.addEventListener('visibilitychange', onVisibility); + const timer = window.setInterval(onFocus, REFRESH_INTERVAL_MS); + return () => { + window.removeEventListener(AUTH_REVALIDATE_EVENT, revalidate); + window.removeEventListener('focus', onFocus); + document.removeEventListener('visibilitychange', onVisibility); + window.clearInterval(timer); + }; + }, [load]); + + const value = useMemo(() => { + const capabilities = state.context?.capabilities ?? []; + return { + ...state, + identityEpoch, + hasCapability: capability => capabilities.includes(capability), + refresh: () => load({force: true}), + }; + }, [identityEpoch, load, state]); + + return {children}; +}; + +const useAuthContext = () => useContext(AuthContext); + +export {AuthContextProvider, useAuthContext}; diff --git a/hugegraph-hubble/hubble-fe/src/auth/AuthContext.test.js b/hugegraph-hubble/hubble-fe/src/auth/AuthContext.test.js new file mode 100644 index 000000000..f541258fa --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/auth/AuthContext.test.js @@ -0,0 +1,141 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {act, cleanup, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import * as api from '../api/index'; +import {AuthContextProvider, useAuthContext} from './AuthContext'; +import {AUTH_REVALIDATE_EVENT} from '../utils/authEvents'; +import {setUser} from '../utils/user'; + +jest.mock('../api/index', () => ({ + auth: { + context: jest.fn(), + }, +})); + +const response = capabilities => ({ + status: 200, + data: { + schema_version: 1, + context_version: capabilities.join('-') || 'none', + mode: 'PD', + role: 'USER', + username: 'alice', + capabilities, + actions: {}, + scopes: {}, + }, +}); + +const deferred = () => { + let resolve; + const promise = new Promise(done => { + resolve = done; + }); + return {promise, resolve}; +}; + +const Probe = () => { + const {loading, context, hasCapability} = useAuthContext(); + if (loading) { + return
loading
; + } + return ( +
+ {context?.context_version ?? 'no-context'} + {hasCapability('operations_health_read') && operations} +
+ ); +}; + +const renderProvider = () => render( + + + + + +); + +beforeEach(() => { + sessionStorage.clear(); + jest.clearAllMocks(); +}); + +afterEach(() => cleanup()); + +test('loads capabilities from the server context without role inference', async () => { + setUser({id: 'alice', is_superadmin: false}); + api.auth.context.mockResolvedValue(response(['operations_health_read'])); + + renderProvider(); + + expect(await screen.findByText('operations')).toBeInTheDocument(); + expect(api.auth.context).toHaveBeenCalledTimes(1); +}); + +test('ignores an old response after the same username logs in again', async () => { + const oldRequest = deferred(); + const currentRequest = deferred(); + setUser({id: 'alice'}); + api.auth.context + .mockReturnValueOnce(oldRequest.promise) + .mockReturnValueOnce(currentRequest.promise); + renderProvider(); + + await waitFor(() => expect(api.auth.context).toHaveBeenCalledTimes(1)); + act(() => setUser({id: 'alice'})); + await waitFor(() => expect(api.auth.context).toHaveBeenCalledTimes(2)); + await act(async () => { + currentRequest.resolve(response([])); + }); + expect(await screen.findByText('none')).toBeInTheDocument(); + + await act(async () => { + oldRequest.resolve(response(['operations_health_read'])); + }); + expect(screen.queryByText('operations')).not.toBeInTheDocument(); + expect(screen.getByText('none')).toBeInTheDocument(); +}); + +test('forces a fresh context after a 403 and applies a downgrade', async () => { + setUser({id: 'alice'}); + api.auth.context + .mockResolvedValueOnce(response(['operations_health_read'])) + .mockResolvedValueOnce(response([])); + renderProvider(); + expect(await screen.findByText('operations')).toBeInTheDocument(); + + await act(async () => { + window.dispatchEvent(new CustomEvent(AUTH_REVALIDATE_EVENT)); + }); + + await waitFor(() => expect(api.auth.context).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByText('operations')) + .not.toBeInTheDocument()); +}); + +test('does not request an auth context for an anonymous browser state', async () => { + renderProvider(); + + expect(await screen.findByText('no-context')).toBeInTheDocument(); + expect(api.auth.context).not.toHaveBeenCalled(); +}); diff --git a/hugegraph-hubble/hubble-fe/src/components/BrandLockup/index.js b/hugegraph-hubble/hubble-fe/src/components/BrandLockup/index.js new file mode 100644 index 000000000..92cc97cbf --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/BrandLockup/index.js @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import PropTypes from 'prop-types'; +import Mark from '../../assets/hugegraph-mark.svg'; +import style from './index.module.scss'; + +const BrandLockup = ({className = '', compact = false}) => ( + + Apache HugeGraph + + +); + +BrandLockup.propTypes = { + className: PropTypes.string, + compact: PropTypes.bool, +}; + +export default BrandLockup; diff --git a/hugegraph-hubble/hubble-fe/src/components/BrandLockup/index.module.scss b/hugegraph-hubble/hubble-fe/src/components/BrandLockup/index.module.scss new file mode 100644 index 000000000..30f8012be --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/BrandLockup/index.module.scss @@ -0,0 +1,52 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +.lockup { + display: inline-flex; + align-items: center; + gap: 10px; + color: inherit; + white-space: nowrap; + + img { + display: block; + width: 34px; + height: 34px; + flex: none; + } +} + +.wordmark { + font-size: 24px; + font-style: normal; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1; +} + +.compact { + gap: 8px; + + img { + width: 28px; + height: 28px; + } + + .wordmark { + font-size: 20px; + } +} diff --git a/hugegraph-hubble/hubble-fe/src/components/CodeEditor/index.js b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/index.js index a3fdf574c..b56239c1f 100644 --- a/hugegraph-hubble/hubble-fe/src/components/CodeEditor/index.js +++ b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/index.js @@ -16,7 +16,7 @@ * under the License. */ -import {autocompletion} from '@codemirror/autocomplete'; +import {autocompletion, closeBrackets} from '@codemirror/autocomplete'; import {syntaxHighlighting, HighlightStyle} from '@codemirror/language'; import {basicSetup, EditorView} from 'codemirror'; import React, {useRef, useEffect} from 'react'; @@ -25,10 +25,26 @@ import syntaxConfig from './syntax'; import {tags} from '@lezer/highlight'; import {useTranslation} from 'react-i18next'; -const CodeEditor = ({value, placeholder, onChange, lang = 'gremlin'}) => { +const CodeEditor = ({ + value, + placeholder, + onChange, + lang = 'gremlin', + readOnly = false, + ariaLabel, + minHeight, + metaEnterNewline = false, + onExecutionShortcut, +}) => { const {t} = useTranslation(); const editor = useRef(); const cm = useRef(); + const initialValue = useRef(value || ''); + const onChangeRef = useRef(onChange); + const executionShortcutRef = useRef(onExecutionShortcut); + onChangeRef.current = onChange; + executionShortcutRef.current = onExecutionShortcut; + const resolvedPlaceholder = placeholder ?? t('analysis.query.placeholder'); useEffect(() => { const syntax = syntaxConfig[lang] ?? syntaxConfig.default; @@ -49,20 +65,65 @@ const CodeEditor = ({value, placeholder, onChange, lang = 'gremlin'}) => { const myHighlightStyle = HighlightStyle.define([ {tag: tags.keyword, color: '#fc6eee'}, {tag: tags.function, color: '#ff0'}, + {tag: tags.string, color: '#067d17'}, + {tag: tags.comment, color: '#6a737d', fontStyle: 'italic'}, + {tag: tags.propertyName, color: '#005cc5'}, ]); cm.current = new EditorView({ + doc: initialValue.current, extensions: [ + !readOnly + ? EditorView.domEventHandlers({ + keydown: event => { + if (event.key !== 'Enter' + || (!event.metaKey && !event.ctrlKey) + || event.altKey || event.shiftKey + || event.isComposing + || !executionShortcutRef.current) { + return false; + } + event.preventDefault(); + executionShortcutRef.current(); + return true; + }, + }) + : [], basicSetup, - autocompletion({override: [myCompletions]}), + readOnly ? [] : closeBrackets(), + readOnly ? [] : autocompletion({override: [myCompletions]}), + syntax.language ?? [], syntaxHighlighting(myHighlightStyle), + EditorView.editable.of(!readOnly), + metaEnterNewline && !readOnly + ? EditorView.domEventHandlers({ + keydown: (event, view) => { + if (event.key !== 'Enter' || !event.metaKey + || event.ctrlKey || event.altKey || event.shiftKey + || event.isComposing) { + return false; + } + view.dispatch(view.state.replaceSelection('\n')); + return true; + }, + }) + : [], + ariaLabel ? EditorView.contentAttributes.of({ + 'aria-label': ariaLabel, + }) : [], EditorView.updateListener.of(e => { - onChange && onChange(e.state.doc.toString()); + if (onChangeRef.current) { + onChangeRef.current(e.state.doc.toString()); + } }), EditorView.theme( { '&': { color: '#000', + 'min-height': minHeight ? `${minHeight}px` : undefined, + }, + '.cm-scroller': { + 'min-height': minHeight ? `${minHeight}px` : undefined, }, '&.cm-focused': { outline: '0', @@ -72,7 +133,7 @@ const CodeEditor = ({value, placeholder, onChange, lang = 'gremlin'}) => { }, } ), - cmplaceholder(placeholder ?? t('analysis.query.placeholder')), + cmplaceholder(resolvedPlaceholder), ], parent: editor.current, }); @@ -82,7 +143,8 @@ const CodeEditor = ({value, placeholder, onChange, lang = 'gremlin'}) => { return () => { cm.current.destroy(); }; - }, [t, lang, onChange, placeholder]); + }, [ariaLabel, lang, metaEnterNewline, minHeight, readOnly, + resolvedPlaceholder]); useEffect(() => { if (value !== null && cm.current.state.doc && value !== cm.current.state.doc.toString()) { diff --git a/hugegraph-hubble/hubble-fe/src/components/CodeEditor/index.test.js b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/index.test.js new file mode 100644 index 000000000..b09cbefe8 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/index.test.js @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {fireEvent, render} from '@testing-library/react'; +import CodeEditor from './index'; + +jest.mock('@codemirror/autocomplete', () => ({ + autocompletion: () => ({}), + closeBrackets: () => ({}), +})); +jest.mock('@codemirror/language', () => ({ + HighlightStyle: {define: () => ({})}, + syntaxHighlighting: () => ({}), +})); +jest.mock('@codemirror/view', () => ({ + placeholder: () => ({}), +})); +jest.mock('@lezer/highlight', () => ({tags: {}})); +jest.mock('./syntax', () => ({default: {hint: [], language: {}}})); +jest.mock('codemirror', () => { + class MockEditorView { + static domEventHandlers(handlers) { + return {handlers}; + } + + static editable = {of: () => ({})}; + static contentAttributes = {of: () => ({})}; + static updateListener = {of: () => ({})}; + static theme() { + return {}; + } + + constructor({doc, extensions, parent}) { + this.state = { + doc: { + length: doc.length, + toString: () => doc, + }, + }; + this.dom = global.document.createElement('div'); + this.dom.className = 'cm-editor'; + this.content = global.document.createElement('div'); + this.content.className = 'cm-content'; + this.content.tabIndex = 0; + this.dom.appendChild(this.content); + parent.appendChild(this.dom); + extensions.flat().filter(extension => extension.handlers) + .forEach(extension => { + this.content.addEventListener('keydown', event => { + extension.handlers.keydown(event, this); + }); + }); + } + + dispatch({changes}) { + const doc = changes.insert; + this.state.doc = { + length: doc.length, + toString: () => doc, + }; + } + + destroy() { + this.dom.remove(); + } + } + + return {basicSetup: {}, EditorView: MockEditorView}; +}); +jest.mock('react-i18next', () => { + const translate = key => key; + return {useTranslation: () => ({t: translate})}; +}); + +it('keeps focus when controlled callbacks change after input', () => { + const firstExecution = jest.fn(); + const nextExecution = jest.fn(); + const firstChange = jest.fn(); + const nextChange = jest.fn(); + const {container, rerender} = render( + + ); + const originalEditor = container.querySelector('.cm-editor'); + const content = container.querySelector('.cm-content'); + content.focus(); + + rerender( + + ); + + expect(container.querySelector('.cm-editor')).toBe(originalEditor); + expect(document.activeElement).toBe(content); + + fireEvent.keyDown(content, {key: 'Enter', ctrlKey: true}); + expect(firstExecution).not.toHaveBeenCalled(); + expect(nextExecution).toHaveBeenCalledTimes(1); +}); diff --git a/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/gremlin.js b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/gremlin.js index 18c80e2af..b2b085879 100644 --- a/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/gremlin.js +++ b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/gremlin.js @@ -50,12 +50,18 @@ // export {hint, highlight}; +const functionCompletion = label => ({ + label, + type: 'function', + apply: `${label}()`, +}); + const hint = [ {label: 'g', type: 'constant'}, {label: 'graph', type: 'constant'}, - {label: 'out', type: 'function'}, - {label: 'int', type: 'function'}, - {label: 'both', type: 'function'}, + functionCompletion('out'), + functionCompletion('in'), + functionCompletion('both'), {label: 'outE', type: 'function'}, {label: 'inE', type: 'function'}, {label: 'bothE', type: 'function'}, @@ -171,6 +177,10 @@ const hint = [ ]; const highlight = []; +const completionHint = hint.map(item => ( + item.type === 'function' && !item.apply + ? {...item, apply: `${item.label}()`} + : item +)); -export {hint, highlight}; - +export {completionHint as hint, highlight}; diff --git a/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/gremlin.test.js b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/gremlin.test.js new file mode 100644 index 000000000..8686e2da0 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/gremlin.test.js @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {hint} from './gremlin'; + +test('Gremlin function completions include callable parentheses', () => { + const functions = hint.filter(item => item.type === 'function'); + + expect(functions.length).toBeGreaterThan(0); + functions.forEach(item => { + expect(item.apply).toBe(`${item.label}()`); + }); +}); diff --git a/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/groovy-language.test.js b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/groovy-language.test.js new file mode 100644 index 000000000..8d4c36975 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/groovy-language.test.js @@ -0,0 +1,66 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {execFileSync} from 'child_process'; +import syntaxConfig from './index'; +import {groovyLanguage, groovyParser} from './groovy'; + +jest.mock('@codemirror/language', () => ({ + StreamLanguage: { + define: jest.fn(parser => ({parser, type: 'stream-language'})), + }, +})); +jest.mock('@codemirror/legacy-modes/mode/groovy', () => ({ + groovy: {name: 'groovy-test-parser'}, +})); + +test('registers the Groovy stream parser as the Gremlin and Groovy language extension', () => { + expect(groovyLanguage).toEqual({parser: groovyParser, type: 'stream-language'}); + expect(syntaxConfig.gremlin.language).toBe(groovyLanguage); + expect(syntaxConfig.groovy.language).toBe(groovyLanguage); +}); + +test('the registered Groovy parser emits keyword, property, string, and comment tokens', () => { + const script = ` + const {groovy} = require('@codemirror/legacy-modes/mode/groovy'); + const {StringStream} = require('@codemirror/language'); + const line = 'def schema = graph.schema(); ' + + 'schema.propertyKey("name").asText().create() // note'; + const stream = new StringStream(line, 4, 2); + const state = groovy.startState(2); + const tokens = []; + while (!stream.eol()) { + const start = stream.pos; + const style = groovy.token(stream, state); + tokens.push([line.slice(start, stream.pos), style]); + } + process.stdout.write(JSON.stringify(tokens)); + `; + const output = execFileSync(process.execPath, ['-e', script], { + cwd: process.cwd(), + encoding: 'utf8', + }); + const tokens = JSON.parse(output); + + expect(tokens).toEqual(expect.arrayContaining([ + ['def', 'keyword'], + ['propertyKey', 'property'], + ['"name"', 'string'], + ['// note', 'comment'], + ])); +}); diff --git a/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/groovy.js b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/groovy.js new file mode 100644 index 000000000..f79790086 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/groovy.js @@ -0,0 +1,24 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {StreamLanguage} from '@codemirror/language'; +import {groovy as groovyParser} from '@codemirror/legacy-modes/mode/groovy'; + +const groovyLanguage = StreamLanguage.define(groovyParser); + +export {groovyLanguage, groovyParser}; diff --git a/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/index.js b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/index.js index b82c9d707..be98ff045 100644 --- a/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/index.js +++ b/hugegraph-hubble/hubble-fe/src/components/CodeEditor/syntax/index.js @@ -18,5 +18,13 @@ import * as gremlin from './gremlin'; import * as cypher from './cypher'; +import {groovyLanguage} from './groovy'; -export default {gremlin, cypher, default: {hint: [], highlight: []}}; +const groovy = {...gremlin, language: groovyLanguage}; + +export default { + gremlin: groovy, + groovy, + cypher, + default: {hint: [], highlight: [], language: []}, +}; diff --git a/hugegraph-hubble/hubble-fe/src/components/ColumnSettings/index.js b/hugegraph-hubble/hubble-fe/src/components/ColumnSettings/index.js new file mode 100644 index 000000000..b73d87c65 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/ColumnSettings/index.js @@ -0,0 +1,237 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import React, {useCallback, useMemo, useState} from 'react'; +import {Button, Checkbox, Popover, Space, Tooltip} from 'antd'; +import { + DownOutlined, + SettingOutlined, + UpOutlined, +} from '@ant-design/icons'; +import c from './index.module.scss'; + +const EMPTY_PREFERENCES = {}; + +const columnKey = column => String(column.key || column.dataIndex); + +export const normalizeColumnPreferences = (columns, preferences, requiredKeys = []) => { + const available = columns.map(columnKey); + const savedOrder = Array.isArray(preferences?.order) ? preferences.order : []; + const order = [ + ...savedOrder.filter((key, index) => ( + available.includes(key) && savedOrder.indexOf(key) === index + )), + ...available.filter(key => !savedOrder.includes(key)), + ]; + const required = new Set(requiredKeys.map(String)); + const savedHidden = Array.isArray(preferences?.hidden) ? preferences.hidden : []; + const hidden = savedHidden.filter((key, index) => ( + available.includes(key) && !required.has(key) + && savedHidden.indexOf(key) === index + )); + return {order, hidden}; +}; + +export const applyColumnPreferences = (columns, preferences) => { + const byKey = new Map(columns.map(column => [columnKey(column), column])); + const hidden = new Set(preferences.hidden); + return preferences.order + .filter(key => !hidden.has(key)) + .map(key => byKey.get(key)) + .filter(Boolean); +}; + +const readPreferences = storageKey => { + try { + return JSON.parse(window.localStorage.getItem(storageKey)) || {}; + } + catch { + return {}; + } +}; + +export const useColumnSettings = ( + columns, + storageKey, + requiredKeys = [], + defaultPreferences = EMPTY_PREFERENCES +) => { + const [preferences, setPreferences] = useState(() => normalizeColumnPreferences( + columns, + { + ...defaultPreferences, + ...readPreferences(storageKey), + }, + requiredKeys + )); + + const updatePreferences = useCallback(next => { + setPreferences(current => { + const value = normalizeColumnPreferences( + columns, + typeof next === 'function' ? next(current) : next, + requiredKeys + ); + try { + window.localStorage.setItem(storageKey, JSON.stringify(value)); + } + catch { + // Column customization remains usable when storage is unavailable. + } + return value; + }); + }, [columns, requiredKeys, storageKey]); + + return { + columns: useMemo( + () => applyColumnPreferences(columns, preferences), + [columns, preferences] + ), + preferences, + setPreferences: updatePreferences, + reset: useCallback(() => { + try { + window.localStorage.removeItem(storageKey); + } + catch { + // Ignore unavailable storage and still reset the visible state. + } + setPreferences(normalizeColumnPreferences( + columns, + defaultPreferences, + requiredKeys + )); + }, [columns, defaultPreferences, requiredKeys, storageKey]), + }; +}; + +const ColumnSettingRow = props => { + const { + column, + columnKey, + index, + count, + checked, + disabled, + labels, + onToggle, + onMove, + } = props; + const handleToggle = useCallback(() => onToggle(columnKey), [columnKey, onToggle]); + const handleMoveUp = useCallback(() => onMove(columnKey, -1), [columnKey, onMove]); + const handleMoveDown = useCallback(() => onMove(columnKey, 1), [columnKey, onMove]); + + return ( +
+ + {column?.title} + + +
+ ); +}; + +const ColumnSettings = props => { + const { + columns, + preferences, + setPreferences, + reset, + requiredKeys = [], + labels, + } = props; + const byKey = new Map(columns.map(column => [columnKey(column), column])); + const hidden = new Set(preferences.hidden); + const required = new Set(requiredKeys.map(String)); + + const toggle = useCallback(key => { + setPreferences(current => ({ + ...current, + hidden: current.hidden.includes(key) + ? current.hidden.filter(item => item !== key) + : [...current.hidden, key], + })); + }, [setPreferences]); + + const move = useCallback((key, offset) => { + setPreferences(current => { + const from = current.order.indexOf(key); + const to = from + offset; + if (from < 0 || to < 0 || to >= current.order.length) { + return current; + } + const order = [...current.order]; + [order[from], order[to]] = [order[to], order[from]]; + return {...current, order}; + }); + }, [setPreferences]); + + const content = ( +
+ {preferences.order.map((key, index) => ( + + ))} + +
+ ); + + return ( + + + + )} + /> + )} + {errors.graphs && ( + + {t('workbench.context.retry_graphs')} + + )} + /> + )} + + )} + + ); +}; + +export default GraphContextSwitcher; diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.module.scss b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.module.scss new file mode 100644 index 000000000..9633e62c9 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.module.scss @@ -0,0 +1,97 @@ +/*! + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +.context { + display: flex; + align-items: center; + min-width: 0; + margin-left: 28px; +} + +.graphspace { + min-width: 112px; + max-width: 240px; +} + +.graph { + min-width: 112px; + max-width: 240px; +} + +.separator { + color: rgba(255, 255, 255, 0.56); +} + +.graphspace, +.graph { + :global(.ant-select-selection-item) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.errorStack { + position: absolute; + z-index: 20; + top: 56px; + left: 148px; + display: grid; + gap: 8px; + min-width: 360px; +} + +.error { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16); +} + +@media (max-width: 1280px) { + .context { + margin-left: 16px; + } + +} + +@media (max-width: 900px) { + .context { + flex: 1 1 auto; + margin-left: 0; + overflow: hidden; + + :global(.ant-space) { + display: flex; + width: 100%; + } + } + + .graphspace, + .graph { + max-width: 30vw; + min-width: 88px; + } + + .separator { + flex: none; + } + + .errorStack { + right: 8px; + left: 8px; + min-width: 0; + } +} diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js new file mode 100644 index 000000000..70efc86ca --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js @@ -0,0 +1,417 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter, useLocation} from 'react-router-dom'; +import GraphContextSwitcher from './index'; +import * as api from '../../api'; + +jest.mock('../../api', () => ({ + manage: { + getGraphSpaceList: jest.fn(), + getGraphList: jest.fn(), + }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({t: key => key}), +})); + +jest.mock('antd', () => { + const React = require('react'); + const Select = ({ + 'aria-description': ariaDescription, + 'aria-label': ariaLabel, + children, + disabled, + onChange, + title, + value, + style, + }) => ( + + ); + Select.Option = ({children, value}) => ; + return { + Alert: ({action, message}) =>
{message}{action}
, + Button: ({children, onClick}) => , + Select, + Space: ({children}) =>
{children}
, + Tag: ({children}) => {children}, + }; +}); + +const LocationProbe = () => { + const location = useLocation(); + return {location.pathname}; +}; + +const renderSwitcher = path => render( + + + + +); + +describe('GraphContextSwitcher', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + sessionStorage.clear(); + api.manage.getGraphSpaceList.mockResolvedValue({ + status: 200, + data: {records: [ + {name: 'space_a', nickname: 'Space A'}, + {name: 'space_b', nickname: 'Space B'}, + ]}, + }); + api.manage.getGraphList.mockResolvedValue({ + status: 200, + data: {records: [{name: 'graph_a', nickname: 'Graph A'}]}, + }); + }); + + test('non-PD fixes GraphSpace to DEFAULT and loads its graphs', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: false})); + renderSwitcher('/navigation'); + + const graphspace = screen.getByRole('combobox', { + name: 'workbench.context.graphspace', + }); + expect(graphspace).toBeDisabled(); + expect(graphspace).toHaveAttribute( + 'aria-description', 'DEFAULT' + ); + expect(graphspace).toHaveAttribute('title', 'DEFAULT'); + expect(screen.getByRole('option', {name: 'DEFAULT'})).toBeInTheDocument(); + expect(screen.queryByText('workbench.context.graphspace_label')) + .not.toBeInTheDocument(); + expect(screen.getByText('/')).toBeInTheDocument(); + await waitFor(() => { + expect(api.manage.getGraphList).toHaveBeenCalledWith( + 'DEFAULT', + {page_no: 1, page_size: -1}, + expect.any(Object) + ); + }); + expect(screen.queryByText('workbench.context.non_pd_fixed')).not.toBeInTheDocument(); + expect(screen.getByRole('group', {name: 'workbench.context.name'})).toBeInTheDocument(); + }); + + test('loads every PD GraphSpace without pagination truncation', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + api.manage.getGraphSpaceList.mockResolvedValueOnce({ + status: 200, + data: [ + {name: 'DEFAULT', nickname: 'Default'}, + {name: 'demo_space', nickname: 'Demo Space'}, + {name: 'loader_space', nickname: 'Loader Space'}, + ], + }); + renderSwitcher('/graphspace/demo_space'); + + expect(await screen.findByRole('option', {name: 'Loader Space'})).toBeInTheDocument(); + expect(screen.getByRole('combobox', {name: 'workbench.context.graphspace'})) + .toHaveValue('demo_space'); + expect(api.manage.getGraphSpaceList).toHaveBeenCalledWith( + {all: true}, + expect.any(Object) + ); + }); + + test('sizes current names within bounds and exposes full values', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + const longName = 'graph_with_a_name_that_is_far_too_long_for_the_topbar'; + api.manage.getGraphList.mockResolvedValueOnce({ + status: 200, + data: {records: [{name: longName}]}, + }); + renderSwitcher(`/gremlin/space_a/${longName}`); + + const graphspace = screen.getByRole('combobox', { + name: 'workbench.context.graphspace', + }); + const graph = await screen.findByRole('combobox', { + name: 'workbench.context.graph', + }); + await waitFor(() => expect(graph).toHaveValue(longName)); + + expect(graphspace).toHaveStyle({width: '112px'}); + expect(graph).toHaveStyle({width: '240px'}); + expect(graph).toHaveAttribute('title', longName); + expect(graph).toHaveAttribute('aria-description', longName); + }); + + test('temporarily keeps the selected GraphSpace in options while loading', () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + api.manage.getGraphSpaceList.mockReturnValueOnce(new Promise(() => {})); + renderSwitcher('/graphspace/demo_space'); + + expect(screen.getByRole('combobox', {name: 'workbench.context.graphspace'})) + .toHaveValue('demo_space'); + expect(screen.getByRole('option', {name: 'demo_space'})).toBeInTheDocument(); + }); + + test('replaces a missing GraphSpace and never reuses its graph', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + api.manage.getGraphSpaceList.mockResolvedValueOnce({ + status: 200, + data: [{name: 'space_b', nickname: 'Space B'}], + }); + renderSwitcher('/gremlin/deleted_space/old_graph'); + + await waitFor(() => { + expect(screen.getByRole('combobox', {name: 'workbench.context.graphspace'})) + .toHaveValue('space_b'); + }); + expect(screen.getByRole('combobox', {name: 'workbench.context.graph'})) + .not.toHaveValue('old_graph'); + expect(screen.getByText('/graphspace/space_b')).toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem('hubble_workbench_graph_context'))).toEqual({ + graphspace: 'space_b', + }); + }); + + test('clears a deep-linked graph that does not belong to its GraphSpace', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + api.manage.getGraphList.mockResolvedValueOnce({ + status: 200, + data: {records: [{name: 'graph_b', nickname: 'Graph B'}]}, + }); + renderSwitcher('/gremlin/space_a/missing_graph'); + + await waitFor(() => { + expect(screen.getByRole('combobox', {name: 'workbench.context.graph'})) + .toHaveValue(''); + }); + await waitFor(() => { + expect(screen.getByText('/graphspace/space_a')).toBeInTheDocument(); + }); + expect(JSON.parse(localStorage.getItem('hubble_workbench_graph_context'))).toEqual({ + graphspace: 'space_a', + }); + }); + + test('route context selects and persists the current graph', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + renderSwitcher('/gremlin/space_a/graph_a'); + + await waitFor(() => { + expect(screen.getByRole('combobox', {name: 'workbench.context.graph'})) + .toHaveValue('graph_a'); + }); + expect(JSON.parse(localStorage.getItem('hubble_workbench_graph_context'))).toEqual({ + graphspace: 'space_a', + graph: 'graph_a', + }); + }); + + test('selecting a graph opens its overview', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + renderSwitcher('/graphspace/space_a'); + + const graphSelect = await screen.findByRole('combobox', { + name: 'workbench.context.graph', + }); + await screen.findByRole('option', {name: 'Graph A'}); + fireEvent.change(graphSelect, {target: {value: 'graph_a'}}); + expect(screen.getByText('/graphspace/space_a/graph/graph_a/detail')).toBeInTheDocument(); + }); + + test.each(['gremlin', 'algorithms', 'asyncTasks'])( + 'selecting a graph preserves the %s workbench', + async moduleName => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + renderSwitcher(`/${moduleName}`); + + const graphspace = screen.getByRole('combobox', { + name: 'workbench.context.graphspace', + }); + await screen.findByRole('option', {name: 'Space A'}); + fireEvent.change(graphspace, {target: {value: 'space_a'}}); + expect(screen.getByText(`/${moduleName}`)).toBeInTheDocument(); + + const graph = await screen.findByRole('combobox', { + name: 'workbench.context.graph', + }); + await screen.findByRole('option', {name: 'Graph A'}); + fireEvent.change(graph, {target: {value: 'graph_a'}}); + + expect(screen.getByText(`/${moduleName}/space_a/graph_a`)) + .toBeInTheDocument(); + } + ); + + test('shows an inline retry when graph loading fails', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: false})); + api.manage.getGraphList.mockRejectedValueOnce(new Error('offline')); + renderSwitcher('/navigation'); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'workbench.context.graphs_load_failed' + ); + fireEvent.click(screen.getByRole('button', { + name: 'workbench.context.retry_graphs', + })); + await waitFor(() => expect(api.manage.getGraphList).toHaveBeenCalledTimes(2)); + }); + + test('treats a resolved graph business error as retryable', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: false})); + api.manage.getGraphList.mockResolvedValueOnce({status: 503, data: null}); + renderSwitcher('/navigation'); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'workbench.context.graphs_load_failed' + ); + }); + + test('distinguishes missing graph permission from a temporary failure', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: false})); + api.manage.getGraphList.mockResolvedValueOnce({status: 403, data: null}); + renderSwitcher('/navigation'); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'workbench.context.graphs_forbidden' + ); + expect(screen.queryByRole('button', { + name: 'workbench.context.retry_graphs', + })).not.toBeInTheDocument(); + }); + + test('graph success cannot erase a concurrent GraphSpace failure', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + api.manage.getGraphSpaceList.mockResolvedValueOnce({status: 500, data: null}); + renderSwitcher('/gremlin/space_a/graph_a'); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'workbench.context.graphspaces_load_failed' + ); + expect(screen.getByRole('combobox', {name: 'workbench.context.graph'})) + .toHaveValue('graph_a'); + }); + + test('switching GraphSpace immediately removes graphs from the previous space', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + let resolveSpaceB; + api.manage.getGraphList.mockImplementation(graphspace => { + if (graphspace === 'space_b') { + return new Promise(resolve => { + resolveSpaceB = resolve; + }); + } + return Promise.resolve({ + status: 200, + data: {records: [{name: 'graph_a', nickname: 'Graph A'}]}, + }); + }); + renderSwitcher('/graphspace/space_a'); + await screen.findByRole('option', {name: 'Graph A'}); + + fireEvent.change( + screen.getByRole('combobox', {name: 'workbench.context.graphspace'}), + {target: {value: 'space_b'}} + ); + + expect(screen.queryByRole('option', {name: 'Graph A'})).not.toBeInTheDocument(); + expect(screen.getByRole('combobox', {name: 'workbench.context.graph'})).toBeDisabled(); + await waitFor(() => { + expect(api.manage.getGraphList).toHaveBeenCalledWith( + 'space_b', + {page_no: 1, page_size: -1}, + expect.any(Object) + ); + }); + resolveSpaceB({ + status: 200, + data: {records: [{name: 'graph_b', nickname: 'Graph B'}]}, + }); + expect(await screen.findByRole('option', {name: 'Graph B'})).toBeInTheDocument(); + }); + + test('ignores a graph response from the previously selected GraphSpace', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + let resolveSpaceA; + api.manage.getGraphList.mockImplementation(graphspace => { + if (graphspace === 'space_a') { + return new Promise(resolve => { + resolveSpaceA = resolve; + }); + } + return Promise.resolve({ + status: 200, + data: {records: [{name: 'graph_b', nickname: 'Graph B'}]}, + }); + }); + renderSwitcher('/graphspace/space_a'); + await screen.findByRole('option', {name: 'Space B'}); + + fireEvent.change( + screen.getByRole('combobox', {name: 'workbench.context.graphspace'}), + {target: {value: 'space_b'}} + ); + expect(await screen.findByRole('option', {name: 'Graph B'})).toBeInTheDocument(); + + await act(async () => { + resolveSpaceA({ + status: 200, + data: {records: [{name: 'graph_a', nickname: 'Graph A'}]}, + }); + await Promise.resolve(); + }); + expect(screen.queryByRole('option', {name: 'Graph A'})).not.toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'Graph B'})).toBeInTheDocument(); + }); + + test('stacks both failures and retries only the selected source', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + api.manage.getGraphSpaceList.mockRejectedValueOnce(new Error('pd offline')); + api.manage.getGraphList.mockRejectedValueOnce(new Error('server offline')); + renderSwitcher('/gremlin/space_a/graph_a'); + + expect(await screen.findByText('workbench.context.graphspaces_load_failed')) + .toBeInTheDocument(); + expect(await screen.findByText('workbench.context.graphs_load_failed')) + .toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { + name: 'workbench.context.retry_graphspaces', + })); + await waitFor(() => expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(2)); + expect(api.manage.getGraphList).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { + name: 'workbench.context.retry_graphs', + })); + await waitFor(() => expect(api.manage.getGraphList).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphJourneyNav/index.js b/hugegraph-hubble/hubble-fe/src/components/GraphJourneyNav/index.js new file mode 100644 index 000000000..568251310 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/GraphJourneyNav/index.js @@ -0,0 +1,58 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import style from './index.module.scss'; + +const GraphJourneyNav = ({graphspace, graph, active}) => { + const {t} = useTranslation(); + const tabs = [ + { + key: 'overview', + label: t('graph.detail.overview'), + path: `/graphspace/${encodeURIComponent(graphspace)}` + + `/graph/${encodeURIComponent(graph)}/detail`, + }, + { + key: 'schema', + label: t('graph.detail.schema'), + path: `/graphspace/${encodeURIComponent(graphspace)}` + + `/graph/${encodeURIComponent(graph)}/meta`, + }, + ]; + + return ( + + ); +}; + +export default GraphJourneyNav; diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphJourneyNav/index.module.scss b/hugegraph-hubble/hubble-fe/src/components/GraphJourneyNav/index.module.scss new file mode 100644 index 000000000..e52e9e8e4 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/GraphJourneyNav/index.module.scss @@ -0,0 +1,52 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +.journeyNav { + margin: -12px 0 12px; + border-bottom: 1px solid #e5eaf0; +} + +.tabs { + display: flex; + gap: 24px; + + a { + position: relative; + padding: 8px 2px; + color: #5b6573; + font-weight: 500; + + &:hover { + color: #096dd9; + } + } +} + +.activeTab { + color: #096dd9 !important; + + &::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + background: #096dd9; + content: ''; + } +} diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphJourneyNav/index.test.js b/hugegraph-hubble/hubble-fe/src/components/GraphJourneyNav/index.test.js new file mode 100644 index 000000000..5a1e2aa91 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/GraphJourneyNav/index.test.js @@ -0,0 +1,60 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {render, screen} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import GraphJourneyNav from './index'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({t: key => ({ + 'graph.detail.journey': 'Graph understanding navigation', + 'graph.detail.overview': 'Overview', + 'graph.detail.schema': 'Schema', + })[key] || key}), +})); + +test.each([ + ['overview', 'Overview', 'Schema'], + ['schema', 'Schema', 'Overview'], +])('marks only %s as the current graph journey tab', (active, current, inactive) => { + render( + + + + ); + + expect(screen.getByRole('navigation', { + name: 'Graph understanding navigation', + })).toBeInTheDocument(); + expect(screen.getByRole('link', {name: current})) + .toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('link', {name: inactive})) + .not.toHaveAttribute('aria-current'); + expect(screen.getByRole('link', {name: 'Overview'})) + .toHaveAttribute('href', '/graphspace/DEFAULT/graph/hugegraph/detail'); + expect(screen.getByRole('link', {name: 'Schema'})) + .toHaveAttribute('href', '/graphspace/DEFAULT/graph/hugegraph/meta'); +}); diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphinView/index.js b/hugegraph-hubble/hubble-fe/src/components/GraphinView/index.js index 83a1d1074..78daae6d0 100644 --- a/hugegraph-hubble/hubble-fe/src/components/GraphinView/index.js +++ b/hugegraph-hubble/hubble-fe/src/components/GraphinView/index.js @@ -16,10 +16,54 @@ * under the License. */ -import {useCallback} from 'react'; -import Graphin, {Behaviors} from '@antv/graphin'; +import {useCallback, useContext, useEffect} from 'react'; +import Graphin, {Behaviors, Components, GraphinContext} from '@antv/graphin'; -const GraphView = ({data, width, height, layout, style, onClick, config, behaviors}) => { +const GRAPH_TOOLTIP_STYLE = { + width: 300, + padding: 12, + borderRadius: 4, + background: '#fff', + pointerEvents: 'none', +}; + +const GraphDoubleClick = ({onDoubleClick}) => { + const {graph} = useContext(GraphinContext); + + useEffect(() => { + if (typeof onDoubleClick !== 'function') { + return undefined; + } + const handleDoubleClick = evt => { + const {item} = evt; + const {id, type} = item._cfg; + const model = item.getModel(); + onDoubleClick(id, type, model.data, model, item, evt); + }; + graph.on('node:dblclick', handleDoubleClick); + graph.on('edge:dblclick', handleDoubleClick); + return () => { + graph.off('node:dblclick', handleDoubleClick); + graph.off('edge:dblclick', handleDoubleClick); + }; + }, [graph, onDoubleClick]); + + return null; +}; + +const GraphView = ({ + data, + width, + height, + layout, + style, + onClick, + onDoubleClick, + config, + behaviors, + nodeTooltip, + edgeTooltip, +}) => { // const [graphData, setGraphData] = useState([]); const {DragCanvas, ZoomCanvas, DragNode, ClickSelect, Hoverable} = Behaviors; @@ -58,6 +102,17 @@ const GraphView = ({data, width, height, layout, style, onClick, config, behavi /> + + {nodeTooltip && ( + + {nodeTooltip} + + )} + {edgeTooltip && ( + + {edgeTooltip} + + )} {/* */} @@ -65,3 +120,5 @@ const GraphView = ({data, width, height, layout, style, onClick, config, behavi }; export default GraphView; + +export {GraphDoubleClick}; diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphinView/index.test.js b/hugegraph-hubble/hubble-fe/src/components/GraphinView/index.test.js new file mode 100644 index 000000000..a3707abc5 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/GraphinView/index.test.js @@ -0,0 +1,75 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {render} from '@testing-library/react'; +import {GraphinContext} from '@antv/graphin'; +import {GraphDoubleClick} from './index'; + +jest.mock('@antv/graphin', () => { + const React = require('react'); + const EmptyComponent = () => null; + return { + __esModule: true, + default: EmptyComponent, + GraphinContext: React.createContext({}), + Behaviors: { + DragCanvas: EmptyComponent, + ZoomCanvas: EmptyComponent, + DragNode: EmptyComponent, + ClickSelect: EmptyComponent, + Hoverable: EmptyComponent, + }, + Components: {Tooltip: EmptyComponent}, + }; +}); + +test('registers, dispatches and removes node and edge double-click listeners', () => { + const listeners = new Map(); + const graph = { + on: jest.fn((event, listener) => listeners.set(event, listener)), + off: jest.fn(), + }; + const onDoubleClick = jest.fn(); + const {unmount} = render( + + + + ); + + expect(graph.on).toHaveBeenCalledWith('node:dblclick', expect.any(Function)); + expect(graph.on).toHaveBeenCalledWith('edge:dblclick', expect.any(Function)); + + const nodeModel = {data: {label: 'person'}}; + const node = { + _cfg: {id: 'person', type: 'node'}, + getModel: () => nodeModel, + }; + const event = {item: node}; + listeners.get('node:dblclick')(event); + expect(onDoubleClick).toHaveBeenCalledWith( + 'person', 'node', nodeModel.data, nodeModel, node, event + ); + + unmount(); + expect(graph.off).toHaveBeenCalledWith( + 'node:dblclick', listeners.get('node:dblclick') + ); + expect(graph.off).toHaveBeenCalledWith( + 'edge:dblclick', listeners.get('edge:dblclick') + ); +}); diff --git a/hugegraph-hubble/hubble-fe/src/components/LanguageToggle/index.js b/hugegraph-hubble/hubble-fe/src/components/LanguageToggle/index.js new file mode 100644 index 000000000..ac7d3280b --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/LanguageToggle/index.js @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {Button} from 'antd'; +import {useCallback, useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {getCurrentLanguage} from '../../utils/language'; +import style from './index.module.scss'; + +const LANGUAGE_OPTION = { + 'en-US': {label: 'EN', targetLabel: '中', value: 'zh-CN'}, + 'zh-CN': {label: '中', targetLabel: 'EN', value: 'en-US'}, +}; + +const resolveLanguage = i18n => { + const language = i18n.resolvedLanguage ?? i18n.language ?? getCurrentLanguage(); + return language === 'zh-CN' || language?.startsWith('zh') ? 'zh-CN' : 'en-US'; +}; + +const LanguageToggle = ({className = '', tone = 'light'}) => { + const {t, i18n} = useTranslation(); + const [language, setLanguage] = useState(() => resolveLanguage(i18n)); + const option = LANGUAGE_OPTION[language] ?? LANGUAGE_OPTION['en-US']; + + useEffect(() => { + const syncLanguage = nextLanguage => { + setLanguage(resolveLanguage({language: nextLanguage})); + }; + setLanguage(resolveLanguage(i18n)); + i18n.on?.('languageChanged', syncLanguage); + return () => i18n.off?.('languageChanged', syncLanguage); + }, [i18n]); + + const toggleLanguage = useCallback(() => { + localStorage.setItem('languageType', option.value); + setLanguage(option.value); + i18n.changeLanguage(option.value); + }, [i18n, option.value]); + + return ( + + ); +}; + +export default LanguageToggle; diff --git a/hugegraph-hubble/hubble-fe/src/components/LanguageToggle/index.module.scss b/hugegraph-hubble/hubble-fe/src/components/LanguageToggle/index.module.scss new file mode 100644 index 000000000..1377bb5ef --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/LanguageToggle/index.module.scss @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +.toggle { + min-width: 34px; + height: 30px; + padding: 0 7px; + border-radius: 6px; + font-weight: 600; +} + +.dark { + color: rgba(255, 255, 255, 0.88) !important; + + &:hover, + &:focus-visible { + color: #fff !important; + background: rgba(255, 255, 255, 0.12) !important; + } +} + +.light { + color: #526176 !important; + + &:hover, + &:focus-visible { + color: #1769e0 !important; + background: #edf4ff !important; + } +} + +.label { + display: inline-block; + animation: language-toggle-in 180ms ease-out; +} + +@keyframes language-toggle-in { + from { + opacity: 0; + transform: translateY(3px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .label { + animation: none; + } +} diff --git a/hugegraph-hubble/hubble-fe/src/components/LanguageToggle/index.test.js b/hugegraph-hubble/hubble-fe/src/components/LanguageToggle/index.test.js new file mode 100644 index 000000000..b57b79427 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/LanguageToggle/index.test.js @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {act, render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import LanguageToggle from './index'; + +const mockChangeLanguage = jest.fn(language => Promise.resolve(language)); +const mockLanguageListeners = new Set(); +const mockI18n = { + language: 'en-US', + resolvedLanguage: 'en-US', + changeLanguage: mockChangeLanguage, + on: jest.fn((event, listener) => { + if (event === 'languageChanged') { + mockLanguageListeners.add(listener); + } + }), + off: jest.fn((event, listener) => { + if (event === 'languageChanged') { + mockLanguageListeners.delete(listener); + } + }), +}; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key, values) => `${key}:${values?.language ?? ''}`, + i18n: mockI18n, + }), +})); + +describe('LanguageToggle', () => { + beforeEach(() => { + mockChangeLanguage.mockClear(); + mockI18n.language = 'en-US'; + mockI18n.resolvedLanguage = 'en-US'; + mockI18n.on.mockClear(); + mockI18n.off.mockClear(); + mockLanguageListeners.clear(); + localStorage.clear(); + }); + + it('shows only the current language and switches immediately', async () => { + render(); + + const english = screen.getByRole('button', {name: /中/}); + expect(english).toHaveAttribute('data-testid', 'language-toggle'); + expect(english).toHaveTextContent('EN'); + expect(screen.queryByText('中')).not.toBeInTheDocument(); + + await userEvent.click(english); + + expect(mockChangeLanguage).toHaveBeenCalledWith('zh-CN'); + expect(localStorage.getItem('languageType')).toBe('zh-CN'); + expect(screen.getByRole('button', {name: /EN/})).toHaveTextContent('中'); + expect(screen.queryByText('EN')).not.toBeInTheDocument(); + }); + + it('shows the active Chinese language while offering English accessibly', () => { + mockI18n.language = 'zh-CN'; + mockI18n.resolvedLanguage = 'zh-CN'; + + render(); + + expect(screen.getByRole('button', {name: /EN/})).toHaveTextContent('中'); + expect(screen.queryByText('EN')).not.toBeInTheDocument(); + }); + + it('follows language changes made outside the toggle', () => { + render(); + expect(screen.getByRole('button', {name: /中/})).toHaveTextContent('EN'); + + act(() => { + mockI18n.language = 'zh-CN'; + mockI18n.resolvedLanguage = 'zh-CN'; + const languageChanged = mockI18n.on.mock.calls[0][1]; + languageChanged('zh-CN'); + }); + + expect(screen.getByRole('button', {name: /EN/})).toHaveTextContent('中'); + }); +}); diff --git a/hugegraph-hubble/hubble-fe/src/components/RouteErrorBoundary/index.js b/hugegraph-hubble/hubble-fe/src/components/RouteErrorBoundary/index.js index f0cbaed0e..f2b40da26 100644 --- a/hugegraph-hubble/hubble-fe/src/components/RouteErrorBoundary/index.js +++ b/hugegraph-hubble/hubble-fe/src/components/RouteErrorBoundary/index.js @@ -18,6 +18,8 @@ import React from 'react'; import {Button, Result} from 'antd'; +import i18n from '../../i18n'; +import {sanitizePublicError} from '../../utils/publicError'; class RouteErrorBoundary extends React.Component { constructor(props) { @@ -30,8 +32,11 @@ class RouteErrorBoundary extends React.Component { return {failed: true}; } - componentDidCatch(error, info) { - console.error('Route render failed', error, info); + componentDidCatch(error) { + console.error( + 'hubble.route_render_failed', + sanitizePublicError(error?.message, 'route render failed') + ); } reload() { @@ -43,8 +48,12 @@ class RouteErrorBoundary extends React.Component { return ( Reload} + title={i18n.t('workbench.route_error.title')} + extra={( + + )} /> ); } diff --git a/hugegraph-hubble/hubble-fe/src/components/RouteErrorBoundary/index.test.js b/hugegraph-hubble/hubble-fe/src/components/RouteErrorBoundary/index.test.js new file mode 100644 index 000000000..e9499c584 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/RouteErrorBoundary/index.test.js @@ -0,0 +1,78 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {render, screen} from '@testing-library/react'; +import i18n from '../../i18n'; +import RouteErrorBoundary from '.'; + +const PRIVATE_VALUE = 'boundary-secret-canary'; + +const ThrowingRoute = () => { + throw new Error( + `password=${PRIVATE_VALUE} Authorization: Bearer ${PRIVATE_VALUE}` + ); +}; + +describe('RouteErrorBoundary', () => { + let consoleError; + + beforeEach(async () => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + await i18n.changeLanguage('en-US'); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + afterAll(async () => { + await i18n.changeLanguage('zh-CN'); + }); + + test('renders a localized fallback and logs only sanitized diagnostics', () => { + render( + + + + ); + + expect(screen.getByText('This page could not be displayed')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Reload page'})).toBeInTheDocument(); + + const boundaryCalls = consoleError.mock.calls.filter( + ([event]) => event === 'hubble.route_render_failed' + ); + expect(boundaryCalls).toHaveLength(1); + expect(boundaryCalls[0]).toHaveLength(2); + expect(boundaryCalls[0].join(' ')).not.toContain(PRIVATE_VALUE); + expect(boundaryCalls[0].join(' ')).toContain('[REDACTED]'); + }); + + test('renders the fallback in Chinese', async () => { + await i18n.changeLanguage('zh-CN'); + + render( + + + + ); + + expect(screen.getByText('此页面暂时无法显示')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: '重新加载页面'})).toBeInTheDocument(); + }); +}); diff --git a/hugegraph-hubble/hubble-fe/src/components/ShortcutHelp/index.js b/hugegraph-hubble/hubble-fe/src/components/ShortcutHelp/index.js new file mode 100644 index 000000000..b16cbbdf7 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/ShortcutHelp/index.js @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {Descriptions, Modal, Typography} from 'antd'; +import {useCallback, useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; + +export const isEditableShortcutTarget = target => { + if (!(target instanceof Element)) { + return false; + } + + return Boolean(target.closest( + 'input, textarea, select, [contenteditable=""], [contenteditable="true"], ' + + '[role="textbox"], .CodeMirror' + )); +}; + +const ShortcutHelp = () => { + const {t} = useTranslation(); + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + + useEffect(() => { + const openFromVisibleTrigger = () => setOpen(true); + const handleKeyDown = event => { + if (event.key !== '?' || event.defaultPrevented || event.isComposing + || event.keyCode === 229 || event.ctrlKey || event.metaKey || event.altKey + || isEditableShortcutTarget(event.target)) { + return; + } + + event.preventDefault(); + setOpen(value => !value); + }; + + window.addEventListener('hubble:shortcut-help', openFromVisibleTrigger); + document.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('hubble:shortcut-help', openFromVisibleTrigger); + document.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + return ( + + + {t('workbench.shortcuts.description')} + + + ?}> + {t('workbench.shortcuts.open_help')} + + Ctrl/⌘ + Enter}> + {t('workbench.shortcuts.run_query')} + + F}> + {t('workbench.shortcuts.fullscreen_graph')} + + + + ); +}; + +export default ShortcutHelp; diff --git a/hugegraph-hubble/hubble-fe/src/components/ShortcutHelp/index.test.js b/hugegraph-hubble/hubble-fe/src/components/ShortcutHelp/index.test.js new file mode 100644 index 000000000..460ba7a5c --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/components/ShortcutHelp/index.test.js @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {act, fireEvent, render, screen} from '@testing-library/react'; +import ShortcutHelp from './index'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({t: key => key}), +})); + +beforeAll(() => { + window.matchMedia = window.matchMedia || (() => ({ + matches: false, + addListener: jest.fn(), + removeListener: jest.fn(), + })); +}); + +test('opens the shortcut overview with question mark outside editors', () => { + render(); + + fireEvent.keyDown(document.body, {key: '?'}); + + expect(screen.getByText('workbench.shortcuts.title')).toBeInTheDocument(); + expect(screen.getByText('workbench.shortcuts.run_query')).toBeInTheDocument(); +}); + +test('opens the shortcut overview from the visible topbar trigger', () => { + render(); + + act(() => { + window.dispatchEvent(new CustomEvent('hubble:shortcut-help')); + }); + + expect(screen.getByText('workbench.shortcuts.title')).toBeInTheDocument(); +}); + +test.each([ + ['input', ], + ['textarea',