diff --git a/.gitignore b/.gitignore index edf38f05a3..00d8e3265e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ node_modules # Local Netlify folder .netlify + +# Local API payload test plan, evidence and reports (not needed by the build or tests) +server/api-service/docs/ diff --git a/server/api-service/.gitignore b/server/api-service/.gitignore index a9fc541a99..02ba9d5f51 100644 --- a/server/api-service/.gitignore +++ b/server/api-service/.gitignore @@ -36,3 +36,8 @@ package-lock.json coverage-summary.json app/client/cypress/locators/Widgets.json application-lowcoder.yml + +# Shaded plugin jars that lowcoder-server's build copies here for its integration tests (pre-integration-test, read via +# pf4j.pluginsDir / LOWCODER_PLUGINS_DIR) and deletes again in post-integration-test; a build stopped in between leaves +# the directory behind. Build output, never source. +lowcoder-plugins/plugins/ diff --git a/server/api-service/lombok.config b/server/api-service/lombok.config index 7eb5eb12e8..4190a3ac95 100644 --- a/server/api-service/lombok.config +++ b/server/api-service/lombok.config @@ -4,3 +4,5 @@ lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Quali lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value lombok.copyableAnnotations += org.springframework.context.annotation.Lazy +# Mark Lombok-generated methods with @lombok.Generated, which JaCoCo leaves out of coverage (T11.1) +lombok.addLombokGeneratedAnnotation = true diff --git a/server/api-service/lowcoder-coverage-report/coverage-gate.xml b/server/api-service/lowcoder-coverage-report/coverage-gate.xml new file mode 100644 index 0000000000..2e114a352e --- /dev/null +++ b/server/api-service/lowcoder-coverage-report/coverage-gate.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/api-service/lowcoder-coverage-report/pom.xml b/server/api-service/lowcoder-coverage-report/pom.xml new file mode 100644 index 0000000000..ede26f05c1 --- /dev/null +++ b/server/api-service/lowcoder-coverage-report/pom.xml @@ -0,0 +1,203 @@ + + + 4.0.0 + + + org.lowcoder + lowcoder-root + ${revision} + + + + lowcoder-coverage-report + pom + lowcoder-coverage-report + + + + true + 0.93 + 0.93 + 0.92 + 0.91 + 0.83 + 0.90 + 0.98 + 0.93 + 0.87 + 0.82 + 0.86 + 0.85 + 0.98 + 0.95 + 0.97 + 0.90 + 0.95 + 0.90 + 0.81 + 0.86 + 0.92 + 0.92 + 0.91 + 0.80 + 0.97 + 0.97 + 0.97 + 0.95 + 0.97 + 0.95 + 0.96 + 0.90 + 1.00 + 1.00 + 0.96 + 0.96 + + + + + org.lowcoder + lowcoder-sdk + + + org.lowcoder + lowcoder-infra + + + org.lowcoder + lowcoder-domain + + + org.lowcoder + lowcoder-server + + + org.lowcoder.plugins + postgresPlugin + ${revision} + + + org.lowcoder.plugins + restApiPlugin + ${revision} + + + org.lowcoder.plugins + mongoPlugin + ${revision} + + + org.lowcoder.plugins + mysqlPlugin + ${revision} + + + org.lowcoder.plugins + elasticSearchPlugin + ${revision} + + + org.lowcoder.plugins + redisPlugin + ${revision} + + + org.lowcoder.plugins + mssqlPlugin + ${revision} + + + org.lowcoder.plugins + smtpPlugin + ${revision} + + + org.lowcoder.plugins + clickHousePlugin + ${revision} + + + org.lowcoder.plugins + googleSheetsPlugin + ${revision} + + + org.lowcoder.plugins + oraclePlugin + ${revision} + + + org.lowcoder.plugins + graphqlPlugin + ${revision} + + + org.lowcoder + sqlBasedPlugin + ${revision} + + + org.lowcoder.plugins + snowflakePlugin + ${revision} + + + + + + + org.jacoco + jacoco-maven-plugin + + + report-aggregate + verify + + report-aggregate + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + org.jacoco + org.jacoco.ant + ${jacoco.version} + + + + + coverage-gate + verify + + run + + + ${jacoco.skip} + + + + + + + + + + diff --git a/server/api-service/lowcoder-dependencies/pom.xml b/server/api-service/lowcoder-dependencies/pom.xml index 0787bcfcf4..30e2b52819 100644 --- a/server/api-service/lowcoder-dependencies/pom.xml +++ b/server/api-service/lowcoder-dependencies/pom.xml @@ -13,8 +13,24 @@ lowcoder-dependencies pom + + 2.0.5 + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + org.springframework.boot spring-boot-dependencies @@ -227,11 +243,6 @@ httpclient 4.5.14 - - de.flapdoodle.embed - de.flapdoodle.embed.mongo.spring30x - 4.11.0 - org.mockito mockito-inline diff --git a/server/api-service/lowcoder-domain/pom.xml b/server/api-service/lowcoder-domain/pom.xml index 52845ece00..28c7391748 100644 --- a/server/api-service/lowcoder-domain/pom.xml +++ b/server/api-service/lowcoder-domain/pom.xml @@ -46,6 +46,14 @@ org.lowcoder lowcoder-sdk + + + org.lowcoder + lowcoder-sdk + ${revision} + test-jar + test + org.lowcoder lowcoder-infra @@ -86,11 +94,6 @@ com.github.spullara.mustache.java compiler - - de.flapdoodle.embed - de.flapdoodle.embed.mongo.spring30x - test - com.google.guava guava @@ -117,11 +120,6 @@ io.micrometer micrometer-registry-prometheus - - junit - junit - test - commons-codec diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/repository/BundleRepository.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/repository/BundleRepository.java index 9670cc525b..a65dd05f99 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/repository/BundleRepository.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/repository/BundleRepository.java @@ -5,13 +5,11 @@ import org.springframework.data.mongodb.repository.ReactiveMongoRepository; import org.springframework.stereotype.Repository; import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; import java.util.Collection; @Repository public interface BundleRepository extends ReactiveMongoRepository { - Mono deleteAllByGid(Collection gids); Flux findByGid(@Nonnull String gid); Flux findAllByGid(Collection gids); diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleNode.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleNode.java deleted file mode 100644 index d10921a8e0..0000000000 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleNode.java +++ /dev/null @@ -1,102 +0,0 @@ -package org.lowcoder.domain.bundle.service; - -import jakarta.annotation.Nonnull; -import jakarta.annotation.Nullable; -import lombok.Getter; -import lombok.Setter; - -import java.util.*; -import java.util.function.Consumer; -import java.util.function.Function; - -@Getter -@Setter -public class BundleNode implements Node { - - private final F self; - private BundleNode parent; - protected final Collection> children; - @Nonnull - private final Function idExtractor; - @Nonnull - private final Function parentIdExtractor; - - BundleNode(F self, @Nonnull Function idExtractor, @Nonnull Function parentIdExtractor, - @Nullable Comparator> comparator) { - this.self = self; - this.idExtractor = idExtractor; - this.parentIdExtractor = parentIdExtractor; - this.children = comparator == null ? new ArrayList<>() : new PriorityQueue<>(comparator); - } - - public String id() { - return idExtractor.apply(self); - } - - @Override - public String parentId() { - return parentIdExtractor.apply(self); - } - - public final List getBundleChildren() { - return children.stream() - .filter(node -> node instanceof BundleNode) - .map(node -> ((BundleNode) node).getSelf()) - .toList(); - } - - public final List getElementChildren() { - return children.stream() - .filter(node -> node instanceof ElementNode) - .map(node -> ((ElementNode) node).getSelf()) - .toList(); - } - - public final List getAllBundleChildren() { - return this.children.stream() - .map(node -> { - if (node instanceof BundleNode bundleNode) { - F self = bundleNode.getSelf(); - List bundleChildren = bundleNode.getAllBundleChildren(); - List all = new ArrayList<>(bundleChildren); - if (self != null) { - all.add(self); - } - return all; - } - return new ArrayList(); - }) - .flatMap(List::stream) - .toList(); - } - - - /** - * @param consumer will be called for children node first, then by parent node - */ - public void postOrderIterate(Consumer> consumer) { - children.forEach(node -> { - if (node instanceof BundleNode bundleNode) { - bundleNode.postOrderIterate(consumer); - return; - } - consumer.accept(node); - }); - consumer.accept(this); - } - - public final int depth() { - if (parent == null) { - return 1; - } - return parent.depth() + 1; - } - - @Override - public String toString() { - return "BundleNode{" + - "self=" + self + - ", children=" + children + - '}'; - } -} \ No newline at end of file diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleService.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleService.java index 5518d82fde..4b4e99fc08 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleService.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleService.java @@ -24,9 +24,6 @@ public interface BundleService { Flux findByUserId(String bundleId); - Mono deleteAllById(Collection ids); - - Mono exist(String id); Mono publish(String bundleId); Mono updatePublishedBundleDSL(String bundleId, Map bundleDSL); diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleServiceImpl.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleServiceImpl.java index 48550aec3c..b2164a1f53 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleServiceImpl.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/BundleServiceImpl.java @@ -87,25 +87,6 @@ public Flux findByUserId(String userId) { return repository.findByCreatedBy(userId); } - @Override - public Mono deleteAllById(Collection ids) { - if(!ids.isEmpty() && FieldName.isGID(ids.stream().findFirst().get())) - return repository.deleteAllByGid(ids); - return repository.deleteAllById(ids); - } - - @Override - public Mono exist(String id) { - return findById(id) - .hasElement() - .onErrorResume(throwable -> { - if (throwable instanceof BizException bizException && bizException.getError() == NO_RESOURCE_FOUND) { - return Mono.just(false); - } - return Mono.error(throwable); - }); - } - @Override public Mono publish(String bundleId) { return findById(bundleId) diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/ElementNode.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/ElementNode.java deleted file mode 100644 index 81325434ed..0000000000 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/ElementNode.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.lowcoder.domain.bundle.service; - -import jakarta.annotation.Nonnull; -import lombok.Getter; -import lombok.Setter; - -import java.util.function.Function; - -@Setter -@Getter -public class ElementNode implements Node { - - @Nonnull - private final T self; - @Nonnull - private BundleNode parent; - @Nonnull - private final Function parentIdExtractor; - - ElementNode(@Nonnull T self, @Nonnull Function parentIdExtractor) { - this.self = self; - this.parentIdExtractor = parentIdExtractor; - } - - @Override - public String parentId() { - return parentIdExtractor.apply(self); - } - - @Override - public String toString() { - return "ElementNode{" + - "self=" + self + - '}'; - } -} \ No newline at end of file diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/Node.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/Node.java deleted file mode 100644 index a09193a2b6..0000000000 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/Node.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.lowcoder.domain.bundle.service; - -public interface Node { - - String parentId(); - - void setParent(BundleNode bundleNode); -} \ No newline at end of file diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/Tree.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/Tree.java deleted file mode 100644 index 58e5d5ad0a..0000000000 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/bundle/service/Tree.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.lowcoder.domain.bundle.service; - -import jakarta.annotation.Nullable; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; - -import java.util.Collection; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Slf4j -public class Tree extends BundleNode { - private static final int DEFAULT_DEPTH = 1; - private final int maxDepth; - private final Map> bundleId2BundleNodeMap; - - public final Function bundleNodeIdExtractor; - public final Function bundleNodeParentIdExtractor; - public final Function elementNodeParentIdExtractor; - - public Tree(List bundles, - Function bundleNodeIdExtractor, - Function bundleNodeParentIdExtractor, - List elements, - Function elementNodeParentIdExtractor, - @Nullable Comparator> comparator) { - this(bundles, bundleNodeIdExtractor, bundleNodeParentIdExtractor, elements, elementNodeParentIdExtractor, DEFAULT_DEPTH, comparator); - } - - public Tree(List bundles, - Function bundleNodeIdExtractor, - Function bundleNodeParentIdExtractor, - List elements, - Function elementNodeParentIdExtractor, - int maxDepth, - @Nullable Comparator> comparator) { - super(null, bundleNodeIdExtractor, bundleNodeParentIdExtractor, comparator); - this.bundleNodeIdExtractor = bundleNodeIdExtractor; - this.bundleNodeParentIdExtractor = bundleNodeParentIdExtractor; - this.elementNodeParentIdExtractor = elementNodeParentIdExtractor; - this.maxDepth = maxDepth; - - this.bundleId2BundleNodeMap = bundles.stream() - .map(bundle -> new BundleNode<>(bundle, bundleNodeIdExtractor, bundleNodeParentIdExtractor, comparator)) - .collect(Collectors.toMap(BundleNode::id, Function.identity())); - mount(this.bundleId2BundleNodeMap.values()); - mount(elements.stream().map(element -> new ElementNode(element, elementNodeParentIdExtractor)).toList()); - } - - private void mount(Collection> nodes) { - nodes.forEach(node -> { - if (StringUtils.isBlank(node.parentId())) { - children.add(node); - node.setParent(this); - return; - } - BundleNode parent = bundleId2BundleNodeMap.get(node.parentId()); - if (parent == null) { - log.warn("error node: {}", node); - // parent is not found, still put it in the tree - children.add(node); - node.setParent(this); - return; - } - parent.getChildren().add(node); - node.setParent(parent); - }); - } - - public BundleNode get(@Nullable String bundleId) { - if (StringUtils.isBlank(bundleId)) { - return this; - } - return bundleId2BundleNodeMap.get(bundleId); - } - - @Override - public String id() { - throw new UnsupportedOperationException(); - } - - @Override - public String parentId() { - throw new UnsupportedOperationException(); - } - - @Override - public String toString() { - return "Tree{" + - "maxDepth=" + maxDepth + - ", children=" + children + - '}'; - } -} diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/repository/GroupRepository.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/repository/GroupRepository.java index c5d94b0873..a66cdd7837 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/repository/GroupRepository.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/repository/GroupRepository.java @@ -23,7 +23,5 @@ public interface GroupRepository extends ReactiveMongoRepository Mono findByOrganizationIdAndAllUsersGroup(String organizationId, boolean allUsersGroup); Mono findByOrganizationIdAndType(String organizationId, String type); - - Flux findBySourceAndOrganizationId(String source, String organizationId); } diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupMemberService.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupMemberService.java index 9f95a4d676..0a11f181b8 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupMemberService.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupMemberService.java @@ -1,6 +1,5 @@ package org.lowcoder.domain.group.service; -import java.util.Collection; import java.util.List; import org.lowcoder.domain.group.model.Group; @@ -37,10 +36,6 @@ public interface GroupMemberService { Mono isMember(Group group, String userId); - Mono> bulkAddMember(Collection groupMembers); - - Mono bulkRemoveMember(String groupId, Collection userIds); - Mono> getGroupMembersByIdAndRole(String groupId, String role); } diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupMemberServiceImpl.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupMemberServiceImpl.java index a5c8f21b1b..8e058899b8 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupMemberServiceImpl.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupMemberServiceImpl.java @@ -2,21 +2,15 @@ import static org.lowcoder.infra.birelation.BiRelationBizType.GROUP_MEMBER; -import java.util.Collection; import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; -import org.bson.Document; import org.lowcoder.domain.group.model.Group; import org.lowcoder.domain.group.model.GroupMember; import org.lowcoder.domain.organization.model.MemberRole; import org.lowcoder.domain.organization.model.OrgMemberState; -import org.lowcoder.infra.birelation.BiRelation; import org.lowcoder.infra.birelation.BiRelationService; -import org.lowcoder.infra.mongo.MongoUpsertHelper; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; @@ -26,7 +20,6 @@ public class GroupMemberServiceImpl implements GroupMemberService { private final BiRelationService biRelationService; - private final MongoUpsertHelper mongoUpsertHelper; @Override public Mono> getGroupMembers(String groupId) { @@ -105,28 +98,4 @@ public Mono> getNonDynamicUserGroupIdsInOrg(String orgId, String us .map(GroupMember::getGroupId) .collectList(); } - - @Override - public Mono> bulkAddMember(Collection groupMembers) { - List biRelations = (List)groupMembers.stream() - .map(groupMember -> BiRelation.builder() - .bizType(GROUP_MEMBER) - .sourceId(groupMember.getGroupId()) - .targetId(groupMember.getUserId()) - .relation(MemberRole.MEMBER.getValue()) - .state(OrgMemberState.NORMAL.getValue()) - .extParam1(groupMember.getOrgId()) - .build()) - .toList(); - return biRelationService.batchAddBiRelation(biRelations) - .map(r -> r.stream().map(GroupMember::from).toList()); - } - - @Override - public Mono bulkRemoveMember(String groupId, Collection userIds) { - List filters = userIds.stream() - .map(userId -> new Document(Map.of("bizType", GROUP_MEMBER.name(), "sourceId", groupId, "targetId", userId))) - .toList(); - return mongoUpsertHelper.bulkRemove(filters, BiRelation.class); - } } diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupService.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupService.java index efcb66d4b4..a341ab8619 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupService.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupService.java @@ -3,7 +3,6 @@ import java.util.Collection; import org.lowcoder.domain.group.model.Group; -import org.lowcoder.infra.mongo.MongoUpsertHelper.PartialResourceWithId; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -35,11 +34,5 @@ default Mono getOrgGroupCount(String organizationId) { Mono createAllUserGroup(String orgId); - Flux getAllGroupsBySource(String orgId, String source); - - Mono bulkCreateSyncGroup(Collection groups); - - Mono bulkUpdateGroup(Collection> groups); - } diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupServiceImpl.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupServiceImpl.java index 97ebf00b7a..a4b8d4af5d 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupServiceImpl.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/group/service/GroupServiceImpl.java @@ -15,7 +15,6 @@ import org.lowcoder.domain.group.util.SystemGroups; import org.lowcoder.domain.organization.model.MemberRole; import org.lowcoder.infra.mongo.MongoUpsertHelper; -import org.lowcoder.infra.mongo.MongoUpsertHelper.PartialResourceWithId; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Service; @@ -113,19 +112,4 @@ public Mono createDevGroup(String orgId) { public Mono createAllUserGroup(String orgId) { return createSystemGroup(orgId, ALL_USER); } - - @Override - public Mono bulkCreateSyncGroup(Collection groups) { - return repository.saveAll(groups).hasElements(); - } - - @Override - public Flux getAllGroupsBySource(String orgId, String source) { - return repository.findBySourceAndOrganizationId(source, orgId); - } - - @Override - public Mono bulkUpdateGroup(Collection> groups) { - return mongoUpsertHelper.bulkUpdate(groups); - } } \ No newline at end of file diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/repository/MaterialMateRepository.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/repository/MaterialMateRepository.java index 0061dce7f6..46de2f37bc 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/repository/MaterialMateRepository.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/repository/MaterialMateRepository.java @@ -6,7 +6,6 @@ import org.springframework.stereotype.Repository; import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; @Repository public interface MaterialMateRepository extends ReactiveMongoRepository { @@ -16,6 +15,4 @@ public interface MaterialMateRepository extends ReactiveMongoRepository findByOrgIdAndType(String orgId, MaterialType type); Flux findByOrgIdAndFilenameAndType(String orgId, String filename, MaterialType type); - - Mono existsByOrgIdAndFilename(String orgId, String filename); } diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/service/meta/MaterialMetaService.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/service/meta/MaterialMetaService.java index fba2364e05..8fb62fcf34 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/service/meta/MaterialMetaService.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/service/meta/MaterialMetaService.java @@ -11,8 +11,6 @@ public interface MaterialMetaService { Mono findById(String id); - Mono existsByOrgIdAndFilename(String orgId, String filename); - Mono totalSize(String orgId); Flux getByOrgId(String orgId); diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/service/meta/MaterialMetaServiceImpl.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/service/meta/MaterialMetaServiceImpl.java index 0e02c704f8..fa1dc6cf02 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/service/meta/MaterialMetaServiceImpl.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/material/service/meta/MaterialMetaServiceImpl.java @@ -27,11 +27,6 @@ public class MaterialMetaServiceImpl implements MaterialMetaService { private final ReactiveMongoTemplate reactiveMongoTemplate; private final MaterialMateRepository repository; - @Override - public Mono existsByOrgIdAndFilename(String orgId, String filename) { - return repository.existsByOrgIdAndFilename(orgId, filename); - } - @Override public Mono findById(String id) { if (id == null) { diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrgMemberService.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrgMemberService.java index b5ad1e5415..3e9a965804 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrgMemberService.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrgMemberService.java @@ -50,14 +50,10 @@ public interface OrgMemberService { Mono> getAllOrgAdmins(String orgId); - Mono bulkAddMember(String orgId, Collection userIds, MemberRole memberRole); - Mono bulkAddToOrgs(Collection orgIds, String userId, MemberRole memberRole); Mono addToAllOrgAsAdminIfNot(String userId); - Mono bulkRemoveMember(String orgId, Collection userIds); - record UserOrgMemberInfo(OrgMember currentOrgMember, List orgMembers) { } diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrgMemberServiceImpl.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrgMemberServiceImpl.java index fdf6127e64..6f31f2fa20 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrgMemberServiceImpl.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/organization/service/OrgMemberServiceImpl.java @@ -3,8 +3,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.bson.Document; -import org.lowcoder.domain.group.model.GroupMember; import org.lowcoder.domain.group.service.GroupMemberService; import org.lowcoder.domain.group.service.GroupService; import org.lowcoder.domain.organization.model.*; @@ -12,7 +10,6 @@ import org.lowcoder.infra.annotation.PossibleEmptyMono; import org.lowcoder.infra.birelation.BiRelation; import org.lowcoder.infra.birelation.BiRelationService; -import org.lowcoder.infra.mongo.MongoUpsertHelper; import org.lowcoder.infra.util.FluxHelper; import org.lowcoder.sdk.config.CommonConfig; import org.lowcoder.sdk.config.CommonConfig.Workspace; @@ -43,7 +40,6 @@ public class OrgMemberServiceImpl implements OrgMemberService { @Lazy private final OrganizationService organizationService; private final CommonConfig commonConfig; - private final MongoUpsertHelper mongoUpsertHelper; @Override public Flux getOrganizationMembers(String orgId) { @@ -231,21 +227,6 @@ public Mono> getAllOrgAdmins(String orgId) { .collectList(); } - @Override - public Mono bulkAddMember(String orgId, Collection userIds, MemberRole memberRole) { - List biRelations = (List)userIds.stream() - .map(userId -> BiRelation.builder() - .bizType(ORG_MEMBER) - .sourceId(orgId) - .targetId(userId) - .relation(memberRole.getValue()) - .state(OrgMemberState.NORMAL.getValue()) - .build()) - .toList(); - return biRelationService.batchAddBiRelation(biRelations) - .then(bulkAddToAllUserGroup(orgId, userIds)); - } - @Override public Mono bulkAddToOrgs(Collection orgIds, String userId, MemberRole memberRole) { List biRelations = (List)orgIds.stream() @@ -269,25 +250,8 @@ public Mono addToAllOrgAsAdminIfNot(String userId) { return newOrgIdsMono.collectList().flatMapMany(newOrgIds -> bulkAddToOrgs(newOrgIds, userId, MemberRole.SUPER_ADMIN)).then(); } - private Mono bulkAddToAllUserGroup(String orgId, Collection userIds) { - return groupService.getAllUsersGroup(orgId) - .map(group -> userIds.stream() - .map(userId -> new GroupMember(group.getId(), userId, MemberRole.MEMBER, orgId, System.currentTimeMillis())) - .toList()) - .flatMap(groupMemberService::bulkAddMember) - .then(); - } - private Mono bulkAddToAllUserGroup(Collection orgIds, String userId, MemberRole memberRole) { return Flux.fromIterable(orgIds).flatMap(orgId -> groupService.getAllUsersGroup(orgId) .flatMap(group -> groupMemberService.addMember(orgId, group.getId(), userId, memberRole))).then(); } - - @Override - public Mono bulkRemoveMember(String orgId, Collection userIds) { - List filters = userIds.stream() - .map(userId -> new Document(Map.of("bizType", ORG_MEMBER.name(), "sourceId", orgId, "targetId", userId))) - .toList(); - return mongoUpsertHelper.bulkRemove(filters, BiRelation.class); - } } diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/ServerApplication.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/ServerApplication.java deleted file mode 100644 index 239e0fc6a5..0000000000 --- a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/ServerApplication.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.lowcoder.domain; - -import org.springframework.boot.Banner; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.builder.SpringApplicationBuilder; - -import lombok.extern.slf4j.Slf4j; -import reactor.core.publisher.Hooks; -import reactor.core.scheduler.Schedulers; - -@Slf4j -@SpringBootApplication(scanBasePackages = "org.lowcoder") -public class ServerApplication { - - public static void main(String[] args) { - - Hooks.onOperatorDebug(); - Schedulers.enableMetrics(); - - new SpringApplicationBuilder(ServerApplication.class) - .bannerMode(Banner.Mode.OFF) - .run(args); - } -} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/model/ApplicationQueryLookupTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/model/ApplicationQueryLookupTest.java new file mode 100644 index 0000000000..0c763ec8fc --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/model/ApplicationQueryLookupTest.java @@ -0,0 +1,209 @@ +package org.lowcoder.domain.application.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.query.model.ApplicationQuery; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Query lookup of an application by view mode, the module container size and the record-id predicates + * (unit U4a, task L3-7). The live DSL comes from the latest published record, else from the editing DSL. + */ +class ApplicationQueryLookupTest { + + private static final String APP_ID = "app-1"; + private static final String LIVE_ONLY_QUERY = "liveOnly"; + private static final String EDIT_ONLY_QUERY = "editOnly"; + private static final String SHARED_QUERY = "shared"; + private static final String GID_ONLY_ID = "q-by-gid-id"; + private static final String GID_ONLY_GID = "q-by-gid"; + + private final ApplicationRecordService records = mock(ApplicationRecordService.class); + + private static Map query(String id, String gid) { + Map query = new HashMap<>(); + query.put("id", id); + query.put("gid", gid); + query.put("name", "name-" + id); + query.put("datasourceId", "ds"); + query.put("comp", Map.of()); + query.put("compType", "query"); + return query; + } + + @SafeVarargs + private static Map dsl(Map... queries) { + Map dsl = new HashMap<>(); + dsl.put("queries", List.of(queries)); + return dsl; + } + + private Application application(Integer type, Map editingDsl, Map liveDsl) { + Application application = Application.builder().id(APP_ID).applicationType(type).editingApplicationDSL(editingDsl).build(); + when(records.getLatestRecordByApplicationId(APP_ID)).thenReturn(liveDsl == null ? Mono.empty() + : Mono.just(ApplicationVersion.builder().applicationId(APP_ID).applicationDSL(liveDsl).build())); + return application; + } + + private static void assertNotFound(Throwable error) { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(BizError.QUERY_NOT_FOUND); + } + + /** Catches: edit/preview mode reading the published queries, or ignoring the editing ones. */ + @Test + void editModeOnlyLooksAtTheEditingQueries() { + Application application = application(ApplicationType.APPLICATION.getValue(), + dsl(query(EDIT_ONLY_QUERY, null), query(SHARED_QUERY, null)), + dsl(query(LIVE_ONLY_QUERY, null), query(SHARED_QUERY, null))); + + StepVerifier.create(application.getQueryByViewModeAndQueryId(false, EDIT_ONLY_QUERY, records)) + .assertNext(found -> { + System.out.println("[ApplicationQueryLookupTest] edit mode found " + found.getId()); + assertThat(found.getId()).isEqualTo(EDIT_ONLY_QUERY); + }) + .verifyComplete(); + // edit mode throws while the Mono is being assembled, not on subscription (see the unknown-id test) + assertThatThrownBy(() -> application.getQueryByViewModeAndQueryId(false, LIVE_ONLY_QUERY, records)) + .satisfies(ApplicationQueryLookupTest::assertNotFound); + } + + /** Catches: view mode reading the editing queries, or ignoring the published ones. */ + @Test + void viewModeOnlyLooksAtTheLiveQueries() { + Application application = application(ApplicationType.APPLICATION.getValue(), + dsl(query(EDIT_ONLY_QUERY, null), query(SHARED_QUERY, null)), + dsl(query(LIVE_ONLY_QUERY, null), query(SHARED_QUERY, null))); + + StepVerifier.create(application.getQueryByViewModeAndQueryId(true, LIVE_ONLY_QUERY, records)) + .assertNext(found -> assertThat(found.getId()).isEqualTo(LIVE_ONLY_QUERY)) + .verifyComplete(); + StepVerifier.create(application.getQueryByViewModeAndQueryId(true, EDIT_ONLY_QUERY, records)) + .expectErrorSatisfies(ApplicationQueryLookupTest::assertNotFound) + .verify(); + System.out.println("[ApplicationQueryLookupTest] view mode sees live-only, not edit-only"); + } + + /** Catches: the gid half of the filter being dropped (modules and library queries are addressed by gid). */ + @Test + void aQueryIsFoundByItsGidInBothModes() { + Map withGid = query(GID_ONLY_ID, GID_ONLY_GID); + Application application = application(ApplicationType.APPLICATION.getValue(), dsl(withGid), dsl(withGid)); + + for (boolean viewMode : new boolean[] {false, true}) { + StepVerifier.create(application.getQueryByViewModeAndQueryId(viewMode, GID_ONLY_GID, records)) + .assertNext(found -> assertThat(found.getId()).isEqualTo(GID_ONLY_ID)) + .verifyComplete(); + } + System.out.println("[ApplicationQueryLookupTest] found by gid in edit and view mode"); + } + + /** Catches: an unknown id yielding anything but QUERY_NOT_FOUND, in either mode. */ + @Test + void anUnknownQueryIdIsQueryNotFoundInBothModes() { + Application application = application(ApplicationType.APPLICATION.getValue(), dsl(query(SHARED_QUERY, null)), dsl(query(SHARED_QUERY, null))); + + StepVerifier.create(application.getQueryByViewModeAndQueryId(true, "nope", records)) + .expectErrorSatisfies(ApplicationQueryLookupTest::assertNotFound).verify(); + // edit mode throws while the Mono is being assembled, not on subscription + assertThatThrownBy(() -> application.getQueryByViewModeAndQueryId(false, "nope", records)) + .satisfies(ApplicationQueryLookupTest::assertNotFound); + System.out.println("[ApplicationQueryLookupTest] unknown id: view mode errors in the Mono, edit mode throws synchronously"); + } + + /** Catches: view mode failing for an application that was never published (the live DSL falls back to editing). */ + @Test + void viewModeFallsBackToTheEditingDslWhenThereIsNoPublishedRecord() { + Application application = application(ApplicationType.APPLICATION.getValue(), dsl(query(EDIT_ONLY_QUERY, null)), null); + + StepVerifier.create(application.getQueryByViewModeAndQueryId(true, EDIT_ONLY_QUERY, records)) + .assertNext(found -> assertThat(found.getId()).isEqualTo(EDIT_ONLY_QUERY)) + .verifyComplete(); + } + + /** + * Pins today's behaviour of candidate defect L3-7(b), reported to the coordinator for a plan section 9 row: when the + * published DSL has no "queries" key, {@code getLiveQueries} maps JSON "null" to a null set, {@code mapNotNull} + * drops it, and the lookup COMPLETES EMPTY in view mode instead of failing with QUERY_NOT_FOUND (edit mode, with an + * empty editing set, throws QUERY_NOT_FOUND). A fix (an error for the missing set) changes this test on purpose. + */ + @Test + void pinsViewModeCompletingEmptyWhenTheLiveDslHasNoQueriesKey() { + Application application = application(ApplicationType.APPLICATION.getValue(), dsl(query(EDIT_ONLY_QUERY, null)), new HashMap<>()); + + StepVerifier.create(application.getQueryByViewModeAndQueryId(true, EDIT_ONLY_QUERY, records)) + .verifyComplete(); + StepVerifier.create(application.getLiveQueries(records)).verifyComplete(); + System.out.println("[ApplicationQueryLookupTest] PINNED L3-7(b): live DSL without queries -> view mode completes empty"); + } + + /** Catches: the type test inverted, so an APPLICATION reports a container size or a module reports none. */ + @Test + void containerSizeIsOnlyReportedForNonApplicationTypes() { + Map liveDsl = new HashMap<>(); + liveDsl.put("ui", Map.of("comp", Map.of("containerSize", Map.of("height", 5)))); + + Application app = application(ApplicationType.APPLICATION.getValue(), null, liveDsl); + StepVerifier.create(app.getLiveContainerSize(records)).verifyComplete(); + + Application module = application(ApplicationType.MODULE.getValue(), null, liveDsl); + StepVerifier.create(module.getLiveContainerSize(records)) + .assertNext(size -> { + System.out.println("[ApplicationQueryLookupTest] module containerSize " + size); + assertThat(size).isEqualTo(Map.of("height", 5)); + }) + .verifyComplete(); + } + + /** Catches: a malformed or sparse DSL breaking the container size instead of yielding an empty map / nothing. */ + @Test + void containerSizeOfASparseDslIsAnEmptyMapOrNothing() { + Application noUi = application(ApplicationType.MODULE.getValue(), null, new HashMap<>()); + StepVerifier.create(noUi.getLiveContainerSize(records)).expectNext(Map.of()).verifyComplete(); + + Application noComp = application(ApplicationType.MODULE.getValue(), null, Map.of("ui", Map.of())); + StepVerifier.create(noComp.getLiveContainerSize(records)).expectNext(Map.of()).verifyComplete(); + + Application noSize = application(ApplicationType.MODULE.getValue(), null, Map.of("ui", Map.of("comp", Map.of()))); + StepVerifier.create(noSize.getLiveContainerSize(records)).verifyComplete(); + System.out.println("[ApplicationQueryLookupTest] sparse DSLs: empty map, empty map, nothing"); + } + + /** Catches: the live record id and the editing record id being confused. */ + @Test + void latestIsTheLiveRecordAndEditingIsAnythingBlankOrEditing() { + assertThat(new ApplicationCombineId(APP_ID, "latest").isUsingLiveRecord()).isTrue(); + assertThat(new ApplicationCombineId(APP_ID, "latest").isUsingEditingRecord()).isFalse(); + assertThat(new ApplicationCombineId(APP_ID, "editing").isUsingLiveRecord()).isFalse(); + assertThat(new ApplicationCombineId(APP_ID, "editing").isUsingEditingRecord()).isTrue(); + assertThat(new ApplicationCombineId(APP_ID, "record-77").isUsingEditingRecord()).isFalse(); + assertThat(new ApplicationCombineId(APP_ID, "record-77").isUsingLiveRecord()).isFalse(); + } + + /** Catches: a missing record id (null, empty, blank) not meaning "editing". */ + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + void aMissingRecordIdMeansEditing(String recordId) { + ApplicationCombineId id = new ApplicationCombineId(APP_ID, recordId); + System.out.println("[ApplicationQueryLookupTest] record id [" + recordId + "] editing=" + id.isUsingEditingRecord()); + assertThat(id.isUsingEditingRecord()).isTrue(); + assertThat(id.isUsingLiveRecord()).isFalse(); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplFindByIdInPinTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplFindByIdInPinTest.java new file mode 100644 index 0000000000..6646279c9c --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplFindByIdInPinTest.java @@ -0,0 +1,74 @@ +package org.lowcoder.domain.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.domain.application.service.ApplicationServiceImplTestSupport.app; +import static org.lowcoder.domain.application.service.ApplicationServiceImplTestSupport.counted; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.application.model.Application; + +import reactor.core.publisher.Flux; + +/** + * Candidate pins G1 and G2 of task L3-12 (plan section 9 rows, subject to the coordinator's merge), both about + * ApplicationServiceImpl.findByIdIn (:128-132), which is reached with client-supplied ids by MetaController.java:46 + * ({@code applicationServiceImpl.findByIdIn(param.appIds())}) and, for the module ids found in a DSL, by + * getAllDependentModulesFromDsl (:153, :164). + * G1 row: "ApplicationServiceImpl.findByIdIn drops the ids of a list as soon as one entry matches a slug (:131)". + * G2 row: "ApplicationServiceImpl.findByIdIn never looks up a slug that starts a list and contains a hyphen (:129-130); a + * hyphenated slug is valid (SlugUtils.validate accepts '-') and findByIdWithoutDsl does find it by slug first". + * Both are consequences of the same heuristics as the L3-11b hyphenated-slug row. A fix changes these tests on purpose. + */ +class ApplicationServiceImplFindByIdInPinTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final ApplicationServiceImplTestSupport s = new ApplicationServiceImplTestSupport(); + + @Test + void findByIdInOfASlugAndAnIdAnswersOnlyTheSlugMatch_pinsTheSection9RowG1() { + AtomicInteger idQueries = new AtomicInteger(); + when(s.repository.findBySlugIn(any())).thenReturn(Flux.just(app("app-of-the-slug"))); + when(s.repository.findByIdIn(any())).thenReturn(counted(Flux.just(app("app-of-the-id")), idQueries)); + + List found = s.service.findByIdIn(List.of("theslug", "someid")).collectList().block(TIMEOUT); + + System.out.println("[ApplicationServiceImplFindByIdInPinTest] PINNED G1: " + found.stream().map(Application::getId).toList() + + ", id query subscribed " + idQueries.get() + " times"); + assertThat(found).extracting(Application::getId).containsExactly("app-of-the-slug"); + assertThat(idQueries.get()).as("the id lookup never runs once a slug matched").isZero(); + } + + @Test + void getPrivateApplicationIdsOfASlugAndAnIdAnswersOnlyTheSlugMatch_sameCauseAsG1() { + AtomicInteger idQueries = new AtomicInteger(); + when(s.repository.findByCreatedByAndSlugIn(any(), any())).thenReturn(Flux.just(app("app-of-the-slug"))); + when(s.repository.findByCreatedByAndIdIn(any(), any())).thenReturn(counted(Flux.just(app("app-of-the-id")), idQueries)); + + assertThat(s.service.getPrivateApplicationIds(List.of("theslug", "someid"), "user").block(TIMEOUT)) + .containsExactly("app-of-the-slug"); + assertThat(idQueries.get()).isZero(); + } + + @Test + void findByIdInOfAHyphenatedSlugGoesToTheGidQueryAndNeverAsksForSlugs_pinsTheSection9RowG2() { + AtomicInteger slugQueries = new AtomicInteger(); + AtomicInteger gidQueries = new AtomicInteger(); + when(s.repository.findBySlugIn(any())).thenReturn(counted(Flux.just(app("app-of-the-slug")), slugQueries)); + when(s.repository.findByGidIn(any())).thenReturn(counted(Flux.empty(), gidQueries)); + + List found = s.service.findByIdIn(List.of("my-slug")).collectList().block(TIMEOUT); + + System.out.println("[ApplicationServiceImplFindByIdInPinTest] PINNED G2: found " + found.size() + ", gid query " + gidQueries.get() + + ", slug query " + slugQueries.get()); + assertThat(found).isEmpty(); + assertThat(gidQueries.get()).isEqualTo(1); + assertThat(slugQueries.get()).as("the slug lookup is never subscribed").isZero(); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplIdKindFamilyPinTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplIdKindFamilyPinTest.java new file mode 100644 index 0000000000..2fe22d3af8 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplIdKindFamilyPinTest.java @@ -0,0 +1,73 @@ +package org.lowcoder.domain.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.domain.application.service.ApplicationServiceImplTestSupport.app; +import static org.lowcoder.domain.application.service.ApplicationServiceImplTestSupport.appWithGid; +import static org.lowcoder.domain.application.service.ApplicationServiceImplTestSupport.counted; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.application.model.Application; + +import reactor.core.publisher.Flux; + +/** + * Pins the application sites of the section 9 family row (task L3-12, G3): "id-or-gid routing decided from one element drops + * the other kind" (same cause as DatasourceRepository.findByIds, L3-11c, and LibraryQueryServiceImpl.getByIds, L3-11d). + * ApplicationServiceImpl.findByIdIn (:129), getPublicApplicationIds (:262), getPrivateApplicationIds (:282), + * getPublicMarketplaceApplicationIds (:303) and getPublicAgencyApplicationIds (:323) look at the first element only; a list + * of one kind with an element of the other drops that element. Reach: ApplicationPermissionHandler.java:78 and :93 pass + * {@code newHashSet(resourceIds)} (arbitrary first element) to getFilteredPublicApplicationIds, and MetaController.java:46 + * passes the client's appIds to findByIdIn. A fix (query both kinds) changes these tests on purpose. + */ +class ApplicationServiceImplIdKindFamilyPinTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final ApplicationServiceImplTestSupport s = new ApplicationServiceImplTestSupport(); + + @Test + void findByIdInOfAGidAndAnIdQueriesOnlyTheKindOfTheFirstElement_pinsTheFamilyRow() { + AtomicInteger idQueries = new AtomicInteger(); + AtomicInteger gidQueries = new AtomicInteger(); + when(s.repository.findByGidIn(any())).thenReturn(counted(Flux.just(app("by-gid")), gidQueries)); + when(s.repository.findByIdIn(any())).thenReturn(counted(Flux.just(app("by-id")), idQueries)); + + List gidFirst = s.service.findByIdIn(List.of("aa-bb", "plainid")).collectList().block(TIMEOUT); + assertThat(gidFirst).extracting(Application::getId).containsExactly("by-gid"); + assertThat(idQueries.get()).as("the id of a gid-first list is never looked up").isZero(); + + List idFirst = s.service.findByIdIn(List.of("plainid", "aa-bb")).collectList().block(TIMEOUT); + System.out.println("[ApplicationServiceImplIdKindFamilyPinTest] PINNED gid first -> " + gidFirst.size() + " (gid queries " + gidQueries.get() + + "), id first -> " + idFirst.size() + " (id queries " + idQueries.get() + ")"); + assertThat(idFirst).extracting(Application::getId).containsExactly("by-id"); + assertThat(gidQueries.get()).as("the gid of an id-first list is never looked up").isEqualTo(1); + } + + @Test + void thePublicIdFilterOfAMixedSetAnswersOnlyTheKindOfTheFirstElement_pinsTheFamilyRow() { + Application withGid = appWithGid("real-id", "the-gid"); + AtomicInteger idQueries = new AtomicInteger(); + AtomicInteger gidQueries = new AtomicInteger(); + when(s.repository.findByPublicToAllIsTrueAndGidIn(any())).thenReturn(counted(Flux.just(withGid), gidQueries)); + when(s.repository.findByPublicToAllIsTrueAndIdIn(any())).thenReturn(counted(Flux.just(withGid), idQueries)); + Set gidFirst = new LinkedHashSet<>(List.of("aa-bb", "plainid")); + Set idFirst = new LinkedHashSet<>(List.of("plainid", "aa-bb")); + + Set byGidFirst = s.service.getPublicApplicationIds(gidFirst).block(TIMEOUT); + Set byIdFirst = s.service.getPublicApplicationIds(idFirst).block(TIMEOUT); + + System.out.println("[ApplicationServiceImplIdKindFamilyPinTest] PINNED public filter: gid first -> " + byGidFirst + ", id first -> " + byIdFirst); + assertThat(byGidFirst).containsExactly("the-gid"); + assertThat(byIdFirst).containsExactly("real-id"); + assertThat(gidQueries.get()).isEqualTo(1); + assertThat(idQueries.get()).isEqualTo(1); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplTest.java new file mode 100644 index 0000000000..c656454689 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplTest.java @@ -0,0 +1,493 @@ +package org.lowcoder.domain.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.domain.application.service.ApplicationServiceImplTestSupport.app; +import static org.lowcoder.domain.application.service.ApplicationServiceImplTestSupport.appWithGid; +import static org.lowcoder.domain.application.service.ApplicationServiceImplTestSupport.counted; +import static org.lowcoder.domain.application.service.ApplicationServiceImplTestSupport.dslWithModules; +import static org.lowcoder.domain.application.service.ApplicationServiceImplTestSupport.version; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationRequestType; +import org.lowcoder.domain.application.model.ApplicationStatus; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.ArgumentCaptor; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * ApplicationServiceImpl with mocked collaborators (unit U12, task L3-12; the NonEmptyMono aspect is not applied, see + * ApplicationServiceImplTestSupport). Not repeated: the L1-8 row (findByIdWithoutDsl routing of a slug and the audit event), + * the L3-11b hyphenated-slug row, and the candidate pins in ApplicationServiceImplFindByIdInPinTest and + * ApplicationServiceImplIdKindFamilyPinTest. ApplicationApiServiceImpl is L1-2's. + */ +class ApplicationServiceImplTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + private static final String USER = "user-1"; + + private final ApplicationServiceImplTestSupport s = new ApplicationServiceImplTestSupport(); + private final ApplicationServiceImpl service = s.service; + + private static BizException failure(Runnable call) { + return assertThrows(BizException.class, call::run); + } + + /** Catches: a null id passing through, an unknown id not failing, the not-found key changed. */ + @Test + void findByIdFailsForNullAndUnknownAndAnswersTheApplication() { + Application found = app("a1"); + when(s.repository.findByIdWithDsl("a1")).thenReturn(Mono.just(found)); + + assertThat(service.findById("a1").block(TIMEOUT)).isSameAs(found); + assertThat(failure(() -> service.findById(null).block(TIMEOUT)).getError()).isEqualTo(BizError.INVALID_PARAMETER); + BizException unknown = failure(() -> service.findById("nope").block(TIMEOUT)); + assertThat(unknown.getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + assertThat(unknown.getMessageKey()).isEqualTo("CANT_FIND_APPLICATION"); + } + + /** + * Catches: the slug / gid / id routing of findByIdWithoutDsl (:64-70) changed: a slug hit stops the lookup, a "-" routes to + * the gid query only, anything else to the id query only. The audit-event consequence of the slug route is the L1-8 row and + * the hyphen heuristic's consequence the L3-11b row, neither is re-pinned here. + */ + @Test + void findByIdWithoutDslRoutesSlugThenGidOrId() { + Application bySlug = app("by-slug"); + Application byGid = app("g1"); + Application byId = app("i1"); + AtomicInteger gidQueries = new AtomicInteger(); + AtomicInteger idQueries = new AtomicInteger(); + when(s.repository.findBySlug("my-slug")).thenReturn(Flux.just(bySlug)); + when(s.repository.findByGid("abc-def")).thenReturn(counted(Flux.just(byGid), gidQueries)); + when(s.repository.findById("plainid")).thenReturn(counted(Mono.just(byId), idQueries)); + + assertThat(service.findByIdWithoutDsl("my-slug").block(TIMEOUT)).isSameAs(bySlug); + assertThat(gidQueries.get() + idQueries.get()).as("a slug hit stops the lookup").isZero(); + + assertThat(service.findByIdWithoutDsl("abc-def").block(TIMEOUT)).isSameAs(byGid); + assertThat(gidQueries.get()).isEqualTo(1); + assertThat(idQueries.get()).isZero(); + + assertThat(service.findByIdWithoutDsl("plainid").block(TIMEOUT)).isSameAs(byId); + assertThat(gidQueries.get()).isEqualTo(1); + assertThat(idQueries.get()).isEqualTo(1); + + BizException unknown = failure(() -> service.findByIdWithoutDsl("unknown").block(TIMEOUT)); + assertThat(unknown.getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + assertThat(failure(() -> service.findByIdWithoutDsl(null).block(TIMEOUT)).getError()).isEqualTo(BizError.INVALID_PARAMETER); + } + + /** Catches: updateById not rejecting null, not delegating the application and id. */ + @Test + void updateByIdRejectsNullAndOtherwiseDelegates() { + Application patch = app("x"); + when(s.mongoUpsertHelper.updateById(patch, "a1")).thenReturn(Mono.just(true)); + + assertThat(service.updateById("a1", patch).block(TIMEOUT)).isTrue(); + assertThat(failure(() -> service.updateById(null, patch).block(TIMEOUT)).getError()).isEqualTo(BizError.INVALID_PARAMETER); + } + + /** Catches: the editing user cleared when editing is not finished, not cleared when it is, an unknown id not failing. */ + @Test + void updateEditStateClearsTheEditingUserOnlyWhenEditingIsFinished() { + when(s.repository.findByIdWithDsl("a1")).thenReturn(Mono.just(app("a1"))); + when(s.mongoUpsertHelper.updateById(any(Application.class), eq("a1"))).thenReturn(Mono.just(true)); + + assertThat(service.updateEditState("a1", false).block(TIMEOUT)).isTrue(); + verify(s.mongoUpsertHelper, never()).updateById(any(Application.class), anyString()); + + assertThat(service.updateEditState("a1", true).block(TIMEOUT)).isTrue(); + ArgumentCaptor patch = ArgumentCaptor.forClass(Application.class); + verify(s.mongoUpsertHelper).updateById(patch.capture(), eq("a1")); + assertThat(patch.getValue().getEditingUserId()).isEmpty(); + + assertThat(failure(() -> service.updateEditState("nope", true).block(TIMEOUT)).getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + } + + /** Catches: the owner permission granted before the save or to the wrong id, user, role or type, a failing grant not failing create. */ + @Test + void createSavesThenGrantsOwnerAndFailsWhenTheGrantFails() { + Application newApplication = app(null); + Application saved = app("saved-id"); + AtomicBoolean saveSubscribed = new AtomicBoolean(); + when(s.repository.save(newApplication)).thenReturn(Mono.just(saved).doOnSubscribe(x -> saveSubscribed.set(true))); + when(s.resourcePermissionService.addResourcePermissionToUser("saved-id", USER, ResourceRole.OWNER, ResourceType.APPLICATION)) + .thenAnswer(invocation -> { + assertThat(saveSubscribed).as("the grant runs after the save").isTrue(); + return Mono.just(true); + }); + + assertThat(service.create(newApplication, USER).block(TIMEOUT)).isSameAs(saved); + verify(s.resourcePermissionService).addResourcePermissionToUser("saved-id", USER, ResourceRole.OWNER, ResourceType.APPLICATION); + + when(s.resourcePermissionService.addResourcePermissionToUser("saved-id", "bad-user", ResourceRole.OWNER, ResourceType.APPLICATION)) + .thenReturn(Mono.error(new IllegalStateException("grant failed"))); + assertThrows(IllegalStateException.class, () -> service.create(newApplication, "bad-user").block(TIMEOUT)); + } + + /** Catches: a listing or count delegated to a neighbouring repository method. */ + @Test + void listingsAndCountsUseTheIntendedRepositoryMethods() { + Application a = app("a"); + when(s.repository.findByOrganizationIdWithDsl("o")).thenReturn(Flux.just(a, app("b"))); + when(s.repository.findByOrganizationId("o")).thenReturn(Flux.just(a)); + when(s.repository.findByPublicToAllIsTrueAndPublicToMarketplaceIsTrue()).thenReturn(Flux.just(app("m1"), app("m2"), app("m3"))); + when(s.repository.findByPublicToAllIsTrueAndAgencyProfileIsTrue()).thenReturn(Flux.just(app("g1"), app("g2"), app("g3"), app("g4"))); + when(s.repository.countByOrganizationIdAndApplicationStatus("o", ApplicationStatus.RECYCLED)).thenReturn(Mono.just(7L)); + when(s.repository.findAll()).thenReturn(Flux.just(a, a, a, a, a)); + + assertThat(service.findByOrganizationIdWithDsl("o").collectList().block(TIMEOUT)).hasSize(2); + assertThat(service.findByOrganizationIdWithoutDsl("o").collectList().block(TIMEOUT)).hasSize(1); + assertThat(service.findAllMarketplaceApps().collectList().block(TIMEOUT)).hasSize(3); + assertThat(service.findAllAgencyProfileApps().collectList().block(TIMEOUT)).hasSize(4); + assertThat(service.countByOrganizationId("o", ApplicationStatus.RECYCLED).block(TIMEOUT)).isEqualTo(7L); + assertThat(service.findAll().collectList().block(TIMEOUT)).hasSize(5); + } + + /** + * Catches: the findByIdIn routing (:128-132) changed. Homogeneous lists only: the empty list asks slugs then ids, a gid + * first asks the gid query only, a slug hit makes the id query unnecessary, no slug hit falls through to the ids. The mixed + * lists are the candidate pins' (ApplicationServiceImplFindByIdInPinTest, ApplicationServiceImplIdKindFamilyPinTest). + */ + @Test + void findByIdInRoutesGidThenSlugThenId() { + AtomicInteger slugQueries = new AtomicInteger(); + AtomicInteger idQueries = new AtomicInteger(); + AtomicInteger gidQueries = new AtomicInteger(); + when(s.repository.findBySlugIn(anyCollection())).thenReturn(counted(Flux.empty(), slugQueries)); + when(s.repository.findByIdIn(anyCollection())).thenReturn(counted(Flux.just(app("i1")), idQueries)); + when(s.repository.findByGidIn(anyCollection())).thenReturn(counted(Flux.just(app("g1")), gidQueries)); + + assertThat(service.findByIdIn(List.of()).collectList().block(TIMEOUT)).extracting(Application::getId).containsExactly("i1"); + assertThat(slugQueries.get()).isEqualTo(1); + assertThat(idQueries.get()).isEqualTo(1); + + assertThat(service.findByIdIn(List.of("plain1", "plain2")).collectList().block(TIMEOUT)).extracting(Application::getId).containsExactly("i1"); + assertThat(slugQueries.get()).isEqualTo(2); + assertThat(idQueries.get()).isEqualTo(2); + assertThat(gidQueries.get()).isZero(); + + assertThat(service.findByIdIn(List.of("gid-1", "gid-2")).collectList().block(TIMEOUT)).extracting(Application::getId).containsExactly("g1"); + assertThat(gidQueries.get()).isEqualTo(1); + assertThat(slugQueries.get()).isEqualTo(2); + assertThat(idQueries.get()).isEqualTo(2); + + when(s.repository.findBySlugIn(anyCollection())).thenReturn(counted(Flux.just(app("s1")), slugQueries)); + assertThat(service.findByIdIn(List.of("slugonly")).collectList().block(TIMEOUT)).extracting(Application::getId).containsExactly("s1"); + assertThat(idQueries.get()).as("a slug hit makes the id query unnecessary").isEqualTo(2); + } + + private static Collection anyCollection() { + return org.mockito.ArgumentMatchers.anyCollection(); + } + + /** Catches: a DSL without modules not answering an empty list, an unknown id not failing. */ + @Test + void anApplicationWithoutModulesHasNoDependentModules() { + when(s.repository.findByIdWithDsl("root")).thenReturn(Mono.just(app("root", new HashMap<>()))); + + assertThat(service.getAllDependentModulesFromApplicationId("root", true).block(TIMEOUT)).isEmpty(); + assertThat(service.getAllDependentModulesFromApplicationId("root", false).block(TIMEOUT)).isEmpty(); + assertThat(failure(() -> service.getAllDependentModulesFromApplicationId("nope", true).block(TIMEOUT)).getError()) + .isEqualTo(BizError.NO_RESOURCE_FOUND); + } + + /** Catches: the nested module not expanded (expandDeep), a circular or self reference not ending, a module listed twice. */ + @Test + void dependentModulesAreExpandedDeepAndCircularReferencesEnd() { + Application root = app("root", dslWithModules("m1", "m2")); + Application m1 = app("m1", dslWithModules("m3", "root")); + Application m2 = app("m2", dslWithModules("m2")); + Application m3 = app("m3", new HashMap<>()); + when(s.repository.findByIdWithDsl("root")).thenReturn(Mono.just(root)); + when(s.repository.findByIdIn(any())).thenAnswer(invocation -> { + Collection requested = invocation.getArgument(0); + return Flux.fromIterable(List.of(m1, m2, m3, root)).filter(a -> requested.contains(a.getId())); + }); + + List modules = service.getAllDependentModulesFromApplicationId("root", false).block(TIMEOUT); + + System.out.println("[ApplicationServiceImplTest] dependent modules: " + modules.stream().map(Application::getId).toList()); + assertThat(modules).extracting(Application::getId) + .as("the root is listed too: the circular set is seeded from the root's DSL only, so a module that refers back to the root returns it once") + .containsExactlyInAnyOrder("m1", "m2", "m3", "root"); + } + + /** Catches: a module reached from two parents listed twice (the circular set must be extended with every module found below the root). */ + @Test + void aModuleReferencedFromTwoParentsIsListedOnce() { + Application root = app("root", dslWithModules("m1", "m2")); + Application m1 = app("m1", dslWithModules("m3")); + Application m2 = app("m2", dslWithModules("m3")); + Application m3 = app("m3", new HashMap<>()); + when(s.repository.findByIdWithDsl("root")).thenReturn(Mono.just(root)); + when(s.repository.findByIdIn(any())).thenAnswer(invocation -> { + Collection requested = invocation.getArgument(0); + return Flux.fromIterable(List.of(m1, m2, m3)).filter(a -> requested.contains(a.getId())); + }); + + List modules = service.getAllDependentModulesFromApplicationId("root", false).block(TIMEOUT); + + assertThat(modules).extracting(Application::getId).containsExactlyInAnyOrder("m1", "m2", "m3"); + } + + /** + * Behaviour (observed): the onErrorContinue of getAllDependentModulesFromDsl (:154) does not make a failing first-level + * lookup skippable: the repository error reaches the caller and the whole expansion fails, although the code logs "on error + * continue". Only a database failure triggers it; no user input does. + */ + @Test + void aFailingFirstLevelModuleLookupFailsTheWholeExpansion() { + Application root = app("root", dslWithModules("bad", "ok")); + when(s.repository.findBySlugIn(any())).thenReturn(Flux.error(new IllegalStateException("lookup failed"))); + when(s.repository.findByIdWithDsl("root")).thenReturn(Mono.just(root)); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> service.getAllDependentModulesFromApplicationId("root", false).block(TIMEOUT)); + + System.out.println("[ApplicationServiceImplTest] first-level lookup failure reaches the caller: " + failure.getMessage()); + assertThat(failure).hasMessage("lookup failed"); + } + + /** Behaviour (observed): the onErrorContinue one level down (:165) does not skip a failing lookup either, the error reaches the caller. */ + @Test + void aFailingSecondLevelModuleLookupFailsTheWholeExpansionToo() { + Application root = app("root", dslWithModules("m1")); + Application m1 = app("m1", dslWithModules("m2")); + when(s.repository.findByIdWithDsl("root")).thenReturn(Mono.just(root)); + when(s.repository.findByIdIn(List.of("m1"))).thenReturn(Flux.just(m1)); + when(s.repository.findBySlugIn(List.of("m2"))).thenReturn(Flux.error(new IllegalStateException("lookup failed"))); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> service.getAllDependentModulesFromApplicationId("root", false).block(TIMEOUT)); + + System.out.println("[ApplicationServiceImplTest] second-level lookup failure reaches the caller: " + failure.getMessage()); + assertThat(failure).hasMessage("lookup failed"); + } + + /** Catches: view mode reading the editing DSL, edit mode reading the live one. */ + @Test + void viewModeUsesTheLiveDslAndEditModeTheEditingDsl() { + Application root = app("root", dslWithModules("editingmodule")); + Application liveModule = app("livemodule"); + Application editingModule = app("editingmodule"); + when(s.applicationRecordService.getLatestRecordByApplicationId("root")).thenReturn(Mono.just(version(dslWithModules("livemodule")))); + when(s.repository.findByIdIn(any())).thenAnswer(invocation -> { + Collection requested = invocation.getArgument(0); + return Flux.fromIterable(List.of(liveModule, editingModule)).filter(a -> requested.contains(a.getId())); + }); + + assertThat(service.getAllDependentModulesFromApplication(root, true).block(TIMEOUT)).extracting(Application::getId).containsExactly("livemodule"); + assertThat(service.getAllDependentModulesFromApplication(root, false).block(TIMEOUT)).extracting(Application::getId).containsExactly("editingmodule"); + } + + /** Catches: a flag setter writing another field or another id. */ + @Test + void publicToAllAndAgencyProfileWriteOnlyTheirOwnFlag() { + when(s.mongoUpsertHelper.updateById(any(Application.class), anyString())).thenReturn(Mono.just(true)); + + assertThat(service.setApplicationPublicToAll("a1", true).block(TIMEOUT)).isTrue(); + assertThat(service.setApplicationAsAgencyProfile("a2", true).block(TIMEOUT)).isTrue(); + + ArgumentCaptor first = ArgumentCaptor.forClass(Application.class); + verify(s.mongoUpsertHelper).updateById(first.capture(), eq("a1")); + assertThat(first.getValue().isPublicToAll()).isTrue(); + assertThat(first.getValue().agencyProfile()).isFalse(); + assertThat(first.getValue().isPublicToMarketplace()).isFalse(); + ArgumentCaptor second = ArgumentCaptor.forClass(Application.class); + verify(s.mongoUpsertHelper).updateById(second.capture(), eq("a2")); + assertThat(second.getValue().agencyProfile()).isTrue(); + assertThat(second.getValue().isPublicToAll()).isFalse(); + } + + /** Catches: the marketplace flag written without the editing DSL, or without the existence check. */ + @Test + void publicToMarketplaceWritesTheFlagAndTheEditingDslAfterAnExistenceCheck() { + Map dsl = new HashMap<>(Map.of("k", "v")); + when(s.repository.findByIdWithDsl("a1")).thenReturn(Mono.just(app("a1", dsl))); + when(s.mongoUpsertHelper.updateById(any(Application.class), eq("a1"))).thenReturn(Mono.just(true)); + + assertThat(service.setApplicationPublicToMarketplace("a1", true).block(TIMEOUT)).isTrue(); + + ArgumentCaptor patch = ArgumentCaptor.forClass(Application.class); + verify(s.mongoUpsertHelper).updateById(patch.capture(), eq("a1")); + assertThat(patch.getValue().isPublicToMarketplace()).isTrue(); + assertThat(patch.getValue().getEditingApplicationDSL()).isEqualTo(dsl); + assertThat(failure(() -> service.setApplicationPublicToMarketplace("nope", true).block(TIMEOUT)).getError()) + .isEqualTo(BizError.NO_RESOURCE_FOUND); + verify(s.mongoUpsertHelper, never()).updateById(any(Application.class), eq("nope")); + } + + private void stubFilterQueries(AtomicInteger publicQ, AtomicInteger privateQ, AtomicInteger marketQ, AtomicInteger agencyQ) { + when(s.repository.findByPublicToAllIsTrueAndIdIn(any())).thenReturn(counted(Flux.just(app("pub")), publicQ)); + when(s.repository.findByCreatedByAndSlugIn(anyString(), any())).thenReturn(Flux.empty()); + when(s.repository.findByCreatedByAndIdIn(anyString(), any())).thenReturn(counted(Flux.just(app("priv")), privateQ)); + when(s.repository.findByPublicToAllIsTrueAndPublicToMarketplaceIsTrueAndIdIn(any())).thenReturn(counted(Flux.just(app("mkt")), marketQ)); + when(s.repository.findByPublicToAllIsTrueAndAgencyProfileIsTrueAndIdIn(any())).thenReturn(counted(Flux.just(app("agy")), agencyQ)); + } + + /** Catches: the request-type switch or the anonymous test routing to a neighbouring query. */ + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void filteredIdsRouteByRequestTypeAndAnonymity(String blankUser) { + AtomicInteger publicQ = new AtomicInteger(); + AtomicInteger privateQ = new AtomicInteger(); + AtomicInteger marketQ = new AtomicInteger(); + AtomicInteger agencyQ = new AtomicInteger(); + stubFilterQueries(publicQ, privateQ, marketQ, agencyQ); + Collection ids = List.of("x1"); + + assertThat(service.getFilteredPublicApplicationIds(ApplicationRequestType.PUBLIC_TO_ALL, ids, blankUser, false).block(TIMEOUT)).containsExactly("pub"); + assertThat(service.getFilteredPublicApplicationIds(ApplicationRequestType.PUBLIC_TO_ALL, ids, null, false).block(TIMEOUT)).containsExactly("pub"); + assertThat(publicQ.get()).isEqualTo(2); + assertThat(privateQ.get()).isZero(); + + assertThat(service.getFilteredPublicApplicationIds(ApplicationRequestType.PUBLIC_TO_ALL, ids, USER, false).block(TIMEOUT)).containsExactly("priv"); + assertThat(privateQ.get()).isEqualTo(1); + assertThat(publicQ.get()).isEqualTo(2); + + assertThat(service.getFilteredPublicApplicationIds(ApplicationRequestType.PUBLIC_TO_MARKETPLACE, ids, USER, false).block(TIMEOUT)).containsExactly("mkt"); + assertThat(service.getFilteredPublicApplicationIds(ApplicationRequestType.AGENCY_PROFILE, ids, null, true).block(TIMEOUT)).containsExactly("agy"); + assertThat(marketQ.get()).isEqualTo(1); + assertThat(agencyQ.get()).isEqualTo(1); + } + + /** + * Behaviour: a request type of null is not handled (the switch dereferences it), and the "default" arm of the switch is + * unreachable for the three enum values. A null type is not passed by the callers (ApplicationPermissionHandler:79, :94 + * pass a request type derived from the request). + */ + @Test + void aNullRequestTypeFailsInTheSwitch() { + assertThrows(NullPointerException.class, () -> service.getFilteredPublicApplicationIds(null, List.of("x"), USER, false)); + } + + /** Catches: the marketplace truth table (anonymous x private mode) changed (:301). */ + @Test + void marketplaceIdsAreHiddenOnlyFromAnonymousVisitorsOfAPrivateMarketplace() { + AtomicInteger marketQ = new AtomicInteger(); + when(s.repository.findByPublicToAllIsTrueAndPublicToMarketplaceIsTrueAndIdIn(any())).thenReturn(counted(Flux.just(app("mkt")), marketQ)); + Collection ids = List.of("x1"); + + assertThat(service.getPublicMarketplaceApplicationIds(ids, true, true).blockOptional(TIMEOUT)).isEmpty(); + assertThat(marketQ.get()).isZero(); + assertThat(service.getPublicMarketplaceApplicationIds(ids, true, false).block(TIMEOUT)).containsExactly("mkt"); + assertThat(service.getPublicMarketplaceApplicationIds(ids, false, true).block(TIMEOUT)).containsExactly("mkt"); + assertThat(service.getPublicMarketplaceApplicationIds(ids, false, false).block(TIMEOUT)).containsExactly("mkt"); + assertThat(marketQ.get()).isEqualTo(3); + } + + /** Catches: the gid variant answering ids or the id variant answering gids; the empty set taking the gid variant. */ + @Test + void theFourFiltersAnswerGidsForGidListsAndIdsOtherwise() { + Application withGid = appWithGid("real-id", "the-gid"); + when(s.repository.findByPublicToAllIsTrueAndGidIn(any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByCreatedByAndGidIn(anyString(), any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByPublicToAllIsTrueAndPublicToMarketplaceIsTrueAndGidIn(any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByPublicToAllIsTrueAndAgencyProfileIsTrueAndGidIn(any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByPublicToAllIsTrueAndIdIn(any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByCreatedByAndIdIn(anyString(), any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByPublicToAllIsTrueAndPublicToMarketplaceIsTrueAndIdIn(any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByPublicToAllIsTrueAndAgencyProfileIsTrueAndIdIn(any())).thenReturn(Flux.just(withGid)); + Collection gids = List.of("aa-bb"); + Collection ids = List.of("plain"); + + assertThat(s.service.getPublicApplicationIds(gids).block(TIMEOUT)).containsExactly("the-gid"); + assertThat(s.service.getPrivateApplicationIds(gids, USER).block(TIMEOUT)).containsExactly("the-gid"); + assertThat(s.service.getPublicMarketplaceApplicationIds(gids, false, false).block(TIMEOUT)).containsExactly("the-gid"); + assertThat(s.service.getPublicAgencyApplicationIds(gids).block(TIMEOUT)).containsExactly("the-gid"); + + assertThat(s.service.getPublicApplicationIds(ids).block(TIMEOUT)).containsExactly("real-id"); + assertThat(s.service.getPrivateApplicationIds(ids, USER).block(TIMEOUT)).containsExactly("real-id"); + assertThat(s.service.getPublicMarketplaceApplicationIds(ids, false, false).block(TIMEOUT)).containsExactly("real-id"); + assertThat(s.service.getPublicAgencyApplicationIds(ids).block(TIMEOUT)).containsExactly("real-id"); + assertThat(s.service.getPublicApplicationIds(List.of()).block(TIMEOUT)).containsExactly("real-id"); + assertThat(s.service.getPublicAgencyApplicationIds(List.of()).block(TIMEOUT)).containsExactly("real-id"); + } + + /** Catches: the private route not trying slugs first, a slug hit not preventing the id query (:287). */ + @Test + void privateIdsTrySlugsFirstAndAnswerTheRealIds() { + AtomicInteger idQueries = new AtomicInteger(); + when(s.repository.findByCreatedByAndSlugIn(eq(USER), any())).thenReturn(Flux.just(app("by-slug-id"))); + when(s.repository.findByCreatedByAndIdIn(anyString(), any())).thenReturn(counted(Flux.just(app("by-id")), idQueries)); + + assertThat(service.getPrivateApplicationIds(List.of("someslug"), USER).block(TIMEOUT)).containsExactly("by-slug-id"); + assertThat(idQueries.get()).isZero(); + } + + /** Catches: updateLastEditedAt writing another time or user, saving nothing, answering true for an unknown application. */ + @Test + void updateLastEditedAtStoresTimeAndUserAndAnswersWhetherAnApplicationWasFound() { + Application application = app("a1"); + Instant time = Instant.parse("2026-02-03T04:05:06Z"); + when(s.repository.findByIdIn(List.of("a1"))).thenReturn(Flux.just(application)); + when(s.repository.save(any(Application.class))).thenAnswer(invocation -> Mono.just(invocation.getArgument(0))); + + assertThat(service.updateLastEditedAt("a1", time, USER).block(TIMEOUT)).isTrue(); + + verify(s.repository).save(application); + assertThat(application.getLastEditedAt()).isEqualTo(time); + assertThat(application.getEditingUserId()).isEqualTo(USER); + assertThat(service.updateLastEditedAt("unknown", time, USER).block(TIMEOUT)).isFalse(); + } + + /** Catches: the live DSL not being the latest record's, the editing fallback missing, an unknown id not failing. */ + @Test + void liveDslIsTheLatestRecordsAndFallsBackToTheEditingDsl() { + Map recordDsl = Map.of("from", "record"); + Map editingDsl = Map.of("from", "editing"); + when(s.applicationRecordService.getLatestRecordByApplicationId("with-record")).thenReturn(Mono.just(version(recordDsl))); + when(s.repository.findByIdWithDsl("without-record")).thenReturn(Mono.just(app("without-record", new HashMap<>(editingDsl)))); + + assertThat(service.getLiveDSLByApplicationId("with-record").block(TIMEOUT)).isEqualTo(recordDsl); + assertThat(service.getLiveDSLByApplicationId("without-record").block(TIMEOUT)).isEqualTo(editingDsl); + assertThat(failure(() -> service.getLiveDSLByApplicationId("nope").block(TIMEOUT)).getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + } + + /** Catches: the slug checks in a different order, a duplicate asked in another org, the slug not saved, an unknown application answering. */ + @Test + void updateSlugValidatesRejectsDuplicatesInTheSameOrgAndSaves() { + Application application = Application.builder().organizationId("org-1").build(); + application.setId("a1"); + when(s.repository.findById("a1")).thenReturn(Mono.just(application)); + when(s.repository.existsByOrganizationIdAndSlug("org-1", "free")).thenReturn(Mono.just(false)); + when(s.repository.existsByOrganizationIdAndSlug("org-1", "taken")).thenReturn(Mono.just(true)); + when(s.repository.existsByOrganizationIdAndSlug("org-1", "bad slug!")).thenReturn(Mono.just(true)); + when(s.repository.save(any(Application.class))).thenAnswer(invocation -> Mono.just(invocation.getArgument(0))); + + assertThat(failure(() -> service.updateSlug("a1", "bad slug!").block(TIMEOUT)).getError()).as("invalid wins over duplicate").isEqualTo(BizError.SLUG_INVALID); + assertThat(failure(() -> service.updateSlug("a1", "taken").block(TIMEOUT)).getError()).isEqualTo(BizError.SLUG_DUPLICATE_ENTRY); + verify(s.repository, never()).save(any(Application.class)); + + Application saved = service.updateSlug("a1", "free").block(TIMEOUT); + assertThat(saved.getSlug()).isEqualTo("free"); + verify(s.repository).save(application); + assertThat(service.updateSlug("unknown", "free").blockOptional(TIMEOUT)).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplTestSupport.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplTestSupport.java new file mode 100644 index 0000000000..9ff83fb792 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/application/service/ApplicationServiceImplTestSupport.java @@ -0,0 +1,99 @@ +package org.lowcoder.domain.application.service; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationVersion; +import org.lowcoder.domain.application.repository.ApplicationRepository; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.user.repository.UserRepository; +import org.lowcoder.infra.mongo.MongoUpsertHelper; +import org.mockito.Mockito; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Shared set-up of the ApplicationServiceImpl tests (task L3-12): a repository mock whose unstubbed Flux/Mono methods complete + * empty (a plain mock returns null, which the service would fail on before the branch under test), and helpers to build + * applications and to count how often a repository answer was subscribed to (a query that was built but never subscribed is + * not run: switchIfEmpty only subscribes its fallback when needed). The class is not Spring-managed, so the + * {@code @NonEmptyMono} aspect on the filter methods is not applied: the tests see the unadvised behaviour. + */ +final class ApplicationServiceImplTestSupport { + + final ApplicationRepository repository = Mockito.mock(ApplicationRepository.class, invocation -> { + Class type = invocation.getMethod().getReturnType(); + if (type == Flux.class) { + return Flux.empty(); + } + if (type == Mono.class) { + return Mono.empty(); + } + return null; + }); + final MongoUpsertHelper mongoUpsertHelper = Mockito.mock(MongoUpsertHelper.class); + final ResourcePermissionService resourcePermissionService = Mockito.mock(ResourcePermissionService.class); + final UserRepository userRepository = Mockito.mock(UserRepository.class); + final ApplicationRecordService applicationRecordService = Mockito.mock(ApplicationRecordService.class); + final ApplicationServiceImpl service; + + ApplicationServiceImplTestSupport() { + Mockito.when(applicationRecordService.getLatestRecordByApplicationId(Mockito.anyString())).thenReturn(Mono.empty()); + service = new ApplicationServiceImpl(mongoUpsertHelper, resourcePermissionService, repository, userRepository, + applicationRecordService); + } + + static Application app(String id, Map editingDsl) { + Application application = Application.builder().editingApplicationDSL(editingDsl).build(); + application.setId(id); + return application; + } + + static Application app(String id) { + return app(id, new HashMap<>()); + } + + static Application appWithGid(String id, String gid) { + Application application = Application.builder().gid(gid).editingApplicationDSL(new HashMap<>()).build(); + application.setId(id); + return application; + } + + /** A DSL that embeds the given applications as modules (ApplicationUtil.getDependentModulesFromDsl reads compType module / comp.appId). */ + static Map dslWithModules(String... moduleIds) { + List items = new java.util.ArrayList<>(); + for (String moduleId : moduleIds) { + Map comp = new HashMap<>(); + comp.put("appId", moduleId); + Map item = new HashMap<>(); + item.put("compType", "module"); + item.put("comp", comp); + items.add(item); + } + Map dsl = new HashMap<>(); + dsl.put("ui", Map.of("items", items)); + return dsl; + } + + static ApplicationVersion version(Map dsl) { + return ApplicationVersion.builder().applicationDSL(dsl).build(); + } + + /** The answer, counting every subscription to it in the returned counter. */ + static Flux counted(Flux flux, AtomicInteger subscriptions) { + return flux.doOnSubscribe(s -> subscriptions.incrementAndGet()); + } + + static Mono counted(Mono mono, AtomicInteger subscriptions) { + return mono.doOnSubscribe(s -> subscriptions.incrementAndGet()); + } + + static Collection ids(String... ids) { + return List.of(ids); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/asset/service/AssetServiceImplTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/asset/service/AssetServiceImplTest.java new file mode 100644 index 0000000000..d0901c1e99 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/asset/service/AssetServiceImplTest.java @@ -0,0 +1,362 @@ +package org.lowcoder.domain.asset.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.domain.asset.model.Asset; +import org.lowcoder.sdk.config.dynamic.Conf; +import org.lowcoder.sdk.config.dynamic.ConfigCenter; +import org.lowcoder.sdk.config.dynamic.ConfigInstance; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.Part; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * AssetServiceImpl (unit U13, task L3-10): upload validation, size estimate, thumbnails with real ImageIO, and the + * image response. The repository is a mock whose save echoes the asset. + */ +class AssetServiceImplTest { + + private static final int DEFAULT_DIMENSION = 128; + private static final int SMALL_DIMENSION = 32; + private static final int CHUNK_KB = 4; + private static final int CHUNK_BYTES = CHUNK_KB * 1024; + private static final int MAX_KB = 4; + private static final Duration TIMEOUT = Duration.ofSeconds(10); + private static final String ASSET_ID = "asset-1"; + private static final byte[] NOT_AN_IMAGE = "".getBytes(StandardCharsets.UTF_8); + + private final AssetRepository repository = mock(AssetRepository.class); + private final AtomicInteger dimension = new AtomicInteger(DEFAULT_DIMENSION); + private final AssetServiceImpl service = newService(repository, dimension); + private final DefaultDataBufferFactory buffers = new DefaultDataBufferFactory(); + + private static AssetServiceImpl newService(AssetRepository repository, AtomicInteger dimension) { + ConfigCenter center = mock(ConfigCenter.class); + ConfigInstance asset = mock(ConfigInstance.class); + Conf conf = dimension::get; + when(center.asset()).thenReturn(asset); + when(asset.ofInteger("thumbNailPhotoDimension", DEFAULT_DIMENSION)).thenReturn(conf); + when(repository.save(any(Asset.class))).thenAnswer(invocation -> Mono.just(invocation.getArgument(0))); + return new AssetServiceImpl(repository, center); + } + + private Part part(MediaType type, DataBuffer... content) { + Part part = mock(Part.class); + HttpHeaders headers = new HttpHeaders(); + if (type != null) { + headers.setContentType(type); + } + when(part.headers()).thenReturn(headers); + when(part.content()).thenReturn(Flux.just(content)); + return part; + } + + private DataBuffer buffer(byte[] bytes) { + return buffers.wrap(bytes); + } + + private DataBuffer buffer(int size) { + return buffers.wrap(new byte[size]); + } + + private static byte[] image(String format, int width, int height, int type, Color fill) throws Exception { + BufferedImage image = new BufferedImage(width, height, type); + java.awt.Graphics2D graphics = image.createGraphics(); + if (fill != null) { + graphics.setColor(fill); + graphics.fillRect(0, 0, width, height); + } + graphics.dispose(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, format, out); + return out.toByteArray(); + } + + private static byte[] png(int width, int height) throws Exception { + return image("png", width, height, BufferedImage.TYPE_INT_RGB, Color.RED); + } + + private static byte[] jpeg(int width, int height) throws Exception { + return image("jpg", width, height, BufferedImage.TYPE_INT_RGB, Color.BLUE); + } + + private static void assertBizError(Throwable error, BizError expected, String messageKey) { + assertThat(error).isInstanceOf(BizException.class); + BizException biz = (BizException) error; + System.out.println("[AssetServiceImplTest] error " + biz.getError() + " / " + biz.getMessageKey()); + assertThat(biz.getError()).isEqualTo(expected); + assertThat(biz.getMessageKey()).isEqualTo(messageKey); + } + + private Asset uploaded(Part part, int maxKb, boolean thumbnail) { + return service.upload(part, maxKb, thumbnail).block(TIMEOUT); + } + + private static String formatOf(byte[] data) throws Exception { + try (ImageInputStream stream = ImageIO.createImageInputStream(new ByteArrayInputStream(data))) { + Iterator readers = ImageIO.getImageReaders(stream); + return readers.next().getFormatName(); + } + } + + // ---------------------------------------------------------------- validation + + /** Catches: a missing part causing an NPE instead of a clean FILE_EMPTY error. */ + @Test + void aNullPartIsFileEmptyAndNothingIsSaved() { + StepVerifier.create(service.upload(null, MAX_KB, false)) + .expectErrorSatisfies(e -> assertBizError(e, BizError.INVALID_PARAMETER, "FILE_EMPTY")) + .verify(TIMEOUT); + verify(repository, never()).save(any()); + } + + /** Catches: an SVG or HTML upload being accepted (stored XSS, the reason for the allow-list at :59-60). */ + @ParameterizedTest(name = "{0} is rejected") + @ValueSource(strings = {"image/svg+xml", "image/gif", "text/html", "application/octet-stream"}) + void onlyJpegAndPngAreAccepted(String rejected) { + Part part = part(MediaType.parseMediaType(rejected), buffer(10)); + StepVerifier.create(service.upload(part, MAX_KB, false)) + .expectErrorSatisfies(e -> assertBizError(e, BizError.INVALID_PARAMETER, "INCORRECT_IMAGE_TYPE")) + .verify(TIMEOUT); + verify(part, never()).content(); + verify(repository, never()).save(any()); + } + + @Test + void aMissingContentTypeIsRejectedWithoutReadingTheContent() { + Part part = part(null, buffer(10)); + StepVerifier.create(service.upload(part, MAX_KB, false)) + .expectErrorSatisfies(e -> assertBizError(e, BizError.INVALID_PARAMETER, "INCORRECT_IMAGE_TYPE")) + .verify(TIMEOUT); + verify(part, never()).content(); + } + + // ---------------------------------------------------------------- size estimate (chunk count) + + /** Catches: an off-by-one or a lost multiplier in the size limit: 2 chunks are 8 KB, 1 chunk is exactly the limit. */ + @Test + void sizeLimitComparesChunkCountTimesFourKbWithTheLimit() { + Part tooBig = part(MediaType.IMAGE_PNG, buffer(CHUNK_BYTES), buffer(CHUNK_BYTES)); + StepVerifier.create(service.upload(tooBig, MAX_KB, false)) + .expectErrorSatisfies(e -> { + assertBizError(e, BizError.PAYLOAD_TOO_LARGE, "PAYLOAD_TOO_LARGE"); + assertThat(((BizException) e).getArgs()).containsExactly(MAX_KB); + }) + .verify(TIMEOUT); + verify(repository, never()).save(any()); + + Part atLimit = part(MediaType.IMAGE_PNG, buffer(CHUNK_BYTES)); + assertThat(uploaded(atLimit, MAX_KB, false)).isNotNull(); + System.out.println("[AssetServiceImplTest] 2 chunks rejected, 1 chunk at the limit accepted"); + } + + /** + * Pins plan section 9 row "upload size limit estimated as chunks x 4 KB (:71-73), not bytes: one large buffer + * passes, many tiny ones are rejected". Names say "chunk count" on purpose: the real effect depends on the chunk + * size of the multipart reader. A fix (count bytes) changes this test on purpose. + */ + @Test + void pinsTheSection9Row_chunkCountDecidesNotBytes_oneLargeBufferPasses() { + int oneMegabyte = 1024 * 1024; + Part large = part(MediaType.IMAGE_PNG, buffer(oneMegabyte)); + + Asset saved = uploaded(large, 100, false); + System.out.println("[AssetServiceImplTest] PINNED: 1 MB in one chunk accepted with a 100 KB limit, saved " + saved.getData().length + " bytes"); + assertThat(saved.getData()).hasSize(oneMegabyte); + } + + @Test + void pinsTheSection9Row_chunkCountDecidesNotBytes_manyTinyBuffersAreRejected() { + DataBuffer[] tiny = new DataBuffer[1000]; + for (int i = 0; i < tiny.length; i++) { + tiny[i] = buffer(1); + } + Part part = part(MediaType.IMAGE_PNG, tiny); + + StepVerifier.create(service.upload(part, 100, false)) + .expectErrorSatisfies(e -> assertBizError(e, BizError.PAYLOAD_TOO_LARGE, "PAYLOAD_TOO_LARGE")) + .verify(TIMEOUT); + System.out.println("[AssetServiceImplTest] PINNED: 1000 bytes in 1000 chunks rejected with a 100 KB limit"); + } + + /** Catches: an upload without any content crashing on the missing count or buffer; asserted as observed. */ + @Test + void anUploadWithoutContentSavesNothing() { + Part part = part(MediaType.IMAGE_PNG); + StepVerifier.create(service.upload(part, MAX_KB, false)).verifyComplete(); + verify(repository, never()).save(any()); + } + + // ---------------------------------------------------------------- content handling + + /** Catches: logos being re-encoded or retyped; exactly the uploaded bytes are stored with the declared type. */ + @Test + void aNonThumbnailUploadKeepsTheBytesAndTheContentType() throws Exception { + byte[] original = png(40, 20); + Asset saved = uploaded(part(MediaType.IMAGE_PNG, buffer(original)), MAX_KB, false); + + assertThat(saved.getData()).isEqualTo(original); + assertThat(saved.getContentType()).isEqualTo(MediaType.IMAGE_PNG_VALUE); + verify(repository).save(any(Asset.class)); + } + + /** + * Behaviour first (candidate C): the bytes of a non-thumbnail upload are not checked, only the declared type is. + * An HTML payload declared as image/png is stored and later served with that content type. + */ + @Test + void aNonThumbnailUploadStoresAnyBytesUnderTheDeclaredImageType() { + Asset saved = uploaded(part(MediaType.IMAGE_PNG, buffer(NOT_AN_IMAGE)), MAX_KB, false); + System.out.println("[AssetServiceImplTest] stored " + new String(saved.getData(), StandardCharsets.UTF_8) + " as " + saved.getContentType()); + assertThat(saved.getData()).isEqualTo(NOT_AN_IMAGE); + assertThat(saved.getContentType()).isEqualTo(MediaType.IMAGE_PNG_VALUE); + } + + // ---------------------------------------------------------------- thumbnails + + /** Catches: a wrong thumbnail format (must be JPEG whatever came in) or size (a square of the configured side). */ + @Test + void aThumbnailIsAJpegSquareOfTheConfiguredDimensionForPngAndJpegInput() throws Exception { + byte[][] inputs = {png(300, 100), jpeg(300, 100)}; + MediaType[] types = {MediaType.IMAGE_PNG, MediaType.IMAGE_JPEG}; + for (int i = 0; i < inputs.length; i++) { + Asset saved = uploaded(part(types[i], buffer(inputs[i])), 1024, true); + BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(saved.getData())); + System.out.println("[AssetServiceImplTest] thumbnail of " + types[i] + ": " + decoded.getWidth() + "x" + + decoded.getHeight() + " " + saved.getContentType()); + assertThat(saved.getContentType()).isEqualTo(MediaType.IMAGE_JPEG_VALUE); + assertThat(formatOf(saved.getData())).isEqualToIgnoringCase("JPEG"); + assertThat(decoded.getWidth()).isEqualTo(DEFAULT_DIMENSION); + assertThat(decoded.getHeight()).isEqualTo(DEFAULT_DIMENSION); + } + + dimension.set(SMALL_DIMENSION); + Asset small = uploaded(part(MediaType.IMAGE_PNG, buffer(png(300, 100))), 1024, true); + BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(small.getData())); + assertThat(decoded.getWidth()).isEqualTo(SMALL_DIMENSION); + assertThat(decoded.getHeight()).isEqualTo(SMALL_DIMENSION); + } + + /** Observation as behaviour: fully transparent pixels become black in the thumbnail (background Color(0,0,0)). */ + @Test + void transparentPixelsOfAThumbnailBecomeBlack() throws Exception { + byte[] transparent = image("png", 64, 64, BufferedImage.TYPE_INT_ARGB, null); + Asset saved = uploaded(part(MediaType.IMAGE_PNG, buffer(transparent)), 1024, true); + + int rgb = ImageIO.read(new ByteArrayInputStream(saved.getData())).getRGB(10, 10); + System.out.println("[AssetServiceImplTest] transparent pixel became rgb=" + Integer.toHexString(rgb & 0xFFFFFF)); + assertThat((rgb >> 16) & 0xFF).isLessThan(10); + assertThat((rgb >> 8) & 0xFF).isLessThan(10); + assertThat(rgb & 0xFF).isLessThan(10); + } + + /** + * Pins plan section 9 row "avatar upload of non-image bytes declared as png/jpeg fails with a NullPointerException + * (ImageIO.read null; catch :81 handles IOException only), not IMAGE_PARSE_ERROR". Reachable through the avatar + * upload, UserServiceImpl:202 (isThumbnail=true). A fix (a null check raising IMAGE_PARSE_ERROR) changes this test on + * purpose. + */ + @Test + void thumbnailOfNonImageBytesFailsWithANullPointerException_pinsTheSection9Row() { + StepVerifier.create(service.upload(part(MediaType.IMAGE_PNG, buffer(NOT_AN_IMAGE)), 1024, true)) + .expectErrorSatisfies(e -> { + System.out.println("[AssetServiceImplTest] PINNED: thumbnail of non-image bytes fails with " + e.getClass().getSimpleName()); + assertThat(e).isInstanceOf(NullPointerException.class).isNotInstanceOf(BizException.class); + }) + .verify(TIMEOUT); + verify(repository, never()).save(any()); + } + + // ---------------------------------------------------------------- repository delegation and response + + @Test + void getByIdAndRemoveDelegateToTheRepository() { + Asset asset = Asset.from(MediaType.IMAGE_PNG, new byte[] {1}); + when(repository.findById(ASSET_ID)).thenReturn(Mono.just(asset)); + when(repository.deleteById(ASSET_ID)).thenReturn(Mono.empty()); + + StepVerifier.create(service.getById(ASSET_ID)).expectNext(asset).verifyComplete(); + StepVerifier.create(service.remove(ASSET_ID)).verifyComplete(); + verify(repository).deleteById(ASSET_ID); + } + + /** Catches: a wrong status, content type header or body in the image response. */ + @Test + void theImageResponseCarriesStatusContentTypeAndTheStoredBytes() { + byte[] data = "binary-image-bytes".getBytes(StandardCharsets.UTF_8); + when(repository.findById(ASSET_ID)).thenReturn(Mono.just(Asset.from(MediaType.IMAGE_PNG, data))); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/assets/" + ASSET_ID)); + + StepVerifier.create(service.makeImageResponse(exchange, ASSET_ID)).verifyComplete(); + + System.out.println("[AssetServiceImplTest] response " + exchange.getResponse().getStatusCode() + " " + + exchange.getResponse().getHeaders().getContentType()); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(exchange.getResponse().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)).isEqualTo(MediaType.IMAGE_PNG_VALUE); + assertThat(exchange.getResponse().getBodyAsString().block(TIMEOUT)).isEqualTo("binary-image-bytes"); + } + + /** Catches: an NPE for legacy assets stored without a content type; no header is set but the bytes are written. */ + @Test + void anAssetWithoutContentTypeIsServedWithoutAContentTypeHeader() { + Asset legacy = Asset.from(null, "x".getBytes(StandardCharsets.UTF_8)); + when(repository.findById(ASSET_ID)).thenReturn(Mono.just(legacy)); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/assets/" + ASSET_ID)); + + StepVerifier.create(service.makeImageResponse(exchange, ASSET_ID)).verifyComplete(); + + assertThat(exchange.getResponse().getHeaders().containsKey(HttpHeaders.CONTENT_TYPE)).isFalse(); + assertThat(exchange.getResponse().getBodyAsString().block(TIMEOUT)).isEqualTo("x"); + } + + /** + * Pins plan section 9 row "GET of an unknown asset id answers 200 with an empty body, not 404 (makeImageResponse + * completes empty; AssetController:21)": nothing is written and no status is set, so the framework answers 200. + * A fix (switchIfEmpty to a not-found error) changes this test on purpose. + */ + @Test + void anUnknownAssetCompletesWithoutWritingAnything_pinsTheSection9Row() { + when(repository.findById(ASSET_ID)).thenReturn(Mono.empty()); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/assets/" + ASSET_ID)); + + StepVerifier.create(service.makeImageResponse(exchange, ASSET_ID)).verifyComplete(); + + System.out.println("[AssetServiceImplTest] PINNED: unknown asset, status=" + exchange.getResponse().getStatusCode() + + " committed=" + exchange.getResponse().isCommitted()); + assertThat(exchange.getResponse().getStatusCode()).isNull(); + assertThat(exchange.getResponse().isCommitted()).isFalse(); + assertThat(exchange.getResponse().getHeaders().containsKey(HttpHeaders.CONTENT_TYPE)).isFalse(); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleNotFoundMessagePinTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleNotFoundMessagePinTest.java new file mode 100644 index 0000000000..90a19a618a --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleNotFoundMessagePinTest.java @@ -0,0 +1,65 @@ +package org.lowcoder.domain.bundle.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.util.LocaleUtils; + +import reactor.core.publisher.Flux; + +/** + * Pins plan section 9 row G6 (task L3-12b): the message keys BundleServiceImpl uses for a bundle that does not exist, + * BUNDLE_NOT_FOUND (BundleServiceImpl:54, :57; BundleApiServiceImpl:553) and CANT_FIND_BUNDLE (BundleServiceImpl:76), are in none + * of locale_en, locale_de and locale_zh.properties. LocaleUtils.getMessage then logs "message key not exist" and answers the + * text of INTERNAL_SERVER_ERROR ("Oops! Service is busy, please try again later."), so a user asking for an unknown bundle is + * told the service is busy. Same shape as the L3-7 JS code display name row. A fix (add the keys to the three locale files) + * changes this test on purpose. + */ +class BundleNotFoundMessagePinTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + private static final String INTERNAL_SERVER_ERROR_KEY = "INTERNAL_SERVER_ERROR"; + private static final String INTERNAL_SERVER_ERROR_EN = "Oops! Service is busy, please try again later."; + + @ParameterizedTest + @CsvSource({ + "BUNDLE_NOT_FOUND,en", "BUNDLE_NOT_FOUND,de", "BUNDLE_NOT_FOUND,zh", + "CANT_FIND_BUNDLE,en", "CANT_FIND_BUNDLE,de", "CANT_FIND_BUNDLE,zh"}) + void theBundleNotFoundKeysAnswerTheInternalServerErrorText_pinsTheSection9Row(String key, String language) { + Locale locale = Locale.forLanguageTag(language); + + String message = LocaleUtils.getMessage(locale, key); + String internalServerError = LocaleUtils.getMessage(locale, INTERNAL_SERVER_ERROR_KEY); + + System.out.println("[BundleNotFoundMessagePinTest] PINNED " + key + " in " + language + ": " + message); + assertThat(message).isEqualTo(internalServerError); + assertThat(internalServerError).as("the INTERNAL_SERVER_ERROR text exists in this language").isNotBlank(); + } + + @Test + void anUnknownBundleIsReportedToTheUserAsABusyService_pinsTheSection9Row() { + BundleServiceImplTestSupport s = new BundleServiceImplTestSupport(); + org.mockito.Mockito.when(s.repository.findById("unknown")).thenReturn(reactor.core.publisher.Mono.empty()); + org.mockito.Mockito.when(s.repository.findByGid("un-known")).thenReturn(Flux.empty()); + + for (String id : new String[] {"unknown", "un-known"}) { + BizException failure = assertThrows(BizException.class, () -> s.service.findById(id).block(TIMEOUT)); + assertThat(failure.getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + assertThat(failure.getMessageKey()).isEqualTo("BUNDLE_NOT_FOUND"); + assertThat(failure.getMessage()).isEqualTo(INTERNAL_SERVER_ERROR_EN); + assertThat(failure.getMessage(Locale.GERMAN)).isEqualTo(LocaleUtils.getMessage(Locale.GERMAN, INTERNAL_SERVER_ERROR_KEY)); + assertThat(failure.getMessage(Locale.CHINESE)).isEqualTo(LocaleUtils.getMessage(Locale.CHINESE, INTERNAL_SERVER_ERROR_KEY)); + } + BizException withoutDsl = assertThrows(BizException.class, () -> s.service.findByIdWithoutDsl("unknown").block(TIMEOUT)); + assertThat(withoutDsl.getMessageKey()).isEqualTo("CANT_FIND_BUNDLE"); + assertThat(withoutDsl.getMessage()).isEqualTo(INTERNAL_SERVER_ERROR_EN); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleServiceImplIdKindFamilyPinTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleServiceImplIdKindFamilyPinTest.java new file mode 100644 index 0000000000..3cc82bce23 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleServiceImplIdKindFamilyPinTest.java @@ -0,0 +1,73 @@ +package org.lowcoder.domain.bundle.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.domain.bundle.service.BundleServiceImplTestSupport.bundle; +import static org.lowcoder.domain.bundle.service.BundleServiceImplTestSupport.bundleWithGid; +import static org.lowcoder.domain.bundle.service.BundleServiceImplTestSupport.counted; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.bundle.model.Bundle; + +import reactor.core.publisher.Flux; + +/** + * Pins the bundle sites of the section 9 family row (task L3-12, G3): "id-or-gid routing decided from one element drops the + * other kind" (same cause as DatasourceRepository.findByIds, L3-11c, LibraryQueryServiceImpl.getByIds, L3-11d, and the + * application sites in ApplicationServiceImplIdKindFamilyPinTest). BundleServiceImpl.findByIdIn (:62-66) and the four filters + * (:138, :158, :182, :202) look at the first element only. Reach: BundlePermissionHandler.java:79 and :87 pass the resource ids + * of the permission check to getFilteredPublicBundleIds, and MetaController.java:51 passes the client's bundleIds to + * findByIdIn. A fix (query both kinds) changes these tests on purpose. + */ +class BundleServiceImplIdKindFamilyPinTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final BundleServiceImplTestSupport s = new BundleServiceImplTestSupport(); + + @Test + @SuppressWarnings("unchecked") + void findByIdInOfAGidAndAnIdQueriesOnlyTheKindOfTheFirstElement_pinsTheFamilyRow() { + AtomicInteger idQueries = new AtomicInteger(); + AtomicInteger gidQueries = new AtomicInteger(); + when(s.repository.findAllByGid(any())).thenReturn(counted(Flux.just(bundle("by-gid")), gidQueries)); + when(s.repository.findAllById(any(Iterable.class))).thenReturn(counted(Flux.just(bundle("by-id")), idQueries)); + + List gidFirst = s.service.findByIdIn(List.of("aa-bb", "plainid")).collectList().block(TIMEOUT); + assertThat(gidFirst).extracting(Bundle::getId).containsExactly("by-gid"); + assertThat(idQueries.get()).as("the id of a gid-first list is never looked up").isZero(); + + List idFirst = s.service.findByIdIn(List.of("plainid", "aa-bb")).collectList().block(TIMEOUT); + System.out.println("[BundleServiceImplIdKindFamilyPinTest] PINNED gid first -> " + gidFirst.size() + ", id first -> " + idFirst.size() + + " (gid queries " + gidQueries.get() + ", id queries " + idQueries.get() + ")"); + assertThat(idFirst).extracting(Bundle::getId).containsExactly("by-id"); + assertThat(gidQueries.get()).as("the gid of an id-first list is never looked up").isEqualTo(1); + } + + @Test + void thePublicIdFilterOfAMixedSetAnswersOnlyTheKindOfTheFirstElement_pinsTheFamilyRow() { + Bundle withGid = bundleWithGid("real-id", "the-gid"); + AtomicInteger idQueries = new AtomicInteger(); + AtomicInteger gidQueries = new AtomicInteger(); + when(s.repository.findByPublicToAllIsTrueAndGidIn(any())).thenReturn(counted(Flux.just(withGid), gidQueries)); + when(s.repository.findByPublicToAllIsTrueAndIdIn(any())).thenReturn(counted(Flux.just(withGid), idQueries)); + Set gidFirst = new LinkedHashSet<>(List.of("aa-bb", "plainid")); + Set idFirst = new LinkedHashSet<>(List.of("plainid", "aa-bb")); + + Set byGidFirst = s.service.getPublicBundleIds(gidFirst).block(TIMEOUT); + Set byIdFirst = s.service.getPublicBundleIds(idFirst).block(TIMEOUT); + + System.out.println("[BundleServiceImplIdKindFamilyPinTest] PINNED public filter: gid first -> " + byGidFirst + ", id first -> " + byIdFirst); + assertThat(byGidFirst).containsExactly("the-gid"); + assertThat(byIdFirst).containsExactly("real-id"); + assertThat(gidQueries.get()).isEqualTo(1); + assertThat(idQueries.get()).isEqualTo(1); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleServiceImplTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleServiceImplTest.java new file mode 100644 index 0000000000..8826cc679a --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleServiceImplTest.java @@ -0,0 +1,265 @@ +package org.lowcoder.domain.bundle.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.domain.bundle.service.BundleServiceImplTestSupport.bundle; +import static org.lowcoder.domain.bundle.service.BundleServiceImplTestSupport.bundleWithEditingDsl; +import static org.lowcoder.domain.bundle.service.BundleServiceImplTestSupport.bundleWithGid; +import static org.lowcoder.domain.bundle.service.BundleServiceImplTestSupport.counted; +import static org.lowcoder.domain.bundle.service.BundleServiceImplTestSupport.dsl; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.domain.bundle.model.Bundle; +import org.lowcoder.domain.bundle.model.BundleRequestType; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.ArgumentCaptor; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * BundleServiceImpl with mocked collaborators (unit U12, task L3-12; the NonEmptyMono aspect is not applied). Not repeated: + * the L1 D6 row (bundle flag setters fail for every user: findByIdWithoutDsl has no gid routing), the family pins in + * BundleServiceImplIdKindFamilyPinTest. deleteAllById and exist are not tested: no caller in main code (owner's list). + */ +class BundleServiceImplTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + private static final String USER = "user-1"; + + private final BundleServiceImplTestSupport s = new BundleServiceImplTestSupport(); + private final BundleServiceImpl service = s.service; + + private static BizException failure(Runnable call) { + return assertThrows(BizException.class, call::run); + } + + /** Catches: null passing through the three lookups, the not-found keys swapped, the gid routing of findById missing. */ + @Test + void lookupsFailForNullAndUnknownWithTheirKeysAndFindByIdRoutesGids() { + Bundle byId = bundle("b1"); + Bundle byGid = bundleWithGid("b2", "aa-bb"); + when(s.repository.findById("b1")).thenReturn(Mono.just(byId)); + when(s.repository.findByGid("aa-bb")).thenReturn(Flux.just(byGid)); + + assertThat(service.findById("b1").block(TIMEOUT)).isSameAs(byId); + assertThat(service.findById("aa-bb").block(TIMEOUT)).isSameAs(byGid); + assertThat(failure(() -> service.findById(null).block(TIMEOUT)).getError()).isEqualTo(BizError.INVALID_PARAMETER); + BizException unknown = failure(() -> service.findById("nope").block(TIMEOUT)); + assertThat(unknown.getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + assertThat(unknown.getMessageKey()).isEqualTo("BUNDLE_NOT_FOUND"); + assertThat(failure(() -> service.findById("no-gid").block(TIMEOUT)).getMessageKey()).isEqualTo("BUNDLE_NOT_FOUND"); + + assertThat(service.findByIdWithoutDsl("b1").block(TIMEOUT)).isSameAs(byId); + assertThat(failure(() -> service.findByIdWithoutDsl(null).block(TIMEOUT)).getError()).isEqualTo(BizError.INVALID_PARAMETER); + assertThat(failure(() -> service.findByIdWithoutDsl("nope").block(TIMEOUT)).getMessageKey()).isEqualTo("CANT_FIND_BUNDLE"); + } + + /** Catches: updateById not rejecting null, not delegating the bundle and id. */ + @Test + void updateByIdRejectsNullAndOtherwiseDelegates() { + Bundle patch = bundle("x"); + when(s.mongoUpsertHelper.updateById(patch, "b1")).thenReturn(Mono.just(true)); + + assertThat(service.updateById("b1", patch).block(TIMEOUT)).isTrue(); + assertThat(failure(() -> service.updateById(null, patch).block(TIMEOUT)).getError()).isEqualTo(BizError.INVALID_PARAMETER); + } + + /** Catches: the findByIdIn routing (:62-66) changed for homogeneous lists. */ + @Test + void findByIdInRoutesGidListsToTheGidQueryAndOthersToTheIdQuery() { + AtomicInteger idQueries = new AtomicInteger(); + AtomicInteger gidQueries = new AtomicInteger(); + when(s.repository.findAllById(any(Iterable.class))).thenReturn(counted(Flux.just(bundle("by-id")), idQueries)); + when(s.repository.findAllByGid(any())).thenReturn(counted(Flux.just(bundle("by-gid")), gidQueries)); + + assertThat(service.findByIdIn(List.of("aa-bb", "cc-dd")).collectList().block(TIMEOUT)).extracting(Bundle::getId).containsExactly("by-gid"); + assertThat(idQueries.get()).isZero(); + assertThat(service.findByIdIn(List.of("plain1", "plain2")).collectList().block(TIMEOUT)).extracting(Bundle::getId).containsExactly("by-id"); + assertThat(service.findByIdIn(List.of()).collectList().block(TIMEOUT)).extracting(Bundle::getId).containsExactly("by-id"); + assertThat(gidQueries.get()).isEqualTo(1); + assertThat(idQueries.get()).isEqualTo(2); + } + + /** Catches: the owner grant before the save or with another id, user, role or type; a failing grant not failing create. */ + @Test + void createSavesThenGrantsOwnerAndFailsWhenTheGrantFails() { + Bundle newBundle = bundle(null); + Bundle saved = bundle("saved-id"); + AtomicBoolean saveSubscribed = new AtomicBoolean(); + when(s.repository.save(newBundle)).thenReturn(Mono.just(saved).doOnSubscribe(x -> saveSubscribed.set(true))); + when(s.resourcePermissionService.addResourcePermissionToUser("saved-id", USER, ResourceRole.OWNER, ResourceType.BUNDLE)) + .thenAnswer(invocation -> { + assertThat(saveSubscribed).as("the grant runs after the save").isTrue(); + return Mono.just(true); + }); + + assertThat(service.create(newBundle, USER).block(TIMEOUT)).isSameAs(saved); + verify(s.resourcePermissionService).addResourcePermissionToUser("saved-id", USER, ResourceRole.OWNER, ResourceType.BUNDLE); + + when(s.resourcePermissionService.addResourcePermissionToUser("saved-id", "bad-user", ResourceRole.OWNER, ResourceType.BUNDLE)) + .thenReturn(Mono.error(new IllegalStateException("grant failed"))); + assertThrows(IllegalStateException.class, () -> service.create(newBundle, "bad-user").block(TIMEOUT)); + } + + /** Catches: a listing delegated to a neighbouring repository method. */ + @Test + void listingsUseTheIntendedRepositoryMethods() { + when(s.repository.findByCreatedBy("u")).thenReturn(Flux.just(bundle("a"))); + when(s.repository.findByPublicToAllIsTrueAndPublicToMarketplaceIsTrue()).thenReturn(Flux.just(bundle("m1"), bundle("m2"))); + when(s.repository.findByPublicToAllIsTrueAndAgencyProfileIsTrue()).thenReturn(Flux.just(bundle("g1"), bundle("g2"), bundle("g3"))); + + assertThat(service.findByUserId("u").collectList().block(TIMEOUT)).hasSize(1); + assertThat(service.findAllMarketplaceBundles().collectList().block(TIMEOUT)).hasSize(2); + assertThat(service.findAllAgencyProfileBundles().collectList().block(TIMEOUT)).hasSize(3); + } + + /** Catches: publish copying the wrong DSL, writing it to the wrong field or bundle, writing for an unknown bundle. */ + @Test + void publishCopiesTheEditingDslToThePublishedDslAndAnUnknownBundleFailsWithoutAWrite() { + Map editing = dsl("editing"); + Bundle loaded = bundleWithEditingDsl("b1", editing); + when(s.repository.findById("b1")).thenReturn(Mono.just(loaded)); + when(s.mongoUpsertHelper.updateById(any(Bundle.class), eq("b1"))).thenReturn(Mono.just(true)); + + assertThat(service.publish("b1").block(TIMEOUT)).isSameAs(loaded); + + ArgumentCaptor patch = ArgumentCaptor.forClass(Bundle.class); + verify(s.mongoUpsertHelper).updateById(patch.capture(), eq("b1")); + assertThat(patch.getValue().getPublishedBundleDSL()).isEqualTo(editing); + assertThat(patch.getValue().getEditingBundleDSL()).isNull(); + assertThat(failure(() -> service.publish("nope").block(TIMEOUT)).getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + verify(s.mongoUpsertHelper, never()).updateById(any(Bundle.class), eq("nope")); + } + + /** Catches: the three flag setters writing another field; the marketplace setter writing for an unknown bundle. */ + @Test + void theFlagSettersWriteOnlyTheirOwnFlagAndMarketplaceChecksTheBundleFirst() { + when(s.mongoUpsertHelper.updateById(any(Bundle.class), anyString())).thenReturn(Mono.just(true)); + when(s.repository.findById("b3")).thenReturn(Mono.just(bundle("b3"))); + + assertThat(service.setBundlePublicToAll("b1", true).block(TIMEOUT)).isTrue(); + assertThat(service.setBundleAsAgencyProfile("b2", true).block(TIMEOUT)).isTrue(); + assertThat(service.setBundlePublicToMarketplace("b3", true).block(TIMEOUT)).isTrue(); + + ArgumentCaptor all = ArgumentCaptor.forClass(Bundle.class); + verify(s.mongoUpsertHelper).updateById(all.capture(), eq("b1")); + assertThat(all.getValue().isPublicToAll()).isTrue(); + assertThat(all.getValue().agencyProfile()).isFalse(); + assertThat(all.getValue().isPublicToMarketplace()).isFalse(); + ArgumentCaptor agency = ArgumentCaptor.forClass(Bundle.class); + verify(s.mongoUpsertHelper).updateById(agency.capture(), eq("b2")); + assertThat(agency.getValue().agencyProfile()).isTrue(); + assertThat(agency.getValue().isPublicToAll()).isFalse(); + ArgumentCaptor marketplace = ArgumentCaptor.forClass(Bundle.class); + verify(s.mongoUpsertHelper).updateById(marketplace.capture(), eq("b3")); + assertThat(marketplace.getValue().isPublicToMarketplace()).isTrue(); + assertThat(marketplace.getValue().isPublicToAll()).isFalse(); + + assertThat(failure(() -> service.setBundlePublicToMarketplace("nope", true).block(TIMEOUT)).getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + verify(s.mongoUpsertHelper, never()).updateById(any(Bundle.class), eq("nope")); + } + + /** Catches: updatePublishedBundleDSL writing another field. */ + @Test + void updatePublishedBundleDslWritesOnlyThePublishedDsl() { + Map published = dsl("published"); + when(s.mongoUpsertHelper.updateById(any(Bundle.class), eq("b1"))).thenReturn(Mono.just(true)); + + assertThat(service.updatePublishedBundleDSL("b1", published).block(TIMEOUT)).isTrue(); + + ArgumentCaptor patch = ArgumentCaptor.forClass(Bundle.class); + verify(s.mongoUpsertHelper).updateById(patch.capture(), eq("b1")); + assertThat(patch.getValue().getPublishedBundleDSL()).isEqualTo(published); + assertThat(patch.getValue().getEditingBundleDSL()).isNull(); + } + + /** Catches: the request-type switch or the anonymous test routing to a neighbouring query. */ + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void filteredIdsRouteByRequestTypeAndAnonymity(String blankUser) { + AtomicInteger publicQ = new AtomicInteger(); + AtomicInteger privateQ = new AtomicInteger(); + when(s.repository.findByPublicToAllIsTrueAndIdIn(any())).thenReturn(counted(Flux.just(bundle("pub")), publicQ)); + when(s.repository.findByCreatedByAndIdIn(anyString(), any())).thenReturn(counted(Flux.just(bundle("priv")), privateQ)); + when(s.repository.findByPublicToAllIsTrueAndPublicToMarketplaceIsTrueAndIdIn(any())).thenReturn(Flux.just(bundle("mkt"))); + when(s.repository.findByPublicToAllIsTrueAndAgencyProfileIsTrueAndIdIn(any())).thenReturn(Flux.just(bundle("agy"))); + Collection ids = List.of("x1"); + + assertThat(service.getFilteredPublicBundleIds(BundleRequestType.PUBLIC_TO_ALL, ids, blankUser, false).block(TIMEOUT)).containsExactly("pub"); + assertThat(service.getFilteredPublicBundleIds(BundleRequestType.PUBLIC_TO_ALL, ids, null, false).block(TIMEOUT)).containsExactly("pub"); + assertThat(publicQ.get()).isEqualTo(2); + assertThat(privateQ.get()).isZero(); + assertThat(service.getFilteredPublicBundleIds(BundleRequestType.PUBLIC_TO_ALL, ids, USER, false).block(TIMEOUT)).containsExactly("priv"); + assertThat(privateQ.get()).isEqualTo(1); + assertThat(service.getFilteredPublicBundleIds(BundleRequestType.PUBLIC_TO_MARKETPLACE, ids, USER, false).block(TIMEOUT)).containsExactly("mkt"); + assertThat(service.getFilteredPublicBundleIds(BundleRequestType.AGENCY_PROFILE, ids, null, true).block(TIMEOUT)).containsExactly("agy"); + } + + /** Behaviour: a request type of null is not handled (the switch dereferences it). */ + @Test + void aNullRequestTypeFailsInTheSwitch() { + assertThrows(NullPointerException.class, () -> service.getFilteredPublicBundleIds(null, List.of("x"), USER, false)); + } + + /** Catches: the marketplace truth table (anonymous x private mode) changed (:180). */ + @Test + void marketplaceIdsAreHiddenOnlyFromAnonymousVisitorsOfAPrivateMarketplace() { + AtomicInteger marketQ = new AtomicInteger(); + when(s.repository.findByPublicToAllIsTrueAndPublicToMarketplaceIsTrueAndIdIn(any())).thenReturn(counted(Flux.just(bundle("mkt")), marketQ)); + Collection ids = List.of("x1"); + + assertThat(service.getPublicMarketplaceBundleIds(ids, true, true).blockOptional(TIMEOUT)).isEmpty(); + assertThat(marketQ.get()).isZero(); + assertThat(service.getPublicMarketplaceBundleIds(ids, true, false).block(TIMEOUT)).containsExactly("mkt"); + assertThat(service.getPublicMarketplaceBundleIds(ids, false, true).block(TIMEOUT)).containsExactly("mkt"); + assertThat(service.getPublicMarketplaceBundleIds(ids, false, false).block(TIMEOUT)).containsExactly("mkt"); + assertThat(marketQ.get()).isEqualTo(3); + } + + /** Catches: the gid variant answering ids or the id variant answering gids; the empty list taking the gid variant. */ + @Test + void theFourFiltersAnswerGidsForGidListsAndIdsOtherwise() { + Bundle withGid = bundleWithGid("real-id", "the-gid"); + when(s.repository.findByPublicToAllIsTrueAndGidIn(any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByCreatedByAndGidIn(anyString(), any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByPublicToAllIsTrueAndPublicToMarketplaceIsTrueAndGidIn(any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByPublicToAllIsTrueAndAgencyProfileIsTrueAndGidIn(any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByPublicToAllIsTrueAndIdIn(any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByCreatedByAndIdIn(anyString(), any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByPublicToAllIsTrueAndPublicToMarketplaceIsTrueAndIdIn(any())).thenReturn(Flux.just(withGid)); + when(s.repository.findByPublicToAllIsTrueAndAgencyProfileIsTrueAndIdIn(any())).thenReturn(Flux.just(withGid)); + Collection gids = List.of("aa-bb"); + Collection ids = List.of("plain"); + + assertThat(service.getPublicBundleIds(gids).block(TIMEOUT)).containsExactly("the-gid"); + assertThat(service.getPrivateBundleIds(gids, USER).block(TIMEOUT)).containsExactly("the-gid"); + assertThat(service.getPublicMarketplaceBundleIds(gids, false, false).block(TIMEOUT)).containsExactly("the-gid"); + assertThat(service.getPublicAgencyBundleIds(gids).block(TIMEOUT)).containsExactly("the-gid"); + + assertThat(service.getPublicBundleIds(ids).block(TIMEOUT)).containsExactly("real-id"); + assertThat(service.getPrivateBundleIds(ids, USER).block(TIMEOUT)).containsExactly("real-id"); + assertThat(service.getPublicMarketplaceBundleIds(ids, false, false).block(TIMEOUT)).containsExactly("real-id"); + assertThat(service.getPublicAgencyBundleIds(ids).block(TIMEOUT)).containsExactly("real-id"); + assertThat(service.getPublicBundleIds(List.of()).block(TIMEOUT)).containsExactly("real-id"); + assertThat(service.getPublicAgencyBundleIds(List.of()).block(TIMEOUT)).containsExactly("real-id"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleServiceImplTestSupport.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleServiceImplTestSupport.java new file mode 100644 index 0000000000..8c2db21d92 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/bundle/service/BundleServiceImplTestSupport.java @@ -0,0 +1,67 @@ +package org.lowcoder.domain.bundle.service; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.lowcoder.domain.bundle.model.Bundle; +import org.lowcoder.domain.bundle.repository.BundleRepository; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.infra.mongo.MongoUpsertHelper; +import org.mockito.Mockito; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Shared set-up of the BundleServiceImpl tests (task L3-12): a repository mock whose unstubbed Flux/Mono methods complete + * empty, bundle builders and a subscription counter. Not Spring-managed: the {@code @NonEmptyMono} aspect is not applied. + */ +final class BundleServiceImplTestSupport { + + final BundleRepository repository = Mockito.mock(BundleRepository.class, invocation -> { + Class type = invocation.getMethod().getReturnType(); + if (type == Flux.class) { + return Flux.empty(); + } + if (type == Mono.class) { + return Mono.empty(); + } + return null; + }); + final MongoUpsertHelper mongoUpsertHelper = Mockito.mock(MongoUpsertHelper.class); + final ResourcePermissionService resourcePermissionService = Mockito.mock(ResourcePermissionService.class); + final BundleServiceImpl service = new BundleServiceImpl(repository, mongoUpsertHelper, resourcePermissionService); + + static Bundle bundle(String id) { + Bundle bundle = Bundle.builder().build(); + bundle.setId(id); + return bundle; + } + + static Bundle bundleWithGid(String id, String gid) { + Bundle bundle = Bundle.builder().gid(gid).build(); + bundle.setId(id); + return bundle; + } + + static Bundle bundleWithEditingDsl(String id, Map dsl) { + Bundle bundle = Bundle.builder().editingBundleDSL(dsl).build(); + bundle.setId(id); + return bundle; + } + + static Map dsl(String marker) { + Map dsl = new HashMap<>(); + dsl.put("marker", marker); + return dsl; + } + + static Flux counted(Flux flux, AtomicInteger subscriptions) { + return flux.doOnSubscribe(s -> subscriptions.incrementAndGet()); + } + + static Mono counted(Mono mono, AtomicInteger subscriptions) { + return mono.doOnSubscribe(s -> subscriptions.incrementAndGet()); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceJsCodeDisplayNamePinTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceJsCodeDisplayNamePinTest.java new file mode 100644 index 0000000000..999bdf36e9 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceJsCodeDisplayNamePinTest.java @@ -0,0 +1,33 @@ +package org.lowcoder.domain.datasource.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.util.LocaleUtils; + +/** + * Pins the plan section 9 row "Datasource.getDisplayName(JS_CODE_ID) returns the INTERNAL_SERVER_ERROR text; key + * JS_CODE_DATASOURCE_NAME in no .properties" (task L3-7). The key is missing from every messages file, so + * {@code LocaleUtils.getMessage} falls back to the INTERNAL_SERVER_ERROR text. A fix (adding the key) changes this + * test on purpose. + */ +class DatasourceJsCodeDisplayNamePinTest { + + private static final String MISSING_KEY = "JS_CODE_DATASOURCE_NAME"; + private static final String INTERNAL_SERVER_ERROR_KEY = "INTERNAL_SERVER_ERROR"; + + @Test + void pinsTheJsCodeDatasourceDisplayNameBeingTheInternalServerErrorText() { + for (Locale locale : new Locale[] {Locale.ENGLISH, Locale.CHINESE}) { + String name = Datasource.getDisplayName(Datasource.JS_CODE_ID, locale); + String internalError = LocaleUtils.getMessage(locale, INTERNAL_SERVER_ERROR_KEY); + System.out.println("[DatasourceJsCodeDisplayNamePinTest] " + locale + " JS code name=[" + name + "]"); + assertThat(name).isEqualTo(internalError).isNotBlank(); + } + assertThat(Datasource.getDisplayName(Datasource.JS_CODE_ID, Locale.ENGLISH)) + .isEqualTo("Oops! Service is busy, please try again later."); + System.out.println("[DatasourceJsCodeDisplayNamePinTest] pinned: key " + MISSING_KEY + " is missing"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceModelLogicTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceModelLogicTest.java new file mode 100644 index 0000000000..cd4dc77ccb --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceModelLogicTest.java @@ -0,0 +1,98 @@ +package org.lowcoder.domain.datasource.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Locale; +import java.util.NoSuchElementException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.lowcoder.sdk.util.LocaleUtils; + +/** + * Decisions of the datasource model (unit U4a, task L3-7): display names, the legacy predicates, the status default + * and the creation-source table. {@code mergeWith} and the shared static datasources are pinned in + * DatasourceServiceImplTest (plan section 9 rows) and are not repeated here. + */ +class DatasourceModelLogicTest { + + private static final String UNKNOWN_ID = "someDatasourceId"; + private static final String CHINESE_REST_NAME = "休息查询"; + + private static Datasource datasource(String type, DatasourceCreationSource source) { + Datasource datasource = new Datasource(); + datasource.setType(type); + datasource.setCreationSource(source.getValue()); + return datasource; + } + + /** Catches: the REST and GraphQL quick datasources swapping their message keys, or the locale being ignored. */ + @Test + void displayNameOfTheQuickDatasourcesComesFromTheirOwnMessageKeyPerLocale() { + String restEn = Datasource.getDisplayName(Datasource.QUICK_REST_API_ID, Locale.ENGLISH); + String graphQlEn = Datasource.getDisplayName(Datasource.QUICK_GRAPHQL_ID, Locale.ENGLISH); + String restZh = Datasource.getDisplayName(Datasource.QUICK_REST_API_ID, Locale.CHINESE); + System.out.println("[DatasourceModelLogicTest] display names: rest=" + restEn + " graphql=" + graphQlEn + " restZh=" + restZh); + + assertThat(restEn).isEqualTo(LocaleUtils.getMessage(Locale.ENGLISH, "QUICK_REST_DATASOURCE_NAME")).isEqualTo("REST Query"); + assertThat(graphQlEn).isEqualTo(LocaleUtils.getMessage(Locale.ENGLISH, "QUICK_GRAPHQL_DATASOURCE_NAME")).isEqualTo("GraphQL Query"); + assertThat(restZh).isEqualTo(CHINESE_REST_NAME).isNotEqualTo(restEn); + } + + /** Catches: an ordinary datasource id being given a quick datasource's display name instead of the empty string. */ + @Test + void displayNameOfAnyOtherIdIsEmpty() { + assertThat(Datasource.getDisplayName(UNKNOWN_ID, Locale.ENGLISH)).isEmpty(); + assertThat(Datasource.getDisplayName(null, Locale.ENGLISH)).isEmpty(); + System.out.println("[DatasourceModelLogicTest] unknown and null ids render as empty"); + } + + /** Catches: the two legacy predicates confused, or the creation source not being part of the decision. */ + @ParameterizedTest(name = "type={0} source={1} -> legacyQuickRest={2} legacyLowcoder={3}") + @CsvSource({ + "restApi,LEGACY_WORKSPACE_PREDEFINED,true,false", + "graphql,LEGACY_WORKSPACE_PREDEFINED,false,true", + "restApi,USER_CREATED,false,false", + "graphql,USER_CREATED,false,false", + "restApi,SYSTEM_STATIC,false,false" + }) + void legacyPredicatesNeedBothTheTypeAndTheLegacyCreationSource(String type, DatasourceCreationSource source, + boolean legacyQuickRest, boolean legacyLowcoder) { + Datasource datasource = datasource(type, source); + System.out.println("[DatasourceModelLogicTest] " + type + "/" + source + " -> quickRest=" + + datasource.isLegacyQuickRestApi() + " lowcoder=" + datasource.isLegacyLowcoderApi()); + + assertThat(datasource.isLegacyQuickRestApi()).isEqualTo(legacyQuickRest); + assertThat(datasource.isLegacyLowcoderApi()).isEqualTo(legacyLowcoder); + } + + /** Catches: a datasource without a stored status being treated as deleted or null. */ + @Test + void statusDefaultsToNormalAndKeepsAnExplicitValue() { + Datasource datasource = new Datasource(); + assertThat(datasource.getDatasourceStatus()).isEqualTo(DatasourceStatus.NORMAL); + + datasource.setDatasourceStatus(DatasourceStatus.DELETED); + assertThat(datasource.getDatasourceStatus()).isEqualTo(DatasourceStatus.DELETED); + System.out.println("[DatasourceModelLogicTest] status default NORMAL, explicit DELETED kept"); + } + + /** Catches: a wrong value table. Note {@code fromValue} is an instance method of the enum, not static. */ + @ParameterizedTest + @CsvSource({"0,USER_CREATED", "1,CLONE_FROM_TEMPLATE", "2,LEGACY_WORKSPACE_PREDEFINED", "3,SYSTEM_STATIC"}) + void creationSourceFromValueRoundTrips(int value, DatasourceCreationSource expected) { + DatasourceCreationSource found = DatasourceCreationSource.USER_CREATED.fromValue(value); + System.out.println("[DatasourceModelLogicTest] fromValue(" + value + ") = " + found); + assertThat(found).isEqualTo(expected); + assertThat(found.getValue()).isEqualTo(value); + } + + /** Catches: an unknown stored value silently mapping to a default source. */ + @Test + void creationSourceFromAnUnknownValueThrows() { + assertThatThrownBy(() -> DatasourceCreationSource.USER_CREATED.fromValue(99)) + .isInstanceOf(NoSuchElementException.class); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/repository/DatasourcePersistenceContractTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/repository/DatasourcePersistenceContractTest.java new file mode 100644 index 0000000000..a69a3e7df5 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/repository/DatasourcePersistenceContractTest.java @@ -0,0 +1,149 @@ +package org.lowcoder.domain.datasource.repository; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceDO; +import org.lowcoder.domain.datasource.service.JsDatasourceHelper; +import org.lowcoder.domain.encryption.EncryptionService; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.JavaValueWalker; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.contract.RepresentativeInput; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.models.JsDatasourceConnectionConfig; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Group {@code persistence-roundtrip} (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T9.4): before a data source is stored, + * {@code DatasourceRepository.save} encrypts its connection config and converts it into the stored {@code Map} as + * {@code fromJsonMap(toJson(encryptedConfig))}. The real repository saves one data source per config of + * {@link #CONFIGS}, with mocked collaborators, and the {@code Map} handed to the store is pinned: the class of every + * value ({@link JavaValueWalker}) and the text the production mapper writes for it, in {@value #REPORT}. The SDK config + * classes are bound by their {@code buildFrom} from the forms of {@value #INPUT}, copies of forms of their T8.1 + * {@code config-binding} input fixtures (the test-jar carries only the contract helpers, not those fixtures); the JS + * plugin config holds the §4.6 representative input and a password parameter. + * + *

Key order: the stored map of a bean config (the SDK classes) has its members in the order the production mapper + * writes the bean, which the JSON contract leaves out (plan §1.2) and which, for getter-only properties such as + * {@code RestApiDatasourceConfig#getAuthType} and {@code #isOauth2InheritFromLogin}, the JVM does not fix (plan §9 + * O18): the pinned key order of these two flipped between two full builds. So their shape keeps the classes but not + * the key order, and their written text is compared with member order only reported ({@code CanonicalJson}). The JS + * plugin config is a map, whose stored key order is its own and is pinned. + * + *

Limits: encryption is a stand-in that marks the text it is given ({@value #ENCRYPTED_PREFIX}), since the real + * cipher is random; the plugin configs of the plugin modules are not on this module's classpath, and only the stored map + * is pinned, not its reading back ({@code DatasourceMetaInfoService.resolveDetailConfig}). + */ +class DatasourcePersistenceContractTest { + + static final String REPORT = "persistence-roundtrip/DatasourceRepository.storedConfigs.json"; + static final String INPUT = "persistence-roundtrip/DatasourceRepository.configs.input.json"; + static final String ENCRYPTED_PREFIX = "encrypted:"; + static final String SHAPE_KEY = "shape"; + static final String WRITTEN_KEY = "written"; + static final String CONFIG_CLASS_KEY = "configClass"; + static final String JS_PLUGIN_TYPE = "contractJsPlugin"; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map> CONFIGS = configs(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-domain/src/main/java/org/lowcoder/domain/datasource/repository/DatasourceRepository.java#DatasourceRepository.encryptDataAndConvertToDataObject#toJson#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/datasource/repository/DatasourceRepository.java#DatasourceRepository.encryptDataAndConvertToDataObject#fromJsonMap#1"}) + @Test + void storedConfigsAsPinned() { + Map report = new LinkedHashMap<>(); + CONFIGS.forEach((name, config) -> report.put(name, stored(config.get()))); + String actual = ConfigBinding.write(report); + System.out.println("[DatasourcePersistenceContractTest] " + CONFIGS.size() + " configs\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** The detail config map {@code DatasourceRepository.save} hands to the store for {@code config}, or the error. */ + private static Map stored(DatasourceConnectionConfig config) { + DatasourceDORepository store = mock(DatasourceDORepository.class); + when(store.save(any(DatasourceDO.class))).thenReturn(Mono.empty()); + JsDatasourceHelper jsDatasourceHelper = mock(JsDatasourceHelper.class); + when(jsDatasourceHelper.fillPluginDefinition(any(Datasource.class))).thenReturn(Mono.empty()); + EncryptionService encryptionService = mock(EncryptionService.class); + when(encryptionService.encryptString(any())).thenAnswer(call -> ENCRYPTED_PREFIX + call.getArgument(0)); + DatasourceRepository repository = new DatasourceRepository(); + ReflectionTestUtils.setField(repository, "repository", store); + ReflectionTestUtils.setField(repository, "jsDatasourceHelper", jsDatasourceHelper); + ReflectionTestUtils.setField(repository, "encryptionService", encryptionService); + + Datasource datasource = new Datasource(); + datasource.setName("contract"); + datasource.setDetailConfig(config); + Map outcome = new LinkedHashMap<>(); + outcome.put(CONFIG_CLASS_KEY, config.getClass().getName()); + try { + repository.save(datasource).block(TIMEOUT); + ArgumentCaptor saved = ArgumentCaptor.forClass(DatasourceDO.class); + verify(store).save(saved.capture()); + Map detailConfig = saved.getValue().getDetailConfig(); + Map shape = JavaValueWalker.shape(detailConfig); + if (!(config instanceof Map)) { + // a bean config's stored map has the members in the mapper's write order (see the class comment) + shape.remove(JavaValueWalker.KEY_ORDER_KEY); + } + outcome.put(SHAPE_KEY, shape); + outcome.put(WRITTEN_KEY, QueryResults.written(detailConfig)); + } catch (RuntimeException e) { + outcome.put(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + return outcome; + } + + private static Map> configs() { + Map> configs = new LinkedHashMap<>(); + configs.put("mysql", () -> MysqlDatasourceConfig.buildFrom(form("mysql"))); + configs.put("restApiBasicAuth", () -> RestApiDatasourceConfig.buildFrom(form("restApiBasicAuth"))); + configs.put("restApiBearerToken", () -> RestApiDatasourceConfig.buildFrom(form("restApiBearerToken"))); + configs.put("graphQLBasicAuth", () -> GraphQLDatasourceConfig.buildFrom(form("graphQLBasicAuth"))); + configs.put("jsPlugin", DatasourcePersistenceContractTest::jsConfig); + return configs; + } + + /** A JS plugin config: a password parameter of its definition, a dynamic password, and the §4.6 representative input. */ + private static DatasourceConnectionConfig jsConfig() { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + config.put("apiKey", "s3crét \"quoted\""); + config.put("limit", 3_000_000_001L); + config.put("ratio", new BigDecimal("1.50")); + config.put("representative", RepresentativeInput.map()); + Map dynamicPassword = new LinkedHashMap<>(); + dynamicPassword.put("key", "token"); + dynamicPassword.put("type", "password"); + config.put("dynamicParamsDef", List.of(dynamicPassword)); + config.put("dynamicParamsConfig", new LinkedHashMap<>(Map.of("token", "t0ken"))); + config.setType(JS_PLUGIN_TYPE); + config.setDefinition(Map.of("dataSourceConfig", Map.of("params", List.of(Map.of("key", "apiKey", "type", "password"))))); + return config; + } + + /** One input form of {@value #INPUT}, read by the production mapper. */ + private static Map form(String name) { + return ConfigBinding.forms(GOLDEN.read(INPUT)).get(name); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/JsDatasourceHelperTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/JsDatasourceHelperTest.java new file mode 100644 index 0000000000..739b44060c --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/JsDatasourceHelperTest.java @@ -0,0 +1,258 @@ +package org.lowcoder.domain.datasource.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.plugin.client.DatasourcePluginClient; +import org.lowcoder.domain.plugin.client.dto.DatasourcePluginDefinition; +import org.lowcoder.domain.plugin.client.dto.GetPluginDynamicConfigRequestDTO; +import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.models.JsDatasourceConnectionConfig; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@code JsDatasourceHelper} (unit U6, task L3-6): when the plugin definition is fetched, and where dynamic + * datasource-config and query-config results are stored. Mockito client and meta info service, injected by field. + */ +class JsDatasourceHelperTest { + + private static final String TYPE = "L3-6-js-plugin"; + + private DatasourceMetaInfoService metaInfoService; + private DatasourcePluginClient pluginClient; + private JsDatasourceHelper helper; + + @BeforeEach + void setUp() { + metaInfoService = mock(DatasourceMetaInfoService.class); + pluginClient = mock(DatasourcePluginClient.class); + helper = new JsDatasourceHelper(); + ReflectionTestUtils.setField(helper, "datasourceMetaInfoService", metaInfoService); + ReflectionTestUtils.setField(helper, "datasourcePluginClient", pluginClient); + when(metaInfoService.isJsDatasourcePlugin(TYPE)).thenReturn(true); + when(metaInfoService.isJavaDatasourcePlugin(TYPE)).thenReturn(false); + } + + private static DatasourcePluginDefinition definition(Map entries) { + DatasourcePluginDefinition definition = new DatasourcePluginDefinition(); + definition.putAll(entries); + return definition; + } + + private static DatasourcePluginDefinition datasourceExtraDynamic() { + return definition(Map.of("dataSourceConfig", Map.of("extra", Map.of("type", "dynamic")))); + } + + private static DatasourcePluginDefinition queryConfigDynamic() { + return definition(Map.of("queryConfig", Map.of("type", "dynamic"))); + } + + /** A datasource whose definition, config definition and config type are all present: nothing to fetch. */ + private static Datasource completeDatasource(DatasourcePluginDefinition definition, JsDatasourceConnectionConfig config) { + config.setDefinition(definition); + config.setType(TYPE); + Datasource datasource = Datasource.builder().id("ds").type(TYPE).detailConfig(config).build(); + datasource.setPluginDefinition(definition); + return datasource; + } + + // ---------------------------------------------------------------- fillPluginDefinition + + /** Catches a fetch for a Java plugin (:29) or for a config that is not a JS config (:30). */ + @Test + void fillPluginDefinition_javaPluginOrNonJsConfig_makesNoClientCall() { + Datasource javaPlugin = Datasource.builder().id("ds").type("L3-6-java").detailConfig(new JsDatasourceConnectionConfig()).build(); + StepVerifier.create(helper.fillPluginDefinition(javaPlugin)).verifyComplete(); + + Datasource otherConfig = Datasource.builder().id("ds").type(TYPE).detailConfig(mock(DatasourceConnectionConfig.class)).build(); + StepVerifier.create(helper.fillPluginDefinition(otherConfig)).verifyComplete(); + + verifyNoInteractions(pluginClient); + System.out.println("[JsDatasourceHelperTest] Java plugin / non-JS config -> no plugin definition fetched"); + } + + /** Catches a repeated plugin-definition fetch (:31): with everything present nothing is fetched. */ + @Test + void fillPluginDefinition_withEverythingPresent_makesNoClientCall() { + Datasource datasource = completeDatasource(definition(Map.of("id", "x")), new JsDatasourceConnectionConfig()); + + StepVerifier.create(helper.fillPluginDefinition(datasource)).verifyComplete(); + + verifyNoInteractions(pluginClient); + System.out.println("[JsDatasourceHelperTest] complete datasource -> no fetch"); + } + + /** + * Catches a config left without its definition or type (:31, :35-37): when any one of the datasource's plugin + * definition, the config's definition or the config's type is missing, the definition is fetched once and stored + * on the datasource and on the config, with the type. + */ + @ParameterizedTest(name = "missing part #{0}") + @ValueSource(ints = {0, 1, 2}) + void fillPluginDefinition_whenAnyPartIsMissing_fetchesOnceAndStoresEverywhere(int missing) { + DatasourcePluginDefinition existing = definition(Map.of("id", "existing")); + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + Datasource datasource = Datasource.builder().id("ds").type(TYPE).detailConfig(config).build(); + if (missing != 0) { + datasource.setPluginDefinition(existing); + } + if (missing != 1) { + config.setDefinition(existing); + } + if (missing != 2) { + config.setType(TYPE); + } + DatasourcePluginDefinition fetched = definition(Map.of("id", "fetched")); + when(pluginClient.getDatasourcePluginDefinition(TYPE)).thenReturn(Mono.just(fetched)); + + StepVerifier.create(helper.fillPluginDefinition(datasource)).verifyComplete(); + + verify(pluginClient).getDatasourcePluginDefinition(TYPE); + assertThat(datasource.getPluginDefinition()).isSameAs(fetched); + assertThat(config.getDefinition()).isSameAs(fetched); + assertThat(config.getType()).isEqualTo(TYPE); + System.out.println("[JsDatasourceHelperTest] part #" + missing + " missing -> fetched and stored on datasource and config"); + } + + /** Catches stale or partial data being stored when the client returns nothing. */ + @Test + void fillPluginDefinition_emptyClientResult_storesNothing() { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + Datasource datasource = Datasource.builder().id("ds").type(TYPE).detailConfig(config).build(); + when(pluginClient.getDatasourcePluginDefinition(TYPE)).thenReturn(Mono.empty()); + + StepVerifier.create(helper.fillPluginDefinition(datasource)).verifyComplete(); + + assertThat(datasource.getPluginDefinition()).isNull(); + assertThat(config.getDefinition()).isNull(); + assertThat(config.getType()).isNull(); + System.out.println("[JsDatasourceHelperTest] empty client result -> nothing stored"); + } + + // ---------------------------------------------------------------- processDynamicDatasourceConfigExtra + + /** Catches dynamic calls for Java plugins (:46) and for definitions that are not dynamic (:52). */ + @Test + void processDynamicDatasourceConfigExtra_javaPluginOrNonDynamicDefinition_makesNoDynamicCall() { + when(metaInfoService.isJavaDatasourcePlugin("L3-6-java")).thenReturn(true); + Datasource javaPlugin = Datasource.builder().id("ds").type("L3-6-java").build(); + javaPlugin.setPluginDefinition(datasourceExtraDynamic()); // even a dynamic definition must be ignored for Java plugins + StepVerifier.create(helper.processDynamicDatasourceConfigExtra(javaPlugin)).verifyComplete(); + + Datasource staticDefinition = completeDatasource(definition(Map.of("id", "static")), new JsDatasourceConnectionConfig()); + StepVerifier.create(helper.processDynamicDatasourceConfigExtra(staticDefinition)).verifyComplete(); + + verify(pluginClient, never()).getPluginDynamicConfig(any()); + verify(pluginClient, never()).getDatasourcePluginDefinition(any()); + System.out.println("[JsDatasourceHelperTest] Java plugin / static definition -> no dynamic config call"); + } + + /** Catches the dynamic result stored under the wrong key (:66) or the wrong path or config being sent (:57-58). */ + @Test + void processDynamicDatasourceConfigExtra_dynamicDefinition_storesASingleResultUnderExtra() { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + Datasource datasource = completeDatasource(datasourceExtraDynamic(), config); + when(pluginClient.getPluginDynamicConfig(any())).thenReturn(Mono.just(List.of((Object) Map.of("options", "a,b")))); + + StepVerifier.create(helper.processDynamicDatasourceConfigExtra(datasource)).verifyComplete(); + + ArgumentCaptor> request = ArgumentCaptor.forClass(List.class); + verify(pluginClient).getPluginDynamicConfig(request.capture()); + assertThat(request.getValue()).hasSize(1); + GetPluginDynamicConfigRequestDTO dto = request.getValue().get(0); + assertThat(dto.getPluginName()).isEqualTo(TYPE); + assertThat(dto.getPath()).isEqualTo("$.dataSourceConfig.extra"); + assertThat(dto.getDataSourceConfig()).isSameAs(config); + assertThat(config.get("extra")).isEqualTo(Map.of("options", "a,b")); + System.out.println("[JsDatasourceHelperTest] dynamic datasource config: one result stored under 'extra'"); + } + + /** Catches ambiguous or empty results being applied (:62): only exactly one result is stored. */ + @ParameterizedTest(name = "{0} results") + @ValueSource(ints = {0, 2}) + void processDynamicDatasourceConfigExtra_notExactlyOneResult_changesNothing(int results) { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + Datasource datasource = completeDatasource(datasourceExtraDynamic(), config); + when(pluginClient.getPluginDynamicConfig(any())).thenReturn(Mono.just(results == 0 ? List.of() : List.of((Object) "one", "two"))); + + StepVerifier.create(helper.processDynamicDatasourceConfigExtra(datasource)).verifyComplete(); + + assertThat(config).doesNotContainKey("extra"); + System.out.println("[JsDatasourceHelperTest] " + results + " results -> config unchanged"); + } + + // ---------------------------------------------------------------- processDynamicQueryConfig + + /** Catches dynamic query config calls for Java plugins (:74) and for definitions that are not dynamic (:80). */ + @Test + void processDynamicQueryConfig_javaPluginOrNonDynamicDefinition_makesNoDynamicCall() { + when(metaInfoService.isJavaDatasourcePlugin("L3-6-java")).thenReturn(true); + Datasource javaPlugin = Datasource.builder().id("ds").type("L3-6-java").build(); + javaPlugin.setPluginDefinition(queryConfigDynamic()); // even a dynamic definition must be ignored for Java plugins + StepVerifier.create(helper.processDynamicQueryConfig(javaPlugin)).verifyComplete(); + + Datasource staticDefinition = completeDatasource(definition(Map.of("id", "static")), new JsDatasourceConnectionConfig()); + StepVerifier.create(helper.processDynamicQueryConfig(staticDefinition)).verifyComplete(); + + verify(pluginClient, never()).getPluginDynamicConfigSafely(any()); + System.out.println("[JsDatasourceHelperTest] Java plugin / static query config -> no dynamic call"); + } + + /** + * Catches the wrong path (:85), the unsafe client call being used (:88) and the result stored in the wrong place + * (:92): the safe variant is used, and a single result replaces "queryConfig" in the plugin definition. + */ + @Test + void processDynamicQueryConfig_dynamicDefinition_usesTheSafeClientAndStoresTheResultInTheDefinition() { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + DatasourcePluginDefinition definition = queryConfigDynamic(); + Datasource datasource = completeDatasource(definition, config); + Object resolved = Map.of("type", "static", "fields", List.of("a")); + when(pluginClient.getPluginDynamicConfigSafely(any())).thenReturn(Mono.just(List.of(resolved))); + + StepVerifier.create(helper.processDynamicQueryConfig(datasource)).verifyComplete(); + + ArgumentCaptor> request = ArgumentCaptor.forClass(List.class); + verify(pluginClient).getPluginDynamicConfigSafely(request.capture()); + verify(pluginClient, never()).getPluginDynamicConfig(any()); + GetPluginDynamicConfigRequestDTO dto = request.getValue().get(0); + assertThat(dto.getPluginName()).isEqualTo(TYPE); + assertThat(dto.getPath()).isEqualTo("$.queryConfig"); + assertThat(dto.getDataSourceConfig()).isSameAs(config); + assertThat(definition.get("queryConfig")).isSameAs(resolved); + System.out.println("[JsDatasourceHelperTest] dynamic query config: safe client used, result stored in the definition"); + } + + /** Catches ambiguous or empty results being applied to the definition (:90). */ + @ParameterizedTest(name = "{0} results") + @ValueSource(ints = {0, 2}) + void processDynamicQueryConfig_notExactlyOneResult_leavesTheDefinitionUnchanged(int results) { + DatasourcePluginDefinition definition = queryConfigDynamic(); + Object before = definition.get("queryConfig"); + Datasource datasource = completeDatasource(definition, new JsDatasourceConnectionConfig()); + when(pluginClient.getPluginDynamicConfigSafely(any())).thenReturn(Mono.just(results == 0 ? List.of() : List.of((Object) "one", "two"))); + + StepVerifier.create(helper.processDynamicQueryConfig(datasource)).verifyComplete(); + + assertThat(definition.get("queryConfig")).isSameAs(before); + System.out.println("[JsDatasourceHelperTest] " + results + " results -> definition unchanged"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/ClientBasedConnectionPoolBehaviourTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/ClientBasedConnectionPoolBehaviourTest.java new file mode 100644 index 0000000000..6dbbc748bc --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/ClientBasedConnectionPoolBehaviourTest.java @@ -0,0 +1,343 @@ +package org.lowcoder.domain.datasource.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.ToDoubleFunction; +import java.util.stream.StreamSupport; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.datasource.model.ClientBasedDatasourceConnectionHolder; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.service.impl.ClientBasedConnectionPool.ClientBasedDatasourceCacheKey; +import org.lowcoder.domain.plugin.DatasourceMetaInfo; +import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService; +import org.lowcoder.infra.perf.PerfEvent; +import org.lowcoder.infra.perf.PerfHelper; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.plugin.common.DatasourceConnector; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import com.google.common.cache.LoadingCache; + +import io.micrometer.core.instrument.Tag; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@code ClientBasedConnectionPool} (unit U5, task L3-5): the behaviours the existing + * {@code org.lowcoder.impl.ClientBasedConnectionPoolTest} does not cover: gauges, error mapping, perf counters, + * destruction on update, {@code info}, key equality and two failure behaviours. Mockito connector and + * {@link PerfHelper}; datasource ids are unique to this class and the shared static Hikari map is cleaned after every + * test (invalidating the cache removes the entries). + */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class ClientBasedConnectionPoolBehaviourTest { + + private static final String TYPE_A = "L3-5-cb-type-a"; + private static final String TYPE_B = "L3-5-cb-type-b"; + private static final String ID_PREFIX = "L3-5-cb-"; + private static final Instant V1 = Instant.parse("2026-02-01T00:00:00Z"); + private static final Instant V2 = V1.plusSeconds(60); + + private DatasourceMetaInfoService metaInfoService; + private PerfHelper perf; + private DatasourceConnector connector; + private ClientBasedConnectionPool pool; + private final AtomicInteger sequence = new AtomicInteger(); + + @BeforeEach + void setUp() { + metaInfoService = mock(DatasourceMetaInfoService.class); + perf = mock(PerfHelper.class); + connector = mock(DatasourceConnector.class); + when(metaInfoService.getDatasourceConnector(any())).thenReturn(connector); + when(connector.doCreateConnection(any())).thenAnswer(invocation -> Mono.just("connection-" + sequence.incrementAndGet())); + when(connector.destroyConnection(any())).thenReturn(Mono.empty()); + pool = new ClientBasedConnectionPool(metaInfoService, perf); + } + + @AfterEach + void releaseConnectionsAndCheckTheSharedMap() { + cache().invalidateAll(); + Map shared = hikariMap(); + assertThat(shared.keySet()).as("Hikari wrappers left behind by this class") + .noneMatch(key -> key.id() != null && key.id().startsWith(ID_PREFIX)); + } + + private LoadingCache cache() { + return (LoadingCache) ReflectionTestUtils.getField(pool, "cache"); + } + + private static Map hikariMap() { + return (Map) ReflectionTestUtils.getField(ClientBasedConnectionPool.class, "HIKARI_PERF_WRAPPER_MAP"); + } + + private Datasource datasource(String idSuffix, String type, Instant version) { + Datasource datasource = new Datasource(); + datasource.setId(ID_PREFIX + idSuffix); + datasource.setType(type); + datasource.setUpdatedAt(version); + datasource.setDetailConfig(mock(DatasourceConnectionConfig.class)); + return datasource; + } + + private ClientBasedDatasourceConnectionHolder connection(Datasource datasource) { + return (ClientBasedDatasourceConnectionHolder) pool.getOrCreateConnection(datasource).block(Duration.ofSeconds(5)); + } + + private static HikariPerfWrapper wrapper(int active, int idle, int waiting, int total) { + HikariPerfWrapper wrapper = mock(HikariPerfWrapper.class); + when(wrapper.getActiveConnections()).thenReturn(active); + when(wrapper.getIdleConnections()).thenReturn(idle); + when(wrapper.getWaitingConnections()).thenReturn(waiting); + when(wrapper.getTotalConnections()).thenReturn(total); + Properties datasourceProperties = new Properties(); + datasourceProperties.setProperty("jdbcUrl", "jdbc:test"); + when(wrapper.getDatasourceProperties()).thenReturn(datasourceProperties); + when(wrapper.getHealthCheckProperties()).thenReturn(new Properties()); + return wrapper; + } + + private static Map tags(Iterable tags) { + Map map = new HashMap<>(); + StreamSupport.stream(tags.spliterator(), false).map(Tag.class::cast).forEach(tag -> map.put(tag.getKey(), tag.getValue())); + return map; + } + + // ---------------------------------------------------------------- init gauges + + /** + * Catches gauges registered for the wrong pool type (:67), a size gauge counting other types (:72), and Hikari + * gauges reading the wrong counter or type (:82, :85-88): the captured gauge functions are applied to real data. + */ + @Test + void init_registersTheGaugesAndTheirFunctionsCountPerType() { + DatasourceMetaInfo clientBased = DatasourceMetaInfo.builder().type(TYPE_A).connectionPool(ClientBasedConnectionPool.class).build(); + DatasourceMetaInfo tokenBased = DatasourceMetaInfo.builder().type(TYPE_B).connectionPool(TokenBasedConnectionPool.class).build(); + when(metaInfoService.getJavaBasedSupportedDatasourceMetaInfos()).thenReturn(List.of(clientBased, tokenBased)); + + pool.init(); + + ArgumentCaptor events = ArgumentCaptor.forClass(PerfEvent.class); + ArgumentCaptor tagCaptor = ArgumentCaptor.forClass(Iterable.class); + ArgumentCaptor targets = ArgumentCaptor.forClass(Object.class); + ArgumentCaptor functions = ArgumentCaptor.forClass(ToDoubleFunction.class); + verify(perf, times(9)).gaugeSafely(events.capture(), tagCaptor.capture(), targets.capture(), functions.capture()); + + // exactly one size gauge, for the client based type only + List sizeCalls = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + if (events.getAllValues().get(i) == PerfEvent.CLIENT_BASED_CONNECTION_SIZE) { + sizeCalls.add(i); + } + } + assertThat(sizeCalls).hasSize(1); + int sizeCall = sizeCalls.get(0); + assertThat(tags(tagCaptor.getAllValues().get(sizeCall))).containsEntry("type", TYPE_A); + connection(datasource("size-1", TYPE_A, V1)); + connection(datasource("size-2", TYPE_A, V1)); + connection(datasource("size-3", TYPE_B, V1)); + assertThat(functions.getAllValues().get(sizeCall).applyAsDouble(targets.getAllValues().get(sizeCall))) + .as("cached connections of the type only").isEqualTo(2.0); + + // four Hikari gauges per supported type, each summing its own counter over the wrappers of that type + Map fake = new HashMap<>(); + fake.put(ClientBasedDatasourceCacheKey.of(datasource("fake-a1", TYPE_A, V1)), wrapper(1, 10, 100, 1000)); + fake.put(ClientBasedDatasourceCacheKey.of(datasource("fake-a2", TYPE_A, V1)), wrapper(3, 30, 300, 3000)); + fake.put(ClientBasedDatasourceCacheKey.of(datasource("fake-b1", TYPE_B, V1)), wrapper(2, 20, 200, 2000)); + Map> expected = Map.of( + TYPE_A, Map.of(PerfEvent.HIKARI_POOL_ACTIVE_CONNECTIONS, 4.0, PerfEvent.HIKARI_POOL_IDLE_CONNECTIONS, 40.0, + PerfEvent.HIKARI_POOL_WAITING_CONNECTIONS, 400.0, PerfEvent.HIKARI_POOL_TOTAL_CONNECTIONS, 4000.0), + TYPE_B, Map.of(PerfEvent.HIKARI_POOL_ACTIVE_CONNECTIONS, 2.0, PerfEvent.HIKARI_POOL_IDLE_CONNECTIONS, 20.0, + PerfEvent.HIKARI_POOL_WAITING_CONNECTIONS, 200.0, PerfEvent.HIKARI_POOL_TOTAL_CONNECTIONS, 2000.0)); + int checked = 0; + for (int i = 0; i < 9; i++) { + PerfEvent event = events.getAllValues().get(i); + if (event == PerfEvent.CLIENT_BASED_CONNECTION_SIZE) { + continue; + } + String type = tags(tagCaptor.getAllValues().get(i)).get("datasourceType"); + assertThat(functions.getAllValues().get(i).applyAsDouble(fake)).as(event + " of " + type).isEqualTo(expected.get(type).get(event)); + checked++; + } + assertThat(checked).isEqualTo(8); + System.out.println("[ClientBasedConnectionPoolBehaviourTest] gauges: 1 size gauge (client based type), 8 hikari gauges, functions verified"); + } + + // ---------------------------------------------------------------- errors + + /** Catches raw exceptions reaching callers (:153-159), while exceptions that already are BaseExceptions pass through (:154). */ + @Test + void connectorFailure_isMappedToPluginCreateConnectionFailed_butPluginExceptionsPassThrough() { + when(connector.doCreateConnection(any())).thenReturn(Mono.error(new IllegalStateException("boom"))); + StepVerifier.create(pool.getOrCreateConnection(datasource("error-mapped", TYPE_A, V1))) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(BizException.class); + BizException biz = (BizException) error; + assertThat(biz.getError()).isEqualTo(BizError.PLUGIN_CREATE_CONNECTION_FAILED); + assertThat(biz.getArgs()).containsExactly("boom"); + }) + .verify(); + + PluginException plugin = new PluginException(PluginCommonError.DATASOURCE_ARGUMENT_ERROR, "DATASOURCE_TYPE_ERROR", "bad config"); + when(connector.doCreateConnection(any())).thenReturn(Mono.error(plugin)); + StepVerifier.create(pool.getOrCreateConnection(datasource("error-plugin", TYPE_A, V1))) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(plugin)) + .verify(); + System.out.println("[ClientBasedConnectionPoolBehaviourTest] raw error -> PLUGIN_CREATE_CONNECTION_FAILED; PluginException unchanged"); + } + + // ---------------------------------------------------------------- perf counters, destruction + + /** Catches missing create (:120) or removal (:107) counters: counted once per version, removal with its cause and type. */ + @Test + void perfCounters_createOncePerLoad_andTheRemovalCountsItsCause() { + connection(datasource("perf", TYPE_A, V1)); + ArgumentCaptor createTags = ArgumentCaptor.forClass(Iterable.class); + verify(perf, times(1)).count(eq(PerfEvent.CLIENT_BASED_CONNECTION_CREATE), createTags.capture()); + assertThat(tags(createTags.getValue())).containsOnly(Map.entry("type", TYPE_A)); + verify(perf, never()).count(eq(PerfEvent.CLIENT_BASED_CONNECTION_REMOVE), any(Iterable.class)); + + connection(datasource("perf", TYPE_A, V2)); // the update releases the previous version + + verify(perf, times(2)).count(eq(PerfEvent.CLIENT_BASED_CONNECTION_CREATE), any(Iterable.class)); + ArgumentCaptor removeTags = ArgumentCaptor.forClass(Iterable.class); + verify(perf, times(1)).count(eq(PerfEvent.CLIENT_BASED_CONNECTION_REMOVE), removeTags.capture()); + assertThat(tags(removeTags.getValue())).containsOnly(Map.entry("type", TYPE_A), Map.entry("cause", "EXPLICIT")); + System.out.println("[ClientBasedConnectionPoolBehaviourTest] create counted per load, removal counted with EXPLICIT"); + } + + /** + * Catches a connection leak after a datasource edit (:111) and collateral invalidation (:137): the old version's + * connection is destroyed on the connector, another datasource's connection is neither destroyed nor replaced. + */ + @Test + void update_destroysThePreviousConnectionOnTheConnector_andLeavesOtherDatasourcesAlone() { + ClientBasedDatasourceConnectionHolder edited = connection(datasource("edited", TYPE_A, V1)); + Datasource other = datasource("other", TYPE_A, V1); + ClientBasedDatasourceConnectionHolder otherHolder = connection(other); + + ClientBasedDatasourceConnectionHolder replaced = connection(datasource("edited", TYPE_A, V2)); + + assertThat(replaced).isNotSameAs(edited); + verify(connector, timeout(5000)).destroyConnection(edited.connection()); + assertThat(connection(other)).as("the other datasource keeps its holder").isSameAs(otherHolder); + verify(connector, never()).destroyConnection(otherHolder.connection()); + verify(connector, never()).destroyConnection(replaced.connection()); + System.out.println("[ClientBasedConnectionPoolBehaviourTest] old version destroyed, other datasource untouched"); + } + + // ---------------------------------------------------------------- info + + /** + * Catches info() listing other datasources for an id (:171), more than 100 entries (:173), and wrappers outliving + * their datasource version (:109): after an update only the new wrapper is listed. + */ + @Test + void info_listsHikariWrappersById_limitedTo100_andDropsReplacedVersions() { + when(connector.doCreateConnection(any())).thenAnswer(invocation -> Mono.just(wrapper(1, 2, 3, 11))); + connection(datasource("info-a", TYPE_A, V1)); + connection(datasource("info-b", TYPE_A, V1)); + + List> forA = (List>) pool.info(ID_PREFIX + "info-a"); + assertThat(forA).hasSize(1); + Map connections = (Map) forA.get(0).get("connections"); + assertThat(connections).containsOnly(Map.entry("total", 11), Map.entry("idle", 2), Map.entry("active", 1), Map.entry("waiting", 3)); + assertThat((Properties) forA.get(0).get("datasource")).containsEntry("jdbcUrl", "jdbc:test"); + assertThat(forA.get(0)).containsKeys("healthCheck"); + + for (String blank : new String[] {null, "", " "}) { + assertThat((List) pool.info(blank)).as("all wrappers for blank id '" + blank + "'").hasSizeGreaterThanOrEqualTo(2); + } + + when(connector.doCreateConnection(any())).thenAnswer(invocation -> Mono.just(wrapper(5, 6, 7, 22))); + connection(datasource("info-a", TYPE_A, V2)); + List> afterUpdate = (List>) pool.info(ID_PREFIX + "info-a"); + assertThat(afterUpdate).as("old wrapper removed with its datasource version").hasSize(1); + assertThat(((Map) afterUpdate.get(0).get("connections")).get("total")).isEqualTo(22); + + for (int i = 0; i < 105; i++) { + connection(datasource("info-many-" + i, TYPE_A, V1)); + } + assertThat((List) pool.info(null)).as("never more than 100 entries").hasSize(100); + System.out.println("[ClientBasedConnectionPoolBehaviourTest] info: filtered by id, limit 100, replaced versions removed"); + } + + // ---------------------------------------------------------------- cache key + + /** Catches the cache key comparing the datasource object (:211): only id and update time count. */ + @Test + void cacheKey_equalsOnIdAndUpdateTimeOnly() { + ClientBasedDatasourceCacheKey key = ClientBasedDatasourceCacheKey.of(datasource("key", TYPE_A, V1)); + ClientBasedDatasourceCacheKey sameIdAndTime = ClientBasedDatasourceCacheKey.of(datasource("key", TYPE_B, V1)); + + assertThat(key).isEqualTo(key).isEqualTo(sameIdAndTime).hasSameHashCodeAs(sameIdAndTime); + assertThat(key).isNotEqualTo(ClientBasedDatasourceCacheKey.of(datasource("key", TYPE_A, V2))); + assertThat(key).isNotEqualTo(ClientBasedDatasourceCacheKey.of(datasource("key-other", TYPE_A, V1))); + assertThat(key).isNotEqualTo(null).isNotEqualTo("a string"); + System.out.println("[ClientBasedConnectionPoolBehaviourTest] cache key equality on id + update time"); + } + + // ---------------------------------------------------------------- section 9 candidates + + /** + * Pins the plan section 9 row "a failed client connection creation is cached" (ClientBasedConnectionPool:124-130, + * :152): the loader caches {@code create(...).cache()}, which also caches the error. After one transient failure + * the connector is not asked again: the second call for the same datasource version fails with the same error + * although the connector would now succeed (it was subscribed exactly once). A fix (do not cache errors) changes + * this test on purpose. + */ + @Test + void failedConnectionCreation_isCached_secondCallDoesNotRetryTheConnector_pinsSection9Row() { + AtomicInteger subscriptions = new AtomicInteger(); + when(connector.doCreateConnection(any())).thenAnswer(invocation -> Mono.defer(() -> subscriptions.incrementAndGet() == 1 + ? Mono.error(new IllegalStateException("database down")) + : Mono.just("connection-after-recovery"))); + Datasource datasource = datasource("cached-failure", TYPE_A, V1); + + StepVerifier.create(pool.getOrCreateConnection(datasource)).expectError(BizException.class).verify(); + StepVerifier.create(pool.getOrCreateConnection(datasource)) + .expectErrorSatisfies(error -> assertThat(error).isInstanceOf(BizException.class).hasMessageContaining("database down")) + .verify(); + + assertThat(subscriptions.get()).as("the connector was subscribed exactly once, the failure is served from the cache").isEqualTo(1); + System.out.println("[ClientBasedConnectionPoolBehaviourTest] pins the section 9 row: failed creation cached, connector asked once"); + } + + /** + * Pins the plan section 9 row "a client based connector that returns no connection completes empty without an + * error" (ClientBasedConnectionPool:187-190; TokenBasedConnectionPool:43 has the switchIfEmpty this lacks): the + * caller of the pool gets an empty Mono. A fix (error on empty) changes this test on purpose. + */ + @Test + void connectorWithoutAConnection_completesEmptyWithoutAnError_pinsSection9Row() { + when(connector.doCreateConnection(any())).thenReturn(Mono.empty()); + + StepVerifier.create(pool.getOrCreateConnection(datasource("empty-connection", TYPE_A, V1))).verifyComplete(); + System.out.println("[ClientBasedConnectionPoolBehaviourTest] pins the section 9 row: no connection -> empty completion, no error"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/DatasourceConnectionPoolFacadeTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/DatasourceConnectionPoolFacadeTest.java new file mode 100644 index 0000000000..dd700a8609 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/DatasourceConnectionPoolFacadeTest.java @@ -0,0 +1,178 @@ +package org.lowcoder.domain.datasource.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.lowcoder.domain.plugin.DatasourceMetaInfoConstants.REST_API; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.lowcoder.domain.datasource.model.ClientBasedDatasourceConnectionHolder; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceConnectionHolder; +import org.lowcoder.domain.datasource.model.StatelessDatasourceConnectionHolder; +import org.lowcoder.domain.datasource.model.TokenBasedConnection; +import org.lowcoder.domain.datasource.model.TokenBasedConnectionHolder; +import org.lowcoder.domain.datasource.repository.TokenBasedConnectionRepository; +import org.lowcoder.domain.datasource.service.DatasourceConnectionPool; +import org.lowcoder.domain.plugin.DatasourceMetaInfo; +import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService; +import org.lowcoder.infra.perf.PerfHelper; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.TokenBasedConnectionDetail; +import org.lowcoder.sdk.plugin.common.DatasourceConnector; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.AuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; +import org.springframework.test.util.ReflectionTestUtils; + +import reactor.core.publisher.Mono; + +/** + * {@code DatasourceConnectionPoolFacade} (unit U5, task L3-5): which pool serves which datasource. The three pools are + * real instances (the facade keys them by class), recognised by the holder type they hand out. + */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class DatasourceConnectionPoolFacadeTest { + + private static final String SQL_TYPE = "L3-5-facade-sql"; + private static final Set STATELESS_AUTH_TYPES = EnumSet.of(RestApiAuthType.NO_AUTH, + RestApiAuthType.BASIC_AUTH, RestApiAuthType.DIGEST_AUTH, RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN); + + private DatasourceMetaInfoService metaInfoService; + private ClientBasedConnectionPool clientBasedPool; + private DatasourceConnectionPoolFacade facade; + private DatasourceConnector connector; + + @BeforeEach + void setUp() { + metaInfoService = mock(DatasourceMetaInfoService.class); + TokenBasedConnectionRepository tokenRepository = mock(TokenBasedConnectionRepository.class); + TokenBasedConnection stored = new TokenBasedConnection(); + stored.setTokenDetail(mock(TokenBasedConnectionDetail.class)); + stored.setUpdatedAt(Instant.now()); + when(tokenRepository.findByDatasourceId(any(), any())).thenReturn(Mono.just(stored)); + connector = mock(DatasourceConnector.class); + when(metaInfoService.getDatasourceConnector(SQL_TYPE)).thenReturn(connector); + when(connector.doCreateConnection(any())).thenReturn(Mono.just("connection")); + when(connector.destroyConnection(any())).thenReturn(Mono.empty()); + + clientBasedPool = new ClientBasedConnectionPool(metaInfoService, mock(PerfHelper.class)); + List pools = new ArrayList<>(); + pools.add(new StatelessConnectionPool()); + pools.add(new TokenBasedConnectionPool(metaInfoService, tokenRepository)); + pools.add(clientBasedPool); + facade = new DatasourceConnectionPoolFacade(pools, metaInfoService); + pools.add(facade); // the facade itself is among the injected pools, as it is a Spring bean of the same type + facade.init(); + } + + @AfterEach + void releaseConnections() { + ((com.google.common.cache.LoadingCache) ReflectionTestUtils.getField(clientBasedPool, "cache")).invalidateAll(); + } + + private static Datasource restDatasource(RestApiAuthType authType) { + AuthConfig authConfig = mock(AuthConfig.class); + when(authConfig.getType()).thenReturn(authType); + Datasource datasource = Datasource.builder().id("L3-5-rest-" + authType).type(REST_API) + .detailConfig(RestApiDatasourceConfig.builder().authConfig(authConfig).build()).build(); + return datasource; + } + + private DatasourceConnectionHolder holderOf(Datasource datasource) { + return facade.getOrCreateConnection(datasource).block(); + } + + /** Catches the facade registering itself (:43): the map holds exactly the three real pools. */ + @Test + void init_registersEveryPoolByClass_exceptTheFacadeItself() { + Map, DatasourceConnectionPool> poolMap = (Map, DatasourceConnectionPool>) ReflectionTestUtils.getField(facade, "poolMap"); + + assertThat(poolMap).containsOnlyKeys(StatelessConnectionPool.class, TokenBasedConnectionPool.class, ClientBasedConnectionPool.class); + System.out.println("[DatasourceConnectionPoolFacadeTest] registered pools: " + poolMap.keySet()); + } + + /** Catches the wrong pool for stateless REST auth types (:55-58): each of the four goes to the stateless pool. */ + @ParameterizedTest + @EnumSource(value = RestApiAuthType.class, names = {"NO_AUTH", "BASIC_AUTH", "DIGEST_AUTH", "OAUTH2_INHERIT_FROM_LOGIN"}) + void rest_statelessAuthTypes_goToTheStatelessPool(RestApiAuthType authType) { + assertThat(STATELESS_AUTH_TYPES).contains(authType); + + assertThat(holderOf(restDatasource(authType))).isInstanceOf(StatelessDatasourceConnectionHolder.class); + System.out.println("[DatasourceConnectionPoolFacadeTest] REST " + authType + " -> stateless pool"); + } + + /** Catches the wrong pool for token REST auth (:61): a stateless pool would never refresh the OAuth token. */ + @ParameterizedTest + @EnumSource(value = RestApiAuthType.class, names = {"BEARER_TOKEN_AUTH", "OAUTH2"}) + void rest_otherAuthTypes_goToTheTokenPool(RestApiAuthType authType) { + assertThat(STATELESS_AUTH_TYPES).doesNotContain(authType); + + assertThat(holderOf(restDatasource(authType))).isInstanceOf(TokenBasedConnectionHolder.class); + System.out.println("[DatasourceConnectionPoolFacadeTest] REST " + authType + " -> token pool"); + } + + /** Catches the REST shortcut applying to any config (:54): a REST type with another config follows the meta info. */ + @Test + void rest_withANonRestConfig_followsTheMetaInfoPool() { + when(metaInfoService.getDatasourceMetaInfo(REST_API)).thenReturn( + DatasourceMetaInfo.builder().type(REST_API).connectionPool(ClientBasedConnectionPool.class).build()); + when(metaInfoService.getDatasourceConnector(REST_API)).thenReturn(connector); + Datasource datasource = Datasource.builder().id("L3-5-rest-other-config").type(REST_API) + .detailConfig(mock(org.lowcoder.sdk.models.DatasourceConnectionConfig.class)).build(); + + assertThat(holderOf(datasource)).isInstanceOf(ClientBasedDatasourceConnectionHolder.class); + System.out.println("[DatasourceConnectionPoolFacadeTest] REST type with a non-REST config -> meta info pool"); + } + + /** Catches a wrong lookup of the pool for non-REST datasources (:66-72), and that info() reaches the client pool. */ + @Test + void nonRest_usesTheMetaInfoPool_andInfoDelegatesToTheClientBasedPool() { + when(metaInfoService.getDatasourceMetaInfo(SQL_TYPE)).thenReturn( + DatasourceMetaInfo.builder().type(SQL_TYPE).connectionPool(ClientBasedConnectionPool.class).build()); + HikariPerfWrapper wrapper = mock(HikariPerfWrapper.class); + when(wrapper.getDatasourceProperties()).thenReturn(new java.util.Properties()); + when(wrapper.getHealthCheckProperties()).thenReturn(new java.util.Properties()); + when(connector.doCreateConnection(any())).thenReturn(Mono.just(wrapper)); + Datasource datasource = Datasource.builder().id("L3-5-facade-info").type(SQL_TYPE).build(); + + assertThat(holderOf(datasource)).isInstanceOf(ClientBasedDatasourceConnectionHolder.class); + + assertThat((List) facade.info("L3-5-facade-info")).hasSize(1); + assertThat((List) facade.info("L3-5-no-such-datasource")).isEmpty(); + System.out.println("[DatasourceConnectionPoolFacadeTest] SQL datasource -> client based pool; info delegated"); + } + + /** + * Pins as behaviour that an unknown pool class is thrown synchronously, not signalled (:69): the BizException + * INVALID_DATASOURCE_CONFIGURATION with message key CANT_FIND_CONNECTION_POOL surfaces from the call itself. + */ + @Test + void unknownPoolClass_throwsInvalidDatasourceConfigurationSynchronously() { + when(metaInfoService.getDatasourceMetaInfo(SQL_TYPE)).thenReturn( + DatasourceMetaInfo.builder().type(SQL_TYPE).connectionPool(DatasourceConnectionPoolFacade.class).build()); + Datasource datasource = Datasource.builder().id("L3-5-unknown-pool").type(SQL_TYPE).build(); + + assertThatThrownBy(() -> facade.getOrCreateConnection(datasource)) + .isInstanceOfSatisfying(BizException.class, biz -> { + assertThat(biz.getError()).isEqualTo(BizError.INVALID_DATASOURCE_CONFIGURATION); + assertThat(biz.getMessageKey()).isEqualTo("CANT_FIND_CONNECTION_POOL"); + }); + System.out.println("[DatasourceConnectionPoolFacadeTest] unregistered pool class -> synchronous BizException CANT_FIND_CONNECTION_POOL"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/DatasourceServiceImplTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/DatasourceServiceImplTest.java new file mode 100644 index 0000000000..091010ad24 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/DatasourceServiceImplTest.java @@ -0,0 +1,536 @@ +package org.lowcoder.domain.datasource.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationStatus; +import org.lowcoder.domain.application.repository.ApplicationRepository; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceStatus; +import org.lowcoder.domain.datasource.repository.DatasourceRepository; +import org.lowcoder.domain.datasource.service.JsDatasourceHelper; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.plugin.client.DatasourcePluginClient; +import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService; +import org.lowcoder.sdk.constants.FieldName; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.models.JsDatasourceConnectionConfig; +import org.lowcoder.sdk.plugin.common.DatasourceConnector; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.util.LocaleUtils; +import org.springframework.dao.DuplicateKeyException; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@code DatasourceServiceImpl} (unit U11, task L3-2/L3-3 table row L3-3): static datasources, create/update validation + * and persistence, connection test routing and the delete guard, with Mockito collaborators and StepVerifier. + */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class DatasourceServiceImplTest { + + private static final String JAVA_TYPE = "postgres"; + private static final String JS_TYPE = "jsPlugin"; + private static final String ORG_ID = "org-1"; + private static final String CREATOR_ID = "creator-1"; + private static final String DATASOURCE_ID = "ds-1"; + + private DatasourceMetaInfoService metaInfoService; + private ApplicationRepository applicationRepository; + private ResourcePermissionService resourcePermissionService; + private DatasourceRepository repository; + private DatasourcePluginClient pluginClient; + private JsDatasourceHelper jsDatasourceHelper; + private DatasourceConnector connector; + private DatasourceServiceImpl service; + + @BeforeEach + void setUp() { + metaInfoService = mock(DatasourceMetaInfoService.class); + applicationRepository = mock(ApplicationRepository.class); + resourcePermissionService = mock(ResourcePermissionService.class); + repository = mock(DatasourceRepository.class); + pluginClient = mock(DatasourcePluginClient.class); + jsDatasourceHelper = mock(JsDatasourceHelper.class); + connector = mock(DatasourceConnector.class); + service = new DatasourceServiceImpl(metaInfoService, applicationRepository, resourcePermissionService, repository, + pluginClient, jsDatasourceHelper); + + when(metaInfoService.isJsDatasourcePlugin(JS_TYPE)).thenReturn(true); + when(metaInfoService.getDatasourceConnector(JAVA_TYPE)).thenReturn(connector); + when(connector.doValidateConfig(any())).thenReturn(Set.of()); + when(connector.doTestConnection(any())).thenReturn(Mono.just(DatasourceTestResult.testSuccess())); + when(jsDatasourceHelper.fillPluginDefinition(any())).thenReturn(Mono.empty()); + when(repository.save(any(Datasource.class))).thenAnswer(invocation -> Mono.just(invocation.getArgument(0))); + when(resourcePermissionService.addDataSourcePermissionToUser(any(), anyString(), any())).thenReturn(Mono.just(true)); + } + + private static Datasource datasource(String id, String name, String type, DatasourceConnectionConfig config) { + return Datasource.builder().id(id).name(name).type(type).organizationId(ORG_ID).detailConfig(config).build(); + } + + private static void assertBizError(Throwable error, BizError expected, Object... args) { + assertThat(error).isInstanceOf(BizException.class); + BizException biz = (BizException) error; + assertThat(biz.getError()).isEqualTo(expected); + if (args.length > 0) { + assertThat(biz.getArgs()).containsExactly(args); + } + } + + // ---------------------------------------------------------------- getById / getByIds + + /** Catches a system-static datasource getting lost (:88/:92/:96): each static id yields its shared constant. */ + @Test + void getById_staticIds_returnTheSharedStaticDatasourceWithoutTouchingTheRepository() { + StepVerifier.create(service.getById(Datasource.QUICK_REST_API_ID)).expectNext(Datasource.QUICK_REST_API).verifyComplete(); + StepVerifier.create(service.getById(Datasource.QUICK_GRAPHQL_ID)).expectNext(Datasource.QUICK_GRAPHQL_API).verifyComplete(); + StepVerifier.create(service.getById(Datasource.JS_CODE_ID)).expectNext(Datasource.JS_CODE).verifyComplete(); + verifyNoInteractions(repository); + + Datasource stored = datasource(DATASOURCE_ID, "db", JAVA_TYPE, null); + when(repository.findById(DATASOURCE_ID)).thenReturn(Mono.just(stored)); + StepVerifier.create(service.getById(DATASOURCE_ID)).expectNext(stored).verifyComplete(); + System.out.println("[DatasourceServiceImplTest] static ids served from constants, others from the repository"); + } + + /** + * Pins the real order of getByIds (the analysis said "preserving order", which is wrong): the static datasources + * come first, in input order, then the repository result; the repository only receives the non-static ids + * (:110, :133). + */ + @Test + void getByIds_staticFirstThenRepositoryResult_repositoryGetsOnlyNonStaticIds() { + Datasource db1 = datasource("db1", "one", JAVA_TYPE, null); + Datasource db2 = datasource("db2", "two", JAVA_TYPE, null); + when(repository.findByIds(any())).thenReturn(Flux.just(db1, db2)); + + StepVerifier.create(service.getByIds(List.of("db1", Datasource.QUICK_REST_API_ID, "db2", Datasource.JS_CODE_ID))) + .expectNext(Datasource.QUICK_REST_API, Datasource.JS_CODE, db1, db2) + .verifyComplete(); + + org.mockito.ArgumentCaptor> ids = org.mockito.ArgumentCaptor.forClass(Collection.class); + verify(repository).findByIds(ids.capture()); + assertThat(ids.getValue()).containsExactly("db1", "db2"); + System.out.println("[DatasourceServiceImplTest] getByIds order: static first, then repository; repository saw only db1, db2"); + } + + // ---------------------------------------------------------------- create + + /** Catches a client-chosen id overwriting a row (:61). */ + @Test + void create_withId_failsInvalidParameterAndNeverSaves() { + StepVerifier.create(service.create(datasource("client-id", "n", JAVA_TYPE, null), CREATOR_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PARAMETER, FieldName.ID)) + .verify(); + verify(repository, never()).save(any(Datasource.class)); + System.out.println("[DatasourceServiceImplTest] create with an id -> INVALID_PARAMETER(id)"); + } + + /** Catches an invalid datasource being persisted: org id (:146), name (:150) and type (:154) are required. */ + @Test + void create_missingOrganizationNameOrType_failsAsErrorSignalAndNeverSaves() { + Datasource noOrg = Datasource.builder().name("n").type(JAVA_TYPE).build(); + StepVerifier.create(service.create(noOrg, CREATOR_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PARAMETER, FieldName.ORGANIZATION_ID)).verify(); + + for (String blank : new String[] {null, "", " "}) { + StepVerifier.create(service.create(datasource(null, blank, JAVA_TYPE, null), CREATOR_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PARAMETER, FieldName.NAME)).verify(); + } + + StepVerifier.create(service.create(datasource(null, "n", null, null), CREATOR_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.DATASOURCE_PLUGIN_ID_NOT_GIVEN)).verify(); + verify(repository, never()).save(any(Datasource.class)); + System.out.println("[DatasourceServiceImplTest] missing org / blank name / missing type rejected before saving"); + } + + /** Catches connector validation being applied to JS plugins (:158): they are validated by the node side only. */ + @Test + void create_jsPlugin_skipsConnectorValidation() { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + StepVerifier.create(service.create(datasource(null, "js", JS_TYPE, config), CREATOR_ID)) + .assertNext(saved -> assertThat(saved.getType()).isEqualTo(JS_TYPE)) + .verifyComplete(); + verify(metaInfoService, never()).getDatasourceConnector(anyString()); + System.out.println("[DatasourceServiceImplTest] JS plugin datasource saved without connector validation"); + } + + /** Catches unvalidated configs being saved, and the localised messages not being joined (:171-173). */ + @Test + void create_javaPlugin_connectorValidationErrorsAreJoinedAndNothingIsSaved() { + DatasourceConnectionConfig config = mock(DatasourceConnectionConfig.class); + when(connector.doValidateConfig(config)).thenReturn(Set.of("DATASOURCE_TEST_TIMEOUT_ERROR", "INTERNAL_SERVER_ERROR")); + String first = LocaleUtils.getMessage(java.util.Locale.ENGLISH, "DATASOURCE_TEST_TIMEOUT_ERROR"); + String second = LocaleUtils.getMessage(java.util.Locale.ENGLISH, "INTERNAL_SERVER_ERROR"); + + StepVerifier.create(service.create(datasource(null, "pg", JAVA_TYPE, config), CREATOR_ID)) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(BizException.class); + BizException biz = (BizException) error; + assertThat(biz.getError()).isEqualTo(BizError.INVALID_DATASOURCE_CONFIGURATION); + assertThat(((String) biz.getArgs()[0]).split("\n")).containsExactlyInAnyOrder(first, second); + }) + .verify(); + verify(repository, never()).save(any(Datasource.class)); + System.out.println("[DatasourceServiceImplTest] connector validation errors joined with newline, nothing saved"); + } + + /** Catches a datasource nobody can manage: the creator gets OWNER after the save (:68), and the saved row is returned. */ + @Test + void create_success_savesThenGrantsCreatorOwnerAndReturnsTheSavedDatasource() { + List events = new ArrayList<>(); + Datasource saved = datasource(DATASOURCE_ID, "pg", JAVA_TYPE, mock(DatasourceConnectionConfig.class)); + when(repository.save(any(Datasource.class))).thenReturn(Mono.fromSupplier(() -> { + events.add("save"); + return saved; + })); + when(resourcePermissionService.addDataSourcePermissionToUser(DATASOURCE_ID, CREATOR_ID, ResourceRole.OWNER)) + .thenReturn(Mono.fromSupplier(() -> { + events.add("grant"); + return true; + })); + + StepVerifier.create(service.create(datasource(null, "pg", JAVA_TYPE, mock(DatasourceConnectionConfig.class)), CREATOR_ID)) + .expectNext(saved) + .verifyComplete(); + assertThat(events).containsExactly("save", "grant"); + + when(resourcePermissionService.addDataSourcePermissionToUser(any(), anyString(), any())) + .thenReturn(Mono.error(new IllegalStateException("grant failed"))); + StepVerifier.create(service.create(datasource(null, "pg", JAVA_TYPE, mock(DatasourceConnectionConfig.class)), CREATOR_ID)) + .expectError(IllegalStateException.class) + .verify(); + System.out.println("[DatasourceServiceImplTest] create: save, then OWNER grant, saved row emitted; failed grant fails create"); + } + + /** Catches a 500 on a duplicate name (:185) and swallowed or rewritten other errors, for create and update. */ + @Test + void save_duplicateKeyBecomesDuplicateDatabaseName_otherErrorsPassThrough() { + when(repository.save(any(Datasource.class))).thenReturn(Mono.error(new DuplicateKeyException("index"))); + StepVerifier.create(service.create(datasource(null, "same-name", JAVA_TYPE, null), CREATOR_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.DUPLICATE_DATABASE_NAME, "same-name")) + .verify(); + + IllegalStateException failure = new IllegalStateException("mongo down"); + when(repository.save(any(Datasource.class))).thenReturn(Mono.error(failure)); + when(repository.findById(DATASOURCE_ID)).thenReturn(Mono.just(datasource(DATASOURCE_ID, "old", JAVA_TYPE, mock(DatasourceConnectionConfig.class)))); + StepVerifier.create(service.update(DATASOURCE_ID, datasource(null, "new", JAVA_TYPE, mock(DatasourceConnectionConfig.class)))) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(failure)) + .verify(); + System.out.println("[DatasourceServiceImplTest] DuplicateKey -> DUPLICATE_DATABASE_NAME; other save errors unchanged"); + } + + // ---------------------------------------------------------------- update + + /** Catches a null id reaching the repository (:75) and an unknown id producing a value. */ + @Test + void update_nullId_failsInvalidParameter_unknownIdIsEmpty() { + StepVerifier.create(service.update(null, datasource(null, "n", JAVA_TYPE, null))) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PARAMETER, FieldName.ID)).verify(); + + when(repository.findById("missing")).thenReturn(Mono.empty()); + StepVerifier.create(service.update("missing", datasource(null, "n", JAVA_TYPE, null))).verifyComplete(); + verify(repository, never()).save(any(Datasource.class)); + System.out.println("[DatasourceServiceImplTest] update(null) -> INVALID_PARAMETER; unknown id -> empty, nothing saved"); + } + + /** + * Catches a merge on a datasource without its plugin definition (:80) and a skipped merge (:81): the stored row + * gets the plugin definition filled, then the update merged into it, then validated and saved. + */ + @Test + void update_fillsPluginDefinitionThenMergesValidatesAndSaves() { + DatasourceConnectionConfig currentConfig = mock(DatasourceConnectionConfig.class); + DatasourceConnectionConfig updatedConfig = mock(DatasourceConnectionConfig.class); + DatasourceConnectionConfig mergedConfig = mock(DatasourceConnectionConfig.class); + when(currentConfig.mergeWithUpdatedConfig(updatedConfig)).thenReturn(mergedConfig); + Datasource current = datasource(DATASOURCE_ID, "old", JAVA_TYPE, currentConfig); + Datasource update = datasource(null, "new", JAVA_TYPE, updatedConfig); + update.setDatasourceStatus(DatasourceStatus.DELETED); + when(repository.findById(DATASOURCE_ID)).thenReturn(Mono.just(current)); + + StepVerifier.create(service.update(DATASOURCE_ID, update)) + .assertNext(saved -> { + assertThat(saved).isSameAs(current); + assertThat(saved.getName()).isEqualTo("new"); + assertThat(saved.getDatasourceStatus()).isEqualTo(DatasourceStatus.DELETED); + assertThat(saved.getDetailConfig()).isSameAs(mergedConfig); + }) + .verifyComplete(); + + org.mockito.InOrder order = inOrder(repository, jsDatasourceHelper, connector); + order.verify(repository).findById(DATASOURCE_ID); + order.verify(jsDatasourceHelper).fillPluginDefinition(current); + order.verify(connector).doValidateConfig(mergedConfig); + order.verify(repository).save(current); + System.out.println("[DatasourceServiceImplTest] update: find, fill definition, merge, validate merged config, save"); + } + + /** + * Pins the plan section 9 row "Datasource.mergeWith NPE on a null config" (Datasource.java:103, + * {@code Optional.of(getDetailConfig())}) as it is reached through update(): a stored datasource without a detail + * config fails with a NullPointerException instead of taking the update's config (the else branch is dead). A fix + * changes this test on purpose. + */ + @Test + void update_storedDatasourceWithoutDetailConfig_failsWithNullPointerException() { + when(repository.findById(DATASOURCE_ID)).thenReturn(Mono.just(datasource(DATASOURCE_ID, "old", JAVA_TYPE, null))); + + StepVerifier.create(service.update(DATASOURCE_ID, datasource(null, "new", JAVA_TYPE, mock(DatasourceConnectionConfig.class)))) + .expectError(NullPointerException.class) + .verify(); + verify(repository, never()).save(any(Datasource.class)); + System.out.println("[DatasourceServiceImplTest] pins the section 9 row: mergeWith on a null stored config -> NPE"); + } + + // ---------------------------------------------------------------- testDatasource + + /** Catches the given datasource not being tested as is when it has no id (:196). */ + @Test + void testDatasource_withoutId_testsTheGivenDatasourceWithoutLookup() { + DatasourceConnectionConfig config = mock(DatasourceConnectionConfig.class); + + StepVerifier.create(service.testDatasource(datasource(null, "pg", JAVA_TYPE, config))) + .expectNext(DatasourceTestResult.testSuccess()) + .verifyComplete(); + verify(connector).doTestConnection(config); + verifyNoInteractions(repository); + System.out.println("[DatasourceServiceImplTest] test without id: given config tested, no lookup"); + } + + /** Catches testing with credentials the user may not use (:198): an unknown id is NOT_AUTHORIZED. */ + @Test + void testDatasource_unknownId_failsNotAuthorized() { + when(repository.findById("missing")).thenReturn(Mono.empty()); + + StepVerifier.create(service.testDatasource(datasource("missing", "pg", JAVA_TYPE, mock(DatasourceConnectionConfig.class)))) + .expectErrorSatisfies(error -> assertBizError(error, BizError.NOT_AUTHORIZED)) + .verify(); + verify(connector, never()).doTestConnection(any()); + System.out.println("[DatasourceServiceImplTest] test with an unknown id -> NOT_AUTHORIZED"); + } + + /** Catches the stored credentials not being merged in before validation and test (:200). */ + @Test + void testDatasource_knownId_mergesTheStoredDatasourceBeforeTesting() { + DatasourceConnectionConfig currentConfig = mock(DatasourceConnectionConfig.class); + DatasourceConnectionConfig updatedConfig = mock(DatasourceConnectionConfig.class); + DatasourceConnectionConfig mergedConfig = mock(DatasourceConnectionConfig.class); + when(currentConfig.mergeWithUpdatedConfig(updatedConfig)).thenReturn(mergedConfig); + when(repository.findById(DATASOURCE_ID)).thenReturn(Mono.just(datasource(DATASOURCE_ID, "old", JAVA_TYPE, currentConfig))); + + StepVerifier.create(service.testDatasource(datasource(DATASOURCE_ID, "new", JAVA_TYPE, updatedConfig))) + .expectNext(DatasourceTestResult.testSuccess()) + .verifyComplete(); + verify(connector).doValidateConfig(mergedConfig); + verify(connector).doTestConnection(mergedConfig); + System.out.println("[DatasourceServiceImplTest] test with a known id: merged config validated and tested"); + } + + /** Catches the wrong test path per plugin kind (:206): JS plugins go to the node client, Java plugins to the connector. */ + @Test + void testDatasource_jsPluginGoesToTheNodeClient_javaPluginToTheLocalConnector() { + JsDatasourceConnectionConfig jsConfig = new JsDatasourceConnectionConfig(); + DatasourceTestResult nodeResult = DatasourceTestResult.testFail("from node"); + when(pluginClient.test(JS_TYPE, jsConfig)).thenReturn(Mono.just(nodeResult)); + + StepVerifier.create(service.testDatasource(datasource(null, "js", JS_TYPE, jsConfig))).expectNext(nodeResult).verifyComplete(); + verify(connector, never()).doTestConnection(any()); + + StepVerifier.create(service.testDatasource(datasource(null, "pg", JAVA_TYPE, mock(DatasourceConnectionConfig.class)))) + .expectNext(DatasourceTestResult.testSuccess()).verifyComplete(); + verify(pluginClient).test(eq(JS_TYPE), any()); + System.out.println("[DatasourceServiceImplTest] test routing: JS -> node client, Java -> local connector"); + } + + /** Catches a connector failure surfacing as an error signal (:217): it must become a failed test result. */ + @Test + void testDatasource_connectorError_becomesAFailedResultNotAnError() { + when(connector.doTestConnection(any())).thenReturn(Mono.error(new IllegalStateException("boom"))); + + StepVerifier.create(service.testDatasource(datasource(null, "pg", JAVA_TYPE, mock(DatasourceConnectionConfig.class)))) + .assertNext(result -> { + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getInvalidMessage(java.util.Locale.ENGLISH)).contains("boom"); + }) + .verifyComplete(); + System.out.println("[DatasourceServiceImplTest] connector error -> failed DatasourceTestResult"); + } + + /** Catches a hanging connector blocking the request (:216): after 10 seconds (virtual time) the test fails as a timeout. */ + @Test + void testDatasource_connectorNeverAnswers_failsAsTimeoutAfterTenSeconds() { + when(connector.doTestConnection(any())).thenReturn(Mono.never()); + + StepVerifier.withVirtualTime(() -> service.testDatasource(datasource(null, "pg", JAVA_TYPE, mock(DatasourceConnectionConfig.class)))) + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(9)) + .thenAwait(Duration.ofSeconds(1)) + .assertNext(result -> { + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getInvalidMessage(java.util.Locale.ENGLISH)) + .isEqualTo(LocaleUtils.getMessage(java.util.Locale.ENGLISH, "DATASOURCE_TEST_TIMEOUT_ERROR")); + }) + .expectComplete() + .verify(Duration.ofSeconds(5)); + System.out.println("[DatasourceServiceImplTest] connector without answer -> timeout result after 10 s"); + } + + // ---------------------------------------------------------------- shared static datasource (section 9 candidate) + + /** + * Pins the plan section 9 row "testDatasource merges into the shared static datasource": for a system-static id, + * {@code getById} returns the shared constant and {@code mergeWith} (:200) mutates it, so a connection test changes + * the global QUICK_REST_API for every later caller. The shared constant is restored in {@code finally}. A fix (merge + * into a copy) changes this test on purpose. + */ + @Test + void testDatasource_staticId_mutatesTheSharedStaticDatasource() { + Datasource shared = Datasource.QUICK_REST_API; + String originalName = shared.getName(); + DatasourceStatus originalStatus = shared.getDatasourceStatus(); + DatasourceConnectionConfig originalConfig = shared.getDetailConfig(); + try { + Datasource request = datasource(Datasource.QUICK_REST_API_ID, "hijacked-name", shared.getType(), RestApiDatasourceConfig.EMPTY_CONFIG); + request.setDatasourceStatus(DatasourceStatus.DELETED); + + // the static datasource has no organization id, so validation fails after the merge: the outcome is not the point + StepVerifier.create(service.testDatasource(request)).expectError(BizException.class).verify(); + + assertThat(shared.getName()).as("the shared constant was mutated by the test request").isEqualTo("hijacked-name"); + assertThat(shared.getDatasourceStatus()).isEqualTo(DatasourceStatus.DELETED); + } finally { + shared.setName(originalName); + shared.setDatasourceStatus(originalStatus); + shared.setDetailConfig(originalConfig); + } + assertThat(Datasource.QUICK_REST_API.getName()).isEqualTo(originalName); + System.out.println("[DatasourceServiceImplTest] pins the section 9 row: static datasource mutated by testDatasource, restored"); + } + + // ---------------------------------------------------------------- removePasswordTypeKeys... + + private static JsDatasourceConnectionConfig jsConfigWithPasswordParam() { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + config.setDefinition(Map.of("dataSourceConfig", Map.of("params", List.of( + Map.of("type", "password", "key", "secret"), + Map.of("type", "input", "key", "host"))))); + config.put("secret", "s3cr3t"); + config.put("host", "db.example.com"); + return config; + } + + /** Catches secrets going unmasked to the client (:230): JS plugin password-type keys are removed, others kept. */ + @Test + void removePasswordTypeKeys_jsPlugin_removesPasswordKeysAfterFillingTheDefinition() { + JsDatasourceConnectionConfig config = jsConfigWithPasswordParam(); + Datasource datasource = datasource(DATASOURCE_ID, "js", JS_TYPE, config); + + StepVerifier.create(service.removePasswordTypeKeysFromJsDatasourcePluginConfig(datasource)).verifyComplete(); + + assertThat(config).doesNotContainKey("secret").containsEntry("host", "db.example.com"); + verify(jsDatasourceHelper).fillPluginDefinition(datasource); + System.out.println("[DatasourceServiceImplTest] JS plugin: password-type keys removed, other keys kept"); + } + + /** Catches passwords being removed even when filling the definition fails (doFinally), and for non-JS plugins (:228). */ + @Test + void removePasswordTypeKeys_removesEvenWhenFillFails_andNeverForJavaPlugins() { + JsDatasourceConnectionConfig failing = jsConfigWithPasswordParam(); + Datasource failingDatasource = datasource(DATASOURCE_ID, "js", JS_TYPE, failing); + when(jsDatasourceHelper.fillPluginDefinition(failingDatasource)).thenReturn(Mono.error(new IllegalStateException("node down"))); + StepVerifier.create(service.removePasswordTypeKeysFromJsDatasourcePluginConfig(failingDatasource)) + .expectError(IllegalStateException.class).verify(); + assertThat(failing).doesNotContainKey("secret"); + + JsDatasourceConnectionConfig notRemoved = jsConfigWithPasswordParam(); + Datasource javaDatasource = datasource(DATASOURCE_ID, "pg", JAVA_TYPE, notRemoved); + StepVerifier.create(service.removePasswordTypeKeysFromJsDatasourcePluginConfig(javaDatasource)).verifyComplete(); + assertThat(notRemoved).as("a Java plugin config is not touched").containsKey("secret"); + System.out.println("[DatasourceServiceImplTest] passwords removed in doFinally; Java plugin config untouched"); + } + + // ---------------------------------------------------------------- retain / delete + + /** Catches a repository call for an empty or null id collection (:252). */ + @Test + void retainNoneExist_emptyOrNullCollection_returnsEmptyWithoutRepository_otherwiseDelegates() { + StepVerifier.create(service.retainNoneExistAndNonCurrentOrgDatasourceIds(List.of(), ORG_ID)).verifyComplete(); + StepVerifier.create(service.retainNoneExistAndNonCurrentOrgDatasourceIds(null, ORG_ID)).verifyComplete(); + verifyNoInteractions(repository); + + when(repository.retainNoneExistAndNonCurrentOrgDatasourceIds(List.of("a", "b"), ORG_ID)).thenReturn(Flux.just("b")); + StepVerifier.create(service.retainNoneExistAndNonCurrentOrgDatasourceIds(List.of("a", "b"), ORG_ID)).expectNext("b").verifyComplete(); + System.out.println("[DatasourceServiceImplTest] retain: empty/null short-circuit, otherwise delegated"); + } + + private static Application application(ApplicationStatus status) { + return Application.builder().applicationStatus(status).build(); + } + + /** Counts subscriptions: production builds markDatasourceAsDeleted(...) eagerly inside then(...), only a subscription writes. */ + private AtomicInteger countDeleteSubscriptions() { + AtomicInteger subscriptions = new AtomicInteger(); + when(repository.markDatasourceAsDeleted(DATASOURCE_ID)).thenReturn(Mono.fromSupplier(() -> { + subscriptions.incrementAndGet(); + return true; + })); + return subscriptions; + } + + /** Catches deleting a datasource that live applications still use (:262): their queries would break. */ + @ParameterizedTest + @EnumSource(value = ApplicationStatus.class, names = {"NORMAL", "RECYCLED"}) + void delete_stillUsedByANonDeletedApplication_failsAndDoesNotMarkDeleted(ApplicationStatus status) { + AtomicInteger subscriptions = countDeleteSubscriptions(); + when(applicationRepository.findByDatasourceId(DATASOURCE_ID)).thenReturn(Flux.just(application(ApplicationStatus.DELETED), application(status))); + + StepVerifier.create(service.delete(DATASOURCE_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.DATASOURCE_DELETE_FAIL_DUE_TO_REMAINING_QUERIES)) + .verify(); + assertThat(subscriptions.get()).isZero(); + System.out.println("[DatasourceServiceImplTest] delete refused while a " + status + " application uses the datasource"); + } + + /** Catches deleted applications blocking the delete (:274), and the delete not being executed once allowed. */ + @Test + void delete_unusedOrOnlyUsedByDeletedApplications_marksDeletedOnce() { + AtomicInteger subscriptions = countDeleteSubscriptions(); + when(applicationRepository.findByDatasourceId(DATASOURCE_ID)).thenReturn(Flux.just(application(ApplicationStatus.DELETED))); + StepVerifier.create(service.delete(DATASOURCE_ID)).expectNext(true).verifyComplete(); + assertThat(subscriptions.get()).isEqualTo(1); + + when(applicationRepository.findByDatasourceId(DATASOURCE_ID)).thenReturn(Flux.empty()); + StepVerifier.create(service.delete(DATASOURCE_ID)).expectNext(true).verifyComplete(); + assertThat(subscriptions.get()).isEqualTo(2); + System.out.println("[DatasourceServiceImplTest] delete allowed for unused / only-deleted-app datasources"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/DatasourceStructureServiceImplTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/DatasourceStructureServiceImplTest.java new file mode 100644 index 0000000000..dc8deacab6 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/DatasourceStructureServiceImplTest.java @@ -0,0 +1,207 @@ +package org.lowcoder.domain.datasource.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.concurrent.TimeoutException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceConnectionHolder; +import org.lowcoder.domain.datasource.model.DatasourceStructureDO; +import org.lowcoder.domain.datasource.repository.DatasourceStructureRepository; +import org.lowcoder.domain.datasource.service.DatasourceConnectionPool; +import org.lowcoder.domain.datasource.service.DatasourceService; +import org.lowcoder.domain.plugin.DatasourceMetaInfo; +import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService; +import org.lowcoder.infra.mongo.MongoUpsertHelper; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.plugin.common.QueryExecutor; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@code DatasourceStructureServiceImpl} (unit U11, row L3-3): cache lookup versus fresh read, saving, the empty + * fallback and the error wrapping. The service has private {@code @Autowired} fields, injected reflectively. + */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class DatasourceStructureServiceImplTest { + + private static final String DATASOURCE_ID = "ds-1"; + private static final String TYPE = "postgres"; + private static final long READ_STRUCTURE_TIMEOUT_MS = 5000; + + private DatasourceService datasourceService; + private DatasourceMetaInfoService metaInfoService; + private DatasourceConnectionPool connectionPool; + private DatasourceStructureRepository structureRepository; + private MongoUpsertHelper mongoUpsertHelper; + private QueryExecutor executor; + private DatasourceConnectionHolder holder; + private Datasource datasource; + private DatasourceConnectionConfig config; + private DatasourceStructureServiceImpl service; + + @BeforeEach + void setUp() { + datasourceService = mock(DatasourceService.class); + metaInfoService = mock(DatasourceMetaInfoService.class); + connectionPool = mock(DatasourceConnectionPool.class); + structureRepository = mock(DatasourceStructureRepository.class); + mongoUpsertHelper = mock(MongoUpsertHelper.class); + executor = mock(QueryExecutor.class); + holder = mock(DatasourceConnectionHolder.class); + config = mock(DatasourceConnectionConfig.class); + datasource = Datasource.builder().id(DATASOURCE_ID).name("pg").type(TYPE).organizationId("org").detailConfig(config).build(); + + CommonConfig commonConfig = new CommonConfig(); + commonConfig.getQuery().setReadStructureTimeout(READ_STRUCTURE_TIMEOUT_MS); + service = new DatasourceStructureServiceImpl(); + ReflectionTestUtils.setField(service, "commonConfig", commonConfig); + ReflectionTestUtils.setField(service, "datasourceService", datasourceService); + ReflectionTestUtils.setField(service, "datasourceMetaInfoService", metaInfoService); + ReflectionTestUtils.setField(service, "connectionContextService", connectionPool); + ReflectionTestUtils.setField(service, "datasourceStructureRepository", structureRepository); + ReflectionTestUtils.setField(service, "mongoUpsertHelper", mongoUpsertHelper); + + when(structureRepository.findByDatasourceId(DATASOURCE_ID)).thenReturn(Mono.empty()); + when(datasourceService.getById(DATASOURCE_ID)).thenReturn(Mono.just(datasource)); + when(metaInfoService.getDatasourceMetaInfo(TYPE)).thenReturn(DatasourceMetaInfo.builder().type(TYPE).hasStructureInfo(true).build()); + when(metaInfoService.getQueryExecutor(TYPE)).thenReturn(executor); + when(connectionPool.getOrCreateConnection(datasource)).thenReturn((Mono) Mono.just(holder)); + when(holder.connection()).thenReturn("connection-object"); + when(mongoUpsertHelper.upsertWithAuditingParams(any(DatasourceStructureDO.class), eq("datasourceId"), eq(DATASOURCE_ID))) + .thenAnswer(invocation -> Mono.just(invocation.getArgument(0))); + } + + private void stubExecutorReturns(DatasourceStructure structure) { + when(executor.doGetStructure("connection-object", config)).thenReturn(Mono.just(structure)); + } + + /** Catches a remote structure read on every call: a cached structure is returned without touching the datasource. */ + @Test + void getStructure_cacheHit_returnsTheCachedStructureWithoutTouchingTheDatasource() { + DatasourceStructure cached = new DatasourceStructure(java.util.List.of()); + DatasourceStructureDO row = new DatasourceStructureDO(); + row.setDatasourceId(DATASOURCE_ID); + row.setStructure(cached); + when(structureRepository.findByDatasourceId(DATASOURCE_ID)).thenReturn(Mono.just(row)); + + StepVerifier.create(service.getStructure(DATASOURCE_ID, false)).expectNext(cached).verifyComplete(); + verifyNoInteractions(datasourceService, connectionPool, mongoUpsertHelper); + System.out.println("[DatasourceStructureServiceImplTest] cache hit -> cached structure, no remote read"); + } + + /** Catches a stale structure being kept (:82): a cache miss reads the latest structure and upserts it by datasource id. */ + @Test + void getStructure_cacheMiss_readsTheLatestStructureAndUpsertsIt() { + DatasourceStructure latest = new DatasourceStructure(java.util.List.of()); + stubExecutorReturns(latest); + + StepVerifier.create(service.getStructure(DATASOURCE_ID, false)).expectNext(latest).verifyComplete(); + + ArgumentCaptor saved = ArgumentCaptor.forClass(DatasourceStructureDO.class); + verify(mongoUpsertHelper).upsertWithAuditingParams(saved.capture(), eq("datasourceId"), eq(DATASOURCE_ID)); + assertThat(saved.getValue().getDatasourceId()).isEqualTo(DATASOURCE_ID); + assertThat(saved.getValue().getStructure()).isSameAs(latest); + System.out.println("[DatasourceStructureServiceImplTest] cache miss -> latest structure read and upserted"); + } + + /** Catches ignoreCache still reading the cache (:63): the cache is skipped and refreshed. */ + @Test + void getStructure_ignoreCache_skipsTheCacheAndRefreshesIt() { + DatasourceStructure latest = new DatasourceStructure(java.util.List.of()); + stubExecutorReturns(latest); + + StepVerifier.create(service.getStructure(DATASOURCE_ID, true)).expectNext(latest).verifyComplete(); + verify(structureRepository, never()).findByDatasourceId(any()); + verify(mongoUpsertHelper).upsertWithAuditingParams(any(DatasourceStructureDO.class), eq("datasourceId"), eq(DATASOURCE_ID)); + System.out.println("[DatasourceStructureServiceImplTest] ignoreCache -> cache not read, structure refreshed"); + } + + /** Catches a structure read on a plugin without structure info (:76) and the empty fallback being lost (:51). */ + @Test + void getStructure_pluginWithoutStructureInfo_orUnknownDatasource_returnsAnEmptyStructure() { + when(metaInfoService.getDatasourceMetaInfo(TYPE)).thenReturn(DatasourceMetaInfo.builder().type(TYPE).hasStructureInfo(false).build()); + StepVerifier.create(service.getStructure(DATASOURCE_ID, false)) + .assertNext(structure -> assertThat(structure.getTables()).isNull()) + .verifyComplete(); + + when(datasourceService.getById("missing")).thenReturn(Mono.empty()); + when(structureRepository.findByDatasourceId("missing")).thenReturn(Mono.empty()); + StepVerifier.create(service.getStructure("missing", false)) + .assertNext(structure -> assertThat(structure.getTables()).isNull()) + .verifyComplete(); + verifyNoInteractions(connectionPool, mongoUpsertHelper); + System.out.println("[DatasourceStructureServiceImplTest] no structure info / unknown datasource -> empty structure, nothing read or saved"); + } + + /** Catches raw exceptions reaching the client (:53, :111): other errors are wrapped, a PluginException passes unchanged. */ + @Test + void getStructure_errors_areWrappedAsStructureErrors_pluginExceptionsPassThrough() { + when(connectionPool.getOrCreateConnection(datasource)).thenReturn(Mono.error(new IllegalStateException("pool exhausted"))); + StepVerifier.create(service.getStructure(DATASOURCE_ID, false)) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(PluginException.class); + PluginException plugin = (PluginException) error; + assertThat(plugin.getError()).isEqualTo(PluginCommonError.DATASOURCE_GET_STRUCTURE_ERROR); + assertThat(plugin.getMessageKey()).isEqualTo("DATASOURCE_GET_STRUCTURE_ERROR"); + assertThat(plugin.getArgs()).containsExactly("pool exhausted"); + }) + .verify(); + + PluginException original = new PluginException(PluginCommonError.DATASOURCE_GET_STRUCTURE_ERROR, "DATASOURCE_GET_STRUCTURE_ERROR", "original"); + when(connectionPool.getOrCreateConnection(datasource)).thenReturn(Mono.error(original)); + StepVerifier.create(service.getStructure(DATASOURCE_ID, false)) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(original)) + .verify(); + System.out.println("[DatasourceStructureServiceImplTest] errors wrapped; PluginException unchanged"); + } + + /** + * Pins the plan section 9 row "DatasourceStructureServiceImpl: a structure read that times out ... the timeout code + * never reaches the caller" (:107 versus :110-116): the timeout is mapped to BizException(PLUGIN_EXECUTION_TIMEOUT), + * which is not a PluginException, so the outer onErrorMap rewraps it as + * PluginException(DATASOURCE_GET_STRUCTURE_ERROR) carrying the BizException message. The holder is told about the + * timeout (doOnError, :106). A fix changes this test on purpose. + */ + @Test + void getStructure_executorTimeout_notifiesTheHolderAndSurfacesOnlyTheGenericStructureError() { + when(executor.doGetStructure("connection-object", config)).thenReturn(Mono.never()); + String expectedMessage = new BizException(BizError.PLUGIN_EXECUTION_TIMEOUT, "PLUGIN_EXECUTION_TIMEOUT", READ_STRUCTURE_TIMEOUT_MS).getMessage(); + + StepVerifier.withVirtualTime(() -> service.getStructure(DATASOURCE_ID, true)) + .expectSubscription() + .expectNoEvent(Duration.ofMillis(READ_STRUCTURE_TIMEOUT_MS - 1)) + .thenAwait(Duration.ofMillis(1)) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(PluginException.class); + PluginException plugin = (PluginException) error; + assertThat(plugin.getError()).isEqualTo(PluginCommonError.DATASOURCE_GET_STRUCTURE_ERROR); + assertThat(plugin.getArgs()).containsExactly(expectedMessage); + }) + .verify(Duration.ofSeconds(5)); + + ArgumentCaptor reported = ArgumentCaptor.forClass(Throwable.class); + verify(holder).onQueryError(reported.capture()); + assertThat(reported.getValue()).isInstanceOf(TimeoutException.class); + System.out.println("[DatasourceStructureServiceImplTest] pins the section 9 row: timeout surfaces as DATASOURCE_GET_STRUCTURE_ERROR"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/StatelessConnectionPoolTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/StatelessConnectionPoolTest.java new file mode 100644 index 0000000000..7abab5c9f7 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/StatelessConnectionPoolTest.java @@ -0,0 +1,36 @@ +package org.lowcoder.domain.datasource.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceConnectionHolder; +import org.lowcoder.domain.datasource.model.StatelessDatasourceConnectionHolder; + +import reactor.test.StepVerifier; + +/** {@code StatelessConnectionPool} (unit U5, task L3-5): nothing to cache, nothing to invalidate. */ +class StatelessConnectionPoolTest { + + /** Catches a stateless datasource getting a stateful holder: a holder is handed out, its errors do nothing, info is unsupported. */ + @Test + void getOrCreateConnection_returnsAStatelessHolder_whoseErrorsAreIgnored_andInfoIsUnsupported() { + StatelessConnectionPool pool = new StatelessConnectionPool(); + Datasource datasource = Datasource.builder().id("L3-5-stateless").type("restApi").build(); + + StepVerifier.create(pool.getOrCreateConnection(datasource)) + .assertNext(holder -> { + assertThat(holder).isInstanceOf(StatelessDatasourceConnectionHolder.class); + assertThat(holder.connection()).isNotNull(); + holder.onQueryError(new IllegalStateException("ignored")); + }) + .verifyComplete(); + + DatasourceConnectionHolder first = pool.getOrCreateConnection(datasource).block(); + DatasourceConnectionHolder second = pool.getOrCreateConnection(datasource).block(); + assertThat(first.connection()).as("the shared constant connection object").isSameAs(second.connection()); + assertThatThrownBy(() -> pool.info("L3-5-stateless")).isInstanceOf(UnsupportedOperationException.class); + System.out.println("[StatelessConnectionPoolTest] stateless holder, constant connection, info unsupported"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/TokenBasedConnectionPoolTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/TokenBasedConnectionPoolTest.java new file mode 100644 index 0000000000..c5c5cd07b2 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/TokenBasedConnectionPoolTest.java @@ -0,0 +1,172 @@ +package org.lowcoder.domain.datasource.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.TokenBasedConnection; +import org.lowcoder.domain.datasource.model.TokenBasedConnectionHolder; +import org.lowcoder.domain.datasource.repository.TokenBasedConnectionRepository; +import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.models.TokenBasedConnectionDetail; +import org.lowcoder.sdk.plugin.common.DatasourceConnector; +import org.mockito.ArgumentCaptor; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@code TokenBasedConnectionPool} (unit U5, task L3-5): a stored token is reused while it is fresh, and fetched and + * saved again when it is missing or stale. Mockito repository, connector and token detail. + */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class TokenBasedConnectionPoolTest { + + private static final String TYPE = "L3-5-token-type"; + private static final String DATASOURCE_ID = "L3-5-token-ds"; + + private TokenBasedConnectionRepository repository; + private DatasourceConnector connector; + private TokenBasedConnectionPool pool; + private DatasourceConnectionConfig config; + private Datasource datasource; + + @BeforeEach + void setUp() { + repository = mock(TokenBasedConnectionRepository.class); + connector = mock(DatasourceConnector.class); + DatasourceMetaInfoService metaInfoService = mock(DatasourceMetaInfoService.class); + when(metaInfoService.getDatasourceConnector(TYPE)).thenReturn(connector); + pool = new TokenBasedConnectionPool(metaInfoService, repository); + config = mock(DatasourceConnectionConfig.class); + datasource = Datasource.builder().id(DATASOURCE_ID).type(TYPE).detailConfig(config).build(); + when(repository.findByDatasourceId(DATASOURCE_ID, TYPE)).thenReturn(Mono.empty()); + when(repository.saveConnection(any(), eq(DATASOURCE_ID))).thenReturn(Mono.empty()); + } + + private static TokenBasedConnection stored(TokenBasedConnectionDetail detail, Instant updatedAt) { + TokenBasedConnection connection = new TokenBasedConnection(); + connection.setDatasourceId(DATASOURCE_ID); + connection.setTokenDetail(detail); + connection.setUpdatedAt(updatedAt); + return connection; + } + + private static TokenBasedConnectionDetail detail(boolean stale) { + TokenBasedConnectionDetail detail = mock(TokenBasedConnectionDetail.class); + when(detail.isStale()).thenReturn(stale); + return detail; + } + + /** Catches a missing token never being fetched (:39-44) or not saved (:44, :65-66), and the holder not exposing the new detail. */ + @Test + void noStoredToken_createsSavesAndReturnsTheNewConnection() { + TokenBasedConnectionDetail fresh = detail(false); + when(connector.doCreateConnection(config)).thenReturn(Mono.just(fresh)); + + StepVerifier.create(pool.getOrCreateConnection(datasource)) + .assertNext(holder -> assertThat(holder.connection()).isSameAs(fresh)) + .verifyComplete(); + + ArgumentCaptor saved = ArgumentCaptor.forClass(TokenBasedConnection.class); + verify(repository).saveConnection(saved.capture(), eq(DATASOURCE_ID)); + assertThat(saved.getValue().getDatasourceId()).isEqualTo(DATASOURCE_ID); + assertThat(saved.getValue().getTokenDetail()).isSameAs(fresh); + System.out.println("[TokenBasedConnectionPoolTest] no stored token -> created, saved, returned"); + } + + /** Catches the token being refetched on every query (:41, :47): a stored, fresh token is reused. */ + @Test + void freshStoredToken_isReusedWithoutCreatingOrSaving() { + TokenBasedConnectionDetail storedDetail = detail(false); + when(repository.findByDatasourceId(DATASOURCE_ID, TYPE)).thenReturn(Mono.just(stored(storedDetail, Instant.now()))); + datasource.setUpdatedAt(Instant.now().minus(1, ChronoUnit.HOURS)); + + StepVerifier.create(pool.getOrCreateConnection(datasource)) + .assertNext(holder -> assertThat(holder.connection()).isSameAs(storedDetail)) + .verifyComplete(); + verify(connector, never()).doCreateConnection(any()); + verify(repository, never()).saveConnection(any(), any()); + System.out.println("[TokenBasedConnectionPoolTest] fresh stored token reused"); + } + + /** Catches a stale token being reused (:41-42): an expired token and a datasource edited after the token are refetched. */ + @ParameterizedTest(name = "tokenExpired={0}") + @ValueSource(booleans = {true, false}) + void staleStoredToken_isFetchedAndSavedAgain(boolean tokenExpired) { + TokenBasedConnectionDetail oldDetail = detail(tokenExpired); + Instant tokenTime = Instant.now().minus(1, ChronoUnit.HOURS); + when(repository.findByDatasourceId(DATASOURCE_ID, TYPE)).thenReturn(Mono.just(stored(oldDetail, tokenTime))); + datasource.setUpdatedAt(tokenExpired ? tokenTime.minusSeconds(60) : Instant.now()); + TokenBasedConnectionDetail newDetail = detail(false); + when(connector.doCreateConnection(config)).thenReturn(Mono.just(newDetail)); + + StepVerifier.create(pool.getOrCreateConnection(datasource)) + .assertNext(holder -> assertThat(holder.connection()).isSameAs(newDetail)) + .verifyComplete(); + verify(repository).saveConnection(any(), eq(DATASOURCE_ID)); + System.out.println("[TokenBasedConnectionPoolTest] stale token (expired=" + tokenExpired + ") -> refetched and saved"); + } + + /** Catches a connector that yields nothing passing silently (:43): PLUGIN_CREATE_CONNECTION_FAILED, nothing saved. */ + @Test + void connectorReturnsNothing_failsWithPluginCreateConnectionFailed() { + when(connector.doCreateConnection(config)).thenReturn(Mono.empty()); + + StepVerifier.create(pool.getOrCreateConnection(datasource)) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(BizError.PLUGIN_CREATE_CONNECTION_FAILED); + }) + .verify(); + verify(repository, never()).saveConnection(any(), any()); + System.out.println("[TokenBasedConnectionPoolTest] connector without connection -> PLUGIN_CREATE_CONNECTION_FAILED"); + } + + /** Catches a connection of the wrong class being saved (:58-59): DATASOURCE_TYPE_ERROR with the class name. */ + @Test + void connectorReturnsAWrongType_failsWithDatasourceTypeError() { + when(connector.doCreateConnection(config)).thenReturn(Mono.just("not a token detail")); + + StepVerifier.create(pool.getOrCreateConnection(datasource)) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(BizException.class); + BizException biz = (BizException) error; + assertThat(biz.getError()).isEqualTo(BizError.DATASOURCE_TYPE_ERROR); + assertThat(biz.getArgs()).containsExactly("String"); + }) + .verify(); + verify(repository, never()).saveConnection(any(), any()); + System.out.println("[TokenBasedConnectionPoolTest] wrong connection class -> DATASOURCE_TYPE_ERROR(String)"); + } + + /** Catches a failed save being swallowed (:65-66), and pins that info() is not supported. */ + @Test + void saveFailure_propagates_andInfoIsUnsupported() { + TokenBasedConnectionDetail created = detail(false); + when(connector.doCreateConnection(config)).thenReturn(Mono.just(created)); + IllegalStateException failure = new IllegalStateException("mongo down"); + when(repository.saveConnection(any(), eq(DATASOURCE_ID))).thenReturn(Mono.error(failure)); + + StepVerifier.create(pool.getOrCreateConnection(datasource)) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(failure)) + .verify(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> pool.info(DATASOURCE_ID)).isInstanceOf(UnsupportedOperationException.class); + System.out.println("[TokenBasedConnectionPoolTest] save failure propagated; info unsupported"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/folder/service/FolderTreeTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/folder/service/FolderTreeTest.java new file mode 100644 index 0000000000..91f0a29108 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/folder/service/FolderTreeTest.java @@ -0,0 +1,224 @@ +package org.lowcoder.domain.folder.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Decisions of the live folder tree {@code Tree / FolderNode / ElementNode} (unit U2 rescoped, task L3-8) that + * lowcoder-server's TreeTest does not cover: root and orphan mounting of elements, parent links and depth, the delete + * cascade list, the post-order contract, duplicate ids, the root's unsupported accessors and the child ordering. + * The bundle copy of these classes has no production caller and is deliberately not tested. + */ +class FolderTreeTest { + + record Fo(String id, String parent, String name, long view) { + Fo(String id, String parent) { + this(id, parent, id, 0); + } + } + + record El(String name, long view, String parent) { + El(String name, String parent) { + this(name, 0, parent); + } + } + + private static Tree tree(List folders, List elements, Comparator> comparator) { + return new Tree<>(folders, Fo::id, Fo::parent, elements, El::parent, comparator); + } + + private static String label(Node node) { + if (node instanceof Tree) { + return "ROOT"; + } + if (node instanceof ElementNode element) { + return element.getSelf().name(); + } + return ((FolderNode) node).getSelf().id(); + } + + /** Same shape as FolderApiServiceImpl.DEFAULT_COMPARATOR (last view time reversed, then name) on the test records. */ + private static final Comparator> VIEW_TIME_DESC_THEN_NAME = + Comparator.comparingLong((Node node) -> node instanceof ElementNode element + ? element.getSelf().view() : ((FolderNode) node).getSelf().view()) + .reversed() + .thenComparing(node -> node instanceof ElementNode element + ? element.getSelf().name() : ((FolderNode) node).getSelf().name()); + + /** Catches: null, empty or blank parent ids not meaning "root", and the kinds being mixed up by the accessors. */ + @Test + void blankParentIdsMountUnderTheRootAndAccessorsSeparateTheKinds() { + Tree tree = tree(List.of(new Fo("f-null", null), new Fo("f-empty", ""), new Fo("f-blank", " ")), + List.of(new El("e-null", null), new El("e-empty", ""), new El("e-blank", " ")), null); + + System.out.println("[FolderTreeTest] root folders=" + tree.getFolderChildren() + " elements=" + tree.getElementChildren()); + assertThat(tree.getFolderChildren()).extracting(Fo::id).containsExactlyInAnyOrder("f-null", "f-empty", "f-blank"); + assertThat(tree.getElementChildren()).extracting(El::name).containsExactly("e-null", "e-empty", "e-blank"); + } + + /** Catches: wrong parent links, depth off by one, and an element mounted at the wrong level. */ + @Test + void nestedNodesGetTheirParentLinkAndDepth() { + Fo f1 = new Fo("f1", null); + Fo f2 = new Fo("f2", "f1"); + Tree tree = tree(List.of(f1, f2), List.of(new El("e2", "f2")), null); + + FolderNode n1 = tree.get("f1"); + FolderNode n2 = tree.get("f2"); + System.out.println("[FolderTreeTest] depths root=" + tree.depth() + " f1=" + n1.depth() + " f2=" + n2.depth()); + assertThat(tree.depth()).isEqualTo(1); + assertThat(n1.depth()).isEqualTo(2); + assertThat(n2.depth()).isEqualTo(3); + assertThat(n1.getParent()).isSameAs(tree); + assertThat(n2.getParent()).isSameAs(n1); + assertThat(n1.getFolderChildren()).containsExactly(f2); + assertThat(n2.getElementChildren()).extracting(El::name).containsExactly("e2"); + assertThat(n2.getChildren().iterator().next()).isInstanceOf(ElementNode.class); + assertThat(((ElementNode) n2.getChildren().iterator().next()).getParent()).isSameAs(n2); + } + + /** Catches: an element whose folder is unknown (deleted, or filtered out) vanishing from the listing. */ + @Test + void anElementWithAnUnknownFolderIsKeptAtTheRoot() { + Tree tree = tree(List.of(new Fo("f1", null)), List.of(new El("orphan", "gone"), new El("child", "f1")), null); + + assertThat(tree.getElementChildren()).extracting(El::name).containsExactly("orphan"); + assertThat(tree.get("f1").getElementChildren()).extracting(El::name).containsExactly("child"); + assertThat(((ElementNode) tree.getChildren().stream().filter(n -> n instanceof ElementNode).findFirst().orElseThrow()) + .getParent()).isSameAs(tree); + System.out.println("[FolderTreeTest] orphan element kept at the root, parent link is the root"); + } + + /** Catches: wrong fallback of get(): blank means the root, unknown means null (turned into FOLDER_NOT_EXIST by the caller). */ + @Test + void getReturnsTheRootForBlankTheNodeForKnownAndNullForUnknown() { + Tree tree = tree(List.of(new Fo("f1", null)), List.of(), null); + + assertThat(tree.get(null)).isSameAs(tree); + assertThat(tree.get("")).isSameAs(tree); + assertThat(tree.get(" ")).isSameAs(tree); + assertThat(tree.get("f1").getSelf().id()).isEqualTo("f1"); + assertThat(tree.get("missing")).isNull(); + } + + /** Catches: the delete cascade (FolderApiServiceImpl:152) missing descendants, listing the null root or elements. */ + @Test + void allFolderChildrenAreTheNestedFoldersChildrenFirstWithoutElementsOrTheRoot() { + Fo f1 = new Fo("f1", null); + Fo f2 = new Fo("f2", "f1"); + Fo f3 = new Fo("f3", "f2"); + Tree tree = tree(List.of(f1, f2, f3), List.of(new El("e1", "f1"), new El("e3", "f3")), null); + + List all = tree.getAllFolderChildren(); + System.out.println("[FolderTreeTest] all folders under the root: " + all); + assertThat(all).containsExactly(f3, f2, f1).doesNotContainNull(); + assertThat(tree.get("f1").getAllFolderChildren()).containsExactly(f3, f2); + assertThat(tree.get("f3").getAllFolderChildren()).isEmpty(); + } + + /** Catches: the visibility pass of getElements seeing a parent before its children (children first, root last). */ + @Test + void postOrderVisitsChildrenBeforeTheirFolderAndTheRootLast() { + Tree tree = tree(List.of(new Fo("f1", null), new Fo("f2", "f1")), + List.of(new El("e0", null), new El("e1", "f1"), new El("e2", "f2")), null); + + List visited = new ArrayList<>(); + tree.postOrderIterate(node -> visited.add(label(node))); + System.out.println("[FolderTreeTest] post-order: " + visited); + assertThat(visited).containsExactly("e2", "f2", "e1", "f1", "e0", "ROOT"); + } + + /** Catches: duplicate folder ids silently overwriting each other instead of failing the build of the tree. */ + @Test + void duplicateFolderIdsAreRejected() { + assertThatThrownBy(() -> tree(List.of(new Fo("dup", null), new Fo("dup", null)), List.of(), null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("dup"); + } + + /** Catches: the root pretending to have an id or a parent id. */ + @Test + void theRootHasNoIdOrParentId() { + Tree tree = tree(List.of(), List.of(), null); + assertThrows(UnsupportedOperationException.class, tree::id); + assertThrows(UnsupportedOperationException.class, tree::parentId); + assertThat(tree.toString()).startsWith("Tree{maxDepth=1"); + } + + /** Catches: the no-comparator tree reordering children. */ + @Test + void withoutAComparatorChildrenKeepTheirInsertionOrder() { + Tree tree = tree(List.of(), List.of(new El("e3", 3, null), new El("e1", 1, null), new El("e2", 2, null)), null); + assertThat(tree.getElementChildren()).extracting(El::name).containsExactly("e3", "e1", "e2"); + } + + /** + * Pins plan section 9 row "folder listings in heap order, FolderNode:29". With a comparator the children are a + * PriorityQueue and every accessor streams its heap array, so only the first child is guaranteed to be the + * comparator minimum. FolderApiServiceImpl.DEFAULT_COMPARATOR (last view time reversed, then name; used for the + * home listing at :349) is private in lowcoder-server and works on server view classes, so it is UNREACHABLE from the + * domain module; VIEW_TIME_DESC_THEN_NAME above, the same ordering on the test records, is used instead. + * Today 8 elements with view times 1..8 come back as e8, e7, e6, e4, e3, e2, e5, e1 instead of e8 ... e1. A fix + * (drain the queue in comparator order) changes this test on purpose. + */ + @Test + void pinsHeapOrderOfChildrenWhenAComparatorIsGiven() { + List elements = new ArrayList<>(); + for (int i = 1; i <= 8; i++) { + elements.add(new El("e" + i, i, null)); + } + Tree tree = tree(List.of(), elements, VIEW_TIME_DESC_THEN_NAME); + + List viaAccessor = tree.getElementChildren().stream().map(El::name).toList(); + List viaPostOrder = new ArrayList<>(); + tree.postOrderIterate(node -> viaPostOrder.add(label(node))); + System.out.println("[FolderTreeTest] PINNED heap order: " + viaAccessor + " (comparator order is e8..e1)"); + + assertThat(viaAccessor.get(0)).isEqualTo("e8"); + assertThat(viaAccessor).containsExactly("e8", "e7", "e6", "e4", "e3", "e2", "e5", "e1"); + assertThat(viaPostOrder).containsExactly("e8", "e7", "e6", "e4", "e3", "e2", "e5", "e1", "ROOT"); + } + + /** + * Pins plan section 9 row "self or cyclic parent: vanishes from listings; depth / getAllFolderChildren / + * postOrderIterate StackOverflowError" for the self-parent case: a folder whose parent id is its own id is mounted + * under itself, is missing from the root's listing and recurses forever. A fix (a self-parent guard) changes this + * test on purpose. + */ + @Test + void pinsASelfParentedFolderVanishingAndOverflowingTheStack() { + Fo self = new Fo("x", "x"); + Tree tree = tree(List.of(self, new Fo("ok", null)), List.of(), null); + FolderNode node = tree.get("x"); + + System.out.println("[FolderTreeTest] PINNED self parent: root folders=" + tree.getFolderChildren()); + assertThat(tree.getFolderChildren()).extracting(Fo::id).containsExactly("ok"); + assertThat(tree.getAllFolderChildren()).extracting(Fo::id).containsExactly("ok"); + assertThat(node.getParent()).isSameAs(node); + assertThat(node.getFolderChildren()).containsExactly(self); + assertThrows(StackOverflowError.class, node::depth); + assertThrows(StackOverflowError.class, node::getAllFolderChildren); + assertThrows(StackOverflowError.class, () -> node.postOrderIterate(n -> { })); + } + + /** Same pinned row for a two-folder cycle a -> b -> a: neither is reachable from the root, all walks overflow. */ + @Test + void pinsATwoFolderCycleVanishingAndOverflowingTheStack() { + Tree tree = tree(List.of(new Fo("a", "b"), new Fo("b", "a"), new Fo("ok", null)), List.of(), null); + + System.out.println("[FolderTreeTest] PINNED cycle: root folders=" + tree.getFolderChildren()); + assertThat(tree.getFolderChildren()).extracting(Fo::id).containsExactly("ok"); + assertThat(tree.getAllFolderChildren()).extracting(Fo::id).containsExactly("ok"); + assertThat(tree.get("a").getParent()).isSameAs(tree.get("b")); + assertThrows(StackOverflowError.class, () -> tree.get("a").depth()); + assertThrows(StackOverflowError.class, () -> tree.get("b").getAllFolderChildren()); + assertThrows(StackOverflowError.class, () -> tree.get("a").postOrderIterate(n -> { })); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/group/model/GroupPredicatesTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/group/model/GroupPredicatesTest.java new file mode 100644 index 0000000000..93a6e8cd9d --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/group/model/GroupPredicatesTest.java @@ -0,0 +1,176 @@ +package org.lowcoder.domain.group.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.lowcoder.domain.group.util.SystemGroups; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.Organization; + +/** + * Group, GroupMember and organisation-settings decisions (unit U4b, task L3-7). + */ +class GroupPredicatesTest { + + private static final String CUSTOM_NAME = "Team Rocket"; + private static final String GROUP_ID = "g1"; + private static final String SOURCE = "ldap"; + private static final long OLD_MILLIS = 1_000L; + private static final long NEWER_MILLIS = 2_000L; + + private static Group group(String name, Boolean allUsers, String type) { + return Group.builder().name(name).allUsersGroup(allUsers).type(type).createdAt(Instant.ofEpochMilli(OLD_MILLIS)).build(); + } + + // ---------------------------------------------------------------- GroupMember + + private static GroupMember member(String groupId, MemberRole role) { + return GroupMember.builder().groupId(groupId).userId("u1").role(role).orgId("o1").build(); + } + + /** Catches: admin and super-admin confused, or a null role counting as one of them. */ + @ParameterizedTest + @EnumSource(MemberRole.class) + void adminPredicatesMatchExactlyTheirOwnRole(MemberRole role) { + GroupMember member = member(GROUP_ID, role); + System.out.println("[GroupPredicatesTest] role " + role + " admin=" + member.isAdmin() + " super=" + member.isSuperAdmin()); + assertThat(member.isAdmin()).isEqualTo(role == MemberRole.ADMIN); + assertThat(member.isSuperAdmin()).isEqualTo(role == MemberRole.SUPER_ADMIN); + } + + @Test + void aMemberWithoutARoleIsNeitherAdminNorSuperAdmin() { + GroupMember member = member(GROUP_ID, null); + assertThat(member.isAdmin()).isFalse(); + assertThat(member.isSuperAdmin()).isFalse(); + } + + /** Catches: the NOT_EXIST sentinel or a blank/null group id counting as a real membership. */ + @Test + void membershipIsInvalidForTheSentinelAndForABlankGroupId() { + assertThat(GroupMember.NOT_EXIST.isInvalid()).isTrue(); + assertThat(GroupMember.NOT_EXIST.isValid()).isFalse(); + assertThat(member("", MemberRole.MEMBER).isInvalid()).isTrue(); + assertThat(member(" ", MemberRole.MEMBER).isInvalid()).isTrue(); + assertThat(member(null, MemberRole.MEMBER).isInvalid()).isTrue(); + + GroupMember real = member(GROUP_ID, MemberRole.MEMBER); + assertThat(real.isInvalid()).isFalse(); + assertThat(real.isValid()).isTrue(); + System.out.println("[GroupPredicatesTest] sentinel/blank invalid, real valid"); + } + + // ---------------------------------------------------------------- Group + + /** Catches: either half of the all-users detection (flag, or stored type) being dropped. */ + @Test + void allUsersGroupIsDetectedByTheFlagOrByTheStoredType() { + assertThat(group(CUSTOM_NAME, true, null).isAllUsersGroup()).isTrue(); + assertThat(group(CUSTOM_NAME, null, SystemGroups.ALL_USER).isAllUsersGroup()).isTrue(); + assertThat(group(CUSTOM_NAME, false, SystemGroups.ALL_USER).isAllUsersGroup()).isTrue(); + assertThat(group(CUSTOM_NAME, null, null).isAllUsersGroup()).isFalse(); + assertThat(group(CUSTOM_NAME, false, SystemGroups.DEV).isAllUsersGroup()).isFalse(); + } + + /** Catches: system-group detection missing the dev group, or flagging a custom group; the negation drifting. */ + @Test + void systemGroupsAreAllUsersAndDevOnly() { + Group allUsers = group("x", true, null); + Group dev = group("x", null, SystemGroups.DEV); + Group custom = group(CUSTOM_NAME, null, null); + + assertThat(dev.isDevGroup()).isTrue(); + assertThat(allUsers.isDevGroup()).isFalse(); + assertThat(allUsers.isSystemGroup()).isTrue(); + assertThat(dev.isSystemGroup()).isTrue(); + assertThat(custom.isSystemGroup()).isFalse(); + assertThat(allUsers.isNotSystemGroup()).isFalse(); + assertThat(dev.isNotSystemGroup()).isFalse(); + assertThat(custom.isNotSystemGroup()).isTrue(); + System.out.println("[GroupPredicatesTest] system group detection checked"); + } + + /** Catches: the all-users flag not overriding the stored type; a custom group's type being replaced. */ + @Test + void typeIsAllUsersWhenFlaggedAndOtherwiseStored() { + assertThat(group("x", true, "custom").getType()).isEqualTo(SystemGroups.ALL_USER); + assertThat(group("x", null, SystemGroups.DEV).getType()).isEqualTo(SystemGroups.DEV); + assertThat(group("x", null, null).getType()).isNull(); + } + + /** Catches: a system group showing its stored name, or a custom group showing a system name; the locale being ignored. */ + @Test + void nameIsLocalisedForSystemGroupsAndStoredForCustomOnes() { + assertThat(group("stored", true, null).getName(Locale.ENGLISH)).isEqualTo("All Users"); + assertThat(group("stored", null, SystemGroups.DEV).getName(Locale.ENGLISH)).isEqualTo("Developers"); + assertThat(group("stored", true, null).getName(Locale.CHINESE)).isEqualTo("所有用户"); + assertThat(group(CUSTOM_NAME, null, null).getName(Locale.ENGLISH)).isEqualTo(CUSTOM_NAME); + System.out.println("[GroupPredicatesTest] names: " + group("stored", true, null).getName(Locale.ENGLISH)); + } + + /** Catches: blank sources / rules counting as sync or dynamic groups. */ + @Test + void syncAndDynamicNeedANonBlankSourceOrRule() { + assertThat(Group.builder().source(SOURCE).build().isSyncGroup()).isTrue(); + assertThat(Group.builder().source(" ").build().isSyncGroup()).isFalse(); + assertThat(Group.builder().build().isSyncGroup()).isFalse(); + assertThat(Group.builder().dynamicRule("a=b").build().isDynamic()).isTrue(); + assertThat(Group.builder().dynamicRule("").build().isDynamic()).isFalse(); + assertThat(Group.builder().dynamicRule(" ").build().isDynamic()).isFalse(); + assertThat(Group.builder().build().isDynamic()).isFalse(); + } + + /** Catches: a group without audit data failing; create time in the wrong unit. */ + @Test + void createTimeIsEpochMillisOrZero() { + assertThat(Group.builder().createdAt(Instant.ofEpochMilli(NEWER_MILLIS)).build().getCreateTime()).isEqualTo(NEWER_MILLIS); + assertThat(Group.builder().build().getCreateTime()).isZero(); + assertThat(Organization.builder().createdAt(Instant.ofEpochMilli(NEWER_MILLIS)).build().getCreateTime()).isEqualTo(NEWER_MILLIS); + assertThat(Organization.builder().build().getCreateTime()).isZero(); + } + + /** Catches: the ordering of the listing: all users, dev, sync groups, then by creation time. */ + @Test + void groupsSortAllUsersThenDevThenSyncThenByCreationTime() { + Group allUsers = group("a", true, null); + Group dev = group("d", null, SystemGroups.DEV); + Group sync = Group.builder().name("s").source(SOURCE).createdAt(Instant.ofEpochMilli(NEWER_MILLIS * 10)).build(); + Group older = Group.builder().name("older").createdAt(Instant.ofEpochMilli(NEWER_MILLIS)).build(); + Group newer = Group.builder().name("newer").createdAt(Instant.ofEpochMilli(NEWER_MILLIS * 2)).build(); + + List sorted = List.of(newer, sync, older, dev, allUsers).stream().sorted().toList(); + System.out.println("[GroupPredicatesTest] sorted: " + sorted.stream().map(g -> g.getName(Locale.ENGLISH)).toList()); + assertThat(sorted).containsExactly(allUsers, dev, sync, older, newer); + } + + // ---------------------------------------------------------------- OrganizationCommonSettings + + /** Catches: the password-reset template leaking into an export; other keys lost; the original mutated. */ + @Test + void sanitizedDropsOnlyThePasswordResetTemplate() { + Organization.OrganizationCommonSettings settings = new Organization.OrganizationCommonSettings(); + settings.put(Organization.OrganizationCommonSettings.PASSWORD_RESET_EMAIL_TEMPLATE, "secret"); + settings.put("theme", "dark"); + + Organization.OrganizationCommonSettings sanitized = settings.sanitized(); + System.out.println("[GroupPredicatesTest] sanitized=" + sanitized + " original keys=" + settings.keySet()); + assertThat(sanitized).containsOnly(java.util.Map.entry("theme", "dark")); + assertThat(settings).hasSize(2); + assertThat(sanitized).isNotSameAs(settings); + } + + /** Catches: an empty settings map returning itself (callers mutate the result) or throwing. */ + @Test + void sanitizedOfEmptySettingsIsANewEmptyInstance() { + Organization.OrganizationCommonSettings empty = new Organization.OrganizationCommonSettings(); + Organization.OrganizationCommonSettings sanitized = empty.sanitized(); + assertThat(sanitized).isEmpty(); + assertThat(sanitized).isNotSameAs(empty); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/model/ResourcePermissionMatchTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/model/ResourcePermissionMatchTest.java new file mode 100644 index 0000000000..d6558ce490 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/model/ResourcePermissionMatchTest.java @@ -0,0 +1,79 @@ +package org.lowcoder.domain.permission.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * {@code ResourcePermission.matchUser/matchGroup} and the status factories of {@code UserPermissionOnResourceStatus} + * (unit U3, task L3-1): the predicates that decide who may do what. A null group set is not tested: the only caller + * passes a non-null set. + */ +class ResourcePermissionMatchTest { + + private static final String USER = "u1"; + private static final String GROUP = "g1"; + + private static ResourcePermission perm(ResourceHolder holder, String holderId, ResourceRole role) { + return ResourcePermission.builder().resourceType(ResourceType.APPLICATION).resourceId("r1") + .resourceHolder(holder).resourceHolderId(holderId).resourceRole(role).build(); + } + + /** Catches matchUser granting on a group holder, another user, or an insufficient role. */ + @Test + void matchUser_requiresUserHolder_sameId_andSufficientRole() { + assertThat(perm(ResourceHolder.USER, USER, ResourceRole.EDITOR).matchUser(USER, ResourceAction.EDIT_APPLICATIONS)).isTrue(); + assertThat(perm(ResourceHolder.GROUP, USER, ResourceRole.OWNER).matchUser(USER, ResourceAction.READ_APPLICATIONS)) + .as("group holder with the same id").isFalse(); + assertThat(perm(ResourceHolder.USER, "other", ResourceRole.OWNER).matchUser(USER, ResourceAction.READ_APPLICATIONS)) + .as("other user").isFalse(); + assertThat(perm(ResourceHolder.USER, USER, ResourceRole.VIEWER).matchUser(USER, ResourceAction.MANAGE_APPLICATIONS)) + .as("VIEWER cannot MANAGE").isFalse(); + System.out.println("[ResourcePermissionMatchTest] matchUser holder/id/role conditions checked"); + } + + /** Catches matchGroup granting on a user holder, a foreign group, an empty set, or an insufficient role. */ + @Test + void matchGroup_requiresGroupHolder_memberGroup_andSufficientRole() { + assertThat(perm(ResourceHolder.GROUP, GROUP, ResourceRole.EDITOR).matchGroup(Set.of(GROUP), ResourceAction.EDIT_APPLICATIONS)).isTrue(); + assertThat(perm(ResourceHolder.USER, GROUP, ResourceRole.OWNER).matchGroup(Set.of(GROUP), ResourceAction.READ_APPLICATIONS)) + .as("user holder whose id is in the set").isFalse(); + assertThat(perm(ResourceHolder.GROUP, "g-other", ResourceRole.OWNER).matchGroup(Set.of(GROUP), ResourceAction.READ_APPLICATIONS)) + .as("foreign group").isFalse(); + assertThat(perm(ResourceHolder.GROUP, GROUP, ResourceRole.OWNER).matchGroup(Set.of(), ResourceAction.READ_APPLICATIONS)) + .as("empty group set").isFalse(); + assertThat(perm(ResourceHolder.GROUP, GROUP, ResourceRole.VIEWER).matchGroup(Set.of(GROUP), ResourceAction.EDIT_APPLICATIONS)) + .as("VIEWER cannot EDIT").isFalse(); + System.out.println("[ResourcePermissionMatchTest] matchGroup holder/set/role conditions checked"); + } + + static Stream statuses() { + ResourcePermission granted = perm(ResourceHolder.USER, USER, ResourceRole.VIEWER); + return Stream.of( + Arguments.of("success", UserPermissionOnResourceStatus.success(granted), true, false, false, false), + Arguments.of("notInOrg", UserPermissionOnResourceStatus.notInOrg(), false, true, false, false), + Arguments.of("notEnoughPermission", UserPermissionOnResourceStatus.notEnoughPermission(), false, false, true, false), + Arguments.of("anonymousUser", UserPermissionOnResourceStatus.anonymousUser(), false, false, false, true), + Arguments.of("fail(NOT_IN_ORG)", UserPermissionOnResourceStatus.fail(UserPermissionOnResourceStatus.FailReason.NOT_IN_ORG), + false, true, false, false)); + } + + /** Catches a factory mapped to the wrong fail reason, or a status that is both granted and failed. */ + @ParameterizedTest(name = "{0}") + @MethodSource("statuses") + void status_exactlyOneOutcomeFlagIsSet(String name, UserPermissionOnResourceStatus status, boolean hasPermission, + boolean notInOrg, boolean notEnough, boolean anonymous) { + assertThat(status.hasPermission()).isEqualTo(hasPermission); + assertThat(status.failByNotInOrg()).isEqualTo(notInOrg); + assertThat(status.failByNotEnoughPermission()).isEqualTo(notEnough); + assertThat(status.failByAnonymousUser()).isEqualTo(anonymous); + assertThat(status.getPermission() != null).isEqualTo(hasPermission); + System.out.println("[ResourcePermissionMatchTest] status " + name + " flags checked"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/BundleIdUnderApplicationActionPermissionTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/BundleIdUnderApplicationActionPermissionTest.java new file mode 100644 index 0000000000..415c2ab535 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/BundleIdUnderApplicationActionPermissionTest.java @@ -0,0 +1,195 @@ +package org.lowcoder.domain.permission.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.sdk.exception.BizError.NOT_AUTHORIZED; +import static org.lowcoder.sdk.exception.BizError.NO_RESOURCE_FOUND; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.repository.ApplicationRepository; +import org.lowcoder.domain.application.service.ApplicationServiceImpl; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.model.ResourceHolder; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.domain.solutions.TemplateSolutionService; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.constants.Authentication; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.springframework.test.util.ReflectionTestUtils; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Pins candidate defect D6 of the L1 lane: the bundle flag setters ({@code BundleApiServiceImpl.setBundlePublicToAll}, + * {@code ...ToMarketplace}, {@code ...AsAgencyProfile}) ask {@code ResourcePermissionService.checkResourcePermissionWithError} + * for the application actions {@code SET_APPLICATIONS_*} on a BUNDLE id, while the bundle actions {@code SET_BUNDLES_*} + * exist and are used nowhere. + * + *

The real {@link ResourcePermissionServiceImpl}, the real {@link ApplicationPermissionHandler} and the real + * {@link ApplicationServiceImpl} run here over mocked repositories and mocked org/group services. The action's resource + * type (APPLICATION) selects the application handler, whose {@code getOrgId} looks the id up as an APPLICATION; a bundle id + * is not one, so the lookup ends in NO_RESOURCE_FOUND before org membership is read. The result is therefore the same for + * an organization admin, a super admin and a plain member: the setters cannot succeed for anybody, and the error is + * NO_RESOURCE_FOUND (HTTP 500) rather than NOT_AUTHORIZED. Only the anonymous user gets NOT_AUTHORIZED (the anonymous role + * may not do EDITOR actions). + * + *

These tests pin today's behaviour (D-6). A fix (the setters asking SET_BUNDLES_* or the handler resolving bundles) + * changes {@link #bundleIdUnderApplicationAction_failsForEveryUser_pinsTheSection9Row} on purpose. The unit test cannot show + * what the controller returns over HTTP, nor that no bundle was changed (the bundle service is not in this chain). + */ +class BundleIdUnderApplicationActionPermissionTest { + + private static final String USER_ID = "user-1"; + private static final String ORG_ID = "org1"; + private static final String BUNDLE_ID = "bundleid1"; + private static final String BUNDLE_GID = "bundle-gid-1"; // an id with a dash is looked up as a GID + private static final String APPLICATION_ID = "applicationid1"; + + private ApplicationRepository applicationRepository; + private OrgMemberService orgMemberService; + private ResourcePermissionRepository permissionRepository; + private ResourcePermissionHandlerService bundleHandler; + private ResourcePermissionServiceImpl service; + + @BeforeEach + void setUp() { + applicationRepository = mock(ApplicationRepository.class); + orgMemberService = mock(OrgMemberService.class); + GroupMemberService groupMemberService = mock(GroupMemberService.class); + permissionRepository = mock(ResourcePermissionRepository.class); + bundleHandler = mock(ResourcePermissionHandlerService.class); + ResourcePermissionHandlerService datasourceHandler = mock(ResourcePermissionHandlerService.class); + + // the real application service: only the repository is mocked, so findByIdWithoutDsl keeps its real fall-through + ApplicationServiceImpl applicationService = new ApplicationServiceImpl(null, null, applicationRepository, null, null); + TemplateSolutionService templateSolutionService = mock(TemplateSolutionService.class); + when(templateSolutionService.getTemplateApplicationIds(anyCollection())).thenReturn(Mono.just(Set.of())); + + ApplicationPermissionHandler applicationHandler = new ApplicationPermissionHandler(applicationService, templateSolutionService); + service = new ResourcePermissionServiceImpl(permissionRepository, applicationHandler, datasourceHandler, bundleHandler); + ReflectionTestUtils.setField(applicationHandler, "resourcePermissionService", service); + ReflectionTestUtils.setField(applicationHandler, "groupMemberService", groupMemberService); + ReflectionTestUtils.setField(applicationHandler, "orgMemberService", orgMemberService); + ReflectionTestUtils.setField(applicationHandler, "config", mock(CommonConfig.class)); + + // no application, whatever the form of the id (slug, GID or object id): what a bundle id looks like to this lookup + when(applicationRepository.findBySlug(anyString())).thenReturn(Flux.empty()); + when(applicationRepository.findByGid(anyString())).thenReturn(Flux.empty()); + when(applicationRepository.findById(anyString())).thenReturn(Mono.empty()); + when(groupMemberService.getUserGroupIdsInOrg(anyString(), anyString())).thenReturn(Mono.just(List.of())); + } + + private void stubMember(MemberRole role) { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(OrgMember.builder() + .orgId(ORG_ID).userId(USER_ID).role(role).build())); + } + + private static void assertBizError(Throwable error, BizError expected) { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getBizErrorCode()).as("error code of %s", error).isEqualTo(expected.getBizErrorCode()); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(value = ResourceAction.class, names = { + "SET_APPLICATIONS_PUBLIC", "SET_APPLICATIONS_PUBLIC_TO_MARKETPLACE", "SET_APPLICATIONS_AS_AGENCY_PROFILE"}) + void bundleIdUnderApplicationAction_failsForEveryUser_pinsTheSection9Row(ResourceAction action) { + assertThat(action.getResourceType()).as("the action belongs to APPLICATION, so the application handler decides") + .isEqualTo(ResourceType.APPLICATION); + + for (String bundleId : List.of(BUNDLE_ID, BUNDLE_GID)) { + for (MemberRole role : MemberRole.values()) { + stubMember(role); + StepVerifier.create(service.checkResourcePermissionWithError(USER_ID, bundleId, action)) + .as(action + " on bundle id " + bundleId + " as " + role) + .expectErrorSatisfies(error -> assertBizError(error, NO_RESOURCE_FOUND)) + .verify(); + } + } + // the application lookup is what fails: it was asked with the bundle id (object id and GID form), and org + // membership was never read + verify(applicationRepository, org.mockito.Mockito.atLeastOnce()).findById(BUNDLE_ID); + verify(applicationRepository, org.mockito.Mockito.atLeastOnce()).findByGid(BUNDLE_GID); + verify(orgMemberService, never()).getOrgMember(anyString(), anyString()); + verify(permissionRepository, never()).getByResourceTypeAndResourceIds(any(), anyCollection()); + verify(bundleHandler, never()).getAllMatchingPermissions(anyString(), anyCollection(), any()); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(value = ResourceAction.class, names = { + "SET_APPLICATIONS_PUBLIC", "SET_APPLICATIONS_PUBLIC_TO_MARKETPLACE", "SET_APPLICATIONS_AS_AGENCY_PROFILE"}) + void bundleIdUnderApplicationAction_anonymousUserIsNotAuthorized(ResourceAction action) { + StepVerifier.create(service.checkResourcePermissionWithError(Authentication.ANONYMOUS_USER_ID, BUNDLE_ID, action)) + .expectErrorSatisfies(error -> assertBizError(error, NOT_AUTHORIZED)) + .verify(); + verify(applicationRepository, never()).findById(anyString()); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(value = ResourceAction.class, names = { + "SET_APPLICATIONS_PUBLIC", "SET_APPLICATIONS_PUBLIC_TO_MARKETPLACE", "SET_APPLICATIONS_AS_AGENCY_PROFILE"}) + void sameActionsOnAnApplicationId_workThroughTheSameHandler(ResourceAction action) { + // control: the handler and the service are wired correctly, so the failure above is about the id, not the setup + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Mono.just(Application.builder().organizationId(ORG_ID).build())); + + stubMember(MemberRole.ADMIN); + StepVerifier.create(service.checkResourcePermissionWithError(USER_ID, APPLICATION_ID, action)).verifyComplete(); + + stubMember(MemberRole.MEMBER); + when(permissionRepository.getByResourceTypeAndResourceIds(eq(ResourceType.APPLICATION), anyCollection())) + .thenReturn(Mono.just(Map.of())); + StepVerifier.create(service.checkResourcePermissionWithError(USER_ID, APPLICATION_ID, action)) + .expectErrorSatisfies(error -> assertBizError(error, NOT_AUTHORIZED)) + .verify(); + + ResourcePermission editor = ResourcePermission.builder() + .resourceType(ResourceType.APPLICATION).resourceId(APPLICATION_ID) + .resourceHolder(ResourceHolder.USER).resourceHolderId(USER_ID) + .resourceRole(ResourceRole.EDITOR).build(); + Map> granted = Map.of(APPLICATION_ID, List.of(editor)); + when(permissionRepository.getByResourceTypeAndResourceIds(eq(ResourceType.APPLICATION), anyCollection())) + .thenReturn(Mono.just(granted)); + StepVerifier.create(service.checkResourcePermissionWithError(USER_ID, APPLICATION_ID, action)).verifyComplete(); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(value = ResourceAction.class, names = { + "SET_BUNDLES_PUBLIC", "SET_BUNDLES_PUBLIC_TO_MARKETPLACE", "SET_BUNDLES_AS_AGENCY_PROFILE"}) + void bundleActions_areRoutedToTheBundleHandler_neverToTheApplicationHandler(ResourceAction action) { + // the unused SET_BUNDLES_* actions reach the bundle handler, which is where a bundle id can be resolved + assertThat(action.getResourceType()).isEqualTo(ResourceType.BUNDLE); + when(bundleHandler.getAllMatchingPermissions(eq(USER_ID), anyCollection(), eq(action))) + .thenReturn(Mono.just(Map.of(BUNDLE_ID, List.of(ResourcePermission.builder() + .resourceType(ResourceType.BUNDLE).resourceId(BUNDLE_ID).resourceRole(ResourceRole.OWNER).build())))); + + StepVerifier.create(service.checkResourcePermissionWithError(USER_ID, BUNDLE_ID, action)).verifyComplete(); + + verify(bundleHandler).getAllMatchingPermissions(eq(USER_ID), anyCollection(), eq(action)); + verify(applicationRepository, never()).findById(anyString()); + verify(applicationRepository, never()).findBySlug(anyString()); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/BundlePermissionHandlerTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/BundlePermissionHandlerTest.java new file mode 100644 index 0000000000..8f91530bfd --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/BundlePermissionHandlerTest.java @@ -0,0 +1,324 @@ +package org.lowcoder.domain.permission.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.GROUP_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.ORG_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.RESOURCE_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.USER_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.orgMember; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.permission; +import static org.lowcoder.sdk.constants.Authentication.ANONYMOUS_USER_ID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.lowcoder.domain.bundle.model.Bundle; +import org.lowcoder.domain.bundle.model.BundleRequestType; +import org.lowcoder.domain.bundle.service.BundleService; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.model.ResourceHolder; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.domain.permission.model.UserPermissionOnResourceStatus; +import org.lowcoder.domain.solutions.TemplateSolutionService; +import org.lowcoder.sdk.config.CommonConfig; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * The bundle clone of the permission decisions (unit U3, task L3-1): {@code BundlePermissionHandler} with its own + * public-id sources and the {@code checkUserPermissionStatusOnBundle} copy of the check method. Catches the bundle + * clone diverging from the resource/application one. Every assertion states today's behaviour. + */ +class BundlePermissionHandlerTest { + + enum Variant { RESOURCE, BUNDLE } + + private static final BundleRequestType REQUEST_TYPE = BundleRequestType.PUBLIC_TO_ALL; + + private BundleService bundleService; + private OrgMemberService orgMemberService; + private GroupMemberService groupMemberService; + private ResourcePermissionService resourcePermissionService; + private CommonConfig config; + private BundlePermissionHandler handler; + + @BeforeEach + void setUp() { + bundleService = mock(BundleService.class); + orgMemberService = mock(OrgMemberService.class); + groupMemberService = mock(GroupMemberService.class); + resourcePermissionService = mock(ResourcePermissionService.class); + config = new CommonConfig(); + handler = new BundlePermissionHandler(bundleService, mock(TemplateSolutionService.class)); + PermissionTestFixtures.inject(handler, orgMemberService, groupMemberService, resourcePermissionService, config); + + stubPublicIds(Set.of(), Set.of()); + when(bundleService.findById(any())).thenReturn(Mono.just(Bundle.builder().organizationId(ORG_ID).build())); + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.empty()); + when(groupMemberService.getUserGroupIdsInOrg(ORG_ID, USER_ID)).thenReturn(Mono.just(List.of())); + when(resourcePermissionService.getByResourceTypeAndResourceIds(any(), any())).thenReturn(Mono.just(Map.of())); + } + + /** Catches wrong type, holder or role on public bundle rows, and a public id missing from the anonymous map. */ + @Test + void getAll_anonymousUser_publicBundlesGetViewerRowsOfTypeBundle() { + when(bundleService.getPublicBundleIds(any())).thenReturn(Mono.just(Set.of("b1"))); + + StepVerifier.create(handler.getAllMatchingPermissions(ANONYMOUS_USER_ID, List.of("b1", "b2"), + ResourceAction.READ_BUNDLES)) + .assertNext(map -> { + assertThat(map).containsOnlyKeys("b1"); + assertAnonymousViewerRow(map.get("b1").get(0), "b1"); + }) + .verifyComplete(); + verifyNoInteractions(orgMemberService); + System.out.println("[BundlePermissionHandlerTest] anonymous: public bundle gets a VIEWER/BUNDLE row"); + } + + /** Catches anonymous users gaining edit rights on bundles: the VIEWER guard returns before any lookup. */ + @Test + void getAll_anonymousUser_actionAboveViewer_returnsEmptyWithoutLookup() { + when(bundleService.getPublicBundleIds(any())).thenReturn(Mono.just(Set.of("b1"))); + + StepVerifier.create(handler.getAllMatchingPermissions(ANONYMOUS_USER_ID, List.of("b1"), ResourceAction.EDIT_BUNDLES)) + .assertNext(map -> assertThat(map).isEmpty()) + .verifyComplete(); + verify(bundleService, never()).getPublicBundleIds(any()); + System.out.println("[BundlePermissionHandlerTest] anonymous + EDIT_BUNDLES -> empty map"); + } + + /** Catches a member's own and group rows being dropped or merged wrongly with the public row on bundles. */ + @Test + void getAll_member_getsOwnRowMergedWithPublicRow() { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + when(groupMemberService.getUserGroupIdsInOrg(ORG_ID, USER_ID)).thenReturn(Mono.just(List.of(GROUP_ID))); + ResourcePermission group = row("b1", ResourceHolder.GROUP, GROUP_ID, ResourceRole.EDITOR); + when(resourcePermissionService.getByResourceTypeAndResourceIds(eq(ResourceType.BUNDLE), any())) + .thenReturn(Mono.just(Map.of("b1", List.of(group)))); + when(bundleService.getPublicBundleIds(any())).thenReturn(Mono.just(Set.of("b1"))); + + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, List.of("b1"), ResourceAction.READ_BUNDLES)) + .assertNext(map -> { + assertThat(map.get("b1")).hasSize(2); + assertThat(map.get("b1").get(0)).isSameAs(group); + assertAnonymousViewerRow(map.get("b1").get(1), "b1"); + }) + .verifyComplete(); + System.out.println("[BundlePermissionHandlerTest] member group row + public row both kept"); + } + + static Stream priorityMatrix() { + Stream.Builder builder = Stream.builder(); + for (Variant variant : Variant.values()) { + for (boolean orgRow : new boolean[] {false, true}) { + for (boolean nonAnonymousPublic : new boolean[] {false, true}) { + for (boolean anonymousPublic : new boolean[] {false, true}) { + builder.add(Arguments.of(variant, orgRow, nonAnonymousPublic, anonymousPublic)); + } + } + } + } + return builder.build(); + } + + /** + * Catches a wrong priority in the bundle clone (:326-334): the org permission (EDITOR) wins over the public VIEWER + * row, any public source beats failure, and a member with nothing gets "not enough permission". The order of the + * two public sources is not observable (identical rows) and not asserted. + */ + @ParameterizedTest(name = "{0}: orgRow={1} nonAnonymousPublic={2} anonymousPublic={3}") + @MethodSource("priorityMatrix") + void check_priority_orgPermissionBeatsPublicPermissions(Variant variant, boolean orgRow, + boolean nonAnonymousPublic, boolean anonymousPublic) { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + when(resourcePermissionService.getByResourceTypeAndResourceIds(eq(ResourceType.BUNDLE), any())) + .thenReturn(Mono.just(orgRow + ? Map.of(RESOURCE_ID, List.of(row(RESOURCE_ID, ResourceHolder.USER, USER_ID, ResourceRole.EDITOR))) + : Map.of())); + stubPublicIds(anonymousPublic ? Set.of(RESOURCE_ID) : Set.of(), nonAnonymousPublic ? Set.of(RESOURCE_ID) : Set.of()); + + StepVerifier.create(check(variant, USER_ID, ResourceAction.READ_BUNDLES)) + .assertNext(status -> { + if (orgRow) { + assertThat(status.getPermission().getResourceRole()).isEqualTo(ResourceRole.EDITOR); + assertThat(status.getPermission().getResourceHolderId()).isEqualTo(USER_ID); + } else if (nonAnonymousPublic || anonymousPublic) { + assertAnonymousViewerRow(status.getPermission(), RESOURCE_ID); + } else { + assertThat(status.hasPermission()).isFalse(); + assertThat(status.failByNotEnoughPermission()).isTrue(); + } + }) + .verifyComplete(); + System.out.println("[BundlePermissionHandlerTest] priority " + variant + " org=" + orgRow + + " nonAnonPublic=" + nonAnonymousPublic + " anonPublic=" + anonymousPublic + " checked"); + } + + /** Catches a wrong status for anonymous users: public bundle -> success, otherwise the anonymous-user failure. */ + @ParameterizedTest + @MethodSource("variantsWithBoolean") + void check_anonymousUser_publicBundle_success_privateBundle_anonymousUserStatus(Variant variant, boolean isPublic) { + stubPublicIds(isPublic ? Set.of(RESOURCE_ID) : Set.of(), Set.of()); + + StepVerifier.create(check(variant, ANONYMOUS_USER_ID, ResourceAction.READ_BUNDLES)) + .assertNext(status -> { + if (isPublic) { + assertAnonymousViewerRow(status.getPermission(), RESOURCE_ID); + } else { + assertThat(status.hasPermission()).isFalse(); + assertThat(status.failByAnonymousUser()).isTrue(); + } + }) + .verifyComplete(); + verifyNoInteractions(orgMemberService); + System.out.println("[BundlePermissionHandlerTest] " + variant + " anonymous, public=" + isPublic + " checked"); + } + + /** Catches an anonymous user reaching an edit action on a public bundle. */ + @ParameterizedTest + @EnumSource(Variant.class) + void check_anonymousUser_actionAboveViewer_isRejectedEvenForPublicBundle(Variant variant) { + stubPublicIds(Set.of(RESOURCE_ID), Set.of()); + + StepVerifier.create(check(variant, ANONYMOUS_USER_ID, ResourceAction.EDIT_BUNDLES)) + .assertNext(status -> assertThat(status.failByAnonymousUser()).isTrue()) + .verifyComplete(); + System.out.println("[BundlePermissionHandlerTest] " + variant + " anonymous + EDIT on public bundle rejected"); + } + + /** Catches a wrong deny reason: non-member with nothing public is "not in org"; unknown bundle too. */ + @ParameterizedTest + @EnumSource(Variant.class) + void check_userNotInOrg_andUnknownBundle_failWithNotInOrg(Variant variant) { + StepVerifier.create(check(variant, USER_ID, ResourceAction.READ_BUNDLES)) + .assertNext(status -> { + assertThat(status.failByNotInOrg()).isTrue(); + assertThat(status.failByNotEnoughPermission()).isFalse(); + }) + .verifyComplete(); + + when(bundleService.findById(any())).thenReturn(Mono.empty()); + StepVerifier.create(check(variant, USER_ID, ResourceAction.READ_BUNDLES)) + .assertNext(status -> assertThat(status.failByNotInOrg()).isTrue()) + .verifyComplete(); + System.out.println("[BundlePermissionHandlerTest] " + variant + " non-member and unknown bundle -> notInOrg"); + } + + static Stream variantsWithAdminRole() { + return Stream.of(Variant.values()).flatMap(v -> + Stream.of(Arguments.of(v, MemberRole.ADMIN), Arguments.of(v, MemberRole.SUPER_ADMIN))); + } + + static Stream variantsWithBoolean() { + return Stream.of(Variant.values()).flatMap(v -> Stream.of(Arguments.of(v, true), Arguments.of(v, false))); + } + + /** + * Catches admin lock-out on bundles (OWNER without permission rows). Pins the plan §9 defect: buildAdminPermission + * called with resource and user id swapped in the single-resource checks (:106, :312); a fix changes this test on + * purpose. + */ + @ParameterizedTest + @MethodSource("variantsWithAdminRole") + void check_orgAdminAndSuperAdmin_getOwner_pinsSwappedIds(Variant variant, MemberRole role) { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(role))); + + StepVerifier.create(check(variant, USER_ID, ResourceAction.MANAGE_BUNDLES)) + .assertNext(status -> { + ResourcePermission granted = status.getPermission(); + assertThat(granted.getResourceRole()).isEqualTo(ResourceRole.OWNER); + assertThat(granted.getResourceHolder()).isEqualTo(ResourceHolder.USER); + assertThat(granted.getResourceType()).isEqualTo(ResourceType.BUNDLE); + assertThat(granted.getResourceId()).isEqualTo(USER_ID); + assertThat(granted.getResourceHolderId()).isEqualTo(RESOURCE_ID); + }) + .verifyComplete(); + verifyNoInteractions(resourcePermissionService); + System.out.println("[BundlePermissionHandlerTest] " + variant + " " + role + " -> OWNER, ids swapped (plan §9 defect pinned)"); + } + + /** Contrast to the pin above: getAllMatchingPermissions builds the admin row with the correct ids. */ + @Test + void getAll_orgAdmin_getsOwnerRowWithCorrectIds() { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(MemberRole.ADMIN))); + + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, List.of("b1"), ResourceAction.MANAGE_BUNDLES)) + .assertNext(map -> { + ResourcePermission granted = map.get("b1").get(0); + assertThat(granted.getResourceRole()).isEqualTo(ResourceRole.OWNER); + assertThat(granted.getResourceId()).isEqualTo("b1"); + assertThat(granted.getResourceHolderId()).isEqualTo(USER_ID); + assertThat(granted.getResourceType()).isEqualTo(ResourceType.BUNDLE); + }) + .verifyComplete(); + System.out.println("[BundlePermissionHandlerTest] getAll admin row has the correct ids"); + } + + /** Catches the request type or marketplace flag not reaching the bundle filter for both user kinds. */ + @ParameterizedTest + @MethodSource("requestTypesWithPrivateMode") + void checkOnBundle_forwardsRequestTypeAndMarketplaceMode(BundleRequestType requestType, boolean privateMode) { + config.getMarketplace().setPrivateMode(privateMode); + when(bundleService.getFilteredPublicBundleIds(any(), any(), any(), any())).thenReturn(Mono.just(Set.of())); + + StepVerifier.create(handler.checkUserPermissionStatusOnBundle(USER_ID, RESOURCE_ID, ResourceAction.READ_BUNDLES, requestType)) + .assertNext(status -> assertThat(status.failByNotInOrg()).isTrue()) + .verifyComplete(); + + verify(bundleService).getFilteredPublicBundleIds(eq(requestType), eq(Set.of(RESOURCE_ID)), isNull(), eq(privateMode)); + verify(bundleService).getFilteredPublicBundleIds(eq(requestType), eq(Set.of(RESOURCE_ID)), eq(USER_ID), eq(privateMode)); + System.out.println("[BundlePermissionHandlerTest] requestType=" + requestType + " privateMode=" + privateMode + " forwarded"); + } + + static Stream requestTypesWithPrivateMode() { + return Stream.of(BundleRequestType.values()).flatMap(t -> Stream.of(Arguments.of(t, true), Arguments.of(t, false))); + } + + private Mono check(Variant variant, String userId, ResourceAction action) { + return switch (variant) { + case RESOURCE -> handler.checkUserPermissionStatusOnResource(userId, RESOURCE_ID, action); + case BUNDLE -> handler.checkUserPermissionStatusOnBundle(userId, RESOURCE_ID, action, REQUEST_TYPE); + }; + } + + private void stubPublicIds(Set anonymousIds, Set nonAnonymousIds) { + when(bundleService.getPublicBundleIds(any())).thenReturn(Mono.just(anonymousIds)); + when(bundleService.getPrivateBundleIds(any(), eq(USER_ID))).thenReturn(Mono.just(nonAnonymousIds)); + when(bundleService.getFilteredPublicBundleIds(eq(REQUEST_TYPE), any(), isNull(), any())).thenReturn(Mono.just(anonymousIds)); + when(bundleService.getFilteredPublicBundleIds(eq(REQUEST_TYPE), any(), eq(USER_ID), any())).thenReturn(Mono.just(nonAnonymousIds)); + } + + private static ResourcePermission row(String resourceId, ResourceHolder holder, String holderId, ResourceRole role) { + return permission(ResourceType.BUNDLE, resourceId, holder, holderId, role); + } + + private static void assertAnonymousViewerRow(ResourcePermission row, String resourceId) { + assertThat(row.getResourceId()).isEqualTo(resourceId); + assertThat(row.getResourceType()).isEqualTo(ResourceType.BUNDLE); + assertThat(row.getResourceHolder()).isEqualTo(ResourceHolder.USER); + assertThat(row.getResourceHolderId()).isEqualTo(ANONYMOUS_USER_ID); + assertThat(row.getResourceRole()).isEqualTo(ResourceRole.VIEWER); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/DatasourcePermissionHandlerTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/DatasourcePermissionHandlerTest.java new file mode 100644 index 0000000000..a1b3e5dc36 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/DatasourcePermissionHandlerTest.java @@ -0,0 +1,229 @@ +package org.lowcoder.domain.permission.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.ORG_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.RESOURCE_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.USER_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.orgMember; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.permission; +import static org.lowcoder.sdk.constants.Authentication.ANONYMOUS_USER_ID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.lowcoder.domain.application.model.ApplicationRequestType; +import org.lowcoder.domain.bundle.model.BundleRequestType; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.service.DatasourceService; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.model.ResourceHolder; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.domain.permission.model.UserPermissionOnResourceStatus; +import org.lowcoder.sdk.config.CommonConfig; +import org.mockito.ArgumentCaptor; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@code DatasourcePermissionHandler} (unit U3, task L3-1): the system-static datasource rows layered over the base + * decision logic, and the base checks with empty public sources. Every assertion states today's behaviour. + */ +class DatasourcePermissionHandlerTest { + + private static final String DATASOURCE_ID = "ds-1"; + private static final String OTHER_DATASOURCE_ID = "ds-2"; + + private DatasourceService datasourceService; + private OrgMemberService orgMemberService; + private GroupMemberService groupMemberService; + private ResourcePermissionService resourcePermissionService; + private DatasourcePermissionHandler handler; + + @BeforeEach + void setUp() { + datasourceService = mock(DatasourceService.class); + orgMemberService = mock(OrgMemberService.class); + groupMemberService = mock(GroupMemberService.class); + resourcePermissionService = mock(ResourcePermissionService.class); + handler = new DatasourcePermissionHandler(datasourceService); + PermissionTestFixtures.inject(handler, orgMemberService, groupMemberService, resourcePermissionService, new CommonConfig()); + + when(datasourceService.getById(any())).thenReturn(Mono.just(Datasource.builder().organizationId(ORG_ID).build())); + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + when(groupMemberService.getUserGroupIdsInOrg(ORG_ID, USER_ID)).thenReturn(Mono.just(List.of())); + when(resourcePermissionService.getByResourceTypeAndResourceIds(any(), any())).thenReturn(Mono.just(Map.of())); + } + + /** Catches static datasource lock-out: static ids get an OWNER row, the others follow the member rows. */ + @Test + void getAll_staticAndNormalIds_staticGetOwnerRowsNormalFollowPermissionRows() { + ResourcePermission own = row(DATASOURCE_ID, USER_ID, ResourceRole.OWNER); + when(resourcePermissionService.getByResourceTypeAndResourceIds(eq(ResourceType.DATASOURCE), any())) + .thenReturn(Mono.just(Map.of(DATASOURCE_ID, List.of(own)))); + + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, + List.of(Datasource.QUICK_REST_API_ID, DATASOURCE_ID, Datasource.JS_CODE_ID, OTHER_DATASOURCE_ID), + ResourceAction.USE_DATASOURCES)) + .assertNext(map -> { + assertThat(map).containsOnlyKeys(Datasource.QUICK_REST_API_ID, Datasource.JS_CODE_ID, + DATASOURCE_ID, OTHER_DATASOURCE_ID); + assertStaticOwnerRow(map.get(Datasource.QUICK_REST_API_ID), Datasource.QUICK_REST_API_ID, USER_ID); + assertStaticOwnerRow(map.get(Datasource.JS_CODE_ID), Datasource.JS_CODE_ID, USER_ID); + assertThat(map.get(DATASOURCE_ID)).containsExactly(own); + assertThat(map.get(OTHER_DATASOURCE_ID)).isEmpty(); + }) + .verifyComplete(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> ids = ArgumentCaptor.forClass(java.util.Collection.class); + verify(resourcePermissionService).getByResourceTypeAndResourceIds(eq(ResourceType.DATASOURCE), ids.capture()); + assertThat(ids.getValue()).containsExactly(DATASOURCE_ID, OTHER_DATASOURCE_ID); + System.out.println("[DatasourcePermissionHandlerTest] static ids -> OWNER rows; only normal ids read from the permission store"); + } + + /** Catches static ids leaking into the base lookup, or normal ids getting static rows. */ + @Test + void getAll_onlyNormalIds_noStaticRows() { + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, List.of(DATASOURCE_ID), ResourceAction.USE_DATASOURCES)) + .assertNext(map -> assertThat(map).containsOnlyKeys(DATASOURCE_ID)) + .verifyComplete(); + System.out.println("[DatasourcePermissionHandlerTest] only normal ids -> only their keys"); + } + + /** Catches duplicate static ids breaking the result (toMap would throw) and an org lookup for static-only input. */ + @Test + void getAll_onlyStaticIds_duplicatesCollapse_andNoOrgLookup() { + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, + List.of(Datasource.JS_CODE_ID, Datasource.JS_CODE_ID, Datasource.QUICK_GRAPHQL_ID), + ResourceAction.USE_DATASOURCES)) + .assertNext(map -> { + assertThat(map).containsOnlyKeys(Datasource.JS_CODE_ID, Datasource.QUICK_GRAPHQL_ID); + assertThat(map.get(Datasource.JS_CODE_ID)).hasSize(1); + }) + .verifyComplete(); + verifyNoInteractions(orgMemberService, datasourceService); + System.out.println("[DatasourcePermissionHandlerTest] static-only ids -> one OWNER row per distinct id, no org lookup"); + } + + /** + * Pins today's behaviour: a system static datasource id grants OWNER to any caller, the anonymous id included + * (static datasources are the built-in ones every viewer of a public app must be able to run). + */ + @Test + void getAll_staticDatasource_grantsOwnerToAnonymousAndNonMemberCallers() { + when(orgMemberService.getOrgMember(ORG_ID, "stranger")).thenReturn(Mono.empty()); + + for (String caller : List.of(ANONYMOUS_USER_ID, "stranger")) { + StepVerifier.create(handler.getAllMatchingPermissions(caller, + List.of(Datasource.QUICK_REST_API_ID, DATASOURCE_ID), ResourceAction.MANAGE_DATASOURCES)) + .assertNext(map -> { + assertThat(map).containsOnlyKeys(Datasource.QUICK_REST_API_ID); + assertStaticOwnerRow(map.get(Datasource.QUICK_REST_API_ID), Datasource.QUICK_REST_API_ID, caller); + }) + .verifyComplete(); + } + System.out.println("[DatasourcePermissionHandlerTest] static datasource -> OWNER for anonymous and non-member callers"); + } + + /** The operations of the handler that decide a status: the generic check and the application/bundle clones. */ + enum Operation { RESOURCE, APPLICATION, BUNDLE } + + private Mono check(Operation op, String userId) { + ResourceAction action = ResourceAction.USE_DATASOURCES; + return switch (op) { + case RESOURCE -> handler.checkUserPermissionStatusOnResource(userId, DATASOURCE_ID, action); + case APPLICATION -> handler.checkUserPermissionStatusOnApplication(userId, DATASOURCE_ID, action, + ApplicationRequestType.PUBLIC_TO_ALL); + case BUNDLE -> handler.checkUserPermissionStatusOnBundle(userId, DATASOURCE_ID, action, BundleRequestType.PUBLIC_TO_ALL); + }; + } + + /** Catches datasources being public: the anonymous user never has a permission, only the anonymous failure. */ + @ParameterizedTest + @EnumSource(Operation.class) + void check_anonymousUser_failsWithAnonymousUser(Operation op) { + StepVerifier.create(check(op, ANONYMOUS_USER_ID)) + .assertNext(status -> { + assertThat(status.hasPermission()).isFalse(); + assertThat(status.failByAnonymousUser()).isTrue(); + }) + .verifyComplete(); + verifyNoInteractions(orgMemberService); + System.out.println("[DatasourcePermissionHandlerTest] " + op + " anonymous -> anonymous-user failure"); + } + + /** Catches a wrong organisation resolution (getOrgId) or a broken member/no-row/unknown mapping on datasources. */ + @ParameterizedTest + @EnumSource(Operation.class) + void check_member_withRow_success_withoutRow_notEnough_unknownDatasource_notInOrg(Operation op) { + // no row + StepVerifier.create(check(op, USER_ID)) + .assertNext(status -> assertThat(status.failByNotEnoughPermission()).isTrue()) + .verifyComplete(); + verify(datasourceService).getById(DATASOURCE_ID); + + // with row + ResourcePermission own = row(DATASOURCE_ID, USER_ID, ResourceRole.OWNER); + when(resourcePermissionService.getByResourceTypeAndResourceIds(eq(ResourceType.DATASOURCE), any())) + .thenReturn(Mono.just(Map.of(DATASOURCE_ID, List.of(own)))); + StepVerifier.create(check(op, USER_ID)) + .assertNext(status -> assertThat(status.getPermission()).isSameAs(own)) + .verifyComplete(); + + // unknown datasource + when(datasourceService.getById(any())).thenReturn(Mono.empty()); + StepVerifier.create(check(op, USER_ID)) + .assertNext(status -> assertThat(status.failByNotInOrg()).isTrue()) + .verifyComplete(); + System.out.println("[DatasourcePermissionHandlerTest] " + op + " member no row / row / unknown datasource checked"); + } + + /** Catches admin lock-out on datasources. Pins the plan §9 swapped-id defect (:106, :253, :312) as in the other handlers. */ + @ParameterizedTest + @EnumSource(Operation.class) + void check_orgAdmin_getsOwner_pinsSwappedIds(Operation op) { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(MemberRole.ADMIN))); + + StepVerifier.create(check(op, USER_ID)) + .assertNext(status -> { + ResourcePermission granted = status.getPermission(); + assertThat(granted.getResourceRole()).isEqualTo(ResourceRole.OWNER); + assertThat(granted.getResourceType()).isEqualTo(ResourceType.DATASOURCE); + assertThat(granted.getResourceId()).isEqualTo(USER_ID); + assertThat(granted.getResourceHolderId()).isEqualTo(DATASOURCE_ID); + }) + .verifyComplete(); + verifyNoInteractions(resourcePermissionService); + System.out.println("[DatasourcePermissionHandlerTest] " + op + " admin -> OWNER, ids swapped (plan §9 defect pinned)"); + } + + private static ResourcePermission row(String resourceId, String userId, ResourceRole role) { + return permission(ResourceType.DATASOURCE, resourceId, ResourceHolder.USER, userId, role); + } + + private static void assertStaticOwnerRow(List rows, String id, String callerId) { + assertThat(rows).hasSize(1); + ResourcePermission row = rows.get(0); + assertThat(row.getResourceId()).isEqualTo(id); + assertThat(row.getResourceType()).isEqualTo(ResourceType.DATASOURCE); + assertThat(row.getResourceHolder()).isEqualTo(ResourceHolder.USER); + assertThat(row.getResourceHolderId()).isEqualTo(callerId); + assertThat(row.getResourceRole()).isEqualTo(ResourceRole.OWNER); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/PermissionTestFixtures.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/PermissionTestFixtures.java new file mode 100644 index 0000000000..60ebc6ea67 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/PermissionTestFixtures.java @@ -0,0 +1,53 @@ +package org.lowcoder.domain.permission.service; + +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.permission.model.ResourceHolder; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.sdk.config.CommonConfig; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Shared constants and builders of the permission-handler tests. The handlers are package-private and take their + * org/group/permission collaborators by field injection, so the tests inject them reflectively. + */ +final class PermissionTestFixtures { + + static final String ORG_ID = "org-1"; + static final String USER_ID = "user-1"; + static final String OTHER_USER_ID = "user-other"; + static final String GROUP_ID = "group-1"; + static final String OTHER_GROUP_ID = "group-other"; + static final String RESOURCE_ID = "resource-1"; + + private PermissionTestFixtures() { + } + + static ResourcePermission permission(ResourceType type, String resourceId, ResourceHolder holder, String holderId, + ResourceRole role) { + return ResourcePermission.builder() + .resourceType(type) + .resourceId(resourceId) + .resourceHolder(holder) + .resourceHolderId(holderId) + .resourceRole(role) + .build(); + } + + static OrgMember orgMember(MemberRole role) { + return OrgMember.builder().orgId(ORG_ID).userId(USER_ID).role(role).build(); + } + + static void inject(ResourcePermissionHandler handler, OrgMemberService orgMemberService, + GroupMemberService groupMemberService, ResourcePermissionService resourcePermissionService, + CommonConfig config) { + ReflectionTestUtils.setField(handler, ResourcePermissionHandler.class, "orgMemberService", orgMemberService, OrgMemberService.class); + ReflectionTestUtils.setField(handler, ResourcePermissionHandler.class, "groupMemberService", groupMemberService, GroupMemberService.class); + ReflectionTestUtils.setField(handler, ResourcePermissionHandler.class, "resourcePermissionService", resourcePermissionService, ResourcePermissionService.class); + ReflectionTestUtils.setField(handler, ResourcePermissionHandler.class, "config", config, CommonConfig.class); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/ResourcePermissionHandlerTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/ResourcePermissionHandlerTest.java new file mode 100644 index 0000000000..0735793625 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/service/ResourcePermissionHandlerTest.java @@ -0,0 +1,492 @@ +package org.lowcoder.domain.permission.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.GROUP_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.ORG_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.OTHER_GROUP_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.OTHER_USER_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.RESOURCE_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.USER_ID; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.orgMember; +import static org.lowcoder.domain.permission.service.PermissionTestFixtures.permission; +import static org.lowcoder.sdk.constants.Authentication.ANONYMOUS_USER_ID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationRequestType; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.model.ResourceHolder; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.domain.permission.model.UserPermissionOnResourceStatus; +import org.lowcoder.domain.solutions.TemplateSolutionService; +import org.lowcoder.sdk.config.CommonConfig; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Decision logic of the abstract {@code ResourcePermissionHandler}, exercised through the concrete + * {@code ApplicationPermissionHandler} with Mockito collaborators (unit U3, task L3-1). No defect of the plan is + * reached; every assertion states today's behaviour. + */ +class ResourcePermissionHandlerTest { + + /** The two copy-pasted check methods of the handler that take the same decisions. */ + enum Variant { RESOURCE, APPLICATION } + + private static final ApplicationRequestType REQUEST_TYPE = ApplicationRequestType.PUBLIC_TO_ALL; + + private ApplicationService applicationService; + private TemplateSolutionService templateSolutionService; + private OrgMemberService orgMemberService; + private GroupMemberService groupMemberService; + private ResourcePermissionService resourcePermissionService; + private CommonConfig config; + private ApplicationPermissionHandler handler; + + @BeforeEach + void setUp() { + applicationService = mock(ApplicationService.class); + templateSolutionService = mock(TemplateSolutionService.class); + orgMemberService = mock(OrgMemberService.class); + groupMemberService = mock(GroupMemberService.class); + resourcePermissionService = mock(ResourcePermissionService.class); + config = new CommonConfig(); + handler = new ApplicationPermissionHandler(applicationService, templateSolutionService); + PermissionTestFixtures.inject(handler, orgMemberService, groupMemberService, resourcePermissionService, config); + + // defaults: nothing is public, no templates, the app lives in ORG_ID, the user is no org member and has no groups + stubPublicIds(Set.of(), Set.of()); + when(templateSolutionService.getTemplateApplicationIds(any())).thenReturn(Mono.just(Set.of())); + when(applicationService.findByIdWithoutDsl(any())) + .thenReturn(Mono.just(Application.builder().organizationId(ORG_ID).build())); + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.empty()); + when(groupMemberService.getUserGroupIdsInOrg(ORG_ID, USER_ID)).thenReturn(Mono.just(List.of())); + when(resourcePermissionService.getByResourceTypeAndResourceIds(any(), any())).thenReturn(Mono.just(Map.of())); + } + + // ---------------------------------------------------------------- getAllMatchingPermissions + + /** Catches an NPE or service lookups for an empty or null id collection (guard at ResourcePermissionHandler:47). */ + @Test + void getAll_emptyOrNullResourceIds_returnEmptyMapWithoutTouchingServices() { + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, List.of(), ResourceAction.READ_APPLICATIONS)) + .assertNext(map -> assertThat(map).isEmpty()) + .verifyComplete(); + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, null, ResourceAction.READ_APPLICATIONS)) + .assertNext(map -> assertThat(map).isEmpty()) + .verifyComplete(); + verifyNoInteractions(orgMemberService, applicationService, templateSolutionService, resourcePermissionService); + System.out.println("[ResourcePermissionHandlerTest] empty and null id collections -> empty map, no collaborator touched"); + } + + /** Catches an anonymous user treated as an org member: it gets VIEWER rows for public and template apps only. */ + @Test + void getAll_anonymousUser_getsViewerRowsForPublicAndTemplateAppsOnly() { + when(applicationService.getPublicApplicationIds(any())).thenReturn(Mono.just(Set.of("a1"))); + when(templateSolutionService.getTemplateApplicationIds(any())).thenReturn(Mono.just(Set.of("a2"))); + + StepVerifier.create(handler.getAllMatchingPermissions(ANONYMOUS_USER_ID, List.of("a1", "a2", "a3"), + ResourceAction.READ_APPLICATIONS)) + .assertNext(map -> { + assertThat(map).containsOnlyKeys("a1", "a2"); + map.forEach((id, rows) -> { + assertThat(rows).hasSize(1); + assertAnonymousViewerRow(rows.get(0), id); + }); + }) + .verifyComplete(); + verifyNoInteractions(orgMemberService, resourcePermissionService); + System.out.println("[ResourcePermissionHandlerTest] anonymous: public + template ids get VIEWER rows, org lookup skipped"); + } + + /** Catches anonymous users gaining edit rights: VIEWER cannot do EDIT, so no public lookup happens at all. */ + @Test + void getAll_anonymousUser_actionAboveViewer_returnsEmptyWithoutLookup() { + when(applicationService.getPublicApplicationIds(any())).thenReturn(Mono.just(Set.of("a1"))); + + StepVerifier.create(handler.getAllMatchingPermissions(ANONYMOUS_USER_ID, List.of("a1"), + ResourceAction.EDIT_APPLICATIONS)) + .assertNext(map -> assertThat(map).isEmpty()) + .verifyComplete(); + verifyNoInteractions(templateSolutionService); + System.out.println("[ResourcePermissionHandlerTest] anonymous + EDIT action -> empty map"); + } + + /** Catches admin lock-out: ADMIN and SUPER_ADMIN get one OWNER row per distinct id without any permission rows. */ + @ParameterizedTest + @EnumSource(value = MemberRole.class, names = {"ADMIN", "SUPER_ADMIN"}) + void getAll_orgAdminAndSuperAdmin_getOwnerForEveryDistinctIdWithoutPermissionRows(MemberRole role) { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(role))); + + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, List.of("a1", "a1", "a2"), + ResourceAction.MANAGE_APPLICATIONS)) + .assertNext(map -> { + assertThat(map).containsOnlyKeys("a1", "a2"); + map.forEach((id, rows) -> { + assertThat(rows).hasSize(1); + ResourcePermission row = rows.get(0); + assertThat(row.getResourceRole()).isEqualTo(ResourceRole.OWNER); + assertThat(row.getResourceHolder()).isEqualTo(ResourceHolder.USER); + assertThat(row.getResourceHolderId()).isEqualTo(USER_ID); + assertThat(row.getResourceType()).isEqualTo(ResourceType.APPLICATION); + assertThat(row.getResourceId()).isEqualTo(id); + }); + }) + .verifyComplete(); + verifyNoInteractions(resourcePermissionService); + System.out.println("[ResourcePermissionHandlerTest] " + role + " -> OWNER rows for distinct ids, no permission rows read"); + } + + /** + * Catches privilege escalation through wrong holder, group or role matching: only the own USER row and the row of + * a group the user belongs to count, and only when the role can do the action. + */ + @Test + void getAll_member_getsOwnAndGroupRowsFilteredByHolderAndRole() { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + when(groupMemberService.getUserGroupIdsInOrg(ORG_ID, USER_ID)) + .thenReturn(Mono.just(List.of(GROUP_ID, "group-viewer"))); + ResourcePermission own = row("a1", ResourceHolder.USER, USER_ID, ResourceRole.EDITOR); + ResourcePermission group = row("a1", ResourceHolder.GROUP, GROUP_ID, ResourceRole.EDITOR); + when(resourcePermissionService.getByResourceTypeAndResourceIds(eq(ResourceType.APPLICATION), any())) + .thenReturn(Mono.just(Map.of( + "a1", List.of(own, group, + row("a1", ResourceHolder.GROUP, OTHER_GROUP_ID, ResourceRole.OWNER), + row("a1", ResourceHolder.USER, OTHER_USER_ID, ResourceRole.OWNER), + row("a1", ResourceHolder.GROUP, "group-viewer", ResourceRole.VIEWER)), + "a2", List.of(row("a2", ResourceHolder.USER, USER_ID, ResourceRole.VIEWER))))); + + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, List.of("a1", "a2", "a3"), + ResourceAction.EDIT_APPLICATIONS)) + .assertNext(map -> { + assertThat(map).containsOnlyKeys("a1", "a2", "a3"); + assertThat(map.get("a1")).containsExactly(own, group); + assertThat(map.get("a2")).as("VIEWER cannot EDIT").isEmpty(); + assertThat(map.get("a3")).as("no rows at all").isEmpty(); + }) + .verifyComplete(); + System.out.println("[ResourcePermissionHandlerTest] member: own + member-group rows kept, foreign/low-role rows dropped"); + } + + /** Catches lock-out of public apps for a user outside the org: the empty org lookup must yield the public map. */ + @Test + void getAll_userNotInOrg_returnsOnlyPublicPermissions() { + when(applicationService.getPublicApplicationIds(any())).thenReturn(Mono.just(Set.of("a1"))); + + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, List.of("a1", "a2"), + ResourceAction.READ_APPLICATIONS)) + .assertNext(map -> { + assertThat(map).containsOnlyKeys("a1"); + assertAnonymousViewerRow(map.get("a1").get(0), "a1"); + }) + .verifyComplete(); + verifyNoInteractions(resourcePermissionService); + System.out.println("[ResourcePermissionHandlerTest] non-member -> public rows only, no permission rows read"); + } + + /** Catches a dropped public/template permission: both lists are kept (union), they are not overwritten. */ + @Test + void getAll_publicRowsAreMergedWithMemberRowsByUnion() { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + ResourcePermission own = row("a1", ResourceHolder.USER, USER_ID, ResourceRole.EDITOR); + when(resourcePermissionService.getByResourceTypeAndResourceIds(eq(ResourceType.APPLICATION), any())) + .thenReturn(Mono.just(Map.of("a1", List.of(own)))); + when(applicationService.getPublicApplicationIds(any())).thenReturn(Mono.just(Set.of("a1", "a2"))); + + StepVerifier.create(handler.getAllMatchingPermissions(USER_ID, List.of("a1", "a2"), + ResourceAction.READ_APPLICATIONS)) + .assertNext(map -> { + assertThat(map).containsOnlyKeys("a1", "a2"); + assertThat(map.get("a1")).hasSize(2); + assertThat(map.get("a1").get(0)).isSameAs(own); + assertAnonymousViewerRow(map.get("a1").get(1), "a1"); + assertThat(map.get("a2")).hasSize(1); + assertAnonymousViewerRow(map.get("a2").get(0), "a2"); + }) + .verifyComplete(); + System.out.println("[ResourcePermissionHandlerTest] member row and public row of the same id are both kept, in that order"); + } + + // ---------------------------------------------------------------- check*: anonymous + + /** Catches a wrong status for anonymous users: public -> VIEWER success, otherwise the anonymous-user failure. */ + @ParameterizedTest + @MethodSource("variantsWithBoolean") + void check_anonymousUser_publicApp_success_privateApp_anonymousUserStatus(Variant variant, boolean isPublic) { + stubPublicIds(isPublic ? Set.of(RESOURCE_ID) : Set.of(), Set.of()); + + StepVerifier.create(check(variant, ANONYMOUS_USER_ID, ResourceAction.READ_APPLICATIONS)) + .assertNext(status -> { + if (isPublic) { + assertThat(status.hasPermission()).isTrue(); + assertAnonymousViewerRow(status.getPermission(), RESOURCE_ID); + } else { + assertThat(status.hasPermission()).isFalse(); + assertThat(status.failByAnonymousUser()).isTrue(); + assertThat(status.failByNotInOrg()).isFalse(); + } + }) + .verifyComplete(); + verifyNoInteractions(orgMemberService); + System.out.println("[ResourcePermissionHandlerTest] " + variant + " anonymous, public=" + isPublic + " checked"); + } + + /** Catches an anonymous user passed on as a real user id to the application filter (guard at :235). */ + @Test + void checkOnApplication_anonymousUser_filtersWithNullUserAndNeverAsksPrivateIds() { + StepVerifier.create(check(Variant.APPLICATION, ANONYMOUS_USER_ID, ResourceAction.READ_APPLICATIONS)) + .assertNext(status -> assertThat(status.failByAnonymousUser()).isTrue()) + .verifyComplete(); + + verify(applicationService).getFilteredPublicApplicationIds(eq(REQUEST_TYPE), eq(Set.of(RESOURCE_ID)), + isNull(), eq(true)); + verify(applicationService, never()).getFilteredPublicApplicationIds(any(), any(), eq(ANONYMOUS_USER_ID), any()); + System.out.println("[ResourcePermissionHandlerTest] anonymous application check filters with userId=null only"); + } + + /** Catches an anonymous user reaching an edit action on a public app: the VIEWER role guard must hold. */ + @ParameterizedTest + @EnumSource(Variant.class) + void check_anonymousUser_actionAboveViewer_isRejectedEvenForPublicApp(Variant variant) { + stubPublicIds(Set.of(RESOURCE_ID), Set.of()); + + StepVerifier.create(check(variant, ANONYMOUS_USER_ID, ResourceAction.EDIT_APPLICATIONS)) + .assertNext(status -> { + assertThat(status.hasPermission()).isFalse(); + assertThat(status.failByAnonymousUser()).isTrue(); + }) + .verifyComplete(); + System.out.println("[ResourcePermissionHandlerTest] " + variant + " anonymous + EDIT on public app -> anonymous-user failure"); + } + + // ---------------------------------------------------------------- check*: priority + + static Stream priorityMatrix() { + Stream.Builder builder = Stream.builder(); + for (Variant variant : Variant.values()) { + for (boolean orgRow : new boolean[] {false, true}) { + for (boolean nonAnonymousPublic : new boolean[] {false, true}) { + for (boolean anonymousPublic : new boolean[] {false, true}) { + builder.add(Arguments.of(variant, orgRow, nonAnonymousPublic, anonymousPublic)); + } + } + } + } + return builder.build(); + } + + /** + * Catches a wrong priority, i.e. privilege escalation or downgrade: the org permission (EDITOR here) wins over + * the public VIEWER row; with no org row any public source grants the public row; with none a member is + * "not enough permission". The non-anonymous and anonymous public rows are built identically, so their mutual order + * is not observable and not asserted. + */ + @ParameterizedTest(name = "{0}: orgRow={1} nonAnonymousPublic={2} anonymousPublic={3}") + @MethodSource("priorityMatrix") + void check_priority_orgPermissionBeatsPublicPermissions(Variant variant, boolean orgRow, + boolean nonAnonymousPublic, boolean anonymousPublic) { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + when(resourcePermissionService.getByResourceTypeAndResourceIds(eq(ResourceType.APPLICATION), any())) + .thenReturn(Mono.just(orgRow + ? Map.of(RESOURCE_ID, List.of(row(RESOURCE_ID, ResourceHolder.USER, USER_ID, ResourceRole.EDITOR))) + : Map.of())); + stubPublicIds(anonymousPublic ? Set.of(RESOURCE_ID) : Set.of(), nonAnonymousPublic ? Set.of(RESOURCE_ID) : Set.of()); + + StepVerifier.create(check(variant, USER_ID, ResourceAction.READ_APPLICATIONS)) + .assertNext(status -> { + if (orgRow) { + assertThat(status.hasPermission()).isTrue(); + assertThat(status.getPermission().getResourceRole()).isEqualTo(ResourceRole.EDITOR); + assertThat(status.getPermission().getResourceHolderId()).isEqualTo(USER_ID); + } else if (nonAnonymousPublic || anonymousPublic) { + assertThat(status.hasPermission()).isTrue(); + assertAnonymousViewerRow(status.getPermission(), RESOURCE_ID); + } else { + assertThat(status.hasPermission()).isFalse(); + assertThat(status.failByNotEnoughPermission()).isTrue(); + } + }) + .verifyComplete(); + System.out.println("[ResourcePermissionHandlerTest] priority " + variant + " org=" + orgRow + + " nonAnonPublic=" + nonAnonymousPublic + " anonPublic=" + anonymousPublic + " checked"); + } + + // ---------------------------------------------------------------- check*: org outcomes + + /** Catches a wrong deny reason: no org membership and nothing public is "not in org", not "not enough permission". */ + @ParameterizedTest + @EnumSource(Variant.class) + void check_userNotInOrg_noPublicRows_failsWithNotInOrg(Variant variant) { + StepVerifier.create(check(variant, USER_ID, ResourceAction.READ_APPLICATIONS)) + .assertNext(status -> { + assertThat(status.hasPermission()).isFalse(); + assertThat(status.failByNotInOrg()).isTrue(); + assertThat(status.failByNotEnoughPermission()).isFalse(); + }) + .verifyComplete(); + System.out.println("[ResourcePermissionHandlerTest] " + variant + " non-member, nothing public -> notInOrg"); + } + + /** Catches a crash or empty result when the resource itself cannot be resolved: it must read as "not in org". */ + @ParameterizedTest + @EnumSource(Variant.class) + void check_unknownResource_failsWithNotInOrg(Variant variant) { + when(applicationService.findByIdWithoutDsl(any())).thenReturn(Mono.empty()); + + StepVerifier.create(check(variant, USER_ID, ResourceAction.READ_APPLICATIONS)) + .assertNext(status -> assertThat(status.failByNotInOrg()).isTrue()) + .verifyComplete(); + verifyNoInteractions(orgMemberService); + System.out.println("[ResourcePermissionHandlerTest] " + variant + " unknown resource -> notInOrg, no member lookup"); + } + + /** + * Catches admin lock-out: an org admin / super admin is OWNER without any permission rows. Also pins the plan §9 + * defect: buildAdminPermission called with resource and user id swapped in the single-resource checks (:106, :253, + * :312), so the granted row has resourceId == user id and resourceHolderId == resource id; a fix changes this + * test on purpose. Contrast: getAllMatchingPermissions builds the same row with the correct ids. + */ + @ParameterizedTest + @MethodSource("variantsWithAdminRole") + void check_orgAdminAndSuperAdmin_getOwnerWithoutPermissionRows_pinsSwappedIds(Variant variant, MemberRole role) { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(role))); + + StepVerifier.create(check(variant, USER_ID, ResourceAction.MANAGE_APPLICATIONS)) + .assertNext(status -> { + assertThat(status.hasPermission()).isTrue(); + ResourcePermission granted = status.getPermission(); + assertThat(granted.getResourceRole()).isEqualTo(ResourceRole.OWNER); + assertThat(granted.getResourceHolder()).isEqualTo(ResourceHolder.USER); + assertThat(granted.getResourceType()).isEqualTo(ResourceType.APPLICATION); + // today's behaviour (plan §9 defect): the two ids are swapped + assertThat(granted.getResourceId()).isEqualTo(USER_ID); + assertThat(granted.getResourceHolderId()).isEqualTo(RESOURCE_ID); + }) + .verifyComplete(); + verifyNoInteractions(resourcePermissionService); + System.out.println("[ResourcePermissionHandlerTest] " + variant + " " + role + + " -> OWNER without permission rows, ids swapped (plan §9 defect pinned)"); + } + + /** Catches getMaxPermission picking the lowest role: VIEWER, OWNER, EDITOR rows resolve to OWNER. */ + @ParameterizedTest + @EnumSource(Variant.class) + void check_member_multipleRows_returnsHighestRole(Variant variant) { + when(orgMemberService.getOrgMember(ORG_ID, USER_ID)).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + when(groupMemberService.getUserGroupIdsInOrg(ORG_ID, USER_ID)).thenReturn(Mono.just(List.of(GROUP_ID))); + ResourcePermission owner = row(RESOURCE_ID, ResourceHolder.GROUP, GROUP_ID, ResourceRole.OWNER); + when(resourcePermissionService.getByResourceTypeAndResourceIds(eq(ResourceType.APPLICATION), any())) + .thenReturn(Mono.just(Map.of(RESOURCE_ID, List.of( + row(RESOURCE_ID, ResourceHolder.USER, USER_ID, ResourceRole.VIEWER), + owner, + row(RESOURCE_ID, ResourceHolder.USER, USER_ID, ResourceRole.EDITOR))))); + + StepVerifier.create(check(variant, USER_ID, ResourceAction.READ_APPLICATIONS)) + .assertNext(status -> assertThat(status.getPermission()).isSameAs(owner)) + .verifyComplete(); + System.out.println("[ResourcePermissionHandlerTest] " + variant + " VIEWER+OWNER+EDITOR rows -> OWNER"); + } + + // ---------------------------------------------------------------- application request type + + /** Catches the request type or private-marketplace flag not reaching the application filter, for both user kinds. */ + @ParameterizedTest + @MethodSource("requestTypesWithPrivateMode") + void checkOnApplication_forwardsRequestTypeAndMarketplaceMode(ApplicationRequestType requestType, boolean privateMode) { + config.getMarketplace().setPrivateMode(privateMode); + when(applicationService.getFilteredPublicApplicationIds(any(), any(), any(), any())).thenReturn(Mono.just(Set.of())); + + StepVerifier.create(handler.checkUserPermissionStatusOnApplication(USER_ID, RESOURCE_ID, + ResourceAction.READ_APPLICATIONS, requestType)) + .assertNext(status -> assertThat(status.failByNotInOrg()).isTrue()) + .verifyComplete(); + + verify(applicationService).getFilteredPublicApplicationIds(eq(requestType), eq(Set.of(RESOURCE_ID)), isNull(), eq(privateMode)); + verify(applicationService).getFilteredPublicApplicationIds(eq(requestType), eq(Set.of(RESOURCE_ID)), eq(USER_ID), eq(privateMode)); + System.out.println("[ResourcePermissionHandlerTest] requestType=" + requestType + " privateMode=" + privateMode + " forwarded"); + } + + /** Catches the template application ids being ignored: a template app is readable by an anonymous user. */ + @ParameterizedTest + @EnumSource(Variant.class) + void check_anonymousUser_templateAppCountsAsPublic(Variant variant) { + when(templateSolutionService.getTemplateApplicationIds(any())).thenReturn(Mono.just(Set.of(RESOURCE_ID))); + + StepVerifier.create(check(variant, ANONYMOUS_USER_ID, ResourceAction.READ_APPLICATIONS)) + .assertNext(status -> assertAnonymousViewerRow(status.getPermission(), RESOURCE_ID)) + .verifyComplete(); + System.out.println("[ResourcePermissionHandlerTest] " + variant + " template app is public for anonymous"); + } + + // ---------------------------------------------------------------- helpers + + static Stream variantsWithBoolean() { + return Stream.of(Variant.values()).flatMap(v -> Stream.of(Arguments.of(v, true), Arguments.of(v, false))); + } + + static Stream variantsWithAdminRole() { + return Stream.of(Variant.values()).flatMap(v -> + Stream.of(Arguments.of(v, MemberRole.ADMIN), Arguments.of(v, MemberRole.SUPER_ADMIN))); + } + + static Stream requestTypesWithPrivateMode() { + return Stream.of(ApplicationRequestType.values()).flatMap(t -> + Stream.of(Arguments.of(t, true), Arguments.of(t, false))); + } + + private Mono check(Variant variant, String userId, ResourceAction action) { + return switch (variant) { + case RESOURCE -> handler.checkUserPermissionStatusOnResource(userId, RESOURCE_ID, action); + case APPLICATION -> handler.checkUserPermissionStatusOnApplication(userId, RESOURCE_ID, action, REQUEST_TYPE); + }; + } + + /** Stubs the public ids of both variants: anonymous (userId null) and non-anonymous (userId USER_ID). */ + private void stubPublicIds(Set anonymousIds, Set nonAnonymousIds) { + when(applicationService.getPublicApplicationIds(any())).thenReturn(Mono.just(anonymousIds)); + when(applicationService.getPrivateApplicationIds(any(), eq(USER_ID))).thenReturn(Mono.just(nonAnonymousIds)); + when(applicationService.getFilteredPublicApplicationIds(eq(REQUEST_TYPE), any(), isNull(), any())) + .thenReturn(Mono.just(anonymousIds)); + when(applicationService.getFilteredPublicApplicationIds(eq(REQUEST_TYPE), any(), eq(USER_ID), any())) + .thenReturn(Mono.just(nonAnonymousIds)); + } + + private static ResourcePermission row(String resourceId, ResourceHolder holder, String holderId, ResourceRole role) { + return permission(ResourceType.APPLICATION, resourceId, holder, holderId, role); + } + + private static void assertAnonymousViewerRow(ResourcePermission row, String resourceId) { + assertThat(row.getResourceId()).isEqualTo(resourceId); + assertThat(row.getResourceType()).isEqualTo(ResourceType.APPLICATION); + assertThat(row.getResourceHolder()).isEqualTo(ResourceHolder.USER); + assertThat(row.getResourceHolderId()).isEqualTo(ANONYMOUS_USER_ID); + assertThat(row.getResourceRole()).isEqualTo(ResourceRole.VIEWER); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/solution/SuggestAppAdminSolutionServiceImplTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/solution/SuggestAppAdminSolutionServiceImplTest.java new file mode 100644 index 0000000000..bd11b1ada1 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/permission/solution/SuggestAppAdminSolutionServiceImplTest.java @@ -0,0 +1,213 @@ +package org.lowcoder.domain.permission.solution; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.group.model.GroupMember; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.permission.model.ResourceHolder; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.mockito.ArgumentCaptor; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * SuggestAppAdminSolutionServiceImpl (unit U8, task L3-9): who is suggested as application admin, in which order and + * under which limit. All collaborators are mocks; user names are resolved from the ids the service asks for. + */ +class SuggestAppAdminSolutionServiceImplTest { + + private static final String APP_ID = "app-1"; + private static final int LIMIT = 3; + private static final int NAMES_LIMIT = 7; + + private final GroupMemberService groupMemberService = mock(GroupMemberService.class); + private final UserService userService = mock(UserService.class); + private final ResourcePermissionService permissionService = mock(ResourcePermissionService.class); + private final SuggestAppAdminSolutionServiceImpl service = + new SuggestAppAdminSolutionServiceImpl(groupMemberService, userService, permissionService); + + /** The ids the service asked the user service for, in order, and the ids the "database" does not know. */ + private final ArgumentCaptor> askedIds = ArgumentCaptor.captor(); + private final Set unknownUsers = new java.util.HashSet<>(); + + @BeforeEach + void stubUserServiceByIds() { + when(userService.getByIds(any())).thenAnswer(invocation -> { + Map users = new HashMap<>(); + for (String id : (Collection) invocation.getArgument(0)) { + if (!unknownUsers.contains(id)) { + users.put(id, User.builder().id(id).name("name-" + id).build()); + } + } + return Mono.just(users); + }); + } + + private static ResourcePermission permission(ResourceHolder holder, String holderId, ResourceRole role) { + return ResourcePermission.builder().resourceType(ResourceType.APPLICATION).resourceId(APP_ID) + .resourceHolder(holder).resourceHolderId(holderId).resourceRole(role).build(); + } + + private static ResourcePermission ownerUser(String id) { + return permission(ResourceHolder.USER, id, ResourceRole.OWNER); + } + + private static ResourcePermission ownerGroup(String id) { + return permission(ResourceHolder.GROUP, id, ResourceRole.OWNER); + } + + private void givenPermissions(ResourcePermission... permissions) { + when(permissionService.getByApplicationId(APP_ID)).thenReturn(Mono.just(List.of(permissions))); + } + + private void givenGroupMembers(String groupId, String... userIds) { + List members = new ArrayList<>(); + for (String userId : userIds) { + members.add(GroupMember.builder().groupId(groupId).userId(userId).build()); + } + when(groupMemberService.getGroupMembers(groupId)).thenReturn(Mono.just(members)); + } + + private static List ids(List users) { + return users.stream().map(User::getId).toList(); + } + + /** Catches: non-owners (viewer, editor) or group holders being suggested as application admins. */ + @Test + void onlyOwnerUsersAreSuggestedWhenThereIsRoomLeft() { + givenPermissions(permission(ResourceHolder.USER, "viewer", ResourceRole.VIEWER), ownerUser("u1"), + permission(ResourceHolder.USER, "editor", ResourceRole.EDITOR), ownerUser("u2"), + permission(ResourceHolder.GROUP, "viewer-group", ResourceRole.VIEWER)); + + StepVerifier.create(service.getApplicationAdminUsers(APP_ID, LIMIT)) + .assertNext(users -> { + System.out.println("[SuggestAppAdminSolutionServiceImplTest] suggested " + ids(users)); + assertThat(ids(users)).containsExactly("u1", "u2"); + }) + .verifyComplete(); + verify(groupMemberService, never()).getGroupMembers("viewer-group"); + } + + /** Catches: the limit being exceeded, and group members being looked up although the owner users already fill it. */ + @Test + void ownerUsersAtOrAboveTheLimitAreTruncatedAndGroupsAreNotConsulted() { + givenPermissions(ownerUser("u1"), ownerUser("u2"), ownerUser("u3"), ownerUser("u4"), ownerGroup("g1")); + + StepVerifier.create(service.getApplicationAdminUsers(APP_ID, LIMIT)) + .assertNext(users -> assertThat(ids(users)).containsExactly("u1", "u2", "u3")) + .verifyComplete(); + verifyNoInteractions(groupMemberService); + + // exactly at the limit counts as full too + givenPermissions(ownerUser("u1"), ownerUser("u2"), ownerUser("u3"), ownerGroup("g1")); + StepVerifier.create(service.getApplicationAdminUsers(APP_ID, LIMIT)) + .assertNext(users -> assertThat(ids(users)).containsExactly("u1", "u2", "u3")) + .verifyComplete(); + verifyNoInteractions(groupMemberService); + System.out.println("[SuggestAppAdminSolutionServiceImplTest] limit reached by owner users: no group lookup"); + } + + /** Catches: duplicates against the owner users, the limit exceeded by group members, other groups' holders used. */ + @Test + void ownerGroupMembersFillTheRemainderWithoutDuplicatingOwnerUsers() { + givenPermissions(ownerUser("u1"), ownerGroup("g1")); + givenGroupMembers("g1", "u1", "u2", "u3", "u4"); + + StepVerifier.create(service.getApplicationAdminUsers(APP_ID, LIMIT)) + .assertNext(users -> { + System.out.println("[SuggestAppAdminSolutionServiceImplTest] with group members " + ids(users)); + assertThat(ids(users)).containsExactly("u1", "u2", "u3"); + }) + .verifyComplete(); + } + + /** + * Pins plan section 9 row "SuggestAppAdminSolutionServiceImpl: a member of two OWNER groups is suggested twice + * (dedupe only against owner users, :64-69)": the filter at :69 only knows the owner USERS, so the same person in + * two owner groups ends up twice in the id list and twice in the result. A fix (distinct across groups) changes + * this test on purpose. + */ + @Test + void aMemberOfTwoOwnerGroupsIsSuggestedTwice_pinsTheSection9Row() { + givenPermissions(ownerGroup("g1"), ownerGroup("g2")); + givenGroupMembers("g1", "u2"); + givenGroupMembers("g2", "u2", "u3"); + + StepVerifier.create(service.getApplicationAdminUsers(APP_ID, NAMES_LIMIT)) + .assertNext(users -> { + System.out.println("[SuggestAppAdminSolutionServiceImplTest] PINNED duplicate suggestion " + ids(users)); + assertThat(ids(users)).containsExactly("u2", "u2", "u3"); + }) + .verifyComplete(); + verify(userService).getByIds(askedIds.capture()); + assertThat(askedIds.getValue()).containsExactly("u2", "u2", "u3"); + } + + /** Catches: results in map order instead of suggestion order, and unknown (deleted) users coming back as null. */ + @Test + void usersComeBackInSuggestionOrderAndUnknownIdsAreDropped() { + givenPermissions(ownerUser("u3"), ownerUser("gone"), ownerUser("u1")); + unknownUsers.add("gone"); + + StepVerifier.create(service.getApplicationAdminUsers(APP_ID, NAMES_LIMIT)) + .assertNext(users -> assertThat(ids(users)).containsExactly("u3", "u1").doesNotContainNull()) + .verifyComplete(); + } + + /** Catches: a wrong display limit (7) or separator (a single space) for the names shown next to an application. */ + @Test + void suggestedNamesAreTheFirstSevenOwnersJoinedBySpaces() { + givenPermissions(ownerUser("u1"), ownerUser("u2"), ownerUser("u3"), ownerUser("u4"), ownerUser("u5"), + ownerUser("u6"), ownerUser("u7"), ownerUser("u8")); + + StepVerifier.create(service.getSuggestAppAdminNames(APP_ID)) + .assertNext(names -> { + System.out.println("[SuggestAppAdminSolutionServiceImplTest] names: " + names); + assertThat(names).isEqualTo("name-u1 name-u2 name-u3 name-u4 name-u5 name-u6 name-u7"); + }) + .verifyComplete(); + verify(userService).getByIds(askedIds.capture()); + assertThat(askedIds.getValue()).hasSize(NAMES_LIMIT); + } + + /** Catches: a failure on an application without owners; limit 0 consulting groups. */ + @Test + void noOwnersGiveAnEmptyListAndLimitZeroAsksForNothing() { + givenPermissions(); + StepVerifier.create(service.getApplicationAdminUsers(APP_ID, LIMIT)) + .assertNext(users -> assertThat(users).isEmpty()) + .verifyComplete(); + StepVerifier.create(service.getSuggestAppAdminNames(APP_ID)) + .assertNext(names -> assertThat(names).isEmpty()) + .verifyComplete(); + + givenPermissions(ownerUser("u1"), ownerGroup("g1")); + StepVerifier.create(service.getApplicationAdminUsers(APP_ID, 0)) + .assertNext(users -> assertThat(users).isEmpty()) + .verifyComplete(); + verify(groupMemberService, never()).getGroupMembers(anyString()); + System.out.println("[SuggestAppAdminSolutionServiceImplTest] empty permissions and limit 0 give empty results"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/plugin/client/DatasourcePluginClientContractTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/plugin/client/DatasourcePluginClientContractTest.java new file mode 100644 index 0000000000..e57acf4974 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/plugin/client/DatasourcePluginClientContractTest.java @@ -0,0 +1,233 @@ +package org.lowcoder.domain.plugin.client; + +import org.apache.commons.codec.binary.Hex; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.encryption.EncryptionServiceImpl; +import org.lowcoder.domain.plugin.client.dto.GetPluginDynamicConfigRequestDTO; +import org.lowcoder.infra.js.NodeServerHelper; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.CommonConfigHelper; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.FieldTree; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.JavaValueWalker; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.contract.RepresentativeInput; +import org.springframework.security.crypto.encrypt.Encryptors; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Group {@code node-service} (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T9.2) and the node-service row of the §4.6 + * producer table: a real {@link DatasourcePluginClient} talks to a local server ({@link RecordingHttpServer}) standing + * in for the node service, once per answer variant of {@link #VARIANTS}: + *

    + *
  • encode: what the server received, per call (content type, encryption header, body as sent; for an encrypted + * {@code runPluginQuery} body, the text decrypted with the node-service key), compared by the §1.2 contract;
  • + *
  • decode: what each call returns, with the Java class of every value ({@link JavaValueWalker}): the + * dynamic-config {@code List}, the plugin definitions, the {@code QueryExecutionResult} as a §4.6 producer + * report ({@link QueryResults#report}), and the {@code DatasourceTestResult} ({@link FieldTree}). + * + * {@code runPluginQuery} is sent plain and encrypted; its encrypted body is written by the client's bare + * {@code new ObjectMapper()} (E11, O10), the plain one by the client codecs, and a {@code queryDsl} holding an + * {@link Instant} pins how each treats a {@code java.time} value. Pinned in {@value #REPORT}. + * + *

    Limits: the node service is a recording stand-in answering fixed bodies; the {@code Accept-Language} header is + * empty, as no request is in the reactor context; the body of {@code runPluginQuery} is a {@code Map.of}, whose member + * order varies between runs and which the §1.2 contract ignores. + */ +class DatasourcePluginClientContractTest { + + static final String REPORT = "node-service/DatasourcePluginClient.calls.json"; + static final String API = "/node-service/api/"; + static final String DYNAMIC_CONFIG = API + "getPluginDynamicConfig"; + static final String PLUGINS = API + "plugins"; + static final String RUN = API + "runPluginQuery"; + static final String VALIDATE = API + "validatePluginDataSourceConfig"; + static final String JSON = "application/json"; + static final String ENCRYPTED_HEADER = "X-Encrypted"; + static final String NODE_PASSWORD = "nodePassword"; + static final String NODE_SALT = "nodeSalt"; + static final String PLUGIN = "contractPlugin"; + static final String REQUEST_KEY = "request"; + static final String DECRYPTED_KEY = "decrypted"; + static final String RESULT_KEY = "result"; + static final String SHAPE_KEY = "shape"; + static final int OK = 200; + static final int BAD_REQUEST = 400; + static final int SERVER_ERROR = 500; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map> VARIANTS = variants(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + private static final Map SERVERS = new LinkedHashMap<>(); + + @BeforeAll + static void startServers() { + VARIANTS.forEach((name, responses) -> SERVERS.put(name, RecordingHttpServer.start(responses))); + } + + @AfterAll + static void stopServers() { + SERVERS.values().forEach(RecordingHttpServer::close); + } + + @BoundarySites({ + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java##import#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.OBJECT_MAPPER#new ObjectMapper#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.getPluginDynamicConfig#bodyValue#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.getPluginDynamicConfig#bodyToMono#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.getDatasourcePluginDefinitions#bodyToMono#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.executeQuery#writeValueAsString#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.executeQuery#bodyValue#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.executeQuery#bodyToMono#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.executeQuery#bodyToMono#2", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.test#bodyValue#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.test#bodyToMono#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.test#bodyToMono#2"}) + @Test + void callsAsPinned() { + Map report = new LinkedHashMap<>(); + SERVERS.forEach((name, server) -> { + Map calls = new LinkedHashMap<>(); + DatasourcePluginClient plain = client(server, false); + DatasourcePluginClient encrypted = client(server, true); + calls.put("getPluginDynamicConfig", call(server, () -> plain.getPluginDynamicConfig(dynamicConfigRequests()).block(TIMEOUT), + JavaValueWalker::shape)); + calls.put("getDatasourcePluginDefinitions", call(server, () -> plain.getDatasourcePluginDefinitions().collectList().block(TIMEOUT), + JavaValueWalker::shape)); + calls.put("executeQuery", call(server, () -> plain.executeQuery(PLUGIN, queryDsl(), context(), datasourceConfig()).block(TIMEOUT), + QueryResults::report)); + calls.put("executeQueryEncrypted", call(server, + () -> encrypted.executeQuery(PLUGIN, queryDsl(), context(), datasourceConfig()).block(TIMEOUT), QueryResults::report)); + calls.put("executeQueryWithInstant", call(server, + () -> plain.executeQuery(PLUGIN, Map.of("at", Instant.EPOCH), List.of(), Map.of()).block(TIMEOUT), QueryResults::report)); + calls.put("executeQueryEncryptedWithInstant", call(server, + () -> encrypted.executeQuery(PLUGIN, Map.of("at", Instant.EPOCH), List.of(), Map.of()).block(TIMEOUT), QueryResults::report)); + calls.put("test", call(server, () -> plain.test(PLUGIN, datasourceConfig()).block(TIMEOUT), FieldTree::of)); + report.put(name, calls); + }); + String actual = ConfigBinding.write(report); + System.out.println("[DatasourcePluginClientContractTest] " + SERVERS.size() + " node-service variants\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** The request the server received for one call (none for a call that sent nothing) and what the call returned. */ + private static Map call(RecordingHttpServer server, java.util.function.Supplier call, Function report) { + int before = server.requests().size(); + Map outcome = new LinkedHashMap<>(); + Object result; + try { + T value = call.get(); + result = value == null ? null : report.apply(value); + } catch (RuntimeException e) { + result = Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + List requests = server.requests(); + outcome.put(REQUEST_KEY, requests.size() == before ? null : request(requests.get(before))); + outcome.put(RESULT_KEY, result); + return outcome; + } + + /** Method, path, encryption header and body of a request; an encrypted body also decrypted. */ + private static Map request(Request request) { + Map report = new LinkedHashMap<>(); + report.put("method", request.method()); + report.put("path", request.pathAndQuery()); + report.put(ENCRYPTED_HEADER, request.header(ENCRYPTED_HEADER)); + if (request.body().length > 0) { + boolean encrypted = !request.header(ENCRYPTED_HEADER).isEmpty(); + report.putAll(encrypted ? Map.of(RecordingHttpServer.CONTENT_TYPE_KEY, request.header(RecordingHttpServer.CONTENT_TYPE)) + : request.jsonBodyReport()); + if (encrypted) { + report.put(DECRYPTED_KEY, new com.fasterxml.jackson.databind.util.RawValue( + Encryptors.text(NODE_PASSWORD, Hex.encodeHexString(NODE_SALT.getBytes(StandardCharsets.UTF_8))).decrypt(request.bodyText()))); + } + } + return report; + } + + /** A client of {@code server}; {@code encrypted}: with the node-service key, so query bodies are encrypted. */ + private static DatasourcePluginClient client(RecordingHttpServer server, boolean encrypted) { + CommonConfig commonConfig = new CommonConfig(); + commonConfig.getJsExecutor().setHost(server.baseUrl()); + commonConfig.getJsExecutor().setPassword(encrypted ? NODE_PASSWORD : ""); + commonConfig.getJsExecutor().setSalt(encrypted ? NODE_SALT : ""); + CommonConfigHelper commonConfigHelper = new CommonConfigHelper(commonConfig, new ConfigCenterForTest()); + NodeServerHelper nodeServerHelper = new NodeServerHelper(); + ReflectionTestUtils.setField(nodeServerHelper, "commonConfigHelper", commonConfigHelper); + return new DatasourcePluginClient(commonConfigHelper, commonConfig, nodeServerHelper, new EncryptionServiceImpl(commonConfig)); + } + + private static List dynamicConfigRequests() { + return List.of(GetPluginDynamicConfigRequestDTO.builder().dataSourceId("ds1").pluginName(PLUGIN).path("tables") + .dataSourceConfig(datasourceConfig()).build()); + } + + /** The query DSL: the §4.6 representative input. */ + private static Object queryDsl() { + return RepresentativeInput.map(); + } + + private static List> context() { + Map entry = new LinkedHashMap<>(); + entry.put("key", "limit"); + entry.put("value", 3_000_000_001L); + return List.of(entry); + } + + private static Map datasourceConfig() { + Map config = new LinkedHashMap<>(); + config.put("host", "žluť \"db\""); + config.put("port", 5432); + config.put("ratio", new BigDecimal("1.50")); + config.put("tags", Arrays.asList("a", null)); + return config; + } + + /** The answers of the stand-in, by variant: a working service, one that fails, and one answering text values. */ + private static Map> variants() { + Map> variants = new LinkedHashMap<>(); + Map ok = new LinkedHashMap<>(); + ok.put(DYNAMIC_CONFIG, json(OK, "[" + RepresentativeInput.TEXT + ", {\"n\": 1.50, \"big\": 9223372036854775808}]")); + ok.put(PLUGINS, json(OK, "[{\"id\": \"p1\", \"name\": \"žluť\", \"dataSourceConfig\": {\"extra\": {\"type\": \"dynamic\"}}, " + + "\"limit\": 3000000001, \"ratio\": 1.50}]")); + ok.put(RUN, json(OK, "{\"result\": " + RepresentativeInput.TEXT + ", \"ignored\": 1}")); + ok.put(VALIDATE, json(OK, "{\"success\": true}")); + variants.put("ok", ok); + Map failing = new LinkedHashMap<>(); + failing.put(DYNAMIC_CONFIG, json(SERVER_ERROR, "{\"message\": \"down\"}")); + failing.put(PLUGINS, json(SERVER_ERROR, "{\"message\": \"down\"}")); + failing.put(RUN, json(SERVER_ERROR, "{\"message\": \"bad \\\"q\\\" žluť\", \"code\": 1.50}")); + failing.put(VALIDATE, json(BAD_REQUEST, "{\"message\": 1.50}")); + variants.put("failing", failing); + Map texts = new LinkedHashMap<>(); + texts.put(DYNAMIC_CONFIG, json(OK, "{\"not\": \"a list\"}")); + texts.put(PLUGINS, json(OK, "[]")); + texts.put(RUN, json(OK, "{\"result\": \"text\"}")); + texts.put(VALIDATE, json(OK, "{\"success\": \"true\", \"message\": 2.50}")); + variants.put("textValues", texts); + return variants; + } + + private static Response json(int status, String body) { + return new Response(status, Map.of(RecordingHttpServer.CONTENT_TYPE, List.of(JSON)), body.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/query/service/QueryExecutionServiceImplTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/query/service/QueryExecutionServiceImplTest.java new file mode 100644 index 0000000000..320589b502 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/query/service/QueryExecutionServiceImplTest.java @@ -0,0 +1,341 @@ +package org.lowcoder.domain.query.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceConnectionHolder; +import org.lowcoder.domain.datasource.service.DatasourceConnectionPool; +import org.lowcoder.domain.plugin.client.DatasourcePluginClient; +import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.models.JsDatasourceConnectionConfig; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.QueryExecutor; +import org.lowcoder.sdk.query.QueryExecutionContext; +import org.lowcoder.sdk.query.QueryVisitorContext; +import org.springframework.http.HttpCookie; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@code QueryExecutionServiceImpl} (unit U7, task L3-6): executor routing, the request handed to the node client, + * oauth2 token injection, error mapping and the timeout, with Mockito collaborators and StepVerifier. + */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class QueryExecutionServiceImplTest { + + private static final String JAVA_TYPE = "L3-6-java"; + private static final String JS_TYPE = "L3-6-js"; + private static final Duration VERIFY_TIMEOUT = Duration.ofSeconds(5); + + private DatasourceConnectionPool pool; + private DatasourceMetaInfoService metaInfoService; + private DatasourcePluginClient pluginClient; + private CommonConfig common; + private QueryExecutionServiceImpl service; + private QueryExecutor executor; + private QueryExecutionContext executionContext; + private DatasourceConnectionHolder holder; + private QueryExecutionResult success; + + @BeforeEach + void setUp() { + pool = mock(DatasourceConnectionPool.class); + metaInfoService = mock(DatasourceMetaInfoService.class); + pluginClient = mock(DatasourcePluginClient.class); + common = new CommonConfig(); + service = new QueryExecutionServiceImpl(pool, metaInfoService, pluginClient, common); + + executor = mock(QueryExecutor.class); + executionContext = mock(QueryExecutionContext.class); + holder = mock(DatasourceConnectionHolder.class); + success = QueryExecutionResult.success("rows"); + when(metaInfoService.isJsDatasourcePlugin(JS_TYPE)).thenReturn(true); + when(metaInfoService.getQueryExecutor(JAVA_TYPE)).thenReturn(executor); + when(executor.buildQueryExecutionContextMono(any(), any(), any(), any())).thenReturn(Mono.just(executionContext)); + when(holder.connection()).thenReturn("connection-object"); + when(pool.getOrCreateConnection(any())).thenReturn((Mono) Mono.just(holder)); + when(executor.doExecuteQuery(any(), any())).thenReturn(Mono.just(success)); + } + + private static Datasource javaDatasource() { + return Datasource.builder().id("ds-java").type(JAVA_TYPE).detailConfig(mock(DatasourceConnectionConfig.class)).build(); + } + + private static Datasource jsDatasource(JsDatasourceConnectionConfig config) { + return Datasource.builder().id("ds-js").type(JS_TYPE).detailConfig(config).build(); + } + + private static QueryVisitorContext visitor(MultiValueMap cookies, Mono> tokens) { + return new QueryVisitorContext("visitor", "org", 8080, cookies, tokens, Set.of()); + } + + private static QueryVisitorContext visitor() { + return visitor(new LinkedMultiValueMap<>(), Mono.just(List.of())); + } + + private static JsDatasourceConnectionConfig jsConfigWithAuthType(String authType) { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + if (authType != null) { + HashMap authConfig = new HashMap<>(); + authConfig.put("type", authType); + config.put("authConfig", authConfig); + } + return config; + } + + // ---------------------------------------------------------------- timeout + + /** Catches a user-set timeout being overwritten (:48) and the default not being written. */ + @Test + void executeQuery_blankTimeout_putsTheDefaultIntoTheQueryConfigWithoutOverwriting() { + Map queryConfig = new HashMap<>(); + StepVerifier.create(service.executeQuery(javaDatasource(), queryConfig, Map.of(), "", visitor())).expectNext(success).verifyComplete(); + assertThat(queryConfig).containsEntry("timeoutMs", "10000"); + + Map preset = new HashMap<>(Map.of("timeoutMs", "777")); + StepVerifier.create(service.executeQuery(javaDatasource(), preset, Map.of(), "5s", visitor())).expectNext(success).verifyComplete(); + assertThat(preset).as("an existing timeoutMs is kept").containsEntry("timeoutMs", "777"); + System.out.println("[QueryExecutionServiceImplTest] default timeout 10000 written; an existing value kept"); + } + + /** + * Pins the plan section 9 row "the maximum query timeout is multiplied by 1000 twice over" (:47): the caller + * passes {@code getMaxQueryTimeout() * 1000} to a method that takes seconds, so with the default maximum of 300 s + * a "400s" timeout is accepted (limit 300000 s). A fix changes this test on purpose. + */ + @Test + void executeQuery_timeoutAboveTheConfiguredMaximum_isAcceptedToday_pinsSection9Row() { + assertThat(common.getMaxQueryTimeout()).isEqualTo(300); + Map queryConfig = new HashMap<>(); + + StepVerifier.create(service.executeQuery(javaDatasource(), queryConfig, Map.of(), "400s", visitor())).expectNext(success).verifyComplete(); + + assertThat(queryConfig).containsEntry("timeoutMs", "400000"); + System.out.println("[QueryExecutionServiceImplTest] pins the section 9 row: 400s accepted with a 300 s maximum"); + } + + /** + * Pins the overflow extension of the same section 9 row: with the x1000 a configured maximum of 2148 s makes the + * argument exceed 2,147,483 s, the int cast in {@code QueryTimeoutUtils:36} turns negative, and the blank-timeout + * default becomes a NEGATIVE timeoutMs, so the query times out at once. A fix (no x1000) changes this test. + */ + @Test + void executeQuery_maximumOf2148Seconds_makesTheDefaultTimeoutNegative_pinsSection9Row() { + common.setMaxQueryTimeout(2148); + Map queryConfig = new HashMap<>(); + when(executor.doExecuteQuery(any(), any())).thenReturn(Mono.never()); + + StepVerifier.create(service.executeQuery(javaDatasource(), queryConfig, Map.of(), "", visitor())) + .assertNext(result -> assertThat(result.getQueryCode()).isEqualTo(PluginCommonError.QUERY_EXECUTION_TIMEOUT.name())) + .expectComplete() + .verify(VERIFY_TIMEOUT); + + assertThat(Integer.parseInt((String) queryConfig.get("timeoutMs"))).isNegative(); + System.out.println("[QueryExecutionServiceImplTest] pins the section 9 row: maximum 2148 -> timeoutMs " + queryConfig.get("timeoutMs")); + } + + /** Catches invalid timeouts reaching the executor (:47): they are thrown synchronously, outside the reactive chain. */ + @ParameterizedTest + @ValueSource(strings = {"-5", "abc"}) + void executeQuery_invalidTimeout_throwsSynchronouslyBeforeAnyExecutor(String timeout) { + assertThatThrownBy(() -> service.executeQuery(javaDatasource(), new HashMap<>(), Map.of(), timeout, visitor())) + .isInstanceOfSatisfying(PluginException.class, plugin -> assertThat(plugin.getError()).isEqualTo(PluginCommonError.QUERY_ARGUMENT_ERROR)); + verifyNoInteractions(executor, pluginClient, pool); + System.out.println("[QueryExecutionServiceImplTest] invalid timeout '" + timeout + "' -> PluginException thrown by the call"); + } + + // ---------------------------------------------------------------- local executor + + /** Catches the wrong executor (:51) or a connection not passed on (:77): the Java plugin path end to end. */ + @Test + void executeQuery_javaPlugin_runsTheLocalExecutorWithTheConnection() { + Datasource datasource = javaDatasource(); + Map queryConfig = new HashMap<>(); + Map params = Map.of("p", 1); + QueryVisitorContext visitor = visitor(); + + StepVerifier.create(service.executeQuery(datasource, queryConfig, params, "", visitor)).expectNext(success).verifyComplete(); + + verify(executor).buildQueryExecutionContextMono(datasource.getDetailConfig(), queryConfig, params, visitor); + verify(pool).getOrCreateConnection(datasource); + verify(executor).doExecuteQuery("connection-object", executionContext); + verifyNoInteractions(pluginClient); + System.out.println("[QueryExecutionServiceImplTest] Java plugin: local executor, connection passed, node client unused"); + } + + /** Catches executor errors not reaching the holder (:78): that call is what marks a Hikari connection stale. */ + @Test + void executeQuery_localExecutorError_isReportedToTheConnectionHolder() { + PluginException failure = new PluginException(PluginCommonError.QUERY_EXECUTION_ERROR, "QUERY_EXECUTION_ERROR", "bad query"); + when(executor.doExecuteQuery(any(), any())).thenReturn(Mono.error(failure)); + + StepVerifier.create(service.executeQuery(javaDatasource(), new HashMap<>(), Map.of(), "", visitor())) + .assertNext(result -> assertThat(result.getQueryCode()).isEqualTo(PluginCommonError.QUERY_EXECUTION_ERROR.name())) + .verifyComplete(); + verify(holder).onQueryError(failure); + System.out.println("[QueryExecutionServiceImplTest] executor error reported to the connection holder"); + } + + // ---------------------------------------------------------------- node client + + /** Catches params or cookies lost or mis-keyed for JS plugins (:83-98): params first, cookies appended, nulls allowed. */ + @Test + void executeQuery_jsPlugin_handsParamsAndCookiesAsKeyValueContextToTheNodeClient() { + JsDatasourceConnectionConfig config = jsConfigWithAuthType(null); + Datasource datasource = jsDatasource(config); + Map queryConfig = new HashMap<>(); + Map params = new HashMap<>(); + params.put("param", "value"); + params.put("nullParam", null); + MultiValueMap cookies = new LinkedMultiValueMap<>(); + cookies.add("session", new HttpCookie("session", "abc")); + when(pluginClient.executeQuery(eq(JS_TYPE), any(), any(), any())).thenReturn(Mono.just(success)); + + StepVerifier.create(service.executeQuery(datasource, queryConfig, params, "", visitor(cookies, Mono.just(List.of())))) + .expectNext(success).verifyComplete(); + + org.mockito.ArgumentCaptor>> context = org.mockito.ArgumentCaptor.forClass(List.class); + verify(pluginClient).executeQuery(eq(JS_TYPE), eq(queryConfig), context.capture(), eq(config)); + List> sent = context.getValue(); + assertThat(sent).hasSize(3); + assertThat(sent.subList(0, 2)).containsExactlyInAnyOrder(Map.of("key", "param", "value", "value"), + new HashMap<>(Map.of("key", "nullParam")) {{ put("value", null); }}); + assertThat(sent.get(2)).containsEntry("key", "session").containsEntry("value", cookies.get("session")); + verifyNoInteractions(executor, pool); + System.out.println("[QueryExecutionServiceImplTest] JS plugin: params then cookies sent as key/value context"); + } + + /** + * Catches the oauth2 token injected for the wrong datasources (:102-103), not added (:117), or the node call made + * before the token is known (:104-105): subscription order is recorded in an event log, and the context seen at the + * moment the client call is assembled already holds the token. + */ + @Test + void executeQuery_jsPluginWithOauth2InheritFromLogin_appendsTheAuthTokensBeforeTheCall() { + JsDatasourceConnectionConfig config = jsConfigWithAuthType("OAUTH2_INHERIT_FROM_LOGIN"); + List events = new ArrayList<>(); + List> contextAtAssembly = new ArrayList<>(); + Mono> tokens = Mono.fromSupplier(() -> { + events.add("token-subscribed"); + return List.of(new Property("access_token", "tok-1"), new Property("refresh_token", "tok-2")); + }); + when(pluginClient.executeQuery(eq(JS_TYPE), any(), any(), any())).thenAnswer(invocation -> { + events.add("client-assembled"); + contextAtAssembly.addAll((List>) invocation.getArgument(2)); + return Mono.fromSupplier(() -> { + events.add("client-subscribed"); + return success; + }); + }); + + StepVerifier.create(service.executeQuery(jsDatasource(config), new HashMap<>(), Map.of("p", "v"), "", visitor(new LinkedMultiValueMap<>(), tokens))) + .expectNext(success).verifyComplete(); + + assertThat(events).containsExactly("token-subscribed", "client-assembled", "client-subscribed"); + assertThat(contextAtAssembly).containsExactly(Map.of("key", "p", "value", "v"), + Map.of("key", "access_token", "value", "tok-1"), Map.of("key", "refresh_token", "value", "tok-2")); + System.out.println("[QueryExecutionServiceImplTest] oauth2 inherit: token subscribed first, client assembled with the tokens: " + events); + } + + /** Catches token injection for other auth types and non-JS configs (:102-103): the token Mono is never subscribed. */ + @ParameterizedTest + @ValueSource(strings = {"NO_AUTH", "BASIC_AUTH", "OAUTH2"}) + void executeQuery_jsPluginWithAnotherAuthType_neverAsksForTheToken(String authType) { + List events = new ArrayList<>(); + Mono> tokens = Mono.fromSupplier(() -> { + events.add("token-subscribed"); + return List.of(); + }); + when(pluginClient.executeQuery(eq(JS_TYPE), any(), any(), any())).thenReturn(Mono.just(success)); + + StepVerifier.create(service.executeQuery(jsDatasource(jsConfigWithAuthType(authType)), new HashMap<>(), Map.of(), + "", visitor(new LinkedMultiValueMap<>(), tokens))).expectNext(success).verifyComplete(); + + assertThat(events).isEmpty(); + System.out.println("[QueryExecutionServiceImplTest] auth type " + authType + " -> the token is not requested"); + } + + // ---------------------------------------------------------------- error mapping + + /** Catches plugin errors surfacing as exceptions (:58): a PluginException becomes an emitted error result. */ + @Test + void executeQuery_pluginException_becomesAnErrorResult() { + when(executor.doExecuteQuery(any(), any())).thenReturn( + Mono.error(new PluginException(PluginCommonError.QUERY_ARGUMENT_ERROR, "INVALID_TIMEOUT_SETTING", "x"))); + + StepVerifier.create(service.executeQuery(javaDatasource(), new HashMap<>(), Map.of(), "", visitor())) + .assertNext(result -> { + assertThat(result.getQueryCode()).isEqualTo(PluginCommonError.QUERY_ARGUMENT_ERROR.name()); + assertThat(result.getMessageKey()).isEqualTo("INVALID_TIMEOUT_SETTING"); + }) + .verifyComplete(); + System.out.println("[QueryExecutionServiceImplTest] PluginException -> error result"); + } + + /** Catches a BizException being rewrapped (:60) and other exceptions leaking raw (:64). */ + @Test + void executeQuery_bizExceptionPassesThrough_otherExceptionsAreWrapped() { + BizException biz = new BizException(BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED"); + when(executor.doExecuteQuery(any(), any())).thenReturn(Mono.error(biz)); + StepVerifier.create(service.executeQuery(javaDatasource(), new HashMap<>(), Map.of(), "", visitor())) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(biz)).verify(VERIFY_TIMEOUT); + + when(executor.doExecuteQuery(any(), any())).thenReturn(Mono.error(new IllegalStateException("db exploded"))); + StepVerifier.create(service.executeQuery(javaDatasource(), new HashMap<>(), Map.of(), "", visitor())) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(BizException.class); + BizException wrapped = (BizException) error; + assertThat(wrapped.getError()).isEqualTo(BizError.QUERY_EXECUTION_ERROR); + assertThat(wrapped.getArgs()).containsExactly("db exploded"); + }).verify(VERIFY_TIMEOUT); + System.out.println("[QueryExecutionServiceImplTest] BizException unchanged; others wrapped as QUERY_EXECUTION_ERROR"); + } + + /** Catches a hanging query (:56) and the timeout code (:57): "2s" times out after 2000 ms (virtual time) as an error result. */ + @Test + void executeQuery_executorThatNeverAnswers_timesOutAfterTheConfiguredTimeout() { + when(executor.doExecuteQuery(any(), any())).thenReturn(Mono.never()); + + StepVerifier.withVirtualTime(() -> service.executeQuery(javaDatasource(), new HashMap<>(), Map.of(), "2s", visitor())) + .expectSubscription() + .expectNoEvent(Duration.ofMillis(1999)) + .thenAwait(Duration.ofMillis(1)) + .assertNext(result -> { + assertThat(result.getQueryCode()).isEqualTo(PluginCommonError.QUERY_EXECUTION_TIMEOUT.name()); + assertThat(result.getMessageKey()).isEqualTo("PLUGIN_EXECUTION_TIMEOUT"); + assertThat(result.getMessageArgs()).containsExactly(2000); + }) + .expectComplete() + .verify(VERIFY_TIMEOUT); + System.out.println("[QueryExecutionServiceImplTest] 2s timeout -> QUERY_EXECUTION_TIMEOUT result after 2000 ms"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/query/util/QueryTimeoutUtilsTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/query/util/QueryTimeoutUtilsTest.java new file mode 100644 index 0000000000..814c6279ec --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/query/util/QueryTimeoutUtilsTest.java @@ -0,0 +1,141 @@ +package org.lowcoder.domain.query.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.lowcoder.domain.query.util.QueryTimeoutUtils.parseQueryTimeoutMs; + +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; + +/** + * {@code QueryTimeoutUtils} (unit U1, task L3-4), in its own unit: the maximum argument is in SECONDS. Note that the + * only production caller, {@code QueryExecutionServiceImpl:47}, passes milliseconds (see the plan section 9 row "the + * maximum query timeout is multiplied by 1000 twice over"); that is pinned with the caller, not here. + * The default is process-global static state, reset after every test. + */ +class QueryTimeoutUtilsTest { + + private static final int DEFAULT_SECONDS = 10; + private static final int MAX_SECONDS = 300; + + @AfterEach + void resetTheStaticDefault() { + new QueryTimeoutUtils().setDefaultQueryTimeoutMillis(DEFAULT_SECONDS); + } + + private static void assertPluginError(Throwable error, PluginCommonError expected, String messageKey, Object arg) { + assertThat(error).isInstanceOf(PluginException.class); + PluginException plugin = (PluginException) error; + assertThat(plugin.getError()).isEqualTo(expected); + assertThat(plugin.getMessageKey()).isEqualTo(messageKey); + assertThat(plugin.getArgs()).containsExactly(arg); + } + + /** Catches a default larger than the maximum being handed out unclamped (:36). */ + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void parse_blankTimeout_usesTheDefaultClampedByTheMaximum(String blank) { + assertThat(parseQueryTimeoutMs(blank, MAX_SECONDS)).as("default 10 s below the maximum").isEqualTo(10_000); + assertThat(parseQueryTimeoutMs(blank, 5)).as("default 10 s clamped to a 5 s maximum").isEqualTo(5_000); + System.out.println("[QueryTimeoutUtilsTest] blank '" + blank + "' -> default 10000 ms, clamped to 5000 ms by a 5 s maximum"); + } + + /** + * Catches unit confusion with the property: despite its name, {@code setDefaultQueryTimeoutMillis} takes SECONDS + * (:24-27), so 30 gives 30000 ms. + */ + @Test + void setDefaultQueryTimeout_takesSecondsDespiteItsName() { + new QueryTimeoutUtils().setDefaultQueryTimeoutMillis(30); + + assertThat(parseQueryTimeoutMs("", MAX_SECONDS)).isEqualTo(30_000); + System.out.println("[QueryTimeoutUtilsTest] setDefaultQueryTimeoutMillis(30) -> blank timeout is 30000 ms (value is seconds)"); + } + + /** Catches seconds read as milliseconds or the reverse, and case sensitivity of the unit (:64, :72-79). */ + @ParameterizedTest(name = "\"{0}\" -> {1} ms") + @CsvSource({"5s,5000", "5S,5000", "2.5s,2500", "500ms,500", "500MS,500", "500,500", "1e3,1000", "0,0", "0s,0"}) + void parse_secondsAndMilliseconds_areScaledAndCaseInsensitive(String input, int expectedMs) { + assertThat(parseQueryTimeoutMs(input, MAX_SECONDS)).isEqualTo(expectedMs); + System.out.println("[QueryTimeoutUtilsTest] '" + input + "' -> " + expectedMs + " ms"); + } + + /** + * Pins the plan section 9 row "QueryTimeoutUtils reads 5m as 5 ms" (convertToMs:63-69): only the exact unit "s" + * is scaled, every other unit (m, min, sec, minutes) is read as milliseconds. A fix changes this test on purpose. + */ + @ParameterizedTest(name = "\"{0}\" -> {1} ms") + @CsvSource({"5m,5", "5min,5", "10sec,10", "1minutes,1", "5M,5"}) + void parse_unitsOtherThanExactlyS_areReadAsMilliseconds_pinsSection9Row(String input, int expectedMs) { + assertThat(parseQueryTimeoutMs(input, MAX_SECONDS)).isEqualTo(expectedMs); + System.out.println("[QueryTimeoutUtilsTest] pins the section 9 row: '" + input + "' read as " + expectedMs + " ms"); + } + + /** Catches garbage turned into a negative or zero timeout (:51): invalid text and negative numbers are rejected. */ + @ParameterizedTest(name = "\"{0}\"") + @ValueSource(strings = {"-5", "-5s", "abc", "s", "ms", "5x", "1.2.3s", "5 5s"}) + void parse_invalidValues_failWithQueryArgumentError(String input) { + assertThatThrownBy(() -> parseQueryTimeoutMs(input, MAX_SECONDS)) + .satisfies(error -> assertPluginError(error, PluginCommonError.QUERY_ARGUMENT_ERROR, "INVALID_TIMEOUT_SETTING", input)); + System.out.println("[QueryTimeoutUtilsTest] '" + input + "' -> QUERY_ARGUMENT_ERROR"); + } + + /** + * Pins the plan section 9 row "Candidate, to be reproduced by L3-4" (NaN): {@code NumberUtils.toDouble("NaN", -1)} + * is NaN, {@code NaN < 0} is false (:51), {@code (int) NaN} is 0 and {@code 0 > max} is false, so "NaN" is accepted + * as a 0 ms timeout. A fix (reject NaN) changes this test on purpose. + */ + @Test + void parse_nan_isAcceptedAsZeroMilliseconds_pinsSection9Candidate() { + assertThat(parseQueryTimeoutMs("NaN", MAX_SECONDS)).isZero(); + System.out.println("[QueryTimeoutUtilsTest] pins the section 9 candidate: 'NaN' -> 0 ms"); + } + + /** + * Catches an off-by-one on the maximum and an int overflow slipping through (:56): with a 10 s maximum, exactly 10 s + * is accepted and anything above is rejected with EXCEED_MAX_QUERY_TIMEOUT carrying the maximum in seconds. (The + * production caller passes milliseconds here, see the section 9 row named in the class comment.) + */ + @Test + void parse_maximumBoundary_isInclusive() { + assertThat(parseQueryTimeoutMs("10s", 10)).isEqualTo(10_000); + assertThat(parseQueryTimeoutMs("10000", 10)).isEqualTo(10_000); + + for (String above : new String[] {"10001", "10.001s", "11s", "99999999999s", "Infinity"}) { + assertThatThrownBy(() -> parseQueryTimeoutMs(above, 10)) + .as(above) + .satisfies(error -> assertPluginError(error, PluginCommonError.EXCEED_MAX_QUERY_TIMEOUT, "EXCEED_MAX_QUERY_TIMEOUT", 10)); + } + System.out.println("[QueryTimeoutUtilsTest] maximum 10 s: 10s/10000 accepted, 10001/10.001s/11s/overflow/Infinity rejected"); + } + + /** Catches mustache placeholders being parsed as garbage (:30): the template is rendered from the parameters first. */ + @Test + void parse_mustacheOverload_rendersParametersFirst() { + assertThat(parseQueryTimeoutMs("{{t}}", Map.of("t", "5s"), MAX_SECONDS)).isEqualTo(5_000); + assertThat(parseQueryTimeoutMs("{{n}}s", Map.of("n", 3), MAX_SECONDS)).isEqualTo(3_000); + assertThat(parseQueryTimeoutMs("", Map.of("t", "5s"), MAX_SECONDS)).as("blank template").isEqualTo(10_000); + assertThat(parseQueryTimeoutMs(null, Map.of(), MAX_SECONDS)).as("null template").isEqualTo(10_000); + System.out.println("[QueryTimeoutUtilsTest] mustache overload: {{t}} -> 5000, {{n}}s -> 3000, blank/null -> default"); + } + + /** + * Pins what an unresolved parameter renders to: {@code {{missing}}} renders to an empty string, which is blank, so + * the default timeout applies instead of an error. + */ + @Test + void parse_mustacheOverload_missingParameterRendersBlankAndFallsBackToTheDefault() { + assertThat(parseQueryTimeoutMs("{{missing}}", Map.of("t", "5s"), MAX_SECONDS)).isEqualTo(10_000); + assertThat(parseQueryTimeoutMs("{{missing}}", Map.of("t", "5s"), 5)).as("clamped by the maximum").isEqualTo(5_000); + System.out.println("[QueryTimeoutUtilsTest] unresolved {{missing}} -> blank -> default timeout"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/solutions/TemplateDslCopyContractTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/solutions/TemplateDslCopyContractTest.java new file mode 100644 index 0000000000..0d8b54c008 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/solutions/TemplateDslCopyContractTest.java @@ -0,0 +1,135 @@ +package org.lowcoder.domain.solutions; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.service.DatasourceService; +import org.lowcoder.domain.template.model.Template; +import org.lowcoder.domain.template.service.TemplateService; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.JavaValueWalker; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.contract.RepresentativeInput; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Group {@code dynamic-roundtrip} (docs/API_PAYLOAD_TEST_PLAN.md §4.10, the {@code lowcoder-domain} part of task T9.3): + * {@code TemplateSolutionServiceImpl.createFromTemplate} copies the template application's DSL as text: the production + * mapper writes it ({@code toJson}), every template data source id is replaced in that text by the id of its copy, and + * the text is read back ({@code fromJsonMap}) as the new application's editing DSL. The real service runs with mocked + * stores on {@link #dsl()}, the §4.6 representative input as a query's {@code comp} and as a UI value, plus a + * {@code java.util.Date} (as MongoDB hands one out) and a {@code BigDecimal}; the DSL handed to + * {@code ApplicationService.create} is pinned, with the Java class of every value ({@link JavaValueWalker}) and the text + * the production mapper writes for it, next to the same for the input, in {@value #REPORT}. + * + *

    Limits: the stores are mocks; the template application has no published record, so its editing DSL is the live + * one (`Application.getLiveApplicationDsl`); the replacement is plain text replacement, so the fixture also shows an id + * replaced inside an unrelated string. + */ +class TemplateDslCopyContractTest { + + static final String REPORT = "dynamic-roundtrip/TemplateSolutionService.createFromTemplate.json"; + static final String TEMPLATE_ID = "template1"; + static final String APPLICATION_ID = "app1"; + static final String ORGANIZATION_ID = "org1"; + static final String VISITOR_ID = "user1"; + static final String TEMPLATE_DATASOURCE_ID = "ds-template"; + static final String COPIED_DATASOURCE_ID = "ds-copy"; + static final String INPUT_KEY = "input"; + static final String COPY_KEY = "copy"; + static final String SHAPE_KEY = "shape"; + static final String WRITTEN_KEY = "written"; + static final Duration TIMEOUT = Duration.ofSeconds(20); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final TemplateService templateService = mock(TemplateService.class); + private final DatasourceService datasourceService = mock(DatasourceService.class); + private final ApplicationService applicationService = mock(ApplicationService.class); + private final ApplicationRecordService applicationRecordService = mock(ApplicationRecordService.class); + + @BoundarySites({ + "lowcoder-domain/src/main/java/org/lowcoder/domain/solutions/TemplateSolutionServiceImpl.java#TemplateSolutionServiceImpl.createFromTemplate#toJson#1", + "lowcoder-domain/src/main/java/org/lowcoder/domain/solutions/TemplateSolutionServiceImpl.java#TemplateSolutionServiceImpl.createFromTemplate#fromJsonMap#1"}) + @Test + void dslCopyAsPinned() { + Map dsl = dsl(); + Map report = new LinkedHashMap<>(); + report.put(INPUT_KEY, outcome(dsl)); + report.put(COPY_KEY, outcome(copy(dsl))); + String actual = ConfigBinding.write(report); + System.out.println("[TemplateDslCopyContractTest]\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** The editing DSL of the application {@code createFromTemplate} creates from a template whose DSL is {@code dsl}. */ + private Map copy(Map dsl) { + Template template = mock(Template.class); + when(template.getName()).thenReturn("Template"); + when(template.getApplicationId()).thenReturn(APPLICATION_ID); + when(templateService.getById(TEMPLATE_ID)).thenReturn(Mono.just(template)); + Application templateApplication = Application.builder().id(APPLICATION_ID).editingApplicationDSL(dsl).build(); + when(applicationService.findById(APPLICATION_ID)).thenReturn(Mono.just(templateApplication)); + when(applicationRecordService.getLatestRecordByApplicationId(APPLICATION_ID)).thenReturn(Mono.empty()); + Datasource templateDatasource = mock(Datasource.class); + when(templateDatasource.getName()).thenReturn("Template datasource"); + when(datasourceService.getById(TEMPLATE_DATASOURCE_ID)).thenReturn(Mono.just(templateDatasource)); + Datasource copiedDatasource = mock(Datasource.class); + when(copiedDatasource.getId()).thenReturn(COPIED_DATASOURCE_ID); + when(datasourceService.create(any(Datasource.class), eq(VISITOR_ID))).thenReturn(Mono.just(copiedDatasource)); + when(applicationService.create(any(Application.class), eq(VISITOR_ID))).thenAnswer(call -> Mono.just(call.getArgument(0))); + + new TemplateSolutionServiceImpl(templateService, datasourceService, applicationService, applicationRecordService) + .createFromTemplate(TEMPLATE_ID, ORGANIZATION_ID, VISITOR_ID).block(TIMEOUT); + ArgumentCaptor created = ArgumentCaptor.forClass(Application.class); + verify(applicationService).create(created.capture(), eq(VISITOR_ID)); + return created.getValue().getEditingApplicationDSL(); + } + + /** The Java class of every value and the text the production mapper writes. */ + private static Map outcome(Map dsl) { + Map outcome = new LinkedHashMap<>(); + outcome.put(SHAPE_KEY, JavaValueWalker.shape(dsl)); + outcome.put(WRITTEN_KEY, QueryResults.written(dsl)); + return outcome; + } + + /** A template DSL: one query on the template data source, UI values, a stored date and a decimal. */ + private static Map dsl() { + Map query = new LinkedHashMap<>(); + query.put("id", "q1"); + query.put("gid", "gq1"); + query.put("name", "query1"); + query.put("datasourceId", TEMPLATE_DATASOURCE_ID); + query.put("compType", "postgres"); + query.put("comp", RepresentativeInput.map()); + Map ui = new LinkedHashMap<>(); + ui.put("representative", RepresentativeInput.map()); + ui.put("savedAt", new Date(1_709_212_455_123L)); + ui.put("ratio", new BigDecimal("1.50")); + ui.put("label", "reads from " + TEMPLATE_DATASOURCE_ID); + Map dsl = new LinkedHashMap<>(); + dsl.put("ui", ui); + dsl.put("queries", new ArrayList<>(List.of(query))); + return dsl; + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/model/UserModelLogicTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/model/UserModelLogicTest.java new file mode 100644 index 0000000000..3f813a899d --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/model/UserModelLogicTest.java @@ -0,0 +1,219 @@ +package org.lowcoder.domain.user.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.lowcoder.sdk.constants.AuthSourceConstants; + +/** + * User model decisions (unit U4b, task L3-7): UserStatus, Connection and ConnectionAuthToken. + */ +class UserModelLogicTest { + + private static final String GUIDANCE_KEY = "newUserGuidance"; + private static final String OTHER_KEY = "other"; + private static final String ORG_A = "orgA"; + private static final String ORG_B = "orgB"; + private static final String GITHUB = "GITHUB"; + private static final String RAW_ID = "raw-1"; + private static final long SECOND = 1000L; + private static final long MARGIN_SECONDS = 60; + private static final long TOLERANCE_SECONDS = 5; + private static final long FAR_SECONDS = 3600; + + private static long nowSeconds() { + return System.currentTimeMillis() / SECOND; + } + + private static Connection connection(String source, Set orgIds) { + return Connection.builder().source(source).rawId(RAW_ID).orgIds(orgIds).build(); + } + + // ---------------------------------------------------------------- UserStatus + + /** Catches: a user without a status map not getting the guidance flag, or a null Boolean throwing. */ + @Test + void statusMapWithoutAStoredMapCarriesOnlyTheGuidanceFlag() { + UserStatus unset = UserStatus.builder().build(); + System.out.println("[UserModelLogicTest] null map, null flag -> " + unset.getStatusMap()); + assertThat(unset.getStatusMap()).containsOnly(Map.entry(GUIDANCE_KEY, false)); + assertThat(UserStatus.builder().hasShowNewUserGuidance(true).build().getStatusMap()) + .containsOnly(Map.entry(GUIDANCE_KEY, true)); + } + + /** Catches: the stored value being overwritten by the Boolean field when the key is already present. */ + @Test + void statusMapThatAlreadyHasTheKeyIsReturnedUnchanged() { + Map stored = new HashMap<>(Map.of(GUIDANCE_KEY, "stored", OTHER_KEY, 1)); + UserStatus status = UserStatus.builder().hasShowNewUserGuidance(true).statusMap(stored).build(); + + assertThat(status.getStatusMap()).isSameAs(stored).containsEntry(GUIDANCE_KEY, "stored"); + } + + /** Catches: the stored map being mutated (the original is shared with the persisted document). */ + @Test + void statusMapWithoutTheKeyIsACopyWithTheFlagAdded() { + Map stored = new HashMap<>(Map.of(OTHER_KEY, 1)); + UserStatus status = UserStatus.builder().hasShowNewUserGuidance(true).statusMap(stored).build(); + + Map result = status.getStatusMap(); + System.out.println("[UserModelLogicTest] stored=" + stored + " result=" + result); + assertThat(result).containsEntry(GUIDANCE_KEY, true).containsEntry(OTHER_KEY, 1); + assertThat(result).isNotSameAs(stored); + assertThat(stored).doesNotContainKey(GUIDANCE_KEY); + } + + /** Catches: a null Boolean throwing or counting as true; a banned user not being reported. */ + @Test + void bannedAndGuidanceFlagsTreatNullAsFalse() { + UserStatus none = UserStatus.builder().build(); + assertThat(none.isBanned()).isFalse(); + assertThat(none.hasShowNewUserGuidance()).isFalse(); + + UserStatus set = UserStatus.builder().banned(true).hasShowNewUserGuidance(true).build(); + assertThat(set.isBanned()).isTrue(); + assertThat(set.hasShowNewUserGuidance()).isTrue(); + + UserStatus unset = UserStatus.builder().banned(false).hasShowNewUserGuidance(false).build(); + assertThat(unset.isBanned()).isFalse(); + assertThat(unset.hasShowNewUserGuidance()).isFalse(); + System.out.println("[UserModelLogicTest] null/true/false flags checked"); + } + + // ---------------------------------------------------------------- Connection + + /** Catches: an NPE when tokens were never set; tokens added and removed wrongly. */ + @Test + void tokensCanBeAddedAndRemovedEvenWhenNeverInitialised() { + Connection fresh = connection(GITHUB, null); + assertThat(fresh.getTokens()).isEmpty(); + fresh.addToken("t1"); + fresh.addToken("t2"); + assertThat(fresh.getTokens()).containsExactlyInAnyOrder("t1", "t2"); + fresh.removeToken("t1"); + assertThat(fresh.getTokens()).containsExactly("t2"); + + Connection never = connection(GITHUB, null); + never.removeToken("absent"); + assertThat(never.getTokens()).isEmpty(); + System.out.println("[UserModelLogicTest] add/remove token on null set ok"); + } + + /** Catches: a null or empty org set not being replaced by a mutable one, so addOrg would NPE. */ + @Test + void orgsStartEmptyEvenFromNullAndCanBeAddedAndFound() { + for (Set given : new Set[] {null, Set.of()}) { + Connection connection = connection(GITHUB, given); + assertThat(connection.containOrg(ORG_A)).isFalse(); + connection.addOrg(ORG_A); + assertThat(connection.containOrg(ORG_A)).isTrue(); + assertThat(connection.containOrg(ORG_B)).isFalse(); + } + } + + /** + * Catches: the cloud match ignoring the organisation, or the self-host match requiring it. + * Columns: sourceType asked for, whether the connection belongs to ORG_A, cloud result, self-host result. + */ + @ParameterizedTest(name = "ask={0} inOrg={1} -> cloud={2} selfHost={3}") + @CsvSource({ + "GITHUB,true,true,true", + "GITHUB,false,false,true", + "GOOGLE,true,false,false", + "GOOGLE,false,false,false" + }) + void thirdPartyMatchNeedsTheOrgOnlyInTheCloud(String asked, boolean inOrg, boolean cloud, boolean selfHost) { + Connection connection = connection(GITHUB, inOrg ? new HashSet<>(Set.of(ORG_A)) : new HashSet<>(Set.of(ORG_B))); + System.out.println("[UserModelLogicTest] ask=" + asked + " inOrg=" + inOrg + " -> cloud=" + + connection.matchThirdPartyLoginSourceInCloud(asked, ORG_A) + " selfHost=" + + connection.matchThirdPartyLoginSourceInSelfHost(asked)); + assertThat(connection.matchThirdPartyLoginSourceInCloud(asked, ORG_A)).isEqualTo(cloud); + assertThat(connection.matchThirdPartyLoginSourceInSelfHost(asked)).isEqualTo(selfHost); + } + + /** Catches: the email source exposing the stored info, or other sources losing it; null info must not leak. */ + @Test + void rawUserInfoIsDerivedFromTheRawIdForEmailAndStoredOtherwise() { + Connection email = Connection.builder().source(AuthSourceConstants.EMAIL).rawId(RAW_ID) + .rawUserInfo(Map.of("ignored", "x")).build(); + assertThat(email.getRawUserInfo()).isEqualTo(Map.of("email", RAW_ID)); + + Connection github = Connection.builder().source(GITHUB).rawId(RAW_ID).rawUserInfo(Map.of("login", "u")).build(); + assertThat(github.getRawUserInfo()).isEqualTo(Map.of("login", "u")); + + Connection nothing = Connection.builder().source(GITHUB).rawId(RAW_ID).build(); + assertThat(nothing.getRawUserInfo()).isNotNull().isEmpty(); + System.out.println("[UserModelLogicTest] raw user info for EMAIL / other / null"); + } + + // ---------------------------------------------------------------- ConnectionAuthToken + + /** Catches: a missing expiry counting as valid; the comparison reversed. Values are an hour from now, so no clock race. */ + @ParameterizedTest(name = "expireAt offset {0}s -> expired={1}") + @CsvSource({"-3600,true", "3600,false"}) + void accessTokenExpiryComparesWithNow(long offsetSeconds, boolean expired) { + ConnectionAuthToken token = ConnectionAuthToken.builder().expireAt(nowSeconds() + offsetSeconds).build(); + System.out.println("[UserModelLogicTest] access expireAt offset " + offsetSeconds + " expired=" + token.isAccessTokenExpired()); + assertThat(token.isAccessTokenExpired()).isEqualTo(expired); + } + + /** Catches: as above for the refresh token, which uses its own field. */ + @ParameterizedTest(name = "refreshTokenExpireAt offset {0}s -> expired={1}") + @CsvSource({"-3600,true", "3600,false"}) + void refreshTokenExpiryComparesWithNow(long offsetSeconds, boolean expired) { + ConnectionAuthToken token = ConnectionAuthToken.builder().refreshTokenExpireAt(nowSeconds() + offsetSeconds).build(); + assertThat(token.isRefreshTokenExpired()).isEqualTo(expired); + } + + /** Catches: a token without any expiry being treated as still valid; the two fields being mixed up. */ + @Test + void aMissingExpiryMeansExpiredAndEachFieldIsIndependent() { + ConnectionAuthToken empty = ConnectionAuthToken.builder().build(); + assertThat(empty.isAccessTokenExpired()).isTrue(); + assertThat(empty.isRefreshTokenExpired()).isTrue(); + + ConnectionAuthToken onlyAccessValid = ConnectionAuthToken.builder().expireAt(nowSeconds() + FAR_SECONDS).build(); + assertThat(onlyAccessValid.isAccessTokenExpired()).isFalse(); + assertThat(onlyAccessValid.isRefreshTokenExpired()).isTrue(); + + ConnectionAuthToken onlyRefreshValid = ConnectionAuthToken.builder().refreshTokenExpireAt(nowSeconds() + FAR_SECONDS).build(); + assertThat(onlyRefreshValid.isAccessTokenExpired()).isTrue(); + assertThat(onlyRefreshValid.isRefreshTokenExpired()).isFalse(); + } + + /** Catches: the 60 s safety margin lost; the zero special case dropped; the two expiries mixed up. */ + @Test + void conversionKeepsZeroAndSubtractsTheSafetyMarginOtherwise() { + long expireIn = 3600; + long refreshExpireIn = 7200; + long before = nowSeconds(); + ConnectionAuthToken converted = ConnectionAuthToken.of(AuthToken.builder().accessToken("a").refreshToken("r") + .expireIn((int) expireIn).refreshTokenExpireIn((int) refreshExpireIn).build()); + long after = nowSeconds(); + System.out.println("[UserModelLogicTest] converted expireAt=" + converted.getExpireAt() + " refreshAt=" + + converted.getRefreshTokenExpireAt() + " now~" + before); + + assertThat(converted.getAccessToken()).isEqualTo("a"); + assertThat(converted.getRefreshToken()).isEqualTo("r"); + assertThat(converted.getSource()).isNull(); + assertThat(converted.getExpireAt()).isBetween(before + expireIn - MARGIN_SECONDS, after + expireIn - MARGIN_SECONDS); + assertThat(converted.getRefreshTokenExpireAt()) + .isBetween(before + refreshExpireIn - MARGIN_SECONDS, after + refreshExpireIn - MARGIN_SECONDS); + + ConnectionAuthToken zero = ConnectionAuthToken.of(AuthToken.builder().accessToken("a").build()); + assertThat(zero.getExpireAt()).isZero(); + assertThat(zero.getRefreshTokenExpireAt()).isZero(); + assertThat(zero.isAccessTokenExpired()).isTrue(); + + ConnectionAuthToken onlyRefresh = ConnectionAuthToken.of(AuthToken.builder().refreshTokenExpireIn((int) refreshExpireIn).build()); + assertThat(onlyRefresh.getExpireAt()).isZero(); + assertThat(onlyRefresh.getRefreshTokenExpireAt()).isGreaterThan(before + refreshExpireIn - MARGIN_SECONDS - TOLERANCE_SECONDS); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/EmailCommunicationServiceImplTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/EmailCommunicationServiceImplTest.java new file mode 100644 index 0000000000..48b826f493 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/EmailCommunicationServiceImplTest.java @@ -0,0 +1,208 @@ +package org.lowcoder.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Properties; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.mockito.ArgumentCaptor; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; + +import jakarta.mail.BodyPart; +import jakarta.mail.Message; +import jakarta.mail.Multipart; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; + +/** + * EmailCommunicationServiceImpl (unit U9, task L3-9). The sender is a mock that hands out a REAL MimeMessage, so the + * recipients, subject and body that would leave the process are read back from the message that was sent. + */ +class EmailCommunicationServiceImplTest { + + private static final String PUBLIC_URL = "https://lowcoder.example"; + private static final String SENDER = "noreply@lowcoder.example"; + private static final String RECIPIENT = "user@example.com"; + private static final String RESET_SUBJECT = "Reset Your Lost Password"; + private static final String INVITE_SUBJECT = "You've been invited!"; + private static final String RESET_TEMPLATE = "

    Hi, %s link: %s

    "; + private static final String INVITE_TEMPLATE = "Join: %s"; + private static final String INVITE_LINK = PUBLIC_URL + "/invite/CODE"; + private static final String LINK_PREFIX = PUBLIC_URL + "/user/auth/lost-password?token="; + private static final String HTML_WITH_LITERAL_PERCENT = "
    %s %s
    "; + + private final JavaMailSender sender = mock(JavaMailSender.class); + private final CommonConfig config = new CommonConfig(); + private final EmailCommunicationServiceImpl service = new EmailCommunicationServiceImpl(sender, config); + + @BeforeEach + void configure() { + config.setLowcoderPublicUrl(PUBLIC_URL); + config.setNotificationsEmailSender(SENDER); + when(sender.createMimeMessage()).thenAnswer(invocation -> new MimeMessage(Session.getInstance(new Properties()))); + } + + private MimeMessage sentOnce() throws Exception { + ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); + verify(sender, times(1)).send(captor.capture()); + MimeMessage message = captor.getValue(); + message.saveChanges(); // what a real sender does before transport: resolves the part content types + return message; + } + + private static String text(Object content) throws Exception { + if (content instanceof Multipart multipart) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < multipart.getCount(); i++) { + BodyPart part = multipart.getBodyPart(i); + out.append(text(part.getContent())); + } + return out.toString(); + } + return String.valueOf(content); + } + + private static boolean hasHtmlPart(Object content) throws Exception { + if (content instanceof Multipart multipart) { + for (int i = 0; i < multipart.getCount(); i++) { + BodyPart part = multipart.getBodyPart(i); + if (part.getContentType().toLowerCase().startsWith("text/html") || hasHtmlPart(part.getContent())) { + return true; + } + } + } + return false; + } + + private static String body(MimeMessage message) throws Exception { + return text(message.getContent()); + } + + private static String[] recipients(MimeMessage message) throws Exception { + InternetAddress[] all = (InternetAddress[]) message.getRecipients(Message.RecipientType.TO); + return all == null ? new String[0] : Arrays.stream(all).map(InternetAddress::getAddress).toArray(String[]::new); + } + + /** Catches: arguments of String.format swapped, wrong path or subject, wrong sender, wrong recipient. */ + @Test + void resetMailGoesToTheUserWithTheTemplateFilledWithAddressAndLink() throws Exception { + assertThat(service.sendPasswordResetEmail(RECIPIENT, "tok", RESET_TEMPLATE)).isTrue(); + + MimeMessage message = sentOnce(); + System.out.println("[EmailCommunicationServiceImplTest] reset body: " + body(message)); + assertThat(recipients(message)).containsExactly(RECIPIENT); + assertThat(((InternetAddress) message.getFrom()[0]).getAddress()).isEqualTo(SENDER); + assertThat(message.getSubject()).isEqualTo(RESET_SUBJECT); + assertThat(hasHtmlPart(message.getContent())).as("sent as text/html so the link is clickable").isTrue(); + assertThat(body(message)).isEqualTo("

    Hi, " + RECIPIENT + " link: " + LINK_PREFIX + "tok

    "); + } + + /** + * Catches: an unencoded token breaking the reset link. The generated passwords (UserServiceImpl.generateNewRandomPwd) + * use & + % # which would split or corrupt the query string. + */ + @Test + void theTokenIsPercentEncodedInTheResetLink() throws Exception { + String token = "a&b+c%d#e f"; + assertThat(service.sendPasswordResetEmail(RECIPIENT, token, "%s %s")).isTrue(); + + String link = body(sentOnce()).substring((RECIPIENT + " ").length()); + System.out.println("[EmailCommunicationServiceImplTest] link: " + link); + assertThat(link).startsWith(LINK_PREFIX); + String query = link.substring(LINK_PREFIX.length()); + assertThat(query).doesNotContain("&", "#", "%d", " "); + assertThat(URLDecoder.decode(query, StandardCharsets.UTF_8)).isEqualTo(token); + } + + /** Catches: a failed send or a broken message being reported as success, or an exception escaping. */ + @Test + void resetMailFailuresReturnFalseInsteadOfThrowing() { + doThrow(new MailSendException("smtp down")).when(sender).send(any(MimeMessage.class)); + assertThat(service.sendPasswordResetEmail(RECIPIENT, "tok", RESET_TEMPLATE)).isFalse(); + + JavaMailSender broken = mock(JavaMailSender.class); + when(broken.createMimeMessage()).thenThrow(new IllegalStateException("no session")); + assertThat(new EmailCommunicationServiceImpl(broken, config).sendPasswordResetEmail(RECIPIENT, "tok", RESET_TEMPLATE)).isFalse(); + + assertThat(service.sendPasswordResetEmail(null, "tok", RESET_TEMPLATE)).isFalse(); + System.out.println("[EmailCommunicationServiceImplTest] send failure, session failure and null recipient all give false"); + } + + /** Catches: a template asking for more arguments than given being sent half formatted. */ + @Test + void aTemplateWithTooManySpecifiersIsNotSent() { + assertThat(service.sendPasswordResetEmail(RECIPIENT, "tok", "%s %s %s")).isFalse(); + verify(sender, never()).send(any(MimeMessage.class)); + } + + /** + * Pins plan section 9 row "a customised reset or invite template containing a literal % makes the mail silently + * fail (String.format; returns false)". Reachability: an org admin can store a template with + * PUT .../commonSettings (OrganizationController:178 -> OrgApiServiceImpl:365), the default template + * (OrganizationService:18-20) has only %s; today the stored value is never read (L3-2 row on the reset-template key, + * UserServiceImpl:423), so the failure becomes live once that is fixed. A fix (escape or replace instead of + * String.format) changes this test on purpose. + */ + @Test + void aLiteralPercentInATemplateMakesBothMailsFailSilently_pinsTheSection9Row() { + assertThat(service.sendPasswordResetEmail(RECIPIENT, "tok", HTML_WITH_LITERAL_PERCENT)).isFalse(); + assertThat(service.sendInvitationEmails(new String[] {RECIPIENT}, INVITE_LINK, "%s")).isFalse(); + verify(sender, never()).send(any(MimeMessage.class)); + System.out.println("[EmailCommunicationServiceImplTest] PINNED: literal % in a template -> false, nothing sent"); + } + + /** Catches: wrong recipients, subject, arguments of the invitation mail (the address is not a format argument). */ + @Test + void invitationGoesToEveryRecipientWithTheLinkInTheTemplate() throws Exception { + String[] to = {"a@example.com", "b@example.com"}; + assertThat(service.sendInvitationEmails(to, INVITE_LINK, INVITE_TEMPLATE)).isTrue(); + + MimeMessage message = sentOnce(); + System.out.println("[EmailCommunicationServiceImplTest] invitation to " + Arrays.toString(recipients(message)) + ": " + body(message)); + assertThat(recipients(message)).containsExactly(to); + assertThat(message.getSubject()).isEqualTo(INVITE_SUBJECT); + assertThat(((InternetAddress) message.getFrom()[0]).getAddress()).isEqualTo(SENDER); + assertThat(hasHtmlPart(message.getContent())).as("sent as text/html so the link is clickable").isTrue(); + assertThat(body(message)).isEqualTo("Join: " + INVITE_LINK); + } + + /** Catches: an exception escaping to the controller, or a failure reported as success. */ + @Test + void invitationFailuresReturnFalseInsteadOfThrowing() { + assertThat(service.sendInvitationEmails(null, INVITE_LINK, INVITE_TEMPLATE)).isFalse(); + assertThat(service.sendInvitationEmails(new String[] {RECIPIENT}, INVITE_LINK, "%d")).isFalse(); + verify(sender, never()).send(any(MimeMessage.class)); + + doThrow(new MailSendException("smtp down")).when(sender).send(any(MimeMessage.class)); + assertThat(service.sendInvitationEmails(new String[] {RECIPIENT}, INVITE_LINK, INVITE_TEMPLATE)).isFalse(); + } + + /** + * Pins plan section 9 row "invitation mail with no recipients is sent and reports true". Reachable: POST + * /email/invite with {"emails": [], "orgId": ...} goes InvitationController:62-66 -> InvitationApiServiceImpl.create + * (which also stores an invitation) -> sendInvitationEmails(req.emails(), ...), with no validation of the array. + * A fix (reject an empty array) changes this test on purpose. + */ + @Test + void anInvitationWithNoRecipientsIsSentAndReportsTrue_pinsTheSection9Row() throws Exception { + assertThat(service.sendInvitationEmails(new String[0], INVITE_LINK, INVITE_TEMPLATE)).isTrue(); + + MimeMessage message = sentOnce(); + System.out.println("[EmailCommunicationServiceImplTest] PINNED: no recipients, sent, recipients=" + Arrays.toString(recipients(message))); + assertThat(recipients(message)).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceFixture.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceFixture.java new file mode 100644 index 0000000000..e9120a1448 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceFixture.java @@ -0,0 +1,79 @@ +package org.lowcoder.domain.user.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.lowcoder.domain.asset.service.AssetService; +import org.lowcoder.domain.authentication.AuthenticationService; +import org.lowcoder.domain.encryption.EncryptionServiceImpl; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.group.service.GroupService; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.repository.UserRepository; +import org.lowcoder.infra.mongo.MongoUpsertHelper; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.dynamic.Conf; +import org.lowcoder.sdk.config.dynamic.ConfigCenter; +import org.lowcoder.sdk.config.dynamic.ConfigInstance; + +import reactor.core.publisher.Mono; + +/** + * Builds a {@link UserServiceImpl} from Mockito collaborators and the real {@link EncryptionServiceImpl}, so password + * tests compare real hashes. Shared by the three {@code UserServiceImpl*Test} classes of unit U10 (task L3-2). + */ +final class UserServiceFixture { + + static final String USER_ID = "user-1"; + static final String USER_EMAIL = "user@example.com"; + static final String ORG_ID = "org-1"; + static final int AVATAR_MAX_SIZE_KB = 300; + + final AssetService assetService = mock(AssetService.class); + final MongoUpsertHelper mongoUpsertHelper = mock(MongoUpsertHelper.class); + final UserRepository repository = mock(UserRepository.class); + final GroupMemberService groupMemberService = mock(GroupMemberService.class); + final OrgMemberService orgMemberService = mock(OrgMemberService.class); + final OrganizationService organizationService = mock(OrganizationService.class); + final GroupService groupService = mock(GroupService.class); + final AuthenticationService authenticationService = mock(AuthenticationService.class); + final EmailCommunicationService emailCommunicationService = mock(EmailCommunicationService.class); + final CommonConfig commonConfig = new CommonConfig(); + final EncryptionServiceImpl encryptionService; + final UserServiceImpl service; + + @SuppressWarnings("unchecked") + UserServiceFixture() { + CommonConfig.JsExecutor jsExecutor = mock(CommonConfig.JsExecutor.class); + when(jsExecutor.getSalt()).thenReturn(""); + when(jsExecutor.getPassword()).thenReturn(""); + commonConfig.setJsExecutor(jsExecutor); + encryptionService = new EncryptionServiceImpl(commonConfig); + + ConfigCenter configCenter = mock(ConfigCenter.class); + ConfigInstance assetConfig = mock(ConfigInstance.class); + Conf avatarMaxSize = mock(Conf.class); + when(configCenter.asset()).thenReturn(assetConfig); + when(assetConfig.ofInteger("avatarMaxSizeInKb", AVATAR_MAX_SIZE_KB)).thenReturn(avatarMaxSize); + when(avatarMaxSize.get()).thenReturn(AVATAR_MAX_SIZE_KB); + + when(repository.save(any(User.class))).thenAnswer(invocation -> Mono.just(invocation.getArgument(0))); + + service = new UserServiceImpl(assetService, configCenter, encryptionService, mongoUpsertHelper, repository, + groupMemberService, orgMemberService, organizationService, groupService, commonConfig, + authenticationService, emailCommunicationService); + service.init(); + } + + /** A user whose stored password is the real hash of {@code plainPassword}. */ + User userWithPassword(String plainPassword) { + return User.builder() + .id(USER_ID) + .email(USER_EMAIL) + .password(plainPassword == null ? null : encryptionService.encryptPassword(plainPassword)) + .build(); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceImplAccountTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceImplAccountTest.java new file mode 100644 index 0000000000..9d79cf118f --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceImplAccountTest.java @@ -0,0 +1,414 @@ +package org.lowcoder.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.lowcoder.domain.user.service.UserServiceFixture.AVATAR_MAX_SIZE_KB; +import static org.lowcoder.domain.user.service.UserServiceFixture.USER_ID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.lowcoder.domain.asset.model.Asset; +import org.lowcoder.domain.authentication.context.AuthRequestContext; +import org.lowcoder.domain.authentication.context.FormAuthRequestContext; +import org.lowcoder.domain.user.model.AuthUser; +import org.lowcoder.domain.user.model.Connection; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.model.UserState; +import org.lowcoder.sdk.auth.AbstractAuthConfig; +import org.lowcoder.sdk.constants.AuthSourceConstants; +import org.lowcoder.sdk.constants.WorkspaceMode; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.codec.multipart.Part; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Account and profile flows of {@code UserServiceImpl} (unit U10, task L3-2): profile photo, enterprise-mode + * deletion, the bind-email race, account creation from an auth user, adding a connection, update, and the + * deterministic candidate pick. + */ +class UserServiceImplAccountTest { + + private static final String AUTH_CONFIG_ID = "auth-config-1"; + private static final String GITHUB = "GITHUB"; + private static final String PREVIOUS_AVATAR = "asset-old"; + private static final String NEW_AVATAR = "asset-new"; + + private UserServiceFixture fixture; + private UserServiceImpl service; + + @BeforeEach + void setUp() { + fixture = new UserServiceFixture(); + service = fixture.service; + } + + // ---------------------------------------------------------------- profile photo + + private void stubUpload(Part part) { + when(fixture.assetService.upload(part, AVATAR_MAX_SIZE_KB, true)) + .thenReturn(Mono.just(Asset.builder().id(NEW_AVATAR).build())); + } + + /** + * Catches orphaned or wrongly ordered assets (:206-210): the previous avatar is removed first, then the user row + * is updated with the new asset id, and the upload honours the configured size limit. + */ + @Test + void saveProfilePhoto_withPreviousAvatar_removesTheOldAssetThenUpdatesTheUser() { + Part part = mock(Part.class); + stubUpload(part); + List events = new ArrayList<>(); + when(fixture.assetService.remove(PREVIOUS_AVATAR)).thenReturn(Mono.fromRunnable(() -> events.add("remove"))); + when(fixture.mongoUpsertHelper.updateById(any(User.class), eq(USER_ID))) + .thenReturn(Mono.fromSupplier(() -> { + events.add("update"); + return true; + })); + User user = User.builder().id(USER_ID).avatar(PREVIOUS_AVATAR).build(); + + StepVerifier.create(service.saveProfilePhoto(part, user)).expectNext(true).verifyComplete(); + + assertThat(events).containsExactly("remove", "update"); + ArgumentCaptor update = ArgumentCaptor.forClass(User.class); + verify(fixture.mongoUpsertHelper).updateById(update.capture(), eq(USER_ID)); + assertThat(update.getValue().getAvatar()).isEqualTo(NEW_AVATAR); + System.out.println("[UserServiceImplAccountTest] photo saved: old asset removed first, new asset id written"); + } + + /** Catches a remove call for an empty previous avatar (:206). */ + @ParameterizedTest + @NullAndEmptySource + void saveProfilePhoto_withoutPreviousAvatar_doesNotRemoveAnything(String previous) { + Part part = mock(Part.class); + stubUpload(part); + when(fixture.mongoUpsertHelper.updateById(any(User.class), eq(USER_ID))).thenReturn(Mono.just(true)); + User user = User.builder().id(USER_ID).avatar(previous).build(); + + StepVerifier.create(service.saveProfilePhoto(part, user)).expectNext(true).verifyComplete(); + verify(fixture.assetService, never()).remove(anyString()); + System.out.println("[UserServiceImplAccountTest] no previous avatar (" + previous + ") -> nothing removed"); + } + + /** Catches an upload failure still removing the old avatar or touching the user. */ + @Test + void saveProfilePhoto_uploadFails_leavesAssetAndUserUntouched() { + Part part = mock(Part.class); + when(fixture.assetService.upload(any(Part.class), anyInt(), any(Boolean.class))) + .thenReturn(Mono.error(new IllegalStateException("too large"))); + User user = User.builder().id(USER_ID).avatar(PREVIOUS_AVATAR).build(); + + StepVerifier.create(service.saveProfilePhoto(part, user)).expectError(IllegalStateException.class).verify(); + verify(fixture.assetService, never()).remove(anyString()); + verify(fixture.mongoUpsertHelper, never()).updateById(any(User.class), anyString()); + System.out.println("[UserServiceImplAccountTest] failed upload -> old avatar kept, user not updated"); + } + + /** Catches the avatar not being cleared on the user (:374) or the asset not being removed (:376). */ + @Test + void deleteProfilePhoto_clearsTheAvatarSavesTheUserAndRemovesTheAsset() { + when(fixture.assetService.remove(PREVIOUS_AVATAR)).thenReturn(Mono.empty()); + User user = User.builder().id(USER_ID).avatar(PREVIOUS_AVATAR).build(); + + StepVerifier.create(service.deleteProfilePhoto(user)).verifyComplete(); + + assertThat(user.getAvatar()).isNull(); + verify(fixture.repository).save(user); + verify(fixture.assetService).remove(PREVIOUS_AVATAR); + System.out.println("[UserServiceImplAccountTest] photo deleted: avatar cleared, user saved, asset removed"); + } + + /** + * Pins the plan section 9 candidate "deleteProfilePhoto thenReturn(null)" (UserServiceImpl:375): for a user + * without an avatar, {@code thenReturn(userAvatar)} receives null and Reactor rejects it with a + * NullPointerException at assembly, so the call fails instead of doing nothing. A fix changes this test on purpose. + */ + @Test + void deleteProfilePhoto_userWithoutAvatar_throwsNullPointerException() { + User user = User.builder().id(USER_ID).build(); + + assertThatThrownBy(() -> service.deleteProfilePhoto(user)).isInstanceOf(NullPointerException.class); + verify(fixture.assetService, never()).remove(any()); + System.out.println("[UserServiceImplAccountTest] pins the section 9 candidate: no avatar -> NPE"); + } + + // ---------------------------------------------------------------- enterprise-mode deletion + + /** Catches SaaS users being deleted through the enterprise path (:549). */ + @Test + void markUserDeletedAtEnterpriseMode_saasMode_doesNothing() { + fixture.commonConfig.getWorkspace().setMode(WorkspaceMode.SAAS); + + StepVerifier.create(service.markUserDeletedAndInvalidConnectionsAtEnterpriseMode(USER_ID)) + .expectNext(false) + .verifyComplete(); + verify(fixture.repository, never()).findById(anyString()); + verify(fixture.mongoUpsertHelper, never()).updateById(any(User.class), anyString()); + System.out.println("[UserServiceImplAccountTest] SaaS mode -> false, user untouched"); + } + + /** Catches an enterprise delete that keeps the connections blocked or the user enabled (:554). */ + @Test + void markUserDeletedAtEnterpriseMode_marksDeletedAndSuffixesTheConnectionSources() { + fixture.commonConfig.getWorkspace().setMode(WorkspaceMode.ENTERPRISE); + Connection connection = Connection.builder().authId("a").source(GITHUB).rawId("raw").build(); + User user = User.builder().id(USER_ID).connections(Set.of(connection)).build(); + when(fixture.repository.findById(USER_ID)).thenReturn(Mono.just(user)); + when(fixture.mongoUpsertHelper.updateById(user, USER_ID)).thenReturn(Mono.just(true)); + + StepVerifier.create(service.markUserDeletedAndInvalidConnectionsAtEnterpriseMode(USER_ID)) + .expectNext(true) + .verifyComplete(); + + assertThat(user.getState()).isEqualTo(UserState.DELETED); + assertThat(user.getIsEnabled()).isFalse(); + assertThat(connection.getSource()).startsWith(GITHUB + "(User deleted at "); + verify(fixture.mongoUpsertHelper).updateById(user, USER_ID); + System.out.println("[UserServiceImplAccountTest] enterprise delete: DELETED, disabled, source " + connection.getSource()); + } + + // ---------------------------------------------------------------- bindEmail race + + private User userWithoutEmailProbeHits(String typed, String normalized) { + when(fixture.repository.findByConnections_SourceAndConnections_RawId(AuthSourceConstants.EMAIL, typed)).thenReturn(Mono.empty()); + when(fixture.repository.findByConnections_SourceAndConnections_RawId(AuthSourceConstants.EMAIL, normalized)).thenReturn(Mono.empty()); + return User.builder().id(USER_ID).build(); + } + + /** Catches a 500 on a concurrent bind (:342): a duplicate key between probe and save becomes ALREADY_BIND. */ + @Test + void bindEmail_duplicateKeyOnSave_becomesAlreadyBind() { + User user = userWithoutEmailProbeHits("New@Example.COM", "new@example.com"); + when(fixture.repository.save(any(User.class))).thenReturn(Mono.error(new DuplicateKeyException("index collision"))); + + StepVerifier.create(service.bindEmail(user, "New@Example.COM")) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(BizException.class); + BizException biz = (BizException) error; + assertThat(biz.getError()).isEqualTo(BizError.ALREADY_BIND); + assertThat(biz.getArgs()).containsExactly("new@example.com", ""); + }) + .verify(); + System.out.println("[UserServiceImplAccountTest] duplicate key on bind -> ALREADY_BIND with the normalized address"); + } + + /** Catches other save errors being swallowed or rewritten (:345). */ + @Test + void bindEmail_otherSaveError_isPropagatedUnchanged() { + User user = userWithoutEmailProbeHits("new@example.com", "new@example.com"); + IllegalStateException failure = new IllegalStateException("mongo down"); + when(fixture.repository.save(any(User.class))).thenReturn(Mono.error(failure)); + + StepVerifier.create(service.bindEmail(user, "new@example.com")) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(failure)) + .verify(); + System.out.println("[UserServiceImplAccountTest] other save error propagated unchanged"); + } + + // ---------------------------------------------------------------- createNewUserByAuthUser + + private static AbstractAuthConfig authConfig(String source) { + AbstractAuthConfig config = mock(AbstractAuthConfig.class); + when(config.getSource()).thenReturn(source); + when(config.getId()).thenReturn(AUTH_CONFIG_ID); + return config; + } + + private static AuthUser formAuthUser(String source, String email, String password) { + FormAuthRequestContext context = new FormAuthRequestContext("login-id", password, true, null); + context.setAuthConfig(authConfig(source)); + return AuthUser.builder().uid("uid-1").username("jane").email(email).authContext(context).build(); + } + + private static AuthUser ssoAuthUser(String email) { + AuthRequestContext context = new AuthRequestContext() { }; + context.setAuthConfig(authConfig(GITHUB)); + return AuthUser.builder().uid("sso-uid").username("jane-gh").email(email).authContext(context).build(); + } + + /** + * Catches a missing password hash for a form registration, an un-normalized address, and a wrong initial state + * (:243-265). + */ + @Test + void createNewUser_formEmailRegistration_hashesPasswordAndNormalizesAddress() { + StepVerifier.create(service.createNewUserByAuthUser(formAuthUser(AuthSourceConstants.EMAIL, "Jane@Example.COM", "s3cret"), false)) + .assertNext(created -> { + assertThat(created.getEmail()).isEqualTo("jane@example.com"); + assertThat(created.getState()).isEqualTo(UserState.ACTIVATED); + assertThat(created.getIsEnabled()).isTrue(); + assertThat(created.getSuperAdmin()).isFalse(); + assertThat(created.getIsNewUser()).isTrue(); + assertThat(created.getActiveAuthId()).isEqualTo(AUTH_CONFIG_ID); + assertThat(created.getConnections()).hasSize(1); + assertThat(fixture.encryptionService.matchPassword("s3cret", created.getPassword())).isTrue(); + }) + .verifyComplete(); + verify(fixture.repository, never()).findBySuperAdminIsTrue(); + System.out.println("[UserServiceImplAccountTest] form registration: hashed password, normalized e-mail, ACTIVATED"); + } + + /** Catches an SSO or non-email form user getting a password (:254-255), and a null e-mail turning into "" (:248). */ + @Test + void createNewUser_ssoOrNonEmailSource_hasNoPassword_andNullEmailStaysNull() { + StepVerifier.create(service.createNewUserByAuthUser(ssoAuthUser(null), false)) + .assertNext(created -> { + assertThat(created.getPassword()).isNull(); + assertThat(created.getEmail()).isNull(); + }) + .verifyComplete(); + + StepVerifier.create(service.createNewUserByAuthUser(formAuthUser(GITHUB, "jane@example.com", "ignored"), false)) + .assertNext(created -> assertThat(created.getPassword()).as("form context but not the EMAIL source").isNull()) + .verifyComplete(); + System.out.println("[UserServiceImplAccountTest] SSO / non-email source -> no password, null e-mail preserved"); + } + + /** Catches a second super admin row (:266-269): an existing super admin is updated instead of inserting. */ + @Test + void createNewUser_superAdmin_updatesTheExistingSuperAdminInsteadOfInserting() { + User existing = User.builder().id("sa-1").superAdmin(true).build(); + when(fixture.repository.findBySuperAdminIsTrue()).thenReturn(Mono.just(existing)); + when(fixture.mongoUpsertHelper.updateById(any(User.class), eq("sa-1"))).thenReturn(Mono.just(true)); + when(fixture.repository.findById("sa-1")).thenReturn(Mono.just(existing)); + // count subscriptions: the production code assembles create(newUser) eagerly in switchIfEmpty, which only + // calls save(), it does not subscribe, so an insert is a subscribed save + List inserted = new ArrayList<>(); + when(fixture.repository.save(any(User.class))).thenAnswer(invocation -> Mono.fromSupplier(() -> { + inserted.add(invocation.getArgument(0)); + return invocation.getArgument(0); + })); + + StepVerifier.create(service.createNewUserByAuthUser(formAuthUser(AuthSourceConstants.EMAIL, "root@example.com", "pw"), true)) + .expectNext(existing) + .verifyComplete(); + + verify(fixture.mongoUpsertHelper).updateById(any(User.class), eq("sa-1")); + assertThat(inserted).as("no second super admin row inserted").isEmpty(); + System.out.println("[UserServiceImplAccountTest] super admin exists -> updated in place, no insert"); + } + + /** Catches the first super admin not being created when none exists (:269). */ + @Test + void createNewUser_superAdmin_noneExisting_createsIt() { + when(fixture.repository.findBySuperAdminIsTrue()).thenReturn(Mono.empty()); + + StepVerifier.create(service.createNewUserByAuthUser(formAuthUser(AuthSourceConstants.EMAIL, "root@example.com", "pw"), true)) + .assertNext(created -> assertThat(created.getSuperAdmin()).isTrue()) + .verifyComplete(); + verify(fixture.repository).save(any(User.class)); + System.out.println("[UserServiceImplAccountTest] no super admin yet -> created"); + } + + // ---------------------------------------------------------------- addNewConnectionAndReturnUser + + /** Catches an empty e-mail not being filled (:355), the active connection not switching (:356), a missing password. */ + @Test + void addNewConnection_formEmail_appendsConnectionFillsEmailSwitchesActiveAndSetsPassword() { + User user = User.builder().id(USER_ID).activeAuthId("old-auth").build(); + when(fixture.repository.findById(USER_ID)).thenReturn(Mono.just(user)); + + StepVerifier.create(service.addNewConnectionAndReturnUser(USER_ID, + formAuthUser(AuthSourceConstants.EMAIL, "Jane@Example.COM", "s3cret"))) + .assertNext(saved -> { + assertThat(saved.getConnections()).hasSize(1); + assertThat(saved.getEmail()).isEqualTo("jane@example.com"); + assertThat(saved.getActiveAuthId()).isEqualTo(AUTH_CONFIG_ID); + assertThat(fixture.encryptionService.matchPassword("s3cret", saved.getPassword())).isTrue(); + }) + .verifyComplete(); + System.out.println("[UserServiceImplAccountTest] connection added, e-mail filled, active auth switched, password set"); + } + + /** Catches an existing e-mail being overwritten and an SSO connection setting a password. */ + @Test + void addNewConnection_sso_keepsExistingEmailAndSetsNoPassword() { + User user = User.builder().id(USER_ID).email("kept@example.com").activeAuthId("old-auth").build(); + when(fixture.repository.findById(USER_ID)).thenReturn(Mono.just(user)); + + StepVerifier.create(service.addNewConnectionAndReturnUser(USER_ID, ssoAuthUser("other@example.com"))) + .assertNext(saved -> { + assertThat(saved.getEmail()).isEqualTo("kept@example.com"); + assertThat(saved.getPassword()).isNull(); + assertThat(saved.getConnections()).hasSize(1); + }) + .verifyComplete(); + System.out.println("[UserServiceImplAccountTest] SSO connection added, e-mail kept, no password"); + } + + /** Catches a form login of a non-EMAIL source setting a password on an existing account (:358). */ + @Test + void addNewConnection_formContextOfANonEmailSource_setsNoPassword() { + User user = User.builder().id(USER_ID).email("kept@example.com").build(); + when(fixture.repository.findById(USER_ID)).thenReturn(Mono.just(user)); + + StepVerifier.create(service.addNewConnectionAndReturnUser(USER_ID, formAuthUser(GITHUB, "kept@example.com", "ignored"))) + .assertNext(saved -> assertThat(saved.getPassword()).isNull()) + .verifyComplete(); + System.out.println("[UserServiceImplAccountTest] form context but not the EMAIL source -> no password set"); + } + + // ---------------------------------------------------------------- update / findById / pick + + /** Catches an update of a missing user reporting success (:224). */ + @Test + void update_userNotFound_failsWithNoUserFound() { + when(fixture.mongoUpsertHelper.updateById(any(User.class), eq("missing"))).thenReturn(Mono.just(false)); + + StepVerifier.create(service.update("missing", User.builder().name("x").build())) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + assertThat(((BizException) error).getMessageKey()).isEqualTo("NO_USER_FOUND"); + }) + .verify(); + verify(fixture.repository, never()).findById(anyString()); + System.out.println("[UserServiceImplAccountTest] update of a missing user -> NO_USER_FOUND"); + } + + /** Catches a null id reaching the repository (:93). */ + @Test + void findById_nullId_failsWithInvalidParameter() { + StepVerifier.create(service.findById(null)) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(BizError.INVALID_PARAMETER); + }) + .verify(); + verify(fixture.repository, never()).findById(anyString()); + System.out.println("[UserServiceImplAccountTest] findById(null) -> INVALID_PARAMETER, repository untouched"); + } + + /** Catches a password reset aimed at a random one of two matching accounts (:191-197). */ + @Test + void pickDeterministically_ownEmailBeatsConnectionMatch_thenSmallestId() { + String value = "jane@example.com"; + User viaConnectionOnly = User.builder().id("a-first").email("other@example.com").build(); + User ownLarger = User.builder().id("z-last").email(value).build(); + User ownSmaller = User.builder().id("m-middle").email(value).build(); + + assertThat(UserServiceImpl.pickDeterministically(List.of(viaConnectionOnly, ownLarger, ownSmaller), value)) + .as("own e-mail wins over a connection match, then the smallest id").isSameAs(ownSmaller); + assertThat(UserServiceImpl.pickDeterministically(List.of(ownLarger, viaConnectionOnly), value)).isSameAs(ownLarger); + assertThat(UserServiceImpl.pickDeterministically(List.of(), value)).isNull(); + System.out.println("[UserServiceImplAccountTest] pick: own e-mail first, then smallest id, none -> null"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceImplDetailTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceImplDetailTest.java new file mode 100644 index 0000000000..e3cb46ab78 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceImplDetailTest.java @@ -0,0 +1,304 @@ +package org.lowcoder.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.domain.user.service.UserServiceFixture.ORG_ID; +import static org.lowcoder.domain.user.service.UserServiceFixture.USER_ID; +import static org.lowcoder.sdk.constants.GlobalContext.CLIENT_IP; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.group.util.SystemGroups; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.user.model.Connection; +import org.lowcoder.domain.user.model.ConnectionAuthToken; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.model.UserDetail; +import org.lowcoder.sdk.constants.AuthSourceConstants; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.util.context.Context; + +/** + * {@code buildUserDetail} and its helpers (unit U10, task L3-2): which connection's token is exposed, what lands in + * {@code extra}, which groups the user sees, and the anonymous shortcut. + */ +class UserServiceImplDetailTest { + + private static final String ACTIVE_AUTH_ID = "auth-active"; + private static final String GITHUB = "GITHUB"; + private static final String IP = "10.1.2.3"; + + private UserServiceFixture fixture; + private UserServiceImpl service; + + @BeforeEach + void setUp() { + fixture = new UserServiceFixture(); + service = fixture.service; + stubMember(MemberRole.MEMBER); + when(fixture.groupMemberService.getUserGroupIdsInOrg(ORG_ID, USER_ID)).thenReturn(Mono.just(List.of())); + when(fixture.groupMemberService.getNonDynamicUserGroupIdsInOrg(ORG_ID, USER_ID)).thenReturn(Mono.just(List.of())); + when(fixture.groupService.getByIds(any())).thenReturn(Flux.empty()); + } + + private void stubMember(MemberRole role) { + when(fixture.orgMemberService.getCurrentOrgMember(USER_ID)) + .thenReturn(Mono.just(OrgMember.builder().orgId(ORG_ID).userId(USER_ID).role(role).build())); + } + + private Mono detail(User user, boolean withoutDynamicGroups) { + return service.buildUserDetail(user, withoutDynamicGroups).contextWrite(Context.of(CLIENT_IP, IP)); + } + + private static Connection connection(String authId, String source, Map rawUserInfo, + ConnectionAuthToken token) { + return Connection.builder().authId(authId).source(source).rawId("raw-" + authId).name("name-" + authId) + .rawUserInfo(rawUserInfo).authConnectionAuthToken(token).build(); + } + + private static User userWith(String activeAuthId, Connection... connections) { + return User.builder().id(USER_ID).name("Jane").activeAuthId(activeAuthId).connections(Set.of(connections)).build(); + } + + // ---------------------------------------------------------------- anonymous + + /** Catches the anonymous shortcut (:498) being lost: no org or group lookup for an anonymous user. */ + @Test + void buildUserDetail_anonymousUser_returnsAnonymousDetailWithoutLookups() { + User anonymous = User.builder().id(USER_ID).isAnonymous(true).build(); + + StepVerifier.create(service.buildUserDetail(anonymous, false)) + .expectNext(UserDetail.ANONYMOUS_CURRENT_USER) + .verifyComplete(); + verifyNoInteractions(fixture.orgMemberService, fixture.groupService, fixture.groupMemberService); + System.out.println("[UserServiceImplDetailTest] anonymous user -> ANONYMOUS_CURRENT_USER, no lookups"); + } + + // ---------------------------------------------------------------- userAuth + + static Stream userAuthCases() { + ConnectionAuthToken token = ConnectionAuthToken.builder().accessToken("access-token-123").build(); + Map raw = Map.of(); + return Stream.of( + Arguments.of("email login exposes no jwt", + connection(ACTIVE_AUTH_ID, AuthSourceConstants.EMAIL, raw, token), ACTIVE_AUTH_ID, + "", AuthSourceConstants.EMAIL), + Arguments.of("sso login exposes its access token", + connection(ACTIVE_AUTH_ID, GITHUB, raw, token), ACTIVE_AUTH_ID, "access-token-123", GITHUB), + Arguments.of("sso login without a stored token exposes an empty jwt", + connection(ACTIVE_AUTH_ID, GITHUB, raw, null), ACTIVE_AUTH_ID, "", GITHUB), + Arguments.of("no connection matches the active auth id", + connection("other-auth", GITHUB, raw, token), ACTIVE_AUTH_ID, null, null)); + } + + /** + * Catches the JWT of an email login leaking (:530), the null-token guard being dropped (:533), and another + * connection's token being exposed (:510). + */ + @ParameterizedTest(name = "{0}") + @MethodSource("userAuthCases") + void buildUserDetail_userAuthFollowsTheActiveConnection(String label, Connection connection, String activeAuthId, + String expectedJwt, String expectedProvider) { + User user = userWith(activeAuthId, connection); + + StepVerifier.create(detail(user, false)) + .assertNext(detail -> { + if (expectedProvider == null) { + assertThat(detail.getUserAuth()).isEmpty(); + } else { + assertThat(detail.getUserAuth()).containsEntry("jwt", expectedJwt) + .containsEntry("provider", expectedProvider) + .hasSize(2); + } + }) + .verifyComplete(); + System.out.println("[UserServiceImplDetailTest] userAuth: " + label); + } + + /** Catches the token of an inactive connection being exposed instead of the active one's. */ + @Test + void buildUserDetail_userAuth_ignoresTheInactiveConnections() { + ConnectionAuthToken inactiveToken = ConnectionAuthToken.builder().accessToken("inactive-token").build(); + ConnectionAuthToken activeToken = ConnectionAuthToken.builder().accessToken("active-token").build(); + User user = userWith(ACTIVE_AUTH_ID, + connection("auth-inactive", "GOOGLE", Map.of(), inactiveToken), + connection(ACTIVE_AUTH_ID, GITHUB, Map.of(), activeToken)); + + StepVerifier.create(detail(user, false)) + .assertNext(detail -> assertThat(detail.getUserAuth()) + .containsEntry("jwt", "active-token").containsEntry("provider", GITHUB)) + .verifyComplete(); + System.out.println("[UserServiceImplDetailTest] only the active connection's token is exposed"); + } + + // ---------------------------------------------------------------- fields and extra + + /** Catches the field mapping of buildUserDetail: name fallback (:514), ip from the context, copied fields. */ + @Test + void buildUserDetail_copiesFieldsAndFallsBackToIdWhenNameIsEmpty() { + User user = User.builder().id(USER_ID).name("").email("jane@example.com").uiLanguage("de") + .activeAuthId(ACTIVE_AUTH_ID).build(); + + StepVerifier.create(detail(user, false)) + .assertNext(detail -> { + assertThat(detail.getId()).isEqualTo(USER_ID); + assertThat(detail.getName()).as("empty name falls back to the id").isEqualTo(USER_ID); + assertThat(detail.getEmail()).isEqualTo("jane@example.com"); + assertThat(detail.getUiLanguage()).isEqualTo("de"); + assertThat(detail.getIp()).isEqualTo(IP); + }) + .verifyComplete(); + + User named = User.builder().id(USER_ID).name("Jane").activeAuthId(ACTIVE_AUTH_ID).build(); + StepVerifier.create(detail(named, false)) + .assertNext(detail -> assertThat(detail.getName()).isEqualTo("Jane")) + .verifyComplete(); + System.out.println("[UserServiceImplDetailTest] fields copied, name falls back to the id, ip from the context"); + } + + /** Catches extra being taken from the wrong place: the org's transformed info wins when present (:560-562). */ + @Test + void buildUserDetail_extraComesFromTheOrgTransformedInfoWhenPresent() { + User user = userWith(ACTIVE_AUTH_ID, connection(ACTIVE_AUTH_ID, GITHUB, Map.of("login", "jane"), null)); + User.OrgTransformedUserInfo info = new User.OrgTransformedUserInfo(); + info.set(ORG_ID, new User.TransformedUserInfo(1L, Map.of("department", "R&D"))); + user.setOrgTransformedUserInfo(info); + + StepVerifier.create(detail(user, false)) + .assertNext(detail -> assertThat(detail.getExtra()).isEqualTo(Map.of("department", "R&D"))) + .verifyComplete(); + System.out.println("[UserServiceImplDetailTest] extra taken from the org transformed info"); + } + + /** Catches extra not falling back to the active connection when the info is for another org (:561). */ + @Test + void buildUserDetail_extraFallsBackToTheActiveSsoConnectionRawInfo() { + Map rawInfo = Map.of("login", "jane"); + User user = userWith(ACTIVE_AUTH_ID, + connection(ACTIVE_AUTH_ID, GITHUB, rawInfo, null), + connection("auth-inactive", "GOOGLE", Map.of("sub", "x"), null)); + User.OrgTransformedUserInfo info = new User.OrgTransformedUserInfo(); + info.set("another-org", new User.TransformedUserInfo(1L, Map.of("department", "other"))); + user.setOrgTransformedUserInfo(info); + + StepVerifier.create(detail(user, false)) + .assertNext(detail -> assertThat(detail.getExtra()).isEqualTo(Map.of(GITHUB, rawInfo))) + .verifyComplete(); + System.out.println("[UserServiceImplDetailTest] extra from the active SSO connection only"); + } + + /** Catches e-mail and phone connections leaking into extra (:586-587). */ + @ParameterizedTest + @MethodSource("emailAndPhoneSources") + void buildUserDetail_extraExcludesEmailAndPhoneConnections(String source) { + User user = userWith(ACTIVE_AUTH_ID, connection(ACTIVE_AUTH_ID, source, Map.of("k", "v"), null)); + + StepVerifier.create(detail(user, false)) + .assertNext(detail -> assertThat(detail.getExtra()).isEmpty()) + .verifyComplete(); + System.out.println("[UserServiceImplDetailTest] extra excludes the " + source + " connection"); + } + + static Stream emailAndPhoneSources() { + return Stream.of(AuthSourceConstants.EMAIL, AuthSourceConstants.PHONE); + } + + // ---------------------------------------------------------------- groups + + private static Group group(String id, String name, long createdAtMillis) { + Group group = Group.builder().id(id).name(name).organizationId(ORG_ID).build(); + group.setCreatedAt(Instant.ofEpochMilli(createdAtMillis)); + return group; + } + + static Stream groupCases() { + return Stream.of( + Arguments.of(MemberRole.ADMIN, false, List.of("Alpha", "Beta")), + Arguments.of(MemberRole.ADMIN, true, List.of("Alpha", "Beta")), + Arguments.of(MemberRole.SUPER_ADMIN, false, List.of("Alpha", "Beta")), + Arguments.of(MemberRole.MEMBER, false, List.of("Own all")), + Arguments.of(MemberRole.MEMBER, true, List.of("Own static"))); + } + + /** + * Catches an admin not seeing every org group (:570), dynamic groups being shown when excluded (:573), and the + * all-users group not being filtered out (:579). The admin's groups arrive unsorted and must come back sorted. + */ + @ParameterizedTest(name = "{0} withoutDynamicGroups={1}") + @MethodSource("groupCases") + void buildUserDetail_groupsDependOnRoleAndDynamicGroupFlag(MemberRole role, boolean withoutDynamicGroups, + List expectedNames) { + stubMember(role); + Group allUsers = group("g-all", "All users", 1); + allUsers.setType(SystemGroups.ALL_USER); + when(fixture.groupService.getByOrgId(ORG_ID)).thenReturn(Flux.just( + group("g-beta", "Beta", 200), allUsers, group("g-alpha", "Alpha", 100))); + when(fixture.groupMemberService.getUserGroupIdsInOrg(ORG_ID, USER_ID)).thenReturn(Mono.just(List.of("g-own-all"))); + when(fixture.groupMemberService.getNonDynamicUserGroupIdsInOrg(ORG_ID, USER_ID)).thenReturn(Mono.just(List.of("g-own-static"))); + when(fixture.groupService.getByIds(List.of("g-own-all"))).thenReturn(Flux.just(group("g-own-all", "Own all", 1), allUsers)); + when(fixture.groupService.getByIds(List.of("g-own-static"))).thenReturn(Flux.just(group("g-own-static", "Own static", 1))); + + StepVerifier.create(detail(User.builder().id(USER_ID).activeAuthId(ACTIVE_AUTH_ID).build(), withoutDynamicGroups)) + .assertNext(detail -> assertThat(detail.getGroups()) + .extracting(g -> g.get("groupName")).containsExactlyElementsOf(expectedNames)) + .verifyComplete(); + if (role == MemberRole.MEMBER) { + verify(fixture.groupService, never()).getByOrgId(any()); + } + System.out.println("[UserServiceImplDetailTest] groups for " + role + " withoutDynamicGroups=" + withoutDynamicGroups + + " -> " + expectedNames); + } + + /** Catches a null group id breaking the map (Map.of rejects null) and system group names not being localised. */ + @Test + void buildUserDetail_groupWithoutIdGetsEmptyIdAndSystemGroupNameIsLocalised() { + Group devGroup = group(null, "ignored-raw-name", 1); + devGroup.setType(SystemGroups.DEV); + when(fixture.groupMemberService.getUserGroupIdsInOrg(ORG_ID, USER_ID)).thenReturn(Mono.just(List.of("g-dev"))); + when(fixture.groupService.getByIds(List.of("g-dev"))).thenReturn(Flux.just(devGroup)); + + StepVerifier.create(detail(User.builder().id(USER_ID).activeAuthId(ACTIVE_AUTH_ID).build(), false)) + .assertNext(detail -> { + assertThat(detail.getGroups()).hasSize(1); + Map only = detail.getGroups().get(0); + assertThat(only.get("groupId")).isEmpty(); + assertThat(only.get("groupName")).isEqualTo(devGroup.getName(Locale.ENGLISH)) + .isNotEqualTo("ignored-raw-name"); + }) + .verifyComplete(); + System.out.println("[UserServiceImplDetailTest] null group id -> \"\", dev group name localised"); + } + + // ---------------------------------------------------------------- convertEmail + + /** Catches convertEmail returning another source's name or failing on no e-mail connection (:592-595). */ + @Test + void convertEmail_returnsTheFirstEmailConnectionNameOrEmpty() { + Set connections = Set.of( + connection("a", GITHUB, new HashMap<>(), null), + connection("b", AuthSourceConstants.EMAIL, new HashMap<>(), null)); + assertThat(service.convertEmail(connections)).isEqualTo("name-b"); + assertThat(service.convertEmail(Set.of(connection("a", GITHUB, new HashMap<>(), null)))).isEmpty(); + System.out.println("[UserServiceImplDetailTest] convertEmail -> e-mail connection name, else empty"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceImplPasswordTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceImplPasswordTest.java new file mode 100644 index 0000000000..8df6a76d92 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/user/service/UserServiceImplPasswordTest.java @@ -0,0 +1,390 @@ +package org.lowcoder.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.domain.organization.service.OrganizationService.PASSWORD_RESET_EMAIL_TEMPLATE_DEFAULT; +import static org.lowcoder.domain.user.service.UserServiceFixture.ORG_ID; +import static org.lowcoder.domain.user.service.UserServiceFixture.USER_EMAIL; +import static org.lowcoder.domain.user.service.UserServiceFixture.USER_ID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.model.Organization.OrganizationCommonSettings; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.model.UserState; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.util.HashUtils; +import org.mockito.ArgumentCaptor; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Password flows of {@code UserServiceImpl} (unit U10, task L3-2): change, reset, forgot-password and reset-by-token, + * against a mocked repository and the real password hashing. Complements the server E2E tests, which only cover the + * successful token reset, the never-requested account, and the soft-deleted account through MongoDB. + */ +class UserServiceImplPasswordTest { + + private static final String OLD_PASSWORD = "old-password"; + private static final String NEW_PASSWORD = "new-password"; + private static final String TOKEN = "reset-token"; + /** Wide margin so nothing depends on timing. */ + private static final Duration ONE_HOUR = Duration.ofHours(1); + + private UserServiceFixture fixture; + private UserServiceImpl service; + + @BeforeEach + void setUp() { + fixture = new UserServiceFixture(); + service = fixture.service; + } + + private void stubFindById(User user) { + when(fixture.repository.findById(USER_ID)).thenReturn(Mono.just(user)); + } + + private void stubEmailLookup(User... users) { + when(fixture.repository.findByEmailOrConnections_Email(USER_EMAIL, USER_EMAIL)).thenReturn(Flux.just(users)); + } + + private static void assertBizError(Throwable error, BizError expected, String messageKey) { + assertThat(error).isInstanceOf(BizException.class); + BizException biz = (BizException) error; + assertThat(biz.getError()).isEqualTo(expected); + assertThat(biz.getMessageKey()).isEqualTo(messageKey); + } + + // ---------------------------------------------------------------- updatePassword + + /** Catches a password changed although the old one does not match (:389). */ + @Test + void updatePassword_wrongOldPassword_failsAndNeverSaves() { + User user = fixture.userWithPassword(OLD_PASSWORD); + String storedHash = user.getPassword(); + stubFindById(user); + + StepVerifier.create(service.updatePassword(USER_ID, "not-the-old-one", NEW_PASSWORD)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PASSWORD, "INVALID_PASSWORD")) + .verify(); + verify(fixture.repository, never()).save(any(User.class)); + assertThat(user.getPassword()).isEqualTo(storedHash); + System.out.println("[UserServiceImplPasswordTest] wrong old password -> INVALID_PASSWORD, nothing saved"); + } + + /** Catches an account without a password (SSO-only) getting one set through the change-password flow (:384). */ + @ParameterizedTest + @NullAndEmptySource + void updatePassword_blankStoredPassword_failsWithInvalidPassword(String storedPassword) { + User user = User.builder().id(USER_ID).password(storedPassword).build(); + stubFindById(user); + + StepVerifier.create(service.updatePassword(USER_ID, "anything", NEW_PASSWORD)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PASSWORD, "INVALID_PASSWORD")) + .verify(); + verify(fixture.repository, never()).save(any(User.class)); + System.out.println("[UserServiceImplPasswordTest] blank stored password (" + storedPassword + ") rejected"); + } + + /** Catches the new password stored in plain text or the old one kept (:393). */ + @Test + void updatePassword_correctOldPassword_savesHashOfNewPassword() { + User user = fixture.userWithPassword(OLD_PASSWORD); + stubFindById(user); + + StepVerifier.create(service.updatePassword(USER_ID, OLD_PASSWORD, NEW_PASSWORD)) + .expectNext(true) + .verifyComplete(); + + ArgumentCaptor saved = ArgumentCaptor.forClass(User.class); + verify(fixture.repository).save(saved.capture()); + String hash = saved.getValue().getPassword(); + assertThat(hash).isNotEqualTo(NEW_PASSWORD); + assertThat(fixture.encryptionService.matchPassword(NEW_PASSWORD, hash)).isTrue(); + assertThat(fixture.encryptionService.matchPassword(OLD_PASSWORD, hash)).isFalse(); + System.out.println("[UserServiceImplPasswordTest] updatePassword stored a hash that matches only the new password"); + } + + // ---------------------------------------------------------------- resetPassword / setPassword / markAsSuperAdmin + + /** Catches a plaintext password persisted, or a different value returned than the one hashed (:409-412). */ + @Test + void resetPassword_returnsPlaintextOnce_andStoresItsHash() { + User user = fixture.userWithPassword(OLD_PASSWORD); + stubFindById(user); + + StepVerifier.create(service.resetPassword(USER_ID)) + .assertNext(plain -> { + assertThat(plain).hasSize(12); + assertThat(user.getPassword()).isNotEqualTo(plain); + assertThat(fixture.encryptionService.matchPassword(plain, user.getPassword())).isTrue(); + assertThat(fixture.encryptionService.matchPassword(OLD_PASSWORD, user.getPassword())).isFalse(); + }) + .verifyComplete(); + verify(fixture.repository).save(user); + System.out.println("[UserServiceImplPasswordTest] resetPassword returned a 12 char password whose hash was saved"); + } + + /** Catches a reset on an account that never had a password (:405). */ + @ParameterizedTest + @NullAndEmptySource + void resetPassword_blankStoredPassword_failsPasswordNotSetYet(String storedPassword) { + stubFindById(User.builder().id(USER_ID).password(storedPassword).build()); + + StepVerifier.create(service.resetPassword(USER_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PASSWORD, "PASSWORD_NOT_SET_YET")) + .verify(); + verify(fixture.repository, never()).save(any(User.class)); + System.out.println("[UserServiceImplPasswordTest] resetPassword on blank password -> PASSWORD_NOT_SET_YET"); + } + + /** Catches a plaintext password stored by setPassword (:478). */ + @Test + void setPassword_encryptsAndSaves() { + User user = User.builder().id(USER_ID).build(); + stubFindById(user); + StepVerifier.create(service.setPassword(USER_ID, NEW_PASSWORD)).expectNext(true).verifyComplete(); + assertThat(user.getPassword()).isNotEqualTo(NEW_PASSWORD); + assertThat(fixture.encryptionService.matchPassword(NEW_PASSWORD, user.getPassword())).isTrue(); + verify(fixture.repository).save(user); + System.out.println("[UserServiceImplPasswordTest] setPassword stored a hash that matches the password"); + } + + /** Catches the super admin flag not being set (:489). */ + @Test + void markAsSuperAdmin_setsFlagAndSaves() { + User user = User.builder().id(USER_ID).build(); + stubFindById(user); + StepVerifier.create(service.markAsSuperAdmin(USER_ID)).expectNext(true).verifyComplete(); + assertThat(user.getSuperAdmin()).isTrue(); + verify(fixture.repository).save(user); + System.out.println("[UserServiceImplPasswordTest] markAsSuperAdmin set the flag and saved"); + } + + // ---------------------------------------------------------------- resetLostPassword + + private static User userWithResetToken(String token, Instant expiry) { + return User.builder() + .id(USER_ID) + .email(USER_EMAIL) + .passwordResetToken(token == null ? null : HashUtils.hash(token.getBytes())) + .passwordResetTokenExpiry(expiry) + .build(); + } + + /** Catches an account takeover with a guessed token (:453): wrong token is rejected and nothing is saved. */ + @Test + void resetLostPassword_wrongToken_isRejectedAndPasswordUnchanged() { + User user = userWithResetToken(TOKEN, Instant.now().plus(ONE_HOUR)); + user.setPassword("untouched-hash"); + stubEmailLookup(user); + + StepVerifier.create(service.resetLostPassword(USER_EMAIL, "guessed-token", NEW_PASSWORD)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PARAMETER, "INVALID_TOKEN")) + .verify(); + verify(fixture.repository, never()).save(any(User.class)); + assertThat(user.getPassword()).isEqualTo("untouched-hash"); + System.out.println("[UserServiceImplPasswordTest] wrong reset token -> INVALID_TOKEN, password untouched"); + } + + static Stream expiries() { + return Stream.of( + Arguments.of("expired an hour ago", Instant.now().minus(ONE_HOUR)), + Arguments.of("expires right now", Instant.now()), + Arguments.of("no expiry stored", null)); + } + + /** Catches an expiry bypass or an NPE on an account without an expiry (:448-449), even with the right token. */ + @ParameterizedTest(name = "{0}") + @MethodSource("expiries") + void resetLostPassword_expiredOrMissingExpiry_isRejected(String label, Instant expiry) { + User user = userWithResetToken(TOKEN, expiry); + stubEmailLookup(user); + + StepVerifier.create(service.resetLostPassword(USER_EMAIL, TOKEN, NEW_PASSWORD)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PARAMETER, "TOKEN_EXPIRED")) + .verify(); + verify(fixture.repository, never()).save(any(User.class)); + System.out.println("[UserServiceImplPasswordTest] reset token " + label + " -> TOKEN_EXPIRED"); + } + + /** Catches token reuse after a successful reset (:457-459): the token is cleared and a second use fails. */ + @Test + void resetLostPassword_success_setsHashAndInvalidatesTheToken() { + User user = userWithResetToken(TOKEN, Instant.now().plus(ONE_HOUR)); + stubEmailLookup(user); + + StepVerifier.create(service.resetLostPassword(USER_EMAIL, TOKEN, NEW_PASSWORD)) + .expectNext(true) + .verifyComplete(); + + assertThat(fixture.encryptionService.matchPassword(NEW_PASSWORD, user.getPassword())).isTrue(); + assertThat(user.getPasswordResetToken()).isEmpty(); + assertThat(user.getPasswordResetTokenExpiry()).isBeforeOrEqualTo(Instant.now()); + verify(fixture.repository).save(user); + + StepVerifier.create(service.resetLostPassword(USER_EMAIL, TOKEN, "another-password")) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PARAMETER, "TOKEN_EXPIRED")) + .verify(); + assertThat(fixture.encryptionService.matchPassword(NEW_PASSWORD, user.getPassword())).isTrue(); + System.out.println("[UserServiceImplPasswordTest] reset succeeded once; the same token fails the second time"); + } + + /** Catches resetting a soft-deleted account (:443). */ + @Test + void resetLostPassword_deletedAccount_isNotRecoverable() { + User user = userWithResetToken(TOKEN, Instant.now().plus(ONE_HOUR)); + user.setState(UserState.DELETED); + stubEmailLookup(user); + when(fixture.repository.findByName(USER_EMAIL)).thenReturn(Mono.empty()); + + StepVerifier.create(service.resetLostPassword(USER_EMAIL, TOKEN, NEW_PASSWORD)).verifyComplete(); + verify(fixture.repository, never()).save(any(User.class)); + System.out.println("[UserServiceImplPasswordTest] deleted account -> empty result, nothing saved"); + } + + /** Catches the lookup by name being lost (:442): an e-mail lookup without hit falls back to the user name. */ + @Test + void resetLostPassword_fallsBackToLookupByName() { + User user = userWithResetToken(TOKEN, Instant.now().plus(ONE_HOUR)); + when(fixture.repository.findByEmailOrConnections_Email("some-handle", "some-handle")).thenReturn(Flux.empty()); + when(fixture.repository.findByName("some-handle")).thenReturn(Mono.just(user)); + + StepVerifier.create(service.resetLostPassword("some-handle", TOKEN, NEW_PASSWORD)) + .expectNext(true) + .verifyComplete(); + verify(fixture.repository).findByName("some-handle"); + System.out.println("[UserServiceImplPasswordTest] reset resolved the account by name after an empty e-mail lookup"); + } + + // ---------------------------------------------------------------- lostPassword + + private void stubOrgOfUser() { + when(fixture.orgMemberService.getCurrentOrgMember(USER_ID)) + .thenReturn(Mono.just(OrgMember.builder().orgId(ORG_ID).userId(USER_ID).build())); + when(fixture.organizationService.getById(ORG_ID)).thenReturn(Mono.just(Organization.builder().build())); + } + + /** + * Catches a plaintext reset token stored, or a wrong lifetime (:429-435): the token handed to the mail hashes to + * the stored value, and the expiry is twelve hours ahead. + */ + @Test + void lostPassword_mailSent_storesTokenHashAndTwelveHourExpiry() { + User user = User.builder().id(USER_ID).email(USER_EMAIL).build(); + stubEmailLookup(user); + stubOrgOfUser(); + when(fixture.emailCommunicationService.sendPasswordResetEmail(eq(USER_EMAIL), any(), any())).thenReturn(true); + Instant before = Instant.now(); + + StepVerifier.create(service.lostPassword(USER_EMAIL)).verifyComplete(); + + ArgumentCaptor token = ArgumentCaptor.forClass(String.class); + verify(fixture.emailCommunicationService).sendPasswordResetEmail(eq(USER_EMAIL), token.capture(), + eq(PASSWORD_RESET_EMAIL_TEMPLATE_DEFAULT)); + assertThat(token.getValue()).hasSize(12); + assertThat(user.getPasswordResetToken()).isEqualTo(HashUtils.hash(token.getValue().getBytes())); + assertThat(user.getPasswordResetToken()).isNotEqualTo(token.getValue()); + assertThat(user.getPasswordResetTokenExpiry()) + .isBetween(before.plus(Duration.ofHours(12)).minusSeconds(60), Instant.now().plus(Duration.ofHours(12)).plusSeconds(60)); + verify(fixture.repository).save(user); + System.out.println("[UserServiceImplPasswordTest] lostPassword stored the token hash and a 12 hour expiry"); + } + + /** Catches a valid token stored for a mail that was never sent (:430). */ + @Test + void lostPassword_mailSendFails_storesNothing() { + User user = User.builder().id(USER_ID).email(USER_EMAIL).build(); + stubEmailLookup(user); + stubOrgOfUser(); + when(fixture.emailCommunicationService.sendPasswordResetEmail(any(), any(), any())).thenReturn(false); + + StepVerifier.create(service.lostPassword(USER_EMAIL)).verifyComplete(); + verify(fixture.repository, never()).save(any(User.class)); + assertThat(user.getPasswordResetToken()).isNull(); + assertThat(user.getPasswordResetTokenExpiry()).isNull(); + System.out.println("[UserServiceImplPasswordTest] mail not sent -> no token stored"); + } + + /** Catches a mail sent for an unknown or soft-deleted account (:420): both end empty without a mail or a save. */ + @Test + void lostPassword_unknownAndDeletedAccount_sendNothing() { + when(fixture.repository.findByEmailOrConnections_Email("nobody@example.com", "nobody@example.com")).thenReturn(Flux.empty()); + when(fixture.repository.findByName("nobody@example.com")).thenReturn(Mono.empty()); + StepVerifier.create(service.lostPassword("nobody@example.com")).verifyComplete(); + + User deleted = User.builder().id(USER_ID).email(USER_EMAIL).state(UserState.DELETED).build(); + stubEmailLookup(deleted); + when(fixture.repository.findByName(USER_EMAIL)).thenReturn(Mono.empty()); + stubOrgOfUser(); + StepVerifier.create(service.lostPassword(USER_EMAIL)).verifyComplete(); + + verify(fixture.emailCommunicationService, never()).sendPasswordResetEmail(any(), any(), any()); + verify(fixture.repository, never()).save(any(User.class)); + System.out.println("[UserServiceImplPasswordTest] unknown and deleted accounts -> no mail, no save"); + } + + /** + * Pins the plan section 9 row "UserServiceImpl.lostPassword reads the org's reset-mail template with the wrong + * key" (UserServiceImpl:423): the lookup key is the default template's own text, not + * {@code PASSWORD_RESET_EMAIL_TEMPLATE}, so an org with a custom template still gets the default one. A fix changes + * this test on purpose. + */ + @Test + void lostPassword_orgWithCustomResetTemplate_stillSendsTheDefaultTemplate() { + User user = User.builder().id(USER_ID).email(USER_EMAIL).build(); + stubEmailLookup(user); + when(fixture.orgMemberService.getCurrentOrgMember(USER_ID)) + .thenReturn(Mono.just(OrgMember.builder().orgId(ORG_ID).userId(USER_ID).build())); + OrganizationCommonSettings settings = new OrganizationCommonSettings(); + settings.put(OrganizationCommonSettings.PASSWORD_RESET_EMAIL_TEMPLATE, "

    custom %s %s

    "); + when(fixture.organizationService.getById(ORG_ID)) + .thenReturn(Mono.just(Organization.builder().commonSettings(settings).build())); + when(fixture.emailCommunicationService.sendPasswordResetEmail(any(), any(), any())).thenReturn(true); + + StepVerifier.create(service.lostPassword(USER_EMAIL)).verifyComplete(); + + verify(fixture.emailCommunicationService).sendPasswordResetEmail(eq(USER_EMAIL), any(), + eq(PASSWORD_RESET_EMAIL_TEMPLATE_DEFAULT)); + verify(fixture.emailCommunicationService, never()).sendPasswordResetEmail(any(), any(), eq("

    custom %s %s

    ")); + System.out.println("[UserServiceImplPasswordTest] pins the section 9 row: custom reset template ignored, default sent"); + } + + /** + * Pins the plan section 9 row "updatePassword, setPassword and markAsSuperAdmin emit true though nothing was + * saved" (UserServiceImpl:396-397, :481-482, :492-493): for an unknown user id each ends in + * {@code flatMap(save).thenReturn(true)}, which emits true after an empty upstream, and save is never subscribed. + * A fix (switchIfEmpty or hasElement) changes this test on purpose. + */ + @ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"updatePassword", "setPassword", "markAsSuperAdmin"}) + void unknownUser_updatePasswordSetPasswordMarkAsSuperAdmin_emitTrueAndSaveNothing(String method) { + when(fixture.repository.findById("missing")).thenReturn(Mono.empty()); + Mono result = switch (method) { + case "updatePassword" -> service.updatePassword("missing", OLD_PASSWORD, NEW_PASSWORD); + case "setPassword" -> service.setPassword("missing", NEW_PASSWORD); + default -> service.markAsSuperAdmin("missing"); + }; + + StepVerifier.create(result).expectNext(true).verifyComplete(); + verify(fixture.repository, never()).save(any(User.class)); + System.out.println("[UserServiceImplPasswordTest] pins the section 9 row: " + method + " on an unknown id emits true, nothing saved"); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/impl/ClientBasedConnectionPoolTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/impl/ClientBasedConnectionPoolTest.java index cbf7046179..b3f637a991 100644 --- a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/impl/ClientBasedConnectionPoolTest.java +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/impl/ClientBasedConnectionPoolTest.java @@ -1,25 +1,27 @@ package org.lowcoder.impl; -import java.lang.reflect.Field; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; -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.function.BooleanSupplier; -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; -import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; import org.lowcoder.domain.datasource.model.ClientBasedDatasourceConnectionHolder; import org.lowcoder.domain.datasource.model.Datasource; -import org.lowcoder.domain.datasource.model.DatasourceConnectionHolder; import org.lowcoder.domain.datasource.service.impl.ClientBasedConnectionPool; -import org.lowcoder.domain.datasource.service.impl.ClientBasedConnectionPool.ClientBasedDatasourceCacheKey; -import org.lowcoder.domain.plugin.service.impl.DatasourceMetaInfoServiceImpl; import org.lowcoder.impl.mock.MockConnection; import org.lowcoder.impl.mock.MockDatasourceConnectionConfig; import org.lowcoder.impl.mock.MockDatasourceConnector; @@ -27,129 +29,157 @@ import org.lowcoder.infra.perf.PerfEvent; import org.lowcoder.infra.perf.PerfHelper; import org.lowcoder.sdk.exception.InvalidHikariDatasourceException; -import org.lowcoder.sdk.plugin.common.DatasourceConnector; - -import com.google.common.cache.LoadingCache; import io.micrometer.core.instrument.Tag; -import reactor.core.publisher.Mono; - -/** TODO: refactor as component test **/ -@SuppressWarnings({"unchecked", "rawtypes"}) -@Disabled -public class ClientBasedConnectionPoolTest { - - /** - private static final ClientBasedConnectionPool POOL = new ClientBasedConnectionPool(); - - private static LoadingCache> POOL_CACHE; - - private static MockDatasourceMetaInfoService mockDatasourceMetaInfoService; - - @BeforeClass - public static void init() throws NoSuchFieldException, IllegalAccessException { - //init autowired - Field datasourceMetaInfoService = POOL.getClass().getDeclaredField("datasourceMetaInfoService"); - datasourceMetaInfoService.setAccessible(true); - mockDatasourceMetaInfoService = new MockDatasourceMetaInfoService(); - datasourceMetaInfoService.set(POOL, mockDatasourceMetaInfoService); - Field perfHelper = POOL.getClass().getDeclaredField("perfHelper"); - perfHelper.setAccessible(true); - perfHelper.set(POOL, new PerfHelper() { - @Override - public void count(PerfEvent event, Iterable tags) { - } - @Override - public void count(PerfEvent event, Iterable tags, int count) { - } - }); - //retrieve cache field - Field cacheField = POOL.getClass().getDeclaredField("cache"); - cacheField.setAccessible(true); - POOL_CACHE = (LoadingCache>) cacheField.get(POOL); +/** + * {@link ClientBasedConnectionPool} with a connector that hands out {@link MockConnection}s and counts how often it + * creates one per datasource version ({@link MockDatasourceConnector#getCreateTimes}). Each test uses datasource ids of its + * own, since the pool and the connector's counts are shared by the class. + * + *

    Limits: metrics are stubbed out (the pool's gauges and counters are not checked); the pool closes a replaced + * connection asynchronously on the query scheduler, so closing is awaited up to {@link #CLOSE_TIMEOUT}. + */ +class ClientBasedConnectionPoolTest { + + private static final String DATASOURCE_TYPE = "es"; + private static final Instant FIRST_VERSION = Instant.parse("2026-01-01T00:00:00Z"); + private static final Instant SECOND_VERSION = FIRST_VERSION.plusSeconds(60); + private static final int CONCURRENT_CALLERS = 100; + private static final int THREADS = 50; + private static final Duration CLOSE_TIMEOUT = Duration.ofSeconds(5); + private static final Duration POLL_INTERVAL = Duration.ofMillis(20); + + private static final MockDatasourceMetaInfoService META_INFO_SERVICE = new MockDatasourceMetaInfoService(); + private static final MockDatasourceConnector CONNECTOR = + (MockDatasourceConnector) (Object) META_INFO_SERVICE.getDatasourceConnector(DATASOURCE_TYPE); + private static final ClientBasedConnectionPool POOL = new ClientBasedConnectionPool(META_INFO_SERVICE, new NoMetrics()); + private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(THREADS); + + @AfterAll + static void shutDown() { + EXECUTOR.shutdownNow(); } @Test - public void testGetConnectionFirstAndAgain() { - Datasource datasource = createDatasource(1); - //first - DatasourceConnectionHolder first = POOL.getOrCreateConnection(datasource).block(); - Assert.assertEquals(first, getFromCacheDirectly(datasource)); - //again - DatasourceConnectionHolder again = POOL.getOrCreateConnection(datasource).block(); - Assert.assertEquals(again, getFromCacheDirectly(datasource)); - Assert.assertEquals(first, again); + void secondRequestForTheSameDatasourceVersionReusesTheConnection() { + Datasource datasource = datasource("reuse", FIRST_VERSION); + + ClientBasedDatasourceConnectionHolder first = connection(datasource); + ClientBasedDatasourceConnectionHolder again = connection(datasource); + System.out.println("[pool] reuse: first " + first.connection() + ", again " + again.connection()); + + assertSame(first, again); + assertEquals(1, CONNECTOR.getCreateTimes(datasource.getDetailConfig())); } @Test - public void testConnectionExpired() { - ClientBasedDatasourceConnectionHolder expired = - (ClientBasedDatasourceConnectionHolder) POOL.getOrCreateConnection(createDatasource(2)).block(); - - Datasource datasource = createDatasource(2); - ClientBasedDatasourceConnectionHolder newConnection = (ClientBasedDatasourceConnectionHolder) POOL.getOrCreateConnection(datasource).block(); - Assert.assertNotEquals(expired, newConnection); - Assert.assertEquals(newConnection, getFromCacheDirectly(datasource)); + void updatedDatasourceGetsANewConnectionAndThePreviousOneIsClosed() { + ClientBasedDatasourceConnectionHolder previous = connection(datasource("updated", FIRST_VERSION)); + Datasource updated = datasource("updated", SECOND_VERSION); + + ClientBasedDatasourceConnectionHolder current = connection(updated); + System.out.println("[pool] update: previous " + previous.connection() + ", current " + current.connection()); + + assertNotSame(previous, current); + assertSame(current, connection(updated)); + awaitClosed(previous, "the connection of the previous datasource version"); + assertFalse(((MockConnection) current.connection()).isClose(), "the current connection must stay open"); } @Test - public void testConnectionStale() { - Datasource datasource = createDatasource(3); - ClientBasedDatasourceConnectionHolder staled = (ClientBasedDatasourceConnectionHolder) POOL.getOrCreateConnection(datasource).block(); - Assert.assertNotNull(staled); - staled.onQueryError(new InvalidHikariDatasourceException()); - // - ClientBasedDatasourceConnectionHolder newConnection = (ClientBasedDatasourceConnectionHolder) POOL.getOrCreateConnection(datasource).block(); - Assert.assertNotEquals(staled, newConnection); - Assert.assertEquals(newConnection, getFromCacheDirectly(datasource)); - Assert.assertTrue(((MockConnection) staled.connection()).isClose()); + void connectionMarkedStaleByAHikariErrorIsReplacedAndClosed() { + Datasource datasource = datasource("stale", FIRST_VERSION); + ClientBasedDatasourceConnectionHolder stale = connection(datasource); + + stale.onQueryError(new InvalidHikariDatasourceException()); + ClientBasedDatasourceConnectionHolder replacement = connection(datasource); + System.out.println("[pool] stale: stale " + stale.connection() + ", replacement " + replacement.connection()); + + assertTrue(stale.isStale()); + assertNotSame(stale, replacement); + assertFalse(replacement.isStale()); + awaitClosed(stale, "the stale connection"); } @Test - public void testCreateConnectionConcurrent() { - Datasource datasource = createDatasource(4); - datasource.setDetailConfig(new MockDatasourceConnectionConfig(datasource)); - ExecutorService executorService = Executors.newFixedThreadPool(50); + void otherQueryErrorsKeepTheConnection() { + Datasource datasource = datasource("other-error", FIRST_VERSION); + ClientBasedDatasourceConnectionHolder holder = connection(datasource); + + holder.onQueryError(new IllegalStateException("query failed")); + + assertFalse(holder.isStale()); + assertSame(holder, connection(datasource)); + } + @Test + void concurrentFirstRequestsCreateOneConnection() throws Exception { + Datasource datasource = datasource("concurrent", FIRST_VERSION); + CountDownLatch start = new CountDownLatch(1); List> futures = new ArrayList<>(); - CountDownLatch countDownLatch = new CountDownLatch(1); - for (int i = 0; i < 100; i++) { - Future future = executorService.submit(() -> { - countDownLatch.await(); - return (ClientBasedDatasourceConnectionHolder) POOL.getOrCreateConnection(datasource).block(); - }); - futures.add(future); + for (int i = 0; i < CONCURRENT_CALLERS; i++) { + futures.add(EXECUTOR.submit(() -> { + start.await(); + return connection(datasource); + })); + } + start.countDown(); + + List holders = new ArrayList<>(); + for (Future future : futures) { + holders.add(future.get(CLOSE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); } - countDownLatch.countDown(); - - int size = (int) futures.stream() - .map(clientBasedDatasourceConnectionFuture -> { - try { - return clientBasedDatasourceConnectionFuture.get(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - }) - .distinct() - .count(); - Assert.assertEquals(1, size); - DatasourceConnector datasourceConnector = mockDatasourceMetaInfoService.getDatasourceConnector(datasource.getType()); - MockDatasourceConnector mockDatasourceConnectionFactory = (MockDatasourceConnector) datasourceConnector; - - Assert.assertEquals(1, mockDatasourceConnectionFactory.getCreateTimes(datasource.getDetailConfig())); + long distinct = holders.stream().distinct().count(); + System.out.println("[pool] concurrent: " + CONCURRENT_CALLERS + " callers, " + distinct + " distinct connection(s), " + + CONNECTOR.getCreateTimes(datasource.getDetailConfig()) + " created"); + + assertEquals(1, distinct); + assertEquals(1, CONNECTOR.getCreateTimes(datasource.getDetailConfig())); } - private ClientBasedDatasourceConnectionHolder getFromCacheDirectly(Datasource datasource) { - return POOL_CACHE.getUnchecked(ClientBasedDatasourceCacheKey.of(datasource)).block(); + private static ClientBasedDatasourceConnectionHolder connection(Datasource datasource) { + return (ClientBasedDatasourceConnectionHolder) POOL.getOrCreateConnection(datasource).block(CLOSE_TIMEOUT); } - private Datasource createDatasource(int id) { + private static Datasource datasource(String id, Instant version) { Datasource datasource = new Datasource(); - datasource.setId(id + ""); - datasource.setUpdatedAt(Instant.now()); - datasource.setType("es"); + datasource.setId(id); + datasource.setUpdatedAt(version); + datasource.setType(DATASOURCE_TYPE); + datasource.setDetailConfig(new MockDatasourceConnectionConfig(datasource)); return datasource; } - **/ -} \ No newline at end of file + + private static void awaitClosed(ClientBasedDatasourceConnectionHolder holder, String what) { + MockConnection connection = (MockConnection) holder.connection(); + assertTrue(poll(connection::isClose), what + " was not closed within " + CLOSE_TIMEOUT); + } + + private static boolean poll(BooleanSupplier condition) { + long deadline = System.nanoTime() + CLOSE_TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + try { + Thread.sleep(POLL_INTERVAL.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return condition.getAsBoolean(); + } + + /** {@link PerfHelper} without a meter registry: the pool's counters are not under test. */ + private static final class NoMetrics extends PerfHelper { + @Override + public void count(PerfEvent event, Iterable tags) { + } + + @Override + public void count(PerfEvent event, Iterable tags, int count) { + } + } +} diff --git a/server/api-service/lowcoder-domain/src/test/resources/application.yml b/server/api-service/lowcoder-domain/src/test/resources/application.yml index 99029dd029..078868a5f6 100644 --- a/server/api-service/lowcoder-domain/src/test/resources/application.yml +++ b/server/api-service/lowcoder-domain/src/test/resources/application.yml @@ -10,9 +10,6 @@ demo: spring: redis: url: redis://localhost:6369 - mongodb: - embedded: - version: 4.0.2 main: allow-circular-references: true diff --git a/server/api-service/lowcoder-domain/src/test/resources/json-contract/dynamic-roundtrip/TemplateSolutionService.createFromTemplate.json b/server/api-service/lowcoder-domain/src/test/resources/json-contract/dynamic-roundtrip/TemplateSolutionService.createFromTemplate.json new file mode 100644 index 0000000000..b97a42f539 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/resources/json-contract/dynamic-roundtrip/TemplateSolutionService.createFromTemplate.json @@ -0,0 +1,254 @@ +{ + "input" : { + "shape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/ui" : "java.util.LinkedHashMap", + "/ui/representative" : "java.util.LinkedHashMap", + "/ui/representative/zeta" : "java.lang.String", + "/ui/representative/int" : "java.lang.Integer", + "/ui/representative/negativeInt" : "java.lang.Integer", + "/ui/representative/intMax" : "java.lang.Integer", + "/ui/representative/intMaxPlusOne" : "java.lang.Long", + "/ui/representative/intMinMinusOne" : "java.lang.Long", + "/ui/representative/longMax" : "java.lang.Long", + "/ui/representative/longMaxPlusOne" : "java.math.BigInteger", + "/ui/representative/longMinMinusOne" : "java.math.BigInteger", + "/ui/representative/decimal" : "java.lang.Double", + "/ui/representative/trailingZeroDecimal" : "java.lang.Double", + "/ui/representative/zeroPointZero" : "java.lang.Double", + "/ui/representative/negativeZero" : "java.lang.Integer", + "/ui/representative/exponent" : "java.lang.Double", + "/ui/representative/exponentUpperCase" : "java.lang.Double", + "/ui/representative/negativeExponent" : "java.lang.Double", + "/ui/representative/longDecimal" : "java.lang.Double", + "/ui/representative/true" : "java.lang.Boolean", + "/ui/representative/false" : "java.lang.Boolean", + "/ui/representative/null" : "null", + "/ui/representative/emptyObject" : "java.util.LinkedHashMap", + "/ui/representative/emptyArray" : "java.util.ArrayList", + "/ui/representative/nested" : "java.util.LinkedHashMap", + "/ui/representative/nested/array" : "java.util.ArrayList", + "/ui/representative/nested/array/0" : "java.lang.Integer", + "/ui/representative/nested/array/1" : "java.lang.String", + "/ui/representative/nested/array/2" : "java.util.ArrayList", + "/ui/representative/nested/array/2/0" : "java.lang.Integer", + "/ui/representative/nested/array/2/1" : "java.util.LinkedHashMap", + "/ui/representative/nested/array/2/1/four" : "java.lang.Double", + "/ui/representative/nested/array/3" : "null", + "/ui/representative/nested/array/4" : "java.lang.Boolean", + "/ui/representative/nested/array/5" : "java.lang.Long", + "/ui/representative/nested/object" : "java.util.LinkedHashMap", + "/ui/representative/nested/object/z" : "java.lang.Integer", + "/ui/representative/nested/object/a" : "java.util.LinkedHashMap", + "/ui/representative/nested/object/a/deeper" : "java.util.ArrayList", + "/ui/representative/nested/object/a/deeper/0" : "java.util.ArrayList", + "/ui/representative/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/ui/representative/unicode" : "java.lang.String", + "/ui/representative/escapes" : "java.lang.String", + "/ui/representative/key with escaped name" : "java.lang.String", + "/ui/representative/alpha" : "java.lang.String", + "/ui/savedAt" : "java.util.Date", + "/ui/ratio" : "java.math.BigDecimal", + "/ui/label" : "java.lang.String", + "/queries" : "java.util.ArrayList", + "/queries/0" : "java.util.LinkedHashMap", + "/queries/0/id" : "java.lang.String", + "/queries/0/gid" : "java.lang.String", + "/queries/0/name" : "java.lang.String", + "/queries/0/datasourceId" : "java.lang.String", + "/queries/0/compType" : "java.lang.String", + "/queries/0/comp" : "java.util.LinkedHashMap", + "/queries/0/comp/zeta" : "java.lang.String", + "/queries/0/comp/int" : "java.lang.Integer", + "/queries/0/comp/negativeInt" : "java.lang.Integer", + "/queries/0/comp/intMax" : "java.lang.Integer", + "/queries/0/comp/intMaxPlusOne" : "java.lang.Long", + "/queries/0/comp/intMinMinusOne" : "java.lang.Long", + "/queries/0/comp/longMax" : "java.lang.Long", + "/queries/0/comp/longMaxPlusOne" : "java.math.BigInteger", + "/queries/0/comp/longMinMinusOne" : "java.math.BigInteger", + "/queries/0/comp/decimal" : "java.lang.Double", + "/queries/0/comp/trailingZeroDecimal" : "java.lang.Double", + "/queries/0/comp/zeroPointZero" : "java.lang.Double", + "/queries/0/comp/negativeZero" : "java.lang.Integer", + "/queries/0/comp/exponent" : "java.lang.Double", + "/queries/0/comp/exponentUpperCase" : "java.lang.Double", + "/queries/0/comp/negativeExponent" : "java.lang.Double", + "/queries/0/comp/longDecimal" : "java.lang.Double", + "/queries/0/comp/true" : "java.lang.Boolean", + "/queries/0/comp/false" : "java.lang.Boolean", + "/queries/0/comp/null" : "null", + "/queries/0/comp/emptyObject" : "java.util.LinkedHashMap", + "/queries/0/comp/emptyArray" : "java.util.ArrayList", + "/queries/0/comp/nested" : "java.util.LinkedHashMap", + "/queries/0/comp/nested/array" : "java.util.ArrayList", + "/queries/0/comp/nested/array/0" : "java.lang.Integer", + "/queries/0/comp/nested/array/1" : "java.lang.String", + "/queries/0/comp/nested/array/2" : "java.util.ArrayList", + "/queries/0/comp/nested/array/2/0" : "java.lang.Integer", + "/queries/0/comp/nested/array/2/1" : "java.util.LinkedHashMap", + "/queries/0/comp/nested/array/2/1/four" : "java.lang.Double", + "/queries/0/comp/nested/array/3" : "null", + "/queries/0/comp/nested/array/4" : "java.lang.Boolean", + "/queries/0/comp/nested/array/5" : "java.lang.Long", + "/queries/0/comp/nested/object" : "java.util.LinkedHashMap", + "/queries/0/comp/nested/object/z" : "java.lang.Integer", + "/queries/0/comp/nested/object/a" : "java.util.LinkedHashMap", + "/queries/0/comp/nested/object/a/deeper" : "java.util.ArrayList", + "/queries/0/comp/nested/object/a/deeper/0" : "java.util.ArrayList", + "/queries/0/comp/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/queries/0/comp/unicode" : "java.lang.String", + "/queries/0/comp/escapes" : "java.lang.String", + "/queries/0/comp/key with escaped name" : "java.lang.String", + "/queries/0/comp/alpha" : "java.lang.String" + }, + "keyOrder" : { + "/ui/representative/emptyObject" : [ ], + "/ui/representative/nested/array/2/1" : [ "four" ], + "/ui/representative/nested/object/a/deeper/1" : [ ], + "/ui/representative/nested/object/a" : [ "deeper" ], + "/ui/representative/nested/object" : [ "z", "a" ], + "/ui/representative/nested" : [ "array", "object" ], + "/ui/representative" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ], + "/ui" : [ "representative", "savedAt", "ratio", "label" ], + "/queries/0/comp/emptyObject" : [ ], + "/queries/0/comp/nested/array/2/1" : [ "four" ], + "/queries/0/comp/nested/object/a/deeper/1" : [ ], + "/queries/0/comp/nested/object/a" : [ "deeper" ], + "/queries/0/comp/nested/object" : [ "z", "a" ], + "/queries/0/comp/nested" : [ "array", "object" ], + "/queries/0/comp" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ], + "/queries/0" : [ "id", "gid", "name", "datasourceId", "compType", "comp" ], + "" : [ "ui", "queries" ] + } + }, + "written" : {"ui":{"representative":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"savedAt":1709212455123,"ratio":1.50,"label":"reads from ds-template"},"queries":[{"id":"q1","gid":"gq1","name":"query1","datasourceId":"ds-template","compType":"postgres","comp":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"}}]} + }, + "copy" : { + "shape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/ui" : "java.util.LinkedHashMap", + "/ui/representative" : "java.util.LinkedHashMap", + "/ui/representative/zeta" : "java.lang.String", + "/ui/representative/int" : "java.lang.Integer", + "/ui/representative/negativeInt" : "java.lang.Integer", + "/ui/representative/intMax" : "java.lang.Integer", + "/ui/representative/intMaxPlusOne" : "java.lang.Long", + "/ui/representative/intMinMinusOne" : "java.lang.Long", + "/ui/representative/longMax" : "java.lang.Long", + "/ui/representative/longMaxPlusOne" : "java.math.BigInteger", + "/ui/representative/longMinMinusOne" : "java.math.BigInteger", + "/ui/representative/decimal" : "java.lang.Double", + "/ui/representative/trailingZeroDecimal" : "java.lang.Double", + "/ui/representative/zeroPointZero" : "java.lang.Double", + "/ui/representative/negativeZero" : "java.lang.Integer", + "/ui/representative/exponent" : "java.lang.Double", + "/ui/representative/exponentUpperCase" : "java.lang.Double", + "/ui/representative/negativeExponent" : "java.lang.Double", + "/ui/representative/longDecimal" : "java.lang.Double", + "/ui/representative/true" : "java.lang.Boolean", + "/ui/representative/false" : "java.lang.Boolean", + "/ui/representative/null" : "null", + "/ui/representative/emptyObject" : "java.util.LinkedHashMap", + "/ui/representative/emptyArray" : "java.util.ArrayList", + "/ui/representative/nested" : "java.util.LinkedHashMap", + "/ui/representative/nested/array" : "java.util.ArrayList", + "/ui/representative/nested/array/0" : "java.lang.Integer", + "/ui/representative/nested/array/1" : "java.lang.String", + "/ui/representative/nested/array/2" : "java.util.ArrayList", + "/ui/representative/nested/array/2/0" : "java.lang.Integer", + "/ui/representative/nested/array/2/1" : "java.util.LinkedHashMap", + "/ui/representative/nested/array/2/1/four" : "java.lang.Double", + "/ui/representative/nested/array/3" : "null", + "/ui/representative/nested/array/4" : "java.lang.Boolean", + "/ui/representative/nested/array/5" : "java.lang.Long", + "/ui/representative/nested/object" : "java.util.LinkedHashMap", + "/ui/representative/nested/object/z" : "java.lang.Integer", + "/ui/representative/nested/object/a" : "java.util.LinkedHashMap", + "/ui/representative/nested/object/a/deeper" : "java.util.ArrayList", + "/ui/representative/nested/object/a/deeper/0" : "java.util.ArrayList", + "/ui/representative/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/ui/representative/unicode" : "java.lang.String", + "/ui/representative/escapes" : "java.lang.String", + "/ui/representative/key with escaped name" : "java.lang.String", + "/ui/representative/alpha" : "java.lang.String", + "/ui/savedAt" : "java.lang.Long", + "/ui/ratio" : "java.lang.Double", + "/ui/label" : "java.lang.String", + "/queries" : "java.util.ArrayList", + "/queries/0" : "java.util.LinkedHashMap", + "/queries/0/id" : "java.lang.String", + "/queries/0/gid" : "java.lang.String", + "/queries/0/name" : "java.lang.String", + "/queries/0/datasourceId" : "java.lang.String", + "/queries/0/compType" : "java.lang.String", + "/queries/0/comp" : "java.util.LinkedHashMap", + "/queries/0/comp/zeta" : "java.lang.String", + "/queries/0/comp/int" : "java.lang.Integer", + "/queries/0/comp/negativeInt" : "java.lang.Integer", + "/queries/0/comp/intMax" : "java.lang.Integer", + "/queries/0/comp/intMaxPlusOne" : "java.lang.Long", + "/queries/0/comp/intMinMinusOne" : "java.lang.Long", + "/queries/0/comp/longMax" : "java.lang.Long", + "/queries/0/comp/longMaxPlusOne" : "java.math.BigInteger", + "/queries/0/comp/longMinMinusOne" : "java.math.BigInteger", + "/queries/0/comp/decimal" : "java.lang.Double", + "/queries/0/comp/trailingZeroDecimal" : "java.lang.Double", + "/queries/0/comp/zeroPointZero" : "java.lang.Double", + "/queries/0/comp/negativeZero" : "java.lang.Integer", + "/queries/0/comp/exponent" : "java.lang.Double", + "/queries/0/comp/exponentUpperCase" : "java.lang.Double", + "/queries/0/comp/negativeExponent" : "java.lang.Double", + "/queries/0/comp/longDecimal" : "java.lang.Double", + "/queries/0/comp/true" : "java.lang.Boolean", + "/queries/0/comp/false" : "java.lang.Boolean", + "/queries/0/comp/null" : "null", + "/queries/0/comp/emptyObject" : "java.util.LinkedHashMap", + "/queries/0/comp/emptyArray" : "java.util.ArrayList", + "/queries/0/comp/nested" : "java.util.LinkedHashMap", + "/queries/0/comp/nested/array" : "java.util.ArrayList", + "/queries/0/comp/nested/array/0" : "java.lang.Integer", + "/queries/0/comp/nested/array/1" : "java.lang.String", + "/queries/0/comp/nested/array/2" : "java.util.ArrayList", + "/queries/0/comp/nested/array/2/0" : "java.lang.Integer", + "/queries/0/comp/nested/array/2/1" : "java.util.LinkedHashMap", + "/queries/0/comp/nested/array/2/1/four" : "java.lang.Double", + "/queries/0/comp/nested/array/3" : "null", + "/queries/0/comp/nested/array/4" : "java.lang.Boolean", + "/queries/0/comp/nested/array/5" : "java.lang.Long", + "/queries/0/comp/nested/object" : "java.util.LinkedHashMap", + "/queries/0/comp/nested/object/z" : "java.lang.Integer", + "/queries/0/comp/nested/object/a" : "java.util.LinkedHashMap", + "/queries/0/comp/nested/object/a/deeper" : "java.util.ArrayList", + "/queries/0/comp/nested/object/a/deeper/0" : "java.util.ArrayList", + "/queries/0/comp/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/queries/0/comp/unicode" : "java.lang.String", + "/queries/0/comp/escapes" : "java.lang.String", + "/queries/0/comp/key with escaped name" : "java.lang.String", + "/queries/0/comp/alpha" : "java.lang.String" + }, + "keyOrder" : { + "/ui/representative/emptyObject" : [ ], + "/ui/representative/nested/array/2/1" : [ "four" ], + "/ui/representative/nested/object/a/deeper/1" : [ ], + "/ui/representative/nested/object/a" : [ "deeper" ], + "/ui/representative/nested/object" : [ "z", "a" ], + "/ui/representative/nested" : [ "array", "object" ], + "/ui/representative" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ], + "/ui" : [ "representative", "savedAt", "ratio", "label" ], + "/queries/0/comp/emptyObject" : [ ], + "/queries/0/comp/nested/array/2/1" : [ "four" ], + "/queries/0/comp/nested/object/a/deeper/1" : [ ], + "/queries/0/comp/nested/object/a" : [ "deeper" ], + "/queries/0/comp/nested/object" : [ "z", "a" ], + "/queries/0/comp/nested" : [ "array", "object" ], + "/queries/0/comp" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ], + "/queries/0" : [ "id", "gid", "name", "datasourceId", "compType", "comp" ], + "" : [ "ui", "queries" ] + } + }, + "written" : {"ui":{"representative":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"savedAt":1709212455123,"ratio":1.5,"label":"reads from ds-copy"},"queries":[{"id":"q1","gid":"gq1","name":"query1","datasourceId":"ds-copy","compType":"postgres","comp":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"}}]} + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-domain/src/test/resources/json-contract/node-service/DatasourcePluginClient.calls.json b/server/api-service/lowcoder-domain/src/test/resources/json-contract/node-service/DatasourcePluginClient.calls.json new file mode 100644 index 0000000000..8a1cb6d188 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/resources/json-contract/node-service/DatasourcePluginClient.calls.json @@ -0,0 +1,470 @@ +{ + "ok" : { + "getPluginDynamicConfig" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/getPluginDynamicConfig", + "X-Encrypted" : [ ], + "contentType" : [ "application/json" ], + "body" : [{"dataSourceId":"ds1","pluginName":"contractPlugin","path":"tables","dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]}}] + }, + "result" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.util.LinkedHashMap", + "/0/zeta" : "java.lang.String", + "/0/int" : "java.lang.Integer", + "/0/negativeInt" : "java.lang.Integer", + "/0/intMax" : "java.lang.Integer", + "/0/intMaxPlusOne" : "java.lang.Long", + "/0/intMinMinusOne" : "java.lang.Long", + "/0/longMax" : "java.lang.Long", + "/0/longMaxPlusOne" : "java.math.BigInteger", + "/0/longMinMinusOne" : "java.math.BigInteger", + "/0/decimal" : "java.lang.Double", + "/0/trailingZeroDecimal" : "java.lang.Double", + "/0/zeroPointZero" : "java.lang.Double", + "/0/negativeZero" : "java.lang.Integer", + "/0/exponent" : "java.lang.Double", + "/0/exponentUpperCase" : "java.lang.Double", + "/0/negativeExponent" : "java.lang.Double", + "/0/longDecimal" : "java.lang.Double", + "/0/true" : "java.lang.Boolean", + "/0/false" : "java.lang.Boolean", + "/0/null" : "null", + "/0/emptyObject" : "java.util.LinkedHashMap", + "/0/emptyArray" : "java.util.ArrayList", + "/0/nested" : "java.util.LinkedHashMap", + "/0/nested/array" : "java.util.ArrayList", + "/0/nested/array/0" : "java.lang.Integer", + "/0/nested/array/1" : "java.lang.String", + "/0/nested/array/2" : "java.util.ArrayList", + "/0/nested/array/2/0" : "java.lang.Integer", + "/0/nested/array/2/1" : "java.util.LinkedHashMap", + "/0/nested/array/2/1/four" : "java.lang.Double", + "/0/nested/array/3" : "null", + "/0/nested/array/4" : "java.lang.Boolean", + "/0/nested/array/5" : "java.lang.Long", + "/0/nested/object" : "java.util.LinkedHashMap", + "/0/nested/object/z" : "java.lang.Integer", + "/0/nested/object/a" : "java.util.LinkedHashMap", + "/0/nested/object/a/deeper" : "java.util.ArrayList", + "/0/nested/object/a/deeper/0" : "java.util.ArrayList", + "/0/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/0/unicode" : "java.lang.String", + "/0/escapes" : "java.lang.String", + "/0/key with escaped name" : "java.lang.String", + "/0/alpha" : "java.lang.String", + "/1" : "java.util.LinkedHashMap", + "/1/n" : "java.lang.Double", + "/1/big" : "java.math.BigInteger" + }, + "keyOrder" : { + "/0/emptyObject" : [ ], + "/0/nested/array/2/1" : [ "four" ], + "/0/nested/object/a/deeper/1" : [ ], + "/0/nested/object/a" : [ "deeper" ], + "/0/nested/object" : [ "z", "a" ], + "/0/nested" : [ "array", "object" ], + "/0" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ], + "/1" : [ "n", "big" ] + } + } + }, + "getDatasourcePluginDefinitions" : { + "request" : { + "method" : "GET", + "path" : "/node-service/api/plugins", + "X-Encrypted" : [ ] + }, + "result" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "org.lowcoder.domain.plugin.client.dto.DatasourcePluginDefinition", + "/0/name" : "java.lang.String", + "/0/limit" : "java.lang.Long", + "/0/dataSourceConfig" : "java.util.LinkedHashMap", + "/0/dataSourceConfig/extra" : "java.util.LinkedHashMap", + "/0/dataSourceConfig/extra/type" : "java.lang.String", + "/0/id" : "java.lang.String", + "/0/ratio" : "java.lang.Double" + }, + "keyOrder" : { + "/0/dataSourceConfig/extra" : [ "type" ], + "/0/dataSourceConfig" : [ "extra" ], + "/0" : [ "name", "limit", "dataSourceConfig", "id", "ratio" ] + } + } + }, + "executeQuery" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/runPluginQuery", + "X-Encrypted" : [ ], + "contentType" : [ "application/json" ], + "body" : {"pluginName":"contractPlugin","dsl":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň \uD83D\uDC0E 中文","escapes":"é \u0000 \n \t \" \\ / \uD83D\uDE00","key with escaped name":"escaped key","alpha":"last key"},"dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]},"context":[{"key":"limit","value":3000000001}]} + }, + "result" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/zeta" : "java.lang.String", + "/int" : "java.lang.Integer", + "/negativeInt" : "java.lang.Integer", + "/intMax" : "java.lang.Integer", + "/intMaxPlusOne" : "java.lang.Long", + "/intMinMinusOne" : "java.lang.Long", + "/longMax" : "java.lang.Long", + "/longMaxPlusOne" : "java.math.BigInteger", + "/longMinMinusOne" : "java.math.BigInteger", + "/decimal" : "java.lang.Double", + "/trailingZeroDecimal" : "java.lang.Double", + "/zeroPointZero" : "java.lang.Double", + "/negativeZero" : "java.lang.Integer", + "/exponent" : "java.lang.Double", + "/exponentUpperCase" : "java.lang.Double", + "/negativeExponent" : "java.lang.Double", + "/longDecimal" : "java.lang.Double", + "/true" : "java.lang.Boolean", + "/false" : "java.lang.Boolean", + "/null" : "null", + "/emptyObject" : "java.util.LinkedHashMap", + "/emptyArray" : "java.util.ArrayList", + "/nested" : "java.util.LinkedHashMap", + "/nested/array" : "java.util.ArrayList", + "/nested/array/0" : "java.lang.Integer", + "/nested/array/1" : "java.lang.String", + "/nested/array/2" : "java.util.ArrayList", + "/nested/array/2/0" : "java.lang.Integer", + "/nested/array/2/1" : "java.util.LinkedHashMap", + "/nested/array/2/1/four" : "java.lang.Double", + "/nested/array/3" : "null", + "/nested/array/4" : "java.lang.Boolean", + "/nested/array/5" : "java.lang.Long", + "/nested/object" : "java.util.LinkedHashMap", + "/nested/object/z" : "java.lang.Integer", + "/nested/object/a" : "java.util.LinkedHashMap", + "/nested/object/a/deeper" : "java.util.ArrayList", + "/nested/object/a/deeper/0" : "java.util.ArrayList", + "/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/unicode" : "java.lang.String", + "/escapes" : "java.lang.String", + "/key with escaped name" : "java.lang.String", + "/alpha" : "java.lang.String" + }, + "keyOrder" : { + "/emptyObject" : [ ], + "/nested/array/2/1" : [ "four" ], + "/nested/object/a/deeper/1" : [ ], + "/nested/object/a" : [ "deeper" ], + "/nested/object" : [ "z", "a" ], + "/nested" : [ "array", "object" ], + "" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ] + } + }, + "written" : {"queryCode":"OK","data":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"success":true} + } + }, + "executeQueryEncrypted" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/runPluginQuery", + "X-Encrypted" : [ "true" ], + "contentType" : [ "text/plain;charset=UTF-8" ], + "decrypted" : {"pluginName":"contractPlugin","dsl":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]},"context":[{"key":"limit","value":3000000001}]} + }, + "result" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/zeta" : "java.lang.String", + "/int" : "java.lang.Integer", + "/negativeInt" : "java.lang.Integer", + "/intMax" : "java.lang.Integer", + "/intMaxPlusOne" : "java.lang.Long", + "/intMinMinusOne" : "java.lang.Long", + "/longMax" : "java.lang.Long", + "/longMaxPlusOne" : "java.math.BigInteger", + "/longMinMinusOne" : "java.math.BigInteger", + "/decimal" : "java.lang.Double", + "/trailingZeroDecimal" : "java.lang.Double", + "/zeroPointZero" : "java.lang.Double", + "/negativeZero" : "java.lang.Integer", + "/exponent" : "java.lang.Double", + "/exponentUpperCase" : "java.lang.Double", + "/negativeExponent" : "java.lang.Double", + "/longDecimal" : "java.lang.Double", + "/true" : "java.lang.Boolean", + "/false" : "java.lang.Boolean", + "/null" : "null", + "/emptyObject" : "java.util.LinkedHashMap", + "/emptyArray" : "java.util.ArrayList", + "/nested" : "java.util.LinkedHashMap", + "/nested/array" : "java.util.ArrayList", + "/nested/array/0" : "java.lang.Integer", + "/nested/array/1" : "java.lang.String", + "/nested/array/2" : "java.util.ArrayList", + "/nested/array/2/0" : "java.lang.Integer", + "/nested/array/2/1" : "java.util.LinkedHashMap", + "/nested/array/2/1/four" : "java.lang.Double", + "/nested/array/3" : "null", + "/nested/array/4" : "java.lang.Boolean", + "/nested/array/5" : "java.lang.Long", + "/nested/object" : "java.util.LinkedHashMap", + "/nested/object/z" : "java.lang.Integer", + "/nested/object/a" : "java.util.LinkedHashMap", + "/nested/object/a/deeper" : "java.util.ArrayList", + "/nested/object/a/deeper/0" : "java.util.ArrayList", + "/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/unicode" : "java.lang.String", + "/escapes" : "java.lang.String", + "/key with escaped name" : "java.lang.String", + "/alpha" : "java.lang.String" + }, + "keyOrder" : { + "/emptyObject" : [ ], + "/nested/array/2/1" : [ "four" ], + "/nested/object/a/deeper/1" : [ ], + "/nested/object/a" : [ "deeper" ], + "/nested/object" : [ "z", "a" ], + "/nested" : [ "array", "object" ], + "" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ] + } + }, + "written" : {"queryCode":"OK","data":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"success":true} + } + }, + "executeQueryWithInstant" : { + "request" : null, + "result" : { + "error" : "org.lowcoder.sdk.exception.ServerException: Encryption error" + } + }, + "executeQueryEncryptedWithInstant" : { + "request" : null, + "result" : { + "error" : "org.lowcoder.sdk.exception.ServerException: Encryption error" + } + }, + "test" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/validatePluginDataSourceConfig", + "X-Encrypted" : [ ], + "contentType" : [ "application/json" ], + "body" : {"pluginName":"contractPlugin","dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]}} + }, + "result" : { + "values" : { + "@class" : "org.lowcoder.sdk.models.DatasourceTestResult", + "localeErrorMsg" : null + }, + "classes" : { + "" : "org.lowcoder.sdk.models.DatasourceTestResult", + "/localeErrorMsg" : "null" + } + } + } + }, + "failing" : { + "getPluginDynamicConfig" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/getPluginDynamicConfig", + "X-Encrypted" : [ ], + "contentType" : [ "application/json" ], + "body" : [{"dataSourceId":"ds1","pluginName":"contractPlugin","path":"tables","dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]}}] + }, + "result" : { + "error" : "org.lowcoder.sdk.exception.ServerException: get dynamic config error" + } + }, + "getDatasourcePluginDefinitions" : { + "request" : { + "method" : "GET", + "path" : "/node-service/api/plugins", + "X-Encrypted" : [ ] + }, + "result" : { + "classes" : { + "" : "java.util.ArrayList" + }, + "keyOrder" : { } + } + }, + "executeQuery" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/runPluginQuery", + "X-Encrypted" : [ ], + "contentType" : [ "application/json" ], + "body" : {"pluginName":"contractPlugin","dsl":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň \uD83D\uDC0E 中文","escapes":"é \u0000 \n \t \" \\ / \uD83D\uDE00","key with escaped name":"escaped key","alpha":"last key"},"dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]},"context":[{"key":"limit","value":3000000001}]} + }, + "result" : { + "queryCode" : "FAILED", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"FAILED","message":"bad \"q\" žluť","success":false} + } + }, + "executeQueryEncrypted" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/runPluginQuery", + "X-Encrypted" : [ "true" ], + "contentType" : [ "text/plain;charset=UTF-8" ], + "decrypted" : {"pluginName":"contractPlugin","dsl":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]},"context":[{"key":"limit","value":3000000001}]} + }, + "result" : { + "queryCode" : "FAILED", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"FAILED","message":"bad \"q\" žluť","success":false} + } + }, + "executeQueryWithInstant" : { + "request" : null, + "result" : { + "error" : "org.lowcoder.sdk.exception.ServerException: Encryption error" + } + }, + "executeQueryEncryptedWithInstant" : { + "request" : null, + "result" : { + "error" : "org.lowcoder.sdk.exception.ServerException: Encryption error" + } + }, + "test" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/validatePluginDataSourceConfig", + "X-Encrypted" : [ ], + "contentType" : [ "application/json" ], + "body" : {"pluginName":"contractPlugin","dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]}} + }, + "result" : { + "values" : { + "@class" : "org.lowcoder.sdk.models.DatasourceTestResult", + "localeErrorMsg" : { + "@class" : "org.lowcoder.sdk.models.LocaleMessage", + "messageKey" : "DATASOURCE_TEST_GENERIC_ERROR", + "args" : [ "1.5" ] + } + }, + "classes" : { + "" : "org.lowcoder.sdk.models.DatasourceTestResult", + "/localeErrorMsg" : "org.lowcoder.sdk.models.LocaleMessage", + "/localeErrorMsg/messageKey" : "java.lang.String", + "/localeErrorMsg/args" : "[Ljava.lang.Object;", + "/localeErrorMsg/args/0" : "java.lang.String" + } + } + } + }, + "textValues" : { + "getPluginDynamicConfig" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/getPluginDynamicConfig", + "X-Encrypted" : [ ], + "contentType" : [ "application/json" ], + "body" : [{"dataSourceId":"ds1","pluginName":"contractPlugin","path":"tables","dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]}}] + }, + "result" : { + "error" : "org.springframework.core.codec.DecodingException: JSON decoding error: Cannot deserialize value of type `java.util.ArrayList` from Object value (token `JsonToken.START_OBJECT`)" + } + }, + "getDatasourcePluginDefinitions" : { + "request" : { + "method" : "GET", + "path" : "/node-service/api/plugins", + "X-Encrypted" : [ ] + }, + "result" : { + "classes" : { + "" : "java.util.ArrayList" + }, + "keyOrder" : { } + } + }, + "executeQuery" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/runPluginQuery", + "X-Encrypted" : [ ], + "contentType" : [ "application/json" ], + "body" : {"pluginName":"contractPlugin","dsl":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň \uD83D\uDC0E 中文","escapes":"é \u0000 \n \t \" \\ / \uD83D\uDE00","key with escaped name":"escaped key","alpha":"last key"},"dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]},"context":[{"key":"limit","value":3000000001}]} + }, + "result" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":"text","success":true} + } + }, + "executeQueryEncrypted" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/runPluginQuery", + "X-Encrypted" : [ "true" ], + "contentType" : [ "text/plain;charset=UTF-8" ], + "decrypted" : {"pluginName":"contractPlugin","dsl":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]},"context":[{"key":"limit","value":3000000001}]} + }, + "result" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":"text","success":true} + } + }, + "executeQueryWithInstant" : { + "request" : null, + "result" : { + "error" : "org.lowcoder.sdk.exception.ServerException: Encryption error" + } + }, + "executeQueryEncryptedWithInstant" : { + "request" : null, + "result" : { + "error" : "org.lowcoder.sdk.exception.ServerException: Encryption error" + } + }, + "test" : { + "request" : { + "method" : "POST", + "path" : "/node-service/api/validatePluginDataSourceConfig", + "X-Encrypted" : [ ], + "contentType" : [ "application/json" ], + "body" : {"pluginName":"contractPlugin","dataSourceConfig":{"host":"žluť \"db\"","port":5432,"ratio":1.50,"tags":["a",null]}} + }, + "result" : { + "values" : { + "@class" : "org.lowcoder.sdk.models.DatasourceTestResult", + "localeErrorMsg" : null + }, + "classes" : { + "" : "org.lowcoder.sdk.models.DatasourceTestResult", + "/localeErrorMsg" : "null" + } + } + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-domain/src/test/resources/json-contract/persistence-roundtrip/DatasourceRepository.configs.input.json b/server/api-service/lowcoder-domain/src/test/resources/json-contract/persistence-roundtrip/DatasourceRepository.configs.input.json new file mode 100644 index 0000000000..268f1c2cc0 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/resources/json-contract/persistence-roundtrip/DatasourceRepository.configs.input.json @@ -0,0 +1,122 @@ +{ + "mysql": { + "database": " lowcoder ", + "username": " root ", + "password": "s3crét \"quoted\"", + "host": " db.example.com ", + "port": 3306, + "usingSsl": true, + "serverTimezone": "Europe/Bratislava", + "isReadonly": true, + "enableTurnOffPreparedStatement": true, + "extParams": { + "connectTimeout": 5000, + "allowPublicKeyRetrieval": "true", + "useCursorFetch": false, + "nested": { + "list": [ + 1, + 2.5, + null + ] + } + } + }, + "restApiBasicAuth": { + "url": " https://api.example.com/v1 ", + "params": [ + { + "key": "page", + "value": "1" + }, + { + "key": "q", + "value": "{{input.text}}" + } + ], + "headers": [ + { + "key": "X-Trace", + "value": "éè" + } + ], + "bodyFormData": [ + { + "key": "file", + "value": "{{file.value}}", + "type": "file" + }, + { + "key": "name", + "value": "n", + "type": "text" + } + ], + "body": "{\"a\": 1}", + "forwardCookies": [ + "session", + "auth", + "a" + ], + "forwardAllCookies": true, + "authConfig": { + "type": "BASIC_AUTH", + "username": "user", + "password": "pass" + }, + "sslConfig": { + "sslCertVerificationType": "VERIFY_SELF_SIGNED_CERT", + "selfSignedCert": "-----BEGIN CERTIFICATE-----\nMII\n-----END CERTIFICATE-----" + } + }, + "restApiBearerToken": { + "url": "https://api.example.com", + "authConfig": { + "type": "BEARER_TOKEN_AUTH", + "token": "t" + } + }, + "graphQLBasicAuth": { + "url": " https://api.example.com/v1 ", + "params": [ + { + "key": "page", + "value": "1" + }, + { + "key": "q", + "value": "{{input.text}}" + } + ], + "headers": [ + { + "key": "X-Trace", + "value": "éè" + } + ], + "bodyFormData": [ + { + "key": "file", + "value": "{{file.value}}", + "type": "file" + }, + { + "key": "name", + "value": "n", + "type": "text" + } + ], + "body": "{\"a\": 1}", + "forwardCookies": [ + "session", + "auth", + "a" + ], + "forwardAllCookies": true, + "authConfig": { + "type": "BASIC_AUTH", + "username": "user", + "password": "pass" + } + } +} diff --git a/server/api-service/lowcoder-domain/src/test/resources/json-contract/persistence-roundtrip/DatasourceRepository.storedConfigs.json b/server/api-service/lowcoder-domain/src/test/resources/json-contract/persistence-roundtrip/DatasourceRepository.storedConfigs.json new file mode 100644 index 0000000000..9b3f179ebe --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/resources/json-contract/persistence-roundtrip/DatasourceRepository.storedConfigs.json @@ -0,0 +1,227 @@ +{ + "mysql" : { + "configClass" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "shape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/database" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/host" : "java.lang.String", + "/port" : "java.lang.Integer", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "java.lang.String", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "java.util.LinkedHashMap", + "/extParams/connectTimeout" : "java.lang.Integer", + "/extParams/allowPublicKeyRetrieval" : "java.lang.String", + "/extParams/useCursorFetch" : "java.lang.Boolean", + "/extParams/nested" : "java.util.LinkedHashMap", + "/extParams/nested/list" : "java.util.ArrayList", + "/extParams/nested/list/0" : "java.lang.Integer", + "/extParams/nested/list/1" : "java.lang.Double", + "/extParams/nested/list/2" : "null", + "/readonly" : "java.lang.Boolean" + } + }, + "written" : {"database":"lowcoder","username":"root","password":"encrypted:s3crét \"quoted\"","host":"db.example.com","port":3306,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true} + }, + "restApiBasicAuth" : { + "configClass" : "org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig", + "shape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/params" : "java.util.ArrayList", + "/params/0" : "java.util.LinkedHashMap", + "/params/0/key" : "java.lang.String", + "/params/0/value" : "java.lang.String", + "/params/0/type" : "null", + "/params/0/multipartFileType" : "java.lang.Boolean", + "/params/1" : "java.util.LinkedHashMap", + "/params/1/key" : "java.lang.String", + "/params/1/value" : "java.lang.String", + "/params/1/type" : "null", + "/params/1/multipartFileType" : "java.lang.Boolean", + "/body" : "java.lang.String", + "/bodyFormData" : "java.util.ArrayList", + "/bodyFormData/0" : "java.util.LinkedHashMap", + "/bodyFormData/0/key" : "java.lang.String", + "/bodyFormData/0/value" : "java.lang.String", + "/bodyFormData/0/type" : "java.lang.String", + "/bodyFormData/0/multipartFileType" : "java.lang.Boolean", + "/bodyFormData/1" : "java.util.LinkedHashMap", + "/bodyFormData/1/key" : "java.lang.String", + "/bodyFormData/1/value" : "java.lang.String", + "/bodyFormData/1/type" : "java.lang.String", + "/bodyFormData/1/multipartFileType" : "java.lang.Boolean", + "/headers" : "java.util.ArrayList", + "/headers/0" : "java.util.LinkedHashMap", + "/headers/0/key" : "java.lang.String", + "/headers/0/value" : "java.lang.String", + "/headers/0/type" : "null", + "/headers/0/multipartFileType" : "java.lang.Boolean", + "/url" : "java.lang.String", + "/forwardCookies" : "java.util.ArrayList", + "/forwardCookies/0" : "java.lang.String", + "/forwardCookies/1" : "java.lang.String", + "/forwardCookies/2" : "java.lang.String", + "/forwardAllCookies" : "java.lang.Boolean", + "/authConfig" : "java.util.LinkedHashMap", + "/authConfig/type" : "java.lang.String", + "/authConfig/username" : "java.lang.String", + "/authConfig/password" : "java.lang.String", + "/sslConfig" : "java.util.LinkedHashMap", + "/sslConfig/sslCertVerificationType" : "java.lang.String", + "/sslConfig/selfSignedCert" : "java.lang.String", + "/authType" : "java.lang.String", + "/oauth2InheritFromLogin" : "java.lang.Boolean" + } + }, + "written" : {"params":[{"key":"page","value":"1","type":null,"multipartFileType":false},{"key":"q","value":"{{input.text}}","type":null,"multipartFileType":false}],"body":"{\"a\": 1}","bodyFormData":[{"key":"file","value":"{{file.value}}","type":"file","multipartFileType":true},{"key":"name","value":"n","type":"text","multipartFileType":false}],"headers":[{"key":"X-Trace","value":"éè","type":null,"multipartFileType":false}],"url":"https://api.example.com/v1","forwardCookies":["a","auth","session"],"forwardAllCookies":true,"authConfig":{"type":"BASIC_AUTH","username":"user","password":"encrypted:pass"},"sslConfig":{"sslCertVerificationType":"VERIFY_SELF_SIGNED_CERT","selfSignedCert":"encrypted:-----BEGIN CERTIFICATE-----\nMII\n-----END CERTIFICATE-----"},"authType":"BASIC_AUTH","oauth2InheritFromLogin":false} + }, + "restApiBearerToken" : { + "configClass" : "org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig", + "shape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/params" : "java.util.ArrayList", + "/body" : "null", + "/bodyFormData" : "java.util.ArrayList", + "/headers" : "java.util.ArrayList", + "/url" : "java.lang.String", + "/forwardCookies" : "java.util.ArrayList", + "/forwardAllCookies" : "java.lang.Boolean", + "/authConfig" : "java.util.LinkedHashMap", + "/authConfig/type" : "java.lang.String", + "/sslConfig" : "null", + "/authType" : "java.lang.String", + "/oauth2InheritFromLogin" : "java.lang.Boolean" + } + }, + "written" : {"params":[],"body":null,"bodyFormData":[],"headers":[],"url":"https://api.example.com","forwardCookies":[],"forwardAllCookies":false,"authConfig":{"type":"BEARER_TOKEN_AUTH"},"sslConfig":null,"authType":"BEARER_TOKEN_AUTH","oauth2InheritFromLogin":false} + }, + "graphQLBasicAuth" : { + "configClass" : "org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig", + "shape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/params" : "java.util.ArrayList", + "/params/0" : "java.util.LinkedHashMap", + "/params/0/key" : "java.lang.String", + "/params/0/value" : "java.lang.String", + "/params/0/type" : "null", + "/params/0/multipartFileType" : "java.lang.Boolean", + "/params/1" : "java.util.LinkedHashMap", + "/params/1/key" : "java.lang.String", + "/params/1/value" : "java.lang.String", + "/params/1/type" : "null", + "/params/1/multipartFileType" : "java.lang.Boolean", + "/body" : "java.lang.String", + "/bodyFormData" : "java.util.ArrayList", + "/bodyFormData/0" : "java.util.LinkedHashMap", + "/bodyFormData/0/key" : "java.lang.String", + "/bodyFormData/0/value" : "java.lang.String", + "/bodyFormData/0/type" : "java.lang.String", + "/bodyFormData/0/multipartFileType" : "java.lang.Boolean", + "/bodyFormData/1" : "java.util.LinkedHashMap", + "/bodyFormData/1/key" : "java.lang.String", + "/bodyFormData/1/value" : "java.lang.String", + "/bodyFormData/1/type" : "java.lang.String", + "/bodyFormData/1/multipartFileType" : "java.lang.Boolean", + "/headers" : "java.util.ArrayList", + "/headers/0" : "java.util.LinkedHashMap", + "/headers/0/key" : "java.lang.String", + "/headers/0/value" : "java.lang.String", + "/headers/0/type" : "null", + "/headers/0/multipartFileType" : "java.lang.Boolean", + "/url" : "java.lang.String", + "/forwardCookies" : "java.util.ArrayList", + "/forwardCookies/0" : "java.lang.String", + "/forwardCookies/1" : "java.lang.String", + "/forwardCookies/2" : "java.lang.String", + "/forwardAllCookies" : "java.lang.Boolean", + "/authConfig" : "java.util.LinkedHashMap", + "/authConfig/type" : "java.lang.String", + "/authConfig/username" : "java.lang.String", + "/authConfig/password" : "java.lang.String", + "/authType" : "java.lang.String", + "/oauth2InheritFromLogin" : "java.lang.Boolean" + } + }, + "written" : {"params":[{"key":"page","value":"1","type":null,"multipartFileType":false},{"key":"q","value":"{{input.text}}","type":null,"multipartFileType":false}],"body":"{\"a\": 1}","bodyFormData":[{"key":"file","value":"{{file.value}}","type":"file","multipartFileType":true},{"key":"name","value":"n","type":"text","multipartFileType":false}],"headers":[{"key":"X-Trace","value":"éè","type":null,"multipartFileType":false}],"url":"https://api.example.com/v1","forwardCookies":["a","auth","session"],"forwardAllCookies":true,"authConfig":{"type":"BASIC_AUTH","username":"user","password":"encrypted:pass"},"authType":"BASIC_AUTH","oauth2InheritFromLogin":false} + }, + "jsPlugin" : { + "configClass" : "org.lowcoder.sdk.models.JsDatasourceConnectionConfig", + "shape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/apiKey" : "java.lang.String", + "/dynamicParamsConfig" : "java.util.LinkedHashMap", + "/dynamicParamsConfig/token" : "java.lang.String", + "/limit" : "java.lang.Long", + "/representative" : "java.util.LinkedHashMap", + "/representative/zeta" : "java.lang.String", + "/representative/int" : "java.lang.Integer", + "/representative/negativeInt" : "java.lang.Integer", + "/representative/intMax" : "java.lang.Integer", + "/representative/intMaxPlusOne" : "java.lang.Long", + "/representative/intMinMinusOne" : "java.lang.Long", + "/representative/longMax" : "java.lang.Long", + "/representative/longMaxPlusOne" : "java.math.BigInteger", + "/representative/longMinMinusOne" : "java.math.BigInteger", + "/representative/decimal" : "java.lang.Double", + "/representative/trailingZeroDecimal" : "java.lang.Double", + "/representative/zeroPointZero" : "java.lang.Double", + "/representative/negativeZero" : "java.lang.Integer", + "/representative/exponent" : "java.lang.Double", + "/representative/exponentUpperCase" : "java.lang.Double", + "/representative/negativeExponent" : "java.lang.Double", + "/representative/longDecimal" : "java.lang.Double", + "/representative/true" : "java.lang.Boolean", + "/representative/false" : "java.lang.Boolean", + "/representative/null" : "null", + "/representative/emptyObject" : "java.util.LinkedHashMap", + "/representative/emptyArray" : "java.util.ArrayList", + "/representative/nested" : "java.util.LinkedHashMap", + "/representative/nested/array" : "java.util.ArrayList", + "/representative/nested/array/0" : "java.lang.Integer", + "/representative/nested/array/1" : "java.lang.String", + "/representative/nested/array/2" : "java.util.ArrayList", + "/representative/nested/array/2/0" : "java.lang.Integer", + "/representative/nested/array/2/1" : "java.util.LinkedHashMap", + "/representative/nested/array/2/1/four" : "java.lang.Double", + "/representative/nested/array/3" : "null", + "/representative/nested/array/4" : "java.lang.Boolean", + "/representative/nested/array/5" : "java.lang.Long", + "/representative/nested/object" : "java.util.LinkedHashMap", + "/representative/nested/object/z" : "java.lang.Integer", + "/representative/nested/object/a" : "java.util.LinkedHashMap", + "/representative/nested/object/a/deeper" : "java.util.ArrayList", + "/representative/nested/object/a/deeper/0" : "java.util.ArrayList", + "/representative/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/representative/unicode" : "java.lang.String", + "/representative/escapes" : "java.lang.String", + "/representative/key with escaped name" : "java.lang.String", + "/representative/alpha" : "java.lang.String", + "/ratio" : "java.lang.Double", + "/dynamicParamsDef" : "java.util.ArrayList", + "/dynamicParamsDef/0" : "java.util.LinkedHashMap", + "/dynamicParamsDef/0/key" : "java.lang.String", + "/dynamicParamsDef/0/type" : "java.lang.String" + }, + "keyOrder" : { + "/dynamicParamsConfig" : [ "token" ], + "/representative/emptyObject" : [ ], + "/representative/nested/array/2/1" : [ "four" ], + "/representative/nested/object/a/deeper/1" : [ ], + "/representative/nested/object/a" : [ "deeper" ], + "/representative/nested/object" : [ "z", "a" ], + "/representative/nested" : [ "array", "object" ], + "/representative" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ], + "/dynamicParamsDef/0" : [ "key", "type" ], + "" : [ "apiKey", "dynamicParamsConfig", "limit", "representative", "ratio", "dynamicParamsDef" ] + } + }, + "written" : {"apiKey":"encrypted:s3crét \"quoted\"","dynamicParamsConfig":{"token":"encrypted:t0ken"},"limit":3000000001,"representative":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"ratio":1.5,"dynamicParamsDef":[{"key":"token","type":"password"}]} + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-infra/pom.xml b/server/api-service/lowcoder-infra/pom.xml index 94831d7e97..0f04964c99 100644 --- a/server/api-service/lowcoder-infra/pom.xml +++ b/server/api-service/lowcoder-infra/pom.xml @@ -16,10 +16,18 @@ org.lowcoder lowcoder-sdk + + + org.lowcoder + lowcoder-sdk + ${revision} + test-jar + test + - junit - junit + org.junit.jupiter + junit-jupiter test diff --git a/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/conditional/ConditionalOnPropertyNotBlank.java b/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/conditional/ConditionalOnPropertyNotBlank.java deleted file mode 100644 index 3e29c4d41b..0000000000 --- a/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/conditional/ConditionalOnPropertyNotBlank.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.lowcoder.infra.conditional; - -import jakarta.annotation.Nullable; -import jakarta.validation.constraints.NotBlank; -import org.apache.commons.lang3.StringUtils; -import org.lowcoder.infra.conditional.ConditionalOnPropertyNotBlank.OnPropertyNotBlankCondition; -import org.springframework.context.annotation.Condition; -import org.springframework.context.annotation.ConditionContext; -import org.springframework.context.annotation.Conditional; -import org.springframework.core.type.AnnotatedTypeMetadata; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import java.util.Map; - -@Target({ElementType.TYPE, ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -@Conditional(OnPropertyNotBlankCondition.class) -public @interface ConditionalOnPropertyNotBlank { - - String[] value() default {}; - - String prefix() default ""; - - class OnPropertyNotBlankCondition implements Condition { - - @Override - public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { - Map attrs = metadata.getAnnotationAttributes(ConditionalOnPropertyNotBlank.class.getName()); - if (attrs == null) { - return false; - } - String[] properties = (String[]) attrs.get("value"); - String prefix = (String) attrs.get("prefix"); - for (String property : properties) { - if (StringUtils.isBlank(property)) { - return false; - } - String value = context.getEnvironment().getProperty(concat(prefix, property)); - if (StringUtils.isBlank(value)) { - return false; - } - } - return true; - } - - private String concat(@Nullable String prefix, @NotBlank String property) { - if (StringUtils.isBlank(prefix)) { - return property; - } - return prefix + "." + property; - } - } -} diff --git a/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/localcache/CaffeineCacheUtils.java b/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/localcache/CaffeineCacheUtils.java deleted file mode 100644 index 12101a89a5..0000000000 --- a/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/localcache/CaffeineCacheUtils.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.lowcoder.infra.localcache; - -import com.github.benmanes.caffeine.cache.Cache; -import reactor.core.publisher.Mono; - -import java.util.function.Supplier; - -public class CaffeineCacheUtils { - - /** - * @param cache should be created without a valueLoader: - *

    - * Cache cache = Caffeine.newBuilder() - * .maximumSize() - * .expireAfterWrite() - * ... - * .build(); - */ - public static Mono getCacheValueMono(Cache cache, K key, Supplier> valueMonoSupplier) { - V v = cache.getIfPresent(key); - if (v != null) { - return Mono.just(v); - } - return valueMonoSupplier.get() - .doOnNext(value -> cache.put(key, value)); - } -} diff --git a/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/mongo/MongoUpsertHelper.java b/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/mongo/MongoUpsertHelper.java index 070645b1e9..8f55180bda 100644 --- a/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/mongo/MongoUpsertHelper.java +++ b/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/mongo/MongoUpsertHelper.java @@ -2,7 +2,6 @@ import com.mongodb.BasicDBObject; import com.mongodb.DBObject; -import com.mongodb.client.model.DeleteOneModel; import com.mongodb.client.model.UpdateOneModel; import com.mongodb.client.model.UpdateOptions; import org.apache.commons.collections4.CollectionUtils; @@ -161,16 +160,6 @@ public Mono bulkUpdate(Collection bulkWriteResult.getModifiedCount() > 0); } - public Mono bulkRemove(Collection filters, Class tClass) { - if (CollectionUtils.isEmpty(filters)) { - return Mono.empty(); - } - var operations = filters.stream().map(filter -> new DeleteOneModel(filter)).toList(); - return reactiveMongoTemplate.getCollection(reactiveMongoTemplate.getCollectionName(tClass)) - .flatMap(collection -> Mono.from(collection.bulkWrite(operations))) - .map(bulkWriteResult -> bulkWriteResult.getDeletedCount() > 0); - } - public record PartialResourceWithId(T partialResource, String id) { } diff --git a/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/util/RSAUtils.java b/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/util/RSAUtils.java deleted file mode 100644 index 4a012e5f48..0000000000 --- a/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/util/RSAUtils.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.lowcoder.infra.util; - -import lombok.extern.slf4j.Slf4j; - -import javax.crypto.Cipher; -import java.security.Key; -import java.security.KeyFactory; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.spec.PKCS8EncodedKeySpec; -import java.security.spec.X509EncodedKeySpec; - -@Slf4j -public class RSAUtils { - - private static final String algorithm = "RSA"; - - public static byte[] encrypt(Key key, byte[] data) throws Exception { - final Cipher cipher = Cipher.getInstance(algorithm); - cipher.init(Cipher.ENCRYPT_MODE, key); - return cipher.doFinal(data); - } - - public static byte[] decrypt(Key key, byte[] encryptedData) throws Exception { - final Cipher cipher = Cipher.getInstance(algorithm); - cipher.init(Cipher.DECRYPT_MODE, key); - return cipher.doFinal(encryptedData); - } - - public static PublicKey getPublicKey(byte[] encryptedPublicKey) throws Exception { - return KeyFactory.getInstance(algorithm).generatePublic(new X509EncodedKeySpec(encryptedPublicKey)); - } - - public static PrivateKey getPrivateKey(byte[] encryptedPrivateKey) throws Exception { - return KeyFactory.getInstance(algorithm).generatePrivate(new PKCS8EncodedKeySpec(encryptedPrivateKey)); - } - -} \ No newline at end of file diff --git a/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/util/RateLimitHelper.java b/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/util/RateLimitHelper.java deleted file mode 100644 index ba6f76fabb..0000000000 --- a/server/api-service/lowcoder-infra/src/main/java/org/lowcoder/infra/util/RateLimitHelper.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.lowcoder.infra.util; - -import com.google.common.collect.Sets; -import es.moki.ratelimitj.core.limiter.request.RequestLimitRule; - -import java.time.Duration; -import java.util.Set; - -public class RateLimitHelper { - - // 1 request per 10 minutes, per phone - public static final Set OTP_SEND_BY_PHONE_LIMIT_RULES = Sets.newHashSet( - RequestLimitRule.of(Duration.ofMinutes(1), 1)); - - // 100 request per minute, per IP - public static final Set OTP_SEND_BY_IP_LIMIT_RULES = Sets.newHashSet( - RequestLimitRule.of(Duration.ofMinutes(1), 100)); - - // 100 request per minute, per phone - public static final Set OTP_VERIFY_BY_PHONE_LIMIT_RULES = Sets.newHashSet( - RequestLimitRule.of(Duration.ofMinutes(1), 100)); - - public static final String OTP_SEND = "OPT_SEND:"; - public static final String OTP_VERIFTY = "OTP_VERIFTY:"; - - public static String buildLimitKey(String biz, String key) { - return "RateLimit:" + biz + key; - } - - -} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/birelation/BiRelationTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/birelation/BiRelationTest.java new file mode 100644 index 0000000000..e54b30394e --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/birelation/BiRelationTest.java @@ -0,0 +1,48 @@ +package org.lowcoder.infra.birelation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * {@link BiRelation#getCreateTime()}, which {@code GroupMember.from} turns into a member's join time: the epoch + * milliseconds of {@code createdAt} for a stored relation, and 0 for one not stored yet ({@code createdAt} still null), + * where reading the time must not throw. + */ +class BiRelationTest { + + private static final Instant CREATED_AT = Instant.parse("2026-10-05T12:00:00.123Z"); + private static final long NOT_STORED_CREATE_TIME = 0L; + + private static BiRelation relation() { + return BiRelation.builder() + .bizType(BiRelationBizType.GROUP_MEMBER) + .sourceId("group-1") + .targetId("user-1") + .relation("member") + .build(); + } + + @Test + void createTimeIsTheEpochMillisecondsOfCreatedAt() { + BiRelation stored = relation(); + stored.setCreatedAt(CREATED_AT); + + long createTime = stored.getCreateTime(); + + System.out.println("[BiRelationTest] createdAt " + CREATED_AT + " -> createTime " + createTime); + assertThat(createTime).isEqualTo(CREATED_AT.toEpochMilli()); + } + + @Test + void createTimeOfARelationWithoutCreatedAtIsZeroInsteadOfAnException() { + BiRelation notStored = relation(); + + long createTime = notStored.getCreateTime(); + + System.out.println("[BiRelationTest] createdAt " + notStored.getCreatedAt() + " -> createTime " + createTime); + assertThat(notStored.getCreatedAt()).isNull(); + assertThat(createTime).isEqualTo(NOT_STORED_CREATE_TIME); + } +} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/AutoReloadConfImplTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/AutoReloadConfImplTest.java new file mode 100644 index 0000000000..295d1424f1 --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/AutoReloadConfImplTest.java @@ -0,0 +1,101 @@ +package org.lowcoder.infra.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.dynamic.Conf; + +/** {@link AutoReloadConfImpl} resolution rules and the {@code ConfKey} caching of {@link AutoReloadConfigInstanceImpl}. */ +class AutoReloadConfImplTest { + + private static final String KEY = "k"; + private static final int DEFAULT = 5; + + /** The factory's stored strings, mutable so a test can change the "configured" value between reads. */ + private final Map stored = new HashMap<>(); + + private final AutoReloadConfigFactory factory = new AutoReloadConfigFactory() { + @Override + public String getValue(String confKey) { + return stored.get(confKey); + } + }; + + private AutoReloadConfImpl intConf() { + return new AutoReloadConfImpl<>(KEY, DEFAULT, factory, Integer::valueOf); + } + + @Test + void absentValueGivesTheDefault() { + assertThat(intConf().get()).isEqualTo(DEFAULT); + } + + @Test + void configuredValueIsResolvedAndAnUnchangedStringReturnsTheResolvedValueAgain() { + stored.put(KEY, "10"); + AutoReloadConfImpl conf = intConf(); + + assertThat(conf.get()).isEqualTo(10); + assertThat(conf.get()).isEqualTo(10); + } + + @Test + void aChangedStringIsResolvedAgain() { + AutoReloadConfImpl conf = intConf(); + stored.put(KEY, "10"); + assertThat(conf.get()).isEqualTo(10); + + stored.put(KEY, "11"); + assertThat(conf.get()).isEqualTo(11); + + stored.remove(KEY); + assertThat(conf.get()).as("a removed value falls back to the default").isEqualTo(DEFAULT); + } + + @Test + void aResolverThatReturnsNullGivesTheDefault() { + stored.put(KEY, "x"); + AutoReloadConfImpl conf = new AutoReloadConfImpl<>(KEY, DEFAULT, factory, s -> null); + + assertThat(conf.get()).isEqualTo(DEFAULT); + assertThat(conf.get()).isEqualTo(DEFAULT); + } + + /** + * DEFECT pinned (plan section 9 row A1, D-6, fix deferred): a value the resolver cannot parse is answered with the + * default only on the first read. {@code previousStrValue} is stored before the resolver runs, so the second read + * sees an unchanged string and returns {@code currentValue}, which still holds the value resolved from the + * previous string (10). Observed sequence 10, 5, 10. The obvious fix is to record {@code previousStrValue} only + * after a successful resolve (or to reset {@code currentValue} on failure), which makes the sequence 10, 5, 5. + */ + @Test + void aValueThatCannotBeResolvedGivesTheDefaultOnlyOnceAndThenTheStaleEarlierValue() { + AutoReloadConfImpl conf = intConf(); + stored.put(KEY, "10"); + int first = conf.get(); + stored.put(KEY, "bad"); + int second = conf.get(); + int third = conf.get(); + + System.out.println("[AutoReloadConfImplTest] sequence " + first + ", " + second + ", " + third); + assertThat(List.of(first, second, third)).containsExactly(10, DEFAULT, 10); + } + + @Test + void confInstanceCachesTheConfPerKeyAndDefault() throws ReflectiveOperationException { + AutoReloadConfigInstanceImpl instance = new AutoReloadConfigInstanceImpl(); + Field field = AutoReloadConfigInstanceImpl.class.getDeclaredField("autoReloadConfigFactory"); + field.setAccessible(true); + field.set(instance, factory); + + Conf first = instance.ofInteger("a", 1); + + assertThat(instance.ofInteger("a", 1)).isSameAs(first); + assertThat(instance.ofInteger("a", 2)).as("another default is another conf").isNotSameAs(first); + assertThat(instance.ofInteger("b", 1)).as("another key is another conf").isNotSameAs(first); + } +} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImplTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImplTest.java index a9a08559fc..b52ac78a0c 100644 --- a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImplTest.java +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImplTest.java @@ -1,9 +1,9 @@ package org.lowcoder.infra.config; import jakarta.annotation.Nullable; -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.lowcoder.sdk.config.dynamic.Conf; import org.lowcoder.sdk.test.JsonFileReader; import org.lowcoder.sdk.util.JsonUtils; @@ -16,7 +16,7 @@ public class AutoReloadConfigInstanceImplTest { private static final AutoReloadConfigInstanceImpl configInstance = new AutoReloadConfigInstanceImpl(); - @BeforeClass + @BeforeAll public static void init() throws NoSuchFieldException, IllegalAccessException { Map map = JsonFileReader.read(AutoReloadConfigInstanceImplTest.class, "$.configInstance"); @@ -39,51 +39,51 @@ public String getValue(String confKey) { @Test public void ofInteger() { Conf integer = configInstance.ofInteger("integer", 1); - Assert.assertEquals(999L, integer.get().longValue()); + Assertions.assertEquals(999L, integer.get().longValue()); Conf integerError = configInstance.ofInteger("integerError", 1); - Assert.assertEquals(1L, integerError.get().longValue()); + Assertions.assertEquals(1L, integerError.get().longValue()); } @Test public void ofString() { Conf string = configInstance.ofString("string", "1"); - Assert.assertEquals("999", string.get()); + Assertions.assertEquals("999", string.get()); } @Test public void ofJson() { Conf json = configInstance.ofJson("json", Json.class, new Json("1")); - Assert.assertEquals("999", json.get().json()); + Assertions.assertEquals("999", json.get().json()); } @Test public void ofList() { Conf> list = configInstance.ofList("list", List.of(1), Integer.class); - Assert.assertEquals(List.of(999), list.get()); + Assertions.assertEquals(List.of(999), list.get()); } @Test public void ofStringList() { Conf> stringList = configInstance.ofStringList("stringList", List.of("1")); - Assert.assertEquals(List.of("999"), stringList.get()); + Assertions.assertEquals(List.of("999"), stringList.get()); } @Test public void ofIntList() { Conf> list = configInstance.ofIntList("list", List.of(1)); - Assert.assertEquals(List.of(999), list.get()); + Assertions.assertEquals(List.of(999), list.get()); } @Test public void ofLongList() { Conf> list = configInstance.ofLongList("list", List.of(1L)); - Assert.assertEquals(List.of(999L), list.get()); + Assertions.assertEquals(List.of(999L), list.get()); } @Test public void ofMap() { Conf> mapConf = configInstance.ofMap("map", String.class, Integer.class, Map.of("map", 1)); - Assert.assertEquals(Map.of("map", 999), mapConf.get()); + Assertions.assertEquals(Map.of("map", 999), mapConf.get()); } public record Json(String json) { diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/ConfigCenterContractTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/ConfigCenterContractTest.java new file mode 100644 index 0000000000..f2df119534 --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/ConfigCenterContractTest.java @@ -0,0 +1,163 @@ +package org.lowcoder.infra.config; + +import org.junit.jupiter.api.Test; +import org.lowcoder.infra.localcache.ReloadableCache; +import org.lowcoder.sdk.config.dynamic.Conf; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.JavaValueWalker; +import org.lowcoder.sdk.contract.QueryResults; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +/** + * Group {@code config-center} (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T9.4): a server config value stored in MongoDB + * is written to JSON by {@code AutoReloadConfigFactory.getValue} ({@code toJson}) and read back by the + * {@code AutoReloadConfigInstanceImpl} accessor that asks for it ({@code fromJson}, {@code fromJsonList}, + * {@code fromJsonMap}); a value that does not read gives the accessor's default. Every accessor is asked for every + * stored value of {@link #STORED} (integers, a long, whole and fractional doubles, numbers and booleans as strings, + * lists and maps), and what it returns is pinned with the Java class of every value ({@link JavaValueWalker}) and the + * production mapper's text, in {@value #REPORT}. The defaults are sentinels ({@link #DEFAULT_INT} and the like, in + * mutable JDK containers so that no JDK-internal class is pinned), so a value that fell back to its default is visible. + * + *

    Limits: the stored values are the Java values MongoDB hands out, set as the value of the factory's cache, a + * {@code ReloadableCache} made without its builder; the factory's reload from {@code ServerConfigRepository} and the + * cache's reload schedule are not run. {@code ofBoolean}'s default is + * {@code false}, which a stored {@code false} cannot be told apart from. + */ +public class ConfigCenterContractTest { + + static final String REPORT = "config-center/AutoReloadConfig.values.json"; + static final String CACHE_FIELD = "allConfigs"; + static final String FACTORY_FIELD = "autoReloadConfigFactory"; + static final String CACHED_VALUE_FIELD = "cachedValue"; + static final int DEFAULT_INT = -1; + static final long DEFAULT_LONG = -1L; + static final String DEFAULT_STRING = ""; + static final boolean DEFAULT_BOOLEAN = false; + static final Map STORED = stored(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + /** A JSON accessor's target type, as the configs that use {@code ofJson} declare one. */ + public record Setting(String name, Integer limit) { + } + + @BoundarySites({ + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigFactory.java#AutoReloadConfigFactory.getValue#toJson#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofInteger#fromJson#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofString#fromJson#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofBoolean#fromJson#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofJson#fromJson#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofList#fromJsonList#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofStringList#fromJsonList#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofIntList#fromJsonList#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofLongList#fromJsonList#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofMap#fromJsonMap#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.fromJson#fromJson#1", + "lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.fromJsonList#fromJsonList#1"}) + @Test + public void valuesAsRead() throws ReflectiveOperationException { + AutoReloadConfigFactory factory = factory(); + Map>> accessors = new LinkedHashMap<>(); + accessors.put("ofInteger", (instance, key) -> instance.ofInteger(key, DEFAULT_INT)); + accessors.put("ofString", (instance, key) -> instance.ofString(key, DEFAULT_STRING)); + accessors.put("ofBoolean", (instance, key) -> instance.ofBoolean(key, DEFAULT_BOOLEAN)); + accessors.put("ofJson", (instance, key) -> instance.ofJson(key, Setting.class, new Setting(DEFAULT_STRING, DEFAULT_INT))); + accessors.put("ofList", (instance, key) -> instance.ofList(key, new ArrayList<>(List.of(DEFAULT_STRING)), Object.class)); + accessors.put("ofStringList", (instance, key) -> instance.ofStringList(key, new ArrayList<>(List.of(DEFAULT_STRING)))); + accessors.put("ofIntList", (instance, key) -> instance.ofIntList(key, new ArrayList<>(List.of(DEFAULT_INT)))); + accessors.put("ofLongList", (instance, key) -> instance.ofLongList(key, new ArrayList<>(List.of(DEFAULT_LONG)))); + accessors.put("ofMap", (instance, key) -> instance.ofMap(key, String.class, Integer.class, new LinkedHashMap<>(Map.of(DEFAULT_STRING, DEFAULT_INT)))); + Map report = new LinkedHashMap<>(); + Map stored = new LinkedHashMap<>(); + STORED.keySet().forEach(key -> stored.put(key, factory.getValue(key))); + report.put("getValue", stored); + for (Map.Entry>> accessor : accessors.entrySet()) { + AutoReloadConfigInstanceImpl instance = instance(factory); + Map values = new LinkedHashMap<>(); + STORED.keySet().forEach(key -> values.put(key, read(() -> accessor.getValue().apply(instance, key).get()))); + report.put(accessor.getKey(), values); + } + String actual = ConfigBinding.write(report); + System.out.println("[ConfigCenterContractTest] " + STORED.size() + " stored values\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** The value an accessor returns, with its classes, or the error. */ + private static Object read(java.util.function.Supplier get) { + Map outcome = new LinkedHashMap<>(); + try { + Object value = get.get(); + outcome.put("classes", JavaValueWalker.shape(value).get(JavaValueWalker.CLASSES_KEY)); + outcome.put("written", QueryResults.written(value)); + } catch (RuntimeException e) { + outcome.put(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + return outcome; + } + + /** + * A factory whose cache holds {@link #STORED}. The cache is made with its private constructor and its value set, + * because {@code ReloadableCache.Builder.build()} starts a scheduled reload thread, which this test does not need. + */ + private static AutoReloadConfigFactory factory() throws ReflectiveOperationException { + Constructor constructor = ReloadableCache.class.getDeclaredConstructor(); + constructor.setAccessible(true); + Object cache = constructor.newInstance(); + set(cache, CACHED_VALUE_FIELD, STORED); + AutoReloadConfigFactory factory = new AutoReloadConfigFactory(); + set(factory, CACHE_FIELD, cache); + return factory; + } + + private static AutoReloadConfigInstanceImpl instance(AutoReloadConfigFactory factory) { + AutoReloadConfigInstanceImpl instance = new AutoReloadConfigInstanceImpl(); + try { + set(instance, FACTORY_FIELD, factory); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("cannot wire " + FACTORY_FIELD, e); + } + return instance; + } + + private static void set(Object target, String name, Object value) throws ReflectiveOperationException { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + /** Server config values as MongoDB hands them out, by key. */ + private static Map stored() { + Map stored = new LinkedHashMap<>(); + stored.put("int", 999); + stored.put("long", 3_000_000_001L); + stored.put("wholeDouble", 1.0); + stored.put("double", 1.5); + stored.put("intText", "999"); + stored.put("decimalText", "1.0"); + stored.put("bool", true); + stored.put("boolText", "true"); + stored.put("text", "žluť \"q\""); + stored.put("list", Arrays.asList(1, 2.0, "3", null)); + Map map = new LinkedHashMap<>(); + map.put("name", "n"); + map.put("limit", 1.0); + map.put("extra", 3_000_000_001L); + stored.put("map", map); + Map intMap = new LinkedHashMap<>(); + intMap.put("a", 1); + intMap.put("b", 1.0); + intMap.put("c", "2"); + stored.put("intMap", intMap); + return stored; + } +} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/ConfigCenterImplTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/ConfigCenterImplTest.java new file mode 100644 index 0000000000..fcac298ec6 --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/config/ConfigCenterImplTest.java @@ -0,0 +1,100 @@ +package org.lowcoder.infra.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.lowcoder.sdk.config.dynamic.ConfigCenter; +import org.lowcoder.sdk.config.dynamic.ConfigInstance; + +/** + * {@link ConfigCenterImpl}: each group is a view that prefixes {@code .} to the key and forwards to the one + * delegate instance. Not covered by ConfigCenterContractTest, which reads AutoReloadConfigInstanceImpl directly. + */ +class ConfigCenterImplTest { + + private static final String KEY = "some.key"; + private final List calls = new ArrayList<>(); + private ConfigCenterImpl center; + + @BeforeEach + void setUp() throws ReflectiveOperationException { + calls.clear(); + ConfigInstance recorder = (ConfigInstance) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {ConfigInstance.class}, + (proxy, method, args) -> { + calls.add(method.getName() + Arrays.toString(args)); + return null; + }); + center = new ConfigCenterImpl(); + Field delegate = ConfigCenterImpl.class.getDeclaredField("delegateConfigInstance"); + delegate.setAccessible(true); + delegate.set(center, recorder); + } + + static Stream groups() { + return Stream.of( + group("asset", ConfigCenter::asset), group("mysqlPlugin", ConfigCenter::mysqlPlugin), + group("clickHousePlugin", ConfigCenter::clickHousePlugin), group("mongoPlugin", ConfigCenter::mongoPlugin), + group("postgresPlugin", ConfigCenter::postgresPlugin), group("oraclePlugin", ConfigCenter::oraclePlugin), + group("threshold", ConfigCenter::threshold), group("proxy", ConfigCenter::proxy), group("auth", ConfigCenter::auth), + group("datasource", ConfigCenter::datasource), group("deployment", ConfigCenter::deployment), + group("application", ConfigCenter::application)); + } + + private static Arguments group(String name, Function accessor) { + return Arguments.of(name, accessor); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("groups") + void everyGroupPrefixesItsOwnNameAndForwardsEveryAccessorWithItsArguments(String name, Function accessor) { + ConfigInstance instance = accessor.apply(center); + + instance.ofInteger(KEY, 1); + instance.ofString(KEY, "s"); + instance.ofBoolean(KEY, true); + instance.ofJson(KEY, String.class, "j"); + instance.ofList(KEY, List.of("l"), String.class); + instance.ofStringList(KEY, List.of("sl")); + instance.ofIntList(KEY, List.of(2)); + instance.ofLongList(KEY, List.of(3L)); + instance.ofMap(KEY, String.class, Integer.class, Map.of("k", 4)); + + String prefixed = name + "." + KEY; + System.out.println("[ConfigCenterImplTest] " + name + " -> " + calls); + assertThat(calls).containsExactly( + "ofInteger[" + prefixed + ", 1]", + "ofString[" + prefixed + ", s]", + "ofBoolean[" + prefixed + ", true]", + "ofJson[" + prefixed + ", class java.lang.String, j]", + "ofList[" + prefixed + ", [l], class java.lang.String]", + "ofStringList[" + prefixed + ", [sl]]", + "ofIntList[" + prefixed + ", [2]]", + "ofLongList[" + prefixed + ", [3]]", + "ofMap[" + prefixed + ", class java.lang.String, class java.lang.Integer, {k=4}]"); + } + + @Test + void groupsOtherThanClickHouseReturnTheSameViewOnEveryCall() { + assertThat(center.asset()).isSameAs(center.asset()); + assertThat(center.application()).isSameAs(center.application()); + assertThat(center.asset()).isNotSameAs(center.proxy()); + } + + /** Behaviour pin, not a defect: the ClickHouse view is created anew on each call (equal as a record, not identical). */ + @Test + void clickHouseGroupReturnsANewViewOnEveryCall() { + assertThat(center.clickHousePlugin()).isNotSameAs(center.clickHousePlugin()); + } +} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/event/AbstractEventTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/event/AbstractEventTest.java new file mode 100644 index 0000000000..7c6014d15a --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/event/AbstractEventTest.java @@ -0,0 +1,123 @@ +package org.lowcoder.infra.event; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.constants.GlobalContext; +import reactor.util.context.Context; + +/** The audit-event details assembled by {@link AbstractEvent#populateDetails} and the builder's {@code detail}. */ +class AbstractEventTest { + + private static final String ENVIRONMENT_ID = "env-1"; + private static final Map HEADERS = Map.of("X-Request-Id", "r-1"); + + @BeforeEach + void setEnvironment() { + AbstractEvent.setEnvironmentID(ENVIRONMENT_ID); + } + + @AfterEach + void resetEnvironment() { + AbstractEvent.setEnvironmentID(null); + } + + private static Context withHeaders() { + return Context.of(GlobalContext.HEADERS, HEADERS); + } + + @Test + void populateDetailsCopiesTheFieldsOfTheConcreteClassAndSkipsNullNonNullFields() { + ApplicationCommonEvent event = ApplicationCommonEvent.builder() + .orgId("org-1").userId("user-1") + .applicationId("app-1").applicationName("My app") + .folderName("Folder") + .build(); + + event.populateDetails(withHeaders()); + + Map details = event.details(); + System.out.println("[AbstractEventTest] details keys " + details.keySet()); + assertThat(details).containsEntry("applicationId", "app-1").containsEntry("applicationName", "My app").containsEntry("folderName", "Folder"); + assertThat(details).as("a null field without @JsonInclude(NON_NULL) is written as null") + .containsKey("oldApplicationName").containsEntry("oldApplicationName", null); + assertThat(details).as("a null @JsonInclude(NON_NULL) field is left out").doesNotContainKey("folderId"); + assertThat(details).as("fields declared in AbstractEvent itself are not copied").doesNotContainKeys("orgId", "userId", "sessionHash", "ipAddress"); + } + + @Test + void populateDetailsCopiesTheDeclaredDetailFieldOfAQueryExecutionEvent() { + QueryExecutionEvent event = QueryExecutionEvent.builder().detail(Map.of("query", "q1")).build(); + + event.populateDetails(withHeaders()); + + assertThat(event.details()).containsEntry("detail", Map.of("query", "q1")); + } + + @Test + void populateDetailsSetsTheEnvironmentIdAndTheContextHeaders() { + ApplicationCommonEvent event = ApplicationCommonEvent.builder().applicationId("app-1").build(); + + event.populateDetails(withHeaders()); + + assertThat(event.details()).containsEntry("environmentId", ENVIRONMENT_ID).containsEntry("headers", HEADERS); + } + + @Test + void populateDetailsKeepsAnExplicitHeadersDetail() { + ApplicationCommonEvent event = ApplicationCommonEvent.builder().applicationId("app-1").detail("headers", "explicit").build(); + + event.populateDetails(withHeaders()); + + assertThat(event.details()).as("the headers detail the event supplied is not overwritten by the context headers").containsEntry("headers", "explicit"); + } + + @Test + void builderDetailCreatesTheMapLazilyAndAddsTheEnvironmentId() { + SystemCommonEvent event = SystemCommonEvent.builder().detail("a", "1").detail("b", "2").build(); + + assertThat(event.details()).containsEntry("a", "1").containsEntry("b", "2").containsEntry("environmentId", ENVIRONMENT_ID); + } + + @Test + void detailsReadsTheEnvironmentIdAtCallTime() { + SystemCommonEvent event = SystemCommonEvent.builder().detail("a", "1").build(); + + AbstractEvent.setEnvironmentID("env-2"); + + assertThat(event.details()).containsEntry("environmentId", "env-2"); + } + + /** + * Pins today's behaviour, not a defect row: {@code details()} dereferences a map that exists only after a + * {@code detail(...)} call or {@code populateDetails}. Not reachable in this repository: {@code details()} has no + * caller in the main code, every production {@code populateDetails} call (ApiEventFilter.java:74, + * BusinessEventPublisher.java:97 and the other calls in that class) runs in a web request, and the only event + * published without it, SystemCommonEvent (ServerLogServiceImpl.java:49), is built with a detail. + */ + @Test + void detailsOfAnEventWithoutAnyDetailThrowsNullPointerException() { + SystemCommonEvent event = SystemCommonEvent.builder().apiCalls(1).build(); + + assertThatThrownBy(event::details).isInstanceOf(NullPointerException.class); + } + + /** + * Pins today's behaviour, not a defect row: a context without {@code GlobalContext.HEADERS} makes + * {@code populateDetails} throw. Not reachable in this repository: the headers are put into the Reactor context + * only by GlobalContextFilter.java:116, which wraps every web request, and all production calls of + * {@code populateDetails} (ApiEventFilter.java:74, BusinessEventPublisher.java:97 and following) run inside one; + * no scheduled task publishes such an event. + */ + @Test + void populateDetailsWithoutHeadersInTheContextThrowsNoSuchElementException() { + ApplicationCommonEvent event = ApplicationCommonEvent.builder().applicationId("app-1").build(); + + assertThatThrownBy(() -> event.populateDetails(Context.empty())).isInstanceOf(NoSuchElementException.class); + } +} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/js/NodeServerHelperTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/js/NodeServerHelperTest.java new file mode 100644 index 0000000000..e413894f04 --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/js/NodeServerHelperTest.java @@ -0,0 +1,44 @@ +package org.lowcoder.infra.js; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.CommonConfigHelper; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; + +class NodeServerHelperTest { + + private static final String HOST = "http://node-host:6060"; + private static final String EXPECTED_BASE = HOST + "/node-service/api/"; + + private static NodeServerHelper helper(String host, Map overrides) throws ReflectiveOperationException { + CommonConfig config = new CommonConfig(); + config.getJsExecutor().setHost(host); + NodeServerHelper helper = new NodeServerHelper(); + Field field = NodeServerHelper.class.getDeclaredField("commonConfigHelper"); + field.setAccessible(true); + field.set(helper, new CommonConfigHelper(config, new ConfigCenterForTest(overrides))); + return helper; + } + + @ParameterizedTest(name = "{0} -> path {1}") + @CsvSource({"plain, plain", "/leading, leading", "trailing/, trailing", "/both/, both", "a/b, a/b", "//double, /double"}) + void createUriTrimsOneLeadingAndOneTrailingSlash(String path, String expectedPath) throws ReflectiveOperationException { + String uri = helper(HOST, Map.of()).createUri(path).toString(); + + System.out.println("[NodeServerHelperTest] " + path + " -> " + uri); + assertThat(uri).isEqualTo(EXPECTED_BASE + expectedPath); + } + + @Test + void createUriUsesTheConfiguredHostAndAnOverrideOfIt() throws ReflectiveOperationException { + assertThat(helper(HOST, Map.of()).createUri("x").toString()).isEqualTo(HOST + "/node-service/api/x"); + assertThat(helper(HOST, Map.of("js-executor.host", "https://other:1")).createUri("x").toString()) + .isEqualTo("https://other:1/node-service/api/x"); + } +} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/localcache/ReloadableCacheTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/localcache/ReloadableCacheTest.java new file mode 100644 index 0000000000..f40d61106b --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/localcache/ReloadableCacheTest.java @@ -0,0 +1,138 @@ +package org.lowcoder.infra.localcache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.destructor.DestructorUtil; +import reactor.core.publisher.Mono; + +/** + * {@link ReloadableCache}. The load-through and default paths use an instance built reflectively (no scheduler). The + * scheduled reload paths call {@code build()}, which starts a non-daemon executor; those tests use a counting factory + * and bounded polling, and the executors are shut down in {@link #stopExecutors()}. + */ +class ReloadableCacheTest { + + private static final String DEFAULT = "default"; + private static final Duration RELOAD_INTERVAL = Duration.ofMillis(20); + private static final Duration POLL_LIMIT = Duration.ofSeconds(5); + + @AfterAll + static void stopExecutors() { + DestructorUtil.onDestroy(); + } + + private static ReloadableCache unscheduled(ReloadableCache.CacheValueMonoProvider factory) throws ReflectiveOperationException { + Constructor constructor = ReloadableCache.class.getDeclaredConstructor(); + constructor.setAccessible(true); + @SuppressWarnings("unchecked") + ReloadableCache cache = (ReloadableCache) constructor.newInstance(); + Field field = ReloadableCache.class.getDeclaredField("factory"); + field.setAccessible(true); + field.set(cache, factory); + return cache; + } + + private static void awaitTrue(String what, BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + POLL_LIMIT.toNanos(); + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("timed out waiting for " + what); + } + Thread.sleep(5); + } + } + + @Test + void nothingIsCachedUntilTheFirstLoadAndThenTheFactoryIsNotCalledAgain() throws ReflectiveOperationException { + AtomicInteger calls = new AtomicInteger(); + ReloadableCache cache = unscheduled(() -> Mono.just("v" + calls.incrementAndGet())); + + assertThat(cache.getCachedOrDefault(DEFAULT)).isEqualTo(DEFAULT); + assertThat(cache.getMonoValue().block()).isEqualTo("v1"); + assertThat(cache.getMonoValue().block()).isEqualTo("v1"); + assertThat(cache.getCachedOrDefault(DEFAULT)).isEqualTo("v1"); + assertThat(calls).hasValue(1); + } + + @Test + void anEmptyFactoryLeavesNothingCachedSoTheNextCallLoadsAgain() throws ReflectiveOperationException { + AtomicInteger calls = new AtomicInteger(); + ReloadableCache cache = unscheduled(() -> { + calls.incrementAndGet(); + return Mono.empty(); + }); + + assertThat(cache.getMonoValue().blockOptional()).isEmpty(); + assertThat(cache.getMonoValue().blockOptional()).isEmpty(); + assertThat(cache.getCachedOrDefault(DEFAULT)).isEqualTo(DEFAULT); + assertThat(calls).hasValue(2); + } + + @Test + void buildRequiresAFactoryAndAnInterval() { + assertThatThrownBy(() -> ReloadableCache.newBuilder().setInterval(RELOAD_INTERVAL).build()).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> ReloadableCache.newBuilder().setFactory(() -> Mono.just("v")).build()).isInstanceOf(NullPointerException.class); + } + + @Test + void scheduledReloadLoadsTheValueWithoutAnyCaller() throws InterruptedException { + ReloadableCache cache = ReloadableCache.newBuilder() + .setFactory(() -> Mono.just("loaded")).setInterval(RELOAD_INTERVAL).setName("loads").build(); + + awaitTrue("the first scheduled load", () -> "loaded".equals(cache.getCachedOrDefault(DEFAULT))); + } + + @Test + void scheduledReloadKeepsTheOldValueWhenTheFactoryFails() throws InterruptedException { + AtomicInteger calls = new AtomicInteger(); + List seenWhenFailing = Collections.synchronizedList(new ArrayList<>()); + @SuppressWarnings("unchecked") + ReloadableCache[] holder = new ReloadableCache[1]; + holder[0] = ReloadableCache.newBuilder().setFactory(() -> { + int call = calls.incrementAndGet(); + if (call == 1) { + return Mono.just("old"); + } + if (call <= 3) { + seenWhenFailing.add(String.valueOf(holder[0].getCachedOrDefault(DEFAULT))); + return Mono.error(new IllegalStateException("source down " + call)); + } + return Mono.just("old"); + }).setInterval(RELOAD_INTERVAL).setName("keeps").build(); + + awaitTrue("two failed reloads", () -> calls.get() >= 4); + + System.out.println("[ReloadableCacheTest] value while failing " + seenWhenFailing); + assertThat(seenWhenFailing).containsExactly("old", "old"); + } + + /** + * DEFECT pinned (plan section 9 row R1, D-6, fix deferred): the scheduled task assigns the result of + * {@code factory.getValue().block()} unconditionally, so a reload that completes empty writes null and the cache + * falls back to the caller's default, unlike a failing reload which keeps the old value. The obvious fix is to + * assign only a non-null result. + */ + @Test + void scheduledReloadThatReturnsAnEmptyMonoDropsTheCachedValue() throws InterruptedException { + AtomicInteger calls = new AtomicInteger(); + ReloadableCache cache = ReloadableCache.newBuilder() + .setFactory(() -> calls.incrementAndGet() == 1 ? Mono.just("v") : Mono.empty()) + .setInterval(RELOAD_INTERVAL).setName("drops").build(); + + awaitTrue("the first load", () -> calls.get() >= 1); + awaitTrue("the value to be dropped after an empty reload", () -> DEFAULT.equals(cache.getCachedOrDefault(DEFAULT)) && calls.get() >= 2); + + assertThat(cache.getCachedOrDefault(DEFAULT)).isEqualTo(DEFAULT); + } +} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/perf/PerfHelperTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/perf/PerfHelperTest.java new file mode 100644 index 0000000000..cd9765956b --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/perf/PerfHelperTest.java @@ -0,0 +1,202 @@ +package org.lowcoder.infra.perf; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.ToDoubleFunction; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * {@link PerfHelper} with a {@link SimpleMeterRegistry} injected into its private registry field (it is a Spring + * {@code @Autowired} field). The helper is fire-and-forget: a failing metrics backend must never fail the caller. + */ +class PerfHelperTest { + + private static final PerfEvent EVENT = PerfEvent.SERVER_LOG_BATCH_INSERT; + private static final String EVENT_NAME = "server_log_batch_insert"; + private static final List TAGS = List.of(Tag.of("size", "3")); + private static final String TIMER_NAME = "test.timer"; + + private SimpleMeterRegistry registry; + private PerfHelper helper; + private Logger logger; + private ListAppender appender; + + private static void inject(PerfHelper helper, Object registry) { + try { + Field field = PerfHelper.class.getDeclaredField("meterRegistry"); + field.setAccessible(true); + field.set(helper, registry); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + helper = new PerfHelper(); + inject(helper, registry); + logger = (Logger) LoggerFactory.getLogger(PerfHelper.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + } + + private Counter counter(List tags) { + return registry.get(EVENT_NAME).tags(tags).counter(); + } + + @Test + void countIncrementsTheCounterNamedAfterTheEventByOneWithItsTags() { + helper.count(EVENT, TAGS); + assertThat(counter(TAGS).count()).isEqualTo(1.0); + + helper.count(EVENT, TAGS); + assertThat(counter(TAGS).count()).isEqualTo(2.0); + System.out.println("[PerfHelperTest] counter " + EVENT_NAME + " " + TAGS + " = " + counter(TAGS).count()); + } + + @Test + void countWithAnAmountAddsThatAmountAndKeepsTagSetsApart() { + List other = List.of(Tag.of("size", "9")); + + helper.count(EVENT, TAGS, 5); + helper.count(EVENT, TAGS, 2); + helper.count(EVENT, other, 1); + + assertThat(counter(TAGS).count()).isEqualTo(7.0); + assertThat(counter(other).count()).isEqualTo(1.0); + } + + @Test + void countSwallowsRegistryFailuresAndLogsAWarning() { + inject(helper, new SimpleMeterRegistry() { + @Override + public Counter counter(String name, Iterable tags) { + throw new IllegalStateException("registry down"); + } + }); + + helper.count(EVENT, TAGS); + helper.count(EVENT, TAGS, 4); + + assertThat(appender.list).hasSize(2).allSatisfy(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).startsWith("count error.").contains(EVENT_NAME); + }); + System.out.println("[PerfHelperTest] failing registry: " + appender.list.get(0).getFormattedMessage()); + } + + @Test + void gaugeSafelyRegistersAGaugeThatReadsTheGivenObject() { + AtomicInteger source = new AtomicInteger(4); + + helper.gaugeSafely(PerfEvent.CLIENT_BASED_CONNECTION_SIZE, TAGS, source, AtomicInteger::doubleValue); + + Gauge gauge = registry.get("client_based_connection_size").tags(TAGS).gauge(); + assertThat(gauge.value()).isEqualTo(4.0); + source.set(9); + assertThat(gauge.value()).as("the gauge follows the object it was given").isEqualTo(9.0); + } + + @Test + void gaugeSafelyAndGaugeIntSwallowRegistryFailuresAndLogAWarning() { + inject(helper, new SimpleMeterRegistry() { + @Override + public T gauge(String name, Iterable tags, T obj, ToDoubleFunction valueFunction) { + throw new IllegalStateException("registry down"); + } + }); + + helper.gaugeSafely(PerfEvent.CLIENT_BASED_CONNECTION_SIZE, TAGS, new AtomicInteger(1), AtomicInteger::doubleValue); + helper.gaugeInt(PerfEvent.HIKARI_POOL_ACTIVE_CONNECTIONS, TAGS, 1); + + assertThat(appender.list).hasSize(2).allSatisfy(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).startsWith("gauge error."); + }); + } + + /** + * Pins the plan section 9 row "PerfHelper.gaugeInt keeps the AtomicInteger it registered first" (D-6, fix + * deferred): {@code MeterRegistry.gauge(name, tags, number)} returns the object it was given, so the later + * {@code gauge.set(number)} updates a fresh AtomicInteger the registry does not know, while the registry holds + * only a weak reference to the first one. The registered gauge therefore never shows the second value: it stays at + * the first value (3.0) or, once the first AtomicInteger has been garbage collected, reads NaN. Both are accepted + * here because the test must not depend on the garbage collector (and does not call System.gc()). A fix changes + * this test on purpose. + */ + @Test + void gaugeIntRegistersAGaugeButNeverShowsTheSecondValue() { + helper.gaugeInt(PerfEvent.HIKARI_POOL_ACTIVE_CONNECTIONS, TAGS, 3); + Gauge gauge = registry.get("hikari_pool_active_connections").tags(TAGS).gauge(); + + helper.gaugeInt(PerfEvent.HIKARI_POOL_ACTIVE_CONNECTIONS, TAGS, 7); + + double value = gauge.value(); + System.out.println("[PerfHelperTest] gauge after gaugeInt(3) then gaugeInt(7) reads " + value + " (plan section 9 row, pinned: 3.0 or NaN, never 7.0)"); + assertThat(value).isNotEqualTo(7.0); + assertThat(value == 3.0 || Double.isNaN(value)).isTrue(); + assertThat(appender.list).isEmpty(); + } + + @Test + void recordRunnableTimeRunsTheRunnableAndRegistersATimerWithTagsAndTheNinetyFifthPercentile() { + AtomicInteger runs = new AtomicInteger(); + + helper.recordRunnableTime(TIMER_NAME, TAGS, runs::incrementAndGet); + + assertThat(runs).hasValue(1); + Timer timer = registry.get(TIMER_NAME).tags(TAGS).timer(); + assertThat(timer.count()).isEqualTo(1); + assertThat(registry.get(TIMER_NAME + ".percentile").tags(TAGS).tag("phi", "0.95").gauge()).isNotNull(); + } + + @Test + void recordTimeAddsTheDurationToTheTimer() { + helper.recordTime(TIMER_NAME, TAGS, Duration.ofMillis(250)); + helper.recordTime(TIMER_NAME, TAGS, Duration.ofMillis(750)); + + Timer timer = registry.get(TIMER_NAME).tags(TAGS).timer(); + assertThat(timer.count()).isEqualTo(2); + assertThat(timer.totalTime(TimeUnit.MILLISECONDS)).isEqualTo(1000.0); + assertThat(registry.get(TIMER_NAME + ".percentile").tags(TAGS).tag("phi", "0.95").gauge()).isNotNull(); + } + + @Test + void recordCallableTimeReturnsTheResultAndPropagatesTheCallablesException() throws Exception { + String result = helper.recordCallableTime(TIMER_NAME, TAGS, () -> "value"); + assertThat(result).isEqualTo("value"); + + Exception failure = new Exception("callable failed"); + assertThatThrownBy(() -> helper.recordCallableTime(TIMER_NAME, TAGS, () -> { + throw failure; + })).isSameAs(failure); + + assertThat(registry.get(TIMER_NAME).tags(TAGS).timer().count()).as("both runs were timed").isEqualTo(2); + assertThat(registry.get(TIMER_NAME + ".percentile").tags(TAGS).tag("phi", "0.95").gauge()).isNotNull(); + } +} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/serverlog/ServerLogServiceImplTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/serverlog/ServerLogServiceImplTest.java new file mode 100644 index 0000000000..b66084d834 --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/serverlog/ServerLogServiceImplTest.java @@ -0,0 +1,229 @@ +package org.lowcoder.infra.serverlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Supplier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.infra.event.SystemCommonEvent; +import org.lowcoder.infra.perf.PerfHelper; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * {@link ServerLogServiceImpl}: the batched, scheduled write of the recorded server logs and the API usage counts. + * The scheduled method is private and is invoked by reflection, so the result no longer depends on the scheduler. + * The repository is a {@link Proxy} fake that records its calls; the publisher is a lambda. + */ +class ServerLogServiceImplTest { + + private static final String ORG_ID = "org-1"; + private static final long ALL_TIME_COUNT = 42L; + private static final long LAST_MONTH_COUNT = 7L; + private static final long MILLIS_PER_SECOND = 1000L; + + private final List> savedBatches = new ArrayList<>(); + private final List calls = new ArrayList<>(); + private final List publishedEvents = new CopyOnWriteArrayList<>(); + private Supplier> saveOverride; + private Runnable duringSave = () -> { }; + private SimpleMeterRegistry meterRegistry; + private ServerLogServiceImpl service; + + private static ServerLog log(String path) { + return ServerLog.builder().userId("u").orgId(ORG_ID).urlPath(path).httpMethod("GET").createTime(1L).build(); + } + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() throws ReflectiveOperationException { + ServerLogRepository repository = (ServerLogRepository) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[]{ServerLogRepository.class}, (proxy, method, args) -> { + switch (method.getName()) { + case "saveAll" -> { + duringSave.run(); + List batch = new ArrayList<>(); + ((Iterable) args[0]).forEach(batch::add); + savedBatches.add(batch); + return saveOverride != null ? saveOverride.get() : Flux.fromIterable(batch); + } + case "countByOrgId" -> { + calls.add("countByOrgId:" + args[0]); + return Mono.just(ALL_TIME_COUNT); + } + case "countByOrgIdAndCreateTimeBetween" -> { + calls.add(List.of(args[0], args[1], args[2])); + return Mono.just(LAST_MONTH_COUNT); + } + default -> throw new UnsupportedOperationException(method.getName()); + } + }); + meterRegistry = new SimpleMeterRegistry(); + PerfHelper perfHelper = new PerfHelper(); + Field field = PerfHelper.class.getDeclaredField("meterRegistry"); + field.setAccessible(true); + field.set(perfHelper, meterRegistry); + service = new ServerLogServiceImpl(repository, perfHelper, publishedEvents::add); + } + + private void flush() throws ReflectiveOperationException { + Method method = ServerLogServiceImpl.class.getDeclaredMethod("scheduledInsert"); + method.setAccessible(true); + method.invoke(service); + } + + @Test + void flushSavesTheRecordedLogsOnceInOrderAndEmptiesTheQueue() throws Exception { + ServerLog first = log("/a"); + ServerLog second = log("/b"); + ServerLog third = log("/c"); + service.record(first); + service.record(second); + service.record(third); + + flush(); + flush(); + + assertThat(savedBatches).as("one batch, saved once; the second flush finds an empty queue").hasSize(1); + assertThat(savedBatches.get(0)).containsExactly(first, second, third); + System.out.println("[ServerLogServiceImplTest] saved batch of " + savedBatches.get(0).size()); + } + + @Test + void flushPublishesTheCountAndCountsTheBatch() throws Exception { + service.record(log("/a")); + service.record(log("/b")); + service.record(log("/c")); + + flush(); + + assertThat(publishedEvents).hasSize(1); + SystemCommonEvent event = (SystemCommonEvent) publishedEvents.get(0); + assertThat(event.getApiCalls()).isEqualTo(3); + assertThat(event.getEventType().name()).isEqualTo("SERVER_INFO"); + assertThat(event.details()).containsEntry("apiCalls", "3"); + assertThat(meterRegistry.get("server_log_batch_insert").tags(List.of(Tag.of("size", "3"))).counter().count()).isEqualTo(1.0); + System.out.println("[ServerLogServiceImplTest] event apiCalls=" + event.getApiCalls() + " details=" + event.details()); + } + + @Test + void flushOfAnEmptyQueueDoesNothing() throws Exception { + flush(); + + assertThat(savedBatches).isEmpty(); + assertThat(publishedEvents).isEmpty(); + assertThat(meterRegistry.getMeters()).isEmpty(); + } + + @Test + void logsRecordedWhileTheBatchIsBeingSavedAreKeptForTheNextFlush() throws Exception { + ServerLog early = log("/early"); + ServerLog late = log("/late"); + service.record(early); + duringSave = () -> { + duringSave = () -> { }; + service.record(late); + }; + + flush(); + flush(); + + assertThat(savedBatches).hasSize(2); + assertThat(savedBatches.get(0)).as("the batch being saved does not receive the late log").containsExactly(early); + assertThat(savedBatches.get(1)).as("the late log is saved by the next flush, not lost").containsExactly(late); + } + + /** + * Pins the plan section 9 candidate "scheduledInsert subscribes without an error handler" (reproduced by L4-6): the + * queue is swapped before the save, so when the save fails the batch is gone, nothing is published or counted, the + * next flush has nothing to retry, and no exception reaches the scheduler. A fix (re-queue or an error handler) + * changes this test on purpose. + */ + @Test + void aFailingSaveLosesTheBatchWithoutRetryOrEvent() throws Exception { + service.record(log("/lost")); + saveOverride = () -> Flux.error(new IllegalStateException("db down")); + + Throwable thrownToScheduler = null; + try { + flush(); + } catch (Throwable t) { + thrownToScheduler = t; + } + saveOverride = null; + flush(); + + System.out.println("[ServerLogServiceImplTest] failing save: thrown to the scheduler = " + thrownToScheduler); + assertThat(thrownToScheduler).as("the error does not reach the scheduler").isNull(); + assertThat(savedBatches).as("the failed batch is not retried").hasSize(1); + assertThat(publishedEvents).isEmpty(); + assertThat(meterRegistry.getMeters()).isEmpty(); + } + + @Test + void apiUsageCountOfAllTimeUsesTheOrgOnly() { + assertThat(service.getApiUsageCount(ORG_ID, false).block()).isEqualTo(ALL_TIME_COUNT); + assertThat(service.getApiUsageCount(ORG_ID, null).block()).isEqualTo(ALL_TIME_COUNT); + + assertThat(calls).containsExactly("countByOrgId:" + ORG_ID, "countByOrgId:" + ORG_ID); + } + + private static long startOfWindow(LocalDateTime now) { + return now.minusMonths(1).with(TemporalAdjusters.firstDayOfMonth()).truncatedTo(ChronoUnit.SECONDS).toEpochSecond(ZoneOffset.UTC) * MILLIS_PER_SECOND; + } + + private static long endOfWindow(LocalDateTime now) { + return now.minusMonths(1).with(TemporalAdjusters.lastDayOfMonth()).truncatedTo(ChronoUnit.SECONDS).toEpochSecond(ZoneOffset.UTC) * MILLIS_PER_SECOND; + } + + /** + * Pins the plan section 9 row "ServerLogServiceImpl month window" (D-6, fix deferred): the last-month window is + * the first and the last day of the previous month at the CURRENT time of day (in whole seconds, UTC), not from + * 00:00:00 of the first day to the end of the last day, so entries from the first hours of the first day and the + * last hours of the last day are excluded depending on when the question is asked. The expected bounds are computed + * from the clock read before and after the call; the call is repeated if the date changed in between. A fix + * changes this test on purpose. + */ + @Test + void apiUsageCountOfLastMonthUsesThePreviousMonthWindowAtTheCurrentTimeOfDay() { + LocalDateTime before = null; + LocalDateTime after = null; + Long result = null; + for (int attempt = 0; attempt < 3; attempt++) { + calls.clear(); + before = LocalDateTime.now(); + result = service.getApiUsageCount(ORG_ID, true).block(); + after = LocalDateTime.now(); + if (before.toLocalDate().equals(after.toLocalDate())) { + break; + } + } + + assertThat(result).isEqualTo(LAST_MONTH_COUNT); + assertThat(calls).hasSize(1); + @SuppressWarnings("unchecked") + List call = (List) calls.get(0); + long start = (Long) call.get(1); + long end = (Long) call.get(2); + System.out.println("[ServerLogServiceImplTest] window start=" + start + " end=" + end + " asked between " + before + " and " + after + + " (plan section 9 month-window row, pinned)"); + assertThat(call.get(0)).isEqualTo(ORG_ID); + assertThat(start).as("first day of the previous month, at the current time of day") + .isBetween(startOfWindow(before), startOfWindow(after)); + assertThat(end).as("last day of the previous month, at the current time of day") + .isBetween(endOfWindow(before), endOfWindow(after)); + assertThat(start).isLessThan(end); + } +} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/util/LogUtilsTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/util/LogUtilsTest.java new file mode 100644 index 0000000000..6ab5118ea4 --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/util/LogUtilsTest.java @@ -0,0 +1,138 @@ +package org.lowcoder.infra.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +/** The Reactor to MDC bridge of {@link LogUtils}: the log context map travels in the Reactor context. */ +class LogUtilsTest { + + private static final String KEY = "traceId"; + private static final String CALLER_KEY = "callerEntry"; + + @BeforeEach + @AfterEach + void cleanMdc() { + MDC.clear(); + } + + @Test + void putLogContextCreatesTheContextMapWhenAbsent() { + Context result = LogUtils.putLogContext(KEY, "t1").apply(Context.empty()); + + Map map = result.get(LogUtils.CONTEXT_MAP); + assertThat(map).containsExactly(Map.entry(KEY, "t1")); + } + + @Test + void putLogContextMutatesTheExistingMapAndReturnsTheSameContext() { + Map existing = new HashMap<>(Map.of("a", "1")); + Context context = Context.of(LogUtils.CONTEXT_MAP, existing); + + Context result = LogUtils.putLogContext(KEY, "t1").apply(context); + + assertThat(result).isSameAs(context); + assertThat(existing).containsEntry("a", "1").containsEntry(KEY, "t1"); + } + + @Test + void logOnNextRunsTheConsumerWithTheContextMapAsMdcAndClearsItAfterwards() { + List seen = new ArrayList<>(); + + String value = Mono.just("x") + .doOnEach(LogUtils.logOnNext(v -> seen.add(v + ":" + MDC.get(KEY)))) + .contextWrite(LogUtils.putLogContext(KEY, "t1")) + .block(); + + assertThat(value).isEqualTo("x"); + assertThat(seen).containsExactly("x:t1"); + assertThat(MDC.get(KEY)).as("the MDC is cleared after the consumer").isNull(); + System.out.println("[LogUtilsTest] consumer saw " + seen); + } + + @Test + void logOnNextClearsTheMdcAlsoWhenTheConsumerThrows() { + Mono mono = Mono.just("x") + .doOnEach(LogUtils.logOnNext(v -> { + throw new IllegalStateException("log failed: " + MDC.get(KEY)); + })) + .contextWrite(LogUtils.putLogContext(KEY, "t1")); + + assertThatThrownBy(mono::block).hasMessageContaining("log failed: t1"); + assertThat(MDC.get(KEY)).isNull(); + } + + @Test + void logOnNextWithoutAContextMapRunsTheConsumerWithoutTouchingTheMdc() { + List seen = new ArrayList<>(); + MDC.put(CALLER_KEY, "kept"); + + Mono.just("x").doOnEach(LogUtils.logOnNext(v -> seen.add(String.valueOf(MDC.get(KEY))))).block(); + + assertThat(seen).containsExactly("null"); + assertThat(MDC.get(CALLER_KEY)).as("without a context map nothing is cleared").isEqualTo("kept"); + } + + @Test + void logOnNextIgnoresSignalsThatAreNotOnNext() { + List seen = new ArrayList<>(); + + Mono.empty().doOnEach(LogUtils.logOnNext(seen::add)).block(); + Mono.error(new IllegalStateException("boom")).doOnEach(LogUtils.logOnNext(seen::add)).onErrorResume(e -> Mono.empty()).block(); + + assertThat(seen).isEmpty(); + } + + /** Documents today's behaviour: the MDC is cleared completely, so entries the caller had set are lost too. */ + @Test + void clearingTheMdcAfterwardsAlsoWipesTheEntriesTheCallerHadSet() { + MDC.put(CALLER_KEY, "set before"); + + Mono.just("x").doOnEach(LogUtils.logOnNext(v -> { })).contextWrite(LogUtils.putLogContext(KEY, "t1")).block(); + + assertThat(MDC.get(CALLER_KEY)).isNull(); + } + + @Test + void logOnErrorRunsTheConsumerForErrorSignalsWithTheContextMapOnly() { + List seen = new ArrayList<>(); + IllegalStateException failure = new IllegalStateException("boom"); + + Mono.error(failure) + .doOnEach(LogUtils.logOnError(t -> seen.add(t.getMessage() + ":" + MDC.get(KEY)))) + .contextWrite(LogUtils.putLogContext(KEY, "t1")) + .onErrorResume(e -> Mono.empty()) + .block(); + Mono.just("fine").doOnEach(LogUtils.logOnError(t -> seen.add("unexpected"))).block(); + + assertThat(seen).containsExactly("boom:t1"); + assertThat(MDC.get(KEY)).isNull(); + } + + @Test + void directLogOnErrorSetsTheMdcOnlyWhenTheContextHasAMapAndClearsItEvenWhenTheConsumerThrows() { + List seen = new ArrayList<>(); + Throwable failure = new IllegalStateException("boom"); + + LogUtils.logOnError(failure, t -> seen.add("with:" + MDC.get(KEY)), Context.of(LogUtils.CONTEXT_MAP, Map.of(KEY, "t2"))); + LogUtils.logOnError(failure, t -> seen.add("without:" + MDC.get(KEY)), Context.empty()); + + assertThat(seen).containsExactly("with:t2", "without:null"); + assertThat(MDC.get(KEY)).isNull(); + + assertThatThrownBy(() -> LogUtils.logOnError(failure, t -> { + throw new IllegalArgumentException("consumer failed"); + }, Context.of(LogUtils.CONTEXT_MAP, Map.of(KEY, "t3")))).hasMessage("consumer failed"); + assertThat(MDC.get(KEY)).isNull(); + } +} diff --git a/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/util/ReactiveUtilsTest.java b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/util/ReactiveUtilsTest.java new file mode 100644 index 0000000000..ffe01f8fd8 --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/java/org/lowcoder/infra/util/ReactiveUtilsTest.java @@ -0,0 +1,71 @@ +package org.lowcoder.infra.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuple3; +import reactor.util.function.Tuple4; +import reactor.util.function.Tuples; + +/** {@link MonoUtils}, {@link TupleUtils} and {@link FluxHelper}. */ +class ReactiveUtilsTest { + + @Test + void emptyMonoIfEmptyListTurnsAnEmptyListIntoAnEmptyMonoAndKeepsOtherLists() { + assertThat(MonoUtils.emptyMonoIfEmptyList(Mono.just(List.of())).blockOptional()).isEmpty(); + assertThat(MonoUtils.emptyMonoIfEmptyList(Mono.just(List.of("a"))).block()).containsExactly("a"); + assertThat(MonoUtils.emptyMonoIfEmptyList(Mono.>empty()).blockOptional()).isEmpty(); + } + + @Test + void emptyIfNullWrapsAValueAndMapsNullToEmpty() { + assertThat(MonoUtils.emptyIfNull("v").block()).isEqualTo("v"); + assertThat(MonoUtils.emptyIfNull(null).blockOptional()).isEmpty(); + } + + @Test + void tupleMergeAddsTheLoneValueFirstOrLastForEveryArity() { + Tuple2 t2 = Tuples.of("a", "b"); + Tuple3 t3 = Tuples.of("a", "b", "c"); + Tuple4 t4 = Tuples.of("a", "b", "c", "d"); + + assertThat(TupleUtils.merge("u", t2).toArray()).containsExactly("u", "a", "b"); + assertThat(TupleUtils.merge(t2, "u").toArray()).containsExactly("a", "b", "u"); + assertThat(TupleUtils.merge("u", t3).toArray()).containsExactly("u", "a", "b", "c"); + assertThat(TupleUtils.merge(t3, "u").toArray()).containsExactly("a", "b", "c", "u"); + assertThat(TupleUtils.merge("u", t4).toArray()).containsExactly("u", "a", "b", "c", "d"); + assertThat(TupleUtils.merge(t4, "u").toArray()).containsExactly("a", "b", "c", "d", "u"); + } + + @Test + void getAllPageByPageConcatenatesPagesUntilAnEmptyPageAndFetchesEachPageOnce() { + List fetchedPages = new ArrayList<>(); + FluxHelper.PageFetcher fetcher = pageable -> { + fetchedPages.add(pageable.getPageNumber()); + return switch (pageable.getPageNumber()) { + case 0 -> Flux.just("a", "b"); + case 1 -> Flux.just("c"); + default -> Flux.empty(); + }; + }; + Pageable first = PageRequest.of(0, 2); + + List all = FluxHelper.getAllPageByPage(fetcher, first).collectList().block(); + + System.out.println("[ReactiveUtilsTest] items " + all + " pages fetched " + fetchedPages); + assertThat(all).containsExactly("a", "b", "c"); + assertThat(fetchedPages).containsExactly(0, 1, 2); + } + + @Test + void getAllPageByPageOfAnEmptyFirstPageIsEmpty() { + assertThat(FluxHelper.getAllPageByPage(p -> Flux.empty(), PageRequest.of(0, 5)).collectList().block()).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-infra/src/test/resources/json-contract/config-center/AutoReloadConfig.values.json b/server/api-service/lowcoder-infra/src/test/resources/json-contract/config-center/AutoReloadConfig.values.json new file mode 100644 index 0000000000..5a2b20312c --- /dev/null +++ b/server/api-service/lowcoder-infra/src/test/resources/json-contract/config-center/AutoReloadConfig.values.json @@ -0,0 +1,756 @@ +{ + "getValue" : { + "int" : "999", + "long" : "3000000001", + "wholeDouble" : "1.0", + "double" : "1.5", + "intText" : "\"999\"", + "decimalText" : "\"1.0\"", + "bool" : "true", + "boolText" : "\"true\"", + "text" : "\"žluť \\\"q\\\"\"", + "list" : "[1,2.0,\"3\",null]", + "map" : "{\"name\":\"n\",\"limit\":1.0,\"extra\":3000000001}", + "intMap" : "{\"a\":1,\"b\":1.0,\"c\":\"2\"}" + }, + "ofInteger" : { + "int" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : 999 + }, + "long" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : -1 + }, + "wholeDouble" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : 1 + }, + "double" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : 1 + }, + "intText" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : 999 + }, + "decimalText" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : -1 + }, + "bool" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : -1 + }, + "boolText" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : -1 + }, + "text" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : -1 + }, + "list" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : -1 + }, + "map" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : -1 + }, + "intMap" : { + "classes" : { + "" : "java.lang.Integer" + }, + "written" : -1 + } + }, + "ofString" : { + "int" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "999" + }, + "long" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "3000000001" + }, + "wholeDouble" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "1.0" + }, + "double" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "1.5" + }, + "intText" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "999" + }, + "decimalText" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "1.0" + }, + "bool" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "true" + }, + "boolText" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "true" + }, + "text" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "žluť \"q\"" + }, + "list" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "" + }, + "map" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "" + }, + "intMap" : { + "classes" : { + "" : "java.lang.String" + }, + "written" : "" + } + }, + "ofBoolean" : { + "int" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : true + }, + "long" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : true + }, + "wholeDouble" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : false + }, + "double" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : false + }, + "intText" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : false + }, + "decimalText" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : false + }, + "bool" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : true + }, + "boolText" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : true + }, + "text" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : false + }, + "list" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : false + }, + "map" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : false + }, + "intMap" : { + "classes" : { + "" : "java.lang.Boolean" + }, + "written" : false + } + }, + "ofJson" : { + "int" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"","limit":-1} + }, + "long" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"","limit":-1} + }, + "wholeDouble" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"","limit":-1} + }, + "double" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"","limit":-1} + }, + "intText" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"","limit":-1} + }, + "decimalText" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"","limit":-1} + }, + "bool" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"","limit":-1} + }, + "boolText" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"","limit":-1} + }, + "text" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"","limit":-1} + }, + "list" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"","limit":-1} + }, + "map" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":"n","limit":1} + }, + "intMap" : { + "classes" : { + "" : "org.lowcoder.infra.config.ConfigCenterContractTest$Setting" + }, + "written" : {"name":null,"limit":null} + } + }, + "ofList" : { + "int" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "long" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "wholeDouble" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "double" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "intText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "decimalText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "bool" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "boolText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "text" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "list" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer", + "/1" : "java.lang.Double", + "/2" : "java.lang.String", + "/3" : "null" + }, + "written" : [1,2.0,"3",null] + }, + "map" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "intMap" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + } + }, + "ofStringList" : { + "int" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "long" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "wholeDouble" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "double" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "intText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "decimalText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "bool" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "boolText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "text" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "list" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String", + "/1" : "java.lang.String", + "/2" : "java.lang.String", + "/3" : "null" + }, + "written" : ["1","2.0","3",null] + }, + "map" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + }, + "intMap" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String" + }, + "written" : [""] + } + }, + "ofIntList" : { + "int" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + }, + "long" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + }, + "wholeDouble" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + }, + "double" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + }, + "intText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + }, + "decimalText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + }, + "bool" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + }, + "boolText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + }, + "text" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + }, + "list" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer", + "/1" : "java.lang.Integer", + "/2" : "java.lang.Integer", + "/3" : "null" + }, + "written" : [1,2,3,null] + }, + "map" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + }, + "intMap" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Integer" + }, + "written" : [-1] + } + }, + "ofLongList" : { + "int" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + }, + "long" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + }, + "wholeDouble" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + }, + "double" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + }, + "intText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + }, + "decimalText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + }, + "bool" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + }, + "boolText" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + }, + "text" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + }, + "list" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long", + "/1" : "java.lang.Long", + "/2" : "java.lang.Long", + "/3" : "null" + }, + "written" : [1,2,3,null] + }, + "map" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + }, + "intMap" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.Long" + }, + "written" : [-1] + } + }, + "ofMap" : { + "int" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "long" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "wholeDouble" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "double" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "intText" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "decimalText" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "bool" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "boolText" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "text" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "list" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "map" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/" : "java.lang.Integer" + }, + "written" : {"":-1} + }, + "intMap" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/a" : "java.lang.Integer", + "/b" : "java.lang.Integer", + "/c" : "java.lang.Integer" + }, + "written" : {"a":1,"b":1,"c":2} + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/pom.xml b/server/api-service/lowcoder-plugins/clickHousePlugin/pom.xml index 0f4b417d37..6942d0d5e3 100644 --- a/server/api-service/lowcoder-plugins/clickHousePlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/pom.xml @@ -57,8 +57,7 @@ org.testcontainers - testcontainers - 1.18.0 + testcontainers-clickhouse test diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseBindContractTest.java b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseBindContractTest.java new file mode 100644 index 0000000000..187c2add6e --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseBindContractTest.java @@ -0,0 +1,63 @@ +package org.lowcoder.plugin.clickhouse; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.UpdateCount; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.RenderValues; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Group {@code downstream-render}, its {@code clickHousePlugin} row (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.5): + * {@code ClickHouseQueryExecutor.executeQuery} runs {@value #QUERY} as a prepared statement with each value of + * {@link RenderValues} as {@code v}, and {@code bindPreparedStatementParams} binds it; a list or a map is bound as the + * text the production mapper writes for it ({@code toJson}). The connection ({@link FakeJdbc}) records every + * {@code set...} call, and the exact text of those calls is pinned, one line per value + * ({@link GoldenJson#assertText}), in {@value #FIXTURE}. + * + *

    Limits: the calls are recorded, not sent to the ClickHouse driver. + */ +public class ClickHouseBindContractTest { + + static final String FIXTURE = "downstream-render/ClickHouseQueryExecutor.binds.txt"; + static final String QUERY = "alter table items update v = {{v}} where id = 1"; + static final String VALUE_PARAM = "v"; + static final String SEPARATOR = "\t"; + static final String NEWLINE = "\n"; + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final ClickHouseQueryExecutor executor = new ClickHouseQueryExecutor(new ConfigCenterForTest()); + + @BoundarySites("lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/ClickHouseQueryExecutor.java#ClickHouseQueryExecutor.bindPreparedStatementParams#toJson#1") + @Test + public void bindsAsSent() { + StringBuilder text = new StringBuilder(); + RenderValues.values().forEach((name, value) -> text.append(name).append(SEPARATOR).append(binds(value)).append(NEWLINE)); + System.out.println("[ClickHouseBindContractTest] " + FIXTURE + "\n" + text); + GOLDEN.assertText(FIXTURE, text.toString()); + } + + /** The {@code set...} calls the statement got for {@code value}, or the error in their place. */ + private String binds(Object value) { + Map params = new HashMap<>(); + params.put(VALUE_PARAM, value); + SqlBasedQueryExecutionContext context = SqlBasedQueryExecutionContext.builder().query(QUERY).requestParams(params).build(); + List binds = new ArrayList<>(); + try { + executor.executeQuery(ClickHouseResultContractTest.dataSource(FakeJdbc.connection(List.of(new UpdateCount(1, null)), binds)), context) + .block(ClickHouseResultContractTest.TIMEOUT); + return String.join(SEPARATOR, binds); + } catch (RuntimeException e) { + return "ERROR " + ConfigBinding.errorText(e); + } + } +} diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseConfigBindingContractTest.java new file mode 100644 index 0000000000..a484cb6df5 --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseConfigBindingContractTest.java @@ -0,0 +1,41 @@ +package org.lowcoder.plugin.clickhouse; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig; +import org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code clickHousePlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task + * T8.1), through the real entry points: {@code ClickHouseDatasourceConfig.buildFrom}, which {@code + * ClickHouseConnector.resolveConfig} delegates to, and {@code ClickHouseQueryConfig.from}. {@code ConfigBinding} + * applies each entry point to every form of the input fixture (canonical names, alias or getter-derived names, + * scalars as strings, missing and empty values) and pins the bound state, the written views and the errors in the + * report fixture. + */ +public class ClickHouseConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseDatasourceConfig.java#ClickHouseDatasourceConfig.buildFrom#toJson#1", + "lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseDatasourceConfig.java#ClickHouseDatasourceConfig.buildFrom#fromJson#1"}) + @Test + public void datasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/ClickHouseDatasourceConfig.input.json", "config-binding/ClickHouseDatasourceConfig.json", + Map.of("buildFrom", ClickHouseDatasourceConfig::buildFrom)); + } + + @BoundarySites({ + "lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseQueryConfig.java#ClickHouseQueryConfig.from#toJson#1", + "lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseQueryConfig.java#ClickHouseQueryConfig.from#fromJson#1"}) + @Test + public void queryConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/ClickHouseQueryConfig.input.json", "config-binding/ClickHouseQueryConfig.json", + Map.of("from", ClickHouseQueryConfig::from)); + } +} diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseConnectorValidationTest.java b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseConnectorValidationTest.java new file mode 100644 index 0000000000..91a91f836b --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseConnectorValidationTest.java @@ -0,0 +1,114 @@ +package org.lowcoder.plugin.clickhouse; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_ARGUMENT_ERROR; + +/** + * Unit CH-1 (task L5-3), no server: {@link ClickHouseConnector#validateConfig} and + * {@link ClickHouseQueryExecutor#buildQueryExecutionContext}. + */ +public class ClickHouseConnectorValidationTest { + + static final String VALID_HOST = "ch.example.org"; + static final String VALID_DATABASE = "app"; + static final String HOST_EMPTY = "HOST_EMPTY"; + static final String HOST_COLON = "HOST_WITH_COLON"; + static final String INVALID_HOST = "INVALID_HOST"; + static final String DATABASE_EMPTY = "DATABASE_NAME_EMPTY"; + + private final ClickHouseConnector connector = new ClickHouseConnector(new ConfigCenterForTest()); + private final ClickHouseQueryExecutor executor = new ClickHouseQueryExecutor(new ConfigCenterForTest()); + + private static ClickHouseDatasourceConfig datasource(String host, String database, boolean enableTurnOffPreparedStatement) { + return ClickHouseDatasourceConfig.builder().host(host).database(database).enableTurnOffPreparedStatement(enableTurnOffPreparedStatement).build(); + } + + private Set invalids(String host, String database) { + return connector.validateConfig(datasource(host, database, false)); + } + + private SqlBasedQueryExecutionContext build(boolean datasourceSwitch, Map queryConfig) { + return executor.buildQueryExecutionContext(datasource(VALID_HOST, VALID_DATABASE, datasourceSwitch), queryConfig, Map.of("x", 1), null); + } + + @Test + public void validateConfigNamesEachInvalidField() { + assertEquals(Set.of(), invalids(VALID_HOST, VALID_DATABASE)); + assertEquals(Set.of(HOST_COLON), invalids("ch:8123", VALID_DATABASE)); + assertEquals(Set.of(HOST_COLON), invalids("ch/db", VALID_DATABASE)); + for (String loopback : new String[] {"localhost", "LOCALHOST", "127.0.0.1"}) { + assertEquals(Set.of(INVALID_HOST), invalids(loopback, VALID_DATABASE), loopback); + } + for (String notLoopback : new String[] {"127.0.0.2", "localhost.example.org"}) { + assertEquals(Set.of(), invalids(notLoopback, VALID_DATABASE), notLoopback); + } + assertEquals(Set.of(DATABASE_EMPTY), invalids(VALID_HOST, " ")); + assertEquals(Set.of(HOST_EMPTY), invalids(" ", VALID_DATABASE)); + System.out.println("[ClickHouseConnectorValidationTest] validateConfig: one message key per invalid field"); + } + + /** + * Defect D3 (a null host adds HOST_EMPTY and then {@code host.contains} throws a NullPointerException) does not + * reproduce: the config's {@code getHost()} trims null to "", so a null host gives only HOST_EMPTY. + */ + @Test + public void nullHostAndDatabaseGiveOnlyTheEmptyMessages_d3DoesNotApply() { + Set invalids = invalids(null, null); + assertEquals(Set.of(HOST_EMPTY, DATABASE_EMPTY), invalids); + System.out.println("[ClickHouseConnectorValidationTest] null host and database: " + invalids + " (no NullPointerException)"); + } + + @Test + public void buildContextRemovesCommentsAndKeepsTheParameters() { + SqlBasedQueryExecutionContext context = build(false, Map.of("sql", "select 1 -- {{x}}\n from t")); + System.out.println("[ClickHouseConnectorValidationTest] query after comment removal: [" + context.getQuery() + "]"); + assertFalse(context.getQuery().contains("{{")); + assertTrue(context.getQuery().contains("from t")); + assertEquals(Map.of("x", 1), context.getRequestParams()); + assertFalse(context.isDisablePreparedStatement()); + } + + @Test + public void blankOrCommentOnlySqlIsSqlEmpty() { + for (String sql : new String[] {"", " ", "-- only a comment {{x}}"}) { + PluginException thrown = assertThrows(PluginException.class, () -> build(false, Map.of("sql", sql))); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals("SQL_EMPTY", thrown.getMessageKey()); + } + } + + @Test + public void disablePreparedStatementNeedsTheDatasourceSwitch() { + Map disabled = Map.of("sql", "select {{x}}", "disablePreparedStatement", true); + PluginException thrown = assertThrows(PluginException.class, () -> build(false, disabled)); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals("CLICKHOUSE_PS_ERROR", thrown.getMessageKey()); + assertTrue(build(true, disabled).isDisablePreparedStatement(), "both set: prepared statements are off"); + assertFalse(build(true, Map.of("sql", "select {{x}}")).isDisablePreparedStatement(), "the datasource switch alone changes nothing"); + System.out.println("[ClickHouseConnectorValidationTest] query flag without the datasource switch: " + thrown.getMessageKey()); + } + + /** + * Pins the plan section 9 row on {@code SqlQueryConfig.getSql()} (same shape, also for {@code ClickHouseQueryConfig}; D-6: + * fix deferred): a query config without a {@code sql} key throws a NullPointerException, not SQL_EMPTY. A fix changes + * this test on purpose. + */ + @Test + public void configWithoutSqlKeyIsANullPointerExceptionNotSqlEmpty() { + NullPointerException thrown = assertThrows(NullPointerException.class, () -> build(false, Map.of("disablePreparedStatement", false))); + assertTrue(thrown.getMessage().contains("sql"), thrown.getMessage()); + System.out.println("[ClickHouseConnectorValidationTest] no sql key: " + thrown.getMessage()); + } +} diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseContainerSupport.java b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseContainerSupport.java new file mode 100644 index 0000000000..b121c51afc --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseContainerSupport.java @@ -0,0 +1,138 @@ +package org.lowcoder.plugin.clickhouse; + +import com.zaxxer.hikari.HikariDataSource; +import org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.ContainerImages; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The shared ClickHouse container of the L5-3 tests (one per test JVM, started on first use, removed by Testcontainers' + * reaper when the JVM exits; the port is the one Docker assigns) and the helpers around it. A generic container, not the + * module's ClickHouseContainer: that one copies files into the container, which needs commons-lang3 3.18, and this module's + * classpath resolves 3.13.0 (see log-L5.md). No Docker guard on purpose (owner decision D-7). Not a test. + */ +final class ClickHouseContainerSupport { + + static final String DATABASE = "app"; + static final String USER = "appuser"; + static final String PASSWORD = "apppass"; + static final int HTTP_PORT = 8123; + private static final Duration START_TIMEOUT = Duration.ofMinutes(2); + + private static final GenericContainer CLICKHOUSE = new GenericContainer<>(DockerImageName.parse(ContainerImages.CLICKHOUSE_24_8)) + .withEnv("CLICKHOUSE_DB", DATABASE).withEnv("CLICKHOUSE_USER", USER).withEnv("CLICKHOUSE_PASSWORD", PASSWORD) + .withExposedPorts(HTTP_PORT) + .waitingFor(Wait.forHttp("/ping").forStatusCode(200).withStartupTimeout(START_TIMEOUT)); + + static { + long start = System.nanoTime(); + CLICKHOUSE.start(); + System.out.println("[ClickHouseContainerSupport] " + ContainerImages.CLICKHOUSE_24_8 + " started in " + + (System.nanoTime() - start) / 1_000_000 + " ms on " + CLICKHOUSE.getHost() + ":" + CLICKHOUSE.getMappedPort(HTTP_PORT)); + } + + private ClickHouseContainerSupport() { + } + + static String host() { + return CLICKHOUSE.getHost(); + } + + static int port() { + return CLICKHOUSE.getMappedPort(HTTP_PORT); + } + + static ClickHouseDatasourceConfig config(String database, String password, boolean ssl, boolean readonly, boolean enableTurnOffPreparedStatement, + String host, int port) { + return ClickHouseDatasourceConfig.builder().database(database).username(USER).password(password).host(host).port((long) port) + .usingSsl(ssl).isReadonly(readonly).enableTurnOffPreparedStatement(enableTurnOffPreparedStatement).build(); + } + + static ClickHouseDatasourceConfig config() { + return config(DATABASE, PASSWORD, false, false, false, host(), port()); + } + + static ClickHouseDatasourceConfig config(String database) { + return config(database, PASSWORD, false, false, false, host(), port()); + } + + static ClickHouseConnector connector() { + return new ClickHouseConnector(new ConfigCenterForTest()); + } + + static ClickHouseQueryExecutor executor() { + return new ClickHouseQueryExecutor(new ConfigCenterForTest()); + } + + static HikariDataSource connect(ClickHouseDatasourceConfig config) { + return connector().createConnection(config).block(); + } + + /** Runs a query config through the executor as the server does: build the context, execute. */ + static QueryExecutionResult run(HikariDataSource pool, ClickHouseDatasourceConfig config, String sql, boolean disablePreparedStatement, + Map params) { + ClickHouseQueryExecutor executor = executor(); + Map queryConfig = new LinkedHashMap<>(); + queryConfig.put("sql", sql); + queryConfig.put("disablePreparedStatement", disablePreparedStatement); + SqlBasedQueryExecutionContext context = executor.buildQueryExecutionContext(config, queryConfig, params, null); + return executor.executeQuery(pool, context).block(); + } + + static QueryExecutionResult run(HikariDataSource pool, ClickHouseDatasourceConfig config, String sql, Map params) { + return run(pool, config, sql, false, params); + } + + /** A plain JDBC connection of the application user, to the database {@code database} (not the code under test). */ + static Connection jdbc(String database) { + try { + return DriverManager.getConnection("jdbc:clickhouse:http://" + host() + ":" + port() + "/" + database, USER, PASSWORD); + } catch (SQLException e) { + throw new IllegalStateException("cannot connect to " + database, e); + } + } + + static void execute(Connection connection, String... statements) { + try (Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } catch (SQLException e) { + throw new IllegalStateException("statement failed", e); + } + } + + static List> rows(Connection connection, String sql) { + List> rows = new ArrayList<>(); + try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) { + ResultSetMetaData metaData = resultSet.getMetaData(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= metaData.getColumnCount(); i++) { + row.put(metaData.getColumnLabel(i), resultSet.getObject(i)); + } + rows.add(row); + } + } catch (SQLException e) { + throw new IllegalStateException("read failed: " + sql, e); + } + return rows; + } +} diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseDatabaseTest.java b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseDatabaseTest.java new file mode 100644 index 0000000000..495a5d3c94 --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseDatabaseTest.java @@ -0,0 +1,293 @@ +package org.lowcoder.plugin.clickhouse; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.models.QueryExecutionResult; + +import java.net.ServerSocket; +import java.sql.Connection; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.DATABASE; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.PASSWORD; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.config; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.connect; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.connector; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.execute; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.executor; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.host; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.jdbc; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.port; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.rows; +import static org.lowcoder.plugin.clickhouse.ClickHouseContainerSupport.run; +import static org.lowcoder.sdk.exception.PluginCommonError.CONNECTION_ERROR; +import static org.lowcoder.sdk.exception.PluginCommonError.DATASOURCE_ARGUMENT_ERROR; +import static org.lowcoder.sdk.exception.PluginCommonError.DATASOURCE_GET_STRUCTURE_ERROR; +import static org.lowcoder.sdk.exception.PluginCommonError.PREPARED_STATEMENT_BIND_PARAMETERS_ERROR; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; + +/** + * Unit CH-2 (task L5-3): {@code ClickHouseConnector}, {@code ClickHouseQueryExecutor} and {@code ClickHouseStructureParser} + * against a real ClickHouse (the image pinned in {@code ContainerImages.CLICKHOUSE_24_8}, the module's + * {@code clickhouse-jdbc 0.3.2-patch11} driver over HTTP). Each test uses its own tables, or its own database for the + * structure tests. + */ +public class ClickHouseDatabaseTest { + + static final String WRONG_PASSWORD = "not-the-password"; + static final String CLOSED_MESSAGE = "hikari datasource closed."; + static final int FAILING_RUNS = 3; + + private static Object column(QueryExecutionResult result, int row, String name) { + @SuppressWarnings("unchecked") + List> rows = (List>) result.getData(); + return rows.get(row).get(name); + } + + private static void close(HikariDataSource pool) { + connector().destroyConnection(pool).block(); + } + + // ---- connector + + @Test + public void createConnectionAndTestConnectionSucceed() { + ClickHouseDatasourceConfig config = config(); + HikariDataSource pool = connect(config); + try { + assertTrue(pool.isRunning()); + assertEquals("com.clickhouse.jdbc.ClickHouseDriver", pool.getDriverClassName()); + assertEquals("jdbc:clickhouse:http://" + host() + ":" + port() + "/" + DATABASE, pool.getJdbcUrl()); + assertEquals(1, ((Number) column(run(pool, config, "select 1 as one", Map.of()), 0, "one")).intValue()); + DatasourceTestResult test = connector().testConnection(config).block(); + assertNotNull(test); + assertTrue(test.isSuccess()); + System.out.println("[ClickHouseDatabaseTest] pool " + pool.getJdbcUrl() + ", test connection succeeded"); + } finally { + close(pool); + } + } + + @Test + public void wrongPasswordAndClosedPortGiveCodedErrors() throws Exception { + ClickHouseDatasourceConfig wrongPassword = config(DATABASE, WRONG_PASSWORD, false, false, false, host(), port()); + PluginException thrown = assertThrows(PluginException.class, () -> connect(wrongPassword)); + System.out.println("[ClickHouseDatabaseTest] wrong password: " + thrown.getError() + " " + thrown.getMessage()); + assertEquals(DATASOURCE_ARGUMENT_ERROR, thrown.getError()); + DatasourceTestResult failed = connector().testConnection(wrongPassword).block(); + assertFalse(failed.isSuccess()); + + int closedPort; + try (ServerSocket socket = new ServerSocket(0)) { + closedPort = socket.getLocalPort(); + } + ClickHouseDatasourceConfig noServer = config(DATABASE, PASSWORD, false, false, false, host(), closedPort); + PluginException refused = assertThrows(PluginException.class, () -> connect(noServer)); + System.out.println("[ClickHouseDatabaseTest] closed port: " + refused.getError() + " " + refused.getMessage()); + assertEquals(DATASOURCE_ARGUMENT_ERROR, refused.getError()); + assertFalse(connector().testConnection(noServer).block().isSuccess()); + } + + @Test + public void destroyConnectionClosesThePoolAndNullIsSafe() { + connector().destroyConnection(null).block(); + HikariDataSource pool = connect(config()); + connector().destroyConnection(pool).block(); + assertTrue(pool.isClosed()); + } + + @Test + public void closedPoolIsRejectedBeforeAnyQuery() { + ClickHouseDatasourceConfig config = config(); + HikariDataSource pool = connect(config); + close(pool); + PluginException query = assertThrows(PluginException.class, () -> run(pool, config, "select 1", Map.of())); + assertEquals(CONNECTION_ERROR, query.getError()); + assertEquals(CLOSED_MESSAGE, query.getArgs()[0]); + PluginException structure = assertThrows(PluginException.class, () -> executor().getStructure(pool, config).block()); + assertEquals(CONNECTION_ERROR, structure.getError()); + assertEquals(CLOSED_MESSAGE, structure.getArgs()[0]); + System.out.println("[ClickHouseDatabaseTest] closed pool: " + query.getArgs()[0]); + } + + @Test + public void usingSslSwitchesTheSchemeToHttps() { + ClickHouseDatasourceConfig https = config(DATABASE, PASSWORD, true, false, false, host(), port()); + PluginException thrown = assertThrows(PluginException.class, () -> connect(https)); + System.out.println("[ClickHouseDatabaseTest] https against the HTTP port: " + thrown.getError() + " " + thrown.getMessage()); + assertEquals(DATASOURCE_ARGUMENT_ERROR, thrown.getError()); + } + + @Test + public void readonlyFlagReachesThePoolAndTheDriversBehaviourIsObserved() throws Exception { + try (Connection jdbc = jdbc(DATABASE)) { + execute(jdbc, "drop table if exists t_readonly", "create table t_readonly (i Int32) engine=Memory"); + ClickHouseDatasourceConfig readonly = config(DATABASE, PASSWORD, false, true, false, host(), port()); + HikariDataSource pool = connect(readonly); + try { + assertTrue(pool.isReadOnly()); + String outcome; + try { + outcome = String.valueOf(run(pool, readonly, "insert into t_readonly values (1)", Map.of()).getData()); + } catch (PluginException e) { + outcome = e.getError() + " " + e.getMessage(); + } + System.out.println("[ClickHouseDatabaseTest] insert through a read-only pool: " + outcome + " ; rows now " + rows(jdbc, "select * from t_readonly")); + } finally { + close(pool); + } + } + } + + // ---- executor + + @Test + public void eachSupportedJavaTypeIsBoundAndReadBack() throws Exception { + ClickHouseDatasourceConfig config = config(); + HikariDataSource pool = connect(config); + try (Connection jdbc = jdbc(DATABASE)) { + execute(jdbc, "drop table if exists t_types", "create table t_types (i Int32, l Int64, d Decimal(38,20), b Bool, s String, j String, n Nullable(String)) engine=Memory"); + Map params = new java.util.HashMap<>(); + params.put("i", 7); + params.put("l", 3_000_000_001L); + params.put("d", 0.1d); + params.put("b", true); + params.put("s", "it's \"q\" žluť"); + params.put("j", Map.of("k", 1)); + params.put("n", null); + run(pool, config, "insert into t_types values ({{i}}, {{l}}, {{d}}, {{b}}, {{s}}, {{j}}, {{n}})", params); + QueryExecutionResult read = run(pool, config, "select i, l, d, b, s, j, n from t_types", Map.of()); + System.out.println("[ClickHouseDatabaseTest] read back: " + read.getData()); + assertEquals(7, ((Number) column(read, 0, "i")).intValue()); + assertEquals(3_000_000_001L, ((Number) column(read, 0, "l")).longValue()); + assertEquals(0, new java.math.BigDecimal("0.1").compareTo(new java.math.BigDecimal(String.valueOf(column(read, 0, "d"))))); + assertTrue(String.valueOf(column(read, 0, "b")).equals("true") || String.valueOf(column(read, 0, "b")).equals("1"), String.valueOf(column(read, 0, "b"))); + assertEquals("it's \"q\" žluť", column(read, 0, "s")); + assertEquals("{\"k\":1}", column(read, 0, "j")); + assertEquals(null, column(read, 0, "n")); + } finally { + close(pool); + } + } + + /** Unlike {@code GeneralSqlExecutor} (defect D2: the name is always empty), this executor names the parameter. */ + @Test + public void unsupportedBindTypeNamesTheParameter() { + ClickHouseDatasourceConfig config = config(); + HikariDataSource pool = connect(config); + try { + PluginException thrown = assertThrows(PluginException.class, + () -> run(pool, config, "select {{ts}} as v", Map.of("ts", new Timestamp(0)))); + assertEquals(PREPARED_STATEMENT_BIND_PARAMETERS_ERROR, thrown.getError()); + assertEquals("PS_BIND_ERROR", thrown.getMessageKey()); + assertEquals(List.of("ts", "Timestamp"), Arrays.asList(thrown.getArgs())); + System.out.println("[ClickHouseDatabaseTest] unsupported type: " + Arrays.toString(thrown.getArgs())); + } finally { + close(pool); + } + } + + @Test + public void affectedRowsOfDdlAndInsertsOnARealServer() throws Exception { + ClickHouseDatasourceConfig config = config(DATABASE, PASSWORD, false, false, true, host(), port()); + HikariDataSource pool = connect(config); + try { + Object ddl = run(pool, config, "create table if not exists t_affected (i Int32) engine=Memory", Map.of()).getData(); + Object prepared = run(pool, config, "insert into t_affected values ({{i}})", Map.of("i", 1)).getData(); + Object plain = run(pool, config, "insert into t_affected values (2)", true, Map.of()).getData(); + System.out.println("[ClickHouseDatabaseTest] affected rows: ddl " + ddl + ", prepared insert " + prepared + ", plain insert " + plain); + assertEquals(Map.of("affectedRows", 0), ddl); + assertEquals(Map.of("affectedRows", 1), prepared); + assertEquals(Map.of("affectedRows", 1), plain); + } finally { + close(pool); + } + } + + @Test + public void disabledPreparedStatementRendersTheValueIntoTheSql() { + ClickHouseDatasourceConfig config = config(DATABASE, PASSWORD, false, false, true, host(), port()); + HikariDataSource pool = connect(config); + try { + Map params = Map.of("v", "x' || 'y"); + Object bound = column(run(pool, config, "select {{v}} as v", false, params), 0, "v"); + Object rendered = column(run(pool, config, "select '{{v}}' as v", true, params), 0, "v"); + System.out.println("[ClickHouseDatabaseTest] bound -> " + bound + " ; rendered into the SQL -> " + rendered); + assertEquals("x' || 'y", bound); + assertEquals("xy", rendered); + } finally { + close(pool); + } + } + + @Test + public void invalidSqlIsQueryExecutionErrorAndTheConnectionComesBack() { + ClickHouseDatasourceConfig config = config(); + HikariDataSource pool = connect(config); + try { + for (int i = 0; i < FAILING_RUNS; i++) { + PluginException thrown = assertThrows(PluginException.class, () -> run(pool, config, "selec nothing from nowhere", Map.of())); + assertEquals(QUERY_EXECUTION_ERROR, thrown.getError()); + } + run(pool, config, "select 1", Map.of()); + assertEquals(0, pool.getHikariPoolMXBean().getActiveConnections(), "connections must go back to the pool"); + System.out.println("[ClickHouseDatabaseTest] active connections after " + FAILING_RUNS + " failures: " + pool.getHikariPoolMXBean().getActiveConnections()); + } finally { + close(pool); + } + } + + @Test + public void duplicateColumnNamesGiveAHintMessage() { + ClickHouseDatasourceConfig config = config(); + HikariDataSource pool = connect(config); + try { + QueryExecutionResult result = run(pool, config, "select 1, 1", Map.of()); + assertEquals(1, result.getHintLocaleMessages().size()); + assertEquals("DUPLICATE_COLUMN", result.getHintLocaleMessages().get(0).messageKey()); + assertEquals(List.of("1"), Arrays.asList(result.getHintLocaleMessages().get(0).args())); + } finally { + close(pool); + } + } + + // ---- structure + + /** + * Pins the plan section 9 row "ClickHouseStructureParser's COLUMNS_QUERY uses col.is_nullable != 0" (D-6: fix deferred): + * against the pinned server, {@code ClickHouseStructureParser.COLUMNS_QUERY} fails, because {@code col.is_nullable != 0} compares a String + * with a number (ClickHouse code 386, NO_COMMON_TYPE), so {@code getStructure} fails for every database. A fix changes + * this test on purpose (then it should assert the tables and columns of two tables, and that tables of another database + * are not listed). + */ + @Test + public void structureQueryFailsAgainstTheServer() throws Exception { + String database = "struct_db"; + try (Connection jdbc = jdbc(DATABASE)) { + execute(jdbc, "drop database if exists " + database, "create database " + database, + "create table " + database + ".a_table (id Int32, name String) engine=Memory"); + } + ClickHouseDatasourceConfig config = config(database); + HikariDataSource pool = connect(config); + try { + PluginException thrown = assertThrows(PluginException.class, () -> executor().getStructure(pool, config).block()); + System.out.println("[ClickHouseDatabaseTest] getStructure: " + thrown.getError() + " " + thrown.getMessage()); + assertEquals(DATASOURCE_GET_STRUCTURE_ERROR, thrown.getError()); + assertTrue(thrown.getMessage().contains("NO_COMMON_TYPE"), thrown.getMessage()); + assertEquals(0, pool.getHikariPoolMXBean().getActiveConnections(), "the connection must go back to the pool"); + } finally { + close(pool); + } + } +} diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseResultContractTest.java b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseResultContractTest.java new file mode 100644 index 0000000000..f35b769bcd --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/java/org/lowcoder/plugin/clickhouse/ClickHouseResultContractTest.java @@ -0,0 +1,101 @@ +package org.lowcoder.plugin.clickhouse; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.Column; +import org.lowcoder.sdk.contract.FakeJdbc.Rows; +import org.lowcoder.sdk.contract.FakeJdbc.StatementResult; +import org.lowcoder.sdk.contract.FakeJdbc.UpdateCount; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Connection; +import java.time.Duration; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * The ClickHouse row of the §4.6 producer table (docs/API_PAYLOAD_TEST_PLAN.md §4.6, task T8.2): + * {@code ClickHouseQueryExecutor.executeQuery} on a data source whose connection ({@link FakeJdbc}) yields each case of + * {@link #CASES}, as a prepared statement and as plain SQL. Rows come from {@code ResultSetParser.parseRows} (a repeated + * column label keeps the last value; the duplicate hint is not written), an update from a {@code LinkedHashMap} of + * {@code affectedRows}, never below 0. Each case's report ({@link QueryResults#report}) is pinned in {@value #REPORT}. + * + *

    Limits: the values are {@link FakeJdbc}'s, not the ClickHouse driver's (whose {@code UInt64} is a + * {@link BigInteger}, given here by hand). + */ +public class ClickHouseResultContractTest { + + static final String REPORT = "query-results/ClickHouseQueryExecutor.results.json"; + static final String QUERY = "select * from items"; + static final String PREPARED = "prepared"; + static final String PLAIN = "plain"; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map>> CASES = cases(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final ClickHouseQueryExecutor executor = new ClickHouseQueryExecutor(new ConfigCenterForTest()); + + @Test + public void resultsAsPinned() { + Map report = new LinkedHashMap<>(); + CASES.forEach((name, results) -> { + Map modes = new LinkedHashMap<>(); + modes.put(PREPARED, report(results.get(), false)); + modes.put(PLAIN, report(results.get(), true)); + report.put(name, modes); + }); + String actual = ConfigBinding.write(report); + System.out.println("[ClickHouseResultContractTest]\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + private Object report(List results, boolean disablePreparedStatement) { + SqlBasedQueryExecutionContext context = SqlBasedQueryExecutionContext.builder() + .query(QUERY).requestParams(Map.of()).disablePreparedStatement(disablePreparedStatement).build(); + try { + QueryExecutionResult result = executor.executeQuery(dataSource(FakeJdbc.connection(results)), context).block(TIMEOUT); + return result == null ? null : QueryResults.report(result); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + } + + /** A data source that is never started: it reports running and hands out {@code connection}. */ + static HikariDataSource dataSource(Connection connection) { + return new HikariDataSource() { + @Override + public Connection getConnection() { + return connection; + } + + @Override + public boolean isRunning() { + return true; + } + }; + } + + private static Map>> cases() { + List columns = List.of(new Column("id", "UInt64"), new Column("name", "String"), new Column("id", "Int32"), + new Column("price", "Decimal(10, 2)"), new Column("day", "date"), new Column("tags", "Array(String)")); + Map>> cases = new LinkedHashMap<>(); + cases.put("selectWithRepeatedLabel", () -> List.of(new Rows(FakeJdbc.resultSet(columns, List.of( + Arrays.asList(new BigInteger("18446744073709551615"), "žluť", 7, new BigDecimal("1.50"), + java.sql.Date.valueOf("2024-02-29"), List.of("a", "b"))))))); + cases.put("insert", () -> List.of(new UpdateCount(3, null))); + cases.put("noUpdateCount", () -> List.of(new UpdateCount(FakeJdbc.NO_UPDATE_COUNT, null))); + return cases; + } +} diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseDatasourceConfig.input.json new file mode 100644 index 0000000000..1e030f7a49 --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseDatasourceConfig.input.json @@ -0,0 +1,41 @@ +{ + "canonical": { + "database": " lowcoder ", + "username": " root ", + "password": "s3crét \"quoted\"", + "host": " db.example.com ", + "port": 8123, + "usingSsl": true, + "serverTimezone": "Europe/Bratislava", + "isReadonly": true, + "enableTurnOffPreparedStatement": true, + "extParams": { + "connectTimeout": 5000, + "allowPublicKeyRetrieval": "true", + "useCursorFetch": false, + "nested": { + "list": [ + 1, + 2.5, + null + ] + } + } + }, + "scalarsAsStrings": { + "host": "db.example.com", + "port": "8124", + "usingSsl": "true", + "isReadonly": "false", + "enableTurnOffPreparedStatement": "true" + }, + "getterNames": { + "host": "db.example.com", + "readonly": true, + "port": 8125.0 + }, + "minimal": { + "host": "db.example.com" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseDatasourceConfig.json b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseDatasourceConfig.json new file mode 100644 index 0000000000..172d0634dc --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseDatasourceConfig.json @@ -0,0 +1,180 @@ +{ + "buildFrom" : { + "canonical" : { + "class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "database" : " lowcoder ", + "username" : " root ", + "password" : "s3crét \"quoted\"", + "host" : " db.example.com ", + "port" : 8123, + "usingSsl" : true, + "serverTimezone" : "Europe/Bratislava", + "isReadonly" : true, + "enableTurnOffPreparedStatement" : true, + "extParams" : { + "connectTimeout" : 5000, + "allowPublicKeyRetrieval" : "true", + "useCursorFetch" : false, + "nested" : { + "list" : [ 1, 2.5, null ] + } + } + }, + "classes" : { + "" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "/database" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "java.lang.String", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "java.util.LinkedHashMap", + "/extParams/connectTimeout" : "java.lang.Integer", + "/extParams/allowPublicKeyRetrieval" : "java.lang.String", + "/extParams/useCursorFetch" : "java.lang.Boolean", + "/extParams/nested" : "java.util.LinkedHashMap", + "/extParams/nested/list" : "java.util.ArrayList", + "/extParams/nested/list/0" : "java.lang.Integer", + "/extParams/nested/list/1" : "java.lang.Double", + "/extParams/nested/list/2" : "null" + }, + "noView" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":8123,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Public" : {"database":"lowcoder","username":"root","host":"db.example.com","port":8123,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Internal" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":8123,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 8124, + "usingSsl" : true, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : true, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":8124,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":8124,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":8124,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false} + }, + "getterNames" : { + "class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 8125, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":8125,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":8125,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":8125,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "minimal" : { + "class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":8123,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":8123,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":8123,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "empty" : { + "class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : null, + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.clickhouse.model.ClickHouseDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"","port":8123,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"","port":8123,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"","port":8123,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "unknownProperty" : "ignored: binds as form canonical" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseQueryConfig.input.json b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseQueryConfig.input.json new file mode 100644 index 0000000000..dc054ee287 --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseQueryConfig.input.json @@ -0,0 +1,20 @@ +{ + "full": { + "sql": " select number from system.numbers limit {{n}} ", + "disablePreparedStatement": true, + "timeout": 30 + }, + "scalarsAsStrings": { + "sql": "select 1", + "disablePreparedStatement": "false", + "timeout": "15" + }, + "decimalTimeout": { + "sql": "select 1", + "timeout": 2.5 + }, + "sqlMissing": { + "timeout": 1 + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseQueryConfig.json b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseQueryConfig.json new file mode 100644 index 0000000000..b78439c648 --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/config-binding/ClickHouseQueryConfig.json @@ -0,0 +1,86 @@ +{ + "from" : { + "full" : { + "class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "sql" : " select number from system.numbers limit {{n}} ", + "disablePreparedStatement" : true, + "timeout" : 30 + }, + "classes" : { + "" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/timeout" : "java.lang.Integer" + }, + "noView" : {"sql":"select number from system.numbers limit {{n}}","disablePreparedStatement":true,"timeout":30}, + "Public" : {"sql":"select number from system.numbers limit {{n}}","disablePreparedStatement":true,"timeout":30}, + "Internal" : {"sql":"select number from system.numbers limit {{n}}","disablePreparedStatement":true,"timeout":30} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "sql" : "select 1", + "disablePreparedStatement" : false, + "timeout" : 15 + }, + "classes" : { + "" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/timeout" : "java.lang.Integer" + }, + "noView" : {"sql":"select 1","disablePreparedStatement":false,"timeout":15}, + "Public" : {"sql":"select 1","disablePreparedStatement":false,"timeout":15}, + "Internal" : {"sql":"select 1","disablePreparedStatement":false,"timeout":15} + }, + "decimalTimeout" : { + "class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "sql" : "select 1", + "disablePreparedStatement" : false, + "timeout" : 2 + }, + "classes" : { + "" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/timeout" : "java.lang.Integer" + }, + "noView" : {"sql":"select 1","disablePreparedStatement":false,"timeout":2}, + "Public" : {"sql":"select 1","disablePreparedStatement":false,"timeout":2}, + "Internal" : {"sql":"select 1","disablePreparedStatement":false,"timeout":2} + }, + "sqlMissing" : { + "class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "timeout" : 1 + }, + "classes" : { + "" : "org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/timeout" : "java.lang.Integer" + }, + "noView" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig[\"sql\"])" + }, + "Public" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig[\"sql\"])" + }, + "Internal" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.clickhouse.model.ClickHouseQueryConfig[\"sql\"])" + } + }, + "empty" : { + "error" : "org.lowcoder.sdk.exception.PluginException: INVALID_QUERY_SETTINGS CLICKHOUSE_CONFIG_EMPTY" + }, + "unknownProperty" : "ignored: binds as form full" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/downstream-render/ClickHouseQueryExecutor.binds.txt b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/downstream-render/ClickHouseQueryExecutor.binds.txt new file mode 100644 index 0000000000..88195e94d3 --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/downstream-render/ClickHouseQueryExecutor.binds.txt @@ -0,0 +1,10 @@ +int setInt(1, java.lang.Integer:1) +long setLong(1, java.lang.Long:3000000001) +decimal setBigDecimal(1, java.math.BigDecimal:1.5) +exponent setBigDecimal(1, java.math.BigDecimal:1.0E+20) +bool setBoolean(1, java.lang.Boolean:true) +null setNull(1, java.lang.Integer:0) +string setString(1, java.lang.String:it's "q" \ žluť) +dollar setString(1, java.lang.String:cost $1) +list setString(1, java.lang.String:[1,"it's \"q\"",2.5,null,[true],{"k":1.50}]) +map setString(1, java.lang.String:{"zeta":"it's","decimal":1.50,"nested":[1,null]}) diff --git a/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/query-results/ClickHouseQueryExecutor.results.json b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/query-results/ClickHouseQueryExecutor.results.json new file mode 100644 index 0000000000..aed332bdf6 --- /dev/null +++ b/server/api-service/lowcoder-plugins/clickHousePlugin/src/test/resources/json-contract/query-results/ClickHouseQueryExecutor.results.json @@ -0,0 +1,100 @@ +{ + "selectWithRepeatedLabel" : { + "prepared" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.util.LinkedHashMap", + "/0/id" : "java.lang.Integer", + "/0/name" : "java.lang.String", + "/0/price" : "java.math.BigDecimal", + "/0/day" : "java.lang.String", + "/0/tags" : "java.util.ImmutableCollections$List12", + "/0/tags/0" : "java.lang.String", + "/0/tags/1" : "java.lang.String" + }, + "keyOrder" : { + "/0" : [ "id", "name", "price", "day", "tags" ] + } + }, + "written" : {"queryCode":"OK","data":[{"id":7,"name":"žluť","price":1.50,"day":"2024-02-29","tags":["a","b"]}],"success":true} + }, + "plain" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.util.LinkedHashMap", + "/0/id" : "java.lang.Integer", + "/0/name" : "java.lang.String", + "/0/price" : "java.math.BigDecimal", + "/0/day" : "java.lang.String", + "/0/tags" : "java.util.ImmutableCollections$List12", + "/0/tags/0" : "java.lang.String", + "/0/tags/1" : "java.lang.String" + }, + "keyOrder" : { + "/0" : [ "id", "name", "price", "day", "tags" ] + } + }, + "written" : {"queryCode":"OK","data":[{"id":7,"name":"žluť","price":1.50,"day":"2024-02-29","tags":["a","b"]}],"success":true} + } + }, + "insert" : { + "prepared" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":3},"success":true} + }, + "plain" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":3},"success":true} + } + }, + "noUpdateCount" : { + "prepared" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":0},"success":true} + }, + "plain" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":0},"success":true} + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/pom.xml b/server/api-service/lowcoder-plugins/elasticSearchPlugin/pom.xml index d420375260..9cb60c2951 100644 --- a/server/api-service/lowcoder-plugins/elasticSearchPlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/pom.xml @@ -38,14 +38,12 @@ org.testcontainers testcontainers - 1.18.0 test org.testcontainers - elasticsearch - 1.16.3 + testcontainers-elasticsearch test diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsConfigBindingContractTest.java new file mode 100644 index 0000000000..807377ce90 --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsConfigBindingContractTest.java @@ -0,0 +1,57 @@ +package org.lowcoder.plugin.es; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The {@code elasticSearchPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task + * T8.1), through the real entry points: {@code EsConnector.resolveConfig} for {@code EsDatasourceConfig}, and + * {@code EsQueryExecutor.buildQueryExecutionContext} for {@code EsQueryConfig} (§4.7: {@code EsQueryExecutor.java:41}). + * The query config is not kept, so its report pins the execution context built from it: the HTTP method bound by name, + * and the path and DSL rendered with {@link #REQUEST_PARAMS}. + */ +public class EsConfigBindingContractTest { + + /** The request parameters the query config is rendered with: a string, and text with a quote and non-ASCII. */ + static final Map REQUEST_PARAMS = requestParams(); + + /** {@code resolveConfig} does not read the common config. */ + static final CommonConfig NO_COMMON_CONFIG = null; + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsConnector.java#EsConnector.resolveConfig#toJson#1", + "lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsConnector.java#EsConnector.resolveConfig#fromJson#1"}) + @Test + public void datasourceConfig() { + EsConnector connector = new EsConnector(new ConfigCenterForTest(), NO_COMMON_CONFIG); + ConfigBinding.assertBinding(GOLDEN, "config-binding/EsDatasourceConfig.input.json", "config-binding/EsDatasourceConfig.json", + Map.of("EsConnector.resolveConfig", connector::resolveConfig)); + } + + @BoundarySites({ + "lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java#EsQueryExecutor.buildQueryExecutionContext#toJson#1", + "lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java#EsQueryExecutor.buildQueryExecutionContext#fromJson#1"}) + @Test + public void queryConfig() { + EsQueryExecutor executor = new EsQueryExecutor(); + ConfigBinding.assertBinding(GOLDEN, "config-binding/EsQueryConfig.input.json", "config-binding/EsQueryConfig.json", + Map.of("EsQueryExecutor.buildQueryExecutionContext", + queryConfig -> executor.buildQueryExecutionContext(null, queryConfig, REQUEST_PARAMS, null))); + } + + private static Map requestParams() { + Map params = new LinkedHashMap<>(); + params.put("index", "logs-2026"); + params.put("q", "kůň \"quoted\""); + return params; + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsConnectorLifecycleTest.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsConnectorLifecycleTest.java new file mode 100644 index 0000000000..17c26bb4fc --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsConnectorLifecycleTest.java @@ -0,0 +1,218 @@ +package org.lowcoder.plugin.es; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.elasticsearch.client.Request; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.es.model.EsConnection; +import org.lowcoder.plugin.es.model.EsDatasourceConfig; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.DatasourceTestResult; + +/** + * {@link EsConnector} against a local server ({@link RecordingHttpServer}, port 0, loopback only): authentication, path + * prefix, {@code testConnection} outcomes, {@code destroyConnection}, the disallowed-hosts guard and connection-string + * validation. The happy-path {@code executeQuery} responses are pinned by EsResultContractTest. + */ +public class EsConnectorLifecycleTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(20); + private static final String DATASOURCE_VALIDATE_TIMEOUT_KEY = "datasourceValidateTimeoutMillis"; + private static final String USER = "elastic"; + private static final String PASSWORD = "s3cret"; + + private final EsConnector connector = new EsConnector(new ConfigCenterForTest(), new CommonConfig()); + + private EsDatasourceConfig config(Map map) { + return connector.resolveConfig(map); + } + + private static Response status(int status) { + return new Response(status, Map.of(), null); + } + + private DatasourceTestResult test(RecordingHttpServer server, Map extra) { + Map map = new java.util.HashMap<>(extra); + map.putIfAbsent("connectionString", server.baseUrl()); + return connector.testConnection(config(map)).block(TIMEOUT); + } + + @Test + public void credentialsAreSentOnlyWhenUsernameAndPasswordAreBothSet() { + String expected = "Basic " + Base64.getEncoder().encodeToString((USER + ":" + PASSWORD).getBytes(StandardCharsets.UTF_8)); + Map> cases = new java.util.LinkedHashMap<>(); + cases.put("both", Map.of("username", USER, "password", PASSWORD)); + cases.put("username only", Map.of("username", USER)); + cases.put("password only", Map.of("password", PASSWORD)); + cases.put("blank password", Map.of("username", USER, "password", " ")); + cases.put("neither", Map.of()); + for (Map.Entry> entry : cases.entrySet()) { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/", status(200)))) { + + DatasourceTestResult result = test(server, entry.getValue()); + + List authorization = server.requests().get(0).header("Authorization"); + System.out.println("[EsConnectorLifecycleTest] " + entry.getKey() + ": Authorization " + authorization + ", success " + result.isSuccess()); + assertTrue(result.isSuccess(), entry.getKey()); + if (entry.getKey().equals("both")) { + assertEquals(List.of(expected), authorization); + } else { + assertEquals(List.of(), authorization, entry.getKey()); + } + } + } + } + + @Test + public void thePathPrefixOfTheConnectionStringIsPrependedToEveryRequest() throws Exception { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of( + "/api/es/idx/_search", new Response(200, Map.of("Content-Type", List.of("application/json")), "{}".getBytes(StandardCharsets.UTF_8)), + "/idx/_search", new Response(200, Map.of("Content-Type", List.of("application/json")), "{}".getBytes(StandardCharsets.UTF_8))))) { + EsQueryExecutor executor = new EsQueryExecutor(); + String host = "127.0.0.1:" + server.port(); + // the scheme is optional (http by default); prefix parts are trimmed and joined with one slash + for (String connectionString : List.of("http://" + host + "/api/es", host + "/ api / es ", host + "/api/es/")) { + int before = server.requests().size(); + try (EsConnection connection = connector.createConnection(config(Map.of("connectionString", connectionString))).block(TIMEOUT)) { + executor.executeQuery(connection, executor.buildQueryExecutionContext(null, Map.of("httpMethod", "GET", "path", "idx/_search"), + Map.of(), null)).block(TIMEOUT); + } + String path = server.requests().get(before).pathAndQuery(); + System.out.println("[EsConnectorLifecycleTest] '" + connectionString + "' -> " + path); + assertEquals("/api/es/idx/_search", path, connectionString); + } + try (EsConnection noPrefix = connector.createConnection(config(Map.of("connectionString", "http://" + host))).block(TIMEOUT)) { + int before = server.requests().size(); + executorGet(noPrefix); + assertEquals("/idx/_search", server.requests().get(before).pathAndQuery()); + } + } + } + + private static void executorGet(EsConnection connection) { + EsQueryExecutor executor = new EsQueryExecutor(); + executor.executeQuery(connection, executor.buildQueryExecutionContext(null, Map.of("httpMethod", "GET", "path", "idx/_search"), + Map.of(), null)).block(TIMEOUT); + } + + @Test + public void testConnectionAnswersSuccessOn200AndFailsWithTheReasonOrErrorForOtherOutcomes() throws Exception { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/", status(200)))) { + DatasourceTestResult ok = test(server, Map.of()); + assertTrue(ok.isSuccess()); + assertEquals("HEAD", server.requests().get(0).method()); + assertEquals("/", server.requests().get(0).pathAndQuery()); + } + // a 2xx that is not 200 is a failure with the reason phrase; HEAD 404 is not an error to the client either + for (Map.Entry expected : Map.of(204, "No Content", 404, "Not Found").entrySet()) { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/", status(expected.getKey())))) { + DatasourceTestResult result = test(server, Map.of()); + String message = result.getInvalidMessage(Locale.ENGLISH); + System.out.println("[EsConnectorLifecycleTest] HEAD " + expected.getKey() + " -> " + message); + assertFalse(result.isSuccess()); + assertTrue(message.contains(expected.getValue()), message); + } + } + // an error status reaches the failure handler as an exception that names the status line + for (Map.Entry expected : Map.of(401, "401 Unauthorized", 500, "500 Internal Server Error").entrySet()) { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/", status(expected.getKey())))) { + DatasourceTestResult result = test(server, Map.of()); + String message = result.getInvalidMessage(Locale.ENGLISH); + System.out.println("[EsConnectorLifecycleTest] HEAD " + expected.getKey() + " -> " + message); + assertFalse(result.isSuccess()); + assertTrue(message.contains(expected.getValue()), message); + } + } + int closedPort; + try (ServerSocket socket = new ServerSocket(0)) { + closedPort = socket.getLocalPort(); + } + DatasourceTestResult refused = connector.testConnection(config(Map.of("connectionString", "127.0.0.1:" + closedPort))).block(TIMEOUT); + System.out.println("[EsConnectorLifecycleTest] closed port -> " + refused.getInvalidMessage(Locale.ENGLISH)); + assertFalse(refused.isSuccess()); + assertTrue(refused.getInvalidMessage(Locale.ENGLISH).contains("Connection refused")); + } + + @Test + public void testConnectionFailsWithTheTimeoutMessageWhenTheServerAnswersAfterTheConfiguredTimeout() { + EsConnector shortTimeout = new EsConnector(new ConfigCenterForTest(Map.of(DATASOURCE_VALIDATE_TIMEOUT_KEY, 300)), new CommonConfig()); + try (RecordingHttpServer server = RecordingHttpServer.serve(Map.of("/", request -> { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return status(200); + }))) { + + DatasourceTestResult result = shortTimeout.testConnection(config(Map.of("connectionString", server.baseUrl()))).block(TIMEOUT); + + System.out.println("[EsConnectorLifecycleTest] slow server -> " + result.getInvalidMessage(Locale.ENGLISH)); + assertFalse(result.isSuccess()); + assertTrue(result.getInvalidMessage(Locale.ENGLISH).contains("timed out")); + } + } + + @Test + public void destroyConnectionClosesTheClientAndAcceptsNull() { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/", status(200)))) { + EsConnection connection = connector.createConnection(config(Map.of("connectionString", server.baseUrl()))).block(TIMEOUT); + connection.reactorRestClientAdaptor().request(new Request("HEAD", "/")).block(TIMEOUT); + + connector.destroyConnection(connection).block(TIMEOUT); + + Throwable failure = assertThrows(RuntimeException.class, + () -> connection.reactorRestClientAdaptor().request(new Request("HEAD", "/")).block(TIMEOUT)); + System.out.println("[EsConnectorLifecycleTest] request after destroy -> " + failure); + assertEquals(1, server.requests().size()); + } + assertNull(connector.destroyConnection(null).block(TIMEOUT)); + } + + @Test + public void aHostListedInDisallowedHostsIsRejectedBeforeAnyRequest() { + CommonConfig commonConfig = new CommonConfig(); + commonConfig.setDisallowedHosts(Set.of("127.0.0.1")); + EsConnector guarded = new EsConnector(new ConfigCenterForTest(), commonConfig); + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/", status(200)))) { + + Throwable failure = assertThrows(BizException.class, + () -> guarded.createConnection(guarded.resolveConfig(Map.of("connectionString", "127.0.0.1:" + server.port()))).block(TIMEOUT)); + + System.out.println("[EsConnectorLifecycleTest] disallowed host -> " + failure.getMessage()); + assertTrue(failure.getMessage().contains("connectionString"), failure.getMessage()); + assertEquals(0, server.requests().size()); + } + } + + @Test + public void invalidConnectionStringsAreRejectedAndAnEmptyOneIsReportedByValidateConfig() { + for (String invalid : List.of("a://b://c", "host:abc", "host:1:2")) { + Throwable failure = assertThrows(BizException.class, + () -> connector.createConnection(config(Map.of("connectionString", invalid))).block(TIMEOUT), invalid); + System.out.println("[EsConnectorLifecycleTest] '" + invalid + "' -> " + failure.getMessage()); + assertTrue(failure.getMessage().contains("connectionString"), failure.getMessage()); + } + assertEquals(Set.of("CONNECTION_STRING_EMPTY"), connector.validateConfig(config(Map.of("connectionString", " ")))); + assertEquals(Set.of(), connector.validateConfig(config(Map.of("connectionString", "host:9200")))); + assertInstanceOf(EsDatasourceConfig.class, config(Map.of("connectionString", "host", "username", USER, "usingSsl", true))); + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsContainerSupport.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsContainerSupport.java new file mode 100644 index 0000000000..2d06ec184c --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsContainerSupport.java @@ -0,0 +1,106 @@ +package org.lowcoder.plugin.es; + +import org.lowcoder.plugin.es.model.EsDatasourceConfig; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.ContainerImages; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +/** + * The shared Elasticsearch 8.15 container of the L4-14 tests (heavy-container tag: one per test JVM, started on first use, + * removed by Testcontainers' reaper when the JVM exits; the port is the one Docker assigns) and the helpers around it. A generic + * container, not the module's ElasticsearchContainer (the module classes need a commons-lang3 other than the one this module's + * classpath resolves; see log-L5.md). A single node with security on and HTTP SSL off, so the plugin's plain-HTTP client meets a + * secured cluster; a 512 MB heap, no machine learning, no geoip download, and the disk-watermark check off (on a nearly full build disk the node refuses to allocate new shards, so creating an index would wait past the client's 30 second socket timeout). Start is bounded twice: a log wait with a startup timeout, + * then a bounded loop of the engine's own {@code testConnection}. No Docker guard on purpose (owner decision D-7). Not a test. + */ +final class EsContainerSupport { + + static final String USER = "elastic"; + static final String PASSWORD = "Es-S3cret-pass"; + static final int HTTP_PORT = 9200; + private static final String READY_PATTERN = ".*\"message\":\\s*\"started.*"; + private static final Duration START_TIMEOUT = Duration.ofMinutes(3); + private static final int READY_ATTEMPTS = 60; + private static final long READY_PAUSE_MILLIS = 1000; + static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(60); + + private static final GenericContainer ELASTICSEARCH = new GenericContainer<>(DockerImageName.parse(ContainerImages.ELASTICSEARCH_8_15)) + .withEnv("discovery.type", "single-node") + .withEnv("xpack.security.enabled", "true") + .withEnv("xpack.security.http.ssl.enabled", "false") + .withEnv("ELASTIC_PASSWORD", PASSWORD) + .withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m") + .withEnv("xpack.ml.enabled", "false") + .withEnv("ingest.geoip.downloader.enabled", "false") + .withEnv("cluster.routing.allocation.disk.threshold_enabled", "false") + .withExposedPorts(HTTP_PORT) + .waitingFor(Wait.forLogMessage(READY_PATTERN, 1).withStartupTimeout(START_TIMEOUT)); + + static final EsConnector CONNECTOR = new EsConnector(new ConfigCenterForTest(), new CommonConfig()); + static final EsQueryExecutor EXECUTOR = new EsQueryExecutor(); + + static { + long start = System.nanoTime(); + ELASTICSEARCH.start(); + System.out.println("[EsContainerSupport] " + ContainerImages.ELASTICSEARCH_8_15 + " started in " + + (System.nanoTime() - start) / 1_000_000 + " ms on " + host() + ":" + port()); + awaitReady(); + } + + private EsContainerSupport() { + } + + static String host() { + return ELASTICSEARCH.getHost(); + } + + static int port() { + return ELASTICSEARCH.getMappedPort(HTTP_PORT); + } + + static String baseUrl() { + return "http://" + host() + ":" + port(); + } + + /** A datasource config for the node; a null user or password leaves the field out. */ + static EsDatasourceConfig config(String connectionString, String username, String password) { + Map values = new HashMap<>(); + values.put("connectionString", connectionString); + if (username != null) { + values.put("username", username); + } + if (password != null) { + values.put("password", password); + } + return CONNECTOR.resolveConfig(values); + } + + static EsDatasourceConfig config() { + return config(baseUrl(), USER, PASSWORD); + } + + private static void awaitReady() { + for (int attempt = 1; attempt <= READY_ATTEMPTS; attempt++) { + DatasourceTestResult result = CONNECTOR.testConnection(config()).block(BLOCK_TIMEOUT); + if (result != null && result.isSuccess()) { + System.out.println("[EsContainerSupport] answered the engine's test connection on attempt " + attempt); + return; + } + try { + Thread.sleep(READY_PAUSE_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for Elasticsearch", interrupted); + } + } + throw new IllegalStateException("Elasticsearch did not accept the engine's test connection after " + READY_ATTEMPTS + " attempts"); + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsContainerTest.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsContainerTest.java new file mode 100644 index 0000000000..91ef4d701a --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsContainerTest.java @@ -0,0 +1,243 @@ +package org.lowcoder.plugin.es; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.es.model.EsConnection; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.models.QueryExecutionResult; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.es.EsContainerSupport.BLOCK_TIMEOUT; +import static org.lowcoder.plugin.es.EsContainerSupport.CONNECTOR; +import static org.lowcoder.plugin.es.EsContainerSupport.EXECUTOR; +import static org.lowcoder.plugin.es.EsContainerSupport.PASSWORD; +import static org.lowcoder.plugin.es.EsContainerSupport.USER; + +/** + * Unit ES-4 (task L4-14, lane L5), against a real Elasticsearch 8.15 single node with security on ({@link EsContainerSupport}): + * {@code testConnection} with the right, a wrong and no password, the queries a user runs (create index, index, get, search, + * bulk, count, delete) through {@code buildQueryExecutionContext} and {@code executeQuery}, the server's error answers, the + * non-object answers, a HEAD request, a TLS connection string against the plain-HTTP node, and {@code destroyConnection}. + * The stub-server cases (credentials sent or not, prefix, the disallowed-hosts guard, connection-string validation) are in other + * classes of the module and are not repeated. + * + *

    Heavy container (tag {@code heavy-container}): outside the gate, run under the heavy command only. Each test uses its own + * index name, so the tests do not depend on order. Limits: one node, plain HTTP, the built-in {@code elastic} user. + */ +@Tag("heavy-container") +public class EsContainerTest { + + static final String TAG = "[EsContainerTest] "; + static final String QUERY_CODE_OK = "OK"; + static final String ES_ERROR_CODE = "ES_EXECUTION_ERROR"; + static final String ES_QUERY_ERROR_KEY = "ES_QUERY_ERROR"; + + private static EsConnection connection; + + @BeforeAll + static void connect() { + connection = CONNECTOR.createConnection(EsContainerSupport.config()).block(BLOCK_TIMEOUT); + } + + @AfterAll + static void disconnect() { + CONNECTOR.destroyConnection(connection).block(BLOCK_TIMEOUT); + } + + private static String index(String name) { + return name + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + private static QueryExecutionResult run(String method, String path, String dsl) { + Map queryConfig = new HashMap<>(); + queryConfig.put("httpMethod", method); + queryConfig.put("path", path); + if (dsl != null) { + queryConfig.put("dsl", dsl); + } + var context = EXECUTOR.buildQueryExecutionContext(null, queryConfig, Map.of(), null); + QueryExecutionResult result = EXECUTOR.executeQuery(connection, context).block(BLOCK_TIMEOUT); + System.out.println(TAG + method + " /" + context.getPath() + " dsl=" + (dsl == null ? null : dsl.replace("\n", "\\n")) + " -> " + result.getQueryCode() + + " " + result.getMessageKey() + " data=" + result.getData() + " args=" + (result.getMessageArgs() == null ? null : Arrays.toString(result.getMessageArgs()))); + return result; + } + + @SuppressWarnings("unchecked") + private static Map data(QueryExecutionResult result) { + assertEquals(QUERY_CODE_OK, result.getQueryCode()); + return (Map) result.getData(); + } + + // ---- testConnection + + private static DatasourceTestResult test(String label, String connectionString, String user, String password) { + DatasourceTestResult result = CONNECTOR.testConnection(EsContainerSupport.config(connectionString, user, password)).block(BLOCK_TIMEOUT); + System.out.println(TAG + label + ": success=" + result.isSuccess() + (result.isSuccess() ? "" : " message=" + result.getInvalidMessage(Locale.ENGLISH))); + return result; + } + + @Test + public void testConnectionSucceedsWithTheRightPasswordAndFailsWithAWrongOrMissingOne() { + String url = EsContainerSupport.baseUrl(); + assertTrue(test("right password", url, USER, PASSWORD).isSuccess()); + DatasourceTestResult wrong = test("wrong password", url, USER, "wrong-password"); + assertFalse(wrong.isSuccess()); + assertTrue(wrong.getInvalidMessage(Locale.ENGLISH).contains("Unauthorized"), wrong.getInvalidMessage(Locale.ENGLISH)); + assertTrue(test("no credentials", url, null, null).getInvalidMessage(Locale.ENGLISH).contains("Unauthorized")); + assertTrue(test("user without a password", url, USER, null).getInvalidMessage(Locale.ENGLISH).contains("Unauthorized"), "credentials are sent only when both are set"); + assertFalse(test("password without a user", url, null, PASSWORD).isSuccess()); + } + + /** A TLS connection string against the plain-HTTP node: the handshake fails, so the test connection reports a failure. */ + @Test + public void tlsConnectionStringAgainstThePlainHttpNodeFails() { + DatasourceTestResult result = test("https against http", "https://" + EsContainerSupport.host() + ":" + EsContainerSupport.port(), USER, PASSWORD); + assertFalse(result.isSuccess()); + } + + // ---- queries + + @Test + public void indexLifecycleThroughTheEngine() { + String idx = index("lifecycle"); + Map created = data(run("PUT", idx, "{\"settings\": {\"number_of_shards\": 1, \"number_of_replicas\": 0}}")); + assertEquals(true, created.get("acknowledged")); + assertEquals(idx, created.get("index")); + + Map indexed = data(run("PUT", idx + "/_doc/1", "{\"title\": \"hello world\", \"views\": 3}")); + assertEquals("created", indexed.get("result")); + data(run("POST", idx + "/_refresh", null)); + + Map fetched = data(run("GET", idx + "/_doc/1", null)); + assertEquals(true, fetched.get("found")); + assertEquals("hello world", ((Map) fetched.get("_source")).get("title")); + + Map all = data(run("POST", idx + "/_search", "{\"query\": {\"match_all\": {}}}")); + assertEquals(1, ((Map) ((Map) all.get("hits")).get("total")).get("value")); + Map matching = data(run("POST", idx + "/_search", "{\"query\": {\"match\": {\"title\": \"hello\"}}}")); + assertEquals(1, ((List) ((Map) matching.get("hits")).get("hits")).size()); + Map none = data(run("POST", idx + "/_search", "{\"query\": {\"match\": {\"title\": \"absent\"}}}")); + assertEquals(0, ((List) ((Map) none.get("hits")).get("hits")).size()); + + assertEquals(1, data(run("GET", idx + "/_count", null)).get("count")); + assertEquals(true, data(run("DELETE", idx, null)).get("acknowledged")); + QueryExecutionResult gone = run("GET", idx + "/_search", null); + assertEquals(ES_ERROR_CODE, gone.getQueryCode()); + } + + /** + * The bulk API takes newline-delimited JSON, and the server accepts such a body when it is sent as it is (the control, through + * the connection's own client). The query path renders the DSL with {@code renderMustacheJsonString} first, which reads the text + * as one JSON value: text that is not a single JSON value (as every valid bulk body is) comes out as one JSON string literal, + * with the quotes and line breaks escaped and the final line break dropped. The server answers that with a 400, so a valid bulk + * body cannot be run from a query. Pins the plan section 9 row "ES _bulk cannot be used: renderMustacheJsonString turns NDJSON into one + * JSON string literal; server 400" against the real server, as observed, with the exact transformation. A fix (leaving a body + * that is not one JSON value as it is) changes this test on purpose. + */ + @Test + public void validBulkBodyIsRewrittenIntoAJsonStringAndRefusedByTheServer_pinsTheSection9Row() throws Exception { + String idx = index("bulk"); + String ndjson = "{\"index\": {\"_index\": \"" + idx + "\", \"_id\": \"1\"}}\n{\"title\": \"one\"}\n" + + "{\"index\": {\"_index\": \"" + idx + "\", \"_id\": \"2\"}}\n{\"title\": \"two\"}\n"; + var context = EXECUTOR.buildQueryExecutionContext(null, Map.of("httpMethod", "POST", "path", "_bulk", "dsl", ndjson), Map.of(), null); + System.out.println(TAG + "bulk dsl in : " + ndjson.replace("\n", "\\n")); + System.out.println(TAG + "bulk dsl out: " + context.getDsl().replace("\n", "\\n")); + String expected = "\"" + ndjson.substring(0, ndjson.length() - 1).replace("\"", "\\\"").replace("\n", "\\n") + "\""; + assertEquals(expected, context.getDsl(), "the whole body becomes one JSON string literal without its final line break"); + + QueryExecutionResult result = run("POST", "_bulk?refresh=true", ndjson); + assertEquals(ES_ERROR_CODE, result.getQueryCode()); + assertEquals(ES_QUERY_ERROR_KEY, result.getMessageKey()); + assertTrue(String.valueOf(result.getMessageArgs()[0]).contains("400"), String.valueOf(result.getMessageArgs()[0])); + + org.elasticsearch.client.Request direct = new org.elasticsearch.client.Request("POST", "/_bulk?refresh=true"); + direct.setJsonEntity(ndjson); + org.elasticsearch.client.Response control = connection.reactorRestClientAdaptor().request(direct).block(BLOCK_TIMEOUT); + String body = org.apache.http.util.EntityUtils.toString(control.getEntity()); + System.out.println(TAG + "the same body sent as it is: " + control.getStatusLine() + " " + body); + assertEquals(200, control.getStatusLine().getStatusCode()); + assertTrue(body.contains("\"errors\":false"), body); + assertEquals(2, ((Map) ((Map) data(run("GET", idx + "/_search", null)).get("hits")).get("total")).get("value")); + } + + @Test + public void serverErrorsBecomeEsQueryErrorResultsWithTheServersText() { + QueryExecutionResult missing = run("GET", index("no-such") + "/_search", null); + assertEquals(ES_ERROR_CODE, missing.getQueryCode()); + assertEquals(ES_QUERY_ERROR_KEY, missing.getMessageKey()); + assertTrue(String.valueOf(missing.getMessageArgs()[0]).contains("index_not_found_exception"), String.valueOf(missing.getMessageArgs()[0])); + assertTrue(String.valueOf(missing.getMessageArgs()[0]).contains("404")); + assertNull(missing.getData()); + + QueryExecutionResult malformed = run("POST", "_search", "{\"query\": {\"no_such_query\": {}}}"); + assertEquals(ES_ERROR_CODE, malformed.getQueryCode()); + assertEquals(ES_QUERY_ERROR_KEY, malformed.getMessageKey()); + assertTrue(String.valueOf(malformed.getMessageArgs()[0]).contains("400"), String.valueOf(malformed.getMessageArgs()[0])); + } + + // ---- non-object answers and HEAD + + @Test + public void answersThatAreNotJsonObjectsAreObservedAgainstTheRealServer() { + String idx = index("cat"); + data(run("PUT", idx, null)); + QueryExecutionResult array = run("GET", "_cat/indices?format=json", null); + QueryExecutionResult text = run("GET", "_cat/indices", null); + System.out.println(TAG + "_cat json: " + array.getQueryCode() + " " + array.getMessageKey() + " data=" + array.getData()); + System.out.println(TAG + "_cat text: " + text.getQueryCode() + " " + text.getMessageKey() + " data=" + text.getData()); + assertEquals(OBSERVED_ARRAY_CODE, array.getQueryCode()); + assertNull(array.getData(), "a JSON array answer is a success with no data (the object parser returns null for it)"); + assertEquals(OBSERVED_TEXT_CODE, text.getQueryCode()); + assertNull(text.getData(), "a plain-text answer is a success with no data"); + } + + static final String OBSERVED_ARRAY_CODE = QUERY_CODE_OK; + static final String OBSERVED_TEXT_CODE = QUERY_CODE_OK; + + /** + * Pins the plan section 9 row "ES: a HEAD query ... answered 200 becomes a failed result" against the real server (the rows + * pinned against a stub in another class of this module, EP3): HEAD on an existing index answers 200 with no body, and the + * executor reads the missing entity as an error. A fix (reading a bodiless answer as success) changes this test on purpose. + */ + @Test + public void headOnAnExistingIndexBecomesAFailedResult_pinsTheSection9RowEP3() { + String idx = index("head"); + data(run("PUT", idx, null)); + QueryExecutionResult exists = run("HEAD", idx, null); + assertFalse(exists.isSuccess()); + assertEquals(ES_ERROR_CODE, exists.getQueryCode()); + assertEquals(ES_QUERY_ERROR_KEY, exists.getMessageKey()); + assertEquals("Entity may not be null", exists.getMessageArgs()[0]); + assertNull(exists.getData()); + QueryExecutionResult absent = run("HEAD", index("head-absent"), null); + assertEquals(ES_ERROR_CODE, absent.getQueryCode()); + assertEquals("Entity may not be null", absent.getMessageArgs()[0], "a missing index (404) gives the same result: the client does not raise a 404 for HEAD, so a HEAD query cannot tell an index exists"); + } + + // ---- destroy + + @Test + public void destroyConnectionReleasesTheClient() { + EsConnection own = CONNECTOR.createConnection(EsContainerSupport.config()).block(BLOCK_TIMEOUT); + var context = EXECUTOR.buildQueryExecutionContext(null, Map.of("httpMethod", "GET", "path", "_cluster/health"), Map.of(), null); + QueryExecutionResult before = EXECUTOR.executeQuery(own, context).block(BLOCK_TIMEOUT); + assertEquals(QUERY_CODE_OK, before.getQueryCode()); + CONNECTOR.destroyConnection(own).block(BLOCK_TIMEOUT); + QueryExecutionResult after = EXECUTOR.executeQuery(own, context).block(BLOCK_TIMEOUT); + System.out.println(TAG + "after destroy: " + after.getQueryCode() + " " + after.getMessageKey() + " " + Arrays.toString(after.getMessageArgs())); + assertEquals(ES_ERROR_CODE, after.getQueryCode()); + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsDatasourceConfigTest.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsDatasourceConfigTest.java new file mode 100644 index 0000000000..974b6e660b --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsDatasourceConfigTest.java @@ -0,0 +1,124 @@ +package org.lowcoder.plugin.es; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.function.Function; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.es.model.EsDatasourceConfig; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.slf4j.LoggerFactory; + +/** + * {@link EsDatasourceConfig}: merging an edited config with the stored one, and the password encryption hooks. The log + * of the failure path is read through a logback list appender on the class's logger. + */ +public class EsDatasourceConfigTest { + + private static final String PASSWORD = "plain-secret"; + + private final Logger configLogger = (Logger) LoggerFactory.getLogger(EsDatasourceConfig.class); + private final ListAppender logs = new ListAppender<>(); + + @BeforeEach + public void captureLog() { + logs.start(); + configLogger.addAppender(logs); + } + + @AfterEach + public void releaseLog() { + configLogger.detachAppender(logs); + logs.stop(); + } + + private static EsDatasourceConfig config(String password) { + return EsDatasourceConfig.builder().connectionString("host:9200").username("elastic").password(password).usingSsl(true).build(); + } + + @Test + public void mergeKeepsTheStoredPasswordWhenTheUpdateHasNoneAndTakesTheUpdatedValuesOtherwise() { + EsDatasourceConfig stored = config("stored-secret"); + EsDatasourceConfig update = EsDatasourceConfig.builder().connectionString("other:9201").username("kibana").usingSsl(false).build(); + + EsDatasourceConfig merged = (EsDatasourceConfig) stored.mergeWithUpdatedConfig(update); + EsDatasourceConfig withNewPassword = (EsDatasourceConfig) stored.mergeWithUpdatedConfig( + EsDatasourceConfig.builder().connectionString("other:9201").password("new-secret").build()); + + assertEquals("stored-secret", merged.getPassword(), "the password the update does not carry is kept"); + assertEquals("other:9201", merged.getConnectionString()); + assertEquals("kibana", merged.getUsername()); + assertEquals(false, merged.getUsingSsl()); + assertEquals("new-secret", withNewPassword.getPassword()); + assertNotSame(stored, merged); + } + + @Test + public void mergeRejectsAConfigOfAnotherType() { + DatasourceConnectionConfig foreign = other -> other; + + BizException failure = assertThrows(BizException.class, () -> config("x").mergeWithUpdatedConfig(foreign)); + + System.out.println("[EsDatasourceConfigTest] foreign type -> " + failure.getMessage()); + } + + @Test + public void encryptAndDecryptApplyTheFunctionToThePasswordAndReturnTheSameObject() { + EsDatasourceConfig config = config("abc"); + + DatasourceConnectionConfig encrypted = config.doEncrypt(text -> "enc(" + text + ")"); + assertSame(config, encrypted); + assertEquals("enc(abc)", config.getPassword()); + + DatasourceConnectionConfig decrypted = config.doDecrypt(text -> text.substring(4, text.length() - 1)); + assertSame(config, decrypted); + assertEquals("abc", config.getPassword()); + assertTrue(logs.list.isEmpty()); + } + + /** + * DEFECT D8 pinned (analysis-plugins section 0.6; plan section 9 "D1-D20" row; D-6, fix deferred): when the + * encryption or decryption function throws, {@code EsDatasourceConfig} logs {@code "fail to encrypt password: {}"} + * with the password itself as the argument (EsDatasourceConfig.java:54 and :65; the decrypt branch even says + * "encrypt"), at ERROR level, together with the exception. The password stays in the object and the object is + * returned unchanged. The obvious fix is to log without the password, which turns the log assertions red. + */ + @Test + public void whenTheFunctionThrowsTheObjectIsUnchangedAndThePasswordIsWrittenToTheLogD8() { + Function failing = text -> { + throw new IllegalStateException("key unavailable"); + }; + EsDatasourceConfig forEncrypt = config(PASSWORD); + EsDatasourceConfig forDecrypt = config(PASSWORD); + + DatasourceConnectionConfig afterEncrypt = forEncrypt.doEncrypt(failing); + DatasourceConnectionConfig afterDecrypt = forDecrypt.doDecrypt(failing); + + assertSame(forEncrypt, afterEncrypt); + assertSame(forDecrypt, afterDecrypt); + assertEquals(PASSWORD, forEncrypt.getPassword()); + assertEquals(PASSWORD, forDecrypt.getPassword()); + List events = logs.list; + events.forEach(event -> System.out.println("[EsDatasourceConfigTest] " + event.getLevel() + " " + event.getFormattedMessage())); + assertEquals(2, events.size()); + events.forEach(event -> { + assertEquals(Level.ERROR, event.getLevel()); + assertTrue(event.getFormattedMessage().contains(PASSWORD), "the plaintext password is in the log: " + event.getFormattedMessage()); + assertTrue(event.getThrowableProxy().getMessage().contains("key unavailable")); + }); + assertFalse(events.get(1).getFormattedMessage().contains("decrypt"), "the decrypt branch reports an encrypt failure"); + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsDisallowedHostsTextCompareTest.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsDisallowedHostsTextCompareTest.java new file mode 100644 index 0000000000..f3390b6142 --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsDisallowedHostsTextCompareTest.java @@ -0,0 +1,70 @@ +package org.lowcoder.plugin.es; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.es.model.EsConnection; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.exception.BizException; + +/** + * DEFECT pinned (probe EP2; plan section 9 row "SSRF guard by string: ES disallowed-hosts compares host text (:97) + * without resolving; localhost and 127.1 reach the server"; D-6, fix deferred). {@code EsConnector.buildRestClient} + * (EsConnector.java:97) tests {@code disallowedHosts.contains()}: the text is never + * resolved. With {@code disallowedHosts = {127.0.0.1}}, {@code 127.0.0.1} is rejected but {@code localhost} and + * {@code 127.1} (both the same loopback address) are connected to. The fix (resolve the host and compare addresses, or + * reuse the SafeHostResolverGroup check) makes the two bypass assertions red. Only my own loopback server on port 0 is + * contacted. + */ +public class EsDisallowedHostsTextCompareTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(20); + private static final String BLOCKED_LITERAL = "127.0.0.1"; + + private final EsConnector guarded = guardedConnector(); + + private static EsConnector guardedConnector() { + CommonConfig commonConfig = new CommonConfig(); + commonConfig.setDisallowedHosts(Set.of(BLOCKED_LITERAL)); + return new EsConnector(new ConfigCenterForTest(), commonConfig); + } + + private int requestsReachingTheServerFor(String host) throws Exception { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/", new Response(200, Map.of(), null)))) { + String connectionString = host + ":" + server.port(); + DatasourceTestResult result = guarded.testConnection(guarded.resolveConfig(Map.of("connectionString", connectionString))).block(TIMEOUT); + System.out.println("[EsDisallowedHostsTextCompareTest] disallowedHosts={" + BLOCKED_LITERAL + "} connectionString '" + connectionString + + "' -> success " + result.isSuccess() + ", requests at my server " + server.requests().size()); + return server.requests().size(); + } + } + + @Test + public void theLiteralAddressIsBlockedButLocalhostAndShortLoopbackReachTheServerEP2() throws Exception { + // the guard throws inside createConnection, which testConnection reports as a failed result without any request + assertEquals(0, requestsReachingTheServerFor(BLOCKED_LITERAL)); + // the same machine under another spelling is not blocked: the request arrives + assertEquals(1, requestsReachingTheServerFor("localhost"), "localhost is not compared by address"); + assertEquals(1, requestsReachingTheServerFor("127.1"), "127.1 is not compared by address"); + } + + @Test + public void createConnectionAcceptsLocalhostWhileRejectingTheLiteral() throws Exception { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/", new Response(200, Map.of(), null)))) { + org.junit.jupiter.api.Assertions.assertThrows(BizException.class, () -> guarded + .createConnection(guarded.resolveConfig(Map.of("connectionString", BLOCKED_LITERAL + ":" + server.port()))).block(TIMEOUT)); + try (EsConnection connection = guarded.createConnection(guarded.resolveConfig(Map.of("connectionString", "localhost:" + server.port()))).block(TIMEOUT)) { + assertNotNull(connection, "createConnection for 'localhost' succeeds although it is 127.0.0.1"); + } + } + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsDslTextContractTest.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsDslTextContractTest.java new file mode 100644 index 0000000000..78ef9e1f4c --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsDslTextContractTest.java @@ -0,0 +1,74 @@ +package org.lowcoder.plugin.es; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.es.model.EsQueryExecutionContext; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.RepresentativeInput; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Group {@code tree-text}, its {@code elasticSearchPlugin} row (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.4): + * {@code EsQueryExecutor.buildQueryExecutionContext} renders the query DSL with {@code renderMustacheJsonString}, + * whose text is {@code JsonNode#toString} (E13), and sends that text as the request entity. The DSL of each template + * of {@link #DSLS} rendered with {@link #PARAMS} is pinned exactly, one line per template, in {@value #REPORT}. + * + *

    Limits: the DSL is read from the built context; sending it is the low-level REST client's. + */ +public class EsDslTextContractTest { + + static final String REPORT = "tree-text/EsQueryExecutor.dsl.txt"; + static final String SEPARATOR = "\t"; + static final String NEWLINE = "\n"; + static final Map PARAMS = params(); + static final Map DSLS = dsls(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final EsQueryExecutor executor = new EsQueryExecutor(); + + @BoundarySites("lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java#EsQueryExecutor.buildQueryExecutionContext#renderMustacheJsonString#1") + @Test + public void dslAsRendered() { + StringBuilder text = new StringBuilder(); + DSLS.forEach((name, dsl) -> { + String rendered; + try { + EsQueryExecutionContext context = executor.buildQueryExecutionContext(null, + Map.of("httpMethod", "POST", "path", "logs/_search", "dsl", dsl), PARAMS, null); + rendered = context.getDsl(); + } catch (RuntimeException e) { + rendered = "ERROR " + ConfigBinding.errorText(e); + } + text.append(name).append(SEPARATOR).append(rendered).append(NEWLINE); + }); + System.out.println("[EsDslTextContractTest]\n" + text); + GOLDEN.assertText(REPORT, text.toString()); + } + + private static Map params() { + Map params = new LinkedHashMap<>(); + params.put("size", 10); + params.put("from", 3_000_000_001L); + params.put("q", "kůň \"quoted\" \\"); + Map range = new LinkedHashMap<>(); + range.put("gte", new BigDecimal("1.50")); + range.put("lt", new BigInteger("9223372036854775808")); + range.put("boost", 0.1); + params.put("range", range); + return params; + } + + private static Map dsls() { + Map dsls = new LinkedHashMap<>(); + dsls.put("representativeInput", RepresentativeInput.TEXT.trim()); + dsls.put("search", "{\"size\": {{size}}, \"from\": {{from}}, \"query\": {\"match\": {\"text\": \"{{q}}\"}}, \"range\": {\"price\": {{range}}}, \"min_score\": 1.50}"); + dsls.put("nonJson", "size={{size}}"); + return dsls; + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsHeadQueryResultTest.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsHeadQueryResultTest.java new file mode 100644 index 0000000000..160b51dcdc --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsHeadQueryResultTest.java @@ -0,0 +1,52 @@ +package org.lowcoder.plugin.es; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.es.model.EsConnection; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.models.QueryExecutionResult; + +/** + * DEFECT pinned (probe EP3; plan section 9 row "ES HEAD query answered 200 becomes a failed result (:82, :88-92)"; + * D-6, fix deferred). A HEAD answer has no entity, so {@code EntityUtils.toString(response.getEntity())} in + * EsQueryExecutor.java:82 throws an IllegalArgumentException (not an IOException, so the catch at :84-85 is skipped) and + * {@code onErrorResume} (:88-92) turns a 200 into a failed result (query code ES_EXECUTION_ERROR, message key + * ES_QUERY_ERROR, the exception text as message) with no data, which is what a user sees for an index-exists check. The fix (treat a null entity as an empty success) makes the failure + * assertions red. Only my own loopback server on port 0 is contacted. + */ +public class EsHeadQueryResultTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(20); + + @Test + public void aHeadQueryAnswered200BecomesAFailedResultEP3() throws Exception { + EsConnector connector = new EsConnector(new ConfigCenterForTest(), new CommonConfig()); + EsQueryExecutor executor = new EsQueryExecutor(); + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/idx", new Response(200, Map.of(), null))); + EsConnection connection = connector.createConnection(connector.resolveConfig(Map.of("connectionString", server.baseUrl()))).block(TIMEOUT)) { + + QueryExecutionResult result = executor.executeQuery(connection, + executor.buildQueryExecutionContext(null, Map.of("httpMethod", "HEAD", "path", "idx"), Map.of(), null)).block(TIMEOUT); + + System.out.println("[EsHeadQueryResultTest] HEAD /idx answered 200 -> success " + result.isSuccess() + ", code " + result.getQueryCode() + ", messageKey " + result.getMessageKey() + + ", data " + result.getData() + ", args " + java.util.Arrays.toString(result.getMessageArgs()) + ", message " + result.getMessage()); + assertEquals(1, server.requests().size()); + assertEquals("HEAD", server.requests().get(0).method()); + assertFalse(result.isSuccess(), "a 200 answer is reported as a failure"); + assertEquals("ES_EXECUTION_ERROR", result.getQueryCode()); + assertEquals("ES_QUERY_ERROR", result.getMessageKey()); + assertTrue("Entity may not be null".equals(result.getMessageArgs()[0]), "the exception about the missing entity is the message"); + assertNull(result.getData()); + } + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsQueryExecutorHttpTest.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsQueryExecutorHttpTest.java new file mode 100644 index 0000000000..c1e48ca1b8 --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsQueryExecutorHttpTest.java @@ -0,0 +1,112 @@ +package org.lowcoder.plugin.es; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.es.model.EsConnection; +import org.lowcoder.plugin.es.model.EsQueryExecutionContext; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.models.QueryExecutionResult; + +/** + * {@link EsQueryExecutor} against a local server: the HTTP method, path and body that reach it, and the path + * normalisation of {@code buildQueryExecutionContext}. Response parsing is pinned by EsResultContractTest. + */ +public class EsQueryExecutorHttpTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(20); + private static final String DSL = "{\"query\":{\"match_all\":{}}}"; + + private final EsQueryExecutor executor = new EsQueryExecutor(); + private final EsConnector connector = new EsConnector(new ConfigCenterForTest(), new CommonConfig()); + private RecordingHttpServer server; + private EsConnection connection; + + @BeforeEach + public void connect() { + server = RecordingHttpServer.serve(Map.of("/idx/_doc", request -> new Response(200, + Map.of("Content-Type", List.of("application/json")), "{\"ok\":true}".getBytes(StandardCharsets.UTF_8)))); + connection = connector.createConnection(connector.resolveConfig(Map.of("connectionString", server.baseUrl()))).block(TIMEOUT); + } + + @AfterEach + public void disconnect() throws Exception { + connection.close(); + server.close(); + } + + private RecordingHttpServer.Request send(String method, String dsl) { + Map queryConfig = Map.of("httpMethod", method, "path", "idx/_doc", "dsl", dsl); + int before = server.requests().size(); + executor.executeQuery(connection, executor.buildQueryExecutionContext(null, queryConfig, Map.of(), null)).block(TIMEOUT); + assertEquals(before + 1, server.requests().size()); + return server.requests().get(before); + } + + /** + * A HEAD with a DSL is not sent at all (the REST client refuses a body on HEAD before any request), so HEAD is only + * checked without one; the outcome of a HEAD query is probe EP3, reported in log-L4.md and not pinned. + */ + @Test + public void theMethodPathAndBodyReachTheServerAndABlankDslSendsNoBody() { + for (String method : List.of("GET", "POST", "PUT", "DELETE", "HEAD")) { + RecordingHttpServer.Request withoutDsl = send(method, " "); + assertEquals(method, withoutDsl.method()); + assertEquals("/idx/_doc", withoutDsl.pathAndQuery()); + assertEquals("", withoutDsl.bodyText(), method + " with a blank DSL sends no body"); + if (method.equals("HEAD")) { + System.out.println("[EsQueryExecutorHttpTest] HEAD -> " + withoutDsl.pathAndQuery() + " without a body"); + continue; + } + RecordingHttpServer.Request withDsl = send(method, DSL); + System.out.println("[EsQueryExecutorHttpTest] " + method + " -> " + withDsl.pathAndQuery() + " body '" + withDsl.bodyText() + + "', blank dsl -> body '" + withoutDsl.bodyText() + "'"); + assertEquals(method, withDsl.method()); + assertEquals("/idx/_doc", withDsl.pathAndQuery()); + assertEquals(DSL, withDsl.bodyText(), method + " sends the DSL as its JSON body"); + assertTrue(withDsl.header("Content-Type").get(0).startsWith("application/json")); + } + } + + @Test + public void theResultOfASuccessfulRequestIsTheParsedJsonObject() { + Map queryConfig = Map.of("httpMethod", "GET", "path", "idx/_doc"); + + QueryExecutionResult result = executor.executeQuery(connection, executor.buildQueryExecutionContext(null, queryConfig, Map.of(), null)) + .block(TIMEOUT); + + assertTrue(result.isSuccess()); + assertEquals(Map.of("ok", true), result.getData()); + } + + @Test + public void prefixPathAndSuffixAreTrimmedRenderedAndJoinedWithSingleSlashes() { + Map params = Map.of("index", "logs-1", "id", "7"); + Map> cases = new java.util.LinkedHashMap<>(); + cases.put("logs-1/_doc/7", Map.of("prefix", " /{{index}}/ ", "path", "//_doc//", "suffix", " {{id}} ")); + cases.put("a/b/c/d", Map.of("prefix", "a/b", "path", "c", "suffix", "d/")); + cases.put("only", Map.of("path", " /only/ ")); + cases.put("", Map.of()); + cases.put("x", Map.of("prefix", " ", "path", "", "suffix", "/x")); + cases.forEach((expected, parts) -> { + Map queryConfig = new java.util.HashMap<>(parts); + queryConfig.put("httpMethod", "GET"); + + EsQueryExecutionContext context = executor.buildQueryExecutionContext(null, queryConfig, params, null); + + assertEquals(expected, context.getPath(), parts.toString()); + assertEquals("", context.getDsl()); + }); + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsResultContractTest.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsResultContractTest.java new file mode 100644 index 0000000000..bdb55ae252 --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsResultContractTest.java @@ -0,0 +1,104 @@ +package org.lowcoder.plugin.es; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.es.model.EsConnection; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.contract.RepresentativeInput; +import org.lowcoder.sdk.models.QueryExecutionResult; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Group {@code external-json} of {@code EsQueryExecutor} and its row of the §4.6 producer table + * (docs/API_PAYLOAD_TEST_PLAN.md §4.6, §4.10, task T8.2): a connection made by {@code EsConnector.createConnection} + * (Elasticsearch's low-level REST client) sends each query to a local server ({@link RecordingHttpServer}) that + * answers the case of {@link #RESPONSES}; {@code executeQuery} reads the body with {@code JsonUtils.fromJsonMap} and + * puts the {@code Map} into the result. A body that is not a JSON object (an array, invalid JSON) is read as + * {@code null}, so the query succeeds without data (O76); a status that is not 2xx is an {@code ES_QUERY_ERROR} + * result. Each case's report ({@link QueryResults#report}) is pinned in {@value #REPORT}. + * + *

    Limits: the server is not Elasticsearch; the error result's message arguments (which name the local port) are + * not pinned. + */ +public class EsResultContractTest { + + static final String REPORT = "external-json/EsQueryExecutor.results.json"; + static final String CONTENT_TYPE = "Content-Type"; + static final String JSON = "application/json"; + static final String DSL = "{\"query\": {\"match_all\": {}}}"; + static final int OK = 200; + static final int NOT_FOUND = 404; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map RESPONSES = responses(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + private static RecordingHttpServer server; + + private final EsQueryExecutor executor = new EsQueryExecutor(); + + @BeforeAll + public static void startServer() { + server = RecordingHttpServer.start(RESPONSES); + } + + @AfterAll + public static void stopServer() { + server.close(); + } + + @BoundarySites("lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java#EsQueryExecutor.executeQuery#fromJsonMap#1") + @Test + public void resultsAsPinned() throws IOException { + EsConnector connector = new EsConnector(new ConfigCenterForTest(), new CommonConfig()); + Map report = new LinkedHashMap<>(); + try (EsConnection connection = connector.createConnection(connector.resolveConfig(Map.of("connectionString", server.baseUrl()))) + .block(TIMEOUT)) { + for (String path : RESPONSES.keySet()) { + report.put(path, report(connection, path)); + } + } + String actual = ConfigBinding.write(report); + System.out.println("[EsResultContractTest] " + server.requests().size() + " requests\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + private Object report(EsConnection connection, String path) { + Map queryConfig = Map.of("httpMethod", "POST", "path", path.substring(1), "dsl", DSL); + try { + QueryExecutionResult result = executor.executeQuery(connection, + executor.buildQueryExecutionContext(null, queryConfig, Map.of(), null)).block(TIMEOUT); + return result == null ? null : QueryResults.report(result); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + } + + private static Map responses() { + Map> json = Map.of(CONTENT_TYPE, List.of(JSON)); + Map responses = new LinkedHashMap<>(); + responses.put("/representative/_search", new Response(OK, json, utf8("{\"took\":1,\"hits\":{\"hits\":[{\"_source\":" + + RepresentativeInput.TEXT + "}]}}"))); + responses.put("/array/_cat", new Response(OK, json, utf8("[{\"index\":\"logs\"}]"))); + responses.put("/invalid/_search", new Response(OK, json, utf8("{\"took\":"))); + responses.put("/missing/_search", new Response(NOT_FOUND, json, utf8("{\"error\":{\"type\":\"index_not_found_exception\"},\"status\":404}"))); + return responses; + } + + private static byte[] utf8(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsTestConnectionPasswordLogTest.java b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsTestConnectionPasswordLogTest.java new file mode 100644 index 0000000000..ef54962c3b --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/java/org/lowcoder/plugin/es/EsTestConnectionPasswordLogTest.java @@ -0,0 +1,94 @@ +package org.lowcoder.plugin.es; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.ServerSocket; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.slf4j.LoggerFactory; + +/** + * DEFECT pinned (probe EP4; plan section 9 row "secret in log: failed ES testConnection logs toJson(config) with the + * plaintext password (:162, :166)", D8-adjacent; D-6, fix deferred). {@code EsConnector.testConnection} logs + * {@code JsonUtils.toJson(connectionConfig)} at ERROR when the answer is not 200 (EsConnector.java:162, "test es + * fail.") and when the call fails (:166, "test es error."). The JSON contains the plaintext password because no Jackson + * view is active. The fix (log without the config, or with the password masked) makes the contains-password assertions + * red. Only my own loopback server on port 0 (and a port I opened and closed) is contacted. + */ +public class EsTestConnectionPasswordLogTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(20); + private static final String USER = "elastic"; + private static final String PASSWORD = "s3cret-for-log"; + + private final EsConnector connector = new EsConnector(new ConfigCenterForTest(), new CommonConfig()); + private final Logger connectorLogger = (Logger) LoggerFactory.getLogger(EsConnector.class); + private final ListAppender logs = new ListAppender<>(); + + @BeforeEach + public void captureLog() { + logs.start(); + connectorLogger.addAppender(logs); + } + + @AfterEach + public void releaseLog() { + connectorLogger.detachAppender(logs); + logs.stop(); + } + + private List errorsWithPassword() { + logs.list.forEach(event -> System.out.println("[EsTestConnectionPasswordLogTest] " + event.getLevel() + " " + + event.getFormattedMessage().replace(PASSWORD, "<>") + (event.getFormattedMessage().contains(PASSWORD) ? " [contains the password]" : ""))); + return logs.list.stream().filter(event -> event.getLevel() == Level.ERROR && event.getFormattedMessage().contains(PASSWORD)).collect(Collectors.toList()); + } + + @Test + public void aNon200AnswerLogsTheWholeConfigIncludingThePasswordAtErrorD8() throws Exception { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/", new Response(204, Map.of(), null)))) { + + DatasourceTestResult result = connector.testConnection( + connector.resolveConfig(Map.of("connectionString", server.baseUrl(), "username", USER, "password", PASSWORD))).block(TIMEOUT); + + assertFalse(result.isSuccess()); + List leaking = errorsWithPassword(); + assertEquals(1, leaking.size(), "one ERROR event carries the password"); + assertTrue(leaking.get(0).getFormattedMessage().startsWith("test es fail."), leaking.get(0).getFormattedMessage()); + assertTrue(leaking.get(0).getFormattedMessage().contains(server.baseUrl())); + } + } + + @Test + public void aFailedCallLogsTheWholeConfigIncludingThePasswordAtErrorD8() throws Exception { + int closedPort; + try (ServerSocket socket = new ServerSocket(0)) { + closedPort = socket.getLocalPort(); + } + + DatasourceTestResult result = connector.testConnection( + connector.resolveConfig(Map.of("connectionString", "127.0.0.1:" + closedPort, "username", USER, "password", PASSWORD))).block(TIMEOUT); + + assertFalse(result.isSuccess()); + List leaking = errorsWithPassword(); + assertEquals(1, leaking.size(), "exactly one ERROR event carries the password"); + assertTrue(leaking.get(0).getFormattedMessage().startsWith("test es error."), leaking.get(0).getFormattedMessage()); + assertTrue(leaking.get(0).getFormattedMessage().contains("\"username\":\"" + USER + "\"")); + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsDatasourceConfig.input.json new file mode 100644 index 0000000000..e62c527369 --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsDatasourceConfig.input.json @@ -0,0 +1,13 @@ +{ + "full": { + "connectionString": "https://es.example.com:9200", + "username": "elastic", + "password": "pw", + "usingSsl": true + }, + "scalarsAsStrings": { + "connectionString": "http://es:9200", + "usingSsl": "false" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsDatasourceConfig.json b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsDatasourceConfig.json new file mode 100644 index 0000000000..37a998a6be --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsDatasourceConfig.json @@ -0,0 +1,65 @@ +{ + "EsConnector.resolveConfig" : { + "full" : { + "class" : "org.lowcoder.plugin.es.model.EsDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.es.model.EsDatasourceConfig", + "connectionString" : "https://es.example.com:9200", + "username" : "elastic", + "password" : "pw", + "usingSsl" : true + }, + "classes" : { + "" : "org.lowcoder.plugin.es.model.EsDatasourceConfig", + "/connectionString" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/usingSsl" : "java.lang.Boolean" + }, + "noView" : {"connectionString":"https://es.example.com:9200","username":"elastic","password":"pw","usingSsl":true}, + "Public" : {"connectionString":"https://es.example.com:9200","username":"elastic","usingSsl":true}, + "Internal" : {"connectionString":"https://es.example.com:9200","username":"elastic","password":"pw","usingSsl":true} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.es.model.EsDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.es.model.EsDatasourceConfig", + "connectionString" : "http://es:9200", + "username" : null, + "password" : null, + "usingSsl" : false + }, + "classes" : { + "" : "org.lowcoder.plugin.es.model.EsDatasourceConfig", + "/connectionString" : "java.lang.String", + "/username" : "null", + "/password" : "null", + "/usingSsl" : "java.lang.Boolean" + }, + "noView" : {"connectionString":"http://es:9200","username":null,"password":null,"usingSsl":false}, + "Public" : {"connectionString":"http://es:9200","username":null,"usingSsl":false}, + "Internal" : {"connectionString":"http://es:9200","username":null,"password":null,"usingSsl":false} + }, + "empty" : { + "class" : "org.lowcoder.plugin.es.model.EsDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.es.model.EsDatasourceConfig", + "connectionString" : null, + "username" : null, + "password" : null, + "usingSsl" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.es.model.EsDatasourceConfig", + "/connectionString" : "null", + "/username" : "null", + "/password" : "null", + "/usingSsl" : "null" + }, + "noView" : {"connectionString":null,"username":null,"password":null,"usingSsl":null}, + "Public" : {"connectionString":null,"username":null,"usingSsl":null}, + "Internal" : {"connectionString":null,"username":null,"password":null,"usingSsl":null} + }, + "unknownProperty" : "ignored: binds as form full" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsQueryConfig.input.json b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsQueryConfig.input.json new file mode 100644 index 0000000000..e01885b038 --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsQueryConfig.input.json @@ -0,0 +1,21 @@ +{ + "full": { + "esMethod": "search", + "prefix": "/{{index}}", + "suffix": "_search/", + "path": " docs ", + "httpMethod": "POST", + "dsl": "{\"query\": {\"match\": {\"title\": {{q}}}}, \"size\": 10}" + }, + "lowerCaseMethod": { + "httpMethod": "get", + "path": "_cat/indices" + }, + "unknownMethod": { + "httpMethod": "PURGE", + "path": "x" + }, + "noMethod": { + "path": "x" + } +} diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsQueryConfig.json b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsQueryConfig.json new file mode 100644 index 0000000000..b88326c46c --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/config-binding/EsQueryConfig.json @@ -0,0 +1,85 @@ +{ + "EsQueryExecutor.buildQueryExecutionContext" : { + "full" : { + "class" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "fields" : { + "@class" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "httpMethod" : "POST", + "path" : "logs-2026/docs/_search", + "dsl" : "{\"query\":{\"match\":{\"title\":\"kůň \\\"quoted\\\"\"}},\"size\":10}", + "visitorId" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "/httpMethod" : "org.springframework.http.HttpMethod", + "/path" : "java.lang.String", + "/dsl" : "java.lang.String", + "/visitorId" : "null" + }, + "noView" : {"visitorId":null,"httpMethod":{},"path":"logs-2026/docs/_search","dsl":"{\"query\":{\"match\":{\"title\":\"kůň \\\"quoted\\\"\"}},\"size\":10}"}, + "Public" : {"visitorId":null,"httpMethod":{},"path":"logs-2026/docs/_search","dsl":"{\"query\":{\"match\":{\"title\":\"kůň \\\"quoted\\\"\"}},\"size\":10}"}, + "Internal" : {"visitorId":null,"httpMethod":{},"path":"logs-2026/docs/_search","dsl":"{\"query\":{\"match\":{\"title\":\"kůň \\\"quoted\\\"\"}},\"size\":10}"} + }, + "lowerCaseMethod" : { + "class" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "fields" : { + "@class" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "httpMethod" : "get", + "path" : "_cat/indices", + "dsl" : "", + "visitorId" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "/httpMethod" : "org.springframework.http.HttpMethod", + "/path" : "java.lang.String", + "/dsl" : "java.lang.String", + "/visitorId" : "null" + }, + "noView" : {"visitorId":null,"httpMethod":{},"path":"_cat/indices","dsl":""}, + "Public" : {"visitorId":null,"httpMethod":{},"path":"_cat/indices","dsl":""}, + "Internal" : {"visitorId":null,"httpMethod":{},"path":"_cat/indices","dsl":""} + }, + "unknownMethod" : { + "class" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "fields" : { + "@class" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "httpMethod" : "PURGE", + "path" : "x", + "dsl" : "", + "visitorId" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "/httpMethod" : "org.springframework.http.HttpMethod", + "/path" : "java.lang.String", + "/dsl" : "java.lang.String", + "/visitorId" : "null" + }, + "noView" : {"visitorId":null,"httpMethod":{},"path":"x","dsl":""}, + "Public" : {"visitorId":null,"httpMethod":{},"path":"x","dsl":""}, + "Internal" : {"visitorId":null,"httpMethod":{},"path":"x","dsl":""} + }, + "noMethod" : { + "class" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "fields" : { + "@class" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "httpMethod" : null, + "path" : "x", + "dsl" : "", + "visitorId" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.es.model.EsQueryExecutionContext", + "/httpMethod" : "null", + "/path" : "java.lang.String", + "/dsl" : "java.lang.String", + "/visitorId" : "null" + }, + "noView" : {"visitorId":null,"httpMethod":null,"path":"x","dsl":""}, + "Public" : {"visitorId":null,"httpMethod":null,"path":"x","dsl":""}, + "Internal" : {"visitorId":null,"httpMethod":null,"path":"x","dsl":""} + }, + "unknownProperty" : "ignored: binds as form full" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/external-json/EsQueryExecutor.results.json b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/external-json/EsQueryExecutor.results.json new file mode 100644 index 0000000000..a38fe0cc7f --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/external-json/EsQueryExecutor.results.json @@ -0,0 +1,102 @@ +{ + "/representative/_search" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/took" : "java.lang.Integer", + "/hits" : "java.util.LinkedHashMap", + "/hits/hits" : "java.util.ArrayList", + "/hits/hits/0" : "java.util.LinkedHashMap", + "/hits/hits/0/_source" : "java.util.LinkedHashMap", + "/hits/hits/0/_source/zeta" : "java.lang.String", + "/hits/hits/0/_source/int" : "java.lang.Integer", + "/hits/hits/0/_source/negativeInt" : "java.lang.Integer", + "/hits/hits/0/_source/intMax" : "java.lang.Integer", + "/hits/hits/0/_source/intMaxPlusOne" : "java.lang.Long", + "/hits/hits/0/_source/intMinMinusOne" : "java.lang.Long", + "/hits/hits/0/_source/longMax" : "java.lang.Long", + "/hits/hits/0/_source/longMaxPlusOne" : "java.math.BigInteger", + "/hits/hits/0/_source/longMinMinusOne" : "java.math.BigInteger", + "/hits/hits/0/_source/decimal" : "java.lang.Double", + "/hits/hits/0/_source/trailingZeroDecimal" : "java.lang.Double", + "/hits/hits/0/_source/zeroPointZero" : "java.lang.Double", + "/hits/hits/0/_source/negativeZero" : "java.lang.Integer", + "/hits/hits/0/_source/exponent" : "java.lang.Double", + "/hits/hits/0/_source/exponentUpperCase" : "java.lang.Double", + "/hits/hits/0/_source/negativeExponent" : "java.lang.Double", + "/hits/hits/0/_source/longDecimal" : "java.lang.Double", + "/hits/hits/0/_source/true" : "java.lang.Boolean", + "/hits/hits/0/_source/false" : "java.lang.Boolean", + "/hits/hits/0/_source/null" : "null", + "/hits/hits/0/_source/emptyObject" : "java.util.LinkedHashMap", + "/hits/hits/0/_source/emptyArray" : "java.util.ArrayList", + "/hits/hits/0/_source/nested" : "java.util.LinkedHashMap", + "/hits/hits/0/_source/nested/array" : "java.util.ArrayList", + "/hits/hits/0/_source/nested/array/0" : "java.lang.Integer", + "/hits/hits/0/_source/nested/array/1" : "java.lang.String", + "/hits/hits/0/_source/nested/array/2" : "java.util.ArrayList", + "/hits/hits/0/_source/nested/array/2/0" : "java.lang.Integer", + "/hits/hits/0/_source/nested/array/2/1" : "java.util.LinkedHashMap", + "/hits/hits/0/_source/nested/array/2/1/four" : "java.lang.Double", + "/hits/hits/0/_source/nested/array/3" : "null", + "/hits/hits/0/_source/nested/array/4" : "java.lang.Boolean", + "/hits/hits/0/_source/nested/array/5" : "java.lang.Long", + "/hits/hits/0/_source/nested/object" : "java.util.LinkedHashMap", + "/hits/hits/0/_source/nested/object/z" : "java.lang.Integer", + "/hits/hits/0/_source/nested/object/a" : "java.util.LinkedHashMap", + "/hits/hits/0/_source/nested/object/a/deeper" : "java.util.ArrayList", + "/hits/hits/0/_source/nested/object/a/deeper/0" : "java.util.ArrayList", + "/hits/hits/0/_source/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/hits/hits/0/_source/unicode" : "java.lang.String", + "/hits/hits/0/_source/escapes" : "java.lang.String", + "/hits/hits/0/_source/key with escaped name" : "java.lang.String", + "/hits/hits/0/_source/alpha" : "java.lang.String" + }, + "keyOrder" : { + "/hits/hits/0/_source/emptyObject" : [ ], + "/hits/hits/0/_source/nested/array/2/1" : [ "four" ], + "/hits/hits/0/_source/nested/object/a/deeper/1" : [ ], + "/hits/hits/0/_source/nested/object/a" : [ "deeper" ], + "/hits/hits/0/_source/nested/object" : [ "z", "a" ], + "/hits/hits/0/_source/nested" : [ "array", "object" ], + "/hits/hits/0/_source" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ], + "/hits/hits/0" : [ "_source" ], + "/hits" : [ "hits" ], + "" : [ "took", "hits" ] + } + }, + "written" : {"queryCode":"OK","data":{"took":1,"hits":{"hits":[{"_source":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"}}]}},"success":true} + }, + "/array/_cat" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","success":true} + }, + "/invalid/_search" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","success":true} + }, + "/missing/_search" : { + "queryCode" : "ES_EXECUTION_ERROR", + "messageKey" : "ES_QUERY_ERROR", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"ES_EXECUTION_ERROR","success":false} + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/tree-text/EsQueryExecutor.dsl.txt b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/tree-text/EsQueryExecutor.dsl.txt new file mode 100644 index 0000000000..7f7b980a72 --- /dev/null +++ b/server/api-service/lowcoder-plugins/elasticSearchPlugin/src/test/resources/json-contract/tree-text/EsQueryExecutor.dsl.txt @@ -0,0 +1,3 @@ +representativeInput "{\n \"zeta\": \"first key, so that sorted output would differ from input order\",\n \"int\": 1,\n \"negativeInt\": -40003,\n \"intMax\": 2147483647,\n \"intMaxPlusOne\": 2147483648,\n \"intMinMinusOne\": -2147483649,\n \"longMax\": 9223372036854775807,\n \"longMaxPlusOne\": 9223372036854775808,\n \"longMinMinusOne\": -9223372036854775809,\n \"decimal\": 1.5,\n \"trailingZeroDecimal\": 1.50,\n \"zeroPointZero\": 0.0,\n \"negativeZero\": -0,\n \"exponent\": 1e2,\n \"exponentUpperCase\": 1E+2,\n \"negativeExponent\": 2.5e-3,\n \"longDecimal\": 0.1000000000000000055511151231257827,\n \"true\": true,\n \"false\": false,\n \"null\": null,\n \"emptyObject\": {},\n \"emptyArray\": [],\n \"nested\": {\n \"array\": [1, \"two\", [3, {\"four\": 4.0}], null, true, 3000000001],\n \"object\": {\"z\": 1, \"a\": {\"deeper\": [[], {}]}}\n },\n \"unicode\": \"žluťoučký kůň 🐎 中文\",\n \"escapes\": \"\\u00e9 \\u0000 \\n \\t \\\" \\\\ \\/ \\ud83d\\ude00\",\n \"k\\u0065y with escaped name\": \"escaped key\",\n \"alpha\": \"last key\"\n}" +search {"size":10,"from":3000000001,"query":{"match":{"text":"kůň \"quoted\" \\"}},"range":{"price":{"gte":1.5,"lt":9223372036854775808,"boost":0.1}},"min_score":1.5} +nonJson "size=10" diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsGetPreParameters.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsGetPreParameters.java index ff3fb7b8e3..e2bc879fda 100644 --- a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsGetPreParameters.java +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsGetPreParameters.java @@ -26,6 +26,22 @@ public class GoogleSheetsGetPreParameters { private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); + /** + * Test-only seam (plan section 5 D-1). {@code null} in production, which keeps Google's root URL. It is + * package-private, so neither a datasource configuration nor any class outside {@code queryhandler} can set it. It is a + * JVM-wide static: a test that sets it must reset it, and it does NOT guard against another test class running + * concurrently in the same JVM while it is set (so tests that set it must not run in parallel with other Sheets tests). + */ + private static volatile String rootUrlOverride; + + /** + * Test-only: points {@link #GetSheetsService} at {@code rootUrl}; {@code null} restores Google's root URL. Package-private + * and JVM-wide, see {@link #rootUrlOverride}. + */ + static void setRootUrlForTests(String rootUrl) { + rootUrlOverride = rootUrl; + } + public static Sheets GetSheetsService(GoogleSheetsQueryExecutionContext context) { HttpTransport httpTransport; try { @@ -35,7 +51,12 @@ public static Sheets GetSheetsService(GoogleSheetsQueryExecutionContext context) } final GoogleCredentials googleCredentials = context.getServiceAccountCredentials(); HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(googleCredentials); - return new Builder(httpTransport, JSON_FACTORY, requestInitializer).build(); + Builder builder = new Builder(httpTransport, JSON_FACTORY, requestInitializer); + String rootUrl = rootUrlOverride; + if (rootUrl != null) { + builder.setRootUrl(rootUrl); + } + return builder.build(); } public static SheetChangeSetRow getChangeSet(GoogleSheetsQueryExecutionContext context) { diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsConfigBindingContractTest.java new file mode 100644 index 0000000000..a3dff6aa16 --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsConfigBindingContractTest.java @@ -0,0 +1,71 @@ +package org.lowcoder.plugin.googlesheets; + +import org.apache.commons.collections4.MapUtils; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.googlesheets.GoogleSheetsPlugin.GoogleSheetsEngine; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Map; + +/** + * The {@code googleSheetsPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1): + * {@code GoogleSheetsEngine.resolveConfig} for {@code GoogleSheetsDatasourceConfig}, and the {@code requestClass} + * dispatch of {@code parseGoogleSheetsActionRequest} ({@code GoogleSheetsPlugin.java:85-92}) for the read, clear and + * delete requests, which binds {@code command} to the class its {@code commandType} names. + * + *

    The dispatch is a private method of the engine; it is called by reflection with the two values + * {@code buildQueryExecutionContext} passes it ({@code GoogleSheetsPlugin.java:102-105}), because that caller then + * builds service-account credentials from a real private key. The append and update requests are built by hand + * ({@code GoogleSheetsPlugin.java:79-84}); their change sets are group {@code node-conversion}'s. + */ +public class GoogleSheetsConfigBindingContractTest { + + static final String DISPATCH_METHOD = "parseGoogleSheetsActionRequest"; + static final String COMMAND_TYPE = "commandType"; + static final String COMMAND = "command"; + /** The default {@code buildQueryExecutionContext} reads a missing {@code commandType} as. */ + static final String NO_COMMAND_TYPE = ""; + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/model/GoogleSheetsDatasourceConfig.java#GoogleSheetsDatasourceConfig.buildFrom#toJson#1", + "lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/model/GoogleSheetsDatasourceConfig.java#GoogleSheetsDatasourceConfig.buildFrom#fromJson#1"}) + @Test + public void datasourceConfig() { + GoogleSheetsEngine engine = new GoogleSheetsEngine(); + ConfigBinding.assertBinding(GOLDEN, "config-binding/GoogleSheetsDatasourceConfig.input.json", + "config-binding/GoogleSheetsDatasourceConfig.json", Map.of("GoogleSheetsEngine.resolveConfig", engine::resolveConfig)); + } + + @BoundarySites({ + "lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/GoogleSheetsPlugin.java#GoogleSheetsPlugin.GoogleSheetsEngine.parseGoogleSheetsActionRequest#toJson#1", + "lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/GoogleSheetsPlugin.java#GoogleSheetsPlugin.GoogleSheetsEngine.parseGoogleSheetsActionRequest#fromJson#1"}) + @Test + public void actionRequestDispatch() throws NoSuchMethodException { + GoogleSheetsEngine engine = new GoogleSheetsEngine(); + Method dispatch = GoogleSheetsEngine.class.getDeclaredMethod(DISPATCH_METHOD, String.class, Map.class); + dispatch.setAccessible(true); + ConfigBinding.assertBinding(GOLDEN, "config-binding/GoogleSheetsActionRequest.input.json", + "config-binding/GoogleSheetsActionRequest.json", Map.of(DISPATCH_METHOD, queryConfig -> invoke(dispatch, engine, queryConfig))); + } + + /** Calls the dispatch as {@code buildQueryExecutionContext} does, rethrowing what it throws. */ + @SuppressWarnings("unchecked") + private static Object invoke(Method dispatch, GoogleSheetsEngine engine, Map queryConfig) { + try { + return dispatch.invoke(engine, MapUtils.getString(queryConfig, COMMAND_TYPE, NO_COMMAND_TYPE), (Map) queryConfig.get(COMMAND)); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof RuntimeException runtime) { + throw runtime; + } + throw new IllegalStateException(e.getCause()); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsEngineTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsEngineTest.java new file mode 100644 index 0000000000..00697a2861 --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsEngineTest.java @@ -0,0 +1,252 @@ +package org.lowcoder.plugin.googlesheets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.googlesheets.GoogleSheetsPlugin.GoogleSheetsEngine; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsActionRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsAppendDataRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsClearDataRequst; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsDatasourceConfig; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsDeleteDataRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsQueryExecutionContext; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsReadDataRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsUpdateDataRequest; +import org.lowcoder.plugin.googlesheets.model.ServiceAccountJsonUtils; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.query.QueryVisitorContext; + +import com.google.api.services.sheets.v4.SheetsScopes; + +/** + * {@link GoogleSheetsEngine}: configuration checks, the connection stubs, {@code buildQueryExecutionContext} for every + * action type and for invalid input, {@code executeQuery} with an unknown action type, and the defects D9 + * ({@code toString()} prints the private key) and the testConnection row (reports success without using the service + * account). The service account is generated at test time ({@link ServiceAccountTestKeys}); no network is used. + */ +public class GoogleSheetsEngineTest { + + private static final String VISITOR = "visitor-1"; + private static final Map PARAMS = Map.of("id", "sheet-id-9", "name", "Orders", "row", "7", "who", "Ann"); + private static final Map CHANGE_SET = Map.of("compType", "KEY_VALUE_PAIRS", + "comp", List.of(Map.of("column", "name", "value", "{{who}}"))); + + private final GoogleSheetsEngine engine = new GoogleSheetsEngine(); + private final GoogleSheetsDatasourceConfig config = engine.resolveConfig(Map.of("serviceAccount", ServiceAccountTestKeys.json())); + private final QueryVisitorContext visitor = new QueryVisitorContext(VISITOR, "org-1", 0, null, null, Set.of()); + + private static Map queryConfig(String commandType, Map command) { + Map map = new java.util.HashMap<>(); + map.put("commandType", commandType); + map.put("command", command); + return map; + } + + private static Map command(Object... extra) { + Map command = new java.util.HashMap<>(Map.of("spreadsheetId", "{{id}}", "sheetName", "{{name}}")); + for (int i = 0; i < extra.length; i += 2) { + command.put((String) extra[i], extra[i + 1]); + } + return command; + } + + private GoogleSheetsQueryExecutionContext build(String commandType, Map command) { + return engine.buildQueryExecutionContext(config, queryConfig(commandType, command), PARAMS, visitor); + } + + private PluginException invalid(String commandType, Map command) { + return assertThrows(PluginException.class, () -> build(commandType, command), commandType); + } + + @Test + public void validateConfigRequiresAServiceAccountAndTheConnectionIsASharedStub() { + assertEquals(Set.of(), engine.validateConfig(config)); + for (String blank : new String[] {null, "", " "}) { + Map map = new java.util.HashMap<>(); + map.put("serviceAccount", blank); + assertEquals(Set.of("GOOGLESHEETS_DATASOURCE_CONFIG_ERROR"), engine.validateConfig(engine.resolveConfig(map)), String.valueOf(blank)); + } + Object first = engine.createConnection(config).block(); + Object second = engine.createConnection(null).block(); + System.out.println("[GoogleSheetsEngineTest] connection objects " + first + " / " + second); + assertNotNull(first); + assertSame(first, second, "one shared stub, whatever the config"); + assertEquals(null, engine.destroyConnection(first).block()); + } + + @Test + public void everyActionTypeBuildsItsRequestWithRenderedValuesAndCredentialsFromTheKey() throws IOException { + Map> expected = new java.util.LinkedHashMap<>(); + expected.put("readData", GoogleSheetsReadDataRequest.class); + expected.put("appendData", GoogleSheetsAppendDataRequest.class); + expected.put("updateData", GoogleSheetsUpdateDataRequest.class); + expected.put("clearData", GoogleSheetsClearDataRequst.class); + expected.put("deleteData", GoogleSheetsDeleteDataRequest.class); + for (Map.Entry> entry : expected.entrySet()) { + Map command = command("rowIndexString", "{{row}}", "changeSet", CHANGE_SET); + + GoogleSheetsQueryExecutionContext context = build(entry.getKey(), command); + + System.out.println("[GoogleSheetsEngineTest] " + entry.getKey() + " -> " + context.getGoogleSheetsActionRequest().getClass().getSimpleName()); + assertEquals(entry.getKey(), context.getActionType()); + assertEquals(VISITOR, context.getVisitorId()); + assertEquals(ServiceAccountTestKeys.json(), context.getServiceAccount()); + assertInstanceOf(entry.getValue(), context.getGoogleSheetsActionRequest()); + assertEquals(ServiceAccountTestKeys.CLIENT_EMAIL, context.getServiceAccountCredentials().getClientEmail()); + assertEquals(ServiceAccountTestKeys.CLIENT_ID, context.getServiceAccountCredentials().getClientId()); + assertEquals(ServiceAccountTestKeys.PRIVATE_KEY_ID, context.getServiceAccountCredentials().getPrivateKeyId()); + assertEquals(SheetsScopes.all(), Set.copyOf(context.getServiceAccountCredentials().getScopes())); + } + GoogleSheetsUpdateDataRequest update = (GoogleSheetsUpdateDataRequest) build("updateData", command("rowIndexString", "{{row}}", "changeSet", CHANGE_SET)) + .getGoogleSheetsActionRequest(); + assertEquals("sheet-id-9", update.getSpreadsheetId()); + assertEquals("Orders", update.getSheetName()); + assertEquals(7, update.getRowIndex()); + assertEquals("Ann", update.getChangeSetItems().getItem("name").renderedValue()); + } + + @Test + public void anUnknownOrBlankCommandTypeAndBlankIdentifiersAreRejectedAsEmptyQueryParam() { + for (String type : new String[] {"", "dropTable", "READDATA"}) { + PluginException failure = invalid(type, command()); + System.out.println("[GoogleSheetsEngineTest] type '" + type + "' -> " + failure.getError() + " " + failure.getMessageKey()); + assertEquals(GoogleSheetError.GOOGLESHEETS_EMPTY_QUERY_PARAM, failure.getError()); + assertEquals("GOOGLESHEETS_QUERY_PARAM_EMPTY", failure.getMessageKey()); + } + for (String type : new String[] {"readData", "clearData", "deleteData", "appendData", "updateData"}) { + Map noSheet = command("rowIndexString", "1", "changeSet", CHANGE_SET); + noSheet.put("sheetName", " "); + Map noId = command("rowIndexString", "1", "changeSet", CHANGE_SET); + noId.put("spreadsheetId", "{{missing}}"); + assertEquals(GoogleSheetError.GOOGLESHEETS_EMPTY_QUERY_PARAM, invalid(type, noSheet).getError(), type + " blank sheet name"); + assertEquals(GoogleSheetError.GOOGLESHEETS_EMPTY_QUERY_PARAM, invalid(type, noId).getError(), type + " id renders to nothing"); + } + } + + /** + * DEFECT pinned (new plan section 9 row "Google Sheets: a malformed service-account key fails with a raw + * RuntimeException"; D-6, fix deferred). A user pastes the service-account JSON in the datasource form, and + * validateConfig only checks that it is not blank. When the key is not a PKCS8 PEM, + * {@code ServiceAccountCredentials.fromPkcs8} throws an IOException that GoogleSheetsPlugin.java:125-127 wraps in a + * plain RuntimeException, not a PluginException with a message key, so the caller gets no coded error. A fix that + * throws a PluginException turns the type assertions red. + */ + @Test + public void aMalformedPrivateKeyFailsWithARawRuntimeException() { + Map bad = Map.of("serviceAccount", ServiceAccountTestKeys.json("not a key")); + GoogleSheetsDatasourceConfig badConfig = engine.resolveConfig(bad); + + assertEquals(Set.of(), engine.validateConfig(badConfig), "validateConfig does not look at the key"); + RuntimeException failure = assertThrows(RuntimeException.class, + () -> engine.buildQueryExecutionContext(badConfig, queryConfig("readData", command()), PARAMS, visitor)); + + System.out.println("[GoogleSheetsEngineTest] malformed key -> " + failure.getClass().getName() + ": " + failure.getMessage() + + " (cause " + failure.getCause() + ")"); + assertEquals(RuntimeException.class, failure.getClass()); + assertInstanceOf(IOException.class, failure.getCause()); + } + + /** + * Observed, no defect row (ruled): the client always sends {@code commandType} and {@code command} + * (googleSheetsQuery.tsx:72-79 builds the query as a type-and-children component keyed "commandType"/"command" with + * default "readData"), so a missing {@code command} is only reachable through a direct API caller. Today it is an + * empty-query-parameter error for read, clear and delete and an NPE in {@code from(null)} for append and update, and a service account that is not JSON object text fails inside + * {@code ServiceAccountJsonUtils.getData}: JSON without a private_key fails inside the Google library (NPE), a JSON array in getData's map (NPE); the same family as the malformed-key row, asserted as observed. + */ + @Test + public void aMissingCommandMapAndAnUnreadableServiceAccountFailWithRawExceptionsAsObserved() { + for (String type : new String[] {"readData", "clearData", "deleteData", "appendData", "updateData"}) { + Map noCommand = new java.util.HashMap<>(); + noCommand.put("commandType", type); + RuntimeException missing = assertThrows(RuntimeException.class, () -> engine.buildQueryExecutionContext(config, noCommand, PARAMS, visitor), type); + System.out.println("[GoogleSheetsEngineTest] missing command, " + type + " -> " + missing); + // read, clear and delete bind "null" to nothing and are rejected as an empty query parameter (:93-95); + // append and update call from(null) and fail with an NPE + if (type.equals("appendData") || type.equals("updateData")) { + assertEquals(NullPointerException.class, missing.getClass(), type); + } else { + assertEquals(GoogleSheetError.GOOGLESHEETS_EMPTY_QUERY_PARAM, ((PluginException) missing).getError(), type); + } + } + + for (String account : new String[] {"{\"client_email\":\"a@b\"}", "[1]"}) { + GoogleSheetsDatasourceConfig odd = engine.resolveConfig(Map.of("serviceAccount", account)); + RuntimeException failure = assertThrows(RuntimeException.class, + () -> engine.buildQueryExecutionContext(odd, queryConfig("readData", command()), PARAMS, visitor), account); + System.out.println("[GoogleSheetsEngineTest] service account " + account + " -> " + failure); + assertEquals(NullPointerException.class, failure.getClass(), account); + } + } + + /** + * Not an error result: {@code executeQuery} looks the handler up before it builds the Mono, so an unknown action type + * is thrown to the caller (GoogleSheetsPlugin.java:131-132, factory :28) and the {@code onErrorResume} mapping to + * GOOGLESHEETS_REQUEST_ERROR is never reached for it. + */ + @Test + public void executeQueryWithAnUnknownActionTypeThrowsBeforeAnyResultIsBuilt() { + GoogleSheetsQueryExecutionContext context = new GoogleSheetsQueryExecutionContext(); + context.setActionType("dropTable"); + + PluginException failure = assertThrows(PluginException.class, () -> engine.executeQuery(new Object(), context)); + + System.out.println("[GoogleSheetsEngineTest] executeQuery unknown type -> " + failure.getError() + " " + failure.getMessage()); + assertEquals(org.lowcoder.sdk.exception.PluginCommonError.QUERY_ARGUMENT_ERROR, failure.getError()); + assertEquals("GOOGLESHEETS_QUERY_PARAM_ERROR", failure.getMessageKey()); + } + + /** + * DEFECT pinned (new plan section 9 row "Google Sheets testConnection reports success without using the service + * account"; D-6, fix deferred). {@code testConnection} (GoogleSheetsPlugin.java:64-66) is + * {@code Mono.just(testSuccess())}: it succeeds for a service account that is garbage or has a key that cannot be + * parsed, and for a config that validateConfig rejects. A fix that parses the key (or makes an authenticated call) + * turns the garbage and bad-key assertions red. + */ + @Test + public void testConnectionSucceedsForAnyConfigWithoutLookingAtTheServiceAccountD() { + List accounts = new java.util.ArrayList<>(List.of("garbage", ServiceAccountTestKeys.json("not a key"), ServiceAccountTestKeys.json())); + accounts.add(null); + for (String account : accounts) { + Map map = new java.util.HashMap<>(); + map.put("serviceAccount", account); + DatasourceTestResult result = engine.testConnection(engine.resolveConfig(map)).block(); + System.out.println("[GoogleSheetsEngineTest] testConnection(" + (account == null ? null : account.substring(0, Math.min(12, account.length()))) + ") -> success " + result.isSuccess()); + assertTrue(result.isSuccess()); + } + } + + /** + * DEFECT D9 pinned (analysis-plugins section 0.6; plan section 9 D1-D20 row; D-6, fix deferred): + * {@code GoogleSheetsQueryExecutionContext.toString()} (:20) prints the whole service-account JSON, so the private + * key, and {@code ServiceAccountJsonUtils.toString()} (:27) prints {@code privateKeyPkcs8}. Anything that logs + * either object writes the key. A toString without the key turns both assertions red. + */ + @Test + public void toStringOfTheContextAndOfTheKeyReaderPrintThePrivateKeyD9() { + GoogleSheetsQueryExecutionContext context = build("readData", command()); + ServiceAccountJsonUtils reader = new ServiceAccountJsonUtils(); + reader.getData(ServiceAccountTestKeys.json()); + + String contextText = context.toString(); + String readerText = reader.toString(); + + System.out.println("[GoogleSheetsEngineTest] context.toString() has the key: " + contextText.contains(ServiceAccountTestKeys.keyMarker()) + + ", reader.toString() has the key: " + readerText.contains(ServiceAccountTestKeys.keyMarker())); + assertTrue(contextText.contains(ServiceAccountTestKeys.keyMarker()), "the context prints the key"); + assertTrue(readerText.contains(ServiceAccountTestKeys.keyMarker()), "the key reader prints the key"); + assertTrue(readerText.contains(ServiceAccountTestKeys.CLIENT_EMAIL)); + assertFalse(contextText.isEmpty()); + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsHandlerFactoryTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsHandlerFactoryTest.java new file mode 100644 index 0000000000..c2b90d266b --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsHandlerFactoryTest.java @@ -0,0 +1,104 @@ +package org.lowcoder.plugin.googlesheets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsAppendDataRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsClearDataRequst; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsDeleteDataRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsQueryExecutionContext; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsReadDataRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsUpdateDataRequest; +import org.lowcoder.plugin.googlesheets.queryhandler.GoogleSheetsActionHandler; +import org.lowcoder.plugin.googlesheets.queryhandler.GoogleSheetsActionHandlerFactory; +import org.lowcoder.plugin.googlesheets.queryhandler.GoogleSheetsAppendDataHandler; +import org.lowcoder.plugin.googlesheets.queryhandler.GoogleSheetsClearDataHandler; +import org.lowcoder.plugin.googlesheets.queryhandler.GoogleSheetsDeleteDataHandler; +import org.lowcoder.plugin.googlesheets.queryhandler.GoogleSheetsGetPreParameters; +import org.lowcoder.plugin.googlesheets.queryhandler.GoogleSheetsReadDataHandler; +import org.lowcoder.plugin.googlesheets.queryhandler.GoogleSheetsUpdateDataHandler; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.sheet.changeset.SheetChangeSetRow; +import org.lowcoder.sdk.query.QueryVisitorContext; + +import com.google.api.services.sheets.v4.Sheets; + +/** + * The handler registry, {@code GoogleSheetsGetPreParameters.getChangeSet} and the Sheets client factory. The handler + * {@code execute} bodies call the live Google API and are not run here (no network). + */ +public class GoogleSheetsHandlerFactoryTest { + + private static final Map CHANGE_SET = Map.of("compType", "KEY_VALUE_PAIRS", "comp", java.util.List.of(Map.of("column", "name", "value", "Ann"))); + + @Test + public void everyActionTypeResolvesToItsOwnHandlerAndAnUnknownOneIsRejected() { + Map> expected = new java.util.LinkedHashMap<>(); + expected.put("readData", GoogleSheetsReadDataHandler.class); + expected.put("appendData", GoogleSheetsAppendDataHandler.class); + expected.put("updateData", GoogleSheetsUpdateDataHandler.class); + expected.put("clearData", GoogleSheetsClearDataHandler.class); + expected.put("deleteData", GoogleSheetsDeleteDataHandler.class); + for (Map.Entry> entry : expected.entrySet()) { + GoogleSheetsActionHandler handler = GoogleSheetsActionHandlerFactory.getGoogleSheetsActionHandler(entry.getKey()); + System.out.println("[GoogleSheetsHandlerFactoryTest] " + entry.getKey() + " -> " + handler.getClass().getSimpleName()); + assertInstanceOf(entry.getValue(), handler); + assertEquals(entry.getKey(), handler.getActionType()); + assertSame(handler, GoogleSheetsActionHandlerFactory.getGoogleSheetsActionHandler(entry.getKey()), "handlers are shared"); + } + for (String unknown : new String[] {"", "raw", "READDATA"}) { + PluginException failure = assertThrows(PluginException.class, () -> GoogleSheetsActionHandlerFactory.getGoogleSheetsActionHandler(unknown), unknown); + assertEquals(PluginCommonError.QUERY_ARGUMENT_ERROR, failure.getError()); + assertEquals("GOOGLESHEETS_QUERY_PARAM_ERROR", failure.getMessageKey()); + } + assertThrows(PluginException.class, () -> GoogleSheetsActionHandlerFactory.getGoogleSheetsActionHandler(null)); + } + + @Test + public void theChangeSetIsReturnedForAppendAndUpdateOnly() { + Map command = Map.of("spreadsheetId", "a", "sheetName", "b", "rowIndexString", "1", "changeSet", CHANGE_SET); + GoogleSheetsAppendDataRequest append = GoogleSheetsAppendDataRequest.from(command); + append.renderParams(Map.of()); + GoogleSheetsUpdateDataRequest update = GoogleSheetsUpdateDataRequest.from(command); + update.renderParams(Map.of()); + + assertSame(append.getChangeSetItems(), GoogleSheetsGetPreParameters.getChangeSet(contextWith(append))); + assertSame(update.getChangeSetItems(), GoogleSheetsGetPreParameters.getChangeSet(contextWith(update))); + SheetChangeSetRow row = GoogleSheetsGetPreParameters.getChangeSet(contextWith(append)); + assertEquals(Set.of("name"), row.getColumns()); + for (Object other : new Object[] {new GoogleSheetsReadDataRequest(), new GoogleSheetsClearDataRequst(), new GoogleSheetsDeleteDataRequest(), null}) { + assertNull(GoogleSheetsGetPreParameters.getChangeSet(contextWith((org.lowcoder.plugin.googlesheets.model.GoogleSheetsActionRequest) other)), String.valueOf(other)); + } + } + + private static GoogleSheetsQueryExecutionContext contextWith(org.lowcoder.plugin.googlesheets.model.GoogleSheetsActionRequest request) { + GoogleSheetsQueryExecutionContext context = new GoogleSheetsQueryExecutionContext(); + context.setGoogleSheetsActionRequest(request); + return context; + } + + @Test + public void theSheetsClientIsBuiltFromTheContextCredentialsWithoutAnyRequest() { + GoogleSheetsPlugin.GoogleSheetsEngine engine = new GoogleSheetsPlugin.GoogleSheetsEngine(); + GoogleSheetsQueryExecutionContext context = engine.buildQueryExecutionContext( + engine.resolveConfig(Map.of("serviceAccount", ServiceAccountTestKeys.json())), + Map.of("commandType", "readData", "command", Map.of("spreadsheetId", "a", "sheetName", "b")), Map.of(), + new QueryVisitorContext("v", "o", 0, null, null, Set.of())); + + Sheets sheets = GoogleSheetsGetPreParameters.GetSheetsService(context); + + System.out.println("[GoogleSheetsHandlerFactoryTest] root " + sheets.getRootUrl() + " base " + sheets.getBaseUrl() + " initializer " + + sheets.getRequestFactory().getInitializer().getClass().getSimpleName()); + assertEquals("https://sheets.googleapis.com/", sheets.getRootUrl()); + assertEquals("https://sheets.googleapis.com/", sheets.getBaseUrl()); + assertInstanceOf(com.google.auth.http.HttpCredentialsAdapter.class, sheets.getRequestFactory().getInitializer()); + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsRequestModelsTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsRequestModelsTest.java new file mode 100644 index 0000000000..1dab7caf9c --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsRequestModelsTest.java @@ -0,0 +1,234 @@ +package org.lowcoder.plugin.googlesheets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsActionRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsAppendDataRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsClearDataRequst; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsDatasourceConfig; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsDeleteDataRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsReadDataRequest; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsUpdateDataRequest; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.plugin.sheet.changeset.SheetChangeSetRow; +import org.lowcoder.sdk.util.JsonUtils; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +/** + * The request classes (binding, mustache rendering, row index, {@code hasInvalidData}) and + * {@link GoogleSheetsDatasourceConfig} (merge, encrypt/decrypt, and the D8 log). + */ +public class GoogleSheetsRequestModelsTest { + + private static final Map PARAMS = Map.of("id", "sheet-9", "name", "Orders", "row", "12", "col", "age", "n", 41); + private static final String SERVICE_ACCOUNT = ServiceAccountTestKeys.json(); + + private final Logger configLogger = (Logger) LoggerFactory.getLogger(GoogleSheetsDatasourceConfig.class); + private final ListAppender logs = new ListAppender<>(); + + @BeforeEach + public void captureLog() { + logs.start(); + configLogger.addAppender(logs); + } + + @AfterEach + public void releaseLog() { + configLogger.detachAppender(logs); + logs.stop(); + } + + private static Map changeSet() { + return Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", "{{col}}x", "value", "{{n}}"), Map.of("column", "name", "value", "Ann"))); + } + + private static T bind(Class type, Map map) { + return JsonUtils.fromJson(JsonUtils.toJson(map), type); + } + + @Test + public void readRequestIsBoundFromJsonAndRendersIdNameAndRangeButNotLimitAndOffset() { + GoogleSheetsReadDataRequest request = bind(GoogleSheetsReadDataRequest.class, + Map.of("spreadsheetId", "{{id}}", "sheetName", "{{name}}", "range", "A{{row}}:B{{row}}", "limit", 5, "offset", 2)); + + request.renderParams(PARAMS); + + assertEquals("sheet-9", request.getSpreadsheetId()); + assertEquals("Orders", request.getSheetName()); + assertEquals("A12:B12", request.getRange()); + assertEquals(5, request.getLimit()); + assertEquals(2, request.getOffset()); + assertFalse(request.hasInvalidData()); + } + + @Test + public void appendAndUpdateRequestsRenderTheChangeSetAndUpdateParsesTheRowIndex() { + Map command = Map.of("spreadsheetId", "{{id}}", "sheetName", "{{name}}", "rowIndexString", "{{row}}", "changeSet", changeSet()); + + GoogleSheetsAppendDataRequest append = GoogleSheetsAppendDataRequest.from(command); + append.renderParams(PARAMS); + GoogleSheetsUpdateDataRequest update = GoogleSheetsUpdateDataRequest.from(command); + update.renderParams(PARAMS); + + for (Map.Entry entry : Map.of("append", append.getChangeSetItems(), "update", update.getChangeSetItems()).entrySet()) { + System.out.println("[GoogleSheetsRequestModelsTest] " + entry.getKey() + " columns " + entry.getValue().getColumns()); + assertEquals(java.util.Set.of("name", "{{col}}x"), entry.getValue().getColumns(), "column names are not rendered"); + assertEquals("Ann", entry.getValue().getItem("name").renderedValue()); + assertEquals(41, entry.getValue().getItem("{{col}}x").renderedValue(), "the value is rendered and typed"); + } + assertEquals("sheet-9", append.getSpreadsheetId()); + assertEquals("Orders", update.getSheetName()); + assertEquals(12, update.getRowIndex()); + assertFalse(append.hasInvalidData()); + assertFalse(update.hasInvalidData()); + } + + @Test + public void deleteAndClearRequestsParseTheRowIndexAndRejectANonNumber() { + for (GoogleSheetsActionRequest request : List.of( + bind(GoogleSheetsDeleteDataRequest.class, Map.of("spreadsheetId", "{{id}}", "sheetName", "{{name}}", "rowIndexString", " {{row}}")), + bind(GoogleSheetsClearDataRequst.class, Map.of("spreadsheetId", "{{id}}", "sheetName", "{{name}}", "rowIndexString", " {{row}}")))) { + String type = request.getClass().getSimpleName(); + PluginException failure = assertThrows(PluginException.class, () -> request.renderParams(PARAMS), type + " with a blank-padded index"); + System.out.println("[GoogleSheetsRequestModelsTest] " + type + " ' 12' -> " + failure.getMessageKey()); + assertEquals(PluginCommonError.DATASOURCE_ARGUMENT_ERROR, failure.getError()); + } + GoogleSheetsDeleteDataRequest delete = bind(GoogleSheetsDeleteDataRequest.class, Map.of("spreadsheetId", "{{id}}", "sheetName", "{{name}}", "rowIndexString", "{{row}}")); + GoogleSheetsClearDataRequst clear = bind(GoogleSheetsClearDataRequst.class, Map.of("spreadsheetId", "{{id}}", "sheetName", "{{name}}", "rowIndexString", "3")); + delete.renderParams(PARAMS); + clear.renderParams(PARAMS); + assertEquals("Orders", delete.getSheetName()); + assertEquals("Orders", clear.getSheetName()); + assertEquals(12, delete.getRowIndex()); + assertEquals(3, clear.getRowIndex()); + assertEquals("sheet-9", clear.getSpreadsheetId()); + for (String bad : new String[] {"x", "", "1.5", "{{missing}}"}) { + GoogleSheetsUpdateDataRequest update = GoogleSheetsUpdateDataRequest.from( + Map.of("spreadsheetId", "a", "sheetName", "b", "rowIndexString", bad, "changeSet", changeSet())); + assertEquals(PluginCommonError.DATASOURCE_ARGUMENT_ERROR, assertThrows(PluginException.class, () -> update.renderParams(PARAMS), bad).getError()); + } + } + + @Test + public void everyRequestClassIsInvalidWhenTheIdOrTheSheetNameIsBlank() { + Map, GoogleSheetsActionRequest>> factories = new java.util.LinkedHashMap<>(); + factories.put("read", map -> bind(GoogleSheetsReadDataRequest.class, map)); + factories.put("delete", map -> bind(GoogleSheetsDeleteDataRequest.class, map)); + factories.put("clear", map -> bind(GoogleSheetsClearDataRequst.class, map)); + factories.put("append", GoogleSheetsAppendDataRequest::from); + factories.put("update", GoogleSheetsUpdateDataRequest::from); + for (Map.Entry, GoogleSheetsActionRequest>> entry : factories.entrySet()) { + for (String[] ids : new String[][] {{"a", "b", "valid"}, {"", "b", "invalid"}, {"a", " ", "invalid"}, {"{{missing}}", "b", "invalid"}}) { + GoogleSheetsActionRequest request = entry.getValue().apply(Map.of("spreadsheetId", ids[0], "sheetName", ids[1], "rowIndexString", "1", "changeSet", changeSet())); + request.renderParams(PARAMS); + assertEquals(ids[2].equals("invalid"), request.hasInvalidData(), entry.getKey() + " " + String.join("|", ids)); + } + } + } + + @Test + public void aCommandWithoutAChangeSetIsRejectedForAppendAndUpdate() { + for (Map badSet : List.>of(Map.of(), Map.of("changeSet", Map.of()), Map.of("changeSet", Map.of("comp", List.of())))) { + PluginException append = assertThrows(PluginException.class, () -> GoogleSheetsAppendDataRequest.from(badSet), String.valueOf(badSet)); + PluginException update = assertThrows(PluginException.class, () -> GoogleSheetsUpdateDataRequest.from(badSet), String.valueOf(badSet)); + assertEquals(PluginCommonError.INVALID_GUI_SETTINGS, append.getError()); + assertEquals(PluginCommonError.INVALID_GUI_SETTINGS, update.getError()); + } + } + + @Test + public void configMergeKeepsTheStoredServiceAccountAndRejectsAForeignConfig() { + GoogleSheetsDatasourceConfig stored = GoogleSheetsDatasourceConfig.buildFrom(Map.of("serviceAccount", "stored")); + + GoogleSheetsDatasourceConfig kept = (GoogleSheetsDatasourceConfig) stored.mergeWithUpdatedConfig(GoogleSheetsDatasourceConfig.builder().build()); + GoogleSheetsDatasourceConfig replaced = (GoogleSheetsDatasourceConfig) stored.mergeWithUpdatedConfig(GoogleSheetsDatasourceConfig.builder().serviceAccount("new").build()); + + assertNotSame(stored, kept); + assertEquals("stored", kept.getServiceAccount()); + assertEquals("new", replaced.getServiceAccount()); + DatasourceConnectionConfig foreign = new DatasourceConnectionConfig() { + @Override + public DatasourceConnectionConfig mergeWithUpdatedConfig(DatasourceConnectionConfig detailConfig) { + return this; + } + + @Override + public DatasourceConnectionConfig doEncrypt(Function encryptFunc) { + return this; + } + + @Override + public DatasourceConnectionConfig doDecrypt(Function decryptFunc) { + return this; + } + }; + PluginException failure = assertThrows(PluginException.class, () -> stored.mergeWithUpdatedConfig(foreign)); + assertEquals(PluginCommonError.DATASOURCE_ARGUMENT_ERROR, failure.getError()); + assertEquals("GOOGLESHEETS_DATASOURCE_CONFIG_ERROR", failure.getMessageKey()); + assertEquals(PluginCommonError.DATASOURCE_ARGUMENT_ERROR, + assertThrows(PluginException.class, () -> GoogleSheetsDatasourceConfig.buildFrom(null)).getError(), "a null map binds to nothing"); + } + + @Test + public void encryptAndDecryptApplyTheFunctionToTheServiceAccount() { + GoogleSheetsDatasourceConfig config = GoogleSheetsDatasourceConfig.buildFrom(Map.of("serviceAccount", "abc")); + + assertSame(config, config.doEncrypt(text -> "[" + text + "]")); + assertEquals("[abc]", config.getServiceAccount()); + assertSame(config, config.doDecrypt(text -> text.substring(1, text.length() - 1))); + assertEquals("abc", config.getServiceAccount()); + assertTrue(logs.list.isEmpty()); + } + + /** + * DEFECT D8 pinned, Google Sheets half (analysis-plugins section 0.6; plan section 9 D1-D20 row; D-6, fix deferred): + * when the function throws, {@code GoogleSheetsDatasourceConfig} logs {@code "fail to encrypt password: {}"} (:51) and + * {@code "fail to decrypt password: {}"} (:61) at ERROR with the whole service-account JSON, private key included, + * as the argument, and returns the object unchanged. The fix is to log without the value; it turns the log + * assertions red. + */ + @Test + public void whenTheFunctionThrowsTheServiceAccountJsonIsWrittenToTheLogD8() { + Function failing = text -> { + throw new IllegalStateException("key unavailable"); + }; + GoogleSheetsDatasourceConfig forEncrypt = GoogleSheetsDatasourceConfig.buildFrom(Map.of("serviceAccount", SERVICE_ACCOUNT)); + GoogleSheetsDatasourceConfig forDecrypt = GoogleSheetsDatasourceConfig.buildFrom(Map.of("serviceAccount", SERVICE_ACCOUNT)); + + assertSame(forEncrypt, forEncrypt.doEncrypt(failing)); + assertSame(forDecrypt, forDecrypt.doDecrypt(failing)); + + assertEquals(SERVICE_ACCOUNT, forEncrypt.getServiceAccount()); + assertEquals(SERVICE_ACCOUNT, forDecrypt.getServiceAccount()); + logs.list.forEach(event -> System.out.println("[GoogleSheetsRequestModelsTest] " + event.getLevel() + " " + + event.getFormattedMessage().replace(ServiceAccountTestKeys.keyMarker(), "<>").substring(0, 60) + "... contains key: " + + event.getFormattedMessage().contains(ServiceAccountTestKeys.keyMarker()))); + assertEquals(2, logs.list.size()); + for (ILoggingEvent event : logs.list) { + assertEquals(Level.ERROR, event.getLevel()); + assertTrue(event.getFormattedMessage().contains(ServiceAccountTestKeys.keyMarker()), "the private key is in the log"); + assertTrue(event.getThrowableProxy().getMessage().contains("key unavailable")); + } + assertTrue(logs.list.get(0).getFormattedMessage().startsWith("fail to encrypt")); + assertTrue(logs.list.get(1).getFormattedMessage().startsWith("fail to decrypt")); + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsResultContractTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsResultContractTest.java new file mode 100644 index 0000000000..fff73e8a63 --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/GoogleSheetsResultContractTest.java @@ -0,0 +1,111 @@ +package org.lowcoder.plugin.googlesheets; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.sheets.v4.model.AppendValuesResponse; +import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetResponse; +import com.google.api.services.sheets.v4.model.ClearValuesResponse; +import com.google.api.services.sheets.v4.model.UpdateValuesResponse; +import com.google.api.services.sheets.v4.model.ValueRange; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.googlesheets.model.ServiceAccountJsonUtils; +import org.lowcoder.plugin.googlesheets.queryhandler.GoogleSheetsReadDataHandler; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.models.QueryExecutionResult; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The Google Sheets rows of the §4.6 producer table and group {@code external-json} of + * {@code ServiceAccountJsonUtils} (docs/API_PAYLOAD_TEST_PLAN.md §4.6, §4.10, task T8.2). + *

      + *
    • Read: {@code GoogleSheetsReadDataHandler.transformToFinalValues} on a {@link ValueRange} parsed from a Sheets + * API answer the way the Google client parses it ({@link GsonFactory}); the result is a {@code List} of + * {@code Map} with {@code rowIndex}, renamed blank and repeated headers. A cell that is not text + * (an unformatted number) fails with a {@link ClassCastException} (O76).
    • + *
    • Append, update, delete, clear: {@code response.values()} of each response class parsed from an API answer + * ({@code GenericData} values, nested response objects included): a list of the values without their names (O77).
    • + *
    • The service-account key: {@code ServiceAccountJsonUtils.getData} reads the key text with + * {@code JsonUtils.fromJsonMap} and keeps four strings; a non-string value fails with a + * {@link ClassCastException} (O76).
    • + *
    + * Pinned in {@value #REPORT}. + * + *

    Limits: no Google API is called; the answers are parsed with the client's own JSON factory. + */ +public class GoogleSheetsResultContractTest { + + static final String REPORT = "external-json/GoogleSheets.results.json"; + static final String READ_ANSWER = "{\"range\":\"Sheet1!A3:D6\",\"majorDimension\":\"ROWS\",\"values\":" + + "[[\"name\",\"\",\"name\"],[\"žluť\",\"1.50\"],[\"b\",\"2\",\"x\",\"extra\"]]}"; + static final String UNFORMATTED_READ_ANSWER = "{\"range\":\"Sheet1!A1:B2\",\"values\":[[\"n\"],[1.5]]}"; + static final String UPDATE_ANSWER = "{\"spreadsheetId\":\"s1\",\"updatedRange\":\"Sheet1!A7:B7\",\"updatedRows\":1," + + "\"updatedColumns\":2,\"updatedCells\":2,\"updatedData\":{\"range\":\"Sheet1!A7:B7\",\"values\":[[\"a\",\"1.50\"]]}}"; + static final String APPEND_ANSWER = "{\"spreadsheetId\":\"s1\",\"tableRange\":\"Sheet1!A1:B6\",\"updates\":" + UPDATE_ANSWER + "}"; + static final String DELETE_ANSWER = "{\"spreadsheetId\":\"s1\",\"replies\":[{}]}"; + static final String CLEAR_ANSWER = "{\"spreadsheetId\":\"s1\",\"clearedRange\":\"Sheet1!A7:Z7\"}"; + static final String SERVICE_ACCOUNT = "{\"type\":\"service_account\",\"client_id\":\"1234567890\",\"client_email\":" + + "\"bot@p.iam.gserviceaccount.com\",\"private_key\":\"-----BEGIN PRIVATE KEY-----\\nMIIE\\u002Bž\\n-----END PRIVATE KEY-----\\n\"," + + "\"private_key_id\":\"k1\"}"; + static final String NUMERIC_CLIENT_ID = "{\"client_id\":1234567890}"; + + private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites("lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/model/ServiceAccountJsonUtils.java#ServiceAccountJsonUtils.getData#fromJsonMap#1") + @Test + public void resultsAsPinned() throws IOException { + GoogleSheetsReadDataHandler readHandler = new GoogleSheetsReadDataHandler(); + Map report = new LinkedHashMap<>(); + report.put("read", result(() -> readHandler.transformToFinalValues(parse(READ_ANSWER, ValueRange.class)))); + report.put("readUnformattedNumber", result(() -> readHandler.transformToFinalValues(parse(UNFORMATTED_READ_ANSWER, ValueRange.class)))); + report.put("append", result(() -> parse(APPEND_ANSWER, AppendValuesResponse.class).values())); + report.put("update", result(() -> parse(UPDATE_ANSWER, UpdateValuesResponse.class).values())); + report.put("delete", result(() -> parse(DELETE_ANSWER, BatchUpdateSpreadsheetResponse.class).values())); + report.put("clear", result(() -> parse(CLEAR_ANSWER, ClearValuesResponse.class).values())); + report.put("serviceAccount", serviceAccount(SERVICE_ACCOUNT)); + report.put("serviceAccountNumericClientId", serviceAccount(NUMERIC_CLIENT_ID)); + String actual = ConfigBinding.write(report); + System.out.println("[GoogleSheetsResultContractTest]\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** The value a handler produces. */ + interface Producer { + Object produce() throws IOException; + } + + /** The report of {@code QueryExecutionResult.success(value)}, as each handler returns it, or the error. */ + private static Object result(Producer producer) { + try { + return QueryResults.report(QueryExecutionResult.success(producer.produce())); + } catch (IOException | RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + } + + private static T parse(String answer, Class type) throws IOException { + return JSON_FACTORY.fromString(answer, type); + } + + private static Object serviceAccount(String key) { + ServiceAccountJsonUtils utils = new ServiceAccountJsonUtils(); + try { + utils.getData(key); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + Map fields = new LinkedHashMap<>(); + fields.put("clientId", utils.getClientId()); + fields.put("clientEmail", utils.getClientEmail()); + fields.put("privateKeyPkcs8", utils.getPrivateKeyPkcs8()); + fields.put("privateKeyId", utils.getPrivateKeyId()); + return fields; + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/ServiceAccountTestKeys.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/ServiceAccountTestKeys.java new file mode 100644 index 0000000000..e308af69be --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/ServiceAccountTestKeys.java @@ -0,0 +1,63 @@ +package org.lowcoder.plugin.googlesheets; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.lowcoder.sdk.util.JsonUtils; + +/** + * Builds service-account JSON text for tests. The RSA key is generated when the class is loaded and exists only in + * memory: no private key is committed. + */ +final class ServiceAccountTestKeys { + + static final String CLIENT_ID = "1234567890"; + static final String CLIENT_EMAIL = "bot@project.iam.gserviceaccount.com"; + static final String PRIVATE_KEY_ID = "key-id-1"; + private static final int KEY_BITS = 2048; + private static final int PEM_LINE = 64; + + /** The PKCS8 PEM of the generated key, as it appears in the {@code private_key} field of a Google JSON key file. */ + static final String PRIVATE_KEY_PEM = generatePem(); + + private ServiceAccountTestKeys() { + } + + private static String generatePem() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(KEY_BITS); + KeyPair pair = generator.generateKeyPair(); + return "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(PEM_LINE, "\n".getBytes()).encodeToString(pair.getPrivate().getEncoded()) + + "\n-----END PRIVATE KEY-----\n"; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + /** A complete service-account JSON with the generated key. */ + static String json() { + return json(PRIVATE_KEY_PEM); + } + + static String json(String privateKey) { + Map map = new LinkedHashMap<>(); + map.put("type", "service_account"); + map.put("client_id", CLIENT_ID); + map.put("client_email", CLIENT_EMAIL); + map.put("private_key", privateKey); + map.put("private_key_id", PRIVATE_KEY_ID); + return JsonUtils.toJson(map); + } + + /** A distinctive fragment of the key body that must never appear in a log or {@code toString()}. */ + static String keyMarker() { + String body = PRIVATE_KEY_PEM.split("\n")[1]; + return body.substring(0, 40); + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/ForeignHostGuard.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/ForeignHostGuard.java new file mode 100644 index 0000000000..6a7b2f7bee --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/ForeignHostGuard.java @@ -0,0 +1,60 @@ +package org.lowcoder.plugin.googlesheets.queryhandler; + +import java.io.IOException; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Test guard that stops any request to a host or port other than the test's own loopback servers. It is a + * {@link ProxySelector}: {@code HttpURLConnection} (so {@code NetHttpTransport}, which the Google client uses) asks + * {@link ProxySelector#getDefault()} before it resolves or connects, and this selector throws for a foreign URI, so + * nothing is resolved or connected for it. + * + *

    Limits: it covers only clients that consult {@code ProxySelector.getDefault()} (HttpURLConnection and + * NetHttpTransport without an explicit proxy). It does NOT cover Reactor Netty, raw sockets, or a client that is given an + * explicit {@link Proxy}. It is JVM-wide: it is installed with {@link ProxySelector#setDefault} in {@code @BeforeEach} + * and the previous selector restored in {@code @AfterEach}, so tests using it must not run in parallel with other tests + * (this module runs its classes sequentially). + */ +final class ForeignHostGuard extends ProxySelector { + + static final String LOOPBACK = "127.0.0.1"; + + private final Set allowedPorts; + private final List allowed = new CopyOnWriteArrayList<>(); + private final List foreign = new CopyOnWriteArrayList<>(); + + ForeignHostGuard(Set allowedPorts) { + this.allowedPorts = allowedPorts; + } + + @Override + public List select(URI uri) { + if (LOOPBACK.equals(uri.getHost()) && allowedPorts.contains(uri.getPort())) { + allowed.add(uri.toString()); + return List.of(Proxy.NO_PROXY); + } + foreign.add(uri.toString()); + throw new IllegalStateException("GUARD: request to a foreign host " + uri.getHost() + ":" + uri.getPort()); + } + + @Override + public void connectFailed(URI uri, SocketAddress address, IOException failure) { + // nothing to do: the guard only decides which URIs may be used + } + + /** The URIs the guard let through. */ + List allowed() { + return allowed; + } + + /** The URIs the guard refused (must be empty in every test). */ + List foreign() { + return foreign; + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsAppendHandlerTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsAppendHandlerTest.java new file mode 100644 index 0000000000..a900d0b584 --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsAppendHandlerTest.java @@ -0,0 +1,101 @@ +package org.lowcoder.plugin.googlesheets.queryhandler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer.Handler; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.models.QueryExecutionResult; + +/** + * {@link GoogleSheetsAppendDataHandler} against a local server. Per the Sheets v4 reference: the header row is read with + * {@code spreadsheets.values.get}, then {@code spreadsheets.values.append} is + * {@code POST /v4/spreadsheets/{id}/values/{range}:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS} with a + * ValueRange body ({@code range}, {@code majorDimension}, {@code values}). + */ +public class GoogleSheetsAppendHandlerTest extends GoogleSheetsCallSupport { + + private static final String READ_PATH = V4 + SPREADSHEET + "/values/Sheet1"; + private static final String APPEND_PATH = V4 + SPREADSHEET + "/values/Sheet1!A1:append"; + private static final String APPENDED = "{\"spreadsheetId\":\"sid\",\"tableRange\":\"Sheet1!A1:C2\",\"updates\":{\"spreadsheetId\":\"sid\"," + + "\"updatedRange\":\"Sheet1!A3:C3\",\"updatedRows\":1,\"updatedColumns\":3,\"updatedCells\":3}}"; + + private static Map command(String column, String value) { + return Map.of("spreadsheetId", SPREADSHEET, "sheetName", "Sheet1", + "changeSet", Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of( + Map.of("column", column, "value", value), Map.of("column", "name", "value", "Ann"), Map.of("column", "other", "value", "ignored")))); + } + + private Map routes(String readAnswer) { + Map routes = new HashMap<>(); + routes.put(READ_PATH, request -> json(readAnswer)); + routes.put(APPEND_PATH, request -> json(APPENDED)); + return routes; + } + + private static Set queryOf(Request request) { + return Set.of(request.pathAndQuery().substring(request.pathAndQuery().indexOf('?') + 1).split("&")); + } + + @Test + public void appendPostsOneRowInHeaderOrderWithBlanksForColumnsWithoutAValue() { + start(routes("{\"range\":\"Sheet1!A1:C2\",\"values\":[[\"name\",\"age\",\"city\"],[\"x\",\"1\",\"y\"]]}")); + + QueryExecutionResult result = run("appendData", command("city", "Brno")); + + List api = apiRequests(); + System.out.println("[GoogleSheetsAppendHandlerTest] " + api.stream().map(r -> r.method() + " " + r.pathAndQuery()).toList() + " body " + bodyText(api.get(1))); + assertTrue(result.isSuccess(), String.valueOf(result.getMessageArgs())); + assertEquals(2, api.size()); + assertEquals("GET", api.get(0).method()); + assertEquals(READ_PATH, api.get(0).pathAndQuery()); + Request post = api.get(1); + assertEquals("POST", post.method()); + assertTrue(post.pathAndQuery().startsWith(APPEND_PATH + "?"), post.pathAndQuery()); + assertEquals(Set.of("valueInputOption=RAW", "insertDataOption=INSERT_ROWS"), queryOf(post)); + assertEquals(Map.of("majorDimension", "ROWS", "range", "Sheet1!A1", "values", List.of(List.of("Ann", "", "Brno"))), body(post)); + assertEquals(List.of("Bearer " + TOKEN), post.header("Authorization")); + assertTrue(String.valueOf(result.getData()).contains("Sheet1!A3:C3"), String.valueOf(result.getData())); + } + + @Test + public void whenNoChangeSetColumnMatchesAHeaderTheCallFailsAndNothingIsPosted() { + start(routes("{\"range\":\"Sheet1!A1:B2\",\"values\":[[\"age\",\"city\"]]}")); + + QueryExecutionResult result = run("appendData", Map.of("spreadsheetId", SPREADSHEET, "sheetName", "Sheet1", + "changeSet", Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", "nothing", "value", "v"))))); + + System.out.println("[GoogleSheetsAppendHandlerTest] no matching column -> " + result.getMessageKey() + " " + failureText(result)); + assertFalse(result.isSuccess()); + assertEquals("GOOGLESHEETS_REQUEST_ERROR", result.getMessageKey()); + assertEquals("Google Sheets query parameter is empty.", failureText(result)); + assertEquals(List.of("GET"), apiRequests().stream().map(Request::method).toList()); + } + + /** + * DEFECT D11 pinned (analysis-plugins section 0.6; plan section 9 "D1-D20" row; D-6, fix deferred): appending to an empty + * sheet, for which the API answers a ValueRange without {@code values}, does {@code values.get(0)} on null + * (GoogleSheetsAppendDataHandler.java:46-48). The caller gets no PluginException but the NPE text of the JVM as the + * message, and nothing is appended. A fix that treats missing values as an empty sheet (or throws a PluginException) + * turns these assertions red. + */ + @Test + public void appendingToAnEmptySheetFailsWithARawNullPointerMessageD11() { + start(routes("{\"range\":\"Sheet1!A1:Z1000\",\"majorDimension\":\"ROWS\"}")); + + QueryExecutionResult result = run("appendData", command("city", "Brno")); + + System.out.println("[GoogleSheetsAppendHandlerTest] empty sheet -> " + result.getMessageKey() + " " + failureText(result)); + assertFalse(result.isSuccess()); + assertEquals("GOOGLESHEETS_REQUEST_ERROR", result.getMessageKey()); + assertEquals("Cannot invoke \"java.util.List.get(int)\" because \"values\" is null", failureText(result)); + assertEquals(List.of("GET"), apiRequests().stream().map(Request::method).toList(), "nothing is appended"); + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsCallSupport.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsCallSupport.java new file mode 100644 index 0000000000..4c6a7b413f --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsCallSupport.java @@ -0,0 +1,156 @@ +package org.lowcoder.plugin.googlesheets.queryhandler; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Method; +import java.net.ProxySelector; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterEach; +import org.lowcoder.plugin.googlesheets.GoogleSheetsPlugin.GoogleSheetsEngine; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsQueryExecutionContext; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Handler; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.query.QueryVisitorContext; +import org.lowcoder.sdk.util.JsonUtils; + +import com.google.auth.oauth2.ServiceAccountCredentials; + +/** + * Runs a Google Sheets query the way production does ({@code buildQueryExecutionContext} then {@code executeQuery}) against + * a local server: the root-URL seam points the Sheets client at it, the service-account credentials get their token + * endpoint at the same server ({@code /token}, the library signs a JWT with the key generated by + * {@code ServiceAccountTestKeys} and the server answers with {@value #TOKEN}), and a {@link ForeignHostGuard} fails + * closed on any other host. The seam and the guard are JVM-wide statics: they are set in {@link #start} and restored + * in {@code @AfterEach}, and classes using this support must not run in parallel (this module runs them sequentially). + */ +abstract class GoogleSheetsCallSupport { + + static final String SPREADSHEET = "sid"; + static final String TOKEN = "served-token"; + static final String TOKEN_PATH = "/token"; + static final String V4 = "/v4/spreadsheets/"; + private static final String TOKEN_ANSWER = "{\"access_token\":\"" + TOKEN + "\",\"expires_in\":3600,\"token_type\":\"Bearer\"}"; + private static final String JSON = "application/json"; + + private final GoogleSheetsEngine engine = new GoogleSheetsEngine(); + private RecordingHttpServer server; + private ProxySelector previousSelector; + private ForeignHostGuard guard; + + static Response json(String body) { + return json(200, body); + } + + static Response json(int status, String body) { + return new Response(status, Map.of("Content-Type", List.of(JSON)), body.getBytes(StandardCharsets.UTF_8)); + } + + /** Starts the server with {@code routes} (exact paths, plus the token endpoint), installs the guard and the seam. */ + final void start(Map routes) { + Map all = new java.util.HashMap<>(routes); + all.put(TOKEN_PATH, request -> json(TOKEN_ANSWER)); + server = RecordingHttpServer.serve(all); + previousSelector = ProxySelector.getDefault(); + guard = new ForeignHostGuard(Set.of(server.port())); + ProxySelector.setDefault(guard); + GoogleSheetsGetPreParameters.setRootUrlForTests(server.baseUrl()); + } + + final void start(String path, Response response) { + start(Map.of(path, request -> response)); + } + + @AfterEach + final void stop() { + GoogleSheetsGetPreParameters.setRootUrlForTests(null); + if (previousSelector != null) { + ProxySelector.setDefault(previousSelector); + } + if (server != null) { + server.close(); + } + if (guard != null) { + assertEquals(List.of(), guard.foreign(), "a request went to a host or port other than the test's own server"); + } + } + + final GoogleSheetsQueryExecutionContext context(String commandType, Map command, Map params) { + GoogleSheetsQueryExecutionContext context = engine.buildQueryExecutionContext( + engine.resolveConfig(Map.of("serviceAccount", serviceAccountJson())), + Map.of("commandType", commandType, "command", command), params, new QueryVisitorContext("v", "o", 0, null, null, Set.of())); + ServiceAccountCredentials real = context.getServiceAccountCredentials(); + context.setServiceAccountCredentials(ServiceAccountCredentials.newBuilder() + .setClientId(real.getClientId()).setClientEmail(real.getClientEmail()).setPrivateKey(real.getPrivateKey()) + .setPrivateKeyId(real.getPrivateKeyId()).setScopes(real.getScopes()) + .setTokenServerUri(URI.create(server.baseUrl() + TOKEN_PATH)).build()); + return context; + } + + final QueryExecutionResult run(String commandType, Map command, Map params) { + return engine.executeQuery(new Object(), context(commandType, command, params)).block(); + } + + final QueryExecutionResult run(String commandType, Map command) { + return run(commandType, command, Map.of()); + } + + /** All requests the server saw, the token request included. */ + final List requests() { + return server.requests(); + } + + /** The requests to the API (everything but the token endpoint). */ + final List apiRequests() { + return server.requests().stream().filter(request -> !request.pathAndQuery().startsWith(TOKEN_PATH)).collect(Collectors.toList()); + } + + /** The request body as text; the Google client sends its JSON gzip-compressed. */ + static String bodyText(Request request) { + if (request.header("Content-Encoding").contains("gzip")) { + try (java.util.zip.GZIPInputStream in = new java.util.zip.GZIPInputStream(new java.io.ByteArrayInputStream(request.body()))) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (java.io.IOException e) { + throw new IllegalStateException(e); + } + } + return request.bodyText(); + } + + static Map body(Request request) { + return JsonUtils.fromJsonMap(bodyText(request)); + } + + /** The decoded payload of the JWT assertion a token request carries. */ + static Map assertionPayload(Request tokenRequest) { + String form = tokenRequest.bodyText(); + String assertion = java.util.Arrays.stream(form.split("&")).filter(part -> part.startsWith("assertion=")).findFirst() + .orElseThrow().substring("assertion=".length()); + String payload = assertion.split("\\.")[1]; + return JsonUtils.fromJsonMap(new String(Base64.getUrlDecoder().decode(payload), StandardCharsets.UTF_8)); + } + + /** The first message argument of a failed result (the exception text {@code executeQuery} maps). */ + static String failureText(QueryExecutionResult result) { + return String.valueOf(result.getMessageArgs()[0]); + } + + private static String serviceAccountJson() { + try { + Method json = Class.forName("org.lowcoder.plugin.googlesheets.ServiceAccountTestKeys").getDeclaredMethod("json"); + json.setAccessible(true); + return (String) json.invoke(null); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsDeleteClearHandlerTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsDeleteClearHandlerTest.java new file mode 100644 index 0000000000..b43d3fa8af --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsDeleteClearHandlerTest.java @@ -0,0 +1,122 @@ +package org.lowcoder.plugin.googlesheets.queryhandler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsQueryExecutionContext; +import org.lowcoder.sdk.contract.RecordingHttpServer.Handler; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.models.QueryExecutionResult; + +/** + * {@link GoogleSheetsDeleteDataHandler} and {@link GoogleSheetsClearDataHandler} against a local server. Per the Sheets v4 + * reference: delete reads the sheets with {@code spreadsheets.get} ({@code GET /v4/spreadsheets/{id}}, whose + * {@code sheets[].properties} carry {@code title} and {@code sheetId}) and sends {@code spreadsheets.batchUpdate} + * ({@code POST /v4/spreadsheets/{id}:batchUpdate}) with a {@code deleteDimension} request over a zero-based, end-exclusive + * range; clear is {@code spreadsheets.values.clear} ({@code POST /v4/spreadsheets/{id}/values/{range}:clear}, empty body). + * Row index {@code 4} of the query form is sheet row 5, that is zero-based index 4. + */ +public class GoogleSheetsDeleteClearHandlerTest extends GoogleSheetsCallSupport { + + private static final String SPREADSHEET_PATH = V4 + SPREADSHEET; + private static final String BATCH_PATH = SPREADSHEET_PATH + ":batchUpdate"; + private static final String SHEETS = "{\"spreadsheetId\":\"sid\",\"sheets\":[{\"properties\":{\"sheetId\":111,\"title\":\"Other\"}}," + + "{\"properties\":{\"sheetId\":222,\"title\":\"Orders\"}}]}"; + private static final String CLEAR_PATH = SPREADSHEET_PATH + "/values/Sheet1!5:5:clear"; + + private static Map deleteCommand(String sheetName) { + return Map.of("spreadsheetId", SPREADSHEET, "sheetName", sheetName, "rowIndexString", "4"); + } + + private void startDelete() { + Map routes = new HashMap<>(); + routes.put(SPREADSHEET_PATH, request -> json(SHEETS)); + routes.put(BATCH_PATH, request -> json("{\"spreadsheetId\":\"sid\",\"replies\":[{}]}")); + start(routes); + } + + private static Map deleteBody(int sheetId) { + return Map.of("requests", List.of(Map.of("deleteDimension", Map.of("range", + Map.of("sheetId", sheetId, "dimension", "ROWS", "startIndex", 4, "endIndex", 5))))); + } + + @Test + public void deleteTargetsTheSheetWhoseTitleMatchesWithAZeroBasedEndExclusiveRowRange() { + startDelete(); + + QueryExecutionResult result = run("deleteData", deleteCommand("Orders")); + + List api = apiRequests(); + System.out.println("[GoogleSheetsDeleteClearHandlerTest] " + api.stream().map(r -> r.method() + " " + r.pathAndQuery()).toList() + " body " + bodyText(api.get(1))); + assertTrue(result.isSuccess(), String.valueOf(result.getMessageArgs())); + assertEquals(List.of("GET " + SPREADSHEET_PATH, "POST " + BATCH_PATH), api.stream().map(r -> r.method() + " " + r.pathAndQuery()).toList()); + assertEquals(deleteBody(222), body(api.get(1))); + assertEquals(List.of("Bearer " + TOKEN), api.get(1).header("Authorization")); + } + + /** + * DEFECT D10 pinned (analysis-plugins section 0.6; plan section 9 "D1-D20" row; D-6, fix deferred): when no sheet has the + * named title, {@code .orElse(0)} (GoogleSheetsDeleteDataHandler.java:43) makes the delete apply to sheet id 0, so a + * misspelt sheet name deletes the row on whatever sheet has id 0. A fix that fails when no sheet matches turns the + * assertions red. (The spreadsheet here has no sheet with id 0; the request is still sent with {@code sheetId 0}.) + */ + @Test + public void aSheetNameThatDoesNotExistDeletesTheRowOnSheetIdZeroD10() { + startDelete(); + + QueryExecutionResult result = run("deleteData", deleteCommand("Missing")); + + List api = apiRequests(); + System.out.println("[GoogleSheetsDeleteClearHandlerTest] missing sheet -> success " + result.isSuccess() + ", batchUpdate " + bodyText(api.get(api.size() - 1))); + assertTrue(result.isSuccess(), "the call is not rejected"); + assertEquals(2, api.size()); + assertEquals("POST", api.get(1).method()); + assertEquals(deleteBody(0), body(api.get(1))); + } + + @Test + public void clearPostsTheRowRangeWithAnEmptyBody() { + start(CLEAR_PATH, json("{\"spreadsheetId\":\"sid\",\"clearedRange\":\"Sheet1!A5:Z5\"}")); + + QueryExecutionResult result = run("clearData", Map.of("spreadsheetId", SPREADSHEET, "sheetName", "Sheet1", "rowIndexString", "4")); + + List api = apiRequests(); + System.out.println("[GoogleSheetsDeleteClearHandlerTest] clear -> " + api.get(0).method() + " " + api.get(0).pathAndQuery() + " body '" + bodyText(api.get(0)) + "' -> " + result.getData()); + assertTrue(result.isSuccess(), String.valueOf(result.getMessageArgs())); + assertEquals(1, api.size()); + assertEquals("POST", api.get(0).method()); + assertEquals(CLEAR_PATH, api.get(0).pathAndQuery()); + assertEquals(Map.of(), body(api.get(0))); + assertEquals(List.of("Bearer " + TOKEN), api.get(0).header("Authorization")); + assertTrue(String.valueOf(result.getData()).contains("Sheet1!A5:Z5"), String.valueOf(result.getData())); + } + + /** + * Observation: delete and clear build the Sheets client before they create the Mono (GoogleSheetsDeleteDataHandler.java:34, + * GoogleSheetsClearDataHandler.java:25), so a failure there (here: no credentials) is thrown to the caller of + * {@code execute} and is not turned into a failed result by the engine's {@code onErrorResume}, unlike the other three + * handlers, which build it inside the Mono. + */ + @Test + public void deleteAndClearBuildTheClientOutsideTheMonoSoAFailureIsThrown() { + start(Map.of()); + GoogleSheetsQueryExecutionContext deleteContext = context("deleteData", deleteCommand("Orders"), Map.of()); + GoogleSheetsQueryExecutionContext clearContext = context("clearData", Map.of("spreadsheetId", SPREADSHEET, "sheetName", "Sheet1", "rowIndexString", "4"), Map.of()); + deleteContext.setServiceAccountCredentials(null); + clearContext.setServiceAccountCredentials(null); + + assertThrows(NullPointerException.class, () -> new GoogleSheetsDeleteDataHandler().execute(new Object(), deleteContext)); + assertThrows(NullPointerException.class, () -> new GoogleSheetsClearDataHandler().execute(new Object(), clearContext)); + GoogleSheetsQueryExecutionContext readContext = context("readData", Map.of("spreadsheetId", SPREADSHEET, "sheetName", "Sheet1"), Map.of()); + readContext.setServiceAccountCredentials(null); + QueryExecutionResult read = new org.lowcoder.plugin.googlesheets.GoogleSheetsPlugin.GoogleSheetsEngine() + .executeQuery(new Object(), readContext).block(); + assertEquals("GOOGLESHEETS_REQUEST_ERROR", read.getMessageKey(), "read builds the client inside the Mono: a failed result"); + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsEmptySheetFamilyTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsEmptySheetFamilyTest.java new file mode 100644 index 0000000000..6abc482a27 --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsEmptySheetFamilyTest.java @@ -0,0 +1,57 @@ +package org.lowcoder.plugin.googlesheets.queryhandler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer.Handler; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.models.QueryExecutionResult; + +/** + * The D11 family (analysis-plugins section 0.6; plan section 9 "D1-D20" row, D11: a missing {@code values} in a Sheets + * answer, which the API sends for an empty range, is dereferenced without a check; D-6, fix deferred). The append case is + * pinned in {@link GoogleSheetsAppendHandlerTest}. The same defect, found by the L4-13b probes, is pinned here for: + *

      + *
    • read of an empty sheet: {@code values.size()} in GoogleSheetsReadDataHandler.transformToFinalValues (R3);
    • + *
    • update when the header row is empty: {@code values1.get(0)} in GoogleSheetsUpdateDataHandler (U5).
    • + *
    + * Each caller gets a failed GOOGLESHEETS_REQUEST_ERROR result whose message is the JVM's NPE text, not a coded error. A fix + * that treats missing values as empty (read: an empty list; update: a coded PluginException) turns these assertions red. + */ +public class GoogleSheetsEmptySheetFamilyTest extends GoogleSheetsCallSupport { + + @Test + public void readingAnEmptySheetFailsWithARawNullPointerMessageR3() { + start(V4 + SPREADSHEET + "/values/Sheet1", json("{\"range\":\"Sheet1!A1:Z1000\",\"majorDimension\":\"ROWS\"}")); + + QueryExecutionResult result = run("readData", Map.of("spreadsheetId", SPREADSHEET, "sheetName", "Sheet1")); + + System.out.println("[GoogleSheetsEmptySheetFamilyTest] read empty sheet -> " + result.getMessageKey() + " " + failureText(result)); + assertFalse(result.isSuccess()); + assertEquals("GOOGLESHEETS_REQUEST_ERROR", result.getMessageKey()); + assertEquals("Cannot invoke \"java.util.List.size()\" because \"values\" is null", failureText(result)); + assertEquals(List.of("GET"), apiRequests().stream().map(Request::method).toList()); + } + + @Test + public void updatingWithAnEmptyHeaderRowFailsWithARawNullPointerMessageU5() { + Map routes = new HashMap<>(); + routes.put(V4 + SPREADSHEET + "/values/Sheet1!1:1", request -> json("{\"range\":\"Sheet1!A1:Z1\",\"majorDimension\":\"ROWS\"}")); + routes.put(V4 + SPREADSHEET + "/values/Sheet1!8:8", request -> json("{\"range\":\"Sheet1!A8:B8\",\"values\":[[\"x\",\"5\"]]}")); + start(routes); + + QueryExecutionResult result = run("updateData", Map.of("spreadsheetId", SPREADSHEET, "sheetName", "Sheet1", "rowIndexString", "7", + "changeSet", Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", "name", "value", "Ann"))))); + + System.out.println("[GoogleSheetsEmptySheetFamilyTest] update with empty header -> " + result.getMessageKey() + " " + failureText(result)); + assertFalse(result.isSuccess()); + assertEquals("GOOGLESHEETS_REQUEST_ERROR", result.getMessageKey()); + assertEquals("Cannot invoke \"java.util.List.get(int)\" because \"values1\" is null", failureText(result)); + assertEquals(List.of("GET", "GET"), apiRequests().stream().map(Request::method).toList(), "nothing is put"); + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsForeignHostGuardTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsForeignHostGuardTest.java new file mode 100644 index 0000000000..c3d362decb --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsForeignHostGuardTest.java @@ -0,0 +1,66 @@ +package org.lowcoder.plugin.googlesheets.queryhandler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.ProxySelector; +import java.net.URL; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; + +/** + * Shows the {@link ForeignHostGuard} failing closed: a URL on a host that never resolves ({@code .invalid}, RFC 6761) and + * Google's token endpoint are refused before any lookup or connection (the selector throws first, and records the URI), + * while the test's own server is let through. Only the guard's own limits apply (see its doc comment). + */ +public class GoogleSheetsForeignHostGuardTest { + + private ProxySelector previous; + + @BeforeEach + public void remember() { + previous = ProxySelector.getDefault(); + } + + @AfterEach + public void restore() { + ProxySelector.setDefault(previous); + } + + @Test + public void aForeignHostIsRefusedBeforeAnyLookupAndRecorded() { + ForeignHostGuard guard = new ForeignHostGuard(Set.of(1)); + ProxySelector.setDefault(guard); + + for (String url : List.of("http://guard-probe.invalid:80/x", "https://oauth2.googleapis.com/token", "http://127.0.0.1:2/other-port")) { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> new URL(url).openConnection().connect(), url); + System.out.println("[GoogleSheetsForeignHostGuardTest] " + url + " -> " + failure.getMessage()); + assertTrue(failure.getMessage().startsWith("GUARD: request to a foreign host"), failure.getMessage()); + } + + assertEquals(List.of("http://guard-probe.invalid:80/x", "https://oauth2.googleapis.com/token", "http://127.0.0.1:2/other-port"), guard.foreign()); + assertEquals(List.of(), guard.allowed()); + } + + @Test + public void theOwnServerIsLetThroughAndRecordedAsAllowed() throws Exception { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/ok", new Response(200, Map.of(), null)))) { + ForeignHostGuard guard = new ForeignHostGuard(Set.of(server.port())); + ProxySelector.setDefault(guard); + + int status = ((java.net.HttpURLConnection) new URL(server.baseUrl() + "/ok").openConnection()).getResponseCode(); + + assertEquals(200, status); + assertEquals(List.of(server.baseUrl() + "/ok"), guard.allowed()); + assertEquals(List.of(), guard.foreign()); + } + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsReadHandlerTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsReadHandlerTest.java new file mode 100644 index 0000000000..39b02dafaa --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsReadHandlerTest.java @@ -0,0 +1,89 @@ +package org.lowcoder.plugin.googlesheets.queryhandler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.models.QueryExecutionResult; + +/** + * {@link GoogleSheetsReadDataHandler} against a local server. Request shape per the Sheets v4 reference, + * {@code spreadsheets.values.get}: {@code GET /v4/spreadsheets/{spreadsheetId}/values/{range}}, the range being the sheet + * name. The token request is part of the exchange (see {@link GoogleSheetsCallSupport}). + */ +public class GoogleSheetsReadHandlerTest extends GoogleSheetsCallSupport { + + private static final String READ_PATH = V4 + SPREADSHEET + "/values/Sheet1"; + private static final String ANSWER = "{\"range\":\"Sheet1!A3:D6\",\"majorDimension\":\"ROWS\",\"values\":" + + "[[\"name\",\"\",\"name\"],[\"a\",\"1\"],[\"b\",\"2\",\"x\",\"extra\"]]}"; + private static final Map COMMAND = Map.of("spreadsheetId", "{{id}}", "sheetName", "{{sheet}}"); + + private static Map row(String... pairs) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < pairs.length; i += 2) { + row.put(pairs[i], pairs[i + 1]); + } + return row; + } + + @Test + public void readIssuesOneGetWithTheServedTokenAndReturnsTheRowsWithOffsetsAndRenamedHeaders() { + start(READ_PATH, json(ANSWER)); + + QueryExecutionResult result = run("readData", COMMAND, Map.of("id", SPREADSHEET, "sheet", "Sheet1")); + + List all = requests(); + System.out.println("[GoogleSheetsReadHandlerTest] requests " + all.stream().map(r -> r.method() + " " + r.pathAndQuery()).toList() + " -> " + result.getData()); + assertEquals(2, all.size()); + // 1: the token request carries the jwt-bearer grant for the service account and the Sheets scope + Request token = all.get(0); + assertEquals("POST", token.method()); + assertEquals(TOKEN_PATH, token.pathAndQuery()); + assertTrue(token.header("Content-Type").get(0).startsWith("application/x-www-form-urlencoded")); + assertTrue(token.bodyText().contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"), token.bodyText()); + Map assertion = assertionPayload(token); + assertEquals("bot@project.iam.gserviceaccount.com", assertion.get("iss")); + assertTrue(String.valueOf(assertion.get("scope")).contains("https://www.googleapis.com/auth/spreadsheets"), String.valueOf(assertion)); + // 2: the API request uses the served token + Request api = all.get(1); + assertEquals("GET", api.method()); + assertEquals(READ_PATH, api.pathAndQuery()); + assertEquals(List.of("Bearer " + TOKEN), api.header("Authorization")); + // result: row indexes start at the answered range's first row, blank and repeated headers are renamed, short rows padded + assertTrue(result.isSuccess()); + assertEquals(List.of( + row("rowIndex", "3", "name", "a", "Column-2", "1", "name_1", "", "Column-4", ""), + row("rowIndex", "4", "name", "b", "Column-2", "2", "name_1", "x", "Column-4", "extra")), result.getData()); + } + + @Test + public void anApiErrorAnswerBecomesAFailedResultCarryingTheApiMessage() { + start(READ_PATH, json(403, "{\"error\":{\"code\":403,\"message\":\"The caller does not have permission\",\"status\":\"PERMISSION_DENIED\"}}")); + + QueryExecutionResult result = run("readData", COMMAND, Map.of("id", SPREADSHEET, "sheet", "Sheet1")); + + System.out.println("[GoogleSheetsReadHandlerTest] 403 -> " + result.getMessageKey() + " " + failureText(result)); + assertFalse(result.isSuccess()); + assertEquals("GOOGLESHEETS_REQUEST_ERROR", result.getMessageKey()); + assertTrue(failureText(result).contains("403"), failureText(result)); + assertTrue(failureText(result).contains("The caller does not have permission"), failureText(result)); + assertEquals(1, apiRequests().size()); + } + + @Test + public void aSheetThatTheServerDoesNotKnowAnswers404AsAFailedResult() { + start(READ_PATH, json(ANSWER)); + + QueryExecutionResult result = run("readData", Map.of("spreadsheetId", SPREADSHEET, "sheetName", "Other")); + + assertFalse(result.isSuccess()); + assertEquals("GOOGLESHEETS_REQUEST_ERROR", result.getMessageKey()); + assertTrue(failureText(result).contains("404"), failureText(result)); + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsRootUrlSeamTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsRootUrlSeamTest.java new file mode 100644 index 0000000000..3ad930bd18 --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsRootUrlSeamTest.java @@ -0,0 +1,99 @@ +package org.lowcoder.plugin.googlesheets.queryhandler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.googlesheets.GoogleSheetsPlugin.GoogleSheetsEngine; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsDatasourceConfig; +import org.lowcoder.plugin.googlesheets.model.GoogleSheetsQueryExecutionContext; +import org.lowcoder.sdk.query.QueryVisitorContext; + +import com.google.api.services.sheets.v4.Sheets; + +/** + * The test-only root-URL seam of {@link GoogleSheetsGetPreParameters} (plan section 5 D-1): the default is Google's URL, + * the override applies, a reset restores the default, and the seam cannot be reached from outside the package. The seam + * is a JVM-wide static, so every test resets it in {@code @AfterEach}; this module runs its test classes sequentially + * (no parallel setting in any pom or junit-platform.properties), so no other Sheets test sees the override. Nothing + * connects: the Sheets client is only built. + */ +public class GoogleSheetsRootUrlSeamTest { + + private static final String GOOGLE_ROOT = "https://sheets.googleapis.com/"; + private static final String LOCAL_ROOT = "http://127.0.0.1:1/"; + + private final GoogleSheetsEngine engine = new GoogleSheetsEngine(); + + @AfterEach + public void resetTheSeam() { + GoogleSheetsGetPreParameters.setRootUrlForTests(null); + } + + private Sheets sheets() { + GoogleSheetsQueryExecutionContext context = engine.buildQueryExecutionContext( + engine.resolveConfig(Map.of("serviceAccount", serviceAccount())), + Map.of("commandType", "readData", "command", Map.of("spreadsheetId", "a", "sheetName", "b")), Map.of(), + new QueryVisitorContext("v", "o", 0, null, null, Set.of())); + return GoogleSheetsGetPreParameters.GetSheetsService(context); + } + + private static String serviceAccount() { + try { + Method json = Class.forName("org.lowcoder.plugin.googlesheets.ServiceAccountTestKeys").getDeclaredMethod("json"); + json.setAccessible(true); + return (String) json.invoke(null); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + @Test + public void withoutTheSeamSetTheClientTargetsGoogle() { + Sheets sheets = sheets(); + + assertEquals(GOOGLE_ROOT, sheets.getRootUrl()); + assertEquals(GOOGLE_ROOT, sheets.getBaseUrl()); + } + + @Test + public void anOverrideRetargetsTheClientAndAResetRestoresGoogle() { + GoogleSheetsGetPreParameters.setRootUrlForTests(LOCAL_ROOT); + Sheets overridden = sheets(); + GoogleSheetsGetPreParameters.setRootUrlForTests("http://127.0.0.1:2"); + Sheets withoutSlash = sheets(); + GoogleSheetsGetPreParameters.setRootUrlForTests(null); + Sheets restored = sheets(); + + System.out.println("[GoogleSheetsRootUrlSeamTest] override " + overridden.getRootUrl() + " / " + overridden.getBaseUrl() + + ", without slash " + withoutSlash.getRootUrl() + ", restored " + restored.getRootUrl()); + assertEquals(LOCAL_ROOT, overridden.getRootUrl()); + assertEquals(LOCAL_ROOT, overridden.getBaseUrl()); + assertFalse(withoutSlash.getRootUrl().isEmpty()); + assertEquals("http://127.0.0.1:2/", withoutSlash.getRootUrl(), "the Builder normalises a missing trailing slash"); + assertEquals(GOOGLE_ROOT, restored.getRootUrl()); + assertEquals(GOOGLE_ROOT, restored.getBaseUrl()); + } + + @Test + public void theSeamIsNotPublicAndNoConfigurationClassHasAMemberForIt() throws Exception { + Method setter = GoogleSheetsGetPreParameters.class.getDeclaredMethod("setRootUrlForTests", String.class); + + assertFalse(Modifier.isPublic(setter.getModifiers())); + assertFalse(Modifier.isProtected(setter.getModifiers())); + assertFalse(Modifier.isPublic(GoogleSheetsGetPreParameters.class.getDeclaredField("rootUrlOverride").getModifiers())); + for (Class type : new Class[] {GoogleSheetsDatasourceConfig.class, GoogleSheetsQueryExecutionContext.class}) { + assertFalse(Arrays.stream(type.getDeclaredFields()).anyMatch(field -> field.getName().toLowerCase().contains("rooturl")), type.getSimpleName()); + assertFalse(Arrays.stream(type.getMethods()).anyMatch(method -> method.getName().toLowerCase().contains("rooturl")), type.getSimpleName()); + } + assertThrows(NoSuchMethodException.class, () -> GoogleSheetsGetPreParameters.class.getMethod("setRootUrlForTests", String.class)); + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsUpdateHandlerTest.java b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsUpdateHandlerTest.java new file mode 100644 index 0000000000..01161188e2 --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsUpdateHandlerTest.java @@ -0,0 +1,87 @@ +package org.lowcoder.plugin.googlesheets.queryhandler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer.Handler; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.models.QueryExecutionResult; + +/** + * {@link GoogleSheetsUpdateDataHandler} against a local server. Per the Sheets v4 reference: the header row + * ({@code Sheet1!1:1}) and the row to change ({@code Sheet1!{n+1}:{n+1}}) are read with {@code spreadsheets.values.get}, + * then {@code spreadsheets.values.update} is + * {@code PUT /v4/spreadsheets/{id}/values/{range}?valueInputOption=RAW&includeValuesInResponse=true} with a ValueRange body. + * Row index {@code 7} of the query form is sheet row 8. + */ +public class GoogleSheetsUpdateHandlerTest extends GoogleSheetsCallSupport { + + private static final String HEADER_PATH = V4 + SPREADSHEET + "/values/Sheet1!1:1"; + private static final String ROW_PATH = V4 + SPREADSHEET + "/values/Sheet1!8:8"; + private static final String HEADER = "{\"range\":\"Sheet1!A1:C1\",\"majorDimension\":\"ROWS\",\"values\":[[\"name\",\"age\",\"city\"]]}"; + private static final String UPDATED = "{\"spreadsheetId\":\"sid\",\"updatedRange\":\"Sheet1!A8:C8\",\"updatedRows\":1,\"updatedColumns\":3," + + "\"updatedCells\":3,\"updatedData\":{\"range\":\"Sheet1!A8:C8\",\"values\":[[\"x\",\"6\",\"y\"]]}}"; + + private static Map command(String column, String value) { + return Map.of("spreadsheetId", SPREADSHEET, "sheetName", "Sheet1", "rowIndexString", "7", + "changeSet", Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", column, "value", value)))); + } + + private void startWith(String rowAnswer) { + Map routes = new HashMap<>(); + routes.put(HEADER_PATH, request -> json(HEADER)); + routes.put(ROW_PATH, request -> request.method().equals("PUT") ? json(UPDATED) : json(rowAnswer)); + start(routes); + } + + @Test + public void updateChangesOnlyTheNamedColumnsAndKeepsTheStoredCells() { + startWith("{\"range\":\"Sheet1!A8:C8\",\"majorDimension\":\"ROWS\",\"values\":[[\"x\",\"5\",\"y\"]]}"); + + QueryExecutionResult result = run("updateData", command("age", "6")); + + List api = apiRequests(); + System.out.println("[GoogleSheetsUpdateHandlerTest] " + api.stream().map(r -> r.method() + " " + r.pathAndQuery()).toList() + " body " + bodyText(api.get(2))); + assertTrue(result.isSuccess(), String.valueOf(result.getMessageArgs())); + assertEquals(List.of("GET " + HEADER_PATH, "GET " + ROW_PATH), api.subList(0, 2).stream().map(r -> r.method() + " " + r.pathAndQuery()).toList()); + Request put = api.get(2); + assertEquals("PUT", put.method()); + assertTrue(put.pathAndQuery().startsWith(ROW_PATH + "?"), put.pathAndQuery()); + assertEquals(Set.of("valueInputOption=RAW", "includeValuesInResponse=true"), + Set.of(put.pathAndQuery().substring(put.pathAndQuery().indexOf('?') + 1).split("&"))); + assertEquals(Map.of("majorDimension", "ROWS", "values", List.of(List.of("x", "6", "y"))), body(put)); + assertEquals(List.of("Bearer " + TOKEN), put.header("Authorization")); + assertTrue(String.valueOf(result.getData()).contains("Sheet1!A8:C8"), String.valueOf(result.getData())); + } + + @Test + public void aRowBeyondTheDataFailsAndNothingIsPut() { + startWith("{\"range\":\"Sheet1!A8:Z8\",\"majorDimension\":\"ROWS\"}"); + + QueryExecutionResult result = run("updateData", command("age", "6")); + + System.out.println("[GoogleSheetsUpdateHandlerTest] row beyond the data -> " + result.getMessageKey() + " " + failureText(result)); + assertFalse(result.isSuccess()); + assertEquals("GOOGLESHEETS_REQUEST_ERROR", result.getMessageKey()); + assertEquals("Google Sheets request failed.", failureText(result), "the handler's own PluginException, not a JVM message"); + assertEquals(List.of("GET", "GET"), apiRequests().stream().map(Request::method).toList()); + } + + @Test + public void whenNoChangeSetColumnMatchesAHeaderTheCallFailsAndNothingIsPut() { + startWith("{\"range\":\"Sheet1!A8:C8\",\"majorDimension\":\"ROWS\",\"values\":[[\"x\",\"5\",\"y\"]]}"); + + QueryExecutionResult result = run("updateData", command("nothing", "6")); + + assertFalse(result.isSuccess()); + assertEquals("Google Sheets query parameter is empty.", failureText(result)); + assertEquals(List.of("GET", "GET"), apiRequests().stream().map(Request::method).toList()); + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsActionRequest.input.json b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsActionRequest.input.json new file mode 100644 index 0000000000..38b3c1b4cb --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsActionRequest.input.json @@ -0,0 +1,41 @@ +{ + "readData": { + "commandType": "readData", + "command": { + "spreadsheetId": "sheet-1", + "sheetName": "Sheet1", + "range": "A1:C10", + "limit": 100, + "offset": "5" + } + }, + "clearData": { + "commandType": "clearData", + "command": { + "spreadsheetId": "sheet-1", + "sheetName": "Sheet1", + "rowIndexString": "{{row}}", + "rowIndex": 3 + } + }, + "deleteData": { + "commandType": "deleteData", + "command": { + "spreadsheetId": "sheet-1", + "sheetName": "Sheet1", + "rowIndexString": "4" + } + }, + "readDataDecimalLimit": { + "commandType": "readData", + "command": { + "spreadsheetId": "s", + "sheetName": "S", + "limit": 10.0 + } + }, + "unknownAction": { + "commandType": "sortData", + "command": {} + } +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsActionRequest.json b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsActionRequest.json new file mode 100644 index 0000000000..417859612f --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsActionRequest.json @@ -0,0 +1,92 @@ +{ + "parseGoogleSheetsActionRequest" : { + "readData" : { + "class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsReadDataRequest", + "fields" : { + "@class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsReadDataRequest", + "spreadsheetId" : "sheet-1", + "sheetName" : "Sheet1", + "range" : "A1:C10", + "limit" : 100, + "offset" : 5 + }, + "classes" : { + "" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsReadDataRequest", + "/spreadsheetId" : "java.lang.String", + "/sheetName" : "java.lang.String", + "/range" : "java.lang.String", + "/limit" : "java.lang.Integer", + "/offset" : "java.lang.Integer" + }, + "noView" : {"spreadsheetId":"sheet-1","sheetName":"Sheet1","range":"A1:C10","limit":100,"offset":5}, + "Public" : {"spreadsheetId":"sheet-1","sheetName":"Sheet1","range":"A1:C10","limit":100,"offset":5}, + "Internal" : {"spreadsheetId":"sheet-1","sheetName":"Sheet1","range":"A1:C10","limit":100,"offset":5} + }, + "clearData" : { + "class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsClearDataRequst", + "fields" : { + "@class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsClearDataRequst", + "spreadsheetId" : "sheet-1", + "sheetName" : "Sheet1", + "rowIndex" : 3, + "rowIndexString" : "{{row}}" + }, + "classes" : { + "" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsClearDataRequst", + "/spreadsheetId" : "java.lang.String", + "/sheetName" : "java.lang.String", + "/rowIndex" : "java.lang.Integer", + "/rowIndexString" : "java.lang.String" + }, + "noView" : {"spreadsheetId":"sheet-1","sheetName":"Sheet1","rowIndex":3,"rowIndexString":"{{row}}"}, + "Public" : {"spreadsheetId":"sheet-1","sheetName":"Sheet1","rowIndex":3,"rowIndexString":"{{row}}"}, + "Internal" : {"spreadsheetId":"sheet-1","sheetName":"Sheet1","rowIndex":3,"rowIndexString":"{{row}}"} + }, + "deleteData" : { + "class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsDeleteDataRequest", + "fields" : { + "@class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsDeleteDataRequest", + "spreadsheetId" : "sheet-1", + "sheetName" : "Sheet1", + "rowIndex" : 0, + "rowIndexString" : "4" + }, + "classes" : { + "" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsDeleteDataRequest", + "/spreadsheetId" : "java.lang.String", + "/sheetName" : "java.lang.String", + "/rowIndex" : "java.lang.Integer", + "/rowIndexString" : "java.lang.String" + }, + "noView" : {"spreadsheetId":"sheet-1","sheetName":"Sheet1","rowIndex":0,"rowIndexString":"4"}, + "Public" : {"spreadsheetId":"sheet-1","sheetName":"Sheet1","rowIndex":0,"rowIndexString":"4"}, + "Internal" : {"spreadsheetId":"sheet-1","sheetName":"Sheet1","rowIndex":0,"rowIndexString":"4"} + }, + "readDataDecimalLimit" : { + "class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsReadDataRequest", + "fields" : { + "@class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsReadDataRequest", + "spreadsheetId" : "s", + "sheetName" : "S", + "range" : null, + "limit" : 10, + "offset" : 0 + }, + "classes" : { + "" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsReadDataRequest", + "/spreadsheetId" : "java.lang.String", + "/sheetName" : "java.lang.String", + "/range" : "null", + "/limit" : "java.lang.Integer", + "/offset" : "java.lang.Integer" + }, + "noView" : {"spreadsheetId":"s","sheetName":"S","range":null,"limit":10,"offset":0}, + "Public" : {"spreadsheetId":"s","sheetName":"S","range":null,"limit":10,"offset":0}, + "Internal" : {"spreadsheetId":"s","sheetName":"S","range":null,"limit":10,"offset":0} + }, + "unknownAction" : { + "error" : "org.lowcoder.sdk.exception.PluginException: GOOGLESHEETS_EMPTY_QUERY_PARAM GOOGLESHEETS_QUERY_PARAM_EMPTY" + }, + "unknownProperty" : "ignored: binds as form readData" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsDatasourceConfig.input.json new file mode 100644 index 0000000000..48f49a72e3 --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsDatasourceConfig.input.json @@ -0,0 +1,6 @@ +{ + "full": { + "serviceAccount": "{\"type\":\"service_account\",\"client_id\":\"1\"}" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsDatasourceConfig.json b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsDatasourceConfig.json new file mode 100644 index 0000000000..2c065054d8 --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/config-binding/GoogleSheetsDatasourceConfig.json @@ -0,0 +1,33 @@ +{ + "GoogleSheetsEngine.resolveConfig" : { + "full" : { + "class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsDatasourceConfig", + "serviceAccount" : "{\"type\":\"service_account\",\"client_id\":\"1\"}" + }, + "classes" : { + "" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsDatasourceConfig", + "/serviceAccount" : "java.lang.String" + }, + "noView" : {"serviceAccount":"{\"type\":\"service_account\",\"client_id\":\"1\"}"}, + "Public" : {}, + "Internal" : {"serviceAccount":"{\"type\":\"service_account\",\"client_id\":\"1\"}"} + }, + "empty" : { + "class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsDatasourceConfig", + "serviceAccount" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.googlesheets.model.GoogleSheetsDatasourceConfig", + "/serviceAccount" : "null" + }, + "noView" : {"serviceAccount":null}, + "Public" : {}, + "Internal" : {"serviceAccount":null} + }, + "unknownProperty" : "ignored: binds as form full" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/external-json/GoogleSheets.results.json b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/external-json/GoogleSheets.results.json new file mode 100644 index 0000000000..cbb0d2a858 --- /dev/null +++ b/server/api-service/lowcoder-plugins/googleSheetsPlugin/src/test/resources/json-contract/external-json/GoogleSheets.results.json @@ -0,0 +1,79 @@ +{ + "read" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.LinkedList", + "/0" : "java.util.LinkedHashMap", + "/0/rowIndex" : "java.lang.String", + "/0/name" : "java.lang.String", + "/0/Column-2" : "java.lang.String", + "/0/name_1" : "java.lang.String", + "/0/Column-4" : "java.lang.String", + "/1" : "java.util.LinkedHashMap", + "/1/rowIndex" : "java.lang.String", + "/1/name" : "java.lang.String", + "/1/Column-2" : "java.lang.String", + "/1/name_1" : "java.lang.String", + "/1/Column-4" : "java.lang.String" + }, + "keyOrder" : { + "/0" : [ "rowIndex", "name", "Column-2", "name_1", "Column-4" ], + "/1" : [ "rowIndex", "name", "Column-2", "name_1", "Column-4" ] + } + }, + "written" : {"queryCode":"OK","data":[{"rowIndex":"3","name":"žluť","Column-2":"1.50","name_1":"","Column-4":""},{"rowIndex":"4","name":"b","Column-2":"2","name_1":"x","Column-4":"extra"}],"success":true} + }, + "readUnformattedNumber" : { + "error" : "java.lang.ClassCastException: class java.math.BigDecimal cannot be cast to class java.lang.String (java.math.BigDecimal and java.lang.String are in module java.base of loader 'bootstrap')" + }, + "append" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.AbstractMap$2" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":["s1","Sheet1!A1:B6",{"spreadsheetId":"s1","updatedCells":2,"updatedColumns":2,"updatedData":{"range":"Sheet1!A7:B7","values":[["a","1.50"]]},"updatedRange":"Sheet1!A7:B7","updatedRows":1}],"success":true} + }, + "update" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.AbstractMap$2" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":["s1",2,2,{"range":"Sheet1!A7:B7","values":[["a","1.50"]]},"Sheet1!A7:B7",1],"success":true} + }, + "delete" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.AbstractMap$2" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":[[{}],"s1"],"success":true} + }, + "clear" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.AbstractMap$2" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":["Sheet1!A7:Z7","s1"],"success":true} + }, + "serviceAccount" : { + "clientId" : "1234567890", + "clientEmail" : "bot@p.iam.gserviceaccount.com", + "privateKeyPkcs8" : "-----BEGIN PRIVATE KEY-----\nMIIE+ž\n-----END PRIVATE KEY-----\n", + "privateKeyId" : "k1" + }, + "serviceAccountNumericClientId" : { + "error" : "java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/pom.xml b/server/api-service/lowcoder-plugins/graphqlPlugin/pom.xml index cc0aa2d7df..c99123a1c2 100644 --- a/server/api-service/lowcoder-plugins/graphqlPlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/pom.xml @@ -87,22 +87,6 @@ test - - - org.powermock - powermock-module-junit4 - 2.0.9 - test - - - - - org.powermock - powermock-api-mockito2 - 2.0.9 - test - - org.springframework.boot spring-boot-starter-webflux diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLAuthTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLAuthTest.java new file mode 100644 index 0000000000..441d4f618d --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLAuthTest.java @@ -0,0 +1,167 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.BasicAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; + +/** + * Basic and digest authentication of {@link GraphQLExecutor} against a local server. The REST API plugin has the same + * code and an engine test for it; the GraphQL plugin had none. + * + *

    Pins, for the GraphQL executor (the same behaviour is pinned for REST in RestApiEngineTest; open, fix deferred + * under D-6, plan section 9 row on digest authentication): with a wrong password a digest-authenticated query is not + * answered "unauthorized"; the executor answers each new challenge until its request limit (5 in GraphQLExecutor) and + * fails with the redirect-limit error. + */ +class GraphQLAuthTest { + + private static final String PATH = "/graphql"; + private static final String USER = "postman"; + private static final String PASSWORD = "password"; + private static final String REALM = "Users"; + private static final String NONCE = "f2a8d2c6b1e04a7f9c3d5e6b7a8c9d0e"; + private static final String DIGEST = "Digest "; + private static final Pattern DIGEST_PARAM = Pattern.compile("(\\w+)=(?:\"([^\"]*)\"|([^,\\s]+))"); + /** {@code GraphQLExecutor.MAX_REDIRECTS} (private): calls 0 to 4 send a request, call 5 fails before sending one. */ + private static final int REQUEST_LIMIT = 5; + + private final GraphQLCallSupport support = new GraphQLCallSupport(); + + private static GraphQLDatasourceConfig datasource(RecordingHttpServer server, RestApiAuthType type, String password) { + return GraphQLDatasourceConfig.builder().url(server.baseUrl() + PATH) + .authConfig(BasicAuthConfig.builder().type(type).username(USER).password(password).build()).build(); + } + + @Test + void basicAuthSendsTheAuthorizationHeaderWithTheEncodedCredentials() { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of(PATH, GraphQLCallSupport.json(200, "{}")))) { + + support.run(datasource(server, RestApiAuthType.BASIC_AUTH, PASSWORD), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, null)); + + String expected = "Basic " + Base64.getEncoder().encodeToString((USER + ":" + PASSWORD).getBytes(StandardCharsets.UTF_8)); + System.out.println("[GraphQLAuthTest] Authorization " + server.requests().get(0).header("Authorization")); + assertThat(server.requests()).hasSize(1); + assertThat(server.requests().get(0).header("Authorization")).containsExactly(expected); + } + } + + @Test + void digestAuthAnswersTheChallengeWithAValidResponseAndGetsTheResult() { + try (RecordingHttpServer server = RecordingHttpServer.serve(Map.of(PATH, GraphQLAuthTest::digestServer))) { + + QueryExecutionResult result = support.run(datasource(server, RestApiAuthType.DIGEST_AUTH, PASSWORD), GraphQLCallSupport.query(), + GraphQLCallSupport.visitor(null, null)); + + System.out.println("[GraphQLAuthTest] digest: " + server.requests().size() + " requests, authenticated=" + result.getData()); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getData().toString()).isEqualTo("{\"authenticated\":true}"); + assertThat(server.requests()).hasSize(2); + assertThat(server.requests().get(0).header("Authorization")).isEmpty(); + assertThat(server.requests().get(1).header("Authorization")).hasSize(1); + } + } + + /** + * Pins the digest wrong-password behaviour for the GraphQL executor (see the class comment; the fix is deferred): the + * server answers every attempt with a new challenge, the executor answers each one until its limit, and the query + * fails with the redirect-limit error rather than an authentication failure. The obvious fix is to stop after a + * challenge that follows an authenticated attempt, which turns this test red. + */ + @Test + void digestAuthWithAWrongPasswordRetriesUntilTheRequestLimitAndReportsTheRedirectLimit() { + try (RecordingHttpServer server = RecordingHttpServer.serve(Map.of(PATH, GraphQLAuthTest::digestServer))) { + + Throwable failure = support.failureOf(datasource(server, RestApiAuthType.DIGEST_AUTH, "wrong"), GraphQLCallSupport.query(), + GraphQLCallSupport.visitor(null, null)); + + System.out.println("[GraphQLAuthTest] digest, wrong password: " + failure + " after " + server.requests().size() + " requests"); + assertThat(failure).isInstanceOf(PluginException.class).hasMessageContaining("maximum HTTP redirects"); + assertThat(server.requests()).hasSize(REQUEST_LIMIT); + } + } + + @Test + void aMalformedDigestChallengeGivesAPluginExceptionAndIsNotRetried() { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of(PATH, + new Response(401, Map.of("WWW-Authenticate", List.of("Digest garbage")), null)))) { + + Throwable failure = support.failureOf(datasource(server, RestApiAuthType.DIGEST_AUTH, PASSWORD), GraphQLCallSupport.query(), + GraphQLCallSupport.visitor(null, null)); + + System.out.println("[GraphQLAuthTest] malformed challenge -> " + failure); + assertThat(failure).isInstanceOf(PluginException.class); + assertThat(server.requests()).hasSize(1); + } + } + + /** Behaviour, not a defect: without a challenge header a 401 is an ordinary answer, returned once. */ + @Test + void aPlain401UnderDigestAuthIsReturnedAsTheResultAndNotRetried() { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of(PATH, new Response(401, Map.of(), null)))) { + + QueryExecutionResult result = support.run(datasource(server, RestApiAuthType.DIGEST_AUTH, PASSWORD), GraphQLCallSupport.query(), + GraphQLCallSupport.visitor(null, null)); + + assertThat(result.isSuccess()).isFalse(); + assertThat(server.requests()).hasSize(1); + } + } + + /** RFC 7616 with MD5 and qop=auth: 401 with a challenge until the Authorization header carries a valid response. */ + private static Response digestServer(Request request) { + String authorization = request.header("Authorization").stream().findFirst().orElse(""); + if (authorization.startsWith(DIGEST) && validDigest(request, digestParameters(authorization))) { + return GraphQLCallSupport.json(200, "{\"authenticated\":true}"); + } + String challenge = DIGEST + "realm=\"" + REALM + "\", nonce=\"" + NONCE + "\", qop=\"auth\""; + return new Response(401, Map.of("WWW-Authenticate", List.of(challenge)), null); + } + + private static boolean validDigest(Request request, Map digest) { + String path = request.pathAndQuery().split("\\?")[0]; + if (!USER.equals(digest.get("username")) || !REALM.equals(digest.get("realm")) || !NONCE.equals(digest.get("nonce")) + || !path.equals(digest.get("uri"))) { + return false; + } + String ha1 = md5(USER + ":" + REALM + ":" + PASSWORD); + String ha2 = md5(request.method() + ":" + digest.get("uri")); + String expected = md5(ha1 + ":" + NONCE + ":" + digest.get("nc") + ":" + digest.get("cnonce") + ":" + digest.get("qop") + ":" + ha2); + return expected.equals(digest.get("response")); + } + + private static Map digestParameters(String authorization) { + Map parameters = new HashMap<>(); + Matcher matcher = DIGEST_PARAM.matcher(authorization.substring(DIGEST.length())); + while (matcher.find()) { + parameters.put(matcher.group(1), matcher.group(2) != null ? matcher.group(2) : matcher.group(3)); + } + return parameters; + } + + private static String md5(String text) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("MD5").digest(text.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLBodyTypesTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLBodyTypesTest.java new file mode 100644 index 0000000000..fed57b0506 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLBodyTypesTest.java @@ -0,0 +1,73 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; + +/** + * Request body types of {@link GraphQLExecutor}: form and multipart bodies are built from the body parameters, and + * {@code application/graphql} sends the query text as given. The default JSON body (query and variables) is pinned by + * GraphQLRequestBodyContractTest and not repeated. + */ +class GraphQLBodyTypesTest { + + private static final String PATH = "/graphql"; + private static final String CONTENT_TYPE = "Content-Type"; + + private final GraphQLCallSupport support = new GraphQLCallSupport(); + + private RecordingHttpServer.Request send(String contentType, Map query) { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of(PATH, GraphQLCallSupport.json(200, "{}")))) { + GraphQLDatasourceConfig datasource = GraphQLDatasourceConfig.builder().url(server.baseUrl() + PATH) + .headers(List.of(new Property(CONTENT_TYPE, contentType))).build(); + support.run(datasource, query, GraphQLCallSupport.visitor(null, null)); + assertThat(server.requests()).hasSize(1); + return server.requests().get(0); + } + } + + @Test + void aFormBodyIsEncodedFromTheBodyParameters() { + RecordingHttpServer.Request request = send("application/x-www-form-urlencoded", + GraphQLCallSupport.query(Map.of("bodyFormData", List.of(new Property("a", "x y&z"), new Property("b", "2"))))); + + System.out.println("[GraphQLBodyTypesTest] form body " + request.bodyText()); + assertThat(String.join(",", request.header(CONTENT_TYPE))).startsWith("application/x-www-form-urlencoded"); + assertThat(request.bodyText().split("&")).containsExactlyInAnyOrder("a=x+y%26z", "b=2"); + } + + @Test + void aMultipartBodyReachesTheServerWithItsPartsAndAContentLength() { + RecordingHttpServer.Request request = send("multipart/form-data", + GraphQLCallSupport.query(Map.of("bodyFormData", List.of(new Property("a", "1"), new Property("b", "two"))))); + + System.out.println("[GraphQLBodyTypesTest] multipart headers " + request.headers().keySet() + " body length " + request.body().length); + assertThat(String.join(",", request.header(CONTENT_TYPE))).startsWith("multipart/form-data;").contains("boundary="); + assertThat(request.bodyText()).contains("name=\"a\"", "name=\"b\"", "two"); + assertThat(request.header("Content-Length")).containsExactly(String.valueOf(request.body().length)); + assertThat(request.header("Transfer-Encoding")).isEmpty(); + } + + @Test + void applicationGraphqlSendsTheQueryTextAsGiven() { + RecordingHttpServer.Request request = send("application/graphql", Map.of("body", GraphQLCallSupport.QUERY, "variables", List.of())); + + assertThat(request.header(CONTENT_TYPE)).containsExactly("application/graphql"); + assertThat(new String(request.body(), StandardCharsets.UTF_8)).isEqualTo(GraphQLCallSupport.QUERY); + } + + @Test + void theJsonBodyWrapsTheQueryAndOmitsAnEmptyVariablesObject() { + RecordingHttpServer.Request request = send("application/json", GraphQLCallSupport.query()); + + assertThat(String.join(",", request.header(CONTENT_TYPE))).startsWith("application/json"); + assertThat(request.bodyText()).isEqualTo("{\"query\":\"" + GraphQLCallSupport.QUERY + "\"}"); + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLCallSupport.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLCallSupport.java new file mode 100644 index 0000000000..3adac11121 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLCallSupport.java @@ -0,0 +1,106 @@ +package org.lowcoder.plugin.graphql; + +import static java.util.Collections.emptyMap; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.lowcoder.plugin.graphql.model.GraphQLQueryExecutionContext; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.query.QueryVisitorContext; +import org.springframework.http.HttpCookie; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import reactor.core.publisher.Mono; + +/** + * What the GraphQL executor tests of L4-10 share (the counterpart of the REST API plugin's RestApiCallSupport): an + * executor whose {@code CommonConfig} names the Lowcoder session cookie, a visitor context with cookies and an OAuth + * token Mono, and one call that builds the execution context and runs it against a {@link RecordingHttpServer}. New + * file; the existing contract tests keep their own setup. + */ +final class GraphQLCallSupport { + + static final String SESSION_COOKIE = "LOWCODER_SESSION"; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final String QUERY = "query { hero { name } }"; + + private final GraphQLExecutor executor; + + GraphQLCallSupport() { + CommonConfig config = new CommonConfig(); + config.setCookieName(SESSION_COOKIE); + executor = new GraphQLExecutor(config); + } + + static QueryVisitorContext visitor(MultiValueMap cookies, Mono> authToken) { + return new QueryVisitorContext("visitor-1", "org-1", 8080, cookies, authToken, Set.of()); + } + + static MultiValueMap cookies(String... nameValuePairs) { + MultiValueMap cookies = new LinkedMultiValueMap<>(); + for (int i = 0; i < nameValuePairs.length; i += 2) { + cookies.add(nameValuePairs[i], new HttpCookie(nameValuePairs[i], nameValuePairs[i + 1])); + } + return cookies; + } + + /** + * The query map of a GraphQL query: the query text in {@code body} and an empty {@code variables} list. The key is + * always given: a query config without {@code variables} fails with a NullPointerException (probe GP4, L4-10). + */ + static Map query() { + return Map.of("body", QUERY, "variables", List.of()); + } + + /** {@link #query()} plus the given entries (keys of the query config: path, headers, params, bodyFormData, variables, body). */ + static Map query(Map extra) { + Map merged = new java.util.HashMap<>(query()); + merged.putAll(extra); + return merged; + } + + GraphQLQueryExecutionContext buildContext(GraphQLDatasourceConfig datasource, Map query, Map params, + QueryVisitorContext visitor) { + return executor.buildQueryExecutionContext(datasource, query, params, visitor); + } + + GraphQLQueryExecutionContext buildContext(GraphQLDatasourceConfig datasource, Map query, QueryVisitorContext visitor) { + return buildContext(datasource, query, emptyMap(), visitor); + } + + /** Builds the context and runs it; an exception of the call is thrown by {@code block}. */ + QueryExecutionResult run(GraphQLDatasourceConfig datasource, Map query, QueryVisitorContext visitor) { + return executor.executeQuery(null, buildContext(datasource, query, visitor)).block(TIMEOUT); + } + + /** The exception of a call that must throw (null if it did not). */ + Throwable failureOf(GraphQLDatasourceConfig datasource, Map query, QueryVisitorContext visitor) { + try { + run(datasource, query, visitor); + return null; + } catch (Throwable t) { + return t; + } + } + + static Response json(int status, String body) { + return new Response(status, Map.of("Content-Type", List.of("application/json")), body.getBytes(StandardCharsets.UTF_8)); + } + + static Response redirect(int status, String location) { + return new Response(status, Map.of("Location", List.of(location)), null); + } + + static List requestsTo(RecordingHttpServer server, String path) { + return server.requests().stream().filter(request -> request.pathAndQuery().split("\\?")[0].equals(path)).toList(); + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLConfigBindingContractTest.java new file mode 100644 index 0000000000..c35da50da0 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLConfigBindingContractTest.java @@ -0,0 +1,30 @@ +package org.lowcoder.plugin.graphql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.graphql.model.GraphQLQueryConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code graphqlPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1), + * through the real entry points: {@code GraphQLQueryConfig.from}, whose creator does not take {@code httpMethod} or + * {@code disableEncodingParams}. {@code ConfigBinding} applies each entry point to every form of the input fixture + * (canonical names, alias or getter-derived names, scalars as strings, missing and empty values) and pins the bound + * state, the written views and the errors in the report fixture. + */ +public class GraphQLConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/model/GraphQLQueryConfig.java#GraphQLQueryConfig.from#toJson#1", + "lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/model/GraphQLQueryConfig.java#GraphQLQueryConfig.from#fromJson#1"}) + @Test + public void queryConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/GraphQLQueryConfig.input.json", "config-binding/GraphQLQueryConfig.json", + Map.of("from", GraphQLQueryConfig::from)); + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLCookieForwardingTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLCookieForwardingTest.java new file mode 100644 index 0000000000..8094f368fd --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLCookieForwardingTest.java @@ -0,0 +1,79 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.plugin.graphql.GraphQLCallSupport.SESSION_COOKIE; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.springframework.http.HttpCookie; +import org.springframework.util.MultiValueMap; + +/** + * Cookie forwarding of {@link GraphQLExecutor}: which of the visitor's request cookies reach the GraphQL endpoint. The + * Lowcoder session cookie must never be one of them. Each test reads the {@code Cookie} header the local server recorded. + */ +class GraphQLCookieForwardingTest { + + private static final String PATH = "/graphql"; + private static final String SESSION_VALUE = "session-secret"; + + private final GraphQLCallSupport support = new GraphQLCallSupport(); + private RecordingHttpServer server; + + @BeforeEach + void startServer() { + server = RecordingHttpServer.start(Map.of(PATH, GraphQLCallSupport.json(200, "{}"))); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private String cookieHeaderSentFor(GraphQLDatasourceConfig.GraphQLDatasourceConfigBuilder datasource, MultiValueMap cookies) { + support.run(datasource.url(server.baseUrl() + PATH).build(), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(cookies, null)); + List requests = GraphQLCallSupport.requestsTo(server, PATH); + assertThat(requests).hasSize(1); + String cookie = String.join("; ", requests.get(0).header("Cookie")); + System.out.println("[GraphQLCookieForwardingTest] Cookie header sent: '" + cookie + "'"); + return cookie; + } + + @Test + void forwardAllCookiesSendsEveryCookieExceptTheLowcoderSessionCookie() { + String cookie = cookieHeaderSentFor(GraphQLDatasourceConfig.builder().forwardAllCookies(true), + GraphQLCallSupport.cookies(SESSION_COOKIE, SESSION_VALUE, "a", "1", "a", "2", "b", "3")); + + assertThat(cookie).contains("a=1", "a=2", "b=3").doesNotContain(SESSION_VALUE).doesNotContain(SESSION_COOKIE); + } + + @Test + void theAllowListForwardsOnlyTheListedCookiesAndNeverTheSessionCookieEvenWhenListed() { + String cookie = cookieHeaderSentFor(GraphQLDatasourceConfig.builder().forwardCookies(Set.of("a", SESSION_COOKIE)), + GraphQLCallSupport.cookies(SESSION_COOKIE, SESSION_VALUE, "a", "1", "b", "3")); + + assertThat(cookie).contains("a=1").doesNotContain("b=3").doesNotContain(SESSION_VALUE); + } + + @Test + void withoutRequestCookiesNoCookieHeaderIsSent() { + String cookie = cookieHeaderSentFor(GraphQLDatasourceConfig.builder().forwardAllCookies(true).forwardCookies(Set.of("a")), null); + + assertThat(cookie).isEmpty(); + } + + @Test + void withAnEmptyAllowListAndForwardAllOffNoCookieIsSent() { + String cookie = cookieHeaderSentFor(GraphQLDatasourceConfig.builder().forwardCookies(Set.of()), + GraphQLCallSupport.cookies(SESSION_COOKIE, SESSION_VALUE, "a", "1")); + + assertThat(cookie).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLExecutorCodecGuardTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLExecutorCodecGuardTest.java new file mode 100644 index 0000000000..f127046eb3 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLExecutorCodecGuardTest.java @@ -0,0 +1,35 @@ +package org.lowcoder.plugin.graphql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ClientCodecs; +import org.springframework.web.reactive.function.client.ExchangeStrategies; + +import java.lang.reflect.Field; + +/** + * Guard of the client codecs of {@link GraphQLExecutor} (docs/API_PAYLOAD_TEST_PLAN.md §5.4, group + * {@code client-codec-guard}, site {@code GraphQLExecutor.java:89}). It reads the real private instance field of an + * executor built as the plugin builds it. + * + *

    Requests run with the {@code WebClientBuildHelper} limit, not the one declared at {@code GraphQLExecutor.java:87-90} + * (plan §9, O11; evidence E14), so the guard pins the strategies a request really gets. + * + *

    Limit: it runs on this module's test classpath. The modules Spring adds to the codec mapper depend on the + * classpath, and in the server the plugin runs on the server's; the server-side guard covers that one. + */ +public class GraphQLExecutorCodecGuardTest { + + private static final String STRATEGIES_FIELD = "EXCHANGE_STRATEGIES"; + + @BoundarySites("lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.EXCHANGE_STRATEGIES#codecs#1") + @Test + public void requestsUseDefaultClientCodecsWithTheHelperLimit() throws ReflectiveOperationException { + Field field = GraphQLExecutor.class.getDeclaredField(STRATEGIES_FIELD); + field.setAccessible(true); + ExchangeStrategies declared = (ExchangeStrategies) field.get(new GraphQLExecutor(new CommonConfig())); + ClientCodecs.assertDefaultClientCodecs("GraphQLExecutor", + ClientCodecs.effectiveThroughWebClientBuildHelper(declared), ClientCodecs.WEB_CLIENT_BUILD_HELPER_DEFAULT_SIZE); + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLHostlessUriResolverGuardTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLHostlessUriResolverGuardTest.java new file mode 100644 index 0000000000..dad75d9eb5 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLHostlessUriResolverGuardTest.java @@ -0,0 +1,137 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import io.netty.resolver.AddressResolver; +import io.netty.resolver.AddressResolverGroup; +import io.netty.util.concurrent.DefaultEventExecutor; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.Future; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.restapi.auth.AuthConfig; +import org.lowcoder.sdk.webclient.SafeHostResolverGroup; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.Connection; +import reactor.netty.ConnectionObserver; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; +import reactor.netty.transport.TransportConfig; + +/** + * DEFECT pinned (plan section 9 row "SSRF guard bypass: a relative Location is followed as a host-less URI that the + * disallowed-hosts resolver does not stop"; D-6, fix deferred; goes with D14): the executor's disallowed hosts work + * through {@code SafeHostResolverGroup}, set on the HttpClient by {@code WebClientBuildHelper}. The guard sees only + * addresses that are still unresolved when the transport connects. For a host-less URI (a relative {@code Location} + * followed as given, GraphQLExecutor.java:343-347) Reactor Netty hands the connection provider an address that is already + * resolved ({@code localhost}, port 80, here {@code /[0:0:0:0:0:0:0:1]:80}), so the resolver group answers + * {@code isResolved == true} and the transport skips it: a disallowed-hosts entry for {@code localhost} does not stop the + * request. (An earlier run of the REST test with {@code disallowedHosts=[localhost]} reached the machine's localhost:80.) + * + *

    How the test stays hermetic: the HttpClient uses a connection provider that connects only to the local recording + * server (by its port) and, for any other address, records it and fails without ever opening a socket. The test + * therefore sends nothing to any uncontrolled address. The redirecting request goes through the executor's private + * {@code httpCall} (reflection) with a WebClient built on that HttpClient, configured as WebClientBuildHelper does + * (resolver = SafeHostResolverGroup with {@code localhost} disallowed). + * + *

    The obvious fix, resolving the relative Location against the request URI ({@code uri.resolve(redirectUrl)}), makes + * the second request go to the recording server, so no host-less address reaches the provider and this test turns red. + * + *

    Limits: the HttpClient is built here the way WebClientBuildHelper builds it, not obtained from the executor; the + * test shows what the guard is shown for a host-less URI, not the behaviour of every Netty version. + */ +class GraphQLHostlessUriResolverGuardTest { + + private static final int HTTP_DEFAULT_PORT = 80; + + /** Connects only to the loopback server on {@code allowedPort}; records and fails every other address, never connecting. */ + private static final class GuardedProvider implements ConnectionProvider { + private final ConnectionProvider delegate = ConnectionProvider.newConnection(); + private final int allowedPort; + final List refused = new CopyOnWriteArrayList<>(); + volatile AddressResolverGroup resolverGroup; + + GuardedProvider(int allowedPort) { + this.allowedPort = allowedPort; + } + + @Override + public Mono acquire(TransportConfig config, ConnectionObserver observer, + Supplier remoteAddress, AddressResolverGroup group) { + SocketAddress address = remoteAddress.get(); + if (address instanceof InetSocketAddress inet && inet.getPort() == allowedPort) { + return delegate.acquire(config, observer, remoteAddress, group); + } + refused.add(address); + resolverGroup = group; + return Mono.error(new IllegalStateException("refused without connecting: " + address)); + } + } + + @Test + void aHostlessRedirectTargetReachesTheTransportAsAnAlreadyResolvedAddressTheGuardIsNotAskedAboutD14() throws ReflectiveOperationException { + try (RecordingHttpServer server = RecordingHttpServer.serve(Map.of( + "/start", request -> GraphQLCallSupport.redirect(302, "/next"), + "/next", request -> GraphQLCallSupport.json(200, "{}")))) { + GuardedProvider provider = new GuardedProvider(server.port()); + HttpClient httpClient = HttpClient.create(provider).resolver(new SafeHostResolverGroup(Set.of("localhost"))); + WebClient client = WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient)).build(); + Method httpCall = GraphQLExecutor.class.getDeclaredMethod("httpCall", WebClient.class, HttpMethod.class, URI.class, + BodyInserter.class, int.class, AuthConfig.class, Consumer.class); + httpCall.setAccessible(true); + URI origin = URI.create(server.baseUrl() + "/start"); + + Mono call = (Mono) httpCall.invoke(new GraphQLExecutor(new CommonConfig()), client, HttpMethod.POST, origin, + BodyInserters.fromValue(new byte[0]), 0, null, (Consumer) headers -> { }); + assertThatThrownBy(() -> call.block(GraphQLCallSupport.TIMEOUT)).isInstanceOf(PluginException.class); + + System.out.println("[GraphQLHostlessUriResolverGuardTest] addresses refused by the provider: " + provider.refused + + "; the recording server saw " + server.requests().stream().map(RecordingHttpServer.Request::pathAndQuery).toList()); + assertThat(server.requests()).extracting(RecordingHttpServer.Request::pathAndQuery).containsExactly("/start"); + assertThat(provider.refused).hasSize(1); + InetSocketAddress hostless = (InetSocketAddress) provider.refused.get(0); + assertThat(hostless.getPort()).isEqualTo(HTTP_DEFAULT_PORT); + assertThat(hostless.getAddress().isLoopbackAddress()).isTrue(); + assertThat(hostless.isUnresolved()).as("the address is already resolved when the transport gets it").isFalse(); + + EventExecutor executor = new DefaultEventExecutor(); + try { + @SuppressWarnings("unchecked") + AddressResolver resolver = (AddressResolver) provider.resolverGroup.getResolver(executor); + assertThat(provider.resolverGroup).isInstanceOf(SafeHostResolverGroup.class); + assertThat(resolver.isResolved(hostless)).as("a resolved address is not passed to the resolver: the guard is skipped").isTrue(); + // control: asked about the same name as an unresolved address, the guard does reject it + Future asked = resolver.resolve(InetSocketAddress.createUnresolved("localhost", HTTP_DEFAULT_PORT)); + asked.await(5000); + System.out.println("[GraphQLHostlessUriResolverGuardTest] guard asked about unresolved localhost:80 -> " + asked.cause()); + assertThat(asked.isSuccess()).isFalse(); + assertThat(asked.cause()).hasMessageContaining("Host not allowed"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } finally { + executor.shutdownGracefully(); + } + } + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLMissingLocaleKeyTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLMissingLocaleKeyTest.java new file mode 100644 index 0000000000..2c9d904da0 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLMissingLocaleKeyTest.java @@ -0,0 +1,44 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.OAuthInheritAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; +import reactor.core.publisher.Mono; + +/** + * DEFECT pinned (plan section 9 row "GRAPHQL_EXECUTION_ERROR in no locale bundle"; D-6, fix deferred): every GraphQL + * execution error is raised with the message key {@code GRAPHQL_EXECUTION_ERROR} (GraphQLExecutor.java, the constant + * {@code DEFAULT_GRAPHQL_ERROR_CODE} and the {@code PluginException}s built with it), but the key has no entry in + * {@code locale_en.properties}, {@code locale_de.properties} or {@code locale_zh.properties}. The text falls back to the + * generic {@code INTERNAL_SERVER_ERROR} message, so the user is told the service is busy and the cause is lost. The REST + * executor's equivalent key ({@code REST_API_EXECUTION_ERROR}) has a text that names the cause. + * + *

    The path used is the OAuth "inherit from login" step with an empty token Mono, which should read + * "$ACCESS_TOKEN parameter missing". The obvious fix, adding {@code GRAPHQL_EXECUTION_ERROR=GraphQL execution error: {0}.} + * to the bundles, turns this test red. + */ +class GraphQLMissingLocaleKeyTest { + + private static final String GENERIC_TEXT = "Oops! Service is busy, please try again later."; + + private final GraphQLCallSupport support = new GraphQLCallSupport(); + + @Test + void anOauthErrorIsReportedWithTheGenericServiceBusyTextAndNotItsCause() { + GraphQLDatasourceConfig datasource = GraphQLDatasourceConfig.builder().url("http://example.invalid/graphql") + .authConfig(OAuthInheritAuthConfig.builder().type(RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN).build()).build(); + + Throwable failure = support.failureOf(datasource, GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, Mono.empty())); + + System.out.println("[GraphQLMissingLocaleKeyTest] message: " + (failure == null ? null : failure.getMessage())); + assertThat(failure).isInstanceOf(PluginException.class); + assertThat(failure.getMessage()).isEqualTo(GENERIC_TEXT); + assertThat(failure.getMessage()).doesNotContain("ACCESS_TOKEN"); + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLOauthInheritTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLOauthInheritTest.java new file mode 100644 index 0000000000..485db9d032 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLOauthInheritTest.java @@ -0,0 +1,143 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.AuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.BasicAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.NoneAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.OAuthInheritAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; +import reactor.core.publisher.Mono; + +/** + * OAuth2 "inherit from login" of {@link GraphQLExecutor}: the visitor's token properties are added to the request, an + * empty or failing token Mono stops the call before any request, and every other auth type leaves the token Mono alone. + * + *

    Unlike the REST API plugin (defect P4 there), the GraphQL executor builds its URI after the token step, so the + * token PARAMS do reach the URL; this class asserts that intended behaviour and it holds. + */ +class GraphQLOauthInheritTest { + + private static final String PATH = "/graphql"; + private static final String TOKEN_HEADER = "X-Token"; + + private final GraphQLCallSupport support = new GraphQLCallSupport(); + private RecordingHttpServer server; + + @BeforeEach + void startServer() { + server = RecordingHttpServer.start(Map.of(PATH, GraphQLCallSupport.json(200, "{}"))); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private GraphQLDatasourceConfig datasource(AuthConfig auth) { + return GraphQLDatasourceConfig.builder().url(server.baseUrl() + PATH) + .params(List.of(new Property("own", "1"), new Property("tok", "from-datasource"))) + .headers(List.of(new Property(TOKEN_HEADER, "from-datasource"), new Property("X-Own", "kept"))) + .authConfig(auth).build(); + } + + private static AuthConfig oauthInherit() { + return OAuthInheritAuthConfig.builder().type(RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN).build(); + } + + private RecordingHttpServer.Request onlyRequest() { + assertThat(server.requests()).hasSize(1); + return server.requests().get(0); + } + + @Test + void theTokenHeadersAndParamsOfTheLoginReachTheRequestOverTheDatasourceValues() { + support.run(datasource(oauthInherit()), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, Mono.just(List.of( + new Property(TOKEN_HEADER, "from-login", "header"), new Property("tok", "from-login", "param"), new Property("fresh", "new", "param"))))); + + RecordingHttpServer.Request request = onlyRequest(); + System.out.println("[GraphQLOauthInheritTest] " + request.pathAndQuery() + " " + request.header(TOKEN_HEADER)); + assertThat(request.header(TOKEN_HEADER)).containsExactly("from-login"); + assertThat(request.header("X-Own")).containsExactly("kept"); + // the order of the params follows an unordered map, so it is not asserted + assertThat(request.pathAndQuery().split("\\?")[1].split("&")).containsExactlyInAnyOrder("own=1", "tok=from-login", "fresh=new"); + } + + @Test + void aTokenWithOnlyHeadersOrOnlyParamsAddsOnlyThatKind() { + support.run(datasource(oauthInherit()), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, + Mono.just(List.of(new Property("only-header", "h", "header"))))); + support.run(datasource(oauthInherit()), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, + Mono.just(List.of(new Property("only-param", "p", "param"))))); + + assertThat(server.requests()).hasSize(2); + assertThat(server.requests().get(0).header("only-header")).containsExactly("h"); + assertThat(server.requests().get(0).pathAndQuery()).doesNotContain("only-param"); + assertThat(server.requests().get(1).header("only-header")).isEmpty(); + assertThat(server.requests().get(1).pathAndQuery()).contains("only-param=p"); + } + + @Test + void anEmptyTokenMonoFailsWithAccessTokenMissingAndSendsNoRequest() { + Throwable failure = support.failureOf(datasource(oauthInherit()), GraphQLCallSupport.query(), + GraphQLCallSupport.visitor(null, Mono.empty())); + + System.out.println("[GraphQLOauthInheritTest] empty token -> " + failure); + // The message is not asserted: the key GRAPHQL_EXECUTION_ERROR has no entry in any locale bundle, so the text is the + // generic "Oops! Service is busy" (probe GP5, reported in log-L4.md, L4-10); the REST executor's message names the cause. + assertThat(failure).isInstanceOf(PluginException.class); + assertThat(server.requests()).isEmpty(); + } + + @Test + void aFailingTokenMonoIsReportedAsAPluginExceptionAndSendsNoRequest() { + Throwable failure = support.failureOf(datasource(oauthInherit()), GraphQLCallSupport.query(), + GraphQLCallSupport.visitor(null, Mono.error(new IllegalStateException("login expired")))); + + System.out.println("[GraphQLOauthInheritTest] failing token -> " + failure); + assertThat(failure).isInstanceOf(PluginException.class); // message not asserted, see the empty-token test + assertThat(server.requests()).isEmpty(); + } + + @Test + void otherAuthTypesNeverSubscribeToTheTokenMono() { + AtomicInteger subscriptions = new AtomicInteger(); + Mono> token = Mono.defer(() -> { + subscriptions.incrementAndGet(); + return Mono.error(new IllegalStateException("must not be read")); + }); + AuthConfig basic = BasicAuthConfig.builder().type(RestApiAuthType.BASIC_AUTH).username("u").password("p").build(); + + support.run(datasource(basic), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, token)); + support.run(datasource(NoneAuthConfig.builder().type(RestApiAuthType.NO_AUTH).build()), GraphQLCallSupport.query(), + GraphQLCallSupport.visitor(null, token)); + support.run(datasource(null), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, token)); + + assertThat(subscriptions).hasValue(0); + assertThat(server.requests()).hasSize(3); + } + + @Test + void theTokenMonoIsReadExactlyOnce() { + AtomicInteger subscriptions = new AtomicInteger(); + Mono> token = Mono.defer(() -> { + subscriptions.incrementAndGet(); + return Mono.just(List.of(new Property(TOKEN_HEADER, "t", "header"))); + }); + + support.run(datasource(oauthInherit()), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, token)); + + assertThat(subscriptions).hasValue(1); + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLQueryConfigWithoutVariablesTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLQueryConfigWithoutVariablesTest.java new file mode 100644 index 0000000000..f04a82add1 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLQueryConfigWithoutVariablesTest.java @@ -0,0 +1,44 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.graphql.model.GraphQLQueryExecutionContext; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; + +/** + * Observed behaviour, not (yet) a confirmed defect (plan section 9 row "no variables key gives NPE at :140"; D-6): a + * query config without the {@code variables} key fails in {@code buildQueryExecutionContext} with a raw + * {@code NullPointerException}, because {@code GraphQLQueryConfig.getVariables()} returns null for a missing key + * (GraphQLQueryConfig has no default and no {@code emptyIfNull} like its other lists) and + * {@code queryConfig.getVariables().forEach} is called (GraphQLExecutor.java:140). + * + *

    Reachability, read in the client: the GraphQL query form defines {@code variables: withDefault(VariablesControl, + * [{ key: "", value: "" }])} (client/packages/lowcoder/src/comps/queries/httpQuery/graphqlQuery.tsx:80), so a query made + * or loaded in the editor always carries the key, a list with a blank row at least, and deleting every row leaves an + * empty list, not a missing key. The key can be missing only for a config that does not come from that form (an + * application JSON written by hand or by another tool); the server does not default it. The existing + * GraphQLResponseContractTest's comment says the same ("fails without the list"). + */ +class GraphQLQueryConfigWithoutVariablesTest { + + private final GraphQLCallSupport support = new GraphQLCallSupport(); + + @Test + void aQueryConfigWithoutVariablesFailsWithANullPointerExceptionAndOneWithAnEmptyListDoesNot() { + GraphQLDatasourceConfig datasource = GraphQLDatasourceConfig.builder().url("http://example.invalid/graphql").build(); + + NullPointerException failure = assertThrows(NullPointerException.class, + () -> support.buildContext(datasource, Map.of("body", GraphQLCallSupport.QUERY), GraphQLCallSupport.visitor(null, null))); + GraphQLQueryExecutionContext context = support.buildContext(datasource, + Map.of("body", GraphQLCallSupport.QUERY, "variables", List.of()), GraphQLCallSupport.visitor(null, null)); + + System.out.println("[GraphQLQueryConfigWithoutVariablesTest] without the key: " + failure.getMessage()); + assertThat(failure.getMessage()).contains("getVariables()"); + assertThat(context.getVariablesParams().isEmpty()).isTrue(); + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRedirectTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRedirectTest.java new file mode 100644 index 0000000000..e4611dac70 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRedirectTest.java @@ -0,0 +1,167 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.plugin.graphql.GraphQLCallSupport.json; +import static org.lowcoder.plugin.graphql.GraphQLCallSupport.redirect; +import static org.lowcoder.plugin.graphql.GraphQLCallSupport.requestsTo; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.AuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.BasicAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** + * Redirect handling of {@link GraphQLExecutor#executeQuery}. The redirect target comes from the {@code Location} header + * as given. + * + *

    Pins the two halves of defect D14 for the GraphQL executor (analysis-plugins section 0.6; plan section 9, row + * "D1-D20 of analysis-plugins 0.6", and the row for a relative Location that the disallowed-hosts resolver does not + * stop; fixes deferred under D-6): a relative {@code Location} is not resolved against the request, and a redirect to + * another origin re-sends the datasource's credentials and the forwarded cookies. Same code as RestApiExecutor. + */ +class GraphQLRedirectTest { + + /** {@code GraphQLExecutor.MAX_REDIRECTS}: calls 0 to 4 send a request, call 5 fails before sending one. */ + private static final int REQUEST_LIMIT = 5; + + private final GraphQLCallSupport support = new GraphQLCallSupport(); + + private static String absolute(RecordingHttpServer.Request request, String path) { + return "http://" + request.header("Host").get(0) + path; + } + + private static GraphQLDatasourceConfig datasource(RecordingHttpServer server, String path) { + return GraphQLDatasourceConfig.builder().url(server.baseUrl() + path).build(); + } + + @Test + void anAbsoluteRedirectIsFollowedAndTheAnswerOfTheTargetIsReturned() { + try (RecordingHttpServer server = RecordingHttpServer.serve(Map.of( + "/start", request -> redirect(302, absolute(request, "/target")), + "/target", request -> json(200, "{\"landed\":true}")))) { + + QueryExecutionResult result = support.run(datasource(server, "/start"), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, null)); + + System.out.println("[GraphQLRedirectTest] requests " + server.requests().stream().map(RecordingHttpServer.Request::pathAndQuery).toList()); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getData().toString()).isEqualTo("{\"landed\":true}"); + assertThat(server.requests()).extracting(RecordingHttpServer.Request::pathAndQuery).containsExactly("/start", "/target"); + } + } + + @Test + void aRedirectLoopEndsAfterFiveRequestsWithTheRedirectLimitError() { + try (RecordingHttpServer server = RecordingHttpServer.serve(Map.of("/loop", request -> redirect(302, absolute(request, "/loop"))))) { + + Throwable failure = support.failureOf(datasource(server, "/loop"), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, null)); + + System.out.println("[GraphQLRedirectTest] loop -> " + failure + " after " + server.requests().size() + " requests"); + assertThat(failure).isInstanceOf(PluginException.class).hasMessageContaining("maximum HTTP redirects"); + assertThat(server.requests()).hasSize(REQUEST_LIMIT); + } + } + + /** + * DEFECT D14, first half, pinned for GraphQL (see the class comment; D-6, fix deferred): a relative + * {@code Location: /next} is turned into a URI without a host ({@code new URI(redirectUrl)}, + * GraphQLExecutor.java:343-347) and the next call is made with that URI as given. The client's default for a host-less + * URI is {@code localhost}, port 80, an uncontrolled port, so this test never lets the second request leave the JVM: + * it calls the private {@code httpCall} through reflection with a WebClient whose exchange function is a stub, + * answers the first call 302 with {@code Location: /next} and records the URL of the second call. The pin: that URL + * is {@code /next}, not absolute, with no host. The obvious fix, {@code uri.resolve(redirectUrl)}, makes the second + * URL absolute on the original host and turns this test red. + * + *

    Limits: it pins the redirect decision of {@code httpCall} (a private method, signature read in the source) and not + * what a real network does with the host-less URI; it breaks, on purpose, if the signature changes. + */ + @Test + void aRelativeLocationIsFollowedAsAHostlessUriTodayD14() throws ReflectiveOperationException { + List called = new CopyOnWriteArrayList<>(); + ExchangeFunction stub = request -> { + called.add(request.url()); + if (called.size() == 1) { + return Mono.just(ClientResponse.create(HttpStatus.FOUND, ExchangeStrategies.withDefaults()).header("Location", "/next").build()); + } + return Mono.just(ClientResponse.create(HttpStatus.OK, ExchangeStrategies.withDefaults()) + .header("Content-Type", "application/json").body("{}").build()); + }; + WebClient client = WebClient.builder().exchangeFunction(stub).build(); + Method httpCall = GraphQLExecutor.class.getDeclaredMethod("httpCall", WebClient.class, HttpMethod.class, URI.class, + BodyInserter.class, int.class, AuthConfig.class, Consumer.class); + httpCall.setAccessible(true); + URI origin = URI.create("http://origin.invalid:8080/start"); + + Mono call = (Mono) httpCall.invoke(new GraphQLExecutor(new CommonConfig()), client, HttpMethod.POST, origin, + BodyInserters.fromValue(new byte[0]), 0, null, (Consumer) headers -> { }); + call.block(GraphQLCallSupport.TIMEOUT); + + System.out.println("[GraphQLRedirectTest] URLs called for a relative Location: " + called); + assertThat(called).hasSize(2); + assertThat(called.get(0)).isEqualTo(origin); + assertThat(called.get(1)).hasToString("/next"); + assertThat(called.get(1).isAbsolute()).isFalse(); + assertThat(called.get(1).getHost()).isNull(); + } + + /** + * DEFECT D14, second half, pinned for GraphQL (see the class comment; D-6, fix deferred): the redirect is sent with + * the same WebClient, whose default headers and cookies are the datasource's, so a redirect to another origin (here + * another port) carries the datasource's Authorization header and the forwarded cookie. The intended behaviour, + * dropping credentials when the host or port changes, would turn this test red. + */ + @Test + void aRedirectToAnotherOriginResendsTheAuthorizationHeaderAndTheForwardedCookiesD14() { + try (RecordingHttpServer other = RecordingHttpServer.serve(Map.of("/landing", request -> json(200, "{}")))) { + try (RecordingHttpServer origin = RecordingHttpServer.serve(Map.of("/start", request -> redirect(302, other.baseUrl() + "/landing")))) { + GraphQLDatasourceConfig datasource = GraphQLDatasourceConfig.builder().url(origin.baseUrl() + "/start") + .authConfig(BasicAuthConfig.builder().type(RestApiAuthType.BASIC_AUTH).username("user").password("secret").build()) + .forwardCookies(Set.of("a")).build(); + + support.run(datasource, GraphQLCallSupport.query(), GraphQLCallSupport.visitor(GraphQLCallSupport.cookies("a", "1"), null)); + + RecordingHttpServer.Request atOther = requestsTo(other, "/landing").get(0); + System.out.println("[GraphQLRedirectTest] other origin received Authorization " + atOther.header("Authorization") + + " Cookie " + atOther.header("Cookie")); + assertThat(origin.port()).isNotEqualTo(other.port()); + assertThat(atOther.header("Authorization")).hasSize(1); + assertThat(atOther.header("Authorization").get(0)).startsWith("Basic "); + assertThat(atOther.header("Cookie")).containsExactly("a=1"); + } + } + } + + @Test + void aLocationThatIsNotAUriGivesAPluginExceptionAndNoSecondRequest() { + try (RecordingHttpServer server = RecordingHttpServer.serve(Map.of("/start", request -> redirect(302, "ht tp://x y")))) { + + Throwable failure = support.failureOf(datasource(server, "/start"), GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, null)); + + System.out.println("[GraphQLRedirectTest] bad Location -> " + failure); + assertThat(failure).isInstanceOf(PluginException.class).hasMessageContaining("Illegal character in scheme name"); + assertThat(server.requests()).hasSize(1); + } + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRedirectWithoutLocationTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRedirectWithoutLocationTest.java new file mode 100644 index 0000000000..ed17032437 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRedirectWithoutLocationTest.java @@ -0,0 +1,44 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; + +/** + * DEFECT pinned for the GraphQL executor (plan section 9 row on a 3xx without Location, "restApi: a 3xx without + * Location..." with its graphql clause; D-6, fix deferred): an answer with a 3xx status and no {@code Location} header, + * for example 304 Not Modified, is treated as a redirect and {@code response.headers().header("Location").get(0)} + * (GraphQLExecutor.java:343) reads an empty list. Unlike the REST executor, which throws a PluginException, the GraphQL + * executor's error handler (:281-291) turns the index error into a failed {@code QueryExecutionResult} with query code + * {@code GRAPHQL_EXECUTION_ERROR} and no data: the caller gets no exception and no status or header of the answer. One + * request is sent. The obvious fix is to treat a 3xx without a Location as an ordinary response, which turns this test + * red. + */ +class GraphQLRedirectWithoutLocationTest { + + private final GraphQLCallSupport support = new GraphQLCallSupport(); + + @ParameterizedTest(name = "status {0}") + @ValueSource(ints = {301, 302, 304}) + void aRedirectStatusWithoutALocationGivesAFailedResultWithoutDataOrStatus(int status) { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/moved", new Response(status, Map.of(), null)))) { + GraphQLDatasourceConfig datasource = GraphQLDatasourceConfig.builder().url(server.baseUrl() + "/moved").build(); + + QueryExecutionResult result = support.run(datasource, GraphQLCallSupport.query(), GraphQLCallSupport.visitor(null, null)); + + System.out.println("[GraphQLRedirectWithoutLocationTest] " + status + " without Location -> success=" + result.isSuccess() + + " code=" + result.getQueryCode() + " data=" + result.getData()); + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getQueryCode()).isEqualTo("GRAPHQL_EXECUTION_ERROR"); + assertThat(result.getData()).isNull(); + assertThat(server.requests()).hasSize(1); + } + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRenderingTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRenderingTest.java new file mode 100644 index 0000000000..862569ad4a --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRenderingTest.java @@ -0,0 +1,112 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.graphql.model.GraphQLQueryExecutionContext; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; + +/** + * Rendering, validation and merging in {@link GraphQLExecutor#buildQueryExecutionContext}, and the one-line delegations of + * {@link GraphQLConnector}. No request is sent: the context is inspected. + */ +class GraphQLRenderingTest { + + private static final String BASE = "http://example.invalid"; + private static final String CONTENT_TYPE = "Content-Type"; + + private final GraphQLCallSupport support = new GraphQLCallSupport(); + + private GraphQLQueryExecutionContext build(GraphQLDatasourceConfig datasource, Map query, Map params) { + return support.buildContext(datasource, query, params, GraphQLCallSupport.visitor(null, null)); + } + + @Test + void parametersAreRenderedInHeaderValuesPathAndVariablesAndBlankVariableRowsAreSkipped() { + GraphQLDatasourceConfig datasource = GraphQLDatasourceConfig.builder().url(BASE).build(); + Map query = GraphQLCallSupport.query(Map.of( + "path", "/{{version}}/graphql", + "headers", List.of(new Property("X-Name", "{{name}}")), + "variables", List.of(new Property("who", "\"{{name}}\""), new Property("", ""), new Property("n", "{{count}}")))); + + GraphQLQueryExecutionContext context = build(datasource, query, Map.of("version", "v2", "name", "Luke", "count", 3)); + + System.out.println("[GraphQLRenderingTest] url " + context.getUrl() + " headers " + context.getHeaders() + " variables " + context.getVariablesParams()); + assertThat(context.getUrl()).isEqualTo(BASE + "/v2/graphql"); + assertThat(context.getHeaders()).containsEntry("X-Name", "Luke"); + assertThat(context.getVariablesParams().toString()).isEqualTo("{\"who\":\"Luke\",\"n\":3}"); + } + + @Test + void aQueryHeaderOverridesADatasourceHeaderBlankHeadersAreDroppedAndJsonIsTheDefaultContentType() { + GraphQLDatasourceConfig datasource = GraphQLDatasourceConfig.builder().url(BASE) + .headers(List.of(new Property("X-A", "1"), new Property("X-Ds", "kept"))).build(); + Map query = GraphQLCallSupport.query(Map.of("headers", + List.of(new Property("X-A", "2"), new Property("X-Blank", " "), new Property(" ", "v")))); + + GraphQLQueryExecutionContext context = build(datasource, query, Map.of()); + + System.out.println("[GraphQLRenderingTest] headers " + context.getHeaders()); + assertThat(context.getHeaders()).containsEntry("X-A", "2").containsEntry("X-Ds", "kept") + .doesNotContainKey("X-Blank").containsEntry(CONTENT_TYPE, "application/json"); + assertThat(context.getContentType()).isEqualTo("application/json"); + } + + @Test + void aGivenContentTypeIsKeptAndLowerCased() { + GraphQLDatasourceConfig datasource = GraphQLDatasourceConfig.builder().url(BASE) + .headers(List.of(new Property(CONTENT_TYPE, "Application/GraphQL"))).build(); + + GraphQLQueryExecutionContext context = build(datasource, GraphQLCallSupport.query(), Map.of()); + + assertThat(context.getContentType()).isEqualTo("application/graphql"); + assertThat(context.getHeaders()).containsEntry(CONTENT_TYPE, "Application/GraphQL"); + } + + @Test + void queryParamsOverrideDatasourceParams() { + GraphQLDatasourceConfig datasource = GraphQLDatasourceConfig.builder().url(BASE) + .params(List.of(new Property("p", "1"), new Property("q", "kept"))).build(); + + GraphQLQueryExecutionContext context = build(datasource, + GraphQLCallSupport.query(Map.of("params", List.of(new Property("p", "2")))), Map.of()); + + assertThat(context.getUrlParams()).containsEntry("p", "2").containsEntry("q", "kept"); + } + + @Test + void anEmptyUrlAnUnparsableUrlAndAnInvalidContentTypeAreRejected() { + PluginException empty = assertThrows(PluginException.class, + () -> build(GraphQLDatasourceConfig.builder().url("").build(), GraphQLCallSupport.query(), Map.of())); + PluginException unparsable = assertThrows(PluginException.class, + () -> build(GraphQLDatasourceConfig.builder().url("ht tp://x y").build(), GraphQLCallSupport.query(), Map.of())); + PluginException contentType = assertThrows(PluginException.class, () -> build(GraphQLDatasourceConfig.builder().url(BASE) + .headers(List.of(new Property(CONTENT_TYPE, "not a type"))).build(), GraphQLCallSupport.query(), Map.of())); + + System.out.println("[GraphQLRenderingTest] " + empty.getMessage() + " | " + unparsable.getMessage() + " | " + contentType.getMessage()); + assertThat(empty).hasMessageContaining("request URL is empty"); + assertThat(unparsable).hasMessageContaining("Invalid request URL").hasMessageContaining("ht tp://x y"); + assertThat(contentType).hasMessageContaining("Invalid Content-Type").hasMessageContaining("not a type"); + } + + @Test + void theConnectorResolvesTheConfigAndAnswersTheTrivialCalls() { + GraphQLConnector connector = new GraphQLConnector(); + + GraphQLDatasourceConfig config = connector.resolveConfig(Map.of("url", BASE)); + + assertThat(config.getUrl()).isEqualTo(BASE); + assertThat(connector.validateConfig(config)).isEqualTo(Set.of()); + assertThat(connector.testConnection(config).block(GraphQLCallSupport.TIMEOUT).isSuccess()).isTrue(); + Object connection = connector.createConnection(config).block(GraphQLCallSupport.TIMEOUT); + assertThat(connection).isNotNull(); + assertThat(connector.destroyConnection(connection).blockOptional(GraphQLCallSupport.TIMEOUT)).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRequestBodyContractTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRequestBodyContractTest.java new file mode 100644 index 0000000000..a2546848f3 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLRequestBodyContractTest.java @@ -0,0 +1,98 @@ +package org.lowcoder.plugin.graphql; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.contract.RenderValues; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.query.QueryVisitorContext; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Group {@code downstream-encode}, its {@code graphqlPlugin} rows (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.5): + * each query variable is rendered into a node with the request parameters + * ({@code GraphQLExecutor.buildQueryExecutionContext}, {@code renderMustacheJson}); the query and the variables become a + * {@code Map} ({@code GraphQLBodyUtils.convertToGraphQLBody}) that the client codecs write + * ({@code buildBodyInserter}, {@code BodyInserters.fromValue}). A real executor POSTs {@value #QUERY} with the + * variables of {@link #VARIABLES} and the parameters {@link RenderValues} to a local server + * ({@link RecordingHttpServer}); what the server received, the content type and the body as sent, is pinned by the §1.2 + * contract (number lexemes compared) in {@value #REPORT}, with a query without variables for comparison. + * + *

    Limits: the body bytes are read as UTF-8 JSON; the body's member order is the {@code HashMap}'s, which the §1.2 + * contract ignores. + */ +public class GraphQLRequestBodyContractTest { + + static final String REPORT = "downstream-encode/GraphQLExecutor.requestBody.json"; + static final String QUERY = "query Items($int: Int) { items(limit: $int) { id } }"; + static final String PATH = "/graphql"; + static final int OK = 200; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final List> VARIABLES = variables(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + private static RecordingHttpServer server; + + private final GraphQLExecutor executor = new GraphQLExecutor(new CommonConfig()); + private final QueryVisitorContext visitorContext = new QueryVisitorContext("userId1", "workspace1", 8080, null, null, null); + + @BeforeAll + public static void startServer() { + server = RecordingHttpServer.start(Map.of(PATH, new Response(OK, Map.of(RecordingHttpServer.CONTENT_TYPE, List.of("application/json")), + "{\"data\":null}".getBytes(StandardCharsets.UTF_8)))); + } + + @AfterAll + public static void stopServer() { + server.close(); + } + + @BoundarySites({ + "lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.buildQueryExecutionContext#renderMustacheJson#1", + "lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.buildBodyInserter#BodyInserters.fromValue#2"}) + @Test + public void requestBodyAsSent() { + Map report = new LinkedHashMap<>(); + report.put("variables", send(VARIABLES)); + report.put("noVariables", send(List.of())); + String actual = ConfigBinding.write(report); + System.out.println("[GraphQLRequestBodyContractTest] " + server.requests().size() + " requests\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** What the server received for one POST of {@link #QUERY} with {@code variables}, or the error. */ + private Object send(List> variables) { + int before = server.requests().size(); + GraphQLDatasourceConfig datasourceConfig = GraphQLDatasourceConfig.builder().url(server.baseUrl()).build(); + Map queryConfig = Map.of("path", PATH, "body", QUERY, "variables", variables); + try { + executor.executeQuery(null, executor.buildQueryExecutionContext(datasourceConfig, queryConfig, RenderValues.values(), visitorContext)) + .block(TIMEOUT); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + return server.requests().get(before).jsonBodyReport(); + } + + /** One variable per parameter, one with text around parameters, and one literal with number lexemes. */ + private static List> variables() { + List> variables = new ArrayList<>(); + RenderValues.values().keySet().forEach(name -> variables.add(Map.of("key", name, "value", "{{" + name + "}}"))); + variables.add(Map.of("key", "inText", "value", "\"{{string}} {{dollar}}\"")); + variables.add(Map.of("key", "literal", "value", "[1.50, 1e3, 2147483648]")); + return variables; + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLResponseContractTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLResponseContractTest.java new file mode 100644 index 0000000000..0c83891688 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLResponseContractTest.java @@ -0,0 +1,137 @@ +package org.lowcoder.plugin.graphql; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.contract.RepresentativeInput; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.query.QueryVisitorContext; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Groups {@code external-json} and {@code header-json} of {@code GraphQLExecutor}, and its row of the §4.6 producer + * table (docs/API_PAYLOAD_TEST_PLAN.md §4.6, §4.10, task T8.2). A real executor POSTs a GraphQL query to a local + * server ({@link RecordingHttpServer}) that answers each case of {@link #RESPONSES}; the result is what a query of the + * GraphQL plugin returns: + *

      + *
    • the response headers, written by the production mapper ({@code toJsonThrows}) and read back into a tree + * ({@code GraphQLExecutor.parseExecuteResultHeaders}), with a header of two values and headers whose bytes are + * not ASCII, plus the {@code X-LOWCODER-RESPONSE-DATA-TYPE} array the executor adds;
    • + *
    • the body by content type ({@code GraphQLExecutor.parseResponseDataInfo}): only a type that + * {@code includes(application/json)} is read into a tree, so {@code +json} and {@code x-ndjson} bodies are text + * here, unlike the REST API plugin; an image and raw bytes base64-encoded into a {@code byte[]}, which the + * mapper base64-encodes again (O7); trimmed text; no body; and a JSON body that does not parse;
    • + *
    • {@code QueryExecutionResult.ofRestApiResult}, with the {@code HTTP...} code of a status that is not 2xx.
    • + *
    + * Each case's report ({@link QueryResults#report}) is pinned in {@value #REPORT}, with the volatile {@code Date} header + * removed. + * + *

    Limits: as for {@code RestApiResponseContractTest}, header values go on the wire as the ISO-8859-1 bytes of their + * characters; the request body the executor builds is not pinned here. + */ +public class GraphQLResponseContractTest { + + static final String REPORT = "external-json/GraphQLExecutor.responses.json"; + static final String CONTENT_TYPE = "Content-Type"; + static final String MULTI_HEADER = "X-Multi"; + static final String LATIN1_HEADER = "X-Latin1"; + static final String UTF8_HEADER = "X-Utf8-Bytes"; + static final String UTF8_HEADER_TEXT = "žluť"; + static final String QUERY = "query { items { id } }"; + /** The query's variables as the editor sends them; {@code buildQueryExecutionContext} fails without the list. */ + static final List> VARIABLES = List.of(Map.of("key", "limit", "value", "10")); + static final byte[] IMAGE_BYTES = {(byte) 0x89, 'P', 'N', 'G', 0, 1, (byte) 0xFF}; + static final int OK = 200; + static final int NO_CONTENT = 204; + static final int UNPROCESSABLE = 422; + static final int BAD_GATEWAY = 502; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map RESPONSES = responses(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + private static RecordingHttpServer server; + + private final GraphQLExecutor executor = new GraphQLExecutor(new CommonConfig()); + private final QueryVisitorContext visitorContext = new QueryVisitorContext("userId1", "workspace1", 8080, null, null, null); + + @BeforeAll + public static void startServer() { + server = RecordingHttpServer.start(RESPONSES); + } + + @AfterAll + public static void stopServer() { + server.close(); + } + + @BoundarySites({ + "lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.parseResponseDataInfo#readTree#1", + "lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.parseExecuteResultHeaders#toJsonThrows#1", + "lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.parseExecuteResultHeaders#readTree#1"}) + @Test + public void responsesAsPinned() { + Map report = new LinkedHashMap<>(); + RESPONSES.keySet().forEach(path -> report.put(path, report(execute(path)))); + String actual = ConfigBinding.write(report); + System.out.println("[GraphQLResponseContractTest] " + server.requests().size() + " requests to " + server.baseUrl() + "\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + private Object report(Object outcome) { + return outcome instanceof QueryExecutionResult result + ? QueryResults.report(result, Set.of(QueryResults.DATE_HEADER)) : outcome; + } + + /** The result of a POST of {@link #QUERY} with {@link #VARIABLES} to {@code path}, or the error text when the query fails. */ + private Object execute(String path) { + GraphQLDatasourceConfig datasourceConfig = GraphQLDatasourceConfig.builder().url(server.baseUrl()).build(); + Map queryConfig = Map.of("path", path, "body", QUERY, "variables", VARIABLES); + try { + return executor.executeQuery(null, executor.buildQueryExecutionContext(datasourceConfig, queryConfig, Map.of(), visitorContext)) + .block(TIMEOUT); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + } + + private static Map responses() { + String latin1OfUtf8 = new String(UTF8_HEADER_TEXT.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + Map> jsonHeaders = new LinkedHashMap<>(); + jsonHeaders.put(CONTENT_TYPE, List.of("application/json")); + jsonHeaders.put(MULTI_HEADER, List.of("first", "second, with comma")); + jsonHeaders.put(LATIN1_HEADER, List.of("café")); + jsonHeaders.put(UTF8_HEADER, List.of(latin1OfUtf8)); + Map responses = new LinkedHashMap<>(); + responses.put("/json", new Response(OK, jsonHeaders, utf8("{\"data\":" + RepresentativeInput.TEXT + "}"))); + responses.put("/json-charset", new Response(OK, Map.of(CONTENT_TYPE, List.of("application/json;charset=UTF-8")), + utf8("{\"data\":null,\"errors\":[{\"message\":\"žluť\",\"locations\":[{\"line\":1,\"column\":9}],\"path\":[\"items\",0]}]}"))); + responses.put("/problem-json", new Response(UNPROCESSABLE, Map.of(CONTENT_TYPE, List.of("application/problem+json")), + utf8("{\"title\":\"Unprocessable\",\"status\":422,\"errors\":[{\"field\":\"name\",\"limit\":10.50}]}"))); + responses.put("/ndjson", new Response(OK, Map.of(CONTENT_TYPE, List.of("application/x-ndjson")), + utf8("{\"line\":1}\n{\"line\":2}\n"))); + responses.put("/image", new Response(OK, Map.of(CONTENT_TYPE, List.of("image/png")), IMAGE_BYTES)); + responses.put("/binary", new Response(OK, Map.of(CONTENT_TYPE, List.of("application/octet-stream")), IMAGE_BYTES)); + responses.put("/text", new Response(OK, Map.of(CONTENT_TYPE, List.of("text/plain;charset=UTF-8")), utf8(" plain žluť text 1.50 "))); + responses.put("/no-content", new Response(NO_CONTENT, Map.of(), null)); + responses.put("/invalid-json", new Response(BAD_GATEWAY, Map.of(CONTENT_TYPE, List.of("application/json")), utf8("{\"a\": 1,"))); + return responses; + } + + private static byte[] utf8(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLUrlSlashCollapseTest.java b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLUrlSlashCollapseTest.java new file mode 100644 index 0000000000..ad89832802 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/java/org/lowcoder/plugin/graphql/GraphQLUrlSlashCollapseTest.java @@ -0,0 +1,36 @@ +package org.lowcoder.plugin.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; + +/** + * DEFECT pinned for the second production caller (plan section 9 row "REST url: // inside a query value collapsed to /"; + * the builder is pinned in the sdk by RestApiUriBuilderCollapseTest; D-6, fix deferred). {@code GraphQLExecutor.java:245} passes + * the url of the query context (datasource url plus path field) to {@code RestApiUriBuilder.buildUri}, whose + * {@code replaceAll} (RestApiUriBuilder.java:41) turns a double slash typed into the query string of the path field into one + * slash. The request line the local server (port 0, loopback) receives is asserted. A fix that collapses only the part before + * the first {@code ?} turns it red. + */ +class GraphQLUrlSlashCollapseTest { + + private final GraphQLCallSupport support = new GraphQLCallSupport(); + + @Test + void aDoubleSlashTypedIntoTheQueryStringOfThePathFieldArrivesAsOneSlashPinsTheSection9Row() { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/graphql", GraphQLCallSupport.json(200, "{\"data\":{}}")))) { + GraphQLDatasourceConfig datasource = GraphQLDatasourceConfig.builder().url(server.baseUrl() + "/graphql").build(); + + support.run(datasource, GraphQLCallSupport.query(Map.of("path", "?u=a//b")), GraphQLCallSupport.visitor(null, null)); + + String line = server.requests().get(0).pathAndQuery(); + System.out.println("[GraphQLUrlSlashCollapseTest] typed '?u=a//b' -> request line '" + line + "'"); + assertThat(server.requests()).hasSize(1); + assertThat(line).isEqualTo("/graphql?u=a/b"); + } + } +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/config-binding/GraphQLQueryConfig.input.json b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/config-binding/GraphQLQueryConfig.input.json new file mode 100644 index 0000000000..4986d121bd --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/config-binding/GraphQLQueryConfig.input.json @@ -0,0 +1,33 @@ +{ + "full": { + "body": "query { user(id: {{id}}) { name } }", + "path": "/graphql", + "params": [ + { + "key": "page", + "value": "1" + }, + { + "key": "q", + "value": "{{input.text}}", + "type": "text" + } + ], + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "bodyFormData": [], + "variables": [ + { + "key": "id", + "value": "{{id}}" + } + ], + "httpMethod": "GET", + "disableEncodingParams": true + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/config-binding/GraphQLQueryConfig.json b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/config-binding/GraphQLQueryConfig.json new file mode 100644 index 0000000000..7d54795732 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/config-binding/GraphQLQueryConfig.json @@ -0,0 +1,97 @@ +{ + "from" : { + "full" : { + "class" : "org.lowcoder.plugin.graphql.model.GraphQLQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.graphql.model.GraphQLQueryConfig", + "httpMethod" : "POST", + "body" : "query { user(id: {{id}}) { name } }", + "path" : "/graphql", + "params" : [ { + "@class" : "org.lowcoder.sdk.models.Property", + "key" : "page", + "value" : "1", + "type" : null + }, { + "@class" : "org.lowcoder.sdk.models.Property", + "key" : "q", + "value" : "{{input.text}}", + "type" : "text" + } ], + "headers" : [ { + "@class" : "org.lowcoder.sdk.models.Property", + "key" : "Authorization", + "value" : "Bearer {{token}}", + "type" : null + } ], + "bodyFormData" : [ ], + "variables" : [ { + "@class" : "org.lowcoder.sdk.models.Property", + "key" : "id", + "value" : "{{id}}", + "type" : null + } ], + "disableEncodingParams" : false + }, + "classes" : { + "" : "org.lowcoder.plugin.graphql.model.GraphQLQueryConfig", + "/httpMethod" : "org.springframework.http.HttpMethod", + "/body" : "java.lang.String", + "/path" : "java.lang.String", + "/params" : "java.util.ArrayList", + "/params/0" : "org.lowcoder.sdk.models.Property", + "/params/0/key" : "java.lang.String", + "/params/0/value" : "java.lang.String", + "/params/0/type" : "null", + "/params/1" : "org.lowcoder.sdk.models.Property", + "/params/1/key" : "java.lang.String", + "/params/1/value" : "java.lang.String", + "/params/1/type" : "java.lang.String", + "/headers" : "java.util.ArrayList", + "/headers/0" : "org.lowcoder.sdk.models.Property", + "/headers/0/key" : "java.lang.String", + "/headers/0/value" : "java.lang.String", + "/headers/0/type" : "null", + "/bodyFormData" : "java.util.ArrayList", + "/variables" : "java.util.ArrayList", + "/variables/0" : "org.lowcoder.sdk.models.Property", + "/variables/0/key" : "java.lang.String", + "/variables/0/value" : "java.lang.String", + "/variables/0/type" : "null", + "/disableEncodingParams" : "java.lang.Boolean" + }, + "noView" : {"body":"query { user(id: {{id}}) { name } }","path":"/graphql","params":[{"key":"page","value":"1","type":null,"multipartFileType":false},{"key":"q","value":"{{input.text}}","type":"text","multipartFileType":false}],"headers":[{"key":"Authorization","value":"Bearer {{token}}","type":null,"multipartFileType":false}],"bodyFormData":[],"variables":[{"key":"id","value":"{{id}}","type":null,"multipartFileType":false}],"httpMethod":{},"disableEncodingParams":false}, + "Public" : {"body":"query { user(id: {{id}}) { name } }","path":"/graphql","params":[{"key":"page","value":"1","type":null,"multipartFileType":false},{"key":"q","value":"{{input.text}}","type":"text","multipartFileType":false}],"headers":[{"key":"Authorization","value":"Bearer {{token}}","type":null,"multipartFileType":false}],"bodyFormData":[],"variables":[{"key":"id","value":"{{id}}","type":null,"multipartFileType":false}],"httpMethod":{},"disableEncodingParams":false}, + "Internal" : {"body":"query { user(id: {{id}}) { name } }","path":"/graphql","params":[{"key":"page","value":"1","type":null,"multipartFileType":false},{"key":"q","value":"{{input.text}}","type":"text","multipartFileType":false}],"headers":[{"key":"Authorization","value":"Bearer {{token}}","type":null,"multipartFileType":false}],"bodyFormData":[],"variables":[{"key":"id","value":"{{id}}","type":null,"multipartFileType":false}],"httpMethod":{},"disableEncodingParams":false} + }, + "empty" : { + "class" : "org.lowcoder.plugin.graphql.model.GraphQLQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.graphql.model.GraphQLQueryConfig", + "httpMethod" : "POST", + "body" : null, + "path" : null, + "params" : null, + "headers" : null, + "bodyFormData" : null, + "variables" : null, + "disableEncodingParams" : false + }, + "classes" : { + "" : "org.lowcoder.plugin.graphql.model.GraphQLQueryConfig", + "/httpMethod" : "org.springframework.http.HttpMethod", + "/body" : "null", + "/path" : "null", + "/params" : "null", + "/headers" : "null", + "/bodyFormData" : "null", + "/variables" : "null", + "/disableEncodingParams" : "java.lang.Boolean" + }, + "noView" : {"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"variables":null,"httpMethod":{},"disableEncodingParams":false}, + "Public" : {"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"variables":null,"httpMethod":{},"disableEncodingParams":false}, + "Internal" : {"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"variables":null,"httpMethod":{},"disableEncodingParams":false} + }, + "unknownProperty" : "ignored: binds as form full" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/downstream-encode/GraphQLExecutor.requestBody.json b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/downstream-encode/GraphQLExecutor.requestBody.json new file mode 100644 index 0000000000..29b0feda2d --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/downstream-encode/GraphQLExecutor.requestBody.json @@ -0,0 +1,10 @@ +{ + "variables" : { + "contentType" : [ "application/json" ], + "body" : {"variables":{"int":1,"long":3000000001,"decimal":1.5,"exponent":1.0E20,"bool":true,"null":null,"string":"it's \"q\" \\ žluť","dollar":"cost $1","list":[1,"it's \"q\"",2.5,null,[true],{"k":1.5}],"map":{"zeta":"it's","decimal":1.5,"nested":[1,null]},"inText":"it's \"q\" \\ žluť cost $1","literal":[1.5,1000.0,2147483648]},"query":"query Items($int: Int) { items(limit: $int) { id } }"} + }, + "noVariables" : { + "contentType" : [ "application/json" ], + "body" : {"query":"query Items($int: Int) { items(limit: $int) { id } }"} + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/external-json/GraphQLExecutor.responses.json b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/external-json/GraphQLExecutor.responses.json new file mode 100644 index 0000000000..4017ff7447 --- /dev/null +++ b/server/api-service/lowcoder-plugins/graphqlPlugin/src/test/resources/json-contract/external-json/GraphQLExecutor.responses.json @@ -0,0 +1,192 @@ +{ + "/json" : { + "queryCode" : "OK", + "headers" : { + "X-multi" : [ "first", "second, with comma" ], + "Content-type" : [ "application/json" ], + "X-utf8-bytes" : [ "žluÅ¥" ], + "X-latin1" : [ "café" ], + "Content-length" : [ "964" ], + "X-LOWCODER-RESPONSE-DATA-TYPE" : [ "JSON" ] + }, + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/data" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/data/zeta" : "com.fasterxml.jackson.databind.node.TextNode", + "/data/int" : "com.fasterxml.jackson.databind.node.IntNode", + "/data/negativeInt" : "com.fasterxml.jackson.databind.node.IntNode", + "/data/intMax" : "com.fasterxml.jackson.databind.node.IntNode", + "/data/intMaxPlusOne" : "com.fasterxml.jackson.databind.node.LongNode", + "/data/intMinMinusOne" : "com.fasterxml.jackson.databind.node.LongNode", + "/data/longMax" : "com.fasterxml.jackson.databind.node.LongNode", + "/data/longMaxPlusOne" : "com.fasterxml.jackson.databind.node.BigIntegerNode", + "/data/longMinMinusOne" : "com.fasterxml.jackson.databind.node.BigIntegerNode", + "/data/decimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/data/trailingZeroDecimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/data/zeroPointZero" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/data/negativeZero" : "com.fasterxml.jackson.databind.node.IntNode", + "/data/exponent" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/data/exponentUpperCase" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/data/negativeExponent" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/data/longDecimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/data/true" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/data/false" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/data/null" : "com.fasterxml.jackson.databind.node.NullNode", + "/data/emptyObject" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/data/emptyArray" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/data/nested" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/data/nested/array" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/data/nested/array/0" : "com.fasterxml.jackson.databind.node.IntNode", + "/data/nested/array/1" : "com.fasterxml.jackson.databind.node.TextNode", + "/data/nested/array/2" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/data/nested/array/2/0" : "com.fasterxml.jackson.databind.node.IntNode", + "/data/nested/array/2/1" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/data/nested/array/2/1/four" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/data/nested/array/3" : "com.fasterxml.jackson.databind.node.NullNode", + "/data/nested/array/4" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/data/nested/array/5" : "com.fasterxml.jackson.databind.node.LongNode", + "/data/nested/object" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/data/nested/object/z" : "com.fasterxml.jackson.databind.node.IntNode", + "/data/nested/object/a" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/data/nested/object/a/deeper" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/data/nested/object/a/deeper/0" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/data/nested/object/a/deeper/1" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/data/unicode" : "com.fasterxml.jackson.databind.node.TextNode", + "/data/escapes" : "com.fasterxml.jackson.databind.node.TextNode", + "/data/key with escaped name" : "com.fasterxml.jackson.databind.node.TextNode", + "/data/alpha" : "com.fasterxml.jackson.databind.node.TextNode" + }, + "keyOrder" : { + "/data/emptyObject" : [ ], + "/data/nested/array/2/1" : [ "four" ], + "/data/nested/object/a/deeper/1" : [ ], + "/data/nested/object/a" : [ "deeper" ], + "/data/nested/object" : [ "z", "a" ], + "/data/nested" : [ "array", "object" ], + "/data" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ], + "" : [ "data" ] + } + }, + "written" : {"queryCode":"OK","headers":{"X-multi":["first","second, with comma"],"Content-type":["application/json"],"X-utf8-bytes":["žluÅ¥"],"X-latin1":["café"],"Content-length":["964"],"X-LOWCODER-RESPONSE-DATA-TYPE":["JSON"]},"data":{"data":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"}},"success":true} + }, + "/json-charset" : { + "queryCode" : "OK", + "headers" : { + "Content-type" : [ "application/json;charset=UTF-8" ], + "Content-length" : [ "100" ], + "X-LOWCODER-RESPONSE-DATA-TYPE" : [ "JSON" ] + }, + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/data" : "com.fasterxml.jackson.databind.node.NullNode", + "/errors" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/errors/0" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/errors/0/message" : "com.fasterxml.jackson.databind.node.TextNode", + "/errors/0/locations" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/errors/0/locations/0" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/errors/0/locations/0/line" : "com.fasterxml.jackson.databind.node.IntNode", + "/errors/0/locations/0/column" : "com.fasterxml.jackson.databind.node.IntNode", + "/errors/0/path" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/errors/0/path/0" : "com.fasterxml.jackson.databind.node.TextNode", + "/errors/0/path/1" : "com.fasterxml.jackson.databind.node.IntNode" + }, + "keyOrder" : { + "/errors/0/locations/0" : [ "line", "column" ], + "/errors/0" : [ "message", "locations", "path" ], + "" : [ "data", "errors" ] + } + }, + "written" : {"queryCode":"OK","headers":{"Content-type":["application/json;charset=UTF-8"],"Content-length":["100"],"X-LOWCODER-RESPONSE-DATA-TYPE":["JSON"]},"data":{"data":null,"errors":[{"message":"žluť","locations":[{"line":1,"column":9}],"path":["items",0]}]},"success":true} + }, + "/problem-json" : { + "queryCode" : "HTTPUNPROCESSABLE_ENTITY", + "headers" : { + "Content-type" : [ "application/problem+json" ], + "Content-length" : [ "80" ], + "X-LOWCODER-RESPONSE-DATA-TYPE" : [ "TEXT" ] + }, + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"HTTPUNPROCESSABLE_ENTITY","headers":{"Content-type":["application/problem+json"],"Content-length":["80"],"X-LOWCODER-RESPONSE-DATA-TYPE":["TEXT"]},"data":"{\"title\":\"Unprocessable\",\"status\":422,\"errors\":[{\"field\":\"name\",\"limit\":10.50}]}","success":false} + }, + "/ndjson" : { + "queryCode" : "OK", + "headers" : { + "Content-type" : [ "application/x-ndjson" ], + "Content-length" : [ "22" ], + "X-LOWCODER-RESPONSE-DATA-TYPE" : [ "TEXT" ] + }, + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","headers":{"Content-type":["application/x-ndjson"],"Content-length":["22"],"X-LOWCODER-RESPONSE-DATA-TYPE":["TEXT"]},"data":"{\"line\":1}\n{\"line\":2}","success":true} + }, + "/image" : { + "queryCode" : "OK", + "headers" : { + "Content-type" : [ "image/png" ], + "Content-length" : [ "7" ], + "X-LOWCODER-RESPONSE-DATA-TYPE" : [ "IMAGE" ] + }, + "dataShape" : { + "classes" : { + "" : "[B" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","headers":{"Content-type":["image/png"],"Content-length":["7"],"X-LOWCODER-RESPONSE-DATA-TYPE":["IMAGE"]},"data":"aVZCT1J3QUIvdz09","success":true} + }, + "/binary" : { + "queryCode" : "OK", + "headers" : { + "Content-type" : [ "application/octet-stream" ], + "Content-length" : [ "7" ], + "X-LOWCODER-RESPONSE-DATA-TYPE" : [ "BINARY" ] + }, + "dataShape" : { + "classes" : { + "" : "[B" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","headers":{"Content-type":["application/octet-stream"],"Content-length":["7"],"X-LOWCODER-RESPONSE-DATA-TYPE":["BINARY"]},"data":"aVZCT1J3QUIvdz09","success":true} + }, + "/text" : { + "queryCode" : "OK", + "headers" : { + "Content-type" : [ "text/plain;charset=UTF-8" ], + "Content-length" : [ "26" ], + "X-LOWCODER-RESPONSE-DATA-TYPE" : [ "TEXT" ] + }, + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","headers":{"Content-type":["text/plain;charset=UTF-8"],"Content-length":["26"],"X-LOWCODER-RESPONSE-DATA-TYPE":["TEXT"]},"data":"plain žluť text 1.50","success":true} + }, + "/no-content" : { + "queryCode" : "OK", + "headers" : { }, + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","headers":{},"success":true} + }, + "/invalid-json" : { + "error" : "org.lowcoder.sdk.exception.PluginException: GRAPHQL_EXECUTION_ERROR INVALID_JSON_FROM_RESPONSE" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/pom.xml b/server/api-service/lowcoder-plugins/mongoPlugin/pom.xml index 4361d9f387..0d955e8754 100644 --- a/server/api-service/lowcoder-plugins/mongoPlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/mongoPlugin/pom.xml @@ -31,8 +31,7 @@ org.testcontainers - testcontainers - 1.18.0 + testcontainers-mongodb test diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfig.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfig.java index 0935f17d2e..aa817a598f 100644 --- a/server/api-service/lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfig.java +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfig.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonView; -import com.google.common.annotations.VisibleForTesting; import lombok.Builder; import lombok.Getter; import lombok.extern.jackson.Jacksonized; @@ -108,22 +107,6 @@ public DatasourceConnectionConfig mergeWithUpdatedConfig(DatasourceConnectionCon .build(); } - @VisibleForTesting - public MongoDatasourceConfigBuilder toBuilder() { - return builder() - .usingUri(usingUri) - .uri(uri) - .srvMode(srvMode) - .ssl(ssl) - .authMechanism(authMechanism) - .endpoints(endpoints) - .database(database) - .username(username) - .password(password) - .host(host) - .port(port); - } - @Override public DatasourceConnectionConfig doEncrypt(Function encryptFunc) { password = encryptFunc.apply(password); diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoConfigBindingContractTest.java new file mode 100644 index 0000000000..1bec49f170 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoConfigBindingContractTest.java @@ -0,0 +1,30 @@ +package org.lowcoder.plugin.mongo; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mongo.model.MongoDatasourceConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code mongoPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1), + * through the real entry points: {@code MongoDatasourceConfig.buildFrom}; the auth mechanism binds by enum constant + * name, not by its {@code value}. {@code ConfigBinding} applies each entry point to every form of the input fixture + * (canonical names, alias or getter-derived names, scalars as strings, missing and empty values) and pins the bound + * state, the written views and the errors in the report fixture. + */ +public class MongoConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfig.java#MongoDatasourceConfig.buildFrom#toJson#1", + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfig.java#MongoDatasourceConfig.buildFrom#fromJson#1"}) + @Test + public void datasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/MongoDatasourceConfig.input.json", "config-binding/MongoDatasourceConfig.json", + Map.of("buildFrom", MongoDatasourceConfig::buildFrom)); + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoContainerSupport.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoContainerSupport.java new file mode 100644 index 0000000000..04d29eb07b --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoContainerSupport.java @@ -0,0 +1,91 @@ +package org.lowcoder.plugin.mongo; + +import org.bson.Document; +import org.lowcoder.plugin.mongo.model.MongoConnection; +import org.lowcoder.plugin.mongo.model.MongoDatasourceConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.ContainerImages; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.Map; + +/** + * The shared MongoDB 7.0 container of the L5-8d tests (default build, not heavy-container: one per test JVM, started on first + * use, removed by Testcontainers' reaper when the JVM exits; the port is the one Docker assigns). A generic container, not the + * module's MongoDBContainer: the module classes need commons-lang3 3.18 and this module's classpath resolves 3.13.0 (see + * log-L5.md). Start is robust in two steps: a log wait with a bounded startup timeout, then a bounded ping loop, so a server + * that logged readiness but does not answer yet cannot fail a test. No Docker guard on purpose (owner decision D-7). Not a test. + */ +final class MongoContainerSupport { + + static final String DATABASE = "mg6"; + static final int MONGO_PORT = 27017; + private static final String READY_PATTERN = ".*[Ww]aiting for connections.*"; + private static final Duration START_TIMEOUT = Duration.ofMinutes(2); + private static final Duration PING_TIMEOUT = Duration.ofSeconds(10); + private static final int PING_ATTEMPTS = 30; + private static final long PING_PAUSE_MILLIS = 500; + static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(30); + + private static final GenericContainer MONGO = new GenericContainer<>(DockerImageName.parse(ContainerImages.MONGO_7_0)) + .withExposedPorts(MONGO_PORT) + .waitingFor(Wait.forLogMessage(READY_PATTERN, 1).withStartupTimeout(START_TIMEOUT)); + + static final MongoPlugin.MongoEngine ENGINE = new MongoPlugin.MongoEngine(new ConfigCenterForTest()); + + static { + long start = System.nanoTime(); + MONGO.start(); + System.out.println("[MongoContainerSupport] " + ContainerImages.MONGO_7_0 + " started in " + + (System.nanoTime() - start) / 1_000_000 + " ms on " + MONGO.getHost() + ":" + MONGO.getMappedPort(MONGO_PORT)); + awaitPing(); + } + + private MongoContainerSupport() { + } + + static String host() { + return MONGO.getHost(); + } + + static int port() { + return MONGO.getMappedPort(MONGO_PORT); + } + + static MongoDatasourceConfig hostConfig() { + return MongoDatasourceConfig.buildFrom(Map.of("host", host(), "port", port(), "database", DATABASE)); + } + + static MongoDatasourceConfig uriConfig(String uri) { + return MongoDatasourceConfig.buildFrom(Map.of("usingUri", true, "uri", uri)); + } + + private static void awaitPing() { + RuntimeException last = null; + for (int attempt = 1; attempt <= PING_ATTEMPTS; attempt++) { + MongoConnection connection = null; + try { + connection = ENGINE.createConnection(hostConfig()).block(BLOCK_TIMEOUT); + Document pong = connection.ping().block(PING_TIMEOUT); + System.out.println("[MongoContainerSupport] ping answered on attempt " + attempt + ": " + pong.toJson()); + return; + } catch (RuntimeException e) { + last = e; + try { + Thread.sleep(PING_PAUSE_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for MongoDB", interrupted); + } + } finally { + if (connection != null) { + connection.close().block(BLOCK_TIMEOUT); + } + } + } + throw new IllegalStateException("MongoDB did not answer a ping after " + PING_ATTEMPTS + " attempts", last); + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoEngineContainerTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoEngineContainerTest.java new file mode 100644 index 0000000000..4c09d49965 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoEngineContainerTest.java @@ -0,0 +1,298 @@ +package org.lowcoder.plugin.mongo; + +import com.fasterxml.jackson.databind.JsonNode; +import org.bson.Document; +import org.bson.types.Decimal128; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mongo.model.MongoConnection; +import org.lowcoder.plugin.mongo.model.MongoDatasourceConfig; +import org.lowcoder.plugin.mongo.model.MongoQueryExecutionContext; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.models.QueryExecutionResult; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.mongo.MongoContainerSupport.BLOCK_TIMEOUT; +import static org.lowcoder.plugin.mongo.MongoContainerSupport.DATABASE; +import static org.lowcoder.plugin.mongo.MongoContainerSupport.ENGINE; + +/** + * Unit MG-6 (task L5-8d): the Mongo engine against a real MongoDB 7.0 ({@link MongoContainerSupport}): connection and + * test-connection, each command built from the form data the client sends and run through {@code buildQueryExecutionContext} + * and {@code executeQuery}, the server's error mappings, and {@code getStructure}. Each test uses its own collection, so the + * tests do not depend on order. + * + *

    Limits: the image runs without authentication, TLS or SRV, so the auth-mechanism and ssl query parameters are not + * exercised against a server; the commands are the single-statement commands the forms build. + */ +public class MongoEngineContainerTest { + + static final String TAG = "[MongoEngineContainerTest] "; + static final String QUERY_CODE_OK = "OK"; + static final String UNREACHABLE_URI = "mongodb://127.0.0.1:1/nodb?serverSelectionTimeoutMS=300&connectTimeoutMS=300"; + static final long SLOW_FILTER_MILLIS = 1500; + + private static final com.fasterxml.jackson.databind.ObjectMapper JSON = new com.fasterxml.jackson.databind.ObjectMapper(); + private static MongoConnection connection; + private static final MongoDatasourceConfig CONFIG = MongoContainerSupport.hostConfig(); + + @BeforeAll + static void connect() { + connection = ENGINE.createConnection(CONFIG).block(BLOCK_TIMEOUT); + } + + @AfterAll + static void disconnect() { + ENGINE.destroyConnection(connection).block(BLOCK_TIMEOUT); + } + + private static String collection(String prefix) { + return prefix + "_" + UUID.randomUUID().toString().substring(0, 8); + } + + private static void seed(String collection, Document... documents) { + Mono.from(connection.getDatabase().getCollection(collection).insertMany(List.of(documents))).block(BLOCK_TIMEOUT); + } + + private static List stored(String collection) { + return Flux.from(connection.getDatabase().getCollection(collection).find().sort(new Document("n", 1))).collectList().block(BLOCK_TIMEOUT); + } + + private static Map form(String type, String collection, Object... compKeysAndValues) { + Map comp = new HashMap<>(); + for (int i = 0; i < compKeysAndValues.length; i += 2) { + comp.put((String) compKeysAndValues[i], compKeysAndValues[i + 1]); + } + Map form = new HashMap<>(); + form.put("compType", type); + form.put("collection", collection); + form.put("comp", comp); + return form; + } + + private static QueryExecutionResult run(MongoConnection on, Map form, Map params) { + MongoQueryExecutionContext context = ENGINE.buildQueryExecutionContext(CONFIG, form, params, null); + QueryExecutionResult result = ENGINE.executeQuery(on, context).block(BLOCK_TIMEOUT); + System.out.println(TAG + context.getCommand().toJson() + " -> " + result.getQueryCode() + " " + result.getData()); + return result; + } + + private static QueryExecutionResult run(Map form) { + return run(connection, form, Map.of()); + } + + private static JsonNode data(QueryExecutionResult result) { + assertEquals(QUERY_CODE_OK, result.getQueryCode()); + return (JsonNode) result.getData(); + } + + // ---- connection + + @Test + public void hostAndUriConfigsConnectAndTestConnectionSucceeds() { + assertTrue(ENGINE.testConnection(CONFIG).block(BLOCK_TIMEOUT).isSuccess()); + String uri = "mongodb://" + MongoContainerSupport.host() + ":" + MongoContainerSupport.port() + "/" + DATABASE; + MongoDatasourceConfig uriConfig = MongoContainerSupport.uriConfig(uri); + assertTrue(ENGINE.testConnection(uriConfig).block(BLOCK_TIMEOUT).isSuccess()); + MongoConnection viaUri = ENGINE.createConnection(uriConfig).block(BLOCK_TIMEOUT); + try { + Document pong = viaUri.ping().block(BLOCK_TIMEOUT); + System.out.println(TAG + "ping through the URI config: " + pong.toJson()); + assertEquals(1.0, pong.get("ok")); + assertEquals(DATABASE, viaUri.getDatabase().getName()); + } finally { + ENGINE.destroyConnection(viaUri).block(BLOCK_TIMEOUT); + } + } + + @Test + public void testConnectionReturnsAFailedResultForAnUnreachableServerInsteadOfThrowing() { + DatasourceTestResult result = ENGINE.testConnection(MongoContainerSupport.uriConfig(UNREACHABLE_URI)).block(BLOCK_TIMEOUT); + System.out.println(TAG + "unreachable: success=" + result.isSuccess() + " message=" + result.getInvalidMessage(Locale.ENGLISH)); + assertEquals(false, result.isSuccess()); + } + + @Test + public void aConfigWithANonMongoUriIsRejectedAtConnect() { + MongoDatasourceConfig blank = MongoContainerSupport.uriConfig(" "); + PluginException thrown = assertThrows(PluginException.class, () -> ENGINE.createConnection(blank).block(BLOCK_TIMEOUT)); + assertEquals("MONGODB_URL_EMPTY", thrown.getMessageKey()); + PluginException invalid = assertThrows(PluginException.class, () -> ENGINE.createConnection(MongoContainerSupport.uriConfig("http://x/y")).block(BLOCK_TIMEOUT)); + assertEquals("MONGODB_URL_EXTRACT_ERROR", invalid.getMessageKey()); + } + + @Test + public void aClosedConnectionCanNoLongerRunCommands() { + MongoConnection closing = ENGINE.createConnection(CONFIG).block(BLOCK_TIMEOUT); + assertEquals(1.0, closing.ping().block(BLOCK_TIMEOUT).get("ok")); + ENGINE.destroyConnection(closing).block(BLOCK_TIMEOUT); + QueryExecutionResult after = run(closing, form("COUNT", collection("closed")), Map.of()); + assertEquals("MONGO_EXECUTION_ERROR", after.getQueryCode(), "observed: a closed client gives an error result, not an exception"); + assertEquals("MONGODB_EXECUTE_ERROR", after.getMessageKey()); + } + + // ---- commands + + @Test + public void insertFindCountDistinctRunAgainstTheServer() { + String c = collection("basic"); + assertEquals("{\"n\":1}", data(run(form("INSERT", c, "documents", "{\"n\": 1, \"city\": \"Oslo\"}"))).toString()); + assertEquals("{\"n\":2}", data(run(form("INSERT", c, "documents", "[{\"n\": 2, \"city\": \"Rome\"}, {\"n\": 3, \"city\": \"Oslo\"}]"))).toString()); + assertEquals(3, stored(c).size()); + + JsonNode found = data(run(form("FIND", c, "query", "{\"city\": \"Oslo\"}", "sort", "{\"n\": -1}", "projection", "{\"n\": 1, \"_id\": 0}"))); + assertEquals("[{\"n\":3},{\"n\":1}]", found.toString()); + assertEquals("[{\"n\":2}]", data(run(form("FIND", c, "sort", "{\"n\": 1}", "projection", "{\"n\": 1, \"_id\": 0}", "limit", "1", "skip", "1"))).toString()); + assertEquals(3, data(run(form("FIND", c))).size()); + + assertEquals(2, data(run(form("COUNT", c, "query", "{\"city\": \"Oslo\"}"))).get("n").asInt()); + assertEquals(3, data(run(form("COUNT", c))).get("n").asInt()); + + JsonNode distinct = data(run(form("DISTINCT", c, "key", "city"))); + System.out.println(TAG + "distinct: " + distinct); + assertTrue(distinct.toString().contains("Oslo") && distinct.toString().contains("Rome")); + } + + @Test + public void updateAndDeleteTouchOneDocumentOrAllAsTheOptionSays() { + String c = collection("write"); + seed(c, new Document("n", 1).append("g", "a"), new Document("n", 2).append("g", "a"), new Document("n", 3).append("g", "b")); + + JsonNode single = data(run(form("UPDATE", c, "query", "{\"g\": \"a\"}", "update", "{\"$set\": {\"u\": 1}}", "limit", "SINGLE"))); + assertEquals(1, single.get("n").asInt()); + assertEquals(false, single.has("nModified"), "observed: the update result carries n only; parseResultBody returns on n before it reaches nModified"); + JsonNode all = data(run(form("UPDATE", c, "query", "{\"g\": \"a\"}", "update", "{\"$set\": {\"v\": 1}}", "limit", "ALL"))); + assertEquals(2, all.get("n").asInt()); + assertEquals(java.util.Arrays.asList(1, 1, null), stored(c).stream().map(d -> d.get("v")).toList()); + + assertEquals(1, data(run(form("DELETE", c, "query", "{\"g\": \"a\"}", "limit", "SINGLE"))).get("n").asInt()); + assertEquals(2, stored(c).size(), "one document of group a is gone"); + assertEquals(1, data(run(form("DELETE", c, "query", "{\"g\": \"a\"}"))).get("n").asInt(), "the default is one document"); + seed(c, new Document("n", 4).append("g", "b")); + assertEquals(2, data(run(form("DELETE", c, "query", "{\"g\": \"b\"}", "limit", "ALL"))).get("n").asInt()); + assertEquals(0, stored(c).size()); + } + + @Test + public void aggregateGroupsAndRawCommandsRun() throws Exception { + String c = collection("agg"); + seed(c, new Document("n", 1).append("g", "a"), new Document("n", 2).append("g", "a"), new Document("n", 3).append("g", "b")); + JsonNode grouped = data(run(form("AGGREGATE", c, "arrayPipelines", + "[{\"$group\": {\"_id\": \"$g\", \"total\": {\"$sum\": \"$n\"}}}, {\"$sort\": {\"_id\": 1}}]", "limit", "10"))); + assertEquals(JSON.readTree("[{\"_id\":\"a\",\"total\":3},{\"_id\":\"b\",\"total\":3}]"), grouped); + + Map raw = new HashMap<>(); + raw.put("compType", "RAW"); + raw.put("comp", new HashMap<>(Map.of("command", "{\"count\": \"{{coll}}\", \"query\": {\"g\": {{g}}}}"))); + assertEquals(2, data(run(connection, raw, Map.of("coll", c, "g", "a"))).get("n").asInt()); + raw.put("comp", new HashMap<>(Map.of("command", "{\"ping\": 1}"))); + QueryExecutionResult ping = run(connection, raw, Map.of()); + System.out.println(TAG + "raw ping: " + ping.getQueryCode() + " " + ping.getData()); + assertEquals(QUERY_CODE_OK, ping.getQueryCode()); + } + + /** + * Pins the plan section 9 row "data missing: a mongo aggregate with no limit field sends cursor {batchSize: 0} and MongoDB 7.0 returns zero rows" as observed: a form without the aggregate's limit field builds the cursor + * {@code {batchSize: 0}} (document pin: MongoCommandDocumentsTest.aggregateBatchSizeFollowsTheLimitField_pinsTheSection9Row) + * and the server returns no rows, while a limit or a blank limit returns all of them. A fix (a default limit when the field + * is absent) changes this test on purpose. + */ + @Test + public void aggregateWithoutALimitFieldAgainstTheServer_pinsTheSection9Row() { + String c = collection("nolimit"); + seed(c, new Document("n", 1), new Document("n", 2), new Document("n", 3)); + String pipeline = "[{\"$match\": {}}, {\"$sort\": {\"n\": 1}}]"; + JsonNode withoutLimit = data(run(form("AGGREGATE", c, "arrayPipelines", pipeline))); + JsonNode withLimit = data(run(form("AGGREGATE", c, "arrayPipelines", pipeline, "limit", "100"))); + JsonNode blankLimit = data(run(form("AGGREGATE", c, "arrayPipelines", pipeline, "limit", " "))); + System.out.println(TAG + "rows without limit: " + withoutLimit.size() + ", with limit 100: " + withLimit.size() + ", blank limit: " + blankLimit.size()); + assertEquals(3, withLimit.size()); + assertEquals(3, blankLimit.size()); + assertEquals(OBSERVED_ROWS_WITHOUT_LIMIT, withoutLimit.size()); + } + + static final int OBSERVED_ROWS_WITHOUT_LIMIT = 0; + + /** + * Pins defect D12 (analysis-plugins section 0.6; plan section 9 D1-D20 row) against a real server: the form's + * {@code timeout} (1 ms) reaches no command, so a find that the server needs {@value #SLOW_FILTER_MILLIS} ms for runs to + * completion and returns its row. The slow filter is {@code $where} with {@code sleep}, which MongoDB 7.0 runs by default + * (server-side JavaScript is enabled). A fix (maxTimeMS in the Find document) makes the server abort it and this test fail + * on purpose. + */ + @Test + public void formTimeoutDoesNotStopASlowFind_pinsD12() { + String c = collection("slow"); + seed(c, new Document("n", 1)); + Map form = form("FIND", c, "query", "{\"$where\": \"sleep(" + SLOW_FILTER_MILLIS + ") || true\"}"); + form.put("timeout", "1"); + long start = System.nanoTime(); + QueryExecutionResult result = run(connection, form, Map.of()); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + System.out.println(TAG + "slow find with timeout=1 took " + elapsedMillis + " ms: " + result.getQueryCode()); + assertEquals(QUERY_CODE_OK, result.getQueryCode()); + assertEquals(1, ((JsonNode) result.getData()).size()); + assertTrue(elapsedMillis >= SLOW_FILTER_MILLIS, "the setting did not cut the command short: " + elapsedMillis); + } + + // ---- server errors + + @Test + public void serverCommandErrorsAreMappedToQueryArgumentErrorsWithTheServersText() { + Map unknown = new HashMap<>(); + unknown.put("compType", "RAW"); + unknown.put("comp", new HashMap<>(Map.of("command", "{\"noSuchCommand\": 1}"))); + PluginException thrown = assertThrows(PluginException.class, () -> run(connection, unknown, Map.of())); + System.out.println(TAG + "unknown command: " + thrown.getError() + " " + thrown.getMessageKey() + " " + List.of(thrown.getArgs())); + assertEquals("QUERY_ARGUMENT_ERROR", thrown.getMessageKey()); + assertTrue(String.valueOf(thrown.getArgs()[0]).contains("noSuchCommand")); + + String c = collection("err"); + PluginException pipeline = assertThrows(PluginException.class, + () -> run(form("AGGREGATE", c, "arrayPipelines", "[{\"$noSuchStage\": {}}]", "limit", "5"))); + assertEquals("QUERY_ARGUMENT_ERROR", pipeline.getMessageKey()); + assertTrue(String.valueOf(pipeline.getArgs()[0]).contains("$noSuchStage")); + + PluginException update = assertThrows(PluginException.class, + () -> run(form("FIND", c, "query", "{\"$badOperator\": 1}"))); + assertEquals("QUERY_ARGUMENT_ERROR", update.getMessageKey()); + } + + // ---- structure + + @Test + public void getStructureListsCollectionsWithColumnsFromAnExampleDocument() { + String c = collection("shape"); + seed(c, new Document("_id", new ObjectId()).append("i", 1).append("l", 2L).append("d", 1.5d) + .append("dec", new Decimal128(new BigDecimal("1.25"))).append("s", "x").append("arr", List.of(1)) + .append("date", new Date()).append("sub", new Document("k", "v"))); + String empty = collection("empty"); + Mono.from(connection.getDatabase().createCollection(empty)).block(BLOCK_TIMEOUT); + + DatasourceStructure structure = ENGINE.getStructure(connection, CONFIG).block(BLOCK_TIMEOUT); + structure.getTables().forEach(t -> System.out.println(TAG + t.getType() + " " + t.getName() + " " + + t.getColumns().stream().map(col -> col.getName() + ":" + col.getType()).toList())); + DatasourceStructure.Table shape = structure.getTables().stream().filter(t -> c.equals(t.getName())).findFirst().orElseThrow(); + assertEquals(DatasourceStructure.TableType.COLLECTION, shape.getType()); + assertEquals(List.of("_id:ObjectId", "arr:Array", "d:Double", "date:Date", "dec:BigDecimal", "i:Integer", "l:Long", "s:String", "sub:Object"), + shape.getColumns().stream().map(col -> col.getName() + ":" + col.getType()).toList()); + DatasourceStructure.Table emptyTable = structure.getTables().stream().filter(t -> empty.equals(t.getName())).findFirst().orElseThrow(); + assertTrue(emptyTable.getColumns().isEmpty(), "observed: an empty collection is listed with no columns, the call does not fail"); + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoEngineQueryConfigTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoEngineQueryConfigTest.java new file mode 100644 index 0000000000..2d1b795970 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoEngineQueryConfigTest.java @@ -0,0 +1,206 @@ +package org.lowcoder.plugin.mongo; + +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mongo.model.MongoDatasourceConfig; +import org.lowcoder.plugin.mongo.model.MongoQueryExecutionContext; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.exception.PluginException; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.sdk.exception.PluginCommonError.INVALID_QUERY_SETTINGS; + +/** + * Unit MG-5 (task L5-8c): {@code MongoEngine.buildQueryExecutionContext}: how the query config the client sends and the + * request parameters become the command and the database name: the raw-command branch (its three error codes), the GUI + * branch (template rendering of each field, BSON fields as JSON, the others as text), the leaf-type check with its dotted + * path, and the wrapping of command errors. The special-type rewriting of rendered text is covered by + * {@code MongoSpecialTypesContractTest}; the command documents themselves by {@code MongoCommandDocumentsTest}. + * + *

    Limits: nothing is sent to a server (MG-6). + */ +public class MongoEngineQueryConfigTest { + + static final String DATABASE = "shop"; + static final String INVALID_SETTINGS = "INVALID_QUERY_SETTINGS"; + + private final MongoPlugin.MongoEngine engine = new MongoPlugin.MongoEngine(new ConfigCenterForTest()); + private final MongoDatasourceConfig datasourceConfig = MongoDatasourceConfig.buildFrom(Map.of("database", DATABASE, "host", "localhost")); + + private MongoQueryExecutionContext build(Map queryConfig, Map params) { + MongoQueryExecutionContext context = engine.buildQueryExecutionContext(datasourceConfig, queryConfig, params, null); + System.out.println("[MongoEngineQueryConfigTest] " + queryConfig + " " + params + " -> " + context.getDatabaseName() + " " + context.getCommand().toJson()); + return context; + } + + private PluginException failure(Map queryConfig, Map params) { + PluginException thrown = assertThrows(PluginException.class, () -> engine.buildQueryExecutionContext(datasourceConfig, queryConfig, params, null)); + System.out.println("[MongoEngineQueryConfigTest] " + queryConfig + " " + params + " -> " + thrown.getMessageKey() + " " + Arrays.toString(thrown.getArgs())); + return thrown; + } + + private static Map raw(Object command) { + Map comp = new HashMap<>(); + comp.put("command", command); + Map config = new HashMap<>(); + config.put("compType", "RAW"); + config.put("comp", comp); + return config; + } + + private static Map gui(String type, Object collection, Map comp) { + Map config = new HashMap<>(); + config.put("compType", type); + config.put("collection", collection); + config.put("comp", comp); + return config; + } + + @Test + public void rawCommandIsRenderedWithJsonEscapedParametersAndParsed() { + MongoQueryExecutionContext context = build(raw("{\"find\": \"{{table}}\", \"filter\": {\"name\": {{name}}, \"age\": {{age}}}}"), + Map.of("table", "users", "name", "a\"b", "age", 7)); + assertEquals(DATABASE, context.getDatabaseName()); + assertEquals(Document.parse("{\"find\": \"users\", \"filter\": {\"name\": \"a\\\"b\", \"age\": 7}}"), context.getCommand()); + } + + @Test + public void rawCommandBodyMustBeANonBlankString() { + PluginException notString = failure(raw(5), Map.of()); + assertEquals(INVALID_QUERY_SETTINGS, notString.getError()); + assertEquals("INVALID_RAW_REQUEST_PARAM", notString.getMessageKey()); + assertEquals("INVALID_RAW_REQUEST_PARAM", failure(raw(null), Map.of()).getMessageKey()); + assertEquals("RAW_REQUEST_PARAM_EMPTY", failure(raw(" "), Map.of()).getMessageKey()); + assertEquals("RAW_REQUEST_PARAM_EMPTY", failure(raw(""), Map.of()).getMessageKey()); + } + + @Test + public void rawCommandThatIsNotJsonIsWrappedAsInvalidQuerySettings() { + PluginException thrown = failure(raw("{\"find\": "), Map.of()); + assertEquals(INVALID_QUERY_SETTINGS, thrown.getError()); + assertEquals(INVALID_SETTINGS, thrown.getMessageKey()); + assertEquals(1, thrown.getArgs().length, "the parser's message is the one argument"); + } + + @Test + public void guiFieldsAreRenderedByKind() { + Map comp = new HashMap<>(); + comp.put("query", "{\"name\": {{name}}, \"n\": {{n}}}"); + comp.put("limit", "{{n}}"); + comp.put("skip", "{{n}}"); + MongoQueryExecutionContext context = build(gui("FIND", "{{coll}}", comp), Map.of("name", "o'neil", "n", 3, "coll", "people")); + Document command = context.getCommand(); + assertEquals("people", command.get("find"), "the collection is rendered as text"); + assertEquals(Document.parse("{\"name\": \"o'neil\", \"n\": 3}"), command.get("filter"), "a BSON field is rendered as JSON"); + assertEquals(3, command.get("limit")); + assertEquals(3L, command.get("skip")); + assertEquals(DATABASE, context.getDatabaseName()); + } + + @Test + public void guiBsonFieldsAreJsonEscapedWhileTextFieldsAreNot() { + Map filter = new HashMap<>(); + filter.put("query", "{\"name\": {{name}}}"); + Document json = build(gui("COUNT", "c", filter), Map.of("name", "x\"y")).getCommand(); + assertEquals(Document.parse("{\"name\": \"x\\\"y\"}"), json.get("query"), "a quote in the value stays inside the string"); + Map key = new HashMap<>(); + key.put("key", "{{k}}"); + assertEquals("x\"y", build(gui("DISTINCT", "c", key), Map.of("k", "x\"y")).getCommand().get("key"), "the distinct key is text: rendered verbatim"); + } + + @Test + public void guiAllBsonFieldsOfEveryCommandAreRendered() { + Map params = Map.of("v", 1); + Map update = new HashMap<>(); + update.put("query", "{\"a\": {{v}}}"); + update.put("update", "{\"$set\": {\"b\": {{v}}}}"); + Document updated = build(gui("UPDATE", "c", update), params).getCommand(); + Document statement = (Document) ((List) updated.get("updates")).get(0); + assertEquals(Document.parse("{\"a\": 1}"), statement.get("q")); + assertEquals(Document.parse("{\"$set\": {\"b\": 1}}"), statement.get("u")); + + Map delete = new HashMap<>(); + delete.put("query", "{\"a\": {{v}}}"); + assertEquals(Document.parse("{\"a\": 1}"), ((Document) ((List) build(gui("DELETE", "c", delete), params).getCommand().get("deletes")).get(0)).get("q")); + + Map insert = new HashMap<>(); + insert.put("documents", "{\"a\": {{v}}}"); + assertEquals(List.of(Document.parse("{\"a\": 1}")), build(gui("INSERT", "c", insert), params).getCommand().get("documents")); + + Map find = new HashMap<>(); + find.put("sort", "{\"a\": {{v}}}"); + find.put("projection", "{\"a\": {{v}}}"); + Document found = build(gui("FIND", "c", find), params).getCommand(); + assertEquals(Document.parse("{\"a\": 1}"), found.get("sort")); + assertEquals(Document.parse("{\"a\": 1}"), found.get("projection")); + + Map distinct = new HashMap<>(); + distinct.put("key", "k"); + distinct.put("query", "{\"a\": {{v}}}"); + assertEquals(Document.parse("{\"a\": 1}"), build(gui("DISTINCT", "c", distinct), params).getCommand().get("query")); + + Map aggregate = new HashMap<>(); + aggregate.put("arrayPipelines", "[{\"$match\": {\"a\": {{v}}}}]"); + aggregate.put("limit", "10"); + Document aggregated = build(gui("AGGREGATE", "c", aggregate), params).getCommand(); + assertEquals("[{\"$match\": {\"a\": 1}}]", ((org.bson.BsonArray) aggregated.get("pipeline")).toString().replace("{\"$numberInt\": \"1\"}", "1").replace("BsonArray{values=", "").replace("}]}", "}]")); + } + + @Test + public void aValueThatIsNotTextAtAnyDepthIsRejectedWithItsDottedPathAndType() { + Map comp = new HashMap<>(); + comp.put("limit", 5); + PluginException direct = failure(gui("FIND", "c", comp), Map.of()); + assertEquals(INVALID_QUERY_SETTINGS, direct.getError()); + assertEquals("INVALID_FORMAT", direct.getMessageKey()); + assertEquals("comp.limit", direct.getArgs()[0]); + assertEquals("Integer", direct.getArgs()[1]); + + PluginException top = failure(gui("FIND", 7L, new HashMap<>()), Map.of()); + assertEquals("INVALID_FORMAT", top.getMessageKey()); + assertEquals("collection", top.getArgs()[0]); + assertEquals("Long", top.getArgs()[1]); + + Map listed = new HashMap<>(); + listed.put("query", List.of("{}", true)); + PluginException inList = failure(gui("FIND", "c", listed), Map.of()); + assertEquals("comp.query", inList.getArgs()[0]); + assertEquals("Boolean", inList.getArgs()[1]); + } + + @Test + public void nullValuesAreKeptAndTheCallersConfigIsNotChanged() { + Map comp = new HashMap<>(); + comp.put("query", "{\"a\": {{v}}}"); + comp.put("sort", null); + Map config = gui("FIND", "c", comp); + Map before = new HashMap<>(config); + Document command = build(config, Map.of("v", 1)).getCommand(); + assertEquals(Document.parse("{\"a\": 1}"), command.get("filter")); + assertEquals(false, command.containsKey("sort")); + assertEquals(before, config); + assertEquals("{\"a\": {{v}}}", ((Map) config.get("comp")).get("query"), "the template in the caller's map is untouched"); + } + + @Test + public void commandErrorsAreWrappedAsInvalidQuerySettingsWhileValidationErrorsPassThrough() { + Map comp = new HashMap<>(); + comp.put("query", "{\"a\": "); + PluginException wrapped = failure(gui("FIND", "c", comp), Map.of()); + assertEquals(INVALID_SETTINGS, wrapped.getMessageKey()); + assertEquals(1, wrapped.getArgs().length, "the original message is the argument"); + + PluginException invalid = failure(gui("DELETE", "c", new HashMap<>()), Map.of()); + assertEquals("INVALID_PARAM_CONFIG_PLZ_CHECK", invalid.getMessageKey()); + assertEquals(List.of("Query"), invalid.getArgs()[0]); + + PluginException unknown = failure(gui("MERGE", "c", new HashMap<>()), Map.of()); + assertEquals("INVALID_MONGODB_REQUEST", unknown.getMessageKey()); + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoPluginValidateConfigTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoPluginValidateConfigTest.java new file mode 100644 index 0000000000..8ddca79023 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoPluginValidateConfigTest.java @@ -0,0 +1,163 @@ +package org.lowcoder.plugin.mongo; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mongo.model.MongoDatasourceConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit MG-3 (task L5-8b): {@code MongoPlugin.MongoEngine.validateConfig} for every branch: URI mode (blank, invalid, valid), + * host mode (blank host, a pasted URI in the host, the loopback restriction, the empty database) and the way the codes combine. + * + *

    Limits: the second check of the URI branch ({@code extractInfoFromConnectionStringURI == null}, MongoPlugin.java:404-407) + * cannot fire, because {@code isValid} true implies a non-null parse, so it is not covered. The message codes are asserted, not + * their localized texts. + */ +public class MongoPluginValidateConfigTest { + + static final String URL_EMPTY = "MONGODB_URL_EMPTY_PLZ_CHECK"; + static final String URL_INVALID = "INVALID_MONGODB_URL_PLZ_CHECK"; + static final String HOST_EMPTY = "HOST_EMPTY_PLZ_CHECK"; + static final String INVALID_HOST = "INVALID_HOST"; + static final String DATABASE_EMPTY = "DATABASE_EMPTY"; + static final String GOOD_HOST = "db.example.org"; + static final String GOOD_DATABASE = "app"; + + private final MongoPlugin.MongoEngine engine = new MongoPlugin.MongoEngine(new ConfigCenterForTest()); + + private Set uri(String uri) { + Set result = engine.validateConfig(MongoDatasourceConfig.builder().usingUri(true).uri(uri).build()); + System.out.println("[MongoPluginValidateConfigTest] uri " + uri + " -> " + new TreeSet<>(result)); + return result; + } + + private Set host(String host, String database) { + Set result = engine.validateConfig(MongoDatasourceConfig.builder().usingUri(false).host(host).database(database).build()); + System.out.println("[MongoPluginValidateConfigTest] host '" + host + "' database '" + database + "' -> " + new TreeSet<>(result)); + return result; + } + + // ---- URI mode + + @Test + public void blankUriIsReportedAsEmpty() { + for (String blank : Arrays.asList(null, "", " ")) { + assertEquals(Set.of(URL_EMPTY), uri(blank), "'" + blank + "'"); + } + } + + @Test + public void invalidUriIsReported() { + for (String invalid : List.of("not a uri", "http://h/db", "mongodb://", "MONGODB://h/db", " mongodb://h/db")) { + assertEquals(Set.of(URL_INVALID), uri(invalid), "'" + invalid + "'"); + } + } + + @Test + public void validUrisGiveNoMessage() { + for (String valid : List.of("mongodb://u:p@db.example.org:27017/app?x=y", "mongodb+srv://cluster0.example.net/app", "mongodb://h1:1,h2:2/app?replicaSet=rs0", "mongodb://db.example.org")) { + assertEquals(Set.of(), uri(valid), valid); + } + } + + /** + * Pins defect D13 (analysis-plugins section 0.6; plan section 9 D1-D20 row): the loopback restriction is applied to host + * mode only. In URI mode the same hosts that host mode rejects ({@code localhost}, {@code 127.0.0.1}) are accepted, so + * switching to URI mode bypasses it. A fix (applying the restriction to the host part of the URI) changes this test on + * purpose. The comparison of host mode is the next test. + */ + @Test + public void uriModeAcceptsLoopbackHostsThatHostModeRejects_pinsD13() { + for (String loopback : List.of("mongodb://localhost/app", "mongodb://LOCALHOST:27017/app", "mongodb://127.0.0.1/app", "mongodb://u:p@127.0.0.1:27017/app?x=y", "mongodb+srv://localhost/app")) { + assertEquals(Set.of(), uri(loopback), loopback); + } + assertEquals(Set.of(INVALID_HOST), host("localhost", GOOD_DATABASE), "host mode rejects the same host"); + assertEquals(Set.of(INVALID_HOST), host("127.0.0.1", GOOD_DATABASE), "host mode rejects the same host"); + } + + // ---- host mode + + @Test + public void hostModeWithHostAndDatabaseGivesNoMessage() { + assertEquals(Set.of(), host(GOOD_HOST, GOOD_DATABASE)); + assertEquals(Set.of(), host("localhost.example.org", GOOD_DATABASE), "only the whole name localhost is restricted"); + assertEquals(Set.of(), host("my-localhost", GOOD_DATABASE), "not a loopback name either"); + } + + @Test + public void localhostAndTheLiteralLoopbackAddressAreRejected() { + for (String restricted : List.of("localhost", "LOCALHOST", "LocalHost", "127.0.0.1")) { + assertEquals(Set.of(INVALID_HOST), host(restricted, GOOD_DATABASE), restricted); + } + } + + /** + * Pins the plan section 9 row "mongo host mode's loopback rejection is an exact string compare: 127.0.0.2, 0.0.0.0 and + * [::1] are accepted" (D-6: fix deferred): {@code validateConfig} compares the host with {@code localhost} (any case) and + * {@code 127.0.0.1} (exact) only, so the other loopback or any-local addresses pass. A fix (a literal-address check or + * {@code InetAddress.isLoopbackAddress/isAnyLocalAddress}) changes this test on purpose. + */ + @Test + public void otherLoopbackAddressesAreAccepted_pinsTheSection9Row() { + for (String loopback : List.of("127.0.0.2", "127.255.255.254", "0.0.0.0", "[::1]", "::1", "0:0:0:0:0:0:0:1", " 127.0.0.1")) { + assertEquals(Set.of(), host(loopback, GOOD_DATABASE), loopback); + } + } + + @Test + public void blankHostIsReportedAsEmpty() { + for (String blank : Arrays.asList(null, "", " ")) { + assertEquals(Set.of(HOST_EMPTY), host(blank, GOOD_DATABASE), "'" + blank + "'"); + } + } + + /** + * Shows the code for a connection string pasted into the host field: it is reported with the same code as an empty host + * ({@code HOST_EMPTY_PLZ_CHECK}) although the host is not empty (the wrong-code part of D13, analysis-plugins section 0.6; + * plan section 9 D1-D20 row). Asserted as today's behaviour. + */ + @Test + public void aUriPastedIntoTheHostFieldIsReportedWithTheEmptyHostCode() { + for (String pasted : List.of("mongodb://db.example.org", "mongodb+srv://cluster0.example.net", "see mongodb://h", "xmongodb+srv")) { + assertEquals(Set.of(HOST_EMPTY), host(pasted, GOOD_DATABASE), pasted); + } + assertEquals(Set.of(), host("mongodb.example.org", GOOD_DATABASE), "the word mongodb alone is fine"); + } + + @Test + public void emptyDatabaseIsReportedButABlankOneIsNot() { + assertEquals(Set.of(DATABASE_EMPTY), host(GOOD_HOST, null)); + assertEquals(Set.of(DATABASE_EMPTY), host(GOOD_HOST, "")); + assertEquals(Set.of(), host(GOOD_HOST, " "), "the check is isEmpty, not isBlank: a database of spaces is accepted"); + } + + @Test + public void severalProblemsAreReportedTogether() { + assertEquals(Set.of(HOST_EMPTY, DATABASE_EMPTY), host("", "")); + assertEquals(Set.of(HOST_EMPTY, DATABASE_EMPTY), host(null, null)); + assertEquals(Set.of(INVALID_HOST, DATABASE_EMPTY), host("localhost", "")); + assertEquals(Set.of(HOST_EMPTY, DATABASE_EMPTY), host("mongodb://localhost", ""), "a pasted URI never also counts as localhost"); + } + + // ---- sdk entry + + @Test + public void doValidateConfigDelegatesForAMongoConfigAndRejectsAForeignOneWithABareClassCastException() { + DatasourceConnectionConfig mongo = MongoDatasourceConfig.builder().usingUri(false).host(GOOD_HOST).database(GOOD_DATABASE).build(); + assertEquals(Set.of(), engine.doValidateConfig(mongo)); + DatasourceConnectionConfig empty = MongoDatasourceConfig.builder().usingUri(true).build(); + assertEquals(Set.of(URL_EMPTY), engine.doValidateConfig(empty)); + MysqlDatasourceConfig foreign = new MysqlDatasourceConfig("db", "u", "p", "h", 3306L, false, null, false, false, null); + ClassCastException thrown = assertThrows(ClassCastException.class, () -> engine.doValidateConfig(foreign)); + System.out.println("[MongoPluginValidateConfigTest] foreign config: " + thrown.getMessage()); + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoResultContractTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoResultContractTest.java new file mode 100644 index 0000000000..e22186c942 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoResultContractTest.java @@ -0,0 +1,162 @@ +package org.lowcoder.plugin.mongo; + +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoDatabase; +import org.bson.Document; +import org.bson.types.Decimal128; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mongo.model.MongoConnection; +import org.lowcoder.plugin.mongo.model.MongoQueryExecutionContext; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.models.QueryExecutionResult; +import reactor.core.publisher.Mono; + +import java.lang.reflect.Proxy; +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Group {@code external-json} of the Mongo plugin and its row of the §4.6 producer table (docs/API_PAYLOAD_TEST_PLAN.md + * §4.6, §4.10, task T8.2): {@code MongoEngine.executeQuery} with a {@link MongoClient} whose database answers each + * reply of {@link #REPLIES} to {@code runCommand}. The engine writes the reply with BSON's relaxed extended JSON + * ({@code Document.toJson}), reads it into {@code org.json}, and {@code MongoQueryUtils.parseResultBody} rewrites the + * {@code $oid}, {@code $numberLong}, {@code $date} and {@code $numberDecimal} wrappers and reads the text into a + * Jackson tree, once per reply kind: a {@code findAndModify} {@code value}, a {@code find} cursor, an insert's + * {@code n}, an update's {@code nModified}, a {@code distinct}'s {@code values}, a reply of none of those, and a reply + * that is not {@code ok}. The {@code org.json} step loses the document's key order, writes a whole double as an + * integer and a {@code Decimal128} as a double, and fails the whole query on a date before 1970 (O75). Each case's report ({@link QueryResults#report}) is pinned in {@value #REPORT}. + * + *

    Limits: the reply is a {@link Document} handed to the engine, not one read from a server; dates are formatted in + * the JVM's default zone ({@code MongoQueryUtils.FORMATTER}), so the test checks each against that formatting and pins + * the instant in its place ({@link #pinnedDates}). + */ +public class MongoResultContractTest { + + static final String REPORT = "external-json/MongoEngine.results.json"; + static final String DATE_PATTERN = "yyyy-MM-dd HH:mm:ss"; + static final String DATE_TOKEN_PREFIX = " REPLIES = replies(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final MongoPlugin.MongoEngine engine = new MongoPlugin.MongoEngine(new ConfigCenterForTest()); + + @BoundarySites({ + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java#MongoPlugin.MongoEngine.executeQuery#parseResultBody#1", + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#1", + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#2", + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#3", + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#4", + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#5", + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#6"}) + @Test + public void resultsAsPinned() { + Map report = new LinkedHashMap<>(); + REPLIES.forEach((name, reply) -> report.put(name, report(reply))); + String actual = pinnedDates(ConfigBinding.write(report)); + System.out.println("[MongoResultContractTest] zone " + ZoneId.systemDefault() + "\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** The report of the engine's result for {@code reply}, or the error text when the query fails. */ + private Object report(Document reply) { + MongoQueryExecutionContext context = MongoQueryExecutionContext.builder() + .databaseName(DATABASE).command(new Document("contract", 1)).build(); + try { + QueryExecutionResult result = engine.executeQuery(connection(reply), context).block(TIMEOUT); + return result == null ? null : QueryResults.report(result); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + } + + /** + * {@code text} with each date of {@link #DATE} and {@link #PRE_EPOCH_DATE} as the engine formats it in the JVM + * zone replaced by a token naming the instant; asserts the formatted text is there, so the formatting stays pinned. + */ + static String pinnedDates(String text) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_PATTERN).withZone(ZoneId.systemDefault()); + String formatted = formatter.format(DATE); + assertTrue(text.contains(formatted), "the report has the date formatted in the JVM zone: " + formatted); + return text.replace(formatted, DATE_TOKEN_PREFIX + DATE + DATE_TOKEN_SUFFIX) + .replace(formatter.format(PRE_EPOCH_DATE), DATE_TOKEN_PREFIX + PRE_EPOCH_DATE + DATE_TOKEN_SUFFIX); + } + + /** A connection whose database answers {@code reply} to every command; any other call fails. */ + static MongoConnection connection(Document reply) { + MongoDatabase database = proxy(MongoDatabase.class, "runCommand", Mono.just(reply)); + return new MongoConnection(proxy(MongoClient.class, "getDatabase", database), DATABASE); + } + + private static T proxy(Class type, String method, Object answer) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, (self, called, args) -> { + if (called.getName().equals(method)) { + return answer; + } + throw new UnsupportedOperationException(type.getSimpleName() + "." + called.getName()); + })); + } + + /** A document of every BSON type the plugin rewrites, plus JSON scalars, in an order a sorted map would change. */ + static Document document() { + return new Document("zeta", "first key") + .append("_id", OBJECT_ID) + .append("int", 1) + .append("intMax", Integer.MAX_VALUE) + .append("long", 3_000_000_001L) + .append("longMax", Long.MAX_VALUE) + .append("decimal128", new Decimal128(new BigDecimal("1.50"))) + .append("double", 1.5) + .append("wholeDouble", 2.0) + .append("bigDouble", 1e20) + .append("date", Date.from(DATE)) + .append("unicode", "žluťoučký kůň 🐎") + .append("null", null) + .append("true", true) + .append("emptyObject", new Document()) + .append("emptyArray", List.of()) + .append("nested", new Document("ids", List.of(OBJECT_ID, 7L, new Document("at", Date.from(DATE))))) + .append("alpha", "last key"); + } + + private static Map replies() { + Map replies = new LinkedHashMap<>(); + replies.put("findAndModify", new Document("lastErrorObject", new Document("n", 1).append("updatedExisting", true)) + .append("value", document()).append(OK, OK_VALUE)); + replies.put("find", new Document("cursor", new Document("firstBatch", List.of(document(), new Document("_id", 2))) + .append("id", 0L).append("ns", "contract.items")).append(OK, OK_VALUE)); + replies.put("findPreEpochDate", new Document("cursor", new Document("firstBatch", + List.of(new Document("date", Date.from(PRE_EPOCH_DATE)))).append("id", 0L)).append(OK, OK_VALUE)); + replies.put("insert", new Document("n", 3_000_000_001L).append(OK, OK_VALUE)); + replies.put("update", new Document("nModified", 2).append(OK, OK_VALUE)); + replies.put("updateWithN", new Document("n", 2).append("nModified", 1).append(OK, OK_VALUE)); + replies.put("distinct", new Document("values", List.of(1, "two", OBJECT_ID, 2.5, 3_000_000_001L, + new Decimal128(new BigDecimal("10.50")), Date.from(DATE))).append(OK, OK_VALUE)); + replies.put("other", new Document("acknowledged", true).append(OK, OK_VALUE)); + replies.put("notOk", new Document("errmsg", "failed").append(OK, NOT_OK_VALUE)); + return replies; + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoSpecialTypesContractTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoSpecialTypesContractTest.java new file mode 100644 index 0000000000..a0f84d9822 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoSpecialTypesContractTest.java @@ -0,0 +1,87 @@ +package org.lowcoder.plugin.mongo; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mongo.model.MongoDatasourceConfig; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.exception.PluginException; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Group {@code downstream-render}, its {@code mongoPlugin} row (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.5): after + * rendering a raw command or a BSON field of a GUI command, {@code MongoEngine.removeOrAddQuotesAroundSpecialTypes} + * rewrites a quoted special type ({@code "ObjectId('x')"}, {@code ISODate}, {@code Date}, {@code Timestamp}) into the + * unquoted shell form, writing the argument of the types that take a string as the production mapper's JSON string + * ({@code toJson}); the engine then parses the text into the command. For each command of {@link #COMMANDS}, run with + * the request parameters {@link #PARAMS}, the command the engine built is pinned as extended JSON (which keeps the + * BSON type of every value), one line per command ({@link GoldenJson#assertText}), in {@value #FIXTURE}. + * + *

    Limits: the rewritten text is internal to the engine; the built command is what it produces. The command is not + * sent to a server. {@code Date(...)} is not run: the command parser reads it as the shell's {@code Date()}, the current + * time as text, whatever the argument, so it has no stable result. + */ +public class MongoSpecialTypesContractTest { + + static final String FIXTURE = "downstream-render/MongoEngine.specialTypes.txt"; + static final String SEPARATOR = "\t"; + static final String NEWLINE = "\n"; + static final Map PARAMS = params(); + static final Map> COMMANDS = commands(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final MongoPlugin.MongoEngine engine = new MongoPlugin.MongoEngine(new ConfigCenterForTest()); + private final MongoDatasourceConfig datasourceConfig = MongoDatasourceConfig.buildFrom(Map.of("database", "contract", "host", "localhost")); + + @BoundarySites("lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java#MongoPlugin.MongoEngine.removeOrAddQuotesAroundSpecialTypes#toJson#1") + @Test + public void commandsAsBuilt() { + StringBuilder text = new StringBuilder(); + COMMANDS.forEach((name, queryConfig) -> { + String command; + try { + command = engine.buildQueryExecutionContext(datasourceConfig, queryConfig, PARAMS, null).getCommand() + .toJson(MongoTreeTextContractTest.EXTENDED); + } catch (PluginException e) { + command = "ERROR " + ConfigBinding.errorText(e) + " " + Arrays.toString(e.getArgs()); + } + text.append(name).append(SEPARATOR).append(command).append(NEWLINE); + }); + System.out.println("[MongoSpecialTypesContractTest] " + FIXTURE + "\n" + text); + GOLDEN.assertText(FIXTURE, text.toString()); + } + + private static Map params() { + Map params = new LinkedHashMap<>(); + params.put("id", "65f0c0ffee0000000000abcd"); + params.put("day", "2024-02-29T13:14:15.123Z"); + params.put("seconds", 1_709_212_455L); + params.put("quoted", "it's \"q\" \\"); + return params; + } + + /** A raw {@code find} command whose filter is {@code filter}. */ + private static Map raw(String filter) { + return MongoTreeTextContractTest.form("RAW", Map.of("command", "{\"find\": \"items\", \"filter\": {" + filter + "}}")); + } + + private static Map> commands() { + Map> commands = new LinkedHashMap<>(); + commands.put("rawObjectId", raw("\"_id\": \"ObjectId('{{id}}')\"")); + commands.put("rawObjectIdSpaced", raw("\"_id\": \"ObjectId( '{{id}}' )\"")); + commands.put("rawIsoDate", raw("\"day\": \"ISODate('{{day}}')\"")); + commands.put("rawIsoDateDoubleQuoted", raw("\"day\": \"ISODate(\\\"{{day}}\\\")\"")); + commands.put("rawTimestamp", raw("\"ts\": \"Timestamp({{seconds}}, 1)\"")); + commands.put("rawSeveral", raw("\"_id\": \"ObjectId('{{id}}')\", \"day\": \"ISODate('{{day}}')\", \"ts\": \"Timestamp({{seconds}}, 1)\"")); + commands.put("rawUnquotedArgument", raw("\"_id\": \"ObjectId({{id}})\"")); + commands.put("rawQuotedText", raw("\"day\": \"ISODate('{{quoted}}')\"")); + commands.put("guiFind", MongoTreeTextContractTest.form("FIND", Map.of("query", + "{\"_id\": \"ObjectId('{{id}}')\", \"day\": {\"$lt\": \"ISODate('{{day}}')\"}}", "limit", "1"))); + return commands; + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoTreeTextContractTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoTreeTextContractTest.java new file mode 100644 index 0000000000..e47571bcdd --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/MongoTreeTextContractTest.java @@ -0,0 +1,159 @@ +package org.lowcoder.plugin.mongo; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import org.bson.Document; +import org.bson.json.JsonMode; +import org.bson.json.JsonWriterSettings; +import org.bson.types.Decimal128; +import org.bson.types.ObjectId; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mongo.model.MongoDatasourceConfig; +import org.lowcoder.plugin.mongo.utils.MongoQueryUtils; +import org.lowcoder.sdk.config.dynamic.ConfigCenterForTest; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.util.JsonUtils; +import org.lowcoder.sdk.util.MustacheHelper; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Group {@code tree-text}, its {@code mongoPlugin} rows (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.4): + *

      + *
    • {@code MongoEngine.buildQueryExecutionContext} renders a raw command, and the BSON fields of a GUI command + * ({@code evaluateString}), with {@code renderMustacheJsonString}, whose text is {@code JsonNode#toString} (E13), + * and parses that text into the command {@link Document}. Each line of {@link #COMMANDS} pins the exact text + * {@code renderMustacheJsonString} gives for the caller's own arguments (the raw command, or each BSON field of + * the GUI form, with the request parameters), then the command the engine built, as extended JSON, which keeps + * the BSON type of every number.
    • + *
    • {@code MongoQueryUtils.parseResultBody} builds a {@code distinct} result as a node and re-reads its + * {@code toString} text ({@code MongoQueryUtils.java:193-204}): the resulting node is pinned by its + * {@code toString} and its production-mapper text.
    • + *
    + * Pinned in {@value #COMMANDS_REPORT} and {@value #DISTINCT_REPORT}. + * + *

    Limits: the engine keeps its rendered text internal, so the test renders it by calling the same static helper with + * the same arguments the engine passes ({@code MongoPlugin.java:178}, {@code :249}); the built command shows the + * engine read that text. The special types ({@code ObjectId(...)}) are rewritten after rendering; that rewrite is + * {@code downstream-render}'s (T8.5). + */ +public class MongoTreeTextContractTest { + + static final String COMMANDS_REPORT = "tree-text/MongoEngine.commands.txt"; + static final String DISTINCT_REPORT = "tree-text/MongoQueryUtils.distinct.txt"; + static final String COLLECTION = "items"; + static final String FIELD_PREFIX = "comp"; + /** The form fields the engine renders as JSON: the raw command and the BSON fields of the GUI commands. */ + static final Set BSON_FIELDS = Set.of("command", "query", "sort", "projection", "documents", "update", "arrayPipelines"); + static final String SEPARATOR = "\t"; + static final String NEWLINE = "\n"; + static final JsonWriterSettings EXTENDED = JsonWriterSettings.builder().outputMode(JsonMode.EXTENDED).build(); + static final Map PARAMS = params(); + static final Map> COMMANDS = commands(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final MongoPlugin.MongoEngine engine = new MongoPlugin.MongoEngine(new ConfigCenterForTest()); + private final MongoDatasourceConfig datasourceConfig = MongoDatasourceConfig.buildFrom(Map.of("database", "contract", "host", "localhost")); + + @BoundarySites({ + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java#MongoPlugin.MongoEngine.buildQueryExecutionContext#renderMustacheJsonString#1", + "lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java#MongoPlugin.MongoEngine.evaluateString#renderMustacheJsonString#1"}) + @Test + public void commandsAsParsed() { + StringBuilder text = new StringBuilder(); + COMMANDS.forEach((name, queryConfig) -> { + text.append(name).append(SEPARATOR).append(renderedText(queryConfig)).append(SEPARATOR); + String command; + try { + command = engine.buildQueryExecutionContext(datasourceConfig, queryConfig, PARAMS, null).getCommand().toJson(EXTENDED); + } catch (RuntimeException e) { + command = "ERROR " + ConfigBinding.errorText(e); + } + text.append(command).append(NEWLINE); + }); + System.out.println("[MongoTreeTextContractTest] commands\n" + text); + GOLDEN.assertText(COMMANDS_REPORT, text.toString()); + } + + @BoundarySites("lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java##import#1") + @Test + public void distinctResultNode() throws JsonProcessingException { + Document reply = new Document("values", List.of(1, 3_000_000_001L, 2.5, "žluť \"quoted\"", new ObjectId("65f0c0ffee0000000000abcd"), + new Decimal128(new BigDecimal("10.50")), new Document("nested", List.of(true)))).append("ok", 1.0); + JsonNode node = MongoQueryUtils.parseResultBody(new JSONObject(reply.toJson())); + String text = "toString" + SEPARATOR + node + NEWLINE + + "production" + SEPARATOR + JsonUtils.getObjectMapper().writeValueAsString(node) + NEWLINE; + System.out.println("[MongoTreeTextContractTest] distinct\n" + text); + GOLDEN.assertText(DISTINCT_REPORT, text); + } + + /** + * The text {@code renderMustacheJsonString} gives for what the engine renders: the raw command, or each BSON + * field of the GUI form ({@code comp.}, in name order), with the request parameters. + */ + @SuppressWarnings("unchecked") + private static String renderedText(Map queryConfig) { + Map comp = (Map) queryConfig.get(FIELD_PREFIX); + StringBuilder rendered = new StringBuilder(); + new TreeMap<>(comp).forEach((field, value) -> { + if (BSON_FIELDS.contains(field)) { + rendered.append(field).append('=').append(outcome(() -> MustacheHelper.renderMustacheJsonString((String) value, PARAMS))).append(' '); + } + }); + return rendered.toString().trim(); + } + + private static String outcome(java.util.function.Supplier render) { + try { + return render.get(); + } catch (RuntimeException e) { + return "ERROR " + ConfigBinding.errorText(e); + } + } + + private static Map params() { + Map params = new LinkedHashMap<>(); + params.put("price", 1.5); + params.put("count", 3_000_000_001L); + params.put("q", "kůň \"quoted\" \\"); + params.put("tags", List.of("a", 2, 2.50)); + Map range = new LinkedHashMap<>(); + range.put("$gte", new BigDecimal("1.50")); + range.put("$lt", 3_000_000_001L); + params.put("range", range); + params.put("filter", Map.of("n", new BigInteger("9223372036854775808"))); + return params; + } + + /** A GUI form as the editor sends it: the command type and collection at the top, the fields under "comp". */ + static Map form(String type, Map fields) { + Map form = new HashMap<>(); + form.put("compType", type); + form.put("collection", COLLECTION); + form.put(FIELD_PREFIX, new HashMap<>(fields)); + return form; + } + + private static Map> commands() { + Map> commands = new LinkedHashMap<>(); + commands.put("raw", form("RAW", Map.of("command", "{\"find\": \"items\", \"filter\": {\"price\": {\"$gte\": {{price}}}, " + + "\"count\": {{count}}, \"name\": \"{{q}}\", \"tags\": {\"$in\": {{tags}}}, \"range\": {{range}}}, \"limit\": 1.50, \"skip\": 2147483648}"))); + commands.put("rawBigIntegerInMap", form("RAW", Map.of("command", "{\"find\": \"items\", \"filter\": {{filter}}}"))); + commands.put("guiFind", form("FIND", Map.of("query", "{\"price\": {{price}}, \"name\": \"{{q}}\", \"range\": {{range}}}", + "sort", "{\"price\": -1}", "projection", "{\"name\": 1.0}", "limit", "10"))); + commands.put("guiInsert", form("INSERT", Map.of("documents", "[{\"n\": {{count}}, \"t\": {{tags}}}]"))); + commands.put("rawInvalid", form("RAW", Map.of("command", "{\"find\": "))); + return commands; + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/commands/MongoCommandDocumentsTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/commands/MongoCommandDocumentsTest.java new file mode 100644 index 0000000000..df1c0a2f32 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/commands/MongoCommandDocumentsTest.java @@ -0,0 +1,395 @@ +package org.lowcoder.plugin.mongo.commands; + +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mongo.utils.MongoQueryUtils; +import org.lowcoder.sdk.exception.PluginException; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_ARGUMENT_ERROR; + +/** + * Unit MG-4 (task L5-8c): the command document each Mongo form builds: {@code MongoQueryUtils.convertMongoFormInputToRawCommand} + * picks the command and validates it, {@code parseCommand()} builds the document that {@code runCommand} gets. One section per + * command: defaults, each field, the validity rules with the exact field names reported, and the error codes. + * + *

    The form data has the shape the client sends ({@code compType}, {@code collection} and a {@code comp} map with the + * command's fields; client/packages/lowcoder/src/comps/queries/mongoQuery.tsx). The delete and update options are the form's + * {@code SINGLE} and {@code ALL} (mongoQuery.tsx:25-33), so only an API call can send another value. + * + *

    Limits: documents are compared as their relaxed JSON text; nothing is sent to a server (MG-6). + */ +public class MongoCommandDocumentsTest { + + static final String COLLECTION = "orders"; + static final String ALL = "ALL"; + static final String SINGLE = "SINGLE"; + static final String INVALID_LIMIT = "INVALID_LIMIT_CONFIG"; + static final String INVALID_PARAMS = "INVALID_PARAM_CONFIG_PLZ_CHECK"; + static final int DEFAULT_TIMEOUT_MS = 8000; + + private static Map form(String type, String collection, Object... compKeysAndValues) { + Map comp = new HashMap<>(); + for (int i = 0; i < compKeysAndValues.length; i += 2) { + comp.put((String) compKeysAndValues[i], compKeysAndValues[i + 1]); + } + Map form = new HashMap<>(); + form.put("compType", type); + if (collection != null) { + form.put("collection", collection); + } + form.put("comp", comp); + return form; + } + + private static Document document(Map form) { + MongoCommand command = MongoQueryUtils.convertMongoFormInputToRawCommand(form); + Document document = command.parseCommand(); + System.out.println("[MongoCommandDocumentsTest] " + form.get("compType") + " " + form.get("comp") + " -> " + document.toJson()); + return document; + } + + private static PluginException invalid(Map form) { + PluginException thrown = assertThrows(PluginException.class, () -> MongoQueryUtils.convertMongoFormInputToRawCommand(form)); + System.out.println("[MongoCommandDocumentsTest] " + form.get("compType") + " " + form.get("comp") + " -> " + thrown.getMessageKey() + " " + Arrays.toString(thrown.getArgs())); + return thrown; + } + + private static void assertInvalidWith(Map form, String... fieldNames) { + PluginException thrown = invalid(form); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals(INVALID_PARAMS, thrown.getMessageKey()); + assertEquals(List.of(fieldNames), thrown.getArgs()[0]); + } + + // ---- find + + @Test + public void findWithEveryFieldBuildsFilterSortProjectionLimitBatchSizeAndSkip() { + Document document = document(form("FIND", COLLECTION, "query", "{\"status\": \"open\"}", "sort", "{\"id\": -1}", "projection", "{\"id\": 1}", "limit", "25", "skip", "5")); + assertEquals(COLLECTION, document.get("find")); + assertEquals(Document.parse("{\"status\": \"open\"}"), document.get("filter")); + assertEquals(Document.parse("{\"id\": -1}"), document.get("sort")); + assertEquals(Document.parse("{\"id\": 1}"), document.get("projection")); + assertEquals(25, document.get("limit")); + assertEquals(25, document.get("batchSize")); + assertEquals(5L, document.get("skip")); + } + + @Test + public void findDefaultsToAnEmptyFilterAndAnUnlimitedBatch() { + Document document = document(form("FIND", COLLECTION)); + assertEquals(new Document(), document.get("filter")); + assertEquals(Integer.MAX_VALUE, document.get("batchSize")); + assertFalse(document.containsKey("limit")); + assertFalse(document.containsKey("sort")); + assertFalse(document.containsKey("projection")); + assertFalse(document.containsKey("skip")); + assertEquals(new Document(), document(form("FIND", COLLECTION, "query", " ")).get("filter"), "a blank query is an empty filter"); + } + + @Test + public void findLimitMustBeAPositiveInteger() { + for (String limit : List.of("0", "-3", "abc", "1.5")) { + PluginException thrown = assertThrows(PluginException.class, () -> document(form("FIND", COLLECTION, "limit", limit)), limit); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals(INVALID_LIMIT, thrown.getMessageKey(), limit); + } + } + + @Test + public void findMalformedJsonNamesTheField() { + for (Map.Entry field : Map.of("query", "Query", "sort", "Sort", "projection", "Projection").entrySet()) { + PluginException thrown = assertThrows(PluginException.class, () -> document(form("FIND", COLLECTION, field.getKey(), "{not json"))); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals("INVALID_JSON_FORMAT", thrown.getMessageKey()); + assertEquals(field.getValue(), thrown.getArgs()[0]); + } + } + + @Test + public void findNonNumericSkipIsARawNumberFormatExceptionAtCommandLevel() { + assertThrows(NumberFormatException.class, () -> document(form("FIND", COLLECTION, "skip", "ten"))); + } + + // ---- insert + + @Test + public void insertWrapsASingleObjectInAListAndKeepsAnArrayAsItIs() { + Document single = document(form("INSERT", COLLECTION, "documents", "{\"a\": 1}")); + assertEquals(COLLECTION, single.get("insert")); + assertEquals(List.of(Document.parse("{\"a\": 1}")), single.get("documents")); + Document many = document(form("INSERT", COLLECTION, "documents", " [{\"a\": 1}, {\"a\": 2}] ")); + org.bson.BsonArray parsed = assertInstanceOf(org.bson.BsonArray.class, many.get("documents")); + assertEquals(2, parsed.size()); + assertEquals(2, parsed.get(1).asDocument().getInt32("a").getValue()); + } + + @Test + public void insertEmptyArrayBecomesTheTextEmptyArray() { + assertEquals("[]", document(form("INSERT", COLLECTION, "documents", "[]")).get("documents")); + } + + @Test + public void insertMalformedInputIsReportedPerForm() { + PluginException object = assertThrows(PluginException.class, () -> document(form("INSERT", COLLECTION, "documents", "{\"a\": "))); + assertEquals("INVALID_JSON_FORMAT", object.getMessageKey()); + assertEquals("Documents", object.getArgs()[0]); + } + + static final List BROKEN_ARRAYS = List.of("[}", "[{\"a\": "); + + /** + * Pins the plan section 9 row "Insert/Aggregate: [} or a truncated array escapes as a raw BsonInvalidOperationException (catch handles JsonParseException only)" as observed. A fix (catching it) changes this test on purpose. + * Most malformed arrays tried ({@link #BROKEN_ARRAYS}) escape as a raw BsonInvalidOperationException instead + * of the INVALID_JSON_ARRAY_FORMAT error; only a JsonParseException input such as {@code [,]} or {@code [{'a':}]} is mapped. + */ + @Test + public void insertMalformedArraysEscapeAsRawBsonExceptionsExceptCommaOnly() { + PluginException mapped = assertThrows(PluginException.class, () -> document(form("INSERT", COLLECTION, "documents", "[,]"))); + assertEquals("INVALID_JSON_ARRAY_FORMAT", mapped.getMessageKey()); + assertEquals("INVALID_JSON_ARRAY_FORMAT", assertThrows(PluginException.class, () -> document(form("INSERT", COLLECTION, "documents", "[{'a':}]"))).getMessageKey()); + for (String broken : BROKEN_ARRAYS) { + assertThrows(org.bson.BsonInvalidOperationException.class, () -> document(form("INSERT", COLLECTION, "documents", broken)), broken); + } + } + + @Test + public void insertWithoutDocumentsIsInvalid() { + assertInvalidWith(form("INSERT", COLLECTION), "Documents"); + assertInvalidWith(form("INSERT", COLLECTION, "documents", " "), "Documents"); + } + + // ---- update + + @Test + public void updateTouchesOneDocumentUnlessTheOptionIsExactlyAll() { + Document single = document(form("UPDATE", COLLECTION, "query", "{\"id\": 1}", "update", "{\"$set\": {\"a\": 2}}", "limit", SINGLE)); + Document defaulted = document(form("UPDATE", COLLECTION, "query", "{\"id\": 1}", "update", "{\"$set\": {\"a\": 2}}")); + Document all = document(form("UPDATE", COLLECTION, "query", "{\"id\": 1}", "update", "{\"$set\": {\"a\": 2}}", "limit", ALL)); + Document lowerCase = document(form("UPDATE", COLLECTION, "query", "{\"id\": 1}", "update", "{\"$set\": {\"a\": 2}}", "limit", "all")); + assertEquals(Boolean.FALSE, ((Document) ((List) single.get("updates")).get(0)).get("multi")); + assertEquals(Boolean.FALSE, ((Document) ((List) defaulted.get("updates")).get(0)).get("multi")); + assertEquals(Boolean.TRUE, ((Document) ((List) all.get("updates")).get(0)).get("multi")); + assertEquals(Boolean.FALSE, ((Document) ((List) lowerCase.get("updates")).get(0)).get("multi"), "only the exact text ALL means all documents: a lower-case value stays single"); + } + + @Test + public void updateDocumentHasTheCollectionAndOneUpdateStatement() { + Document document = document(form("UPDATE", COLLECTION, "query", "{\"id\": 1}", "update", "{\"$set\": {\"a\": 2}}")); + assertEquals(COLLECTION, document.get("update")); + List updates = assertInstanceOf(List.class, document.get("updates")); + assertEquals(1, updates.size()); + Document statement = (Document) updates.get(0); + assertEquals(Document.parse("{\"id\": 1}"), statement.get("q")); + assertEquals(Document.parse("{\"$set\": {\"a\": 2}}"), statement.get("u")); + } + + @Test + public void updateNeedsQueryAndUpdateAndNamesWhatIsMissing() { + assertInvalidWith(form("UPDATE", COLLECTION), "Query", "Update"); + assertInvalidWith(form("UPDATE", COLLECTION, "update", "{\"$set\": {}}"), "Query"); + assertInvalidWith(form("UPDATE", COLLECTION, "query", "{}"), "Update"); + assertInvalidWith(form("UPDATE", COLLECTION, "query", " ", "update", " "), "Query", "Update"); + } + + // ---- delete + + /** The data-loss test: one document by default, all only for the exact option ALL, never without a query. */ + @Test + public void deleteRemovesOneDocumentUnlessTheOptionIsExactlyAll() { + Map query = Map.of("query", "{\"id\": 1}"); + Document single = document(form("DELETE", COLLECTION, "query", "{\"id\": 1}", "limit", SINGLE)); + Document defaulted = document(form("DELETE", COLLECTION, "query", "{\"id\": 1}")); + Document all = document(form("DELETE", COLLECTION, "query", "{\"id\": 1}", "limit", ALL)); + Document lowerCase = document(form("DELETE", COLLECTION, "query", "{\"id\": 1}", "limit", "all")); + assertEquals(1, ((Document) ((List) single.get("deletes")).get(0)).get("limit")); + assertEquals(1, ((Document) ((List) defaulted.get("deletes")).get(0)).get("limit")); + assertEquals(0, ((Document) ((List) all.get("deletes")).get(0)).get("limit"), "limit 0 deletes every matching document"); + assertEquals(1, ((Document) ((List) lowerCase.get("deletes")).get(0)).get("limit"), "only the exact text ALL means all documents: a lower-case value stays single"); + assertEquals(query.get("query"), "{\"id\": 1}"); + } + + @Test + public void deleteDocumentHasTheCollectionAndOneDeleteStatement() { + Document document = document(form("DELETE", COLLECTION, "query", "{\"id\": 1}")); + assertEquals(COLLECTION, document.get("delete")); + Document statement = (Document) ((List) document.get("deletes")).get(0); + assertEquals(Document.parse("{\"id\": 1}"), statement.get("q")); + assertEquals(1, ((List) document.get("deletes")).size()); + } + + @Test + public void deleteWithoutAQueryIsInvalidBecauseThereIsNoSmartDefault() { + assertInvalidWith(form("DELETE", COLLECTION), "Query"); + assertInvalidWith(form("DELETE", COLLECTION, "query", " ", "limit", ALL), "Query"); + } + + // ---- count and distinct + + @Test + public void countDefaultsToAnEmptyQuery() { + Document defaulted = document(form("COUNT", COLLECTION)); + assertEquals(COLLECTION, defaulted.get("count")); + assertEquals(new Document(), defaulted.get("query")); + assertEquals(Document.parse("{\"a\": 1}"), document(form("COUNT", COLLECTION, "query", "{\"a\": 1}")).get("query")); + PluginException thrown = assertThrows(PluginException.class, () -> document(form("COUNT", COLLECTION, "query", "{"))); + assertEquals("INVALID_JSON_FORMAT", thrown.getMessageKey()); + assertEquals("Query", thrown.getArgs()[0]); + } + + @Test + public void distinctNeedsAKeyAndDefaultsToAnEmptyQuery() { + Document document = document(form("DISTINCT", COLLECTION, "key", "city")); + assertEquals(COLLECTION, document.get("distinct")); + assertEquals("city", document.get("key")); + assertEquals(new Document(), document.get("query")); + assertEquals(Document.parse("{\"a\": 1}"), document(form("DISTINCT", COLLECTION, "key", "city", "query", "{\"a\": 1}")).get("query")); + assertInvalidWith(form("DISTINCT", COLLECTION), "Key/Field"); + assertInvalidWith(form("DISTINCT", COLLECTION, "key", " "), "Key/Field"); + } + + // ---- aggregate + + @Test + public void aggregateKeepsAnArrayPipelineAndWrapsASingleStage() { + Document array = document(form("AGGREGATE", COLLECTION, "arrayPipelines", "[{\"$match\": {\"a\": 1}}, {\"$limit\": 2}]", "limit", "50")); + assertEquals(COLLECTION, array.get("aggregate")); + assertEquals(2, assertInstanceOf(org.bson.BsonArray.class, array.get("pipeline")).size()); + Document single = document(form("AGGREGATE", COLLECTION, "arrayPipelines", "{\"$match\": {\"a\": 1}}", "limit", "50")); + assertEquals(List.of(Document.parse("{\"$match\": {\"a\": 1}}")), single.get("pipeline")); + assertEquals("[]", document(form("AGGREGATE", COLLECTION, "arrayPipelines", "[]", "limit", "50")).get("pipeline"), "an empty array becomes the text []"); + } + + /** + * The absent-limit assertion pins the plan section 9 row "data missing: a mongo aggregate with no limit field sends cursor {batchSize: 0} and MongoDB 7.0 returns zero rows" (server side: + * MongoEngineContainerTest.aggregateWithoutALimitFieldAgainstTheServer_pinsTheSection9Row). A fix (a default limit when the + * field is absent) changes it on purpose. + */ + @Test + public void aggregateBatchSizeFollowsTheLimitField_pinsTheSection9Row() { + assertEquals(Document.parse("{batchSize: 50}"), document(form("AGGREGATE", COLLECTION, "arrayPipelines", "[]", "limit", "50")).get("cursor")); + assertEquals(Document.parse("{batchSize: " + Integer.MAX_VALUE + "}"), document(form("AGGREGATE", COLLECTION, "arrayPipelines", "[]", "limit", " ")).get("cursor"), "a blank limit means unlimited"); + Document absent = document(form("AGGREGATE", COLLECTION, "arrayPipelines", "[]")); + assertEquals(Document.parse("{batchSize: 0}"), absent.get("cursor"), "an absent limit field leaves the limit at 0: batchSize 0 (observed; MG-6 confirms the rows against a server)"); + } + + @Test + public void aggregateLimitMustBeAPositiveIntegerAndFailsWhileTheCommandIsBuilt() { + for (String limit : List.of("0", "-1", "x")) { + PluginException thrown = assertThrows(PluginException.class, () -> MongoQueryUtils.convertMongoFormInputToRawCommand(form("AGGREGATE", COLLECTION, "arrayPipelines", "[]", "limit", limit)), limit); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals(INVALID_LIMIT, thrown.getMessageKey(), limit); + } + } + + /** Aggregate side of the section 9 row named at {@link #insertMalformedArraysEscapeAsRawBsonExceptionsExceptCommaOnly}: Pins the plan section 9 row "Insert/Aggregate: [} or a truncated array escapes as a raw BsonInvalidOperationException (catch handles JsonParseException only)" as observed. */ + @Test + public void aggregateMalformedPipelineAndMissingPipelineAreReported() { + assertEquals("INVALID_MONGODB_BSON_ARRAY_FORMAT", assertThrows(PluginException.class, () -> document(form("AGGREGATE", COLLECTION, "arrayPipelines", "[,]", "limit", "5"))).getMessageKey()); + for (String broken : BROKEN_ARRAYS) { + assertThrows(org.bson.BsonInvalidOperationException.class, () -> document(form("AGGREGATE", COLLECTION, "arrayPipelines", broken, "limit", "5")), "observation: not mapped to INVALID_MONGODB_BSON_ARRAY_FORMAT: " + broken); + } + PluginException stage = assertThrows(PluginException.class, () -> document(form("AGGREGATE", COLLECTION, "arrayPipelines", "{\"$match\": ", "limit", "5"))); + assertEquals("INVALID_JSON_FORMAT", stage.getMessageKey()); + assertEquals("Array of Pipelines", stage.getArgs()[0]); + assertInvalidWith(form("AGGREGATE", COLLECTION, "limit", "5"), "Array of Pipelines"); + assertInvalidWith(form("AGGREGATE", COLLECTION, "arrayPipelines", "", "limit", "5"), "Array of Pipelines"); + } + + // ---- collection, dispatch, timeout + + @Test + public void everyCommandNeedsACollection() { + for (String type : List.of("FIND", "INSERT", "UPDATE", "DELETE", "COUNT", "DISTINCT", "AGGREGATE")) { + assertInvalidWith(form(type, null), "collection"); + assertInvalidWith(form(type, " "), "collection"); + } + } + + @Test + public void commandTypeIsMatchedInAnyCaseAndUnknownTypesAreRejectedWithTheirName() { + assertInstanceOf(Find.class, MongoQueryUtils.convertMongoFormInputToRawCommand(form("find", COLLECTION))); + assertInstanceOf(Find.class, MongoQueryUtils.convertMongoFormInputToRawCommand(form("Find", COLLECTION))); + assertInstanceOf(Insert.class, MongoQueryUtils.convertMongoFormInputToRawCommand(form("insert", COLLECTION, "documents", "{}"))); + assertInstanceOf(UpdateMany.class, MongoQueryUtils.convertMongoFormInputToRawCommand(form("update", COLLECTION, "query", "{}", "update", "{}"))); + assertInstanceOf(Delete.class, MongoQueryUtils.convertMongoFormInputToRawCommand(form("delete", COLLECTION, "query", "{}"))); + assertInstanceOf(Count.class, MongoQueryUtils.convertMongoFormInputToRawCommand(form("count", COLLECTION))); + assertInstanceOf(Distinct.class, MongoQueryUtils.convertMongoFormInputToRawCommand(form("distinct", COLLECTION, "key", "k"))); + assertInstanceOf(Aggregate.class, MongoQueryUtils.convertMongoFormInputToRawCommand(form("aggregate", COLLECTION, "arrayPipelines", "[]", "limit", "1"))); + for (String unknown : Arrays.asList("MERGE", "", "RAW")) { + PluginException thrown = invalid(form(unknown, COLLECTION)); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals("INVALID_MONGODB_REQUEST", thrown.getMessageKey()); + assertEquals(unknown, thrown.getArgs()[0]); + } + Map noType = new HashMap<>(); + noType.put("collection", COLLECTION); + assertEquals("INVALID_MONGODB_REQUEST", invalid(noType).getMessageKey(), "a missing type is the empty type"); + } + + @Test + public void rawIsDetectedInAnyCase() { + assertTrue(MongoQueryUtils.isRawCommand(form("RAW", null))); + assertTrue(MongoQueryUtils.isRawCommand(form("raw", null))); + assertFalse(MongoQueryUtils.isRawCommand(form("FIND", null))); + assertFalse(MongoQueryUtils.isRawCommand(new HashMap<>())); + } + + /** + * Pins defect D17 (analysis-plugins section 0.6; plan section 9 D1-D20 row: default-locale toUpperCase, + * {@code MongoQueryUtils.java:90}): under a Turkish default locale the command type {@code insert} (lower case) becomes a + * dotted capital I word and is rejected as unknown. The client sends upper-case types (mongoQuery.tsx:15-22), which a + * Turkish locale does not change, so only an API call with a lower-case type reaches it. A fix ({@code Locale.ROOT}) + * changes this test on purpose. The default locale is global state: restored in finally. + */ + @Test + public void lowerCaseCommandTypeIsRejectedUnderATurkishDefaultLocale_pinsD17() { + Locale saved = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + System.out.println("[MongoCommandDocumentsTest] default locale: " + Locale.getDefault() + ", upper case of insert: " + "insert".toUpperCase()); + PluginException thrown = invalid(form("insert", COLLECTION, "documents", "{}")); + assertEquals("INVALID_MONGODB_REQUEST", thrown.getMessageKey()); + assertInstanceOf(Insert.class, MongoQueryUtils.convertMongoFormInputToRawCommand(form("INSERT", COLLECTION, "documents", "{}")), "the upper-case type the client sends is unaffected"); + } finally { + Locale.setDefault(saved); + } + assertInstanceOf(Insert.class, MongoQueryUtils.convertMongoFormInputToRawCommand(form("insert", COLLECTION, "documents", "{}")), "locale restored: " + Locale.getDefault()); + } + + /** + * Pins defect D12 (analysis-plugins section 0.6; plan section 9 D1-D20 row): the {@code timeout} form field is read into + * {@code timeoutMs} (default 8000) and never reaches a command document: none of them carries {@code maxTimeMS}, so the + * setting has no effect. A fix (adding {@code maxTimeMS}) changes this test on purpose. + */ + @Test + public void timeoutIsReadButNoCommandDocumentCarriesIt_pinsD12() { + Map withTimeout = form("FIND", COLLECTION); + withTimeout.put("timeout", 100); + assertEquals(100, MongoQueryUtils.convertMongoFormInputToRawCommand(withTimeout).getTimeoutMs()); + assertEquals(DEFAULT_TIMEOUT_MS, MongoQueryUtils.convertMongoFormInputToRawCommand(form("FIND", COLLECTION)).getTimeoutMs()); + List> forms = new ArrayList<>(List.of( + form("FIND", COLLECTION), form("INSERT", COLLECTION, "documents", "{}"), form("UPDATE", COLLECTION, "query", "{}", "update", "{}"), + form("DELETE", COLLECTION, "query", "{}"), form("COUNT", COLLECTION), form("DISTINCT", COLLECTION, "key", "k"), + form("AGGREGATE", COLLECTION, "arrayPipelines", "[]", "limit", "1"))); + for (Map form : forms) { + form.put("timeout", 100); + Document document = MongoQueryUtils.convertMongoFormInputToRawCommand(form).parseCommand(); + assertFalse(document.containsKey("maxTimeMS"), form.get("compType") + ": " + document.toJson()); + assertNull(document.get("timeout")); + } + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/model/MongoConnectionUriParserTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/model/MongoConnectionUriParserTest.java new file mode 100644 index 0000000000..e28ad9c043 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/model/MongoConnectionUriParserTest.java @@ -0,0 +1,162 @@ +package org.lowcoder.plugin.mongo.model; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginException; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.PluginCommonError.DATASOURCE_ARGUMENT_ERROR; + +/** + * Unit MG-1 (task L5-8a): {@link MongoConnectionUriParser}: the validity check, the split of a connection string into + * head, credentials, host list, database and options, and the database name taken from it. + * + *

    What consumes the result (MongoPlugin.java): the raw URI is what {@code MongoClients.create} gets in URI mode + * ({@code buildClientUri}, lines 316-331), so the parsed host and credentials are never used to connect; the parts only + * validate the string ({@code validateConfig} 394-410, {@code buildClientUri} 324-327) and the database name comes from here + * ({@code getParsedDatabase}, used at lines 183, 206, 301 and 359). A misread therefore shows as a wrong or missing database + * name, not as a wrong host. + * + *

    Limits: pure string handling, no driver and no server; the client form carries a copy of the same regex + * (mongoDatasourceForm.tsx:53-55), not tested here. + */ +public class MongoConnectionUriParserTest { + + static final String HEAD = "uriHead"; + static final String USER = "username"; + static final String PASSWORD = "password"; + static final String HOST_PORT = "hostPort"; + static final String DB = "dbName"; + static final String TAIL = "uriTail"; + static final String PLAIN_HEAD = "mongodb://"; + static final String SRV_HEAD = "mongodb+srv://"; + + private static Map parts(String uri) { + Map info = MongoConnectionUriParser.extractInfoFromConnectionStringURI(uri); + System.out.println("[MongoConnectionUriParserTest] " + uri + " -> " + (info == null ? null : new java.util.TreeMap<>(info))); + return info; + } + + private static Map expected(String head, String user, String password, String hostPort, String db, String tail) { + Map map = new HashMap<>(); + map.put(HEAD, head); + map.put(USER, user); + map.put(PASSWORD, password); + map.put(HOST_PORT, hostPort); + map.put(DB, db); + map.put(TAIL, tail); + return map; + } + + @Test + public void plainUriWithCredentialsPortDatabaseAndOptionsIsSplitIntoItsParts() { + assertEquals(expected(PLAIN_HEAD, "u", "p", "h:27017", "db", "x=y"), parts("mongodb://u:p@h:27017/db?x=y")); + assertTrue(MongoConnectionUriParser.isValid("mongodb://u:p@h:27017/db?x=y")); + assertEquals("db", MongoConnectionUriParser.parseDatabaseFrom("mongodb://u:p@h:27017/db?x=y")); + } + + @Test + public void srvUriKeepsItsHead() { + assertEquals(expected(SRV_HEAD, "u", "p", "cluster0.example.net", "db", null), parts("mongodb+srv://u:p@cluster0.example.net/db")); + assertEquals(expected(SRV_HEAD, null, null, "cluster0.example.net", "db", "retryWrites=true&w=majority"), parts("mongodb+srv://cluster0.example.net/db?retryWrites=true&w=majority")); + } + + @Test + public void multiHostReplicaSetWithoutCredentialsKeepsTheWholeHostList() { + assertEquals(expected(PLAIN_HEAD, null, null, "h1:27017,h2:27018,h3:27019", "db", "replicaSet=rs0"), + parts("mongodb://h1:27017,h2:27018,h3:27019/db?replicaSet=rs0")); + } + + @Test + public void credentialsAreReturnedAsWrittenNotDecoded() { + assertEquals(expected(PLAIN_HEAD, "us%40er", "p%3Aw%40rd", "h", "db", null), parts("mongodb://us%40er:p%3Aw%40rd@h/db")); + assertEquals(expected(PLAIN_HEAD, "u", "p@ss", "h", "db", null), parts("mongodb://u:p@ss@h/db"), "the greedy credentials group runs to the last @ before the host"); + assertEquals(expected(PLAIN_HEAD, "u", "p/ss", "h", "db", null), parts("mongodb://u:p/ss@h/db"), "a slash in the password stays in the password"); + } + + @Test + public void missingDatabaseGivesNullInTheMapAndAnErrorFromParseDatabase() { + for (String uri : List.of("mongodb://h", "mongodb://h/", "mongodb://h:27017?x=y", "mongodb://h/?x=y")) { + Map info = parts(uri); + assertNull(info.get(DB), uri); + assertTrue(MongoConnectionUriParser.isValid(uri), uri); + PluginException thrown = assertThrows(PluginException.class, () -> MongoConnectionUriParser.parseDatabaseFrom(uri), uri); + assertEquals(DATASOURCE_ARGUMENT_ERROR, thrown.getError()); + assertEquals("MONGODB_DATABASE_EMPTY", thrown.getMessageKey()); + } + assertEquals("x=y", parts("mongodb://h/?x=y").get(TAIL)); + PluginException blank = assertThrows(PluginException.class, () -> MongoConnectionUriParser.parseDatabaseFrom("mongodb://h/ ")); + assertEquals("MONGODB_DATABASE_EMPTY", blank.getMessageKey(), "a blank database name counts as missing"); + } + + @Test + public void emptyOptionsAfterTheQuestionMarkGiveNoTail() { + assertEquals(expected(PLAIN_HEAD, null, null, "h", "db", null), parts("mongodb://h/db?")); + } + + @Test + public void databaseNameRunsUpToTheQuestionMarkSoASlashStaysInIt() { + assertEquals(expected(PLAIN_HEAD, null, null, "h", "db/x", "a=1"), parts("mongodb://h/db/x?a=1")); + assertEquals("db/x", MongoConnectionUriParser.parseDatabaseFrom("mongodb://h/db/x?a=1")); + } + + @Test + public void invalidStringsAreRejectedAndGiveNoParts() { + for (String uri : List.of("", "mongodb://", "http://h/db", "MONGODB://h/db", "mongodb:/h/db", " mongodb://h/db", "mongodb+srv:/h", "h/db", "mongodb://?x=y")) { + assertFalse(MongoConnectionUriParser.isValid(uri), "'" + uri + "'"); + assertNull(MongoConnectionUriParser.extractInfoFromConnectionStringURI(uri), "'" + uri + "'"); + PluginException thrown = assertThrows(PluginException.class, () -> MongoConnectionUriParser.parseDatabaseFrom(uri), uri); + assertEquals(DATASOURCE_ARGUMENT_ERROR, thrown.getError()); + assertEquals("INVALID_MONGODB_URL", thrown.getMessageKey()); + } + } + + /** + * Pins the plan section 9 row "MongoConnectionUriParser reads a `:`...`@` in the query string as credentials: no database (createConnection fails) or a different database than the URI names (queries run against it)" (D-6: fix deferred): the optional credentials group {@code (.+):(.+)@} is greedy and sees the whole string, so a + * {@code :} and a later {@code @} in the options are read as user name and password and the host moves. In + * {@code mongodb://realhost/db?appName=a:b@other} the "user" becomes {@code realhost/db?appName=a}, the "password" {@code b}, + * the "host" {@code other} and the database is lost: {@code parseDatabaseFrom} fails with MONGODB_DATABASE_EMPTY while + * the driver, which gets the raw URI, would connect to realhost/db. When a {@code /} follows the {@code @} the database name + * is the wrong one: {@code mongodb://h/db?appName=a:b@c/other} gives database {@code other}. A fix (credentials matched + * only before the first {@code /} or {@code ?}) changes this test on purpose. + */ + @Test + public void aColonAndAnAtSignInTheOptionsAreReadAsCredentials_pinsTheSection9Row() { + String uri = "mongodb://realhost/db?appName=a:b@other"; + Map info = parts(uri); + assertEquals(expected(PLAIN_HEAD, "realhost/db?appName=a", "b", "other", null, null), info); + assertTrue(MongoConnectionUriParser.isValid(uri), "the string passes the validity check"); + PluginException thrown = assertThrows(PluginException.class, () -> MongoConnectionUriParser.parseDatabaseFrom(uri)); + assertEquals("MONGODB_DATABASE_EMPTY", thrown.getMessageKey(), "the database of realhost/db is lost"); + + String wrongDatabase = "mongodb://h/db?appName=a:b@c/other"; + assertEquals(expected(PLAIN_HEAD, "h/db?appName=a", "b", "c", "other", null), parts(wrongDatabase)); + assertEquals("other", MongoConnectionUriParser.parseDatabaseFrom(wrongDatabase), "the database named in the options is taken instead of db"); + } + + /** + * Observation, no section 9 row: a user name without a password has no {@code :} to match the credentials group, so + * {@code mongodb://user@h/db} is read as host {@code user@h} with no user. The database is still right and the driver gets + * the raw URI, so nothing downstream goes wrong; only the parts are misleading. A fix changes this test on purpose. + */ + @Test + public void aUserNameWithoutPasswordIsReadAsPartOfTheHost() { + assertEquals(expected(PLAIN_HEAD, null, null, "user@h", "db", null), parts("mongodb://user@h/db")); + assertEquals("db", MongoConnectionUriParser.parseDatabaseFrom("mongodb://user@h/db")); + } + + /** Null is not guarded: all three entry points throw a NullPointerException. */ + @Test + public void nullInputThrowsANullPointerException() { + assertThrows(NullPointerException.class, () -> MongoConnectionUriParser.isValid(null)); + assertThrows(NullPointerException.class, () -> MongoConnectionUriParser.extractInfoFromConnectionStringURI(null)); + assertThrows(NullPointerException.class, () -> MongoConnectionUriParser.parseDatabaseFrom(null)); + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfigTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfigTest.java new file mode 100644 index 0000000000..051c767f7a --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfigTest.java @@ -0,0 +1,193 @@ +package org.lowcoder.plugin.mongo.model; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.models.Endpoint; +import org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.BizError.INVALID_DATASOURCE_CONFIG_TYPE; +import static org.lowcoder.sdk.exception.PluginCommonError.DATASOURCE_ARGUMENT_ERROR; + +/** + * Unit MG-2 (task L5-8a): {@link MongoDatasourceConfig}: the merge of an edited config into the stored one, the defaults and + * derived values (port, trimmed user name, endpoints, database), and the encrypt and decrypt hooks. + * + *

    Limits: encrypt and decrypt are checked with a function shaped like the production one ({@code EncryptionServiceImpl} + * returns a null or empty input unchanged); a function that fails on null is not tested because production never passes one. + */ +public class MongoDatasourceConfigTest { + + static final String STORED_URI = "mongodb://u:p@stored-host:27017/stored_db"; + static final String NEW_URI = "mongodb://new-host/new_db"; + static final String STORED_PASSWORD = "stored-secret"; + static final String NEW_PASSWORD = "new-secret"; + static final int DEFAULT_PORT = 27017; + static final int CUSTOM_PORT = 28018; + static final Function ENCRYPT = s -> s == null || s.isEmpty() ? s : "enc(" + s + ")"; + static final Function DECRYPT = s -> s == null || s.isEmpty() ? s : s.substring("enc(".length(), s.length() - 1); + + private static MongoDatasourceConfig.MongoDatasourceConfigBuilder host() { + return MongoDatasourceConfig.builder().host("old-host").port(CUSTOM_PORT).database("old_db").username("old-user").password(STORED_PASSWORD).ssl(true).srvMode(true); + } + + private static MongoDatasourceConfig merged(MongoDatasourceConfig stored, MongoDatasourceConfig update) { + MongoDatasourceConfig result = (MongoDatasourceConfig) stored.mergeWithUpdatedConfig(update); + System.out.println("[MongoDatasourceConfigTest] merged: usingUri " + result.isUsingUri() + ", uri " + result.getUri() + ", host " + result.getHost() + ", port " + result.getPort() + + ", database " + result.getDatabase() + ", user '" + result.getUsername() + "', password " + result.getPassword()); + return result; + } + + // ---- merge + + @Test + public void uriModeKeepsTheStoredUriWhenTheUpdateHasNoneAndTakesTheUpdateOtherwise() { + MongoDatasourceConfig stored = MongoDatasourceConfig.builder().usingUri(true).uri(STORED_URI).build(); + MongoDatasourceConfig kept = merged(stored, MongoDatasourceConfig.builder().usingUri(true).build()); + assertTrue(kept.isUsingUri()); + assertEquals(STORED_URI, kept.getUri()); + MongoDatasourceConfig replaced = merged(stored, MongoDatasourceConfig.builder().usingUri(true).uri(NEW_URI).build()); + assertEquals(NEW_URI, replaced.getUri()); + assertEquals("new_db", replaced.getParsedDatabase()); + } + + @Test + public void uriModeMergeDropsEveryHostModeField() { + MongoDatasourceConfig merged = merged(host().build(), MongoDatasourceConfig.builder().usingUri(true).uri(NEW_URI).host("ignored").database("ignored").username("ignored").password("ignored").ssl(true).build()); + assertTrue(merged.isUsingUri()); + assertNull(merged.getHost()); + assertNull(merged.getDatabase()); + assertEquals("", merged.getUsername()); + assertNull(merged.getPassword()); + assertFalse(merged.isSsl()); + assertFalse(merged.isSrvMode()); + assertEquals(DEFAULT_PORT, merged.getPort()); + assertEquals(List.of(), merged.getEndpoints()); + } + + @Test + public void hostModeKeepsTheStoredPasswordWhenTheUpdateHasNoneAndTakesEverythingElseFromTheUpdate() { + MongoDatasourceConfig update = MongoDatasourceConfig.builder().host("new-host").port(CUSTOM_PORT + 1).database("new_db").username("new-user") + .ssl(false).srvMode(false).authMechanism(MongoAuthMechanism.SCRAM_SHA_256).endpoints(List.of(Endpoint.builder().host("e").port(1L).build())).build(); + MongoDatasourceConfig merged = merged(host().build(), update); + assertFalse(merged.isUsingUri()); + assertEquals(STORED_PASSWORD, merged.getPassword(), "the stored password is kept"); + assertEquals("new-host", merged.getHost()); + assertEquals(CUSTOM_PORT + 1, merged.getPort()); + assertEquals("new_db", merged.getDatabase()); + assertEquals("new-user", merged.getUsername()); + assertFalse(merged.isSsl()); + assertFalse(merged.isSrvMode()); + assertEquals(MongoAuthMechanism.SCRAM_SHA_256, merged.getAuthMechanism()); + assertEquals(1, merged.getEndpoints().size()); + MongoDatasourceConfig withPassword = merged(host().build(), MongoDatasourceConfig.builder().host("h").database("d").password(NEW_PASSWORD).build()); + assertEquals(NEW_PASSWORD, withPassword.getPassword(), "a password in the update replaces the stored one"); + } + + @Test + public void hostModeMergeWithAnEmptyPasswordReplacesTheStoredOne() { + MongoDatasourceConfig merged = merged(host().build(), MongoDatasourceConfig.builder().host("h").database("d").password("").build()); + System.out.println("[MongoDatasourceConfigTest] merge with an empty password: '" + merged.getPassword() + "'"); + assertEquals("", merged.getPassword(), "only null keeps the stored password: an empty string is taken from the update"); + } + + @Test + public void switchingModesDoesNotCarryTheOtherModesFields() { + MongoDatasourceConfig toHost = merged(MongoDatasourceConfig.builder().usingUri(true).uri(STORED_URI).build(), MongoDatasourceConfig.builder().host("h").database("d").build()); + assertFalse(toHost.isUsingUri()); + assertNull(toHost.getUri(), "the stored URI is not kept in host mode"); + assertNull(toHost.getPassword(), "a URI-mode config had no password to keep"); + MongoDatasourceConfig toUri = merged(host().build(), MongoDatasourceConfig.builder().usingUri(true).build()); + assertTrue(toUri.isUsingUri()); + assertNull(toUri.getUri(), "nothing stored to keep and nothing given: the URI stays null"); + } + + @Test + public void foreignConfigTypeIsRejectedWithItsClassName() { + MysqlDatasourceConfig foreign = new MysqlDatasourceConfig("db", "u", "p", "h", 3306L, false, null, false, false, null); + BizException thrown = assertThrows(BizException.class, () -> host().build().mergeWithUpdatedConfig(foreign)); + System.out.println("[MongoDatasourceConfigTest] foreign config: " + thrown.getError() + " " + thrown.getMessageKey() + " " + java.util.Arrays.toString(thrown.getArgs())); + assertEquals(INVALID_DATASOURCE_CONFIG_TYPE, thrown.getError()); + assertEquals("INVALID_DATASOURCE_CONFIG_TYPE", thrown.getMessageKey()); + assertEquals("MysqlDatasourceConfig", thrown.getArgs()[0]); + } + + // ---- defaults and derived values + + @Test + public void portDefaultsTo27017UserNameIsTrimmedAndEndpointsDefaultToEmpty() { + MongoDatasourceConfig empty = MongoDatasourceConfig.builder().build(); + assertEquals(DEFAULT_PORT, empty.getPort()); + assertEquals("", empty.getUsername()); + assertEquals(List.of(), empty.getEndpoints()); + MongoDatasourceConfig set = MongoDatasourceConfig.builder().port(CUSTOM_PORT).username(" bob ").endpoints(List.of(Endpoint.builder().host("h").port(5L).build())).build(); + assertEquals(CUSTOM_PORT, set.getPort()); + assertEquals("bob", set.getUsername()); + assertEquals(1, set.getEndpoints().size()); + } + + @Test + public void parsedDatabaseComesFromTheUriInUriModeAndFromTheFieldInHostMode() { + assertEquals("stored_db", MongoDatasourceConfig.builder().usingUri(true).uri(STORED_URI).database("ignored").build().getParsedDatabase()); + assertEquals("old_db", host().build().getParsedDatabase()); + assertEquals("old_db", host().uri(STORED_URI).build().getParsedDatabase(), "a stored URI is ignored in host mode"); + PluginException noDatabase = assertThrows(PluginException.class, () -> MongoDatasourceConfig.builder().usingUri(true).uri("mongodb://h").build().getParsedDatabase()); + assertEquals(DATASOURCE_ARGUMENT_ERROR, noDatabase.getError()); + assertEquals("MONGODB_DATABASE_EMPTY", noDatabase.getMessageKey()); + PluginException invalid = assertThrows(PluginException.class, () -> MongoDatasourceConfig.builder().usingUri(true).uri("not a uri").build().getParsedDatabase()); + assertEquals("INVALID_MONGODB_URL", invalid.getMessageKey()); + } + + /** + * A URI-mode config with no URI cannot be created through the client: the URI field is required unless an existing + * URI-mode datasource is being edited (mongoDatasourceForm.tsx:45-50), an edit that sends no URI keeps the stored one (the + * merge above), and the server checks for a blank URI before it reads the database (MongoPlugin.java:318-321 and + * 395-397). So the NullPointerException of {@code getParsedDatabase} for a missing URI is reachable only through the + * class API or a hand-made API call; asserted as observed. + */ + @Test + public void parsedDatabaseOfAUriModeConfigWithoutAUriThrowsANullPointerException() { + assertThrows(NullPointerException.class, () -> MongoDatasourceConfig.builder().usingUri(true).build().getParsedDatabase()); + } + + // ---- encrypt and decrypt + + @Test + public void encryptAndDecryptApplyTheFunctionToPasswordAndUriAndReturnTheSameObject() { + MongoDatasourceConfig config = MongoDatasourceConfig.builder().usingUri(true).uri(STORED_URI).password(STORED_PASSWORD).build(); + List seen = new ArrayList<>(); + DatasourceConnectionConfig encrypted = config.doEncrypt(s -> { + seen.add(s); + return ENCRYPT.apply(s); + }); + assertSame(config, encrypted); + assertEquals(List.of(STORED_PASSWORD, STORED_URI), seen, "password first, then the URI"); + assertEquals("enc(" + STORED_PASSWORD + ")", config.getPassword()); + assertEquals("enc(" + STORED_URI + ")", config.getUri()); + assertSame(config, config.doDecrypt(DECRYPT)); + assertEquals(STORED_PASSWORD, config.getPassword()); + assertEquals(STORED_URI, config.getUri()); + } + + @Test + public void nullPasswordAndNullUriStayNullThroughTheProductionStyleFunction() { + MongoDatasourceConfig config = host().password(null).build(); + config.doEncrypt(ENCRYPT); + assertNull(config.getPassword()); + assertNull(config.getUri()); + config.doDecrypt(DECRYPT); + assertNull(config.getPassword()); + assertNull(config.getUri()); + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtilsStructureTest.java b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtilsStructureTest.java new file mode 100644 index 0000000000..10f9736a32 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtilsStructureTest.java @@ -0,0 +1,95 @@ +package org.lowcoder.plugin.mongo.utils; + +import org.bson.Document; +import org.bson.types.Decimal128; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.models.DatasourceStructure; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit MG-5 (task L5-8c), collection structure: {@code MongoQueryUtils.generateTemplatesAndStructureForACollection} turns a + * sample document into the column list the schema browser shows: one column per field with a type label, sorted by name; + * only an ObjectId is marked auto-generated. Also {@code urlEncode}. + * + *

    Limits: the sample is the caller's; how {@code getStructure} picks it from a collection is MG-6. + */ +public class MongoQueryUtilsStructureTest { + + static final String TAG = "[MongoQueryUtilsStructureTest] "; + + private static List columns(Document sample) { + ArrayList columns = new ArrayList<>(); + MongoQueryUtils.generateTemplatesAndStructureForACollection(sample, columns); + columns.forEach(c -> System.out.println(TAG + c.getName() + " " + c.getType() + " autogenerated=" + c.getIsAutogenerated())); + return columns; + } + + @Test + public void everyValueKindGetsItsTypeLabel() { + Document sample = new Document(); + sample.put("a_int", 1); + sample.put("b_long", 2L); + sample.put("c_double", 1.5d); + sample.put("d_decimal", new Decimal128(new BigDecimal("1.25"))); + sample.put("e_string", "x"); + sample.put("f_id", new ObjectId()); + sample.put("g_list", List.of(1, 2)); + sample.put("h_date", new Date()); + sample.put("i_doc", new Document("k", "v")); + sample.put("j_bool", true); + sample.put("k_null", null); + List columns = columns(sample); + assertEquals(List.of("Integer", "Long", "Double", "BigDecimal", "String", "ObjectId", "Array", "Date", "Object", "Object", "Object"), + columns.stream().map(DatasourceStructure.Column::getType).toList()); + assertEquals(List.of("a_int", "b_long", "c_double", "d_decimal", "e_string", "f_id", "g_list", "h_date", "i_doc", "j_bool", "k_null"), + columns.stream().map(DatasourceStructure.Column::getName).toList()); + } + + @Test + public void onlyAnObjectIdIsAutogeneratedAndNoColumnHasADefault() { + Document sample = new Document(); + sample.put("_id", new ObjectId()); + sample.put("name", "n"); + sample.put("n", 1); + for (DatasourceStructure.Column column : columns(sample)) { + assertEquals("_id".equals(column.getName()), column.getIsAutogenerated(), column.getName()); + assertEquals(null, column.getDefaultValue()); + } + } + + @Test + public void columnsAreSortedByNameWhateverTheDocumentOrder() { + Document sample = new Document(); + sample.put("zeta", 1); + sample.put("Alpha", 1); + sample.put("mid", 1); + sample.put("_id", new ObjectId()); + assertEquals(List.of("Alpha", "_id", "mid", "zeta"), columns(sample).stream().map(DatasourceStructure.Column::getName).toList()); + } + + @Test + public void anEmptyDocumentGivesNoColumnsAndExistingColumnsAreKeptAndResorted() { + assertTrue(columns(new Document()).isEmpty()); + ArrayList existing = new ArrayList<>(); + existing.add(new DatasourceStructure.Column("z", "String", null, false)); + MongoQueryUtils.generateTemplatesAndStructureForACollection(new Document("a", 1), existing); + assertEquals(List.of("a", "z"), existing.stream().map(DatasourceStructure.Column::getName).toList()); + assertFalse(existing.get(0).getIsAutogenerated()); + } + + @Test + public void urlEncodeEscapesReservedCharactersAsUtf8() { + assertEquals("p%40ss%3Aw%2Frd+%C3%A9", MongoQueryUtils.urlEncode("p@ss:w/rd é")); + assertEquals("", MongoQueryUtils.urlEncode("")); + assertEquals("plain-_.*", MongoQueryUtils.urlEncode("plain-_.*")); + } +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/config-binding/MongoDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/config-binding/MongoDatasourceConfig.input.json new file mode 100644 index 0000000000..01dfcbf430 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/config-binding/MongoDatasourceConfig.input.json @@ -0,0 +1,37 @@ +{ + "endpoints": { + "usingUri": false, + "srvMode": true, + "ssl": true, + "endpoints": [ + { + "host": " m1.example.com ", + "port": 27017 + }, + { + "host": "m2.example.com", + "port": "27018" + }, + { + "host": "m3.example.com" + } + ], + "host": "m.example.com", + "port": 27019, + "database": "app", + "username": " admin ", + "password": "pw", + "authMechanism": "SCRAM_SHA_256" + }, + "uri": { + "usingUri": "true", + "uri": "mongodb+srv://u:p@cluster.example.com/app?retryWrites=true" + }, + "authMechanismValue": { + "authMechanism": "SCRAM-SHA-256" + }, + "longPort": { + "port": 4294967296 + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/config-binding/MongoDatasourceConfig.json b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/config-binding/MongoDatasourceConfig.json new file mode 100644 index 0000000000..58113dc34b --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/config-binding/MongoDatasourceConfig.json @@ -0,0 +1,134 @@ +{ + "buildFrom" : { + "endpoints" : { + "class" : "org.lowcoder.plugin.mongo.model.MongoDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mongo.model.MongoDatasourceConfig", + "usingUri" : false, + "srvMode" : true, + "ssl" : true, + "uri" : null, + "endpoints" : [ { + "@class" : "org.lowcoder.sdk.models.Endpoint", + "host" : " m1.example.com ", + "port" : 27017 + }, { + "@class" : "org.lowcoder.sdk.models.Endpoint", + "host" : "m2.example.com", + "port" : 27018 + }, { + "@class" : "org.lowcoder.sdk.models.Endpoint", + "host" : "m3.example.com", + "port" : null + } ], + "host" : "m.example.com", + "port" : 27019, + "database" : "app", + "username" : " admin ", + "password" : "pw", + "authMechanism" : "SCRAM_SHA_256" + }, + "classes" : { + "" : "org.lowcoder.plugin.mongo.model.MongoDatasourceConfig", + "/usingUri" : "java.lang.Boolean", + "/srvMode" : "java.lang.Boolean", + "/ssl" : "java.lang.Boolean", + "/uri" : "null", + "/endpoints" : "java.util.ArrayList", + "/endpoints/0" : "org.lowcoder.sdk.models.Endpoint", + "/endpoints/0/host" : "java.lang.String", + "/endpoints/0/port" : "java.lang.Long", + "/endpoints/1" : "org.lowcoder.sdk.models.Endpoint", + "/endpoints/1/host" : "java.lang.String", + "/endpoints/1/port" : "java.lang.Long", + "/endpoints/2" : "org.lowcoder.sdk.models.Endpoint", + "/endpoints/2/host" : "java.lang.String", + "/endpoints/2/port" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Integer", + "/database" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/authMechanism" : "org.lowcoder.plugin.mongo.model.MongoAuthMechanism" + }, + "noView" : {"usingUri":false,"srvMode":true,"ssl":true,"uri":null,"endpoints":[{"host":"m1.example.com","port":27017},{"host":"m2.example.com","port":27018},{"host":"m3.example.com","port":null}],"host":"m.example.com","port":27019,"database":"app","username":"admin","password":"pw","authMechanism":"SCRAM_SHA_256"}, + "Public" : {"usingUri":false,"srvMode":true,"ssl":true,"endpoints":[{"host":"m1.example.com","port":27017},{"host":"m2.example.com","port":27018},{"host":"m3.example.com","port":null}],"host":"m.example.com","port":27019,"database":"app","username":"admin","authMechanism":"SCRAM_SHA_256"}, + "Internal" : {"usingUri":false,"srvMode":true,"ssl":true,"uri":null,"endpoints":[{"host":"m1.example.com","port":27017},{"host":"m2.example.com","port":27018},{"host":"m3.example.com","port":null}],"host":"m.example.com","port":27019,"database":"app","username":"admin","password":"pw","authMechanism":"SCRAM_SHA_256"} + }, + "uri" : { + "class" : "org.lowcoder.plugin.mongo.model.MongoDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mongo.model.MongoDatasourceConfig", + "usingUri" : true, + "srvMode" : false, + "ssl" : false, + "uri" : "mongodb+srv://u:p@cluster.example.com/app?retryWrites=true", + "endpoints" : null, + "host" : null, + "port" : null, + "database" : null, + "username" : null, + "password" : null, + "authMechanism" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mongo.model.MongoDatasourceConfig", + "/usingUri" : "java.lang.Boolean", + "/srvMode" : "java.lang.Boolean", + "/ssl" : "java.lang.Boolean", + "/uri" : "java.lang.String", + "/endpoints" : "null", + "/host" : "null", + "/port" : "null", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/authMechanism" : "null" + }, + "noView" : {"usingUri":true,"srvMode":false,"ssl":false,"uri":"mongodb+srv://u:p@cluster.example.com/app?retryWrites=true","endpoints":[],"host":null,"port":27017,"database":null,"username":"","password":null,"authMechanism":null}, + "Public" : {"usingUri":true,"srvMode":false,"ssl":false,"endpoints":[],"host":null,"port":27017,"database":null,"username":"","authMechanism":null}, + "Internal" : {"usingUri":true,"srvMode":false,"ssl":false,"uri":"mongodb+srv://u:p@cluster.example.com/app?retryWrites=true","endpoints":[],"host":null,"port":27017,"database":null,"username":"","password":null,"authMechanism":null} + }, + "authMechanismValue" : { + "error" : "org.lowcoder.sdk.exception.PluginException: DATASOURCE_ARGUMENT_ERROR INVALID_MONGODB_CONFIG" + }, + "longPort" : { + "error" : "org.lowcoder.sdk.exception.PluginException: DATASOURCE_ARGUMENT_ERROR INVALID_MONGODB_CONFIG" + }, + "empty" : { + "class" : "org.lowcoder.plugin.mongo.model.MongoDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mongo.model.MongoDatasourceConfig", + "usingUri" : false, + "srvMode" : false, + "ssl" : false, + "uri" : null, + "endpoints" : null, + "host" : null, + "port" : null, + "database" : null, + "username" : null, + "password" : null, + "authMechanism" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mongo.model.MongoDatasourceConfig", + "/usingUri" : "java.lang.Boolean", + "/srvMode" : "java.lang.Boolean", + "/ssl" : "java.lang.Boolean", + "/uri" : "null", + "/endpoints" : "null", + "/host" : "null", + "/port" : "null", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/authMechanism" : "null" + }, + "noView" : {"usingUri":false,"srvMode":false,"ssl":false,"uri":null,"endpoints":[],"host":null,"port":27017,"database":null,"username":"","password":null,"authMechanism":null}, + "Public" : {"usingUri":false,"srvMode":false,"ssl":false,"endpoints":[],"host":null,"port":27017,"database":null,"username":"","authMechanism":null}, + "Internal" : {"usingUri":false,"srvMode":false,"ssl":false,"uri":null,"endpoints":[],"host":null,"port":27017,"database":null,"username":"","password":null,"authMechanism":null} + }, + "unknownProperty" : "ignored: binds as form endpoints" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/downstream-render/MongoEngine.specialTypes.txt b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/downstream-render/MongoEngine.specialTypes.txt new file mode 100644 index 0000000000..734b43f97d --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/downstream-render/MongoEngine.specialTypes.txt @@ -0,0 +1,9 @@ +rawObjectId {"find": "items", "filter": {"_id": {"$oid": "65f0c0ffee0000000000abcd"}}} +rawObjectIdSpaced {"find": "items", "filter": {"_id": {"$oid": "65f0c0ffee0000000000abcd"}}} +rawIsoDate {"find": "items", "filter": {"day": {"$date": {"$numberLong": "1709212455123"}}}} +rawIsoDateDoubleQuoted {"find": "items", "filter": {"day": {"$date": {"$numberLong": "1709212455123"}}}} +rawTimestamp {"find": "items", "filter": {"ts": {"$timestamp": {"t": 1709212455, "i": 1}}}} +rawSeveral {"find": "items", "filter": {"_id": {"$oid": "65f0c0ffee0000000000abcd"}, "day": {"$date": {"$numberLong": "1709212455123"}}, "ts": {"$timestamp": {"t": 1709212455, "i": 1}}}} +rawUnquotedArgument {"find": "items", "filter": {"_id": {"$oid": "65f0c0ffee0000000000abcd"}}} +rawQuotedText ERROR org.lowcoder.sdk.exception.PluginException: INVALID_QUERY_SETTINGS INVALID_QUERY_SETTINGS [Failed to parse string as a date: it's \"q\" \\] +guiFind {"find": "items", "filter": {"_id": {"$oid": "65f0c0ffee0000000000abcd"}, "day": {"$lt": {"$date": {"$numberLong": "1709212455123"}}}}, "limit": {"$numberInt": "1"}, "batchSize": {"$numberInt": "1"}} diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/external-json/MongoEngine.results.json b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/external-json/MongoEngine.results.json new file mode 100644 index 0000000000..cd09019a5d --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/external-json/MongoEngine.results.json @@ -0,0 +1,167 @@ +{ + "findAndModify" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/value" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/value/zeta" : "com.fasterxml.jackson.databind.node.TextNode", + "/value/date" : "com.fasterxml.jackson.databind.node.TextNode", + "/value/longMax" : "com.fasterxml.jackson.databind.node.LongNode", + "/value/intMax" : "com.fasterxml.jackson.databind.node.IntNode", + "/value/double" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/value/wholeDouble" : "com.fasterxml.jackson.databind.node.IntNode", + "/value/nested" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/value/nested/ids" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/value/nested/ids/0" : "com.fasterxml.jackson.databind.node.TextNode", + "/value/nested/ids/1" : "com.fasterxml.jackson.databind.node.IntNode", + "/value/nested/ids/2" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/value/nested/ids/2/at" : "com.fasterxml.jackson.databind.node.TextNode", + "/value/int" : "com.fasterxml.jackson.databind.node.IntNode", + "/value/long" : "com.fasterxml.jackson.databind.node.LongNode", + "/value/null" : "com.fasterxml.jackson.databind.node.NullNode", + "/value/alpha" : "com.fasterxml.jackson.databind.node.TextNode", + "/value/true" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/value/unicode" : "com.fasterxml.jackson.databind.node.TextNode", + "/value/_id" : "com.fasterxml.jackson.databind.node.TextNode", + "/value/emptyArray" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/value/emptyObject" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/value/decimal128" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/value/bigDouble" : "com.fasterxml.jackson.databind.node.DoubleNode" + }, + "keyOrder" : { + "/value/nested/ids/2" : [ "at" ], + "/value/nested" : [ "ids" ], + "/value/emptyObject" : [ ], + "/value" : [ "zeta", "date", "longMax", "intMax", "double", "wholeDouble", "nested", "int", "long", "null", "alpha", "true", "unicode", "_id", "emptyArray", "emptyObject", "decimal128", "bigDouble" ], + "" : [ "value" ] + } + }, + "written" : {"queryCode":"OK","data":{"value":{"zeta":"first key","date":"","longMax":9223372036854775807,"intMax":2147483647,"double":1.5,"wholeDouble":2,"nested":{"ids":["65f0c0ffee0000000000abcd",7,{"at":""}]},"int":1,"long":3000000001,"null":null,"alpha":"last key","true":true,"unicode":"žluťoučký kůň 🐎","_id":"65f0c0ffee0000000000abcd","emptyArray":[],"emptyObject":{},"decimal128":1.5,"bigDouble":1.0E20}},"success":true} + }, + "find" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/0" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/zeta" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/date" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/longMax" : "com.fasterxml.jackson.databind.node.LongNode", + "/0/intMax" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/double" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/wholeDouble" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/nested" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/nested/ids" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/0/nested/ids/0" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/nested/ids/1" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/nested/ids/2" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/nested/ids/2/at" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/int" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/long" : "com.fasterxml.jackson.databind.node.LongNode", + "/0/null" : "com.fasterxml.jackson.databind.node.NullNode", + "/0/alpha" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/true" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/0/unicode" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/_id" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/emptyArray" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/0/emptyObject" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/decimal128" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/bigDouble" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/1" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/1/_id" : "com.fasterxml.jackson.databind.node.IntNode" + }, + "keyOrder" : { + "/0/nested/ids/2" : [ "at" ], + "/0/nested" : [ "ids" ], + "/0/emptyObject" : [ ], + "/0" : [ "zeta", "date", "longMax", "intMax", "double", "wholeDouble", "nested", "int", "long", "null", "alpha", "true", "unicode", "_id", "emptyArray", "emptyObject", "decimal128", "bigDouble" ], + "/1" : [ "_id" ] + } + }, + "written" : {"queryCode":"OK","data":[{"zeta":"first key","date":"","longMax":9223372036854775807,"intMax":2147483647,"double":1.5,"wholeDouble":2,"nested":{"ids":["65f0c0ffee0000000000abcd",7,{"at":""}]},"int":1,"long":3000000001,"null":null,"alpha":"last key","true":true,"unicode":"žluťoučký kůň 🐎","_id":"65f0c0ffee0000000000abcd","emptyArray":[],"emptyObject":{},"decimal128":1.5,"bigDouble":1.0E20},{"_id":2}],"success":true} + }, + "findPreEpochDate" : { + "error" : "org.lowcoder.sdk.exception.PluginException: QUERY_EXECUTION_ERROR QUERY_EXECUTION_ERROR" + }, + "insert" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/n" : "com.fasterxml.jackson.databind.node.LongNode" + }, + "keyOrder" : { + "" : [ "n" ] + } + }, + "written" : {"queryCode":"OK","data":{"n":3000000001},"success":true} + }, + "update" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/nModified" : "com.fasterxml.jackson.databind.node.IntNode" + }, + "keyOrder" : { + "" : [ "nModified" ] + } + }, + "written" : {"queryCode":"OK","data":{"nModified":2},"success":true} + }, + "updateWithN" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/n" : "com.fasterxml.jackson.databind.node.IntNode" + }, + "keyOrder" : { + "" : [ "n" ] + } + }, + "written" : {"queryCode":"OK","data":{"n":2},"success":true} + }, + "distinct" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/values" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/values/0" : "com.fasterxml.jackson.databind.node.IntNode", + "/values/1" : "com.fasterxml.jackson.databind.node.TextNode", + "/values/2" : "com.fasterxml.jackson.databind.node.TextNode", + "/values/3" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/values/4" : "com.fasterxml.jackson.databind.node.LongNode", + "/values/5" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/values/6" : "com.fasterxml.jackson.databind.node.TextNode" + }, + "keyOrder" : { + "" : [ "values" ] + } + }, + "written" : {"queryCode":"OK","data":{"values":[1,"two","65f0c0ffee0000000000abcd",2.5,3000000001,10.5,""]},"success":true} + }, + "other" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","success":true} + }, + "notOk" : { + "queryCode" : "MONGO_EXECUTION_ERROR", + "messageKey" : "MONGODB_EXECUTE_ERROR", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"MONGO_EXECUTION_ERROR","success":false} + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/tree-text/MongoEngine.commands.txt b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/tree-text/MongoEngine.commands.txt new file mode 100644 index 0000000000..9a386c5495 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/tree-text/MongoEngine.commands.txt @@ -0,0 +1,5 @@ +raw command={"find":"items","filter":{"price":{"$gte":1.5},"count":3000000001,"name":"kůň \"quoted\" \\","tags":{"$in":["a",2,2.5]},"range":{"$gte":1.5,"$lt":3000000001}},"limit":1.5,"skip":2147483648} {"find": "items", "filter": {"price": {"$gte": {"$numberDouble": "1.5"}}, "count": {"$numberLong": "3000000001"}, "name": "kůň \"quoted\" \\", "tags": {"$in": ["a", {"$numberInt": "2"}, {"$numberDouble": "2.5"}]}, "range": {"$gte": {"$numberDouble": "1.5"}, "$lt": {"$numberLong": "3000000001"}}}, "limit": {"$numberDouble": "1.5"}, "skip": {"$numberLong": "2147483648"}} +rawBigIntegerInMap command={"find":"items","filter":{"n":9223372036854775808}} ERROR org.lowcoder.sdk.exception.PluginException: INVALID_QUERY_SETTINGS INVALID_QUERY_SETTINGS +guiFind projection={"name":1.0} query={"price":1.5,"name":"kůň \"quoted\" \\","range":{"$gte":1.5,"$lt":3000000001}} sort={"price":-1} {"find": "items", "filter": {"price": {"$numberDouble": "1.5"}, "name": "kůň \"quoted\" \\", "range": {"$gte": {"$numberDouble": "1.5"}, "$lt": {"$numberLong": "3000000001"}}}, "sort": {"price": {"$numberInt": "-1"}}, "projection": {"name": {"$numberDouble": "1.0"}}, "limit": {"$numberInt": "10"}, "batchSize": {"$numberInt": "10"}} +guiInsert documents=[{"n":3000000001,"t":["a",2,2.5]}] {"insert": "items", "documents": [{"n": {"$numberLong": "3000000001"}, "t": ["a", {"$numberInt": "2"}, {"$numberDouble": "2.5"}]}]} +rawInvalid command="{\"find\":" ERROR org.lowcoder.sdk.exception.PluginException: INVALID_QUERY_SETTINGS INVALID_QUERY_SETTINGS diff --git a/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/tree-text/MongoQueryUtils.distinct.txt b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/tree-text/MongoQueryUtils.distinct.txt new file mode 100644 index 0000000000..07c84a0f87 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mongoPlugin/src/test/resources/json-contract/tree-text/MongoQueryUtils.distinct.txt @@ -0,0 +1,2 @@ +toString {"values":[1,3000000001,2.5,"žluť \"quoted\"","65f0c0ffee0000000000abcd",10.5,{"nested":[true]}]} +production {"values":[1,3000000001,2.5,"žluť \"quoted\"","65f0c0ffee0000000000abcd",10.5,{"nested":[true]}]} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/pom.xml b/server/api-service/lowcoder-plugins/mssqlPlugin/pom.xml index 790a695bdb..ede6988079 100644 --- a/server/api-service/lowcoder-plugins/mssqlPlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/pom.xml @@ -43,13 +43,11 @@ org.testcontainers testcontainers - 1.18.0 test org.testcontainers - mssqlserver - 1.16.3 + testcontainers-mssqlserver test diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlConfigBindingContractTest.java new file mode 100644 index 0000000000..abb26d4fae --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlConfigBindingContractTest.java @@ -0,0 +1,43 @@ +package org.lowcoder.plugin.mssql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig; +import org.lowcoder.plugin.mssql.model.MssqlQueryConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code mssqlPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1), + * through the real entry points: {@code MssqlDatasourceConfig.buildFrom}, {@code MssqlConnector.resolveConfig} (the + * SDK default, {@code DatasourceConnector.resolveConfig}) and {@code MssqlQueryConfig.from}. {@code ConfigBinding} + * applies each entry point to every form of the input fixture (canonical names, alias or getter-derived names, + * scalars as strings, missing and empty values) and pins the bound state, the written views and the errors in the + * report fixture. + */ +public class MssqlConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlDatasourceConfig.java#MssqlDatasourceConfig.buildFrom#toJson#1", + "lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlDatasourceConfig.java#MssqlDatasourceConfig.buildFrom#fromJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#toJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#fromJson#1"}) + @Test + public void datasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/MssqlDatasourceConfig.input.json", "config-binding/MssqlDatasourceConfig.json", + ConfigBinding.entryPoints("buildFrom", MssqlDatasourceConfig::buildFrom, "MssqlConnector.resolveConfig", new MssqlConnector()::resolveConfig)); + } + + @BoundarySites({ + "lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlQueryConfig.java#MssqlQueryConfig.from#toJson#1", + "lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlQueryConfig.java#MssqlQueryConfig.from#fromJson#1"}) + @Test + public void queryConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/MssqlQueryConfig.input.json", "config-binding/MssqlQueryConfig.json", + Map.of("from", MssqlQueryConfig::from)); + } +} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlConnectorUrlTest.java b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlConnectorUrlTest.java new file mode 100644 index 0000000000..21caca2ad2 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlConnectorUrlTest.java @@ -0,0 +1,109 @@ +package org.lowcoder.plugin.mssql; + +import com.zaxxer.hikari.HikariConfig; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit MS-2 (task L5-5a): the JDBC URL and the Hikari settings {@link MssqlConnector#setUpConfigs} builds for each + * datasource shape, without a server. The URL carries host and port (or the instance name, which drops the port), + * the optional database, user and password, and {@code encrypt} from the SSL flag; read-only goes to Hikari. + * + *

    Limits: the URL is compared as text; the MSSQL driver is not asked to parse it (that is the container unit + * MS-4, not part of this task). + */ +public class MssqlConnectorUrlTest { + + static final String HOST = "db.example.org"; + static final String INSTANCE_HOST = "WINBOX\\SQLEXPRESS01"; + static final long PORT = 1444L; + static final long DEFAULT_PORT = 1433L; + static final String DATABASE = "shop"; + static final String USER = "sa"; + static final String PASSWORD = "secret"; + static final String DRIVER = "com.microsoft.sqlserver.jdbc.SQLServerDriver"; + static final String PREFIX = "jdbc:sqlserver://"; + + private final MssqlConnector connector = new MssqlConnector(); + + private HikariConfig configured(String host, Long port, String database, String username, String password, boolean ssl, boolean readonly) { + MssqlDatasourceConfig datasource = new MssqlDatasourceConfig(database, username, password, host, port, ssl, null, readonly, false, null); + HikariConfig config = new HikariConfig(); + connector.setUpConfigs(datasource, config); + System.out.println("[MssqlConnectorUrlTest] url " + config.getJdbcUrl() + ", user " + config.getUsername() + ", read-only " + config.isReadOnly()); + return config; + } + + @Test + public void urlHasHostPortDatabaseCredentialsAndEncryptOff() { + HikariConfig config = configured(HOST, PORT, DATABASE, USER, PASSWORD, false, false); + assertEquals(PREFIX + HOST + ":" + PORT + ";databaseName=" + DATABASE + ";user=" + USER + ";password=" + PASSWORD + ";encrypt=false;", config.getJdbcUrl()); + assertEquals(USER, config.getUsername()); + assertEquals(PASSWORD, config.getPassword()); + assertEquals(DRIVER, connector.getJdbcDriver()); + assertFalse(config.isReadOnly()); + } + + @Test + public void missingPortUsesTheDefault1433() { + assertEquals(PREFIX + HOST + ":" + DEFAULT_PORT + ";encrypt=false;", configured(HOST, null, "", "", null, false, false).getJdbcUrl()); + } + + @Test + public void instanceNameInTheHostDropsThePort() { + HikariConfig config = configured(INSTANCE_HOST, PORT, DATABASE, USER, PASSWORD, false, false); + assertEquals(PREFIX + INSTANCE_HOST + ";databaseName=" + DATABASE + ";user=" + USER + ";password=" + PASSWORD + ";encrypt=false;", config.getJdbcUrl()); + assertFalse(config.getJdbcUrl().contains(String.valueOf(PORT)), "the port must not appear next to an instance name"); + } + + @Test + public void blankDatabaseUserAndPasswordAreLeftOutOfTheUrl() { + HikariConfig blank = configured(HOST, PORT, " ", " ", " ", false, false); + assertEquals(PREFIX + HOST + ":" + PORT + ";encrypt=false;", blank.getJdbcUrl(), "blank database, user and password are not in the URL"); + assertNull(blank.getUsername(), "a blank user trims to empty and is not set"); + assertEquals(" ", blank.getPassword(), "a blank, not empty, password is still set on Hikari (only the URL checks blank)"); + HikariConfig none = configured(HOST, PORT, null, null, null, false, false); + assertEquals(PREFIX + HOST + ":" + PORT + ";encrypt=false;", none.getJdbcUrl()); + assertNull(none.getUsername()); + assertNull(none.getPassword()); + } + + @Test + public void sslFlagBecomesEncryptAndReadonlyGoesToHikari() { + HikariConfig on = configured(HOST, PORT, DATABASE, USER, PASSWORD, true, true); + assertTrue(on.getJdbcUrl().endsWith(";encrypt=true;"), on.getJdbcUrl()); + assertTrue(on.isReadOnly()); + HikariConfig off = configured(HOST, PORT, DATABASE, USER, PASSWORD, false, false); + assertTrue(off.getJdbcUrl().endsWith(";encrypt=false;"), off.getJdbcUrl()); + assertFalse(off.isReadOnly()); + } + + /** + * Pins defect D4 (analysis-plugins section 0.6; plan section 9 row D1-D20): the password (and user) are appended raw + * into the JDBC URL, so a password containing {@code ;} adds connection properties of its own. Here the password + * {@code p;encrypt=false} turns the single {@code encrypt} property of an SSL data source into two, the injected + * {@code false} before the real {@code true}. A fix (wrapping the value in braces with {@code }} doubled) changes this + * test on purpose. The Hikari password stays the raw text either way. + */ + @Test + public void passwordWithSemicolonInjectsConnectionProperties_pinsD4() { + String injected = "p;encrypt=false"; + HikariConfig config = configured(HOST, PORT, DATABASE, USER, injected, true, false); + assertEquals(PREFIX + HOST + ":" + PORT + ";databaseName=" + DATABASE + ";user=" + USER + ";password=" + injected + ";encrypt=true;", config.getJdbcUrl()); + List properties = Arrays.asList(config.getJdbcUrl().substring((PREFIX + HOST + ":" + PORT + ";").length()).split(";")); + System.out.println("[MssqlConnectorUrlTest] D4 properties after the host: " + properties); + assertEquals(List.of("databaseName=" + DATABASE, "user=" + USER, "password=p", "encrypt=false", "encrypt=true"), properties, + "the password text is read as two properties, so encrypt appears twice"); + assertEquals(injected, config.getPassword()); + String braces = "a}b;c"; + assertTrue(configured(HOST, PORT, DATABASE, USER, braces, false, false).getJdbcUrl().contains(";password=" + braces + ";"), "a closing brace is not doubled either"); + } +} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlContainerSupport.java b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlContainerSupport.java new file mode 100644 index 0000000000..169f4fadec --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlContainerSupport.java @@ -0,0 +1,170 @@ +package org.lowcoder.plugin.mssql; + +import org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig; +import org.lowcoder.sdk.contract.ContainerImages; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The shared SQL Server 2022 container of the L5-5b tests (heavy-container tag: one per test JVM, started on first + * use, removed by Testcontainers' reaper when the JVM exits; the port is the one Docker assigns) and the helpers around + * it. A generic container, not the module's MSSQLServerContainer: the module classes need commons-lang3 3.18 and this + * module's classpath resolves 3.13.0 (see log-L5.md). The EULA is accepted through {@code ACCEPT_EULA=Y}. No Docker + * guard on purpose (owner decision D-7). Not a test. + */ +final class MssqlContainerSupport { + + static final String DATABASE = "app"; + static final String USER = "sa"; + static final String PASSWORD = "Str0ng!Passw0rd"; + static final int MSSQL_PORT = 1433; + private static final String READY_PATTERN = ".*SQL Server is now ready for client connections.*"; + private static final Duration START_TIMEOUT = Duration.ofMinutes(3); + private static final int CONNECT_ATTEMPTS = 60; + private static final long CONNECT_PAUSE_MILLIS = 1000; + + private static final GenericContainer MSSQL = new GenericContainer<>(DockerImageName.parse(ContainerImages.MSSQL_2022)) + .withEnv("ACCEPT_EULA", "Y").withEnv("MSSQL_SA_PASSWORD", PASSWORD) + .withExposedPorts(MSSQL_PORT) + .waitingFor(Wait.forLogMessage(READY_PATTERN, 1).withStartupTimeout(START_TIMEOUT)); + + static { + long start = System.nanoTime(); + MSSQL.start(); + System.out.println("[MssqlContainerSupport] " + ContainerImages.MSSQL_2022 + " started in " + + (System.nanoTime() - start) / 1_000_000 + " ms on " + MSSQL.getHost() + ":" + MSSQL.getMappedPort(MSSQL_PORT)); + createDatabase(); + } + + static final MssqlConnector CONNECTOR = new MssqlConnector(); + static final MssqlQueryExecutor EXECUTOR = new MssqlQueryExecutor(); + + private MssqlContainerSupport() { + } + + static String host() { + return MSSQL.getHost(); + } + + static int port() { + return MSSQL.getMappedPort(MSSQL_PORT); + } + + static MssqlDatasourceConfig config(String user, String password, boolean ssl, boolean readonly) { + return new MssqlDatasourceConfig(DATABASE, user, password, host(), (long) port(), ssl, null, readonly, false, null); + } + + static MssqlDatasourceConfig config() { + return config(USER, PASSWORD, false, false); + } + + static HikariPerfWrapper connect(MssqlDatasourceConfig config) { + return CONNECTOR.createConnection(config).block(); + } + + static void destroy(HikariPerfWrapper wrapper) { + CONNECTOR.destroyConnection(wrapper).block(); + } + + /** Runs a query config through the executor as the server does: build the context, execute, return the data. */ + static Object run(HikariPerfWrapper wrapper, MssqlDatasourceConfig config, Map queryConfig, Map params) { + SqlBasedQueryExecutionContext context = EXECUTOR.buildQueryExecutionContext(config, queryConfig, params, null); + return EXECUTOR.executeQuery(wrapper, context).block().getData(); + } + + static Object sql(HikariPerfWrapper wrapper, MssqlDatasourceConfig config, String sql, Map params) { + return run(wrapper, config, sqlConfig(sql), params); + } + + static Map sqlConfig(String sql) { + Map config = new LinkedHashMap<>(); + config.put("mode", "SQL"); + config.put("sql", sql); + config.put("disablePreparedStatement", false); + return config; + } + + static Map guiConfig(String type, Map command) { + Map config = new LinkedHashMap<>(); + config.put("mode", "GUI"); + config.put("commandType", type); + config.put("command", command); + return config; + } + + /** A plain JDBC connection of the administrator {@code sa} to {@code database} (not the code under test). */ + static Connection jdbc(String database) { + try { + return DriverManager.getConnection("jdbc:sqlserver://" + host() + ":" + port() + ";databaseName=" + database + + ";user=" + USER + ";password=" + PASSWORD + ";encrypt=false;"); + } catch (SQLException e) { + throw new IllegalStateException("cannot connect", e); + } + } + + static Connection jdbc() { + return jdbc(DATABASE); + } + + private static void createDatabase() { + SQLException last = null; + for (int attempt = 1; attempt <= CONNECT_ATTEMPTS; attempt++) { + try (Connection connection = jdbc("master")) { + execute(connection, "if db_id('" + DATABASE + "') is null create database " + DATABASE); + System.out.println("[MssqlContainerSupport] database " + DATABASE + " ready after " + attempt + " attempt(s)"); + return; + } catch (IllegalStateException | SQLException e) { + last = e instanceof SQLException sql ? sql : e.getCause() instanceof SQLException cause ? cause : null; + try { + Thread.sleep(CONNECT_PAUSE_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for the server", interrupted); + } + } + } + throw new IllegalStateException("the server did not accept the administrator connection", last); + } + + static void execute(Connection connection, String... statements) { + try (Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } catch (SQLException e) { + throw new IllegalStateException("statement failed", e); + } + } + + static List> rows(Connection connection, String sql) { + List> rows = new ArrayList<>(); + try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) { + ResultSetMetaData metaData = resultSet.getMetaData(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= metaData.getColumnCount(); i++) { + row.put(metaData.getColumnLabel(i), resultSet.getObject(i)); + } + rows.add(row); + } + } catch (SQLException e) { + throw new IllegalStateException("read failed: " + sql, e); + } + return rows; + } +} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlDatabaseTest.java b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlDatabaseTest.java new file mode 100644 index 0000000000..86110fed92 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlDatabaseTest.java @@ -0,0 +1,396 @@ +package org.lowcoder.plugin.mssql; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.DatasourceStructure.Column; +import org.lowcoder.sdk.models.DatasourceStructure.Table; +import org.lowcoder.sdk.models.DatasourceStructure.TableType; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.CONNECTOR; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.EXECUTOR; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.PASSWORD; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.USER; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.config; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.connect; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.destroy; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.execute; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.guiConfig; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.jdbc; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.rows; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.run; +import static org.lowcoder.plugin.mssql.MssqlContainerSupport.sql; + +/** + * Unit MS-4 (task L5-5b): {@code MssqlConnector}, {@code MssqlQueryExecutor}, {@code MssqlStructureParser}, + * {@code MssqlResultParser} and the GUI commands against a real SQL Server 2022 (the image pinned in + * {@code ContainerImages.MSSQL_2022}, the module's {@code mssql-jdbc 10.2.1.jre11} driver), started once per JVM by + * {@link MssqlContainerSupport}. Heavy-container tag: the default build does not run it (run command in log-L5.md). + * Each test uses its own tables or schemas in database {@code app}. The self-signed certificate of the image is not + * trusted, so a pool with usingSsl fails; {@code readOnly} is not enforced by the driver (see + * {@link #readonlyFlagDoesNotStopAWriteBecauseTheDriverIgnoresIt}). + */ +@Tag("heavy-container") +public class MssqlDatabaseTest { + + static final String WRONG_PASSWORD = "not-the-password"; + static final int MSSQL_POOL_SIZE = 50; + static final String DRIVER = "com.microsoft.sqlserver.jdbc.SQLServerDriver"; + static final String INJECTION = "x'; drop table t_gui; --"; + static final String D4_USER = "d4user"; + static final String D4_PASSWORD = "Str0ng;Pass}word1"; + static final String SEMICOLON_USER = "semiuser"; + static final String SEMICOLON_PASSWORD = "Str0ng;Pass1"; + static final long INIT_TIMEOUT_MILLIS = 10_000; + + private static Map keyValues(Object... columnsAndValues) { + List> comp = new ArrayList<>(); + for (int i = 0; i < columnsAndValues.length; i += 2) { + comp.add(Map.of("column", columnsAndValues[i], "value", columnsAndValues[i + 1])); + } + return Map.of("compType", "KEY_VALUE_PAIRS", "comp", comp); + } + + private static Map filterOn(String table, String column, boolean allowMultiModify) { + Map command = new HashMap<>(); + command.put("table", table); + command.put("allowMultiModify", allowMultiModify); + command.put("filterBy", List.of(Map.of("column", column, "condition", "=", "value", "{{key}}"))); + return command; + } + + private Object gui(String type, Map command, Map params) { + MssqlDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + return run(pool, config, guiConfig(type, command), params); + } finally { + destroy(pool); + } + } + + private static Object cell(Object data, String column) { + @SuppressWarnings("unchecked") + List> rows = (List>) data; + return rows.get(0).get(column); + } + + private static List column(Connection jdbc, String sql, String column) { + return rows(jdbc, sql).stream().map(row -> row.get(column)).toList(); + } + + private static void createLogin(Connection jdbc, String login, String password) { + execute(jdbc, "if exists (select 1 from sys.database_principals where name = '" + login + "') drop user " + login, + "if exists (select 1 from sys.server_principals where name = '" + login + "') drop login " + login); + execute(jdbc, "create login " + login + " with password = '" + password + "', check_policy = off", + "create user " + login + " for login " + login, "alter role db_owner add member " + login); + } + + // ---- connector + + @Test + public void poolOpensRunsQueriesAndTestConnectionSucceeds() { + MssqlDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + HikariDataSource dataSource = (HikariDataSource) pool.getHikariDataSource(); + assertTrue(dataSource.isRunning()); + assertEquals(DRIVER, dataSource.getDriverClassName()); + assertEquals(MSSQL_POOL_SIZE, dataSource.getMaximumPoolSize()); + assertEquals("jdbc:sqlserver://" + MssqlContainerSupport.host() + ":" + MssqlContainerSupport.port() + ";databaseName=app;user=" + USER + + ";password=" + PASSWORD + ";encrypt=false;", dataSource.getJdbcUrl()); + assertEquals(1, cell(sql(pool, config, "select 1 as one", Map.of()), "one")); + assertEquals(42, cell(sql(pool, config, "select {{a}} + 1 as r", Map.of("a", 41)), "r"), "a bound parameter reaches the server"); + DatasourceTestResult test = CONNECTOR.testConnection(config).block(); + assertNotNull(test); + assertTrue(test.isSuccess()); + System.out.println("[MssqlDatabaseTest] pool url (password included, see D4): " + dataSource.getJdbcUrl() + ", test connection succeeded"); + } finally { + destroy(pool); + } + } + + @Test + public void wrongPasswordIsReportedWithinTheInitTimeout() { + long start = System.nanoTime(); + RuntimeException thrown = assertThrows(RuntimeException.class, () -> connect(config(USER, WRONG_PASSWORD, false, false))); + long millis = (System.nanoTime() - start) / 1_000_000; + System.out.println("[MssqlDatabaseTest] wrong password: " + thrown.getClass().getSimpleName() + " after " + millis + " ms: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("Login failed for user 'sa'"), thrown.getMessage()); + assertTrue(millis < INIT_TIMEOUT_MILLIS, "took " + millis + " ms"); + } + + private void createD4Logins() { + try (Connection admin = jdbc()) { + createLogin(admin, D4_USER, D4_PASSWORD); + createLogin(admin, SEMICOLON_USER, SEMICOLON_PASSWORD); + assertEquals(1, rows(admin, "select 1 as one from sys.server_principals where name = '" + D4_USER + "'").size(), "the login exists"); + } catch (java.sql.SQLException e) { + throw new IllegalStateException(e); + } + } + + /** + * Pins defect D4 (analysis-plugins section 0.6; plan section 9 D1-D20 row) as the real driver shows it: the password is + * appended raw into the JDBC URL, so a password with {@code ;} and {@code }} that the server accepts for a login makes + * the pool fail before any connection: the driver does not accept the URL. A password with only a {@code ;} fails the + * same way. A fix (braces in the URL) changes this test on purpose. + */ + @Test + public void passwordWithSemicolonAndBraceCannotConnect_pinsD4() { + createD4Logins(); + RuntimeException braces = assertThrows(RuntimeException.class, () -> connect(config(D4_USER, D4_PASSWORD, false, false))); + System.out.println("[MssqlDatabaseTest] D4 password with ; and }: " + braces.getClass().getSimpleName() + ": " + braces.getMessage()); + assertTrue(braces.getMessage().contains("claims to not accept jdbcUrl"), braces.getMessage()); + RuntimeException semicolon = assertThrows(RuntimeException.class, () -> connect(config(SEMICOLON_USER, SEMICOLON_PASSWORD, false, false))); + System.out.println("[MssqlDatabaseTest] D4 password with ; only: " + semicolon.getClass().getSimpleName() + ": " + semicolon.getMessage()); + assertTrue(semicolon.getMessage().contains("claims to not accept jdbcUrl"), semicolon.getMessage()); + } + + /** + * Pins the plan section 9 row "secret in error text" (D-6: fix deferred): the pool failure of the previous test carries + * the URL with the password masked only up to its first {@code ;}, so everything after it is in the exception message + * (here {@code Pass}word1} and {@code Pass1}). A fix (braces quoting, so the URL is accepted, or masking the whole + * password) changes this test on purpose. + */ + @Test + public void failureTextContainsThePasswordTailAfterTheFirstSemicolon_pinsTheSecretInErrorTextRow() { + createD4Logins(); + for (String[] login : new String[][] {{D4_USER, D4_PASSWORD}, {SEMICOLON_USER, SEMICOLON_PASSWORD}}) { + String tail = login[1].substring(login[1].indexOf(';') + 1); + RuntimeException thrown = assertThrows(RuntimeException.class, () -> connect(config(login[0], login[1], false, false))); + System.out.println("[MssqlDatabaseTest] password tail '" + tail + "' in the failure text: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("password=;" + tail + ";"), "the tail after the first ';' is in the message: " + thrown.getMessage()); + assertTrue(!thrown.getMessage().contains(login[1]), "the part before the ';' is masked: " + thrown.getMessage()); + } + } + + @Test + public void sslAgainstTheSelfSignedCertificateFailsAndSslOffWorks() { + RuntimeException thrown = assertThrows(RuntimeException.class, () -> connect(config(USER, PASSWORD, true, false))); + System.out.println("[MssqlDatabaseTest] ssl against the self-signed certificate: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("could not establish a secure connection"), thrown.getMessage()); + assertTrue(thrown.getMessage().contains("PKIX path building failed"), thrown.getMessage()); + destroy(connect(config(USER, PASSWORD, false, false))); + } + + /** + * Shows where the read-only switch stops: the connector hands {@code readOnly} to Hikari (asserted in + * {@code MssqlConnectorUrlTest}), but the SQL Server driver treats {@code Connection.setReadOnly} as a hint and sends + * nothing to the server, so an insert through a read-only pool succeeds and the row is stored. Pins the plan section 9 row + * "read-only not enforced" (D-6: fix deferred): the driver ignores the hint, the connector does set it, and even an + * {@code applicationIntent=ReadOnly} property does not make this server refuse the write. + */ + @Test + public void readonlyFlagDoesNotStopAWriteBecauseTheDriverIgnoresIt() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table if exists dbo.t_readonly", "create table dbo.t_readonly (id int)"); + MssqlDatasourceConfig readonly = config(USER, PASSWORD, false, true); + HikariPerfWrapper pool = connect(readonly); + try { + assertTrue(((HikariDataSource) pool.getHikariDataSource()).isReadOnly(), "the connector set read-only on the pool"); + Object result = sql(pool, readonly, "insert into dbo.t_readonly values (1)", Map.of()); + System.out.println("[MssqlDatabaseTest] insert through a read-only pool: " + result); + assertEquals(1, ((Map) result).get("affectedRows")); + } finally { + destroy(pool); + } + assertEquals(List.of(1), column(jdbc, "select id from dbo.t_readonly", "id"), "the write reached the table"); + } + } + + // ---- structure + + private static Table table(DatasourceStructure structure, String fullName) { + return structure.getTables().stream().filter(t -> t.getName().equals(fullName)).findFirst().orElseThrow(); + } + + @Test + public void structureListsTablesAndViewsWithSchemaNamesAndOrdinalColumnOrder() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop view if exists s_a.v_alpha", "drop table if exists s_a.alpha", "drop table if exists s_b.alpha", + "drop schema if exists s_a", "drop schema if exists s_b"); + execute(jdbc, "create schema s_a", "create schema s_b"); + execute(jdbc, "create table s_a.alpha (zeta int not null primary key, alpha nvarchar(20) null, mid datetime2 null, ident int identity(1,1))", + "create table s_b.alpha (id int)", "create view s_a.v_alpha as select zeta from s_a.alpha"); + } + MssqlDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + DatasourceStructure structure = EXECUTOR.getStructure(pool, config).block(); + List names = structure.getTables().stream().map(Table::getName).toList(); + System.out.println("[MssqlDatabaseTest] tables: " + names); + assertTrue(names.containsAll(List.of("s_a.alpha", "s_a.v_alpha", "s_b.alpha")), names.toString()); + assertTrue(names.stream().noneMatch(n -> n.startsWith("sys.") || n.startsWith("INFORMATION_SCHEMA.")), "system schemas are not listed: " + names); + assertEquals(names.stream().sorted().toList(), names, "the tables come ordered by their schema.table name"); + assertEquals(1, names.stream().filter("s_b.alpha"::equals).count(), "one entry per table"); + + Table alpha = table(structure, "s_a.alpha"); + assertEquals(TableType.TABLE, alpha.getType()); + assertEquals("s_a", alpha.getSchema()); + assertEquals(List.of("zeta", "alpha", "mid", "ident"), alpha.getColumns().stream().map(Column::getName).toList(), "columns in ordinal order, not name order"); + assertEquals(List.of("int", "nvarchar", "datetime2", "int"), alpha.getColumns().stream().map(Column::getType).toList()); + assertEquals(List.of(false, false, false, false), alpha.getColumns().stream().map(Column::getIsAutogenerated).toList(), "the parser never marks a column as generated, the identity column included"); + assertEquals(List.of(), alpha.getKeys(), "keys are not read: the primary key of zeta is not listed"); + Table view = table(structure, "s_a.v_alpha"); + assertEquals(TableType.TABLE, view.getType(), "a view is listed as a TABLE"); + assertEquals(List.of("zeta"), view.getColumns().stream().map(Column::getName).toList()); + assertEquals(List.of("id"), table(structure, "s_b.alpha").getColumns().stream().map(Column::getName).toList()); + } finally { + destroy(pool); + } + } + + // ---- results + + @Test + public void realDriverReturnsWhatTheResultParserAssumes() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table if exists dbo.t_types"); + execute(jdbc, "create table dbo.t_types (c_datetime datetime, c_datetime2 datetime2, c_small smalldatetime, c_date date, c_time time, c_dto datetimeoffset, " + + "c_guid uniqueidentifier, c_money money, c_bit bit, c_nv nvarchar(20), c_bin varbinary(4), c_dec decimal(10,2), c_big bigint)"); + execute(jdbc, "insert into dbo.t_types values ('2024-02-29 13:14:15.123', '2024-02-29 13:14:15.1234567', '2024-02-29 13:15:00', '2024-02-29', '13:14:15.123', " + + "'2024-02-29 13:14:15.123 +05:30', '123e4567-e89b-12d3-a456-426614174000', 10.5, 1, N'žluťoučký', 0x0001FF, 12345678.12, 9223372036854775807)"); + execute(jdbc, "insert into dbo.t_types (c_nv) values (null)"); + + List typeNames = new ArrayList<>(); + try (Statement statement = jdbc.createStatement(); ResultSet resultSet = statement.executeQuery("select * from dbo.t_types")) { + ResultSetMetaData metaData = resultSet.getMetaData(); + for (int i = 1; i <= metaData.getColumnCount(); i++) { + typeNames.add(metaData.getColumnTypeName(i)); + } + } + System.out.println("[MssqlDatabaseTest] driver type names: " + typeNames); + assertEquals(List.of("datetime", "datetime2", "smalldatetime", "date", "time", "datetimeoffset", "uniqueidentifier", "money", "bit", + "nvarchar", "varbinary", "decimal", "bigint"), typeNames, "the driver reports lower-case type names, so the case-sensitive match of D16 does not bite for this driver"); + + MssqlDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + @SuppressWarnings("unchecked") + List> data = (List>) sql(pool, config, "select * from dbo.t_types", Map.of()); + System.out.println("[MssqlDatabaseTest] parsed rows: " + data); + Map first = data.get(0); + assertEquals("2024-02-29 13:14:15", first.get("c_datetime")); + assertEquals("2024-02-29 13:14:15", first.get("c_datetime2"), "the fraction is dropped"); + assertEquals("2024-02-29 13:15:00", first.get("c_small")); + assertEquals("2024-02-29", first.get("c_date")); + assertEquals("13:14:15.1230000", first.get("c_time")); + assertEquals("2024-02-29T13:14:15.123+05:30", first.get("c_dto")); + assertEquals("123E4567-E89B-12D3-A456-426614174000", first.get("c_guid"), "the driver gives the guid as upper-case text"); + assertEquals(new BigDecimal("10.5000"), first.get("c_money")); + assertEquals(true, first.get("c_bit")); + assertEquals("žluťoučký", first.get("c_nv")); + assertArrayEquals(new byte[] {0, 1, (byte) 0xFF}, (byte[]) first.get("c_bin")); + assertEquals(new BigDecimal("12345678.12"), first.get("c_dec")); + assertEquals(Long.MAX_VALUE, first.get("c_big")); + Map second = data.get(1); + assertEquals(13, second.size()); + second.forEach((name, value) -> assertNull(value, name)); + assertEquals(new ArrayList<>(first.keySet()), new ArrayList<>(second.keySet()), "column order is the same on every row"); + } finally { + destroy(pool); + } + } + } + + /** + * Pins the plan section 9 row "timestamp (rowversion) columns cannot be read" (D-6: fix deferred): {@code MssqlResultParser} + * formats the type name {@code timestamp} with {@code getTimestamp}, but on SQL Server {@code timestamp} is the binary + * row version, which the driver refuses to convert. So {@code select *} from a table with a rowversion column fails the + * whole query. A fix (reading {@code timestamp} as bytes) changes this test on purpose. + */ + @Test + public void tableWithARowversionColumnCannotBeQueried_pinsTheRowversionRow() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table if exists dbo.t_rowversion", "create table dbo.t_rowversion (id int, rv rowversion)", "insert into dbo.t_rowversion (id) values (1)"); + MssqlDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + assertEquals(1, cell(sql(pool, config, "select id from dbo.t_rowversion", Map.of()), "id"), "without the rowversion column the table reads fine"); + PluginException thrown = assertThrows(PluginException.class, () -> sql(pool, config, "select * from dbo.t_rowversion", Map.of())); + System.out.println("[MssqlDatabaseTest] select * with a rowversion column: " + thrown.getError() + " " + thrown.getMessage()); + assertEquals(QUERY_EXECUTION_ERROR, thrown.getError()); + assertTrue(thrown.getMessage().contains("The conversion from timestamp to TIMESTAMP is unsupported"), thrown.getMessage()); + } finally { + destroy(pool); + } + } + } + + // ---- GUI commands + + @Test + public void guiCommandsChangeTheRealTableAndAnInjectionValueIsStoredAsText() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table if exists dbo.t_gui", "create table dbo.t_gui (id int primary key, name nvarchar(100))"); + Object inserted = gui("insert", Map.of("table", "dbo.t_gui", "changeSet", keyValues("id", "{{id}}", "name", "{{name}}")), Map.of("id", 1, "name", INJECTION)); + System.out.println("[MssqlDatabaseTest] insert: " + inserted); + assertEquals(1, ((Map) inserted).get("affectedRows")); + assertEquals(List.of(Map.of("id", 1, "name", INJECTION)), rows(jdbc, "select id, name from dbo.t_gui"), "the value is stored as text, the table is intact"); + + Map updateDetail = filterOn("dbo.t_gui", "id", true); + updateDetail.put("changeSet", keyValues("name", "{{name}}")); + gui("UPDATE", updateDetail, Map.of("key", 1, "name", "o'brien")); + assertEquals(List.of("o'brien"), column(jdbc, "select name from dbo.t_gui where id = 1", "name")); + + gui("BULK_INSERT", Map.of("table", "dbo.t_gui", "records", "[{\"id\":2,\"name\":\"b\"},{\"id\":3,\"name\":\"c\"}]"), Map.of()); + assertEquals(List.of("o'brien", "b", "c"), column(jdbc, "select name from dbo.t_gui order by id", "name")); + gui("BULK_UPDATE", Map.of("table", "dbo.t_gui", "primaryKey", "id", "records", "[{\"id\":2,\"name\":\"B\"},{\"id\":3,\"name\":\"C\"}]"), Map.of()); + assertEquals(List.of("o'brien", "B", "C"), column(jdbc, "select name from dbo.t_gui order by id", "name")); + + gui("DELETE", filterOn("dbo.t_gui", "id", false), Map.of("key", 3)); + assertEquals(List.of(1, 2), column(jdbc, "select id from dbo.t_gui order by id", "id")); + } + } + + @Test + public void topOneChangesAndDeletesExactlyOneOfThreeMatchingRows() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table if exists dbo.t_top", "create table dbo.t_top (id int, grp int, marker nvarchar(10))", + "insert into dbo.t_top values (1, 1, 'orig'), (2, 1, 'orig'), (3, 1, 'orig'), (4, 2, 'orig')"); + + Map update = filterOn("dbo.t_top", "grp", false); + update.put("changeSet", keyValues("marker", "changed")); + Object updated = gui("UPDATE", update, Map.of("key", 1)); + System.out.println("[MssqlDatabaseTest] update top (1): " + updated); + assertEquals(1, ((Map) updated).get("affectedRows")); + assertEquals(1, rows(jdbc, "select * from dbo.t_top where marker = 'changed'").size(), "exactly one row changed, the server accepts the double space"); + + Object deleted = gui("DELETE", filterOn("dbo.t_top", "grp", false), Map.of("key", 1)); + assertEquals(1, ((Map) deleted).get("affectedRows")); + assertEquals(3, rows(jdbc, "select * from dbo.t_top").size(), "exactly one of the three matching rows is gone"); + + Object all = gui("DELETE", filterOn("dbo.t_top", "grp", true), Map.of("key", 1)); + assertEquals(2, ((Map) all).get("affectedRows")); + assertEquals(List.of(4), column(jdbc, "select id from dbo.t_top", "id"), "the row of the other group stays"); + + Map multiUpdate = filterOn("dbo.t_top", "grp", true); + multiUpdate.put("changeSet", keyValues("marker", "all")); + assertEquals(1, ((Map) gui("UPDATE", multiUpdate, Map.of("key", 2))).get("affectedRows")); + assertEquals(List.of("all"), column(jdbc, "select marker from dbo.t_top", "marker")); + } + } +} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlFakeConnections.java b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlFakeConnections.java new file mode 100644 index 0000000000..65b4fbaeaf --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlFakeConnections.java @@ -0,0 +1,67 @@ +package org.lowcoder.plugin.mssql; + +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.Column; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; + +/** + * Test support for the MSSQL result and executor units (task L5-5a): a {@link FakeJdbc} result set that also answers + * {@code getTimestamp(int)} (which {@code MssqlResultParser} calls for the datetime types and {@code FakeJdbc} does not + * implement), and the never-started pool wrapper the executor runs on (the same shape as + * {@code PostgresResultContractTest.wrap}). + * + *

    Limits: public only because the parser test lives in another package; a {@code getTimestamp} cell must be a {@link Timestamp}; any other call goes to {@code FakeJdbc} unchanged. + */ +public final class MssqlFakeConnections { + + private MssqlFakeConnections() { + } + + /** A {@code FakeJdbc} result set of {@code rows} that also reads timestamp cells through {@code getTimestamp}. */ + public static ResultSet resultSet(List columns, List> rows) { + ResultSet delegate = FakeJdbc.resultSet(columns, rows); + return (ResultSet) Proxy.newProxyInstance(MssqlFakeConnections.class.getClassLoader(), new Class[] {ResultSet.class}, (self, method, args) -> { + if (method.getName().equals("getTimestamp") && args.length == 1) { + return (Timestamp) delegate.getObject((int) args[0]); + } + try { + return method.invoke(delegate, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }); + } + + /** A data source that is never started: it reports running with an idle pool and hands out {@code connection}. */ + public static HikariPerfWrapper wrap(Connection connection) { + HikariPoolMXBean pool = (HikariPoolMXBean) Proxy.newProxyInstance(HikariPoolMXBean.class.getClassLoader(), + new Class[] {HikariPoolMXBean.class}, (self, method, args) -> 0); + HikariDataSource dataSource = new HikariDataSource() { + @Override + public Connection getConnection() throws SQLException { + return connection; + } + + @Override + public HikariPoolMXBean getHikariPoolMXBean() { + return pool; + } + + @Override + public boolean isRunning() { + return true; + } + }; + return HikariPerfWrapper.wrap(dataSource, () -> 0, () -> 0, () -> 0, () -> 0, null, null); + } +} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlQueryExecutorTest.java b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlQueryExecutorTest.java new file mode 100644 index 0000000000..1937701ce9 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/MssqlQueryExecutorTest.java @@ -0,0 +1,133 @@ +package org.lowcoder.plugin.mssql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mssql.gui.MssqlBulkInsertCommand; +import org.lowcoder.plugin.mssql.gui.MssqlBulkUpdateCommand; +import org.lowcoder.plugin.mssql.gui.MssqlDeleteCommand; +import org.lowcoder.plugin.mssql.gui.MssqlInsertCommand; +import org.lowcoder.plugin.mssql.gui.MssqlUpdateCommand; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.Column; +import org.lowcoder.sdk.contract.FakeJdbc.FailingCell; +import org.lowcoder.sdk.contract.FakeJdbc.Rows; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand; + +import java.sql.Timestamp; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_ARGUMENT_ERROR; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; + +/** + * Unit MS-3 (task L5-5a), the executor half: {@link MssqlQueryExecutor} on a {@link FakeJdbc} connection (through the + * never-started pool of {@link MssqlFakeConnections#wrap}): the anonymous {@code parseDataRows} turns every row of the + * result set into a column-ordered map through the MSSQL parser, and {@code parseSqlCommand} picks the GUI command class + * by type name. + */ +public class MssqlQueryExecutorTest { + + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final String QUERY = "select * from items"; + static final Map KEY_VALUES = Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", "a", "value", "1"))); + static final List> FILTER = List.of(Map.of("column", "id", "condition", "=", "value", "1")); + static final Map> DETAILS = Map.of( + "insert", Map.of("table", "t", "changeSet", KEY_VALUES), + "update", Map.of("table", "t", "changeSet", KEY_VALUES, "filterBy", FILTER), + "delete", Map.of("table", "t", "filterBy", FILTER), + "bulk_insert", Map.of("table", "t", "records", "[{\"a\":1}]"), + "bulk_update", Map.of("table", "t", "primaryKey", "a", "records", "[{\"a\":1}]")); + static final Map> TYPES = Map.of( + "insert", MssqlInsertCommand.class, "update", MssqlUpdateCommand.class, "delete", MssqlDeleteCommand.class, + "bulk_insert", MssqlBulkInsertCommand.class, "bulk_update", MssqlBulkUpdateCommand.class); + + private final MssqlQueryExecutor executor = new MssqlQueryExecutor(); + + private QueryExecutionResult run(List columns, List> rows) { + SqlBasedQueryExecutionContext context = SqlBasedQueryExecutionContext.builder().query(QUERY).requestParams(Map.of()).build(); + QueryExecutionResult result = executor.executeQuery(MssqlFakeConnections.wrap( + FakeJdbc.connection(List.of(new Rows(MssqlFakeConnections.resultSet(columns, rows))))), context).block(TIMEOUT); + System.out.println("[MssqlQueryExecutorTest] " + columns + " -> " + (result == null ? null : result.getData())); + return result; + } + + @Test + public void everyRowBecomesAColumnOrderedMapThroughTheMssqlParser() { + List columns = List.of(new Column("id", "int"), new Column("created", "datetime2"), new Column("note", "nvarchar")); + QueryExecutionResult result = run(columns, List.of( + new ArrayList<>(java.util.Arrays.asList(1, Timestamp.valueOf("2024-02-29 13:14:15.5"), "žluť")), + new ArrayList<>(java.util.Arrays.asList(2, null, null)))); + assertTrue(result.isSuccess()); + List data = assertInstanceOf(List.class, result.getData()); + assertEquals(2, data.size()); + Map first = assertInstanceOf(Map.class, data.get(0)); + assertEquals(List.of("id", "created", "note"), new ArrayList<>(first.keySet())); + assertEquals(1, first.get("id")); + assertEquals("2024-02-29 13:14:15", first.get("created")); + assertEquals("žluť", first.get("note")); + Map second = assertInstanceOf(Map.class, data.get(1)); + assertEquals(2, second.get("id")); + assertEquals(null, second.get("created")); + assertEquals(null, second.get("note")); + } + + @Test + public void emptyResultIsAnEmptySuccessfulList() { + QueryExecutionResult result = run(List.of(new Column("id", "int")), List.of()); + assertTrue(result.isSuccess()); + assertEquals(List.of(), result.getData()); + } + + @Test + public void aFailingCellFailsTheQueryWithAnExecutionError() { + PluginException thrown = assertThrows(PluginException.class, () -> run(List.of(new Column("d", "date")), + List.of(List.of(new FailingCell("cannot read the value", "text"))))); + System.out.println("[MssqlQueryExecutorTest] failing cell: " + thrown.getError() + " " + thrown.getMessageKey()); + assertEquals(QUERY_EXECUTION_ERROR, thrown.getError()); + } + + @Test + public void parseSqlCommandMapsEachGuiTypeInAnyCase() { + TYPES.forEach((type, commandClass) -> { + assertInstanceOf(commandClass, executor.parseSqlCommand(type, DETAILS.get(type)), type); + assertInstanceOf(commandClass, executor.parseSqlCommand(type.toUpperCase(Locale.ROOT), DETAILS.get(type)), type.toUpperCase(Locale.ROOT)); + }); + } + + @Test + public void unknownGuiTypeIsRejectedWithItsName() { + PluginException thrown = assertThrows(PluginException.class, () -> executor.parseSqlCommand("merge", DETAILS.get("insert"))); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals("INVALID_GUI_COMMAND_TYPE", thrown.getMessageKey()); + assertEquals("merge", thrown.getArgs()[0]); + } + + /** + * Pins defect D17 (analysis-plugins section 0.6; plan section 9 row D1-D20: default-locale toUpperCase): under a + * Turkish default locale "insert" becomes a dotted capital I word and falls into the error branch. A fix + * ({@code Locale.ROOT}) changes this test on purpose. The default locale is global state: restored in finally. + */ + @Test + public void guiTypeInsertFailsUnderATurkishDefaultLocale_pinsD17() { + Locale saved = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + System.out.println("[MssqlQueryExecutorTest] default locale: " + Locale.getDefault() + ", upper case of insert: " + "insert".toUpperCase()); + PluginException thrown = assertThrows(PluginException.class, () -> executor.parseSqlCommand("insert", DETAILS.get("insert"))); + assertEquals("INVALID_GUI_COMMAND_TYPE", thrown.getMessageKey()); + } finally { + Locale.setDefault(saved); + } + assertInstanceOf(MssqlInsertCommand.class, executor.parseSqlCommand("insert", DETAILS.get("insert")), "locale restored: " + Locale.getDefault()); + } +} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/gui/MssqlGuiCommandRenderTest.java b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/gui/MssqlGuiCommandRenderTest.java new file mode 100644 index 0000000000..ecef436591 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/gui/MssqlGuiCommandRenderTest.java @@ -0,0 +1,203 @@ +package org.lowcoder.plugin.mssql.gui; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand.GuiSqlCommandRenderResult; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.sdk.exception.PluginCommonError.INVALID_GUI_SETTINGS; +import static org.lowcoder.sdk.exception.PluginCommonError.INVALID_INSERT_COMMAND; +import static org.lowcoder.sdk.exception.PluginCommonError.INVALID_UPDATE_COMMAND; + +/** + * Unit MS-1 (task L5-5a): the SQL text and the bind parameters of the five MSSQL GUI commands, rendered from the same + * command-detail maps the executor receives ({@code from(Map)}). Pins the MSSQL specifics: {@code top (1)} for a + * delete or update that does not allow multi modify (and no limit clause), square-bracket column delimiters, values + * bound and never inlined (also for a value that looks like an injection). + * + *

    Limits: the table name is rendered as given (never bracketed) and no escaping of a {@code ]} inside a column name + * is asserted: the analysis expected one, the render does none (see the L5-5a log, findings). The update render with + * {@code top (1)} has two spaces after {@code update} today; that is asserted as the current rendering, no defect is + * claimed. + */ +public class MssqlGuiCommandRenderTest { + + static final String TABLE = "dbo.items"; + static final String INJECTION = "'; drop table t; --"; + static final String KEY_TABLE = "table"; + static final String KEY_FILTER = "filterBy"; + static final String KEY_CHANGE_SET = "changeSet"; + static final String KEY_MULTI = "allowMultiModify"; + static final String KEY_RECORDS = "records"; + static final String KEY_PRIMARY = "primaryKey"; + static final Map ID_FILTER = Map.of("column", "id", "condition", "=", "value", "{{id}}"); + static final Map NAME_SET = Map.of("compType", "KEY_VALUE_PAIRS", + "comp", List.of(Map.of("column", "name", "value", "{{name}}"), Map.of("column", "qty", "value", "3"))); + + private static GuiSqlCommandRenderResult print(String label, GuiSqlCommandRenderResult result) { + System.out.println("[MssqlGuiCommandRenderTest] " + label + " -> " + result.sql().replace("\n", "\\n") + " " + result.bindParams()); + return result; + } + + private static Map detail(Object... keyValues) { + Map map = new java.util.LinkedHashMap<>(); + map.put(KEY_TABLE, TABLE); + for (int i = 0; i < keyValues.length; i += 2) { + map.put((String) keyValues[i], keyValues[i + 1]); + } + return map; + } + + @Test + public void deleteHasTopOneUnlessMultiModifyAndBindsTheFilterValue() { + GuiSqlCommand single = MssqlDeleteCommand.from(detail(KEY_FILTER, List.of(ID_FILTER))); + GuiSqlCommandRenderResult one = print("delete single", single.render(Map.of("id", 7))); + assertEquals("delete top (1) from dbo.items where [id] = ? ", one.sql()); + assertEquals(List.of(7), one.bindParams()); + + GuiSqlCommand multi = MssqlDeleteCommand.from(detail(KEY_FILTER, List.of(ID_FILTER), KEY_MULTI, true)); + GuiSqlCommandRenderResult many = print("delete multi", multi.render(Map.of("id", 7))); + assertEquals("delete from dbo.items where [id] = ? ", many.sql()); + assertEquals(List.of(7), many.bindParams()); + } + + @Test + public void deleteWithoutFilterHasNoWhereAndNoBinds() { + GuiSqlCommandRenderResult single = print("delete no filter single", MssqlDeleteCommand.from(detail(KEY_FILTER, List.of())).render(Map.of())); + assertEquals("delete top (1) from dbo.items", single.sql()); + assertEquals(List.of(), single.bindParams()); + GuiSqlCommandRenderResult multi = print("delete no filter multi", MssqlDeleteCommand.from(detail(KEY_FILTER, List.of(), KEY_MULTI, true)).render(Map.of())); + assertEquals("delete from dbo.items", multi.sql()); + } + + @Test + public void deleteBindsAnInjectionLookingValueInsteadOfInliningIt() { + GuiSqlCommandRenderResult result = print("delete injection", MssqlDeleteCommand.from(detail(KEY_FILTER, List.of( + Map.of("column", "name", "condition", "=", "value", "{{name}}")))).render(Map.of("name", INJECTION))); + assertEquals("delete top (1) from dbo.items where [name] = ? ", result.sql()); + assertEquals(List.of(INJECTION), result.bindParams()); + } + + @Test + public void deleteFilterOperatorsRenderAsTheSharedFilterSetDoes() { + GuiSqlCommandRenderResult result = print("delete operators", MssqlDeleteCommand.from(detail(KEY_FILTER, List.of( + Map.of("column", "a", "condition", "!=", "value", "{{a}}"), + Map.of("column", "b", "condition", "is", "value", "null"), + Map.of("column", "c", "condition", "in", "value", "{{ids}}")), KEY_MULTI, true)) + .render(Map.of("a", 1, "ids", List.of(1, 2)))); + assertEquals("delete from dbo.items where [a] != ? and [b] IS null and [c] IN (1,2)", result.sql()); + assertEquals(List.of(1), result.bindParams()); + PluginException invalid = assertThrows(PluginException.class, () -> MssqlDeleteCommand.from(detail(KEY_FILTER, List.of( + Map.of("column", "a", "condition", "like", "value", "x")))).render(Map.of())); + assertEquals(INVALID_GUI_SETTINGS, invalid.getError()); + assertEquals("GUI_INVALID_FILTER_FIELD", invalid.getMessageKey()); + } + + @Test + public void updateHasTopOneWithTheCurrentDoubleSpaceUnlessMultiModify() { + Map params = Map.of("id", 7, "name", "bob"); + GuiSqlCommandRenderResult one = print("update single", MssqlUpdateCommand.from( + detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of(ID_FILTER))).render(params)); + assertEquals("update top (1) dbo.items set [name]=?,[qty]=? where [id] = ? ", one.sql(), "two spaces after update: today's rendering"); + assertEquals(List.of("bob", 3, 7), one.bindParams()); + + GuiSqlCommandRenderResult many = print("update multi", MssqlUpdateCommand.from( + detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of(ID_FILTER), KEY_MULTI, true)).render(params)); + assertEquals("update dbo.items set [name]=?,[qty]=? where [id] = ? ", many.sql()); + assertEquals(List.of("bob", 3, 7), many.bindParams()); + } + + @Test + public void updateWithoutFilterHasNoWhereAndNoLimit() { + GuiSqlCommandRenderResult result = print("update no filter", MssqlUpdateCommand.from( + detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of())).render(Map.of("name", "bob"))); + assertEquals("update top (1) dbo.items set [name]=?,[qty]=?", result.sql()); + assertEquals(List.of("bob", 3), result.bindParams()); + } + + @Test + public void updateBindsAnInjectionLookingValue() { + GuiSqlCommandRenderResult result = print("update injection", MssqlUpdateCommand.from( + detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of(ID_FILTER), KEY_MULTI, true)).render(Map.of("id", 1, "name", INJECTION))); + assertEquals("update dbo.items set [name]=?,[qty]=? where [id] = ? ", result.sql()); + assertEquals(List.of(INJECTION, 3, 1), result.bindParams()); + } + + @Test + public void insertRendersBracketedColumnsAndBoundValues() { + GuiSqlCommandRenderResult result = print("insert", MssqlInsertCommand.from(detail(KEY_CHANGE_SET, NAME_SET)).render(Map.of("name", INJECTION))); + assertEquals("insert into dbo.items ([name],[qty]) values (?,?)", result.sql()); + assertEquals(List.of(INJECTION, 3), result.bindParams()); + } + + @Test + public void aChangeSetWithoutColumnsIsRejectedWhileParsingTheCommand() { + Map noColumns = Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of()); + for (String type : List.of("insert", "update")) { + Map command = detail(KEY_CHANGE_SET, noColumns, KEY_FILTER, List.of(ID_FILTER)); + PluginException thrown = assertThrows(PluginException.class, () -> { + GuiSqlCommand parsed = type.equals("insert") ? MssqlInsertCommand.from(command) : MssqlUpdateCommand.from(command); + print(type + " without columns", parsed.render(Map.of("id", 1))); + }); + System.out.println("[MssqlGuiCommandRenderTest] " + type + " without columns: " + thrown.getError() + " " + thrown.getMessageKey()); + assertEquals(type.equals("insert") ? INVALID_INSERT_COMMAND : INVALID_UPDATE_COMMAND, thrown.getError()); + assertEquals(type.equals("insert") ? "INSERT_DATA_EMPTY" : "UPDATE_DATA_EMPTY", thrown.getMessageKey()); + } + } + + @Test + public void bulkInsertRendersOneRowOfPlaceholdersPerRecord() { + GuiSqlCommandRenderResult result = print("bulk insert", MssqlBulkInsertCommand.from(detail( + KEY_RECORDS, "[{\"name\":\"{{n1}}\",\"qty\":1},{\"name\":\"b\",\"qty\":2}]")).render(Map.of("n1", INJECTION))); + assertEquals("insert into dbo.items ([name],[qty]) values (?,?),(?,?)", result.sql()); + assertEquals(List.of(INJECTION, 1, "b", 2), result.bindParams()); + } + + @Test + public void bulkUpdateRendersCaseWhenPerColumnAndTheKeysInTheWhere() { + GuiSqlCommandRenderResult result = print("bulk update", MssqlBulkUpdateCommand.from(detail(KEY_PRIMARY, "id", + KEY_RECORDS, "[{\"id\":1,\"name\":\"a\"},{\"id\":2,\"name\":\"b\"}]")).render(Map.of())); + assertEquals("UPDATE dbo.items set\n[name] = CASE WHEN [id] = ? THEN ? WHEN [id] = ? THEN ? ELSE [name] END\nwhere id in (?,?)", result.sql()); + assertEquals(List.of(1, "a", 2, "b", 1, 2), result.bindParams()); + PluginException missingKey = assertThrows(PluginException.class, () -> MssqlBulkUpdateCommand.from(detail(KEY_PRIMARY, "id", + KEY_RECORDS, "[{\"name\":\"a\"}]")).render(Map.of())); + assertEquals("BULK_UPDATE_DATA_NOT_CONTAIN_PRIMARY_KEY", missingKey.getMessageKey()); + } + + @Test + public void mustacheKeysAreExtractedFromFilterAndChangeSet() { + assertEquals(Set.of("{{id}}"), MssqlDeleteCommand.from(detail(KEY_FILTER, List.of(ID_FILTER))).extractMustacheKeys()); + assertEquals(Set.of("{{id}}", "{{name}}"), MssqlUpdateCommand.from(detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of(ID_FILTER))).extractMustacheKeys()); + assertEquals(false, MssqlDeleteCommand.from(detail(KEY_FILTER, List.of())).isInsertCommand()); + assertEquals(true, MssqlInsertCommand.from(detail(KEY_CHANGE_SET, NAME_SET)).isInsertCommand()); + } + + /** + * Pins the plan section 9 row "GUI SQL commands render identifiers unescaped" (SQL injection; D-6: fix deferred; the + * render is in the shared sdk classes). The table name is put into the SQL as given (never bracketed), a {@code ]} inside + * a column name is not doubled, so a column name can close the bracket and continue with SQL of its own. A fix (bracket + * and escape the identifiers) changes these assertions on purpose. + */ + @Test + public void identifiersAreRenderedUnescaped_pinsTheSection9Row() { + String table = "t]; drop table x; --"; + Map deleteDetail = detail(KEY_FILTER, List.of( + Map.of("column", "a]=1 or [b", "condition", "=", "value", "1")), KEY_MULTI, true); + deleteDetail.put(KEY_TABLE, table); + GuiSqlCommandRenderResult delete = print("delete raw table and column breakout", MssqlDeleteCommand.from(deleteDetail).render(Map.of())); + assertEquals("delete from t]; drop table x; -- where [a]=1 or [b] = ? ", delete.sql(), "the table is raw and the column closes its bracket"); + assertEquals(List.of(1), delete.bindParams()); + + Map insertDetail = detail(KEY_CHANGE_SET, Map.of("compType", "KEY_VALUE_PAIRS", + "comp", List.of(Map.of("column", "a]b", "value", "1")))); + GuiSqlCommandRenderResult insert = print("insert column with a closing bracket", MssqlInsertCommand.from(insertDetail).render(Map.of())); + assertEquals("insert into dbo.items ([a]b]) values (?)", insert.sql(), "the closing bracket of the column name is not doubled"); + assertEquals(List.of(1), insert.bindParams()); + } +} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/util/MssqlResultParserTest.java b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/util/MssqlResultParserTest.java new file mode 100644 index 0000000000..b9616e360b --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/java/org/lowcoder/plugin/mssql/util/MssqlResultParserTest.java @@ -0,0 +1,148 @@ +package org.lowcoder.plugin.mssql.util; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mssql.MssqlFakeConnections; +import org.lowcoder.sdk.contract.FakeJdbc.Column; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit MS-3 (task L5-5a), the parser half: {@link MssqlResultParser#parseRowValue} on a {@code FakeJdbc} row typed by + * the SQL Server type names the driver reports. {@code date} and {@code time} become text, the datetime family is + * formatted with {@code yyyy-MM-dd HH:mm:ss} (no fraction), {@code datetimeoffset} becomes an ISO date-time with its + * offset, a null cell stays null, every other type reaches the result as {@code getObject} gave it. + * + *

    Limits: the cells are what the MSSQL driver is assumed to return for these types, built by hand (the real driver is + * the container unit MS-4); {@code FakeJdbc} has no {@code getTimestamp}, so the test-side wrapper + * {@code MssqlFakeConnections.resultSet} supplies it. + */ +public class MssqlResultParserTest { + + static final Timestamp TIMESTAMP = Timestamp.valueOf("2024-02-29 13:14:15.123"); + static final String TIMESTAMP_TEXT = "2024-02-29 13:14:15"; + static final OffsetDateTime OFFSET = OffsetDateTime.parse("2024-02-29T13:14:15.123+05:30"); + static final String OFFSET_TEXT = "2024-02-29T13:14:15.123+05:30"; + static final UUID GUID = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + + private static Map parse(Map cells, Map typeNames) throws SQLException { + List columns = new ArrayList<>(); + cells.keySet().forEach(name -> columns.add(new Column(name, typeNames.get(name)))); + ResultSet resultSet = MssqlFakeConnections.resultSet(columns, List.of(new ArrayList<>(cells.values()))); + resultSet.next(); + ResultSetMetaData metaData = resultSet.getMetaData(); + Map row = MssqlResultParser.parseRowValue(resultSet, metaData, metaData.getColumnCount()); + System.out.println("[MssqlResultParserTest] " + typeNames + " -> " + render(row)); + return row; + } + + private static String render(Map row) { + Map shown = new LinkedHashMap<>(); + row.forEach((key, value) -> shown.put(key, value == null ? "null" : value.getClass().getSimpleName() + ":" + (value instanceof byte[] bytes ? Arrays.toString(bytes) : value))); + return shown.toString(); + } + + private static Map row(Object... namesAndCells) { + Map cells = new LinkedHashMap<>(); + for (int i = 0; i < namesAndCells.length; i += 2) { + cells.put((String) namesAndCells[i], namesAndCells[i + 1]); + } + return cells; + } + + private static Map types(Map cells, String... typeNames) { + Map types = new LinkedHashMap<>(); + int i = 0; + for (String name : cells.keySet()) { + types.put(name, typeNames[i++]); + } + return types; + } + + @Test + public void dateAndTimeBecomeTextInAnyCase() throws SQLException { + Map cells = row("d", java.sql.Date.valueOf("2024-02-29"), "t", Time.valueOf("13:14:15"), "D", java.sql.Date.valueOf("2024-02-29"), "T", Time.valueOf("13:14:15")); + Map parsed = parse(cells, types(cells, "date", "time", "DATE", "TIME")); + assertEquals(Arrays.asList("2024-02-29", "13:14:15", "2024-02-29", "13:14:15"), new ArrayList<>(parsed.values())); + } + + @Test + public void datetimeFamilyIsFormattedWithoutFraction() throws SQLException { + Map cells = row("a", TIMESTAMP, "b", TIMESTAMP, "c", TIMESTAMP, "d", TIMESTAMP); + Map parsed = parse(cells, types(cells, "datetime", "datetime2", "smalldatetime", "timestamp")); + assertEquals(Arrays.asList(TIMESTAMP_TEXT, TIMESTAMP_TEXT, TIMESTAMP_TEXT, TIMESTAMP_TEXT), new ArrayList<>(parsed.values())); + } + + /** + * Pins defect D16 (analysis-plugins section 0.6; plan section 9 row D1-D20): the datetime family is matched with a + * case-sensitive {@code Set.contains} while {@code date}, {@code time} and {@code datetimeoffset} use + * {@code equalsIgnoreCase}, so a type name in upper case skips the formatting and the raw {@link Timestamp} is + * returned. A fix (a case-insensitive match) changes this test on purpose. + */ + @Test + public void upperCaseDatetimeTypeNameSkipsTheFormatting_pinsD16() throws SQLException { + Map cells = row("a", TIMESTAMP, "b", TIMESTAMP, "c", TIMESTAMP, "d", TIMESTAMP); + Map parsed = parse(cells, types(cells, "DATETIME", "DATETIME2", "SMALLDATETIME", "TIMESTAMP")); + parsed.forEach((name, value) -> { + assertInstanceOf(Timestamp.class, value, name + ": the raw Timestamp object comes back, not " + TIMESTAMP_TEXT); + assertEquals(TIMESTAMP, value, name); + }); + } + + @Test + public void datetimeoffsetIsAnIsoDateTimeWithItsOffsetInAnyCase() throws SQLException { + Map cells = row("a", OFFSET, "b", OFFSET); + Map parsed = parse(cells, types(cells, "datetimeoffset", "DATETIMEOFFSET")); + assertEquals(Arrays.asList(OFFSET_TEXT, OFFSET_TEXT), new ArrayList<>(parsed.values())); + } + + @Test + public void nullCellStaysNullForEveryTypeBranch() throws SQLException { + Map cells = row("a", null, "b", null, "c", null, "d", null, "e", null); + Map parsed = parse(cells, types(cells, "date", "datetime2", "datetimeoffset", "time", "nvarchar")); + assertEquals(5, parsed.size()); + parsed.forEach((name, value) -> assertNull(value, name)); + } + + @Test + public void otherTypesPassThroughAsTheDriverReturnsThemAndColumnsKeepTheirOrder() throws SQLException { + byte[] bytes = {0, 1, (byte) 0xFF}; + Map cells = row("z_text", "žluťoučký", "a_int", 5, "m_big", Long.MAX_VALUE, "k_money", new BigDecimal("10.5000"), + "g_guid", GUID.toString(), "f_bit", true, "b_bin", bytes, "d_unknown", "x"); + Map parsed = parse(cells, types(cells, "nvarchar", "int", "bigint", "money", "uniqueidentifier", "bit", "varbinary", "geography")); + assertEquals(List.of("z_text", "a_int", "m_big", "k_money", "g_guid", "f_bit", "b_bin", "d_unknown"), new ArrayList<>(parsed.keySet())); + assertEquals("žluťoučký", parsed.get("z_text")); + assertEquals(5, parsed.get("a_int")); + assertEquals(Long.MAX_VALUE, parsed.get("m_big")); + assertEquals(new BigDecimal("10.5000"), parsed.get("k_money")); + assertEquals(GUID.toString(), parsed.get("g_guid")); + assertEquals(true, parsed.get("f_bit")); + assertArrayEquals(bytes, (byte[]) parsed.get("b_bin")); + assertEquals("x", parsed.get("d_unknown"), "an unknown type name is passed through"); + } + + @Test + public void aFailingColumnReadIsAnSqlExceptionNotSwallowed() { + Map cells = row("d", new org.lowcoder.sdk.contract.FakeJdbc.FailingCell("cannot read the value", "text")); + SQLException thrown = assertThrows(SQLException.class, () -> parse(cells, types(cells, "date"))); + System.out.println("[MssqlResultParserTest] failing cell: " + thrown.getMessage()); + assertEquals("cannot read the value", thrown.getMessage()); + } +} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlDatasourceConfig.input.json new file mode 100644 index 0000000000..ed7351f2e6 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlDatasourceConfig.input.json @@ -0,0 +1,41 @@ +{ + "canonical": { + "database": " lowcoder ", + "username": " root ", + "password": "s3crét \"quoted\"", + "host": " db.example.com ", + "port": 1433, + "usingSsl": true, + "serverTimezone": "Europe/Bratislava", + "isReadonly": true, + "enableTurnOffPreparedStatement": true, + "extParams": { + "connectTimeout": 5000, + "allowPublicKeyRetrieval": "true", + "useCursorFetch": false, + "nested": { + "list": [ + 1, + 2.5, + null + ] + } + } + }, + "scalarsAsStrings": { + "host": "db.example.com", + "port": "1434", + "usingSsl": "true", + "isReadonly": "false", + "enableTurnOffPreparedStatement": "true" + }, + "getterNames": { + "host": "db.example.com", + "readonly": true, + "port": 1435.0 + }, + "minimal": { + "host": "db.example.com" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlDatasourceConfig.json b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlDatasourceConfig.json new file mode 100644 index 0000000000..baf7f9dc0b --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlDatasourceConfig.json @@ -0,0 +1,358 @@ +{ + "buildFrom" : { + "canonical" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "database" : " lowcoder ", + "username" : " root ", + "password" : "s3crét \"quoted\"", + "host" : " db.example.com ", + "port" : 1433, + "usingSsl" : true, + "serverTimezone" : "Europe/Bratislava", + "isReadonly" : true, + "enableTurnOffPreparedStatement" : true, + "extParams" : { + "connectTimeout" : 5000, + "allowPublicKeyRetrieval" : "true", + "useCursorFetch" : false, + "nested" : { + "list" : [ 1, 2.5, null ] + } + } + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "/database" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "java.lang.String", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "java.util.LinkedHashMap", + "/extParams/connectTimeout" : "java.lang.Integer", + "/extParams/allowPublicKeyRetrieval" : "java.lang.String", + "/extParams/useCursorFetch" : "java.lang.Boolean", + "/extParams/nested" : "java.util.LinkedHashMap", + "/extParams/nested/list" : "java.util.ArrayList", + "/extParams/nested/list/0" : "java.lang.Integer", + "/extParams/nested/list/1" : "java.lang.Double", + "/extParams/nested/list/2" : "null" + }, + "noView" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":1433,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Public" : {"database":"lowcoder","username":"root","host":"db.example.com","port":1433,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Internal" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":1433,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 1434, + "usingSsl" : true, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : true, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":1434,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":1434,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":1434,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false} + }, + "getterNames" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 1435, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":1435,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":1435,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":1435,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "minimal" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "empty" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : null, + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "unknownProperty" : "ignored: binds as form canonical" + }, + "MssqlConnector.resolveConfig" : { + "canonical" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "database" : " lowcoder ", + "username" : " root ", + "password" : "s3crét \"quoted\"", + "host" : " db.example.com ", + "port" : 1433, + "usingSsl" : true, + "serverTimezone" : "Europe/Bratislava", + "isReadonly" : true, + "enableTurnOffPreparedStatement" : true, + "extParams" : { + "connectTimeout" : 5000, + "allowPublicKeyRetrieval" : "true", + "useCursorFetch" : false, + "nested" : { + "list" : [ 1, 2.5, null ] + } + } + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "/database" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "java.lang.String", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "java.util.LinkedHashMap", + "/extParams/connectTimeout" : "java.lang.Integer", + "/extParams/allowPublicKeyRetrieval" : "java.lang.String", + "/extParams/useCursorFetch" : "java.lang.Boolean", + "/extParams/nested" : "java.util.LinkedHashMap", + "/extParams/nested/list" : "java.util.ArrayList", + "/extParams/nested/list/0" : "java.lang.Integer", + "/extParams/nested/list/1" : "java.lang.Double", + "/extParams/nested/list/2" : "null" + }, + "noView" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":1433,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Public" : {"database":"lowcoder","username":"root","host":"db.example.com","port":1433,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Internal" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":1433,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 1434, + "usingSsl" : true, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : true, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":1434,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":1434,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":1434,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false} + }, + "getterNames" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 1435, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":1435,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":1435,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":1435,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "minimal" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "empty" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : null, + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"","port":1433,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "unknownProperty" : "ignored: binds as form canonical" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlQueryConfig.input.json b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlQueryConfig.input.json new file mode 100644 index 0000000000..fc82a9e4ca --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlQueryConfig.input.json @@ -0,0 +1,65 @@ +{ + "raw": { + "sql": " select * from users where id = {{id}} ", + "disablePreparedStatement": true, + "mode": "SQL" + }, + "gui": { + "mode": "GUI", + "commandType": "INSERT", + "command": { + "table": "users", + "changeSet": [ + { + "key": "id", + "value": "{{1}}" + }, + { + "key": "name", + "value": "{{input.value}}" + } + ], + "allowMultiModify": true, + "filterBy": [ + { + "key": "id", + "operator": "=", + "value": "7" + } + ] + } + }, + "guiJavaNames": { + "mode": "GUI", + "guiStatementType": "UPDATE", + "guiStatementDetail": { + "table": "users", + "changeSet": [ + { + "key": "id", + "value": "{{1}}" + }, + { + "key": "name", + "value": "{{input.value}}" + } + ], + "allowMultiModify": true, + "filterBy": [ + { + "key": "id", + "operator": "=", + "value": "7" + } + ] + } + }, + "scalarsAsStrings": { + "sql": "select 1", + "disablePreparedStatement": "true" + }, + "sqlMissing": { + "mode": "SQL" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlQueryConfig.json b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlQueryConfig.json new file mode 100644 index 0000000000..94a3e91e6b --- /dev/null +++ b/server/api-service/lowcoder-plugins/mssqlPlugin/src/test/resources/json-contract/config-binding/MssqlQueryConfig.json @@ -0,0 +1,165 @@ +{ + "from" : { + "raw" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "sql" : " select * from users where id = {{id}} ", + "disablePreparedStatement" : true, + "mode" : "SQL", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : {"sql":"select * from users where id = {{id}}","disablePreparedStatement":true,"mode":"SQL","commandType":null,"command":null,"guiMode":false}, + "Public" : {"sql":"select * from users where id = {{id}}","disablePreparedStatement":true,"mode":"SQL","commandType":null,"command":null,"guiMode":false}, + "Internal" : {"sql":"select * from users where id = {{id}}","disablePreparedStatement":true,"mode":"SQL","commandType":null,"command":null,"guiMode":false} + }, + "gui" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "GUI", + "guiStatementType" : "INSERT", + "guiStatementDetail" : { + "table" : "users", + "changeSet" : [ { + "key" : "id", + "value" : "{{1}}" + }, { + "key" : "name", + "value" : "{{input.value}}" + } ], + "allowMultiModify" : true, + "filterBy" : [ { + "key" : "id", + "operator" : "=", + "value" : "7" + } ] + } + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "java.lang.String", + "/guiStatementDetail" : "java.util.LinkedHashMap", + "/guiStatementDetail/table" : "java.lang.String", + "/guiStatementDetail/changeSet" : "java.util.ArrayList", + "/guiStatementDetail/changeSet/0" : "java.util.LinkedHashMap", + "/guiStatementDetail/changeSet/0/key" : "java.lang.String", + "/guiStatementDetail/changeSet/0/value" : "java.lang.String", + "/guiStatementDetail/changeSet/1" : "java.util.LinkedHashMap", + "/guiStatementDetail/changeSet/1/key" : "java.lang.String", + "/guiStatementDetail/changeSet/1/value" : "java.lang.String", + "/guiStatementDetail/allowMultiModify" : "java.lang.Boolean", + "/guiStatementDetail/filterBy" : "java.util.ArrayList", + "/guiStatementDetail/filterBy/0" : "java.util.LinkedHashMap", + "/guiStatementDetail/filterBy/0/key" : "java.lang.String", + "/guiStatementDetail/filterBy/0/operator" : "java.lang.String", + "/guiStatementDetail/filterBy/0/value" : "java.lang.String" + }, + "noView" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mssql.model.MssqlQueryConfig[\"sql\"])" + }, + "Public" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mssql.model.MssqlQueryConfig[\"sql\"])" + }, + "Internal" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mssql.model.MssqlQueryConfig[\"sql\"])" + } + }, + "guiJavaNames" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "GUI", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mssql.model.MssqlQueryConfig[\"sql\"])" + }, + "Public" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mssql.model.MssqlQueryConfig[\"sql\"])" + }, + "Internal" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mssql.model.MssqlQueryConfig[\"sql\"])" + } + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "sql" : "select 1", + "disablePreparedStatement" : true, + "mode" : null, + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "null", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"commandType":null,"command":null,"guiMode":false}, + "Public" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"commandType":null,"command":null,"guiMode":false}, + "Internal" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"commandType":null,"command":null,"guiMode":false} + }, + "sqlMissing" : { + "class" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "SQL", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mssql.model.MssqlQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mssql.model.MssqlQueryConfig[\"sql\"])" + }, + "Public" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mssql.model.MssqlQueryConfig[\"sql\"])" + }, + "Internal" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mssql.model.MssqlQueryConfig[\"sql\"])" + } + }, + "empty" : { + "error" : "org.lowcoder.sdk.exception.PluginException: INVALID_QUERY_SETTINGS SQLSERVER_QUERY_CONFIG_EMPTY" + }, + "unknownProperty" : "ignored: binds as form raw" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/pom.xml b/server/api-service/lowcoder-plugins/mysqlPlugin/pom.xml index 201618644d..bf1dc3ab58 100644 --- a/server/api-service/lowcoder-plugins/mysqlPlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/pom.xml @@ -42,14 +42,12 @@ org.testcontainers testcontainers - 1.18.0 test org.testcontainers - mysql - 1.16.3 + testcontainers-mysql test diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/main/java/org/lowcoder/plugin/mysql/utils/MysqlResultParser.java b/server/api-service/lowcoder-plugins/mysqlPlugin/src/main/java/org/lowcoder/plugin/mysql/utils/MysqlResultParser.java deleted file mode 100644 index 150586c7a1..0000000000 --- a/server/api-service/lowcoder-plugins/mysqlPlugin/src/main/java/org/lowcoder/plugin/mysql/utils/MysqlResultParser.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.lowcoder.plugin.mysql.utils; - -import static org.lowcoder.plugin.mysql.utils.MysqlStructureParser.DATETIME_COLUMN_TYPE_NAME; -import static org.lowcoder.plugin.mysql.utils.MysqlStructureParser.DATE_COLUMN_TYPE_NAME; -import static org.lowcoder.plugin.mysql.utils.MysqlStructureParser.TIMESTAMP_COLUMN_TYPE_NAME; -import static org.lowcoder.sdk.util.DateTimeUtils.DATE_TIME_FORMAT; - -import java.sql.ResultSet; -import java.sql.ResultSetMetaData; -import java.sql.SQLException; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.LinkedHashMap; -import java.util.Map; - -public class MysqlResultParser { - - public static Map parseRowValue(ResultSet resultSet, ResultSetMetaData metaData, int colCount) throws SQLException { - - Map row = new LinkedHashMap<>(colCount); - - for (int i = 1; i <= colCount; i++) { - - String columnLabel = metaData.getColumnLabel(i); - String typeName = metaData.getColumnTypeName(i); - Object value = getValueByType(resultSet, i, typeName); - row.put(columnLabel, value); - } - - return row; - } - - private static Object getValueByType(ResultSet resultSet, int i, String typeName) throws SQLException { - if (resultSet.getObject(i) == null) { - return null; - } - if (DATE_COLUMN_TYPE_NAME.equalsIgnoreCase(typeName)) { - return DateTimeFormatter.ISO_DATE.format(resultSet.getDate(i).toLocalDate()); - } - if (DATETIME_COLUMN_TYPE_NAME.equalsIgnoreCase(typeName) - || TIMESTAMP_COLUMN_TYPE_NAME.equalsIgnoreCase(typeName)) { - return DATE_TIME_FORMAT.format(LocalDateTime.of(resultSet.getDate(i).toLocalDate(), resultSet.getTime(i).toLocalTime())); - } - if ("year".equalsIgnoreCase(typeName)) { - return resultSet.getDate(i).toLocalDate().getYear(); - } - return resultSet.getObject(i); - - } -} diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/EmptyLocaleBundle.java b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/EmptyLocaleBundle.java new file mode 100644 index 0000000000..31ce95e2ba --- /dev/null +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/EmptyLocaleBundle.java @@ -0,0 +1,30 @@ +package org.lowcoder.plugin.mysql; + +import org.junit.jupiter.api.function.Executable; + +import java.util.MissingResourceException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Pins the plan section 9 row "mysqlPlugin's empty locale.properties and locale_en.properties shadow the sdk bundle" + * (D-6: fix deferred): where a PluginException is expected, constructing it throws a MissingResourceException with key + * INTERNAL_SERVER_ERROR instead. A fix (removing the two files) changes the tests that use this on purpose, and also the + * "empty" case of the fixture {@code config-binding/MysqlQueryConfig.json} pinned by {@code MysqlConfigBindingContractTest} + * (it records the missing-resource text today): whoever fixes it regenerates that fixture. Kept apart from + * {@link MysqlContainerSupport} so the tests that need no server do not start the container. Not a test. + */ +final class EmptyLocaleBundle { + + static final String KEY = "INTERNAL_SERVER_ERROR"; + + private EmptyLocaleBundle() { + } + + static MissingResourceException assertThrown(Executable action) { + MissingResourceException thrown = assertThrows(MissingResourceException.class, action); + assertEquals(KEY, thrown.getKey(), thrown.getMessage()); + return thrown; + } +} diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlConfigBindingContractTest.java new file mode 100644 index 0000000000..eba19886df --- /dev/null +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlConfigBindingContractTest.java @@ -0,0 +1,40 @@ +package org.lowcoder.plugin.mysql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.mysql.model.MysqlQueryConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code mysqlPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1), + * through the real entry points: {@code MysqlConnector.resolveConfig} (the SDK default, {@code + * DatasourceConnector.resolveConfig}, binding the SDK's {@code MysqlDatasourceConfig}) and {@code + * MysqlQueryConfig.from}. {@code ConfigBinding} applies each entry point to every form of the input fixture + * (canonical names, alias or getter-derived names, scalars as strings, missing and empty values) and pins the bound + * state, the written views and the errors in the report fixture. + */ +public class MysqlConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#toJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#fromJson#1"}) + @Test + public void datasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/MysqlDatasourceConfig.input.json", "config-binding/MysqlDatasourceConfig.json", + Map.of("MysqlConnector.resolveConfig", new MysqlConnector()::resolveConfig)); + } + + @BoundarySites({ + "lowcoder-plugins/mysqlPlugin/src/main/java/org/lowcoder/plugin/mysql/model/MysqlQueryConfig.java#MysqlQueryConfig.from#toJson#1", + "lowcoder-plugins/mysqlPlugin/src/main/java/org/lowcoder/plugin/mysql/model/MysqlQueryConfig.java#MysqlQueryConfig.from#fromJson#1"}) + @Test + public void queryConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/MysqlQueryConfig.input.json", "config-binding/MysqlQueryConfig.json", + Map.of("from", MysqlQueryConfig::from)); + } +} diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlConnectorConfigTest.java b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlConnectorConfigTest.java new file mode 100644 index 0000000000..9f2b9a5861 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlConnectorConfigTest.java @@ -0,0 +1,83 @@ +package org.lowcoder.plugin.mysql; + +import com.zaxxer.hikari.HikariConfig; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig; + +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit MY-2 (task L5-2): what {@link MysqlConnector#setUpConfigs} puts on the Hikari config for each datasource flag, + * without a server (the fast guard next to the container tests of {@code MysqlDatabaseTest}). + */ +public class MysqlConnectorConfigTest { + + static final String HOST = "db.example.org"; + static final long PORT = 3307L; + static final String DATABASE = "app"; + static final String USER = "appuser"; + static final String PASSWORD = "secret"; + static final String DRIVER = "com.mysql.cj.jdbc.Driver"; + static final String TRUE = "true"; + static final String FALSE = "false"; + + private final MysqlConnector connector = new MysqlConnector(); + + private HikariConfig configured(String database, String username, String password, Long port, boolean ssl, boolean readonly) { + MysqlDatasourceConfig datasource = new MysqlDatasourceConfig(database, username, password, HOST, port, ssl, null, readonly, false, null); + HikariConfig config = new HikariConfig(); + connector.setUpConfigs(datasource, config); + return config; + } + + @Test + public void jdbcUrlAndCredentialsFollowTheConfig() { + HikariConfig config = configured(DATABASE, USER, PASSWORD, PORT, false, false); + assertEquals("jdbc:mysql://" + HOST + ":" + PORT + "/" + DATABASE, config.getJdbcUrl()); + assertEquals(USER, config.getUsername()); + assertEquals(PASSWORD, config.getPassword()); + System.out.println("[MysqlConnectorConfigTest] url " + config.getJdbcUrl() + ", user " + config.getUsername()); + } + + @Test + public void blankDatabaseDefaultPortAndBlankCredentialsAreHandled() { + HikariConfig config = configured(" ", "", null, null, false, false); + assertEquals("jdbc:mysql://" + HOST + ":3306/", config.getJdbcUrl(), "blank database gives an empty path, no port gives 3306"); + assertNull(config.getUsername(), "an empty user name must not be set"); + assertNull(config.getPassword(), "a null password must not be set"); + System.out.println("[MysqlConnectorConfigTest] blank database / default port url " + config.getJdbcUrl()); + } + + @Test + public void sslFlagMapsToUseSslAndRequireSsl() { + Properties on = configured(DATABASE, USER, PASSWORD, PORT, true, false).getDataSourceProperties(); + Properties off = configured(DATABASE, USER, PASSWORD, PORT, false, false).getDataSourceProperties(); + assertEquals(TRUE, on.get("useSSL")); + assertEquals(TRUE, on.get("requireSSL")); + assertEquals(FALSE, off.get("useSSL")); + assertEquals(FALSE, off.get("requireSSL")); + System.out.println("[MysqlConnectorConfigTest] ssl on " + on + " ; ssl off " + off); + } + + @Test + public void fixedDriverPropertiesAreSetForBothSslSettings() { + for (boolean ssl : new boolean[] {false, true}) { + Properties properties = configured(DATABASE, USER, PASSWORD, PORT, ssl, false).getDataSourceProperties(); + assertEquals("convertToNull", properties.get("zeroDateTimeBehavior"), "ssl " + ssl); + assertEquals(TRUE, properties.get("allowMultiQueries"), "ssl " + ssl); + } + assertEquals(DRIVER, connector.getJdbcDriver()); + System.out.println("[MysqlConnectorConfigTest] zeroDateTimeBehavior and allowMultiQueries set, driver " + connector.getJdbcDriver()); + } + + @Test + public void readonlyFlagMapsToHikariReadOnly() { + assertTrue(configured(DATABASE, USER, PASSWORD, PORT, false, true).isReadOnly()); + assertFalse(configured(DATABASE, USER, PASSWORD, PORT, false, false).isReadOnly()); + } +} diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlContainerSupport.java b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlContainerSupport.java new file mode 100644 index 0000000000..36c1acfff7 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlContainerSupport.java @@ -0,0 +1,174 @@ +package org.lowcoder.plugin.mysql; + +import org.lowcoder.sdk.contract.ContainerImages; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + + +/** + * The shared {@code mysql:8.0} container of the L5-2 tests (one per test JVM, started on first use, removed by + * Testcontainers' reaper when the JVM exits; the port is the one Docker assigns) and the helpers around it. No Docker + * guard on purpose: without a Docker daemon the first use fails, as the other container tests of the build do (owner + * decision D-7). Not a test. + */ +final class MysqlContainerSupport { + + static final String DATABASE = "app"; + static final String USER = "appuser"; + static final String PASSWORD = "apppass"; + static final String ROOT = "root"; + + static final int MYSQL_PORT = 3306; + /** The final server logs its TCP port; the temporary one of the image's init phase logs "port: 0". */ + private static final String READY_PATTERN = ".*ready for connections.*port: 3306.*"; + private static final Duration START_TIMEOUT = Duration.ofMinutes(2); + + /** + * A generic container, not the module's {@code MySQLContainer}: that one copies files into the container, which needs + * commons-lang3 3.18 ({@code ArrayFill}, used by commons-compress), and this module's classpath resolves 3.13.0 from + * lowcoder-sdk. A pom override would fix it for the module; it is outside the lane's files, so it is reported instead. + */ + private static final GenericContainer MYSQL = new GenericContainer<>(DockerImageName.parse(ContainerImages.MYSQL_8_0)) + .withEnv("MYSQL_DATABASE", DATABASE).withEnv("MYSQL_USER", USER).withEnv("MYSQL_PASSWORD", PASSWORD) + .withEnv("MYSQL_ROOT_PASSWORD", PASSWORD) + .withExposedPorts(MYSQL_PORT) + .waitingFor(Wait.forLogMessage(READY_PATTERN, 1).withStartupTimeout(START_TIMEOUT)); + + static { + long start = System.nanoTime(); + MYSQL.start(); + System.out.println("[MysqlContainerSupport] " + ContainerImages.MYSQL_8_0 + " started in " + + (System.nanoTime() - start) / 1_000_000 + " ms on " + MYSQL.getHost() + ":" + MYSQL.getMappedPort(MYSQL_PORT)); + } + + static { + // MySQL 8 authenticates with caching_sha2_password: the first login of a user needs a secure channel or the RSA key + // ("Public Key Retrieval is not allowed" without SSL) and afterwards the server has the user's hash cached. One TLS + // login per user here (as a database client would have made) keeps the tests of the pools without SSL independent + // of their order; the behaviour itself is pinned in MysqlDatabaseTest. + try (Connection app = open(DATABASE, USER, PASSWORD); Connection root = open(DATABASE, ROOT, PASSWORD)) { + System.out.println("[MysqlContainerSupport] credentials cached by the server: " + app.isValid(1) + " " + root.isValid(1)); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + + static final MysqlConnector CONNECTOR = new MysqlConnector(); + static final MysqlQueryExecutor EXECUTOR = new MysqlQueryExecutor(); + + private MysqlContainerSupport() { + } + + static MysqlDatasourceConfig config(String database, String password, boolean ssl, boolean readonly, boolean enableTurnOffPreparedStatement) { + return config(database, USER, password, ssl, readonly, enableTurnOffPreparedStatement); + } + + static MysqlDatasourceConfig config(String database, String user, String password, boolean ssl, boolean readonly, boolean enableTurnOffPreparedStatement) { + return new MysqlDatasourceConfig(database, user, password, MYSQL.getHost(), (long) MYSQL.getMappedPort(MYSQL_PORT), ssl, null, + readonly, enableTurnOffPreparedStatement, null); + } + + static MysqlDatasourceConfig config() { + return config(DATABASE, PASSWORD, false, false, false); + } + + static HikariPerfWrapper connect(MysqlDatasourceConfig config) { + return CONNECTOR.createConnection(config).block(); + } + + static void destroy(HikariPerfWrapper wrapper) { + CONNECTOR.destroyConnection(wrapper).block(); + } + + /** Runs a query config through the executor, as the server does: build the context, execute, return the data. */ + static Object run(HikariPerfWrapper wrapper, MysqlDatasourceConfig config, Map queryConfig, Map params) { + SqlBasedQueryExecutionContext context = EXECUTOR.buildQueryExecutionContext(config, queryConfig, params, null); + return EXECUTOR.executeQuery(wrapper, context).block().getData(); + } + + /** A plain JDBC connection of the application user (not the code under test). */ + static Connection app() { + return open(DATABASE, USER, PASSWORD); + } + + static Connection root() { + return open(DATABASE, ROOT, PASSWORD); + } + + private static Connection open(String database, String user, String password) { + try { + return DriverManager.getConnection("jdbc:mysql://" + MYSQL.getHost() + ":" + MYSQL.getMappedPort(MYSQL_PORT) + "/" + database, user, password); + } catch (SQLException e) { + throw new IllegalStateException("cannot connect as " + user, e); + } + } + + static void execute(Connection connection, String... statements) { + try (Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } catch (SQLException e) { + throw new IllegalStateException("statement failed", e); + } + } + + /** Creates an empty schema the application user may use (through the root user), so a test sees only its own tables. */ + static void newSchema(String name) { + try (Connection root = root()) { + execute(root, "drop database if exists " + name, "create database " + name, + "grant all on " + name + ".* to '" + USER + "'@'%'"); + } catch (SQLException e) { + throw new IllegalStateException("cannot create schema " + name, e); + } + } + + static List> rows(Connection connection, String sql) { + List> rows = new ArrayList<>(); + try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) { + ResultSetMetaData metaData = resultSet.getMetaData(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= metaData.getColumnCount(); i++) { + row.put(metaData.getColumnLabel(i), resultSet.getObject(i)); + } + rows.add(row); + } + } catch (SQLException e) { + throw new IllegalStateException("read failed: " + sql, e); + } + return rows; + } + + static Map sqlConfig(String sql, boolean disablePreparedStatement) { + Map config = new LinkedHashMap<>(); + config.put("mode", "SQL"); + config.put("sql", sql); + config.put("disablePreparedStatement", disablePreparedStatement); + return config; + } + + static Map guiConfig(String type, Map command) { + Map config = new LinkedHashMap<>(); + config.put("mode", "GUI"); + config.put("commandType", type); + config.put("command", command); + return config; + } +} diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlDatabaseTest.java b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlDatabaseTest.java new file mode 100644 index 0000000000..2b8d89cb0a --- /dev/null +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlDatabaseTest.java @@ -0,0 +1,361 @@ +package org.lowcoder.plugin.mysql; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.DatasourceStructure.Column; +import org.lowcoder.sdk.models.DatasourceStructure.ForeignKey; +import org.lowcoder.sdk.models.DatasourceStructure.PrimaryKey; +import org.lowcoder.sdk.models.DatasourceStructure.Table; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; +import org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig; + +import java.sql.Connection; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.mysql.MysqlContainerSupport.app; +import static org.lowcoder.plugin.mysql.MysqlContainerSupport.config; +import static org.lowcoder.plugin.mysql.MysqlContainerSupport.connect; +import static org.lowcoder.plugin.mysql.MysqlContainerSupport.destroy; +import static org.lowcoder.plugin.mysql.MysqlContainerSupport.execute; +import static org.lowcoder.plugin.mysql.MysqlContainerSupport.guiConfig; +import static org.lowcoder.plugin.mysql.MysqlContainerSupport.rows; +import static org.lowcoder.plugin.mysql.MysqlContainerSupport.run; +import static org.lowcoder.plugin.mysql.MysqlContainerSupport.sqlConfig; + +/** + * Unit MY-1 (task L5-2): {@code MysqlConnector}, {@code MysqlQueryExecutor} and {@code MysqlStructureParser} against a real + * {@code mysql:8.0} (the image pinned in {@code ContainerImages.MYSQL_8_0}). Each test uses its own tables, or its own + * schema for the structure tests, so tests do not depend on order. + * + *

    Error paths assert the MissingResourceException (key INTERNAL_SERVER_ERROR) that stands in for the PluginException and + * the effect on the data: the module's own empty {@code locale.properties} shadows the SDK's message bundle (plan section 9 + * row "mysqlPlugin's empty locale.properties and locale_en.properties", D-6: fix deferred; the contract fixture + * {@code MysqlQueryConfig.json} shows the same). + * TLS behaviour of the pinned image ({@code mysql:8.0}): the server offers TLS with a generated certificate, so a pool with + * usingSsl (useSSL and requireSSL true, no certificate verification) negotiates TLS 1.3. + */ +public class MysqlDatabaseTest { + + static final String WRONG_PASSWORD = "not-the-password"; + static final String TRUE = "true"; + static final int MYSQL_POOL_SIZE = 50; + + private static Object value(Object data, int row, String column) { + @SuppressWarnings("unchecked") + List> rows = (List>) data; + return rows.get(row).get(column); + } + + private static Map keyValueChangeSet(Object... columnsAndValues) { + List> comp = new java.util.ArrayList<>(); + for (int i = 0; i < columnsAndValues.length; i += 2) { + comp.add(Map.of("column", columnsAndValues[i], "value", columnsAndValues[i + 1])); + } + return Map.of("compType", "KEY_VALUE_PAIRS", "comp", comp); + } + + private static Map filterOnId() { + return Map.of("column", "id", "condition", "=", "value", "{{id}}"); + } + + // ---- (a) connector + + @Test + public void poolOpensAndReturnsRealColumnTypes() throws Exception { + MysqlDatasourceConfig config = config(); + HikariPerfWrapper wrapper = connect(config); + try (Connection app = app()) { + execute(app, "drop table if exists t_pool", "create table t_pool (id int primary key, name varchar(20), price decimal(10,2), created datetime)", + "insert into t_pool values (1, 'ann', 10.50, '2024-02-29 13:14:15')"); + HikariDataSource dataSource = (HikariDataSource) wrapper.getHikariDataSource(); + assertTrue(dataSource.isRunning()); + assertEquals(MYSQL_POOL_SIZE, dataSource.getMaximumPoolSize()); + Object data = run(wrapper, config, sqlConfig("select id, name, price, created from t_pool", false), Map.of()); + System.out.println("[MysqlDatabaseTest] rows from MySQL: " + data); + assertEquals(1, value(data, 0, "id")); + assertEquals("ann", value(data, 0, "name")); + assertEquals(new java.math.BigDecimal("10.50"), value(data, 0, "price")); + assertEquals("2024-02-29 13:14:15", String.valueOf(value(data, 0, "created")).replace('T', ' ')); + } finally { + destroy(wrapper); + } + } + + @Test + public void wrongPasswordFailsTheConnectionCreation() { + long start = System.nanoTime(); + RuntimeException thrown = assertThrows(RuntimeException.class, () -> connect(config(MysqlContainerSupport.DATABASE, WRONG_PASSWORD, false, false, false))); + System.out.println("[MysqlDatabaseTest] wrong password: " + thrown.getClass().getSimpleName() + " after " + + (System.nanoTime() - start) / 1_000_000 + " ms: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("Public Key Retrieval is not allowed"), "without SSL a wrong password ends in the RSA step: " + thrown.getMessage()); + RuntimeException withSsl = assertThrows(RuntimeException.class, () -> connect(config(MysqlContainerSupport.DATABASE, WRONG_PASSWORD, true, false, false))); + System.out.println("[MysqlDatabaseTest] wrong password over SSL: " + withSsl.getMessage()); + assertTrue(withSsl.getMessage().contains("Access denied"), withSsl.getMessage()); + } + + @Test + public void readonlyPoolRejectsWritesAndWritablePoolAccepts() throws Exception { + try (Connection app = app()) { + execute(app, "drop table if exists t_readonly", "create table t_readonly (id int primary key)"); + MysqlDatasourceConfig readonly = config(MysqlContainerSupport.DATABASE, MysqlContainerSupport.PASSWORD, false, true, false); + HikariPerfWrapper readonlyPool = connect(readonly); + try { + RuntimeException thrown = EmptyLocaleBundle.assertThrown( + () -> run(readonlyPool, readonly, sqlConfig("insert into t_readonly values (1)", false), Map.of())); + System.out.println("[MysqlDatabaseTest] read-only insert refused: " + thrown); + } finally { + destroy(readonlyPool); + } + assertEquals(0, rows(app, "select * from t_readonly").size()); + MysqlDatasourceConfig writable = config(); + HikariPerfWrapper writablePool = connect(writable); + try { + run(writablePool, writable, sqlConfig("insert into t_readonly values (1)", false), Map.of()); + } finally { + destroy(writablePool); + } + assertEquals(1, rows(app, "select * from t_readonly").size()); + } + } + + @Test + public void sslFlagReachesTheDriver() { + MysqlDatasourceConfig off = config(MysqlContainerSupport.DATABASE, MysqlContainerSupport.PASSWORD, false, false, false); + MysqlDatasourceConfig on = config(MysqlContainerSupport.DATABASE, MysqlContainerSupport.PASSWORD, true, false, false); + String cipherOff = cipher(off); + String cipherOn = cipher(on); + System.out.println("[MysqlDatabaseTest] Ssl_cipher with ssl off: [" + cipherOff + "], with ssl on: [" + cipherOn + "]"); + assertEquals("", cipherOff); + assertTrue(!cipherOn.isEmpty(), "with usingSsl the session must be encrypted"); + } + + /** + * The pool without SSL cannot make the first login of a user the server has not cached ({@code caching_sha2_password} + * needs TLS or the RSA key, and the connector does not set {@code allowPublicKeyRetrieval}). Pins the plan section 9 row + * "the MySQL connector does not set allowPublicKeyRetrieval" (D-6: fix deferred); a fix changes this test, and the + * wrong-password test above, on purpose. + */ + @Test + public void sslOffFirstLoginOfAUserTheServerHasNotCachedFails() throws Exception { + try (Connection root = MysqlContainerSupport.root()) { + execute(root, "drop user if exists 'fresh'@'%'", "create user 'fresh'@'%' identified by 'freshpw'", "grant all on app.* to 'fresh'@'%'"); + } + RuntimeException thrown = assertThrows(RuntimeException.class, + () -> connect(config(MysqlContainerSupport.DATABASE, "fresh", "freshpw", false, false, false))); + System.out.println("[MysqlDatabaseTest] first login without SSL: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("Public Key Retrieval is not allowed"), thrown.getMessage()); + HikariPerfWrapper overSsl = connect(config(MysqlContainerSupport.DATABASE, "fresh", "freshpw", true, false, false)); + destroy(overSsl); + HikariPerfWrapper afterwards = connect(config(MysqlContainerSupport.DATABASE, "fresh", "freshpw", false, false, false)); + destroy(afterwards); + } + + private String cipher(MysqlDatasourceConfig config) { + HikariPerfWrapper wrapper = connect(config); + try { + return String.valueOf(value(run(wrapper, config, sqlConfig("show session status like 'Ssl_cipher'", false), Map.of()), 0, "Value")); + } finally { + destroy(wrapper); + } + } + + @Test + public void allowMultiQueriesReturnsEachResultAndRunsStackedStatements() throws Exception { + MysqlDatasourceConfig config = config(MysqlContainerSupport.DATABASE, MysqlContainerSupport.PASSWORD, false, false, true); + HikariPerfWrapper wrapper = connect(config); + try (Connection app = app()) { + execute(app, "drop table if exists t_multi", "create table t_multi (id int primary key)"); + Object two = run(wrapper, config, sqlConfig("select 1 as a; select 2 as b", true), Map.of()); + System.out.println("[MysqlDatabaseTest] two result sets: " + two); + assertEquals(2, ((List) two).size()); + assertEquals(1L, value(((List) two).get(0), 0, "a")); + assertEquals(2L, value(((List) two).get(1), 0, "b")); + Object stacked = run(wrapper, config, sqlConfig("insert into t_multi values (1); insert into t_multi values (2)", true), Map.of()); + System.out.println("[MysqlDatabaseTest] stacked inserts: " + stacked); + assertEquals(2, ((List) stacked).size()); + assertEquals(2, rows(app, "select * from t_multi").size()); + } finally { + destroy(wrapper); + } + } + + @Test + public void zeroDateIsReturnedAsNullNotAnException() throws Exception { + MysqlDatasourceConfig config = config(); + HikariPerfWrapper wrapper = connect(config); + try (Connection app = app()) { + execute(app, "drop table if exists t_zero", "create table t_zero (id int primary key, created datetime)", + "set session sql_mode=''", "insert into t_zero values (1, '0000-00-00 00:00:00')"); + Object data = run(wrapper, config, sqlConfig("select id, created from t_zero", false), Map.of()); + System.out.println("[MysqlDatabaseTest] zero date row: " + data); + assertEquals(1, value(data, 0, "id")); + assertNull(value(data, 0, "created")); + } finally { + destroy(wrapper); + } + } + + // ---- (b) structure: each test in its own schema + + private DatasourceStructure structureOf(String schema) { + MysqlDatasourceConfig config = config(schema, MysqlContainerSupport.PASSWORD, false, false, false); + HikariPerfWrapper wrapper = connect(config); + try { + return MysqlContainerSupport.EXECUTOR.getStructure(wrapper, config).block(); + } finally { + destroy(wrapper); + } + } + + private static Table table(DatasourceStructure structure, String name) { + return structure.getTables().stream().filter(t -> t.getName().equals(name)).findFirst().orElseThrow(); + } + + @Test + public void structureListsTablesColumnsAndAutogeneratedFlags() throws Exception { + String schema = "struct_columns"; + MysqlContainerSupport.newSchema(schema); + try (Connection root = MysqlContainerSupport.root()) { + execute(root, "use " + schema, + "create table b_table (id int auto_increment primary key, code varchar(10) not null, created timestamp default current_timestamp not null, " + + "uid varchar(36) default (uuid()), note varchar(10))", + "create table a_table (x bigint)", "create view a_view as select id from b_table"); + } + DatasourceStructure structure = structureOf(schema); + System.out.println("[MysqlDatabaseTest] structure: " + structure); + assertEquals(List.of("a_table", "b_table"), structure.getTables().stream().map(Table::getName).toList(), "tables sorted, the view is not listed"); + assertEquals(List.of("id", "code", "created", "uid", "note"), table(structure, "b_table").getColumns().stream().map(Column::getName).toList()); + assertEquals(List.of("int", "varchar", "timestamp", "varchar", "varchar"), table(structure, "b_table").getColumns().stream().map(Column::getType).toList()); + assertEquals(List.of(true, false, true, true, false), table(structure, "b_table").getColumns().stream().map(Column::getIsAutogenerated).toList(), + "auto_increment, timestamp default current_timestamp and default (uuid()) are generated"); + } + + @Test + public void singleAndCompositePrimaryKeys() throws Exception { + String schema = "struct_pk"; + MysqlContainerSupport.newSchema(schema); + try (Connection root = MysqlContainerSupport.root()) { + execute(root, "use " + schema, "create table single_pk (id int primary key, v int)", + "create table composite_pk (a int, b int, c int, primary key (a, b))"); + } + DatasourceStructure structure = structureOf(schema); + PrimaryKey single = assertInstanceOf(PrimaryKey.class, table(structure, "single_pk").getKeys().get(0)); + PrimaryKey composite = assertInstanceOf(PrimaryKey.class, table(structure, "composite_pk").getKeys().get(0)); + System.out.println("[MysqlDatabaseTest] keys: " + single + " ; " + composite); + assertEquals(List.of("id"), single.getColumnNames()); + assertEquals("PRIMARY", composite.getName()); + assertEquals(List.of("a", "b"), composite.getColumnNames().stream().sorted().toList(), + "the columns, not their order: KEYS_QUERY orders by position_in_unique_constraint, which is NULL for a primary key, so the server may return them in either order (seen: this test failed once in the scratch runs)"); + assertEquals(1, table(structure, "composite_pk").getKeys().size(), "one key, not one per column"); + } + + @Test + public void foreignKeysInTheSameAndInAnotherSchema() throws Exception { + String schema = "struct_fk"; + String other = "struct_fk_other"; + MysqlContainerSupport.newSchema(other); + MysqlContainerSupport.newSchema(schema); + try (Connection root = MysqlContainerSupport.root()) { + execute(root, "create table " + other + ".remote (id int primary key)", + "use " + schema, + "create table parent (id int, id2 int, unique key u (id, id2))", + "create table child (pid int, pid2 int, oid int, " + + "constraint fk_same foreign key (pid, pid2) references parent (id, id2), " + + "constraint fk_other foreign key (oid) references " + other + ".remote (id))"); + } + DatasourceStructure structure = structureOf(schema); + List keys = table(structure, "child").getKeys(); + System.out.println("[MysqlDatabaseTest] child keys: " + keys); + assertEquals(2, keys.size()); + ForeignKey same = keys.stream().filter(ForeignKey.class::isInstance).map(ForeignKey.class::cast).filter(k -> k.getName().equals("fk_same")).findFirst().orElseThrow(); + ForeignKey remote = keys.stream().filter(ForeignKey.class::isInstance).map(ForeignKey.class::cast).filter(k -> k.getName().equals("fk_other")).findFirst().orElseThrow(); + assertEquals(List.of("pid", "pid2"), same.getFromColumns()); + assertEquals(List.of("parent.id", "parent.id2"), same.getToColumns(), "same schema: no schema prefix"); + assertEquals(List.of("oid"), remote.getFromColumns()); + assertEquals(List.of(other + ".remote.id"), remote.getToColumns(), "other schema: prefixed"); + assertTrue(table(structure, "parent").getKeys().stream().noneMatch(ForeignKey.class::isInstance)); + } + + @Test + public void keysAreSortedPrimaryBeforeForeign() throws Exception { + String schema = "struct_sort"; + MysqlContainerSupport.newSchema(schema); + try (Connection root = MysqlContainerSupport.root()) { + execute(root, "use " + schema, "create table target (id int primary key)", + "create table both_keys (id int primary key, tid int, constraint a_fk foreign key (tid) references target (id))"); + } + List keys = table(structureOf(schema), "both_keys").getKeys(); + System.out.println("[MysqlDatabaseTest] keys of both_keys: " + keys); + assertEquals(2, keys.size()); + assertInstanceOf(PrimaryKey.class, keys.get(0), "the query returns the foreign key first (a_fk < PRIMARY); the executor sorts it behind"); + assertInstanceOf(ForeignKey.class, keys.get(1)); + } + + // ---- (c) GUI commands end to end + + private Object gui(String type, Map command, Map params) { + MysqlDatasourceConfig config = config(); + HikariPerfWrapper wrapper = connect(config); + try { + return run(wrapper, config, guiConfig(type, command), params); + } finally { + destroy(wrapper); + } + } + + @Test + public void guiInsertThenUpdateThenDeleteChangeTheRealTable() throws Exception { + try (Connection app = app()) { + execute(app, "drop table if exists t_gui", "create table t_gui (id int primary key, name varchar(20))"); + Object inserted = gui("insert", Map.of("table", "t_gui", "changeSet", keyValueChangeSet("id", "{{id}}", "name", "{{name}}")), + Map.of("id", 1, "name", "ann")); + System.out.println("[MysqlDatabaseTest] insert: " + inserted); + assertEquals(1, ((Map) inserted).get("affectedRows")); + assertEquals("ann", rows(app, "select name from t_gui where id = 1").get(0).get("name")); + + Object updated = gui("UPDATE", Map.of("table", "t_gui", "changeSet", keyValueChangeSet("name", "{{name}}"), + "filterBy", List.of(filterOnId()), "allowMultiModify", false), Map.of("id", 1, "name", "bob")); + assertEquals(1, ((Map) updated).get("affectedRows")); + assertEquals("bob", rows(app, "select name from t_gui where id = 1").get(0).get("name")); + + execute(app, "insert into t_gui values (2, 'cy')"); + Object deleted = gui("DELETE", Map.of("table", "t_gui", "filterBy", List.of(filterOnId()), "allowMultiModify", false), Map.of("id", 1)); + assertEquals(1, ((Map) deleted).get("affectedRows")); + assertEquals(List.of(2), rows(app, "select id from t_gui").stream().map(r -> r.get("id")).toList()); + } + } + + @Test + public void guiUpsertInsertsThenUpdatesTheSameKey() throws Exception { + try (Connection app = app()) { + execute(app, "drop table if exists t_upsert", "create table t_upsert (id int primary key, name varchar(20))"); + Map command = Map.of("table", "t_upsert", + "insertChangeSet", keyValueChangeSet("id", "{{id}}", "name", "{{name}}"), + "updateChangeSet", keyValueChangeSet("name", "{{name}}")); + gui("UPSERT", command, Map.of("id", 1, "name", "first")); + gui("UPSERT", command, Map.of("id", 1, "name", "second")); + List> rows = rows(app, "select id, name from t_upsert"); + System.out.println("[MysqlDatabaseTest] after two upserts: " + rows); + assertEquals(1, rows.size()); + assertEquals("second", rows.get(0).get("name")); + } + } + + @Test + public void guiBulkInsertAndBulkUpdateChangeSeveralRows() throws Exception { + try (Connection app = app()) { + execute(app, "drop table if exists t_bulk", "create table t_bulk (id int primary key, name varchar(20))"); + gui("BULK_INSERT", Map.of("table", "t_bulk", "records", "[{\"id\":1,\"name\":\"a\"},{\"id\":2,\"name\":\"b\"}]"), Map.of()); + assertEquals(List.of("a", "b"), rows(app, "select name from t_bulk order by id").stream().map(r -> r.get("name")).toList()); + gui("BULK_UPDATE", Map.of("table", "t_bulk", "primaryKey", "id", "records", "[{\"id\":1,\"name\":\"A\"},{\"id\":2,\"name\":\"B\"}]"), Map.of()); + assertEquals(List.of("A", "B"), rows(app, "select name from t_bulk order by id").stream().map(r -> r.get("name")).toList()); + } + } +} diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlQueryExecutorTest.java b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlQueryExecutorTest.java new file mode 100644 index 0000000000..a7f1a8a51d --- /dev/null +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/java/org/lowcoder/plugin/mysql/MysqlQueryExecutorTest.java @@ -0,0 +1,73 @@ +package org.lowcoder.plugin.mysql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlBulkInsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlBulkUpdateCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlDeleteCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlInsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlUpdateCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlUpsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +/** + * Unit MY-1 (c), the part that needs no server (task L5-2): {@link MysqlQueryExecutor#parseSqlCommand} maps each GUI type + * to its MySQL command class (the SQL they produce is run by {@code MysqlDatabaseTest}). The error cases assert the + * MissingResourceException that stands in for the PluginException (plan section 9 row "mysqlPlugin's empty + * locale.properties and locale_en.properties", D-6: fix deferred), see {@code MysqlDatabaseTest}. + */ +public class MysqlQueryExecutorTest { + + static final Map KEY_VALUES = Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", "a", "value", "1"))); + static final List> FILTER = List.of(Map.of("column", "id", "condition", "=", "value", "1")); + static final Map> DETAILS = Map.of( + "insert", Map.of("table", "t", "changeSet", KEY_VALUES), + "update", Map.of("table", "t", "changeSet", KEY_VALUES, "filterBy", FILTER), + "upsert", Map.of("table", "t", "insertChangeSet", KEY_VALUES, "updateChangeSet", KEY_VALUES), + "delete", Map.of("table", "t", "filterBy", FILTER), + "bulk_insert", Map.of("table", "t", "records", "[{\"a\":1}]"), + "bulk_update", Map.of("table", "t", "primaryKey", "a", "records", "[{\"a\":1}]")); + static final Map> TYPES = Map.of( + "insert", MysqlInsertCommand.class, "update", MysqlUpdateCommand.class, "upsert", MysqlUpsertCommand.class, + "delete", MysqlDeleteCommand.class, "bulk_insert", MysqlBulkInsertCommand.class, "bulk_update", MysqlBulkUpdateCommand.class); + + private final MysqlQueryExecutor executor = new MysqlQueryExecutor(); + + @Test + public void everyGuiTypeMapsToItsOwnCommandInAnyCase() { + TYPES.forEach((type, commandClass) -> { + assertInstanceOf(commandClass, executor.parseSqlCommand(type, DETAILS.get(type)), type); + assertInstanceOf(commandClass, executor.parseSqlCommand(type.toUpperCase(Locale.ROOT), DETAILS.get(type)), type.toUpperCase(Locale.ROOT)); + }); + System.out.println("[MysqlQueryExecutorTest] " + TYPES.size() + " GUI types mapped, lower and upper case"); + } + + @Test + public void unknownGuiTypeIsRejected() { + RuntimeException thrown = EmptyLocaleBundle.assertThrown(() -> executor.parseSqlCommand("MERGE", DETAILS.get("insert"))); + System.out.println("[MysqlQueryExecutorTest] unknown type rejected with " + thrown.getClass().getSimpleName()); + } + + /** + * Pins defect D17 (plan section 9: default-locale toUpperCase): under a Turkish default locale "insert" becomes + * "INSERT" with a dotted capital I and falls into the error branch. A fix ({@code Locale.ROOT}) changes this test on + * purpose. The default locale is global state: restored in finally. + */ + @Test + public void guiTypeInsertFailsUnderATurkishDefaultLocale_pinsD17() { + Locale saved = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + System.out.println("[MysqlQueryExecutorTest] default locale: " + Locale.getDefault() + ", upper case of insert: " + "insert".toUpperCase()); + EmptyLocaleBundle.assertThrown(() -> executor.parseSqlCommand("insert", DETAILS.get("insert"))); + } finally { + Locale.setDefault(saved); + } + assertInstanceOf(MysqlInsertCommand.class, executor.parseSqlCommand("insert", DETAILS.get("insert")), "locale restored: " + Locale.getDefault()); + } +} diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlDatasourceConfig.input.json new file mode 100644 index 0000000000..9db7b9a310 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlDatasourceConfig.input.json @@ -0,0 +1,41 @@ +{ + "canonical": { + "database": " lowcoder ", + "username": " root ", + "password": "s3crét \"quoted\"", + "host": " db.example.com ", + "port": 3306, + "usingSsl": true, + "serverTimezone": "Europe/Bratislava", + "isReadonly": true, + "enableTurnOffPreparedStatement": true, + "extParams": { + "connectTimeout": 5000, + "allowPublicKeyRetrieval": "true", + "useCursorFetch": false, + "nested": { + "list": [ + 1, + 2.5, + null + ] + } + } + }, + "scalarsAsStrings": { + "host": "db.example.com", + "port": "3307", + "usingSsl": "true", + "isReadonly": "false", + "enableTurnOffPreparedStatement": "true" + }, + "getterNames": { + "host": "db.example.com", + "readonly": true, + "port": 3308.0 + }, + "minimal": { + "host": "db.example.com" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlDatasourceConfig.json b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlDatasourceConfig.json new file mode 100644 index 0000000000..ad8f15a5e3 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlDatasourceConfig.json @@ -0,0 +1,180 @@ +{ + "MysqlConnector.resolveConfig" : { + "canonical" : { + "class" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "database" : " lowcoder ", + "username" : " root ", + "password" : "s3crét \"quoted\"", + "host" : " db.example.com ", + "port" : 3306, + "usingSsl" : true, + "serverTimezone" : "Europe/Bratislava", + "isReadonly" : true, + "enableTurnOffPreparedStatement" : true, + "extParams" : { + "connectTimeout" : 5000, + "allowPublicKeyRetrieval" : "true", + "useCursorFetch" : false, + "nested" : { + "list" : [ 1, 2.5, null ] + } + } + }, + "classes" : { + "" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "/database" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "java.lang.String", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "java.util.LinkedHashMap", + "/extParams/connectTimeout" : "java.lang.Integer", + "/extParams/allowPublicKeyRetrieval" : "java.lang.String", + "/extParams/useCursorFetch" : "java.lang.Boolean", + "/extParams/nested" : "java.util.LinkedHashMap", + "/extParams/nested/list" : "java.util.ArrayList", + "/extParams/nested/list/0" : "java.lang.Integer", + "/extParams/nested/list/1" : "java.lang.Double", + "/extParams/nested/list/2" : "null" + }, + "noView" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":3306,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Public" : {"database":"lowcoder","username":"root","host":"db.example.com","port":3306,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Internal" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":3306,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 3307, + "usingSsl" : true, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : true, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":3307,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":3307,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":3307,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false} + }, + "getterNames" : { + "class" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 3308, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":3308,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":3308,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":3308,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "minimal" : { + "class" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":3306,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":3306,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":3306,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "empty" : { + "class" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : null, + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"","port":3306,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"","port":3306,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"","port":3306,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "unknownProperty" : "ignored: binds as form canonical" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlQueryConfig.input.json b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlQueryConfig.input.json new file mode 100644 index 0000000000..fc82a9e4ca --- /dev/null +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlQueryConfig.input.json @@ -0,0 +1,65 @@ +{ + "raw": { + "sql": " select * from users where id = {{id}} ", + "disablePreparedStatement": true, + "mode": "SQL" + }, + "gui": { + "mode": "GUI", + "commandType": "INSERT", + "command": { + "table": "users", + "changeSet": [ + { + "key": "id", + "value": "{{1}}" + }, + { + "key": "name", + "value": "{{input.value}}" + } + ], + "allowMultiModify": true, + "filterBy": [ + { + "key": "id", + "operator": "=", + "value": "7" + } + ] + } + }, + "guiJavaNames": { + "mode": "GUI", + "guiStatementType": "UPDATE", + "guiStatementDetail": { + "table": "users", + "changeSet": [ + { + "key": "id", + "value": "{{1}}" + }, + { + "key": "name", + "value": "{{input.value}}" + } + ], + "allowMultiModify": true, + "filterBy": [ + { + "key": "id", + "operator": "=", + "value": "7" + } + ] + } + }, + "scalarsAsStrings": { + "sql": "select 1", + "disablePreparedStatement": "true" + }, + "sqlMissing": { + "mode": "SQL" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlQueryConfig.json b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlQueryConfig.json new file mode 100644 index 0000000000..a3496719e3 --- /dev/null +++ b/server/api-service/lowcoder-plugins/mysqlPlugin/src/test/resources/json-contract/config-binding/MysqlQueryConfig.json @@ -0,0 +1,165 @@ +{ + "from" : { + "raw" : { + "class" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "sql" : " select * from users where id = {{id}} ", + "disablePreparedStatement" : true, + "mode" : "SQL", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : {"sql":"select * from users where id = {{id}}","disablePreparedStatement":true,"mode":"SQL","commandType":null,"command":null,"guiMode":false}, + "Public" : {"sql":"select * from users where id = {{id}}","disablePreparedStatement":true,"mode":"SQL","commandType":null,"command":null,"guiMode":false}, + "Internal" : {"sql":"select * from users where id = {{id}}","disablePreparedStatement":true,"mode":"SQL","commandType":null,"command":null,"guiMode":false} + }, + "gui" : { + "class" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "GUI", + "guiStatementType" : "INSERT", + "guiStatementDetail" : { + "table" : "users", + "changeSet" : [ { + "key" : "id", + "value" : "{{1}}" + }, { + "key" : "name", + "value" : "{{input.value}}" + } ], + "allowMultiModify" : true, + "filterBy" : [ { + "key" : "id", + "operator" : "=", + "value" : "7" + } ] + } + }, + "classes" : { + "" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "java.lang.String", + "/guiStatementDetail" : "java.util.LinkedHashMap", + "/guiStatementDetail/table" : "java.lang.String", + "/guiStatementDetail/changeSet" : "java.util.ArrayList", + "/guiStatementDetail/changeSet/0" : "java.util.LinkedHashMap", + "/guiStatementDetail/changeSet/0/key" : "java.lang.String", + "/guiStatementDetail/changeSet/0/value" : "java.lang.String", + "/guiStatementDetail/changeSet/1" : "java.util.LinkedHashMap", + "/guiStatementDetail/changeSet/1/key" : "java.lang.String", + "/guiStatementDetail/changeSet/1/value" : "java.lang.String", + "/guiStatementDetail/allowMultiModify" : "java.lang.Boolean", + "/guiStatementDetail/filterBy" : "java.util.ArrayList", + "/guiStatementDetail/filterBy/0" : "java.util.LinkedHashMap", + "/guiStatementDetail/filterBy/0/key" : "java.lang.String", + "/guiStatementDetail/filterBy/0/operator" : "java.lang.String", + "/guiStatementDetail/filterBy/0/value" : "java.lang.String" + }, + "noView" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mysql.model.MysqlQueryConfig[\"sql\"])" + }, + "Public" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mysql.model.MysqlQueryConfig[\"sql\"])" + }, + "Internal" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mysql.model.MysqlQueryConfig[\"sql\"])" + } + }, + "guiJavaNames" : { + "class" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "GUI", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mysql.model.MysqlQueryConfig[\"sql\"])" + }, + "Public" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mysql.model.MysqlQueryConfig[\"sql\"])" + }, + "Internal" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mysql.model.MysqlQueryConfig[\"sql\"])" + } + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "sql" : "select 1", + "disablePreparedStatement" : true, + "mode" : null, + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "null", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"commandType":null,"command":null,"guiMode":false}, + "Public" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"commandType":null,"command":null,"guiMode":false}, + "Internal" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"commandType":null,"command":null,"guiMode":false} + }, + "sqlMissing" : { + "class" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "SQL", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.mysql.model.MysqlQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mysql.model.MysqlQueryConfig[\"sql\"])" + }, + "Public" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mysql.model.MysqlQueryConfig[\"sql\"])" + }, + "Internal" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.mysql.model.MysqlQueryConfig[\"sql\"])" + } + }, + "empty" : { + "error" : "java.util.MissingResourceException: Can't find resource for bundle java.util.PropertyResourceBundle, key INTERNAL_SERVER_ERROR" + }, + "unknownProperty" : "ignored: binds as form raw" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/oraclePlugin/pom.xml b/server/api-service/lowcoder-plugins/oraclePlugin/pom.xml index a109e8a7ae..8df1e0466a 100644 --- a/server/api-service/lowcoder-plugins/oraclePlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/oraclePlugin/pom.xml @@ -36,8 +36,7 @@ org.testcontainers - oracle-xe - 1.17.3 + testcontainers-oracle-free test diff --git a/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleConfigBindingContractTest.java new file mode 100644 index 0000000000..08faf41787 --- /dev/null +++ b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleConfigBindingContractTest.java @@ -0,0 +1,29 @@ +package org.lowcoder.plugin.oracle; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code oraclePlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1), + * through the real entry points: {@code OracleConnector.resolveConfig}, the SDK default that binds {@code + * OracleDatasourceConfig} (C8-A7). {@code ConfigBinding} applies each entry point to every form of the input + * fixture (canonical names, alias or getter-derived names, scalars as strings, missing and empty values) and pins + * the bound state, the written views and the errors in the report fixture. + */ +public class OracleConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#toJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#fromJson#1"}) + @Test + public void datasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/OracleDatasourceConfig.input.json", "config-binding/OracleDatasourceConfig.json", + Map.of("OracleConnector.resolveConfig", new OracleConnector()::resolveConfig)); + } +} diff --git a/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleConnectorConfigTest.java b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleConnectorConfigTest.java new file mode 100644 index 0000000000..92fa97ce4f --- /dev/null +++ b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleConnectorConfigTest.java @@ -0,0 +1,143 @@ +package org.lowcoder.plugin.oracle; + +import com.zaxxer.hikari.HikariConfig; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.oracle.model.OracleDatasourceConfig; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.BizError.INVALID_DATASOURCE_CONFIG_TYPE; + +/** + * Unit OR-2 (task L5-6): the JDBC URL {@link OracleDatasourceConfig#getJdbcUrl()} builds (explicit URL, then SID, then + * service name), what {@link OracleConnector#setUpConfigs} puts on the Hikari config, {@link OracleConnector#validateConfig} + * and {@link OracleDatasourceConfig#mergeWithUpdatedConfig}, without a server. + * + *

    Limits: the URL is compared as text; the Oracle driver is not asked to parse it (the real server is the heavy unit + * OR-3). + */ +public class OracleConnectorConfigTest { + + static final String HOST = "db.example.org"; + static final long PORT = 1522L; + static final long DEFAULT_PORT = 1521L; + static final String SID = "ORCL"; + static final String SERVICE = "FREEPDB1"; + static final String USER = "app"; + static final String PASSWORD = "secret"; + static final String EXPLICIT_URL = "jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1)))"; + static final String DRIVER = "oracle.jdbc.OracleDriver"; + + private final OracleConnector connector = new OracleConnector(); + + private static OracleDatasourceConfig.OracleDatasourceConfigBuilder builder() { + return OracleDatasourceConfig.builder().host(HOST).username(USER).password(PASSWORD); + } + + private HikariConfig configured(OracleDatasourceConfig datasource) { + HikariConfig config = new HikariConfig(); + connector.setUpConfigs(datasource, config); + System.out.println("[OracleConnectorConfigTest] url " + config.getJdbcUrl() + ", user " + config.getUsername() + ", read-only " + config.isReadOnly()); + return config; + } + + @Test + public void explicitUrlWinsOverSidAndServiceNameAndSidWinsOverServiceName() { + assertEquals(EXPLICIT_URL, builder().port(PORT).sid(SID).serviceName(SERVICE).jdbcUrl(EXPLICIT_URL).build().getJdbcUrl()); + assertEquals("jdbc:oracle:thin:@" + HOST + ":" + PORT + ":" + SID, builder().port(PORT).sid(SID).serviceName(SERVICE).build().getJdbcUrl()); + assertEquals("jdbc:oracle:thin:@//" + HOST + ":" + PORT + "/" + SERVICE, builder().port(PORT).serviceName(SERVICE).build().getJdbcUrl()); + } + + @Test + public void missingPortUsesTheDefault1521AndBlankValuesFallThrough() { + assertEquals("jdbc:oracle:thin:@" + HOST + ":" + DEFAULT_PORT + ":" + SID, builder().sid(SID).build().getJdbcUrl()); + assertEquals("jdbc:oracle:thin:@//" + HOST + ":" + DEFAULT_PORT + "/" + SERVICE, builder().serviceName(SERVICE).build().getJdbcUrl()); + assertEquals("jdbc:oracle:thin:@" + HOST + ":" + DEFAULT_PORT + ":" + SID, builder().jdbcUrl(" ").sid(SID).serviceName(SERVICE).build().getJdbcUrl(), "a blank URL is ignored"); + assertEquals("jdbc:oracle:thin:@//" + HOST + ":" + DEFAULT_PORT + "/" + SERVICE, builder().jdbcUrl("").sid(" ").serviceName(SERVICE).build().getJdbcUrl(), "a blank sid is ignored"); + } + + @Test + public void setUpConfigsSetsDriverUrlCredentialsAndReadOnly() { + HikariConfig config = configured(builder().port(PORT).serviceName(SERVICE).isReadonly(true).build()); + assertEquals(DRIVER, config.getDriverClassName()); + assertEquals("jdbc:oracle:thin:@//" + HOST + ":" + PORT + "/" + SERVICE, config.getJdbcUrl()); + assertEquals(USER, config.getUsername()); + assertEquals(PASSWORD, config.getPassword()); + assertTrue(config.isReadOnly()); + assertEquals(DRIVER, connector.getJdbcDriver()); + assertFalse(configured(builder().serviceName(SERVICE).build()).isReadOnly()); + } + + @Test + public void blankCredentialsAreNotSet() { + HikariConfig config = configured(OracleDatasourceConfig.builder().host(HOST).username(" ").password("").serviceName(SERVICE).build()); + assertNull(config.getUsername()); + assertNull(config.getPassword()); + HikariConfig none = configured(OracleDatasourceConfig.builder().host(HOST).serviceName(SERVICE).build()); + assertNull(none.getUsername()); + assertNull(none.getPassword()); + } + + /** + * Pins the plan section 9 row "validateConfig never fires" (D-6: fix deferred): {@code validateConfig} tests {@code isBlank(getJdbcUrl())}, but {@code getJdbcUrl()} is the + * computed URL (explicit URL, else built from host, port and sid or service name), which is never blank, so the check + * never fires and every config, an empty one included, is accepted. A fix (testing the stored URL) changes this test on + * purpose. + */ + @Test + public void validateConfigNeverFiresBecauseTheComputedUrlIsNeverBlank_pinsTheSection9Row() { + assertEquals(Set.of(), connector.validateConfig(builder().sid(SID).build())); + assertEquals(Set.of(), connector.validateConfig(builder().serviceName(SERVICE).build())); + assertEquals(Set.of(), connector.validateConfig(OracleDatasourceConfig.builder().jdbcUrl(EXPLICIT_URL).build())); + OracleDatasourceConfig empty = OracleDatasourceConfig.builder().build(); + System.out.println("[OracleConnectorConfigTest] empty config: url '" + empty.getJdbcUrl() + "', validate " + connector.validateConfig(empty)); + assertEquals("jdbc:oracle:thin:@//:" + DEFAULT_PORT + "/null", empty.getJdbcUrl(), "the computed URL of an empty config is not blank"); + assertEquals(Set.of(), connector.validateConfig(empty), "an empty config is accepted"); + assertEquals(Set.of(), connector.validateConfig(builder().build()), "a host without sid or service name is accepted"); + assertEquals(Set.of(), connector.validateConfig(builder().sid(" ").serviceName("").jdbcUrl(" ").build()), "blank values are accepted too"); + } + + @Test + public void mergeKeepsTheStoredPasswordWhenTheUpdateHasNoneAndTakesTheRestFromTheUpdate() { + OracleDatasourceConfig stored = builder().port(PORT).sid(SID).build(); + OracleDatasourceConfig update = OracleDatasourceConfig.builder().host("new.example.org").username("other").serviceName(SERVICE).isReadonly(true).build(); + OracleDatasourceConfig merged = (OracleDatasourceConfig) stored.mergeWithUpdatedConfig(update); + System.out.println("[OracleConnectorConfigTest] merged url " + merged.getJdbcUrl() + ", user " + merged.getUsername() + ", read-only " + merged.isReadonly()); + assertEquals(PASSWORD, merged.getPassword(), "the stored password is kept"); + assertEquals("other", merged.getUsername()); + assertEquals("new.example.org", merged.getHost()); + assertEquals(DEFAULT_PORT, merged.getPort(), "the port comes from the update (its default), not from the stored config"); + assertEquals("jdbc:oracle:thin:@//new.example.org:" + DEFAULT_PORT + "/" + SERVICE, merged.getJdbcUrl()); + assertEquals(null, merged.getSid()); + assertTrue(merged.isReadonly()); + OracleDatasourceConfig withPassword = (OracleDatasourceConfig) stored.mergeWithUpdatedConfig(builder().password("new").serviceName(SERVICE).build()); + assertEquals("new", withPassword.getPassword(), "a password in the update replaces the stored one"); + } + + @Test + public void mergeWithAnEmptyPasswordReplacesTheStoredOne() { + OracleDatasourceConfig stored = builder().serviceName(SERVICE).build(); + OracleDatasourceConfig merged = (OracleDatasourceConfig) stored.mergeWithUpdatedConfig( + OracleDatasourceConfig.builder().host(HOST).username(USER).password("").serviceName(SERVICE).build()); + System.out.println("[OracleConnectorConfigTest] merge with an empty password: '" + merged.getPassword() + "'"); + assertEquals("", merged.getPassword(), "only null keeps the stored password: an empty string is taken from the update"); + } + + @Test + public void mergeRejectsAForeignConfigType() { + OracleDatasourceConfig stored = builder().serviceName(SERVICE).build(); + MysqlDatasourceConfig foreign = new MysqlDatasourceConfig("db", USER, PASSWORD, HOST, 3306L, false, null, false, false, null); + BizException thrown = assertThrows(BizException.class, () -> stored.mergeWithUpdatedConfig(foreign)); + System.out.println("[OracleConnectorConfigTest] foreign config: " + thrown.getError() + " " + thrown.getMessageKey() + " " + java.util.Arrays.toString(thrown.getArgs())); + assertEquals(INVALID_DATASOURCE_CONFIG_TYPE, thrown.getError()); + assertEquals("INVALID_DATASOURCE_CONFIG_TYPE", thrown.getMessageKey()); + assertEquals("MysqlDatasourceConfig", thrown.getArgs()[0]); + } +} diff --git a/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleContainerSupport.java b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleContainerSupport.java new file mode 100644 index 0000000000..0d1d531a25 --- /dev/null +++ b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleContainerSupport.java @@ -0,0 +1,152 @@ +package org.lowcoder.plugin.oracle; + +import org.lowcoder.plugin.oracle.model.OracleDatasourceConfig; +import org.lowcoder.sdk.contract.ContainerImages; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The shared Oracle Free 23 container of the L5-6 tests (heavy-container tag: one per test JVM, started on first use, + * removed by Testcontainers' reaper when the JVM exits; the port is the one Docker assigns) and the helpers around it. A + * generic container, not the module's OracleContainer: the module classes need commons-lang3 3.18 and this module's + * classpath resolves 3.13.0 (see log-L5.md). The image creates the application user in the pluggable database + * {@value #SERVICE}. No Docker guard on purpose (owner decision D-7). Not a test. + */ +final class OracleContainerSupport { + + static final String SERVICE = "FREEPDB1"; + static final String SID = "FREE"; + static final String ADMIN_USER = "system"; + static final String ADMIN_PASSWORD = "AdminPass1"; + static final String USER = "app"; + static final String PASSWORD = "apppass"; + static final int ORACLE_PORT = 1521; + private static final String READY_PATTERN = ".*DATABASE IS READY TO USE!.*"; + private static final Duration START_TIMEOUT = Duration.ofMinutes(5); + + private static final GenericContainer ORACLE = new GenericContainer<>(DockerImageName.parse(ContainerImages.ORACLE_FREE_23)) + .withEnv("ORACLE_PASSWORD", ADMIN_PASSWORD).withEnv("APP_USER", USER).withEnv("APP_USER_PASSWORD", PASSWORD) + .withExposedPorts(ORACLE_PORT) + .waitingFor(Wait.forLogMessage(READY_PATTERN, 1).withStartupTimeout(START_TIMEOUT)); + + static { + long start = System.nanoTime(); + ORACLE.start(); + System.out.println("[OracleContainerSupport] " + ContainerImages.ORACLE_FREE_23 + " started in " + + (System.nanoTime() - start) / 1_000_000 + " ms on " + ORACLE.getHost() + ":" + ORACLE.getMappedPort(ORACLE_PORT)); + } + + static final OracleConnector CONNECTOR = new OracleConnector(); + static final OracleQueryExecutor EXECUTOR = new OracleQueryExecutor(); + + private OracleContainerSupport() { + } + + static String host() { + return ORACLE.getHost(); + } + + static int port() { + return ORACLE.getMappedPort(ORACLE_PORT); + } + + static OracleDatasourceConfig serviceConfig(String user, String password, boolean readonly) { + return OracleDatasourceConfig.builder().host(host()).port((long) port()).serviceName(SERVICE).username(user).password(password).isReadonly(readonly).build(); + } + + static OracleDatasourceConfig config() { + return serviceConfig(USER, PASSWORD, false); + } + + static HikariPerfWrapper connect(OracleDatasourceConfig config) { + return CONNECTOR.createConnection(config).block(); + } + + static void destroy(HikariPerfWrapper wrapper) { + CONNECTOR.destroyConnection(wrapper).block(); + } + + /** Runs a query config through the executor as the server does: build the context, execute, return the data. */ + static Object run(HikariPerfWrapper wrapper, OracleDatasourceConfig config, Map queryConfig, Map params) { + SqlBasedQueryExecutionContext context = EXECUTOR.buildQueryExecutionContext(config, queryConfig, params, null); + return EXECUTOR.executeQuery(wrapper, context).block().getData(); + } + + static Object sql(HikariPerfWrapper wrapper, OracleDatasourceConfig config, String sql, Map params) { + return run(wrapper, config, sqlConfig(sql), params); + } + + static Map sqlConfig(String sql) { + Map config = new LinkedHashMap<>(); + config.put("mode", "SQL"); + config.put("sql", sql); + config.put("disablePreparedStatement", false); + return config; + } + + static Map guiConfig(String type, Map command) { + Map config = new LinkedHashMap<>(); + config.put("mode", "GUI"); + config.put("commandType", type); + config.put("command", command); + return config; + } + + /** A plain JDBC connection of the application user (not the code under test). */ + static Connection jdbc() { + try { + return DriverManager.getConnection("jdbc:oracle:thin:@//" + host() + ":" + port() + "/" + SERVICE, USER, PASSWORD); + } catch (SQLException e) { + throw new IllegalStateException("cannot connect", e); + } + } + + /** Runs each statement, ignoring ORA-00942 (table or view does not exist) so that a test can drop what it creates. */ + static void execute(Connection connection, String... statements) { + try (Statement statement = connection.createStatement()) { + for (String sql : statements) { + try { + statement.execute(sql); + } catch (SQLException e) { + if (!(sql.startsWith("drop ") && e.getErrorCode() == 942)) { + throw e; + } + } + } + } catch (SQLException e) { + throw new IllegalStateException("statement failed", e); + } + } + + static List> rows(Connection connection, String sql) { + List> rows = new ArrayList<>(); + try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) { + ResultSetMetaData metaData = resultSet.getMetaData(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= metaData.getColumnCount(); i++) { + row.put(metaData.getColumnLabel(i), resultSet.getObject(i)); + } + rows.add(row); + } + } catch (SQLException e) { + throw new IllegalStateException("read failed: " + sql, e); + } + return rows; + } +} diff --git a/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleDatabaseTest.java b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleDatabaseTest.java new file mode 100644 index 0000000000..4f50f8d5a2 --- /dev/null +++ b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleDatabaseTest.java @@ -0,0 +1,363 @@ +package org.lowcoder.plugin.oracle; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.oracle.model.OracleDatasourceConfig; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.DatasourceStructure.Table; +import org.lowcoder.sdk.models.DatasourceStructure.TableType; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.ADMIN_PASSWORD; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.ADMIN_USER; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.CONNECTOR; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.EXECUTOR; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.PASSWORD; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.SERVICE; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.SID; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.USER; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.config; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.connect; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.destroy; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.execute; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.guiConfig; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.host; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.jdbc; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.port; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.rows; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.run; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.serviceConfig; +import static org.lowcoder.plugin.oracle.OracleContainerSupport.sql; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; + +/** + * Unit OR-3 (task L5-6): {@code OracleConnector}, {@code OracleQueryExecutor} (structure), the shared result parsing and the + * GUI commands against a real Oracle Free 23 (the image pinned in {@code ContainerImages.ORACLE_FREE_23}, the module's + * {@code ojdbc11 23.7.0.25.01} driver), started once per JVM by {@link OracleContainerSupport}. Heavy-container tag: the + * default build does not run it (run command in log-L5.md). Each test uses its own tables in the schema of user {@code app} + * (created without quotes, so their names and the columns the structure reports are upper case; the GUI tests use those + * names). + */ +@Tag("heavy-container") +public class OracleDatabaseTest { + + static final String WRONG_PASSWORD = "not-the-password"; + static final int ORACLE_POOL_SIZE = 50; + static final String DRIVER = "oracle.jdbc.OracleDriver"; + static final String INJECTION = "x'; drop table t_gui; --"; + static final String LOGON_DENIED = "ORA-01017"; + static final long INIT_TIMEOUT_MILLIS = 10_000; + + private static Map keyValues(Object... columnsAndValues) { + List> comp = new ArrayList<>(); + for (int i = 0; i < columnsAndValues.length; i += 2) { + comp.add(Map.of("column", columnsAndValues[i], "value", columnsAndValues[i + 1])); + } + return Map.of("compType", "KEY_VALUE_PAIRS", "comp", comp); + } + + private static Map filterOn(String table, String column, boolean allowMultiModify) { + Map command = new HashMap<>(); + command.put("table", table); + command.put("allowMultiModify", allowMultiModify); + command.put("filterBy", List.of(Map.of("column", column, "condition", "=", "value", "{{key}}"))); + return command; + } + + private Object gui(String type, Map command, Map params) { + OracleDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + return run(pool, config, guiConfig(type, command), params); + } finally { + destroy(pool); + } + } + + private static Object cell(Object data, String column) { + @SuppressWarnings("unchecked") + List> rows = (List>) data; + return rows.get(0).get(column); + } + + private static List column(Connection jdbc, String sql, String column) { + return rows(jdbc, sql).stream().map(row -> row.get(column)).toList(); + } + + // ---- connector + + @Test + public void poolOpensByServiceNameRunsQueriesAndTestConnectionSucceeds() { + OracleDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + HikariDataSource dataSource = (HikariDataSource) pool.getHikariDataSource(); + assertTrue(dataSource.isRunning()); + assertEquals(DRIVER, dataSource.getDriverClassName()); + assertEquals(ORACLE_POOL_SIZE, dataSource.getMaximumPoolSize()); + assertEquals("jdbc:oracle:thin:@//" + host() + ":" + port() + "/" + SERVICE, dataSource.getJdbcUrl()); + assertEquals(BigDecimal.ONE, cell(sql(pool, config, "select 1 as \"one\" from dual", Map.of()), "one")); + assertEquals(new BigDecimal(42), cell(sql(pool, config, "select {{a}} + 1 as \"r\" from dual", Map.of("a", 41)), "r"), "a bound parameter reaches the server"); + DatasourceTestResult test = CONNECTOR.testConnection(config).block(); + assertNotNull(test); + assertTrue(test.isSuccess()); + System.out.println("[OracleDatabaseTest] pool " + dataSource.getJdbcUrl() + ", test connection succeeded"); + } finally { + destroy(pool); + } + } + + @Test + public void sidExplicitUrlAndServiceNameReachTheirDatabases() { + RuntimeException appViaSid = assertThrows(RuntimeException.class, () -> connect( + OracleDatasourceConfig.builder().host(host()).port((long) port()).sid(SID).username(USER).password(PASSWORD).build())); + System.out.println("[OracleDatabaseTest] app user by SID " + SID + ": " + appViaSid.getMessage()); + assertTrue(appViaSid.getMessage().contains(LOGON_DENIED), "the SID names the container root, where the application user does not exist: " + appViaSid.getMessage()); + + OracleDatasourceConfig systemViaSid = OracleDatasourceConfig.builder().host(host()).port((long) port()).sid(SID).username(ADMIN_USER).password(ADMIN_PASSWORD).build(); + HikariPerfWrapper rootPool = connect(systemViaSid); + try { + assertEquals("CDB$ROOT", cell(sql(rootPool, systemViaSid, "select sys_context('USERENV', 'CON_NAME') as \"con\" from dual", Map.of()), "con")); + } finally { + destroy(rootPool); + } + + OracleDatasourceConfig explicit = OracleDatasourceConfig.builder().jdbcUrl("jdbc:oracle:thin:@//" + host() + ":" + port() + "/" + SERVICE).username(USER).password(PASSWORD).build(); + HikariPerfWrapper explicitPool = connect(explicit); + try { + assertEquals(SERVICE, cell(sql(explicitPool, explicit, "select sys_context('USERENV', 'CON_NAME') as \"con\" from dual", Map.of()), "con")); + } finally { + destroy(explicitPool); + } + } + + @Test + public void wrongPasswordIsReportedWithinTheInitTimeout() { + long start = System.nanoTime(); + RuntimeException thrown = assertThrows(RuntimeException.class, () -> connect(serviceConfig(USER, WRONG_PASSWORD, false))); + long millis = (System.nanoTime() - start) / 1_000_000; + System.out.println("[OracleDatabaseTest] wrong password: " + thrown.getClass().getSimpleName() + " after " + millis + " ms: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains(LOGON_DENIED), thrown.getMessage()); + assertTrue(millis < INIT_TIMEOUT_MILLIS, "took " + millis + " ms"); + } + + /** + * Pins the plan section 9 row "read-only not enforced" (D-6: fix deferred) for Oracle: the connector hands + * {@code readOnly} to Hikari (asserted in {@code OracleConnectorConfigTest}), but the Oracle driver treats + * {@code Connection.setReadOnly} as a hint and sends nothing to the server, so an insert through a read-only pool succeeds + * and the row is stored. The driver ignores it; the connector does set it. A fix (a read-only transaction on the + * connection) changes this test on purpose. + */ + @Test + public void readonlyFlagDoesNotStopAWriteBecauseTheDriverIgnoresIt() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table t_readonly", "create table t_readonly (id number)"); + OracleDatasourceConfig readonly = serviceConfig(USER, PASSWORD, true); + HikariPerfWrapper pool = connect(readonly); + try { + assertTrue(((HikariDataSource) pool.getHikariDataSource()).isReadOnly(), "the connector set read-only on the pool"); + Object result = sql(pool, readonly, "insert into t_readonly values (1)", Map.of()); + System.out.println("[OracleDatabaseTest] insert through a read-only pool: " + result); + assertEquals(1, ((Map) result).get("affectedRows")); + } finally { + destroy(pool); + } + assertEquals(List.of(BigDecimal.ONE), column(jdbc, "select id as \"id\" from t_readonly", "id"), "the write reached the table"); + } + } + + // ---- structure + + private static Table table(DatasourceStructure structure, String name) { + return structure.getTables().stream().filter(t -> t.getName().equals(name)).findFirst().orElseThrow(); + } + + @Test + public void structureListsTheTablesAndViewsOfTheConnectedUserWithUpperCaseNames() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop view v_alpha", "drop table beta", "drop table alpha"); + execute(jdbc, "create table alpha (zeta number primary key, \"mixed\" varchar2(20), mid timestamp)", + "create table beta (id number, ref number references alpha(zeta))", "create view v_alpha as select zeta from alpha"); + } + OracleDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + DatasourceStructure structure = EXECUTOR.getStructure(pool, config).block(); + Set names = structure.getTables().stream().map(Table::getName).collect(Collectors.toSet()); + System.out.println("[OracleDatabaseTest] tables: " + names); + assertTrue(names.containsAll(Set.of("ALPHA", "BETA", "V_ALPHA")), names.toString()); + assertTrue(names.stream().noneMatch(n -> n.startsWith("SYS") || n.contains(".")), "only the user's own objects, without a schema prefix: " + names); + Table alpha = table(structure, "ALPHA"); + assertEquals(TableType.TABLE, alpha.getType()); + assertNull(alpha.getSchema()); + assertEquals(Set.of("ZETA", "mixed", "MID"), alpha.getColumns().stream().map(DatasourceStructure.Column::getName).collect(Collectors.toSet()), "a quoted mixed-case column keeps its case"); + assertEquals(Set.of("NUMBER", "VARCHAR2", "TIMESTAMP(6)"), alpha.getColumns().stream().map(DatasourceStructure.Column::getType).collect(Collectors.toSet())); + assertEquals(List.of(), alpha.getKeys(), "keys are not read: the primary key of ZETA and the foreign key of BETA are not listed"); + assertEquals(List.of(), table(structure, "BETA").getKeys()); + assertEquals(TableType.TABLE, table(structure, "V_ALPHA").getType(), "a view is listed as a TABLE"); + } finally { + destroy(pool); + } + } + + // ---- results + + @Test + public void realDriverReturnsWhatTheSharedResultParserMakesOfEachType() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table t_types"); + execute(jdbc, "create table t_types (c_num number, c_int number(10), c_float float, c_bd binary_double, c_date date, c_dtime date, c_ts timestamp, c_v varchar2(40), " + + "c_nv nvarchar2(20), c_raw raw(4), c_blob blob, c_char char(3))"); + execute(jdbc, "insert into t_types values (12.5, 42, 1.5, 2.25, date '2024-02-29', to_date('2024-02-29 13:14:15', 'YYYY-MM-DD HH24:MI:SS'), timestamp '2024-02-29 13:14:15.123456', " + + "'žluťoučký', 'žluť', hextoraw('0001FF'), hextoraw('0001FF'), 'ab')"); + execute(jdbc, "insert into t_types (c_v) values (null)"); + OracleDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + @SuppressWarnings("unchecked") + List> data = (List>) sql(pool, config, "select * from t_types", Map.of()); + System.out.println("[OracleDatabaseTest] parsed rows: " + data); + Map first = data.get(0); + assertEquals(new BigDecimal("12.5"), first.get("C_NUM")); + assertEquals(new BigDecimal("42"), first.get("C_INT")); + assertEquals(new BigDecimal("1.5"), first.get("C_FLOAT")); + assertEquals(2.25d, first.get("C_BD")); + assertEquals("2024-02-29", first.get("C_DATE")); + assertEquals("2024-02-29", first.get("C_DTIME"), "an Oracle DATE with a time part is returned as its date only"); + assertEquals("2024-02-29 13:14:15", first.get("C_TS"), "the fraction is dropped"); + assertEquals("žluťoučký", first.get("C_V")); + assertEquals("žluť", first.get("C_NV")); + assertArrayEquals(new byte[] {0, 1, (byte) 0xFF}, (byte[]) first.get("C_RAW")); + assertEquals("AAH/", first.get("C_BLOB"), "a blob comes back as base64 text"); + assertEquals("ab ", first.get("C_CHAR"), "a char column is blank-padded"); + Map second = data.get(1); + assertEquals(new ArrayList<>(first.keySet()), new ArrayList<>(second.keySet()), "column order is the same on every row"); + second.forEach((name, value) -> assertNull(value, name)); + } finally { + destroy(pool); + } + } + } + + /** + * Pins the plan section 9 row "CLOB/TIMESTAMPTZ/INTERVALDS cells as driver objects, toJson empty" (D-6: fix deferred): the shared result parser hands {@code CLOB}, {@code TIMESTAMP WITH TIME ZONE} and {@code INTERVAL DAY TO SECOND} cells to the result + * as the driver's own objects ({@code oracle.sql.CLOB}, {@code oracle.sql.TIMESTAMPTZ}, {@code oracle.sql.INTERVALDS}) instead of text, so the query + * result holds objects that are not readable data, and writing the result as JSON gives an empty string. A fix (reading them as text) changes this test on purpose. + */ + @Test + public void lobTimeZoneAndIntervalCellsReachTheResultAsDriverObjects_pinsTheSection9Row() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table t_lob", "create table t_lob (c_clob clob, c_tstz timestamp with time zone, c_intv interval day to second)"); + execute(jdbc, "insert into t_lob values ('long text', timestamp '2024-02-29 13:14:15.123 +05:30', interval '1 02:03:04' day to second)"); + OracleDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + Object data = sql(pool, config, "select * from t_lob", Map.of()); + Object clob = cell(data, "C_CLOB"); + Object tstz = cell(data, "C_TSTZ"); + Object interval = cell(data, "C_INTV"); + System.out.println("[OracleDatabaseTest] clob " + clob.getClass().getName() + ", timestamptz " + tstz.getClass().getName() + ", interval " + interval.getClass().getName()); + assertEquals("oracle.sql.INTERVALDS", interval.getClass().getName()); + assertEquals("oracle.sql.CLOB", clob.getClass().getName()); + assertEquals("oracle.sql.TIMESTAMPTZ", tstz.getClass().getName()); + String json = org.lowcoder.sdk.util.JsonUtils.toJson(data); + System.out.println("[OracleDatabaseTest] json of the result: '" + json + "'"); + assertEquals("", json, "the result with these objects cannot be written as JSON: the JSON helper returns an empty string"); + } finally { + destroy(pool); + } + } + } + + // ---- GUI commands + + @Test + public void guiCommandsChangeTheRealTableAndAnInjectionValueIsStoredAsText() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table t_gui", "create table t_gui (id number primary key, name varchar2(100))"); + Object inserted = gui("insert", Map.of("table", "t_gui", "changeSet", keyValues("ID", "{{id}}", "NAME", "{{name}}")), Map.of("id", 1, "name", INJECTION)); + System.out.println("[OracleDatabaseTest] insert: " + inserted); + assertEquals(1, ((Map) inserted).get("affectedRows")); + assertEquals(List.of(Map.of("ID", BigDecimal.ONE, "NAME", INJECTION)), rows(jdbc, "select id, name from t_gui"), "the value is stored as text, the table is intact"); + + Map updateDetail = filterOn("t_gui", "ID", true); + updateDetail.put("changeSet", keyValues("NAME", "{{name}}")); + gui("UPDATE", updateDetail, Map.of("key", 1, "name", "o'brien")); + assertEquals(List.of("o'brien"), column(jdbc, "select name from t_gui where id = 1", "NAME")); + + execute(jdbc, "insert into t_gui values (2, 'b')", "insert into t_gui values (3, 'c')"); + gui("BULK_UPDATE", Map.of("table", "t_gui", "primaryKey", "ID", "records", "[{\"ID\":2,\"NAME\":\"B\"},{\"ID\":3,\"NAME\":\"C\"}]"), Map.of()); + assertEquals(List.of("o'brien", "B", "C"), column(jdbc, "select name from t_gui order by id", "NAME")); + + gui("DELETE", filterOn("t_gui", "ID", false), Map.of("key", 3)); + assertEquals(List.of(BigDecimal.ONE, new BigDecimal(2)), column(jdbc, "select id from t_gui order by id", "ID")); + } + } + + @Test + public void rownumOneChangesAndDeletesExactlyOneOfThreeMatchingRows() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table t_top", "create table t_top (id number, grp number, marker varchar2(10))", + "insert into t_top values (1, 1, 'orig')", "insert into t_top values (2, 1, 'orig')", "insert into t_top values (3, 1, 'orig')", "insert into t_top values (4, 2, 'orig')"); + + Map update = filterOn("t_top", "GRP", false); + update.put("changeSet", keyValues("MARKER", "changed")); + Object updated = gui("UPDATE", update, Map.of("key", 1)); + System.out.println("[OracleDatabaseTest] update rownum=1: " + updated); + assertEquals(1, ((Map) updated).get("affectedRows")); + assertEquals(1, rows(jdbc, "select * from t_top where marker = 'changed'").size(), "exactly one row changed"); + + Object deleted = gui("DELETE", filterOn("t_top", "GRP", false), Map.of("key", 1)); + assertEquals(1, ((Map) deleted).get("affectedRows")); + assertEquals(3, rows(jdbc, "select * from t_top").size(), "exactly one of the three matching rows is gone"); + + Object all = gui("DELETE", filterOn("t_top", "GRP", true), Map.of("key", 1)); + assertEquals(2, ((Map) all).get("affectedRows")); + assertEquals(List.of(new BigDecimal(4)), column(jdbc, "select id from t_top", "ID"), "the row of the other group stays"); + + Map multiUpdate = filterOn("t_top", "GRP", true); + multiUpdate.put("changeSet", keyValues("MARKER", "all")); + assertEquals(1, ((Map) gui("UPDATE", multiUpdate, Map.of("key", 2))).get("affectedRows")); + assertEquals(List.of("all"), column(jdbc, "select marker from t_top", "MARKER")); + } + } + + /** + * Pins the plan section 9 row "bulk insert of two or more records fails with ORA-63809" (D-6: fix deferred): the bulk insert renders one {@code insert ... values (?,?),(?,?)} (a table value constructor) and + * the executor asks for generated keys, which Oracle 23 refuses for such a statement (ORA-63809), so a bulk insert of two + * or more records fails and nothing is written; a single record works. + */ + @Test + public void bulkInsertOfTwoRecordsFailsOnOracleWhileOneRecordWorks_pinsTheSection9Row() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table t_bulk", "create table t_bulk (id number, name varchar2(10))"); + Object one = gui("BULK_INSERT", Map.of("table", "t_bulk", "records", "[{\"ID\":1,\"NAME\":\"a\"}]"), Map.of()); + System.out.println("[OracleDatabaseTest] bulk insert of one record: " + one); + assertEquals(1, rows(jdbc, "select * from t_bulk").size()); + PluginException thrown = assertThrows(PluginException.class, () -> gui("BULK_INSERT", + Map.of("table", "t_bulk", "records", "[{\"ID\":2,\"NAME\":\"b\"},{\"ID\":3,\"NAME\":\"c\"}]"), Map.of())); + System.out.println("[OracleDatabaseTest] bulk insert of two records: " + thrown.getError() + " " + thrown.getMessage()); + assertEquals(QUERY_EXECUTION_ERROR, thrown.getError()); + assertTrue(thrown.getMessage().contains("ORA-63809"), thrown.getMessage()); + assertEquals(1, rows(jdbc, "select * from t_bulk").size(), "nothing of the failed bulk insert was written"); + } + } +} diff --git a/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleFakeConnections.java b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleFakeConnections.java new file mode 100644 index 0000000000..09e479c356 --- /dev/null +++ b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleFakeConnections.java @@ -0,0 +1,61 @@ +package org.lowcoder.plugin.oracle; + +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; + +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * Test support for the executor unit of task L5-6: the never-started pool wrapper (the same shape as + * {@code PostgresResultContractTest.wrap}) that hands out a {@code FakeJdbc} connection, and a connection whose plain + * statement answers {@code executeQuery} with a given result set ({@code FakeJdbc} statements only implement + * {@code execute}; the structure query uses {@code executeQuery}). Not a test. + */ +final class OracleFakeConnections { + + private OracleFakeConnections() { + } + + /** A data source that is never started: it reports running with an idle pool and hands out {@code connection}. */ + static HikariPerfWrapper wrap(Connection connection) { + HikariPoolMXBean pool = (HikariPoolMXBean) Proxy.newProxyInstance(HikariPoolMXBean.class.getClassLoader(), + new Class[] {HikariPoolMXBean.class}, (self, method, args) -> 0); + HikariDataSource dataSource = new HikariDataSource() { + @Override + public Connection getConnection() throws SQLException { + return connection; + } + + @Override + public HikariPoolMXBean getHikariPoolMXBean() { + return pool; + } + + @Override + public boolean isRunning() { + return true; + } + }; + return HikariPerfWrapper.wrap(dataSource, () -> 0, () -> 0, () -> 0, () -> 0, null, null); + } + + /** A connection whose {@code createStatement().executeQuery(sql)} returns {@code resultSet} for any SQL text. */ + static Connection connectionAnswering(ResultSet resultSet) { + Statement statement = (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class[] {Statement.class}, (self, method, args) -> switch (method.getName()) { + case "executeQuery" -> resultSet; + case "close" -> null; + default -> throw new UnsupportedOperationException("not implemented by the test connection: " + method.getName()); + }); + return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[] {Connection.class}, (self, method, args) -> switch (method.getName()) { + case "createStatement" -> statement; + case "close" -> null; + case "isClosed" -> false; + default -> throw new UnsupportedOperationException("not implemented by the test connection: " + method.getName()); + }); + } +} diff --git a/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleQueryExecutorTest.java b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleQueryExecutorTest.java new file mode 100644 index 0000000000..79a022ebd6 --- /dev/null +++ b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/OracleQueryExecutorTest.java @@ -0,0 +1,169 @@ +package org.lowcoder.plugin.oracle; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.oracle.gui.OracleBulkInsertCommand; +import org.lowcoder.plugin.oracle.gui.OracleBulkUpdateCommand; +import org.lowcoder.plugin.oracle.gui.OracleDeleteCommand; +import org.lowcoder.plugin.oracle.gui.OracleInsertCommand; +import org.lowcoder.plugin.oracle.gui.OracleUpdateCommand; +import org.lowcoder.plugin.oracle.model.OracleDatasourceConfig; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.Column; +import org.lowcoder.sdk.contract.FakeJdbc.FailingCell; +import org.lowcoder.sdk.contract.FakeJdbc.Rows; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.DatasourceStructure.Table; +import org.lowcoder.sdk.models.DatasourceStructure.TableType; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.PluginCommonError.DATASOURCE_GET_STRUCTURE_ERROR; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_ARGUMENT_ERROR; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; + +/** + * Unit OR-1 (task L5-6), the executor half: {@link OracleQueryExecutor} on a {@link FakeJdbc} connection (through the + * never-started pool of {@link OracleFakeConnections#wrap}): a query result goes through the shared + * {@code GeneralSqlExecutor}, {@code getStructure} reads the rows of the sdk {@code StructureParser} query into tables, and + * {@code parseSqlCommand} picks the GUI command class by type name. + * + *

    Limits: the structure rows are what {@code user_tab_cols} is assumed to return (upper-case names), built by hand; the + * real server is the heavy unit OR-3. + */ +public class OracleQueryExecutorTest { + + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final String QUERY = "select * from items"; + static final String TABLE_NAME = "table_name"; + static final String COLUMN_NAME = "column_name"; + static final String DATA_TYPE = "data_type"; + static final Map KEY_VALUES = Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", "a", "value", "1"))); + static final List> FILTER = List.of(Map.of("column", "id", "condition", "=", "value", "1")); + static final Map> DETAILS = Map.of( + "insert", Map.of("table", "t", "changeSet", KEY_VALUES), + "update", Map.of("table", "t", "changeSet", KEY_VALUES, "filterBy", FILTER), + "delete", Map.of("table", "t", "filterBy", FILTER), + "bulk_insert", Map.of("table", "t", "records", "[{\"a\":1}]"), + "bulk_update", Map.of("table", "t", "primaryKey", "a", "records", "[{\"a\":1}]")); + static final Map> TYPES = Map.of( + "insert", OracleInsertCommand.class, "update", OracleUpdateCommand.class, "delete", OracleDeleteCommand.class, + "bulk_insert", OracleBulkInsertCommand.class, "bulk_update", OracleBulkUpdateCommand.class); + + private final OracleQueryExecutor executor = new OracleQueryExecutor(); + private final OracleDatasourceConfig config = OracleDatasourceConfig.builder().host("h").serviceName("S").build(); + + private QueryExecutionResult run(List columns, List> rows) { + SqlBasedQueryExecutionContext context = SqlBasedQueryExecutionContext.builder().query(QUERY).requestParams(Map.of()).build(); + QueryExecutionResult result = executor.executeQuery(OracleFakeConnections.wrap( + FakeJdbc.connection(List.of(new Rows(FakeJdbc.resultSet(columns, rows))))), context).block(TIMEOUT); + System.out.println("[OracleQueryExecutorTest] " + columns + " -> " + (result == null ? null : result.getData())); + return result; + } + + private static List row(Object... cells) { + return new ArrayList<>(Arrays.asList(cells)); + } + + @Test + public void everyRowBecomesAColumnOrderedMap() { + QueryExecutionResult result = run(List.of(new Column("ID", "NUMBER"), new Column("NAME", "VARCHAR2")), List.of(row(1, "žluť"), row(2, null))); + assertTrue(result.isSuccess()); + List data = assertInstanceOf(List.class, result.getData()); + assertEquals(2, data.size()); + Map first = assertInstanceOf(Map.class, data.get(0)); + assertEquals(List.of("ID", "NAME"), new ArrayList<>(first.keySet())); + assertEquals(1, first.get("ID")); + assertEquals("žluť", first.get("NAME")); + assertNull(assertInstanceOf(Map.class, data.get(1)).get("NAME")); + } + + @Test + public void aFailingCellFailsTheQueryWithAnExecutionError() { + PluginException thrown = assertThrows(PluginException.class, () -> run(List.of(new Column("D", "DATE")), List.of(List.of(new FailingCell("cannot read the value", "text"))))); + System.out.println("[OracleQueryExecutorTest] failing cell: " + thrown.getError() + " " + thrown.getMessageKey()); + assertEquals(QUERY_EXECUTION_ERROR, thrown.getError()); + } + + @Test + public void structureGroupsTheRowsIntoTablesWithTheirColumnsInTheGivenOrder() { + List columns = List.of(new Column(TABLE_NAME, "VARCHAR2"), new Column(COLUMN_NAME, "VARCHAR2"), new Column(DATA_TYPE, "VARCHAR2")); + DatasourceStructure structure = executor.getStructure(OracleFakeConnections.wrap(OracleFakeConnections.connectionAnswering(FakeJdbc.resultSet(columns, List.of( + row("ORDERS", "ID", "NUMBER"), row("CUSTOMERS", "NAME", "VARCHAR2"), row("ORDERS", "NOTE", "CLOB"), row("ORDERS", "CREATED", "TIMESTAMP(6)"))))), config).block(TIMEOUT); + List tables = structure.getTables().stream().sorted(java.util.Comparator.comparing(Table::getName)).toList(); + System.out.println("[OracleQueryExecutorTest] tables: " + tables.stream().map(t -> t.getName() + t.getColumns().stream().map(c -> c.getName() + ":" + c.getType()).toList()).toList()); + assertEquals(List.of("CUSTOMERS", "ORDERS"), tables.stream().map(Table::getName).toList()); + Table orders = tables.get(1); + assertEquals(TableType.TABLE, orders.getType()); + assertNull(orders.getSchema()); + assertEquals(List.of("ID", "NOTE", "CREATED"), orders.getColumns().stream().map(DatasourceStructure.Column::getName).toList()); + assertEquals(List.of("NUMBER", "CLOB", "TIMESTAMP(6)"), orders.getColumns().stream().map(DatasourceStructure.Column::getType).toList()); + assertEquals(List.of(), orders.getKeys(), "keys are not read"); + assertEquals(List.of("NAME"), tables.get(0).getColumns().stream().map(DatasourceStructure.Column::getName).toList()); + } + + @Test + public void structureOfAnEmptyResultHasNoTables() { + List columns = List.of(new Column(TABLE_NAME, "VARCHAR2"), new Column(COLUMN_NAME, "VARCHAR2"), new Column(DATA_TYPE, "VARCHAR2")); + DatasourceStructure structure = executor.getStructure(OracleFakeConnections.wrap(OracleFakeConnections.connectionAnswering(FakeJdbc.resultSet(columns, List.of()))), config).block(TIMEOUT); + assertEquals(List.of(), structure.getTables()); + } + + @Test + public void aFailingStructureReadIsAStructureError() { + List columns = List.of(new Column(TABLE_NAME, "VARCHAR2"), new Column(COLUMN_NAME, "VARCHAR2"), new Column(DATA_TYPE, "VARCHAR2")); + List> rows = List.of(row(new FailingCell("cannot read the table name", "t"), "ID", "NUMBER")); + PluginException thrown = assertThrows(PluginException.class, () -> executor.getStructure( + OracleFakeConnections.wrap(OracleFakeConnections.connectionAnswering(FakeJdbc.resultSet(columns, rows))), config).block(TIMEOUT)); + System.out.println("[OracleQueryExecutorTest] failing structure: " + thrown.getError() + " " + thrown.getMessageKey() + " " + Arrays.toString(thrown.getArgs())); + assertEquals(DATASOURCE_GET_STRUCTURE_ERROR, thrown.getError()); + } + + @Test + public void parseSqlCommandMapsEachGuiTypeInAnyCase() { + TYPES.forEach((type, commandClass) -> { + assertInstanceOf(commandClass, executor.parseSqlCommand(type, DETAILS.get(type)), type); + assertInstanceOf(commandClass, executor.parseSqlCommand(type.toUpperCase(Locale.ROOT), DETAILS.get(type)), type.toUpperCase(Locale.ROOT)); + }); + } + + @Test + public void unknownGuiTypeIsRejectedWithItsName() { + PluginException thrown = assertThrows(PluginException.class, () -> executor.parseSqlCommand("merge", DETAILS.get("insert"))); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals("INVALID_GUI_COMMAND_TYPE", thrown.getMessageKey()); + assertEquals("merge", thrown.getArgs()[0]); + } + + /** + * Pins defect D17 (analysis-plugins section 0.6; plan section 9 D1-D20 row: default-locale toUpperCase): under a + * Turkish default locale "insert" becomes a dotted capital I word and falls into the error branch. A fix + * ({@code Locale.ROOT}) changes this test on purpose. The default locale is global state: restored in finally. + */ + @Test + public void guiTypeInsertFailsUnderATurkishDefaultLocale_pinsD17() { + Locale saved = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + System.out.println("[OracleQueryExecutorTest] default locale: " + Locale.getDefault() + ", upper case of insert: " + "insert".toUpperCase()); + PluginException thrown = assertThrows(PluginException.class, () -> executor.parseSqlCommand("insert", DETAILS.get("insert"))); + assertEquals("INVALID_GUI_COMMAND_TYPE", thrown.getMessageKey()); + } finally { + Locale.setDefault(saved); + } + assertInstanceOf(OracleInsertCommand.class, executor.parseSqlCommand("insert", DETAILS.get("insert")), "locale restored: " + Locale.getDefault()); + } +} diff --git a/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/gui/OracleGuiCommandRenderTest.java b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/gui/OracleGuiCommandRenderTest.java new file mode 100644 index 0000000000..bbb8b344bf --- /dev/null +++ b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/java/org/lowcoder/plugin/oracle/gui/OracleGuiCommandRenderTest.java @@ -0,0 +1,225 @@ +package org.lowcoder.plugin.oracle.gui; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand.GuiSqlCommandRenderResult; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.sdk.exception.PluginCommonError.INVALID_INSERT_COMMAND; +import static org.lowcoder.sdk.exception.PluginCommonError.INVALID_UPDATE_COMMAND; + +/** + * Unit OR-1 (task L5-6), the GUI half: the SQL text and the bind parameters of the five Oracle GUI commands, rendered + * from the same command-detail maps the executor receives ({@code from(Map)}). Pins the Oracle specifics: a delete or + * update that does not allow multi modify adds the raw condition {@code rownum=1} to its where clause (and there is no + * limit clause), double-quoted column delimiters, values bound and never inlined (also an injection-looking value). + * + *

    Limits: the table name is rendered as given (never quoted) and a {@code "} inside a column name is not doubled + * ({@link #identifiersAreRenderedUnescaped_pinsTheSection9Row}); the command classes keep one filter set, which D15 + * ({@link #renderingTheSameCommandTwiceAddsRownumTwice_pinsD15}) shows. + */ +public class OracleGuiCommandRenderTest { + + static final String TABLE = "ITEMS"; + static final String INJECTION = "'; drop table t; --"; + static final String KEY_TABLE = "table"; + static final String KEY_FILTER = "filterBy"; + static final String KEY_CHANGE_SET = "changeSet"; + static final String KEY_MULTI = "allowMultiModify"; + static final String KEY_RECORDS = "records"; + static final String KEY_PRIMARY = "primaryKey"; + static final Map ID_FILTER = Map.of("column", "id", "condition", "=", "value", "{{id}}"); + static final Map NAME_SET = Map.of("compType", "KEY_VALUE_PAIRS", + "comp", List.of(Map.of("column", "name", "value", "{{name}}"), Map.of("column", "qty", "value", "3"))); + + private static GuiSqlCommandRenderResult print(String label, GuiSqlCommandRenderResult result) { + System.out.println("[OracleGuiCommandRenderTest] " + label + " -> " + result.sql().replace("\n", "\\n") + " " + result.bindParams()); + return result; + } + + private static Map detail(Object... keyValues) { + Map map = new LinkedHashMap<>(); + map.put(KEY_TABLE, TABLE); + for (int i = 0; i < keyValues.length; i += 2) { + map.put((String) keyValues[i], keyValues[i + 1]); + } + return map; + } + + @Test + public void deleteHasRownumOneUnlessMultiModifyAndBindsTheFilterValue() { + GuiSqlCommandRenderResult one = print("delete single", OracleDeleteCommand.from(detail(KEY_FILTER, List.of(ID_FILTER))).render(Map.of("id", 7))); + assertEquals("delete from ITEMS where \"id\" = ? and rownum=1", one.sql()); + assertEquals(List.of(7), one.bindParams()); + GuiSqlCommandRenderResult many = print("delete multi", OracleDeleteCommand.from(detail(KEY_FILTER, List.of(ID_FILTER), KEY_MULTI, true)).render(Map.of("id", 7))); + assertEquals("delete from ITEMS where \"id\" = ? ", many.sql()); + assertEquals(List.of(7), many.bindParams()); + } + + @Test + public void deleteWithoutFilterIsLimitedByRownumOnlyWhenSingle() { + GuiSqlCommandRenderResult single = print("delete no filter single", OracleDeleteCommand.from(detail(KEY_FILTER, List.of())).render(Map.of())); + assertEquals("delete from ITEMS where rownum=1", single.sql()); + assertEquals(List.of(), single.bindParams()); + GuiSqlCommandRenderResult multi = print("delete no filter multi", OracleDeleteCommand.from(detail(KEY_FILTER, List.of(), KEY_MULTI, true)).render(Map.of())); + assertEquals("delete from ITEMS", multi.sql()); + } + + @Test + public void deleteBindsAnInjectionLookingValueInsteadOfInliningIt() { + GuiSqlCommandRenderResult result = print("delete injection", OracleDeleteCommand.from(detail(KEY_FILTER, List.of( + Map.of("column", "name", "condition", "=", "value", "{{name}}")), KEY_MULTI, true)).render(Map.of("name", INJECTION))); + assertEquals("delete from ITEMS where \"name\" = ? ", result.sql()); + assertEquals(List.of(INJECTION), result.bindParams()); + } + + @Test + public void updateHasRownumOneUnlessMultiModify() { + Map params = Map.of("id", 7, "name", "bob"); + GuiSqlCommandRenderResult one = print("update single", OracleUpdateCommand.from( + detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of(ID_FILTER))).render(params)); + assertEquals("update ITEMS set \"name\"=?,\"qty\"=? where \"id\" = ? and rownum=1", one.sql()); + assertEquals(List.of("bob", 3, 7), one.bindParams()); + GuiSqlCommandRenderResult many = print("update multi", OracleUpdateCommand.from( + detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of(ID_FILTER), KEY_MULTI, true)).render(params)); + assertEquals("update ITEMS set \"name\"=?,\"qty\"=? where \"id\" = ? ", many.sql()); + assertEquals(List.of("bob", 3, 7), many.bindParams()); + } + + @Test + public void updateWithoutFilterIsLimitedByRownumOnlyWhenSingleAndBindsAnInjectionValue() { + GuiSqlCommandRenderResult single = print("update no filter single", OracleUpdateCommand.from( + detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of())).render(Map.of("name", INJECTION))); + assertEquals("update ITEMS set \"name\"=?,\"qty\"=? where rownum=1", single.sql()); + assertEquals(List.of(INJECTION, 3), single.bindParams()); + GuiSqlCommandRenderResult multi = print("update no filter multi", OracleUpdateCommand.from( + detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of(), KEY_MULTI, true)).render(Map.of("name", "bob"))); + assertEquals("update ITEMS set \"name\"=?,\"qty\"=?", multi.sql()); + } + + /** + * Pins defect D15 (analysis-plugins section 0.6; plan section 9 D1-D20 row): {@code render()} adds the {@code rownum=1} + * condition to the command's own filter set on every call, so rendering the same command object twice gives the condition + * twice. Reachable through the class API only: the server builds a new command for every execution and + * {@code sanitizeQueryConfig} only extracts the keys, so a normal query run renders each command once. A fix (rendering + * on a copy of the filter set) changes this test on purpose. + */ + @Test + public void renderingTheSameCommandTwiceAddsRownumTwice_pinsD15() { + GuiSqlCommand delete = OracleDeleteCommand.from(detail(KEY_FILTER, List.of(ID_FILTER))); + assertEquals("delete from ITEMS where \"id\" = ? and rownum=1", print("delete render 1", delete.render(Map.of("id", 7))).sql()); + assertEquals("delete from ITEMS where \"id\" = ? and rownum=1 and rownum=1", print("delete render 2", delete.render(Map.of("id", 7))).sql()); + GuiSqlCommand update = OracleUpdateCommand.from(detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of(ID_FILTER))); + Map params = Map.of("id", 7, "name", "bob"); + assertEquals("update ITEMS set \"name\"=?,\"qty\"=? where \"id\" = ? and rownum=1", print("update render 1", update.render(params)).sql()); + assertEquals("update ITEMS set \"name\"=?,\"qty\"=? where \"id\" = ? and rownum=1 and rownum=1", print("update render 2", update.render(params)).sql()); + } + + /** + * Pins the plan section 9 row "GUI SQL commands render identifiers unescaped" (SQL injection; D-6: fix deferred; the + * render is in the shared sdk classes) for Oracle: the table name is put into the SQL as given (never quoted), and a + * {@code "} inside a column name is not doubled, so a column name can close its quote and continue with SQL of its own. + * A fix (quote and escape the identifiers) changes these assertions on purpose. + */ + @Test + public void identifiersAreRenderedUnescaped_pinsTheSection9Row() { + Map deleteDetail = detail(KEY_FILTER, List.of(Map.of("column", "a\"=1 or \"b", "condition", "=", "value", "1")), KEY_MULTI, true); + deleteDetail.put(KEY_TABLE, "t; drop table x; --"); + GuiSqlCommandRenderResult delete = print("delete raw table and column breakout", OracleDeleteCommand.from(deleteDetail).render(Map.of())); + assertEquals("delete from t; drop table x; -- where \"a\"=1 or \"b\" = ? ", delete.sql(), "the table is raw and the column closes its quote"); + assertEquals(List.of(1), delete.bindParams()); + GuiSqlCommandRenderResult insert = print("insert column with a quote", OracleInsertCommand.from(detail(KEY_CHANGE_SET, Map.of("compType", "KEY_VALUE_PAIRS", + "comp", List.of(Map.of("column", "a\"b", "value", "1"))))).render(Map.of())); + assertEquals("insert into ITEMS (\"a\"b\") values (?)", insert.sql(), "the quote of the column name is not doubled"); + assertEquals(List.of(1), insert.bindParams()); + } + + @Test + public void insertRendersQuotedColumnsAndBoundValues() { + GuiSqlCommandRenderResult result = print("insert", OracleInsertCommand.from(detail(KEY_CHANGE_SET, NAME_SET)).render(Map.of("name", INJECTION))); + assertEquals("insert into ITEMS (\"name\",\"qty\") values (?,?)", result.sql()); + assertEquals(List.of(INJECTION, 3), result.bindParams()); + } + + @Test + public void aChangeSetWithoutColumnsIsRejectedForInsertAndUpdate() { + Map noColumns = Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of()); + for (String type : List.of("insert", "update")) { + Map command = detail(KEY_CHANGE_SET, noColumns, KEY_FILTER, List.of(ID_FILTER)); + PluginException thrown = assertThrows(PluginException.class, () -> { + GuiSqlCommand parsed = type.equals("insert") ? OracleInsertCommand.from(command) : OracleUpdateCommand.from(command); + parsed.render(Map.of("id", 1)); + }); + System.out.println("[OracleGuiCommandRenderTest] " + type + " without columns: " + thrown.getError() + " " + thrown.getMessageKey()); + assertEquals(type.equals("insert") ? INVALID_INSERT_COMMAND : INVALID_UPDATE_COMMAND, thrown.getError()); + assertEquals(type.equals("insert") ? "INSERT_DATA_EMPTY" : "UPDATE_DATA_EMPTY", thrown.getMessageKey()); + } + } + + @Test + public void bulkInsertRendersOneRowOfPlaceholdersPerRecord() { + GuiSqlCommandRenderResult result = print("bulk insert", OracleBulkInsertCommand.from(detail( + KEY_RECORDS, "[{\"name\":\"{{n1}}\",\"qty\":1},{\"name\":\"b\",\"qty\":2}]")).render(Map.of("n1", INJECTION))); + assertEquals("insert into ITEMS (\"name\",\"qty\") values (?,?),(?,?)", result.sql()); + assertEquals(List.of(INJECTION, 1, "b", 2), result.bindParams()); + } + + @Test + public void bulkUpdateRendersCaseWhenPerColumnAndTheKeysInTheWhere() { + GuiSqlCommandRenderResult result = print("bulk update", OracleBulkUpdateCommand.from(detail(KEY_PRIMARY, "id", + KEY_RECORDS, "[{\"id\":1,\"name\":\"a\"},{\"id\":2,\"name\":\"b\"}]")).render(Map.of())); + assertEquals("UPDATE ITEMS set\n\"name\" = CASE WHEN \"id\" = ? THEN ? WHEN \"id\" = ? THEN ? ELSE \"name\" END\nwhere id in (?,?)", result.sql()); + assertEquals(List.of(1, "a", 2, "b", 1, 2), result.bindParams()); + PluginException missingKey = assertThrows(PluginException.class, () -> OracleBulkUpdateCommand.from(detail(KEY_PRIMARY, "id", + KEY_RECORDS, "[{\"name\":\"a\"}]")).render(Map.of())); + assertEquals("BULK_UPDATE_DATA_NOT_CONTAIN_PRIMARY_KEY", missingKey.getMessageKey()); + } + + @Test + public void mustacheKeysAreExtractedFromFilterAndChangeSet() { + assertEquals(Set.of("{{id}}"), OracleDeleteCommand.from(detail(KEY_FILTER, List.of(ID_FILTER))).extractMustacheKeys()); + assertEquals(Set.of("{{id}}", "{{name}}"), OracleUpdateCommand.from(detail(KEY_CHANGE_SET, NAME_SET, KEY_FILTER, List.of(ID_FILTER))).extractMustacheKeys()); + assertEquals(false, OracleDeleteCommand.from(detail(KEY_FILTER, List.of())).isInsertCommand()); + assertEquals(true, OracleInsertCommand.from(detail(KEY_CHANGE_SET, NAME_SET)).isInsertCommand()); + } + + /** The constructors kept for tests render like the commands built from a detail map. */ + @Test + public void testConstructorsRenderLikeTheDetailMapCommands() { + Map columns = new LinkedHashMap<>(); + columns.put("name", "{{name}}"); + columns.put("qty", "3"); + org.lowcoder.sdk.plugin.sqlcommand.changeset.KeyValuePairChangeSet changeSet = org.lowcoder.sdk.plugin.sqlcommand.changeset.KeyValuePairChangeSet.buildForTest(columns); + GuiSqlCommandRenderResult insert = print("insert (test constructor)", new OracleInsertCommand(TABLE, changeSet).render(Map.of("name", "bob"))); + assertEquals("insert into ITEMS (\"name\",\"qty\") values (?,?)", insert.sql()); + assertEquals(List.of("bob", 3), insert.bindParams()); + org.lowcoder.sdk.plugin.sqlcommand.filter.FilterSet filterSet = new org.lowcoder.sdk.plugin.sqlcommand.filter.FilterSet(); + filterSet.addCondition("id", "=", "{{id}}"); + GuiSqlCommandRenderResult update = print("update (test constructor)", new OracleUpdateCommand(TABLE, changeSet, filterSet, false).render(Map.of("name", "bob", "id", 7))); + assertEquals("update ITEMS set \"name\"=?,\"qty\"=? where \"id\" = ? and rownum=1", update.sql()); + assertEquals(List.of("bob", 3, 7), update.bindParams()); + } + + /** + * Pins the plan section 9 row "bulk update renders the primary key unquoted while columns are quoted" (D-6: fix deferred; + * the render is in the shared sdk {@code BulkUpdateCommand}): the key column is double-quoted inside the {@code CASE WHEN} + * parts but written raw in the {@code where ... in (...)}, and Oracle folds an unquoted name to upper case, so for a + * mixed-case or lower-case quoted key the statement names a column that does not exist (ORA-00904 on a real server, seen + * in the L5-6 probe). A fix (quoting the key in the where clause) changes this test on purpose. + */ + @Test + public void bulkUpdateRendersTheKeyUnquotedInTheWhereClauseWhileColumnsAreQuoted_pinsTheSection9Row() { + GuiSqlCommandRenderResult result = print("bulk update mixed-case key", OracleBulkUpdateCommand.from(detail(KEY_PRIMARY, "Id", + KEY_RECORDS, "[{\"Id\":1,\"name\":\"a\"}]")).render(Map.of())); + assertEquals("UPDATE ITEMS set\n\"name\" = CASE WHEN \"Id\" = ? THEN ? ELSE \"name\" END\nwhere Id in (?)", result.sql(), + "the key is quoted in the CASE WHEN and raw in the where clause"); + assertEquals(List.of(1, "a", 1), result.bindParams()); + } +} diff --git a/server/api-service/lowcoder-plugins/oraclePlugin/src/test/resources/json-contract/config-binding/OracleDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/resources/json-contract/config-binding/OracleDatasourceConfig.input.json new file mode 100644 index 0000000000..a58eedd5c6 --- /dev/null +++ b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/resources/json-contract/config-binding/OracleDatasourceConfig.input.json @@ -0,0 +1,35 @@ +{ + "serviceName": { + "username": " system ", + "password": "s3crét", + "host": " ora.example.com ", + "port": 1521, + "serviceName": "ORCLPDB1", + "isReadonly": true, + "enableTurnOffPreparedStatement": true, + "extParams": { + "connectTimeout": 5000, + "allowPublicKeyRetrieval": "true", + "useCursorFetch": false, + "nested": { + "list": [ + 1, + 2.5, + null + ] + } + }, + "database": "ignored by the creator", + "usingSsl": true + }, + "sid": { + "host": "ora.example.com", + "port": "1522", + "sid": "ORCL" + }, + "jdbcUrl": { + "jdbcUrl": "jdbc:oracle:thin:@//ora.example.com:1521/X", + "readonly": true + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/oraclePlugin/src/test/resources/json-contract/config-binding/OracleDatasourceConfig.json b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/resources/json-contract/config-binding/OracleDatasourceConfig.json new file mode 100644 index 0000000000..b78e8a1488 --- /dev/null +++ b/server/api-service/lowcoder-plugins/oraclePlugin/src/test/resources/json-contract/config-binding/OracleDatasourceConfig.json @@ -0,0 +1,172 @@ +{ + "OracleConnector.resolveConfig" : { + "serviceName" : { + "class" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "sid" : null, + "serviceName" : "ORCLPDB1", + "jdbcUrl" : null, + "database" : "ignored by the creator", + "username" : " system ", + "password" : "s3crét", + "host" : " ora.example.com ", + "port" : 1521, + "usingSsl" : true, + "serverTimezone" : null, + "isReadonly" : true, + "enableTurnOffPreparedStatement" : true, + "extParams" : { + "connectTimeout" : 5000, + "allowPublicKeyRetrieval" : "true", + "useCursorFetch" : false, + "nested" : { + "list" : [ 1, 2.5, null ] + } + } + }, + "classes" : { + "" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "/sid" : "null", + "/serviceName" : "java.lang.String", + "/jdbcUrl" : "null", + "/database" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "java.util.LinkedHashMap", + "/extParams/connectTimeout" : "java.lang.Integer", + "/extParams/allowPublicKeyRetrieval" : "java.lang.String", + "/extParams/useCursorFetch" : "java.lang.Boolean", + "/extParams/nested" : "java.util.LinkedHashMap", + "/extParams/nested/list" : "java.util.ArrayList", + "/extParams/nested/list/0" : "java.lang.Integer", + "/extParams/nested/list/1" : "java.lang.Double", + "/extParams/nested/list/2" : "null" + }, + "noView" : {"username":"system","password":"s3crét","host":"ora.example.com","port":1521,"sid":null,"serviceName":"ORCLPDB1","jdbcUrl":"jdbc:oracle:thin:@//ora.example.com:1521/ORCLPDB1","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"database":"ignored by the creator","usingSsl":true,"serverTimezone":null,"readonly":true}, + "Public" : {"username":"system","host":"ora.example.com","port":1521,"sid":null,"serviceName":"ORCLPDB1","jdbcUrl":"jdbc:oracle:thin:@//ora.example.com:1521/ORCLPDB1","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"database":"ignored by the creator","usingSsl":true,"serverTimezone":null,"readonly":true}, + "Internal" : {"username":"system","password":"s3crét","host":"ora.example.com","port":1521,"sid":null,"serviceName":"ORCLPDB1","jdbcUrl":"jdbc:oracle:thin:@//ora.example.com:1521/ORCLPDB1","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"database":"ignored by the creator","usingSsl":true,"serverTimezone":null,"readonly":true} + }, + "sid" : { + "class" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "sid" : "ORCL", + "serviceName" : null, + "jdbcUrl" : null, + "database" : null, + "username" : null, + "password" : null, + "host" : "ora.example.com", + "port" : 1522, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "/sid" : "java.lang.String", + "/serviceName" : "null", + "/jdbcUrl" : "null", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"username":"","password":null,"host":"ora.example.com","port":1522,"sid":"ORCL","serviceName":null,"jdbcUrl":"jdbc:oracle:thin:@ora.example.com:1522:ORCL","enableTurnOffPreparedStatement":false,"extParams":{},"database":"","usingSsl":false,"serverTimezone":null,"readonly":false}, + "Public" : {"username":"","host":"ora.example.com","port":1522,"sid":"ORCL","serviceName":null,"jdbcUrl":"jdbc:oracle:thin:@ora.example.com:1522:ORCL","enableTurnOffPreparedStatement":false,"extParams":{},"database":"","usingSsl":false,"serverTimezone":null,"readonly":false}, + "Internal" : {"username":"","password":null,"host":"ora.example.com","port":1522,"sid":"ORCL","serviceName":null,"jdbcUrl":"jdbc:oracle:thin:@ora.example.com:1522:ORCL","enableTurnOffPreparedStatement":false,"extParams":{},"database":"","usingSsl":false,"serverTimezone":null,"readonly":false} + }, + "jdbcUrl" : { + "class" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "sid" : null, + "serviceName" : null, + "jdbcUrl" : "jdbc:oracle:thin:@//ora.example.com:1521/X", + "database" : null, + "username" : null, + "password" : null, + "host" : null, + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "/sid" : "null", + "/serviceName" : "null", + "/jdbcUrl" : "java.lang.String", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"username":"","password":null,"host":"","port":1521,"sid":null,"serviceName":null,"jdbcUrl":"jdbc:oracle:thin:@//ora.example.com:1521/X","enableTurnOffPreparedStatement":false,"extParams":{},"database":"","usingSsl":false,"serverTimezone":null,"readonly":false}, + "Public" : {"username":"","host":"","port":1521,"sid":null,"serviceName":null,"jdbcUrl":"jdbc:oracle:thin:@//ora.example.com:1521/X","enableTurnOffPreparedStatement":false,"extParams":{},"database":"","usingSsl":false,"serverTimezone":null,"readonly":false}, + "Internal" : {"username":"","password":null,"host":"","port":1521,"sid":null,"serviceName":null,"jdbcUrl":"jdbc:oracle:thin:@//ora.example.com:1521/X","enableTurnOffPreparedStatement":false,"extParams":{},"database":"","usingSsl":false,"serverTimezone":null,"readonly":false} + }, + "empty" : { + "class" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "sid" : null, + "serviceName" : null, + "jdbcUrl" : null, + "database" : null, + "username" : null, + "password" : null, + "host" : null, + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.oracle.model.OracleDatasourceConfig", + "/sid" : "null", + "/serviceName" : "null", + "/jdbcUrl" : "null", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"username":"","password":null,"host":"","port":1521,"sid":null,"serviceName":null,"jdbcUrl":"jdbc:oracle:thin:@//:1521/null","enableTurnOffPreparedStatement":false,"extParams":{},"database":"","usingSsl":false,"serverTimezone":null,"readonly":false}, + "Public" : {"username":"","host":"","port":1521,"sid":null,"serviceName":null,"jdbcUrl":"jdbc:oracle:thin:@//:1521/null","enableTurnOffPreparedStatement":false,"extParams":{},"database":"","usingSsl":false,"serverTimezone":null,"readonly":false}, + "Internal" : {"username":"","password":null,"host":"","port":1521,"sid":null,"serviceName":null,"jdbcUrl":"jdbc:oracle:thin:@//:1521/null","enableTurnOffPreparedStatement":false,"extParams":{},"database":"","usingSsl":false,"serverTimezone":null,"readonly":false} + }, + "unknownProperty" : "ignored: binds as form serviceName" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/pom.xml b/server/api-service/lowcoder-plugins/pom.xml index e0535b582b..9c6abe2655 100644 --- a/server/api-service/lowcoder-plugins/pom.xml +++ b/server/api-service/lowcoder-plugins/pom.xml @@ -21,6 +21,15 @@ + + + heavy-container + + org.pf4j @@ -38,6 +47,14 @@ lowcoder-sdk provided + + + org.lowcoder + lowcoder-sdk + ${revision} + test-jar + test + org.projectlombok lombok @@ -58,8 +75,8 @@ - junit - junit + org.junit.jupiter + junit-jupiter test @@ -208,6 +225,15 @@ + + + org.apache.maven.plugins + maven-surefire-plugin + + ${heavy.excluded} + + + diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/pom.xml b/server/api-service/lowcoder-plugins/postgresPlugin/pom.xml index 4d6c00086d..8019b5cb58 100644 --- a/server/api-service/lowcoder-plugins/postgresPlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/postgresPlugin/pom.xml @@ -45,13 +45,11 @@ org.testcontainers testcontainers - 1.18.0 test org.testcontainers - postgresql - 1.16.3 + testcontainers-postgresql test diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresDatasourceConfig.java b/server/api-service/lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresDatasourceConfig.java index e80c298d48..36920d2e41 100644 --- a/server/api-service/lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresDatasourceConfig.java +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresDatasourceConfig.java @@ -9,8 +9,6 @@ import org.lowcoder.sdk.exception.PluginCommonError; import org.lowcoder.sdk.plugin.common.sql.SqlBasedDatasourceConnectionConfig; -import com.google.common.annotations.VisibleForTesting; - import lombok.Builder; import lombok.extern.slf4j.Slf4j; @@ -39,17 +37,4 @@ public static PostgresDatasourceConfig buildFrom(Map requestMap) return result; } - @VisibleForTesting - public PostgresDatasourceConfigBuilder toBuilder() { - return builder() - .database(getDatabase()) - .username(getUsername()) - .password(getPassword()) - .usingSsl(isUsingSsl()) - .host(getHost()) - .port(getPort()) - .serverTimezone(getServerTimezone()) - .enableTurnOffPreparedStatement(isEnableTurnOffPreparedStatement()); - } - } diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresConfigBindingContractTest.java new file mode 100644 index 0000000000..8247e2581a --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresConfigBindingContractTest.java @@ -0,0 +1,43 @@ +package org.lowcoder.plugin.postgres; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig; +import org.lowcoder.plugin.postgres.model.PostgresQueryConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code postgresPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1), + * through the real entry points: {@code PostgresDatasourceConfig.buildFrom}, {@code + * PostgresConnector.resolveConfig} (the SDK default) and {@code PostgresQueryConfig.from}. {@code ConfigBinding} + * applies each entry point to every form of the input fixture (canonical names, alias or getter-derived names, + * scalars as strings, missing and empty values) and pins the bound state, the written views and the errors in the + * report fixture. + */ +public class PostgresConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresDatasourceConfig.java#PostgresDatasourceConfig.buildFrom#toJson#1", + "lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresDatasourceConfig.java#PostgresDatasourceConfig.buildFrom#fromJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#toJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#fromJson#1"}) + @Test + public void datasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/PostgresDatasourceConfig.input.json", "config-binding/PostgresDatasourceConfig.json", + ConfigBinding.entryPoints("buildFrom", PostgresDatasourceConfig::buildFrom, "PostgresConnector.resolveConfig", new PostgresConnector()::resolveConfig)); + } + + @BoundarySites({ + "lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresQueryConfig.java#PostgresQueryConfig.from#toJson#1", + "lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresQueryConfig.java#PostgresQueryConfig.from#fromJson#1"}) + @Test + public void queryConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/PostgresQueryConfig.input.json", "config-binding/PostgresQueryConfig.json", + Map.of("from", PostgresQueryConfig::from)); + } +} diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresConnectorConfigTest.java b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresConnectorConfigTest.java new file mode 100644 index 0000000000..5517250418 --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresConnectorConfigTest.java @@ -0,0 +1,79 @@ +package org.lowcoder.plugin.postgres; + +import com.zaxxer.hikari.HikariConfig; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig; + +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Unit PG-3 (task L5-4), no server: what {@link PostgresConnector#setUpConfigs} puts on the Hikari config. */ +public class PostgresConnectorConfigTest { + + static final String HOST = "db.example.org"; + static final long PORT = 6543L; + static final String DATABASE = "app"; + static final String USER = "appuser"; + static final String PASSWORD = "secret"; + + private final PostgresConnector connector = new PostgresConnector(); + + private HikariConfig configured(String database, String username, String password, Long port, boolean ssl, boolean readonly) { + PostgresDatasourceConfig datasource = PostgresDatasourceConfig.builder().database(database).username(username).password(password) + .host(HOST).port(port).usingSsl(ssl).isReadonly(readonly).build(); + HikariConfig config = new HikariConfig(); + connector.setUpConfigs(datasource, config); + return config; + } + + @Test + public void jdbcUrlAndCredentialsFollowTheConfig() { + HikariConfig config = configured(DATABASE, USER, PASSWORD, PORT, false, false); + assertEquals("jdbc:postgresql://" + HOST + ":" + PORT + "/" + DATABASE, config.getJdbcUrl()); + assertEquals(USER, config.getUsername()); + assertEquals(PASSWORD, config.getPassword()); + HikariConfig blank = configured(" ", "", null, null, false, false); + assertEquals("jdbc:postgresql://" + HOST + ":5432/", blank.getJdbcUrl(), "blank database gives an empty path, no port gives 5432"); + assertNull(blank.getUsername()); + assertNull(blank.getPassword()); + System.out.println("[PostgresConnectorConfigTest] url " + config.getJdbcUrl() + " ; blank " + blank.getJdbcUrl()); + } + + @Test + public void sslFlagMapsToSslAndSslmode() { + Properties on = configured(DATABASE, USER, PASSWORD, PORT, true, false).getDataSourceProperties(); + Properties off = configured(DATABASE, USER, PASSWORD, PORT, false, false).getDataSourceProperties(); + assertEquals("true", on.get("ssl")); + assertEquals("require", on.get("sslmode")); + assertEquals("false", off.get("ssl")); + assertEquals("disable", off.get("sslmode")); + } + + @Test + public void readonlyFlagMapsToHikariAndReadOnlyMode() { + HikariConfig readonly = configured(DATABASE, USER, PASSWORD, PORT, false, true); + assertTrue(readonly.isReadOnly()); + assertEquals("always", readonly.getDataSourceProperties().get("readOnlyMode")); + HikariConfig writable = configured(DATABASE, USER, PASSWORD, PORT, false, false); + assertFalse(writable.isReadOnly()); + assertNull(writable.getDataSourceProperties().get("readOnlyMode")); + } + + /** + * Pins the plan section 9 row "PostgresConnector builds the JDBC URL ... without encoding" (connection-property + * injection, the same class as D4; D-6: fix deferred): a database name carries driver properties into the URL verbatim. + * A fix (URL-encoding the name) changes this test on purpose. + */ + @Test + public void databaseNameIsPutIntoTheUrlUnencoded() { + String sslOff = configured("app?ssl=false", USER, PASSWORD, PORT, true, false).getJdbcUrl(); + String second = configured("app?ApplicationName=injected&options=-c%20search_path=evil", USER, PASSWORD, PORT, false, false).getJdbcUrl(); + System.out.println("[PostgresConnectorConfigTest] urls: " + sslOff + " ; " + second); + assertEquals("jdbc:postgresql://" + HOST + ":" + PORT + "/app?ssl=false", sslOff); + assertEquals("jdbc:postgresql://" + HOST + ":" + PORT + "/app?ApplicationName=injected&options=-c%20search_path=evil", second); + } +} diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresContainerSupport.java b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresContainerSupport.java new file mode 100644 index 0000000000..872fc84422 --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresContainerSupport.java @@ -0,0 +1,143 @@ +package org.lowcoder.plugin.postgres; + +import org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig; +import org.lowcoder.sdk.contract.ContainerImages; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The shared {@code postgres:16-alpine} container of the L5-4 tests (one per test JVM, started on first use, removed by + * Testcontainers' reaper when the JVM exits; the port is the one Docker assigns) and the helpers around it. A generic + * container, not the module's PostgreSQLContainer: that one copies files into the container, which needs commons-lang3 + * 3.18, and this module's classpath resolves 3.13.0 (see log-L5.md). No Docker guard on purpose (owner decision D-7). + * Not a test. + */ +final class PostgresContainerSupport { + + static final String DATABASE = "app"; + static final String USER = "appuser"; + static final String PASSWORD = "apppass"; + static final int PG_PORT = 5432; + private static final int READY_MESSAGES = 2; + private static final Duration START_TIMEOUT = Duration.ofMinutes(2); + + private static final GenericContainer POSTGRES = new GenericContainer<>(DockerImageName.parse(ContainerImages.POSTGRES_16)) + .withEnv("POSTGRES_DB", DATABASE).withEnv("POSTGRES_USER", USER).withEnv("POSTGRES_PASSWORD", PASSWORD) + .withExposedPorts(PG_PORT) + .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*", READY_MESSAGES).withStartupTimeout(START_TIMEOUT)); + + static { + long start = System.nanoTime(); + POSTGRES.start(); + System.out.println("[PostgresContainerSupport] " + ContainerImages.POSTGRES_16 + " started in " + + (System.nanoTime() - start) / 1_000_000 + " ms on " + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(PG_PORT)); + } + + static final PostgresConnector CONNECTOR = new PostgresConnector(); + static final PostgresExecutor EXECUTOR = new PostgresExecutor(); + + private PostgresContainerSupport() { + } + + static String host() { + return POSTGRES.getHost(); + } + + static int port() { + return POSTGRES.getMappedPort(PG_PORT); + } + + static PostgresDatasourceConfig config(String password, boolean ssl, boolean readonly, boolean enableTurnOffPreparedStatement) { + return PostgresDatasourceConfig.builder().database(DATABASE).username(USER).password(password).host(host()).port((long) port()) + .usingSsl(ssl).isReadonly(readonly).enableTurnOffPreparedStatement(enableTurnOffPreparedStatement).build(); + } + + static PostgresDatasourceConfig config() { + return config(PASSWORD, false, false, false); + } + + static HikariPerfWrapper connect(PostgresDatasourceConfig config) { + return CONNECTOR.createConnection(config).block(); + } + + static void destroy(HikariPerfWrapper wrapper) { + CONNECTOR.destroyConnection(wrapper).block(); + } + + /** Runs a query config through the executor as the server does: build the context, execute, return the data. */ + static Object run(HikariPerfWrapper wrapper, PostgresDatasourceConfig config, Map queryConfig, Map params) { + SqlBasedQueryExecutionContext context = EXECUTOR.buildQueryExecutionContext(config, queryConfig, params, null); + return EXECUTOR.executeQuery(wrapper, context).block().getData(); + } + + static Object sql(HikariPerfWrapper wrapper, PostgresDatasourceConfig config, String sql, Map params) { + return run(wrapper, config, sqlConfig(sql, false), params); + } + + static Map sqlConfig(String sql, boolean disablePreparedStatement) { + Map config = new LinkedHashMap<>(); + config.put("mode", "SQL"); + config.put("sql", sql); + config.put("disablePreparedStatement", disablePreparedStatement); + return config; + } + + static Map guiConfig(String type, Map command) { + Map config = new LinkedHashMap<>(); + config.put("mode", "GUI"); + config.put("commandType", type); + config.put("command", command); + return config; + } + + /** A plain JDBC connection of the application user (not the code under test). */ + static Connection jdbc() { + try { + return DriverManager.getConnection("jdbc:postgresql://" + host() + ":" + port() + "/" + DATABASE + "?sslmode=disable", USER, PASSWORD); + } catch (SQLException e) { + throw new IllegalStateException("cannot connect", e); + } + } + + static void execute(Connection connection, String... statements) { + try (Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } catch (SQLException e) { + throw new IllegalStateException("statement failed", e); + } + } + + static List> rows(Connection connection, String sql) { + List> rows = new ArrayList<>(); + try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) { + ResultSetMetaData metaData = resultSet.getMetaData(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= metaData.getColumnCount(); i++) { + row.put(metaData.getColumnLabel(i), resultSet.getObject(i)); + } + rows.add(row); + } + } catch (SQLException e) { + throw new IllegalStateException("read failed: " + sql, e); + } + return rows; + } +} diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresDataTypeUtilsTest.java b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresDataTypeUtilsTest.java new file mode 100644 index 0000000000..79983fbc25 --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresDataTypeUtilsTest.java @@ -0,0 +1,148 @@ +package org.lowcoder.plugin.postgres; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.postgres.model.DataType; +import org.lowcoder.plugin.postgres.utils.PostgresDataTypeUtils; + +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.Time; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.plugin.postgres.utils.PostgresDataTypeUtils.castValueWithTargetType; +import static org.lowcoder.plugin.postgres.utils.PostgresDataTypeUtils.extractExplicitCasting; + +/** + * Unit PG-1 (task L5-4), no server: the {@code ?::type} cast scanner and the value conversion of + * {@link PostgresDataTypeUtils}. + */ +public class PostgresDataTypeUtilsTest { + + static final Set SUPPORTED = Set.of("int8", "int4", "decimal", "varchar", "bool", "date", "time", "float8", "text", "int"); + + @Test + public void extractExplicitCastingMapsEachSupportedTypeInAnyCase() { + Map observed = new LinkedHashMap<>(); + for (String type : new String[] {"int8", "int4", "decimal", "varchar", "bool", "date", "time", "float8", "text", "int"}) { + observed.put(type, extractExplicitCasting("select ?::" + type)); + assertEquals(observed.get(type), extractExplicitCasting("select ?::" + type.toUpperCase()), type + " in upper case"); + } + // int8, int4 and float8 are asserted in castPatternReadsLettersOnly (plan section 9 row) + System.out.println("[PostgresDataTypeUtilsTest] casts as observed: " + observed); + assertEquals(List.of(DataType.STRING), observed.get("varchar")); + assertEquals(List.of(DataType.BOOLEAN), observed.get("bool")); + assertEquals(List.of(DataType.DATE), observed.get("date")); + assertEquals(List.of(DataType.TIME), observed.get("time")); + assertEquals(List.of(DataType.STRING), observed.get("text")); + assertEquals(List.of(DataType.INTEGER), observed.get("int")); + assertEquals(List.of(DataType.FLOAT), observed.get("decimal")); + } + + /** + * Pins the plan section 9 row "PostgresDataTypeUtils' cast pattern ... reads letters only" (D-6: fix deferred): the + * pattern {@code [a-zA-Z]+} stops at a digit, so {@code ?::int8} is read as {@code int} (INTEGER, not LONG), {@code ?::float8} + * as {@code float} (not a supported name: no cast) and {@code ?::int4} only works by accident. A fix (digits in the + * pattern) changes this test on purpose. + */ + @Test + public void castPatternReadsLettersOnly_int8IsReadAsInt() { + assertEquals(List.of(DataType.INTEGER), extractExplicitCasting("select ?::int8"), "int8 is read as int"); + assertEquals(List.of(DataType.INTEGER), extractExplicitCasting("select ?::int4")); + assertEquals(Arrays.asList((DataType) null), extractExplicitCasting("select ?::float8"), "float8 is read as float: no cast"); + System.out.println("[PostgresDataTypeUtilsTest] int8 -> " + extractExplicitCasting("?::int8") + ", float8 -> " + extractExplicitCasting("?::float8")); + } + + @Test + public void unknownTypeAndNoCastGiveNullAtTheRightPosition() { + List casts = extractExplicitCasting("select ?::text, ?, ?::bool, ?::numeric"); + System.out.println("[PostgresDataTypeUtilsTest] positions: " + casts); + assertEquals(Arrays.asList(DataType.STRING, null, DataType.BOOLEAN, null), casts); + assertEquals(List.of(), extractExplicitCasting("select 1")); + } + + @Test + public void castValueWithTargetTypeForEveryDataType() { + assertEquals(42, castValueWithTargetType("42", DataType.INTEGER)); + assertEquals(42L, castValueWithTargetType("42", DataType.LONG)); + assertEquals(1.5f, castValueWithTargetType("1.5", DataType.FLOAT)); + assertEquals(1.5d, castValueWithTargetType("1.5", DataType.DOUBLE)); + assertEquals(true, castValueWithTargetType("true", DataType.BOOLEAN)); + assertEquals(Date.valueOf("2024-02-29"), castValueWithTargetType("2024-02-29", DataType.DATE)); + assertEquals(Time.valueOf("13:14:15"), castValueWithTargetType("13:14:15", DataType.TIME)); + assertEquals(Timestamp.valueOf("2024-02-29 13:14:15"), castValueWithTargetType("2024-02-29 13:14:15", DataType.TIMESTAMP)); + assertNull(castValueWithTargetType("x", DataType.NULL)); + for (DataType type : new DataType[] {DataType.STRING, DataType.ASCII, DataType.BINARY, DataType.BYTES, DataType.BSON}) { + assertEquals("7", castValueWithTargetType(7, type), type.name()); + } + // a value that already has the target class comes back as the same object + Integer integer = 1000; + Long aLong = 1000L; + Float aFloat = 1.5f; + Double aDouble = 1.5d; + Date date = Date.valueOf("2024-02-29"); + Time time = Time.valueOf("13:14:15"); + Timestamp timestamp = Timestamp.valueOf("2024-02-29 13:14:15"); + assertSame(integer, castValueWithTargetType(integer, DataType.INTEGER)); + assertSame(aLong, castValueWithTargetType(aLong, DataType.LONG)); + assertSame(aFloat, castValueWithTargetType(aFloat, DataType.FLOAT)); + assertSame(aDouble, castValueWithTargetType(aDouble, DataType.DOUBLE)); + assertSame(Boolean.TRUE, castValueWithTargetType(Boolean.TRUE, DataType.BOOLEAN)); + assertSame(date, castValueWithTargetType(date, DataType.DATE)); + assertSame(time, castValueWithTargetType(time, DataType.TIME)); + assertSame(timestamp, castValueWithTargetType(timestamp, DataType.TIMESTAMP)); + List list = List.of(1, 2); + Map map = Map.of("a", 1); + assertSame(list, castValueWithTargetType(list, DataType.ARRAY)); + assertEquals("[1, 2]", castValueWithTargetType("[1, 2]", DataType.ARRAY)); + assertEquals("7", castValueWithTargetType(7, DataType.ARRAY)); + assertSame(list, castValueWithTargetType(list, DataType.JSON_OBJECT)); + assertSame(map, castValueWithTargetType(map, DataType.JSON_OBJECT)); + assertEquals("7", castValueWithTargetType(7, DataType.JSON_OBJECT)); + System.out.println("[PostgresDataTypeUtilsTest] " + DataType.values().length + " data types converted"); + } + + /** + * Pins the plan section 9 row "PostgresDataTypeUtils casts a bound text value ... raw NumberFormatException / + * IllegalArgumentException instead of a coded PluginException" (D-6: fix deferred): a text that does not parse as the + * cast type escapes as the JDK's own exception. A fix (a PluginException) changes this test on purpose. + */ + @Test + public void invalidTextEscapesAsRawParseExceptions() { + assertThrows(NumberFormatException.class, () -> castValueWithTargetType("12.5", DataType.INTEGER)); + assertThrows(NumberFormatException.class, () -> castValueWithTargetType("x", DataType.LONG)); + assertThrows(IllegalArgumentException.class, () -> castValueWithTargetType("not-a-date", DataType.DATE)); + assertThrows(IllegalArgumentException.class, () -> castValueWithTargetType("yesterday", DataType.TIMESTAMP)); + System.out.println("[PostgresDataTypeUtilsTest] raw exceptions for 12.5 -> INTEGER, not-a-date -> DATE"); + } + + /** + * Pins the plan section 9 row "PostgresDataTypeUtils maps an explicit ?::decimal cast to FLOAT ... precision is lost" + * (D-6: fix deferred): the bound text becomes a Java float before the database sees it. A fix (BigDecimal) changes this + * test on purpose; the loss through a real server is shown by {@code PostgresDatabaseTest}. + */ + @Test + public void decimalCastGoesThroughAFloatAndLosesPrecision() { + assertEquals(List.of(DataType.FLOAT), extractExplicitCasting("select ?::decimal")); + Object cast = castValueWithTargetType("12345678.9", DataType.FLOAT); + assertEquals(Float.class, cast.getClass()); + assertEquals(1.23456792E7f, cast); + assertEquals(false, new BigDecimal("12345678.9").compareTo(new BigDecimal(String.valueOf(cast))) == 0, "the digits after the float's precision are gone"); + System.out.println("[PostgresDataTypeUtilsTest] 12345678.9 cast as decimal -> " + cast); + } + + @Test + public void dataTypesAreTheSupportedNamesAndStable() { + Set first = PostgresDataTypeUtils.dataType.getDataTypes(); + assertEquals(SUPPORTED, first); + assertSame(first, PostgresDataTypeUtils.dataType.getDataTypes()); + } +} diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresDatabaseTest.java b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresDatabaseTest.java new file mode 100644 index 0000000000..a82506b46f --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresDatabaseTest.java @@ -0,0 +1,335 @@ +package org.lowcoder.plugin.postgres; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.DatasourceStructure.Column; +import org.lowcoder.sdk.models.DatasourceStructure.ForeignKey; +import org.lowcoder.sdk.models.DatasourceStructure.PrimaryKey; +import org.lowcoder.sdk.models.DatasourceStructure.Table; +import org.lowcoder.sdk.models.DatasourceStructure.TableType; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.CONNECTOR; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.EXECUTOR; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.PASSWORD; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.config; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.connect; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.destroy; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.execute; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.guiConfig; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.jdbc; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.rows; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.run; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.sql; +import static org.lowcoder.plugin.postgres.PostgresContainerSupport.sqlConfig; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; + +/** + * Units PG-4, PG-5 and PG-6 (task L5-4): {@code PostgresConnector}, {@code PostgresExecutor}, {@code PostgresResultParser} + * and the GUI commands against a real {@code postgres:16-alpine} (the image pinned in {@code ContainerImages.POSTGRES_16}, the + * module's {@code postgresql 42.3.8} driver). Each test uses its own tables, or its own schemas. TLS behaviour of the pinned + * image: none is configured, so a pool with usingSsl fails with "The server does not support SSL". + */ +public class PostgresDatabaseTest { + + static final String WRONG_PASSWORD = "not-the-password"; + static final int POSTGRES_POOL_SIZE = 100; + static final String INJECTION = "x'; drop table t_gui; --"; + + private static Object cell(Object data, String column) { + @SuppressWarnings("unchecked") + List> rows = (List>) data; + return rows.get(0).get(column); + } + + private static Map keyValues(Object... columnsAndValues) { + List> comp = new ArrayList<>(); + for (int i = 0; i < columnsAndValues.length; i += 2) { + comp.add(Map.of("column", columnsAndValues[i], "value", columnsAndValues[i + 1])); + } + return Map.of("compType", "KEY_VALUE_PAIRS", "comp", comp); + } + + private static Map filterOn(String column, boolean allowMultiModify) { + return Map.of("allowMultiModify", allowMultiModify, "filterBy", List.of(Map.of("column", column, "condition", "=", "value", "{{key}}"))); + } + + // ---- PG-4: connector + + @Test + public void poolOpensRunsAQueryAndTestConnectionSucceeds() { + PostgresDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + HikariDataSource dataSource = (HikariDataSource) pool.getHikariDataSource(); + assertTrue(dataSource.isRunning()); + assertEquals("org.postgresql.Driver", dataSource.getDriverClassName()); + assertEquals(POSTGRES_POOL_SIZE, dataSource.getMaximumPoolSize()); + assertEquals("jdbc:postgresql://" + PostgresContainerSupport.host() + ":" + PostgresContainerSupport.port() + "/app", dataSource.getJdbcUrl()); + assertEquals(1, cell(sql(pool, config, "select 1 as one", Map.of()), "one")); + DatasourceTestResult test = CONNECTOR.testConnection(config).block(); + assertNotNull(test); + assertTrue(test.isSuccess()); + System.out.println("[PostgresDatabaseTest] pool " + dataSource.getJdbcUrl() + ", test connection succeeded"); + } finally { + destroy(pool); + } + } + + @Test + public void wrongPasswordIsReportedWithinTheInitTimeout() { + long start = System.nanoTime(); + RuntimeException thrown = assertThrows(RuntimeException.class, () -> connect(config(WRONG_PASSWORD, false, false, false))); + long millis = (System.nanoTime() - start) / 1_000_000; + System.out.println("[PostgresDatabaseTest] wrong password: " + thrown.getClass().getSimpleName() + " after " + millis + " ms: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("password authentication failed"), thrown.getMessage()); + assertTrue(millis < 10_000, "took " + millis + " ms"); + } + + @Test + public void readonlyPoolRejectsWritesAndWritablePoolAccepts() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table if exists t_readonly", "create table t_readonly (id int)"); + PostgresDatasourceConfig readonly = config(PASSWORD, false, true, false); + HikariPerfWrapper readonlyPool = connect(readonly); + try { + PluginException thrown = assertThrows(PluginException.class, () -> sql(readonlyPool, readonly, "insert into t_readonly values (1)", Map.of())); + assertEquals(QUERY_EXECUTION_ERROR, thrown.getError()); + System.out.println("[PostgresDatabaseTest] read-only insert refused: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("read-only"), thrown.getMessage()); + } finally { + destroy(readonlyPool); + } + assertEquals(0, rows(jdbc, "select * from t_readonly").size()); + PostgresDatasourceConfig writable = config(); + HikariPerfWrapper writablePool = connect(writable); + try { + sql(writablePool, writable, "insert into t_readonly values (1)", Map.of()); + } finally { + destroy(writablePool); + } + assertEquals(1, rows(jdbc, "select * from t_readonly").size()); + } + } + + @Test + public void sslAgainstAServerWithoutTlsFailsAndSslOffWorks() { + RuntimeException thrown = assertThrows(RuntimeException.class, () -> connect(config(PASSWORD, true, false, false))); + System.out.println("[PostgresDatabaseTest] ssl against the non-TLS image: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("does not support SSL"), thrown.getMessage()); + destroy(connect(config(PASSWORD, false, false, false))); + } + + /** + * Shows through the server the plan section 9 row "PostgresDataTypeUtils maps an explicit ?::decimal cast to FLOAT": + * the decimals of the bound text are gone when the database answers. A fix (BigDecimal) changes this test on purpose. + */ + @Test + public void explicitCastsRoundTripAndDecimalLosesItsDigitsThroughTheServer() { + PostgresDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + Object data = sql(pool, config, "select {{a}}::int4 as a, {{b}}::decimal as b, {{c}}::date as c, {{d}}::bool as d, {{e}}::text as e, {{f}}::float8 as f", + Map.of("a", "42", "b", "12345678.123456", "c", "2024-02-29", "d", "true", "e", "x", "f", "0.1")); + System.out.println("[PostgresDatabaseTest] casts through the server: " + data); + assertEquals(42, cell(data, "a")); + assertEquals("2024-02-29", cell(data, "c")); + assertEquals(true, cell(data, "d")); + assertEquals("x", cell(data, "e")); + assertEquals(0.1d, ((Number) cell(data, "f")).doubleValue()); + assertEquals("12345678", String.valueOf(cell(data, "b")), "the digits after the point of ?::decimal are lost"); + } finally { + destroy(pool); + } + } + + @Test + public void realDriverReturnsWhatTheResultContractCellsAssume() { + PostgresDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + Object data = sql(pool, config, """ + select '{"zeta": 1, "alpha": [1.50, 3000000001, null]}'::jsonb as jb, '{"a":1}'::json as js, null::json as jn, + array[1,null,3] as ai, array['a','žluť'] as at, array[1.50::numeric] as an, '192.168.0.1'::inet as ip, + timestamptz '2024-02-29 13:14:15.123+05:30' as tz, timestamp '2024-02-29 13:14:15.123' as ts, date '2024-02-29' as d, + time '13:14:15' as t, timetz '13:14:15+02' as ttz, interval '1 year 2 mons 3 days 04:05:06.5' as iv, + '123e4567-e89b-12d3-a456-426614174000'::uuid as u, 10.50::numeric as n, 9223372036854775807::int8 as i8, 0.1::float8 as f8, + true as b, '\\x0001ff'::bytea as by""", Map.of()); + assertEquals("{\"zeta\":1,\"alpha\":[1.5,3000000001,null]}", String.valueOf(cell(data, "jb"))); + assertEquals("{\"a\":1}", String.valueOf(cell(data, "js"))); + assertEquals(null, cell(data, "jn")); + assertEquals(Arrays.asList(1, null, 3), Arrays.asList((Object[]) cell(data, "ai"))); + assertEquals(List.of("a", "žluť"), Arrays.asList((Object[]) cell(data, "at"))); + assertEquals(1, ((Object[]) cell(data, "an")).length); + assertEquals("192.168.0.1", cell(data, "ip"), "a type JDBC does not know reaches the result as the text of its PGobject"); + assertEquals("2024-02-29T07:44:15.123Z", cell(data, "tz")); + assertEquals("2024-02-29 13:14:15", cell(data, "ts")); + assertEquals("2024-02-29", cell(data, "d")); + assertEquals("13:14:15", cell(data, "t")); + assertEquals("13:14:15+02", cell(data, "ttz")); + assertEquals("1 years 2 mons 3 days 4 hours 5 mins 6.5 secs", cell(data, "iv")); + assertEquals(java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), cell(data, "u")); + assertEquals(new java.math.BigDecimal("10.50"), cell(data, "n")); + assertEquals(Long.MAX_VALUE, cell(data, "i8")); + assertEquals(0.1d, cell(data, "f8")); + assertEquals(true, cell(data, "b")); + assertTrue(Arrays.equals(new byte[] {0, 1, (byte) 0xFF}, (byte[]) cell(data, "by"))); + } finally { + destroy(pool); + } + } + + // ---- PG-5: structure + + private static Table table(DatasourceStructure structure, String fullName) { + return structure.getTables().stream().filter(t -> t.getName().equals(fullName)).findFirst().orElseThrow(); + } + + @Test + public void structureOfTablesViewsSerialAndKeys() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop schema if exists struct_a cascade", "drop schema if exists struct_b cascade", "create schema struct_a", "create schema struct_b", + "create table struct_b.remote (id int primary key)", + "create table struct_a.parent (id serial primary key, id2 int, note text default 'n', unique (id, id2))", + "create view struct_a.v_parent as select id from struct_a.parent", + "create table struct_a.pk2 (a int, b int, c int, primary key (a, b))", + "create table struct_a.child (cid int not null, pid int, pid2 int, oid int, " + + "constraint fk_same foreign key (pid, pid2) references struct_a.parent (id, id2), " + + "constraint fk_other foreign key (oid) references struct_b.remote (id))", + // the primary key is added last and named to sort after the foreign keys, so the query is likely to return it last + "alter table struct_a.child add constraint zz_pk primary key (cid)"); + } + PostgresDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + DatasourceStructure structure = EXECUTOR.getStructure(pool, config).block(); + List names = structure.getTables().stream().map(Table::getName).toList(); + System.out.println("[PostgresDatabaseTest] tables: " + names); + assertTrue(names.containsAll(List.of("struct_a.parent", "struct_a.v_parent", "struct_a.pk2", "struct_a.child", "struct_b.remote")), names.toString()); + assertTrue(names.stream().noneMatch(n -> n.startsWith("pg_catalog.") || n.startsWith("information_schema.")), "system schemas are not listed"); + + Table parent = table(structure, "struct_a.parent"); + assertEquals(TableType.TABLE, parent.getType()); + assertEquals("struct_a", parent.getSchema()); + assertEquals(List.of("id", "id2", "note"), parent.getColumns().stream().map(Column::getName).toList()); + assertEquals(List.of(true, false, false), parent.getColumns().stream().map(Column::getIsAutogenerated).toList(), "only the serial column is generated"); + assertEquals(TableType.VIEW, table(structure, "struct_a.v_parent").getType()); + + PrimaryKey composite = assertInstanceOf(PrimaryKey.class, table(structure, "struct_a.pk2").getKeys().get(0)); + assertEquals(List.of("a", "b"), composite.getColumnNames()); + + List keys = table(structure, "struct_a.child").getKeys(); + assertEquals(3, keys.size()); + assertInstanceOf(PrimaryKey.class, keys.get(0), "keys are sorted, the primary key before the foreign keys"); + ForeignKey same = keys.stream().filter(ForeignKey.class::isInstance).map(ForeignKey.class::cast).filter(k -> k.getName().equals("fk_same")).findFirst().orElseThrow(); + ForeignKey other = keys.stream().filter(ForeignKey.class::isInstance).map(ForeignKey.class::cast).filter(k -> k.getName().equals("fk_other")).findFirst().orElseThrow(); + assertEquals(List.of("pid", "pid2"), same.getFromColumns()); + assertEquals(List.of("parent.id", "parent.id2"), same.getToColumns(), "same schema: no schema prefix"); + assertEquals(List.of("struct_b.remote.id"), other.getToColumns(), "other schema: prefixed"); + assertEquals(1, parent.getKeys().size(), "the unique constraint is not a key of the structure"); + } finally { + destroy(pool); + } + } + + // ---- PG-6: GUI commands, guard, sort + + private Object gui(String type, Map command, Map params) { + PostgresDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try { + return run(pool, config, guiConfig(type, command), params); + } finally { + destroy(pool); + } + } + + @Test + public void guiCommandsChangeTheRealTableAndAnInjectionValueIsStoredAsText() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table if exists t_gui", "drop table if exists \"Odd \"\"name\"", "create table t_gui (id int primary key, name text)", + "create table \"Odd \"\"name\" (id int primary key, name text)"); + Object inserted = gui("insert", Map.of("table", "t_gui", "changeSet", keyValues("id", "{{id}}", "name", "{{name}}")), Map.of("id", 1, "name", INJECTION)); + System.out.println("[PostgresDatabaseTest] insert: " + inserted); + assertEquals(1, ((Map) inserted).get("affectedRows")); + assertEquals(List.of(Map.of("id", 1, "name", INJECTION)), rows(jdbc, "select id, name from t_gui"), "the value is stored as text, the table and schema are intact"); + + gui("UPDATE", Map.of("table", "t_gui", "changeSet", keyValues("name", "{{name}}"), "allowMultiModify", true, + "filterBy", List.of(Map.of("column", "id", "condition", "=", "value", "{{id}}"))), Map.of("id", 1, "name", "o'brien")); + assertEquals("o'brien", rows(jdbc, "select name from t_gui where id = 1").get(0).get("name")); + + gui("BULK_INSERT", Map.of("table", "t_gui", "records", "[{\"id\":2,\"name\":\"b\"},{\"id\":3,\"name\":\"c\"}]"), Map.of()); + assertEquals(List.of("o'brien", "b", "c"), rows(jdbc, "select name from t_gui order by id").stream().map(r -> r.get("name")).toList()); + gui("BULK_UPDATE", Map.of("table", "t_gui", "primaryKey", "id", "records", "[{\"id\":2,\"name\":\"B\"},{\"id\":3,\"name\":\"C\"}]"), Map.of()); + assertEquals(List.of("o'brien", "B", "C"), rows(jdbc, "select name from t_gui order by id").stream().map(r -> r.get("name")).toList()); + + Map deleteDetail = new HashMap<>(filterOn("id", false)); + deleteDetail.put("table", "t_gui"); + gui("DELETE", deleteDetail, Map.of("key", 3)); + assertEquals(List.of(1, 2), rows(jdbc, "select id from t_gui order by id").stream().map(r -> r.get("id")).toList()); + + gui("INSERT", Map.of("table", "\"Odd \"\"name\"", "changeSet", keyValues("id", "{{id}}", "name", "{{name}}")), Map.of("id", 7, "name", "quoted")); + assertEquals("quoted", rows(jdbc, "select name from \"Odd \"\"name\"").get(0).get("name")); + } + } + + @Test + public void singleRowGuardOnARealServer() throws Exception { + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table if exists t_guard", "create table t_guard (id int, grp int, marker text)", + "insert into t_guard values (1, 1, 'orig'), (2, 1, 'orig'), (3, 2, 'orig')"); + Map update = new HashMap<>(filterOn("grp", false)); + update.put("table", "t_guard"); + update.put("changeSet", keyValues("marker", "changed")); + PluginException thrown = assertThrows(PluginException.class, () -> gui("UPDATE", update, Map.of("key", 1))); + assertEquals(QUERY_EXECUTION_ERROR, thrown.getError()); + assertEquals("AFFECT_MORE_THAN_ONE_ROWS_FOR_SINGLE_COMMAND", thrown.getMessageKey()); + assertEquals(3, rows(jdbc, "select * from t_guard where marker = 'orig'").size(), "nothing changes when the guard refuses"); + assertEquals(1, ((Map) gui("UPDATE", update, Map.of("key", 2))).get("affectedRows")); + assertEquals(1, rows(jdbc, "select * from t_guard where marker = 'changed'").size()); + Map delete = new HashMap<>(filterOn("grp", false)); + delete.put("table", "t_guard"); + assertThrows(PluginException.class, () -> gui("DELETE", delete, Map.of("key", 1))); + assertEquals(3, rows(jdbc, "select * from t_guard").size()); + assertEquals(1, ((Map) gui("DELETE", delete, Map.of("key", 2))).get("affectedRows")); + assertEquals(2, rows(jdbc, "select * from t_guard").size()); + } + } + + @Test + public void sortPlaceholderOrdersRowsAndAnInjectedValueFallsBackToAsc() throws Exception { + PostgresDatasourceConfig config = config(); + HikariPerfWrapper pool = connect(config); + try (Connection jdbc = jdbc()) { + execute(jdbc, "drop table if exists t_sort", "create table t_sort (id int)", "insert into t_sort values (1), (2), (3)"); + String query = "select id from t_sort order by id {{dir}}"; + assertEquals(List.of(3, 2, 1), ids(sql(pool, config, query, Map.of("dir", Map.of("sort", "desc"))))); + assertEquals(List.of(1, 2, 3), ids(sql(pool, config, query, Map.of("dir", Map.of("sort", "x; drop table t_sort"))))); + assertEquals(3, rows(jdbc, "select * from t_sort").size(), "the table survives the injected sort value"); + } finally { + destroy(pool); + } + } + + private static List ids(Object data) { + @SuppressWarnings("unchecked") + List> rows = (List>) data; + return rows.stream().map(r -> r.get("id")).toList(); + } +} diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresExecutorPreparedInputTest.java b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresExecutorPreparedInputTest.java new file mode 100644 index 0000000000..582af47914 --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresExecutorPreparedInputTest.java @@ -0,0 +1,136 @@ +package org.lowcoder.plugin.postgres; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.UpdateCount; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresBulkInsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresBulkUpdateCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresDeleteCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresInsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresUpdateCommand; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_ARGUMENT_ERROR; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; + +/** + * Unit PG-2 (task L5-4): what {@link PostgresExecutor} binds for a prepared query with explicit casts, on the + * {@link FakeJdbc} connection that records every {@code set...} call (the executor goes through + * {@code PostgresResultContractTest.wrap}, the same never-started pool), and the GUI command dispatch. + */ +public class PostgresExecutorPreparedInputTest { + + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map KEY_VALUES = Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", "a", "value", "1"))); + static final List> FILTER = List.of(Map.of("column", "id", "condition", "=", "value", "1")); + static final Map> DETAILS = Map.of( + "insert", Map.of("table", "t", "changeSet", KEY_VALUES), + "update", Map.of("table", "t", "changeSet", KEY_VALUES, "filterBy", FILTER), + "delete", Map.of("table", "t", "filterBy", FILTER), + "bulk_insert", Map.of("table", "t", "records", "[{\"a\":1}]"), + "bulk_update", Map.of("table", "t", "primaryKey", "a", "records", "[{\"a\":1}]")); + static final Map> TYPES = Map.of( + "insert", PostgresInsertCommand.class, "update", PostgresUpdateCommand.class, "delete", PostgresDeleteCommand.class, + "bulk_insert", PostgresBulkInsertCommand.class, "bulk_update", PostgresBulkUpdateCommand.class); + + private final PostgresExecutor executor = new PostgresExecutor(); + + private List binds(String sql, Map params) { + List binds = new ArrayList<>(); + SqlBasedQueryExecutionContext context = SqlBasedQueryExecutionContext.builder().query(sql).requestParams(params).build(); + executor.executeQuery(PostgresResultContractTest.wrap(FakeJdbc.connection(List.of(new UpdateCount(1, null)), binds)), context).block(TIMEOUT); + System.out.println("[PostgresExecutorPreparedInputTest] " + sql + " " + params + " -> " + binds); + return binds; + } + + @Test + public void castBindsTheCastJavaTypeAtTheRightPosition() { + assertEquals(List.of("setInt(1, java.lang.Integer:41)"), binds("select {{n}}::int4 + 1", Map.of("n", "41"))); + assertEquals(List.of("setDate(1, java.sql.Date:2024-02-29)"), binds("select {{d}}::date", Map.of("d", "2024-02-29"))); + assertEquals(List.of("setString(1, java.lang.String:5)"), binds("select {{t}}::text", Map.of("t", 5))); + List mixed = binds("select {{a}}::bool, {{b}}, {{c}}::varchar", Map.of("a", "true", "b", 7, "c", 8)); + assertEquals(List.of("setBoolean(1, java.lang.Boolean:true)", "setInt(2, java.lang.Integer:7)", "setString(3, java.lang.String:8)"), mixed); + } + + /** + * Pins the plan section 9 row "PostgresDataTypeUtils' cast scanner reads the whole prepared SQL" (D-6: fix deferred): the + * scanner finds every {@code ?} of the SQL text, not only the parameters, so a {@code ?::bool} inside a string literal takes + * the cast slot of the first parameter, and the jsonb {@code ??} operator shifts the casts by one. A fix (a scanner that + * skips literals and escaped operators) changes this test on purpose. + */ + @Test + public void castScannerReadsTheWholeSqlSoALiteralAndTheJsonbOperatorShiftTheCasts() { + assertEquals(List.of("setBoolean(1, java.lang.Boolean:false)"), binds("select '?::bool', {{b}}::int4", Map.of("b", "5")), + "the cast of the literal is applied to the parameter: 5 becomes false"); + assertEquals(List.of("setString(1, java.lang.String:5)"), binds("select data ?? 'k' from t where x = {{b}}::int4", Map.of("b", "5")), + "the ?? operator takes the cast slot: 5 stays a string"); + } + + /** The row of {@code PostgresDataTypeUtilsTest.castPatternReadsLettersOnly_int8IsReadAsInt}, seen through the executor. */ + @Test + public void int8CastIsReadAsIntSoABigNumberFailsWithARawNumberFormatException() { + assertThrows(NumberFormatException.class, () -> binds("select {{a}}::int8", Map.of("a", "3000000001"))); + assertEquals(List.of("setString(1, java.lang.String:0.1)"), binds("select {{a}}::float8", Map.of("a", "0.1")), "float8 is not cast at all"); + } + + @Test + public void queryWithoutPlaceholdersBindsNothing() { + assertEquals(List.of(), binds("select 1", Map.of("unused", 1))); + } + + @Test + public void missingKeyIsBoundValueNotMatchAndNullValueIsBoundAsNull() { + PluginException thrown = assertThrows(PluginException.class, () -> binds("select {{missing}}::int4", Map.of("other", 1))); + assertEquals(QUERY_EXECUTION_ERROR, thrown.getError()); + assertEquals("BOUND_VALUE_NOT_MATCH", thrown.getMessageKey()); + assertEquals("missing", thrown.getArgs()[0]); + Map withNull = new HashMap<>(); + withNull.put("n", null); + List nullBinds = binds("select {{n}}", withNull); + assertEquals(1, nullBinds.size()); + assertEquals(true, nullBinds.get(0).startsWith("setNull(1,"), nullBinds.get(0)); + } + + @Test + public void parseSqlCommandMapsEachGuiTypeInAnyCase() { + TYPES.forEach((type, commandClass) -> { + assertInstanceOf(commandClass, executor.parseSqlCommand(type, DETAILS.get(type)), type); + assertInstanceOf(commandClass, executor.parseSqlCommand(type.toUpperCase(Locale.ROOT), DETAILS.get(type)), type.toUpperCase(Locale.ROOT)); + }); + PluginException thrown = assertThrows(PluginException.class, () -> executor.parseSqlCommand("merge", DETAILS.get("insert"))); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals("INVALID_GUI_COMMAND_TYPE", thrown.getMessageKey()); + assertEquals("merge", thrown.getArgs()[0]); + } + + /** + * Pins defect D17 (plan section 9: default-locale toUpperCase): under a Turkish default locale "insert" becomes a dotted + * capital I word and falls into the error branch. A fix ({@code Locale.ROOT}) changes this test on purpose. The default + * locale is global state: restored in finally. + */ + @Test + public void guiTypeInsertFailsUnderATurkishDefaultLocale_pinsD17() { + Locale saved = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + System.out.println("[PostgresExecutorPreparedInputTest] default locale: " + Locale.getDefault() + ", upper case of insert: " + "insert".toUpperCase()); + PluginException thrown = assertThrows(PluginException.class, () -> executor.parseSqlCommand("insert", DETAILS.get("insert"))); + assertEquals("INVALID_GUI_COMMAND_TYPE", thrown.getMessageKey()); + } finally { + Locale.setDefault(saved); + } + assertInstanceOf(PostgresInsertCommand.class, executor.parseSqlCommand("insert", DETAILS.get("insert")), "locale restored: " + Locale.getDefault()); + } +} diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresResultContractTest.java b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresResultContractTest.java new file mode 100644 index 0000000000..86745a45af --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/java/org/lowcoder/plugin/postgres/PostgresResultContractTest.java @@ -0,0 +1,154 @@ +package org.lowcoder.plugin.postgres; + +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.Column; +import org.lowcoder.sdk.contract.FakeJdbc.Rows; +import org.lowcoder.sdk.contract.FakeJdbc.SqlArray; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.contract.RepresentativeInput; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.postgresql.util.PGInterval; +import org.postgresql.util.PGobject; + +import java.lang.reflect.Proxy; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Group {@code external-json} of {@code PostgresResultParser} and the Postgres columns of the §4.6 producer table + * (docs/API_PAYLOAD_TEST_PLAN.md §4.6, §4.10, task T8.2): {@code PostgresExecutor.executeQuery} on a data source whose + * connection ({@link FakeJdbc}) answers one row of {@link #cells}, typed by Postgres type name. {@code json} and + * {@code jsonb} columns are read into a tree by the production mapper ({@code readTree}), arrays become the Java array + * of {@code getArray().getArray()}, a {@link PGobject} its text, dates and times text, and the rest reaches Jackson as + * {@code getObject} gives it. A {@code json} column whose text does not parse fails the query + * ({@link #invalidJsonFailsTheQuery}). Pinned in {@value #REPORT}. + * + *

    Limits: the cells are what the Postgres driver's {@code getObject} returns for these types, built by hand; the + * {@code timestamp} conversion uses the JVM zone on both sides, so it is zone-independent. + */ +public class PostgresResultContractTest { + + static final String REPORT = "external-json/PostgresResultParser.rows.json"; + static final String QUERY = "select * from items"; + static final String INVALID_JSON = "{\"a\": 1,"; + static final Duration TIMEOUT = Duration.ofSeconds(20); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final PostgresExecutor executor = new PostgresExecutor(); + + @BoundarySites("lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/utils/PostgresResultParser.java#PostgresResultParser.parseValue#readTree#1") + @Test + public void rowsAsPinned() throws SQLException { + Object report = report(cells()); + String actual = ConfigBinding.write(report); + System.out.println("[PostgresResultContractTest]\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** A {@code json} cell whose text does not parse: the parser's {@code JsonProcessingException} fails the query. */ + @Test + public void invalidJsonFailsTheQuery() throws SQLException { + Map cells = new LinkedHashMap<>(); + cells.put("json", pg("json", INVALID_JSON)); + Object report = report(cells); + System.out.println("[PostgresResultContractTest] invalid json: " + report); + if (!report.equals(Map.of(QueryResults.ERROR_KEY, + "org.lowcoder.sdk.exception.PluginException: QUERY_EXECUTION_ERROR QUERY_EXECUTION_ERROR"))) { + throw new AssertionError("an invalid json column must fail the query, got " + report); + } + } + + private Object report(Map cells) { + List columns = new ArrayList<>(); + cells.keySet().forEach(name -> columns.add(new Column(name, typeName(name)))); + Connection connection = FakeJdbc.connection(List.of(new Rows(FakeJdbc.resultSet(columns, List.of(new ArrayList<>(cells.values())))))); + SqlBasedQueryExecutionContext context = SqlBasedQueryExecutionContext.builder().query(QUERY).requestParams(Map.of()).build(); + try { + QueryExecutionResult result = executor.executeQuery(wrap(connection), context).block(TIMEOUT); + return result == null ? null : QueryResults.report(result); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + } + + /** The Postgres type name of a column: its name up to the first {@code $}. */ + private static String typeName(String column) { + return column.contains("$") ? column.substring(0, column.indexOf('$')) : column; + } + + /** One cell per Postgres type the parser switches on, and the leaf types; {@code $} separates variants. */ + static Map cells() throws SQLException { + Map cells = new LinkedHashMap<>(); + cells.put("json", pg("json", RepresentativeInput.TEXT)); + cells.put("jsonb", pg("jsonb", "{\"zeta\": 1, \"alpha\": [1.50, 3000000001, null]}")); + cells.put("json$array", pg("json", "[1, \"two\", {\"three\": 3.0}]")); + cells.put("json$scalar", pg("json", "\"žluť\"")); + cells.put("json$null", null); + cells.put("_int4", new SqlArray("int4", Types.INTEGER, new Integer[] {1, null, 3})); + cells.put("_text", new SqlArray("text", Types.VARCHAR, new String[] {"a", "žluť"})); + cells.put("_numeric", new SqlArray("numeric", Types.NUMERIC, new BigDecimal[] {new BigDecimal("1.50")})); + cells.put("citext", pg("citext", "Case Insensitive")); + cells.put("timestamptz", OffsetDateTime.parse("2024-02-29T13:14:15.123+05:30")); + cells.put("timestamp", Timestamp.valueOf("2024-02-29 13:14:15.123")); + cells.put("date", java.sql.Date.valueOf("2024-02-29")); + cells.put("time", "13:14:15"); + cells.put("timetz", "13:14:15+02"); + cells.put("interval", new PGInterval("1 year 2 mons 3 days 04:05:06.5")); + cells.put("uuid", UUID.fromString("123e4567-e89b-12d3-a456-426614174000")); + cells.put("numeric", new BigDecimal("10.50")); + cells.put("int8", Long.MAX_VALUE); + cells.put("float8", 0.1d); + cells.put("bool", true); + cells.put("bytea", new byte[] {0, 1, (byte) 0xFF}); + return cells; + } + + static PGobject pg(String type, String value) throws SQLException { + PGobject object = new PGobject(); + object.setType(type); + object.setValue(value); + return object; + } + + /** A data source that is never started: it reports running with an idle pool and hands out {@code connection}. */ + static HikariPerfWrapper wrap(Connection connection) { + HikariPoolMXBean pool = (HikariPoolMXBean) Proxy.newProxyInstance(HikariPoolMXBean.class.getClassLoader(), + new Class[] {HikariPoolMXBean.class}, (self, method, args) -> 0); + HikariDataSource dataSource = new HikariDataSource() { + @Override + public Connection getConnection() { + return connection; + } + + @Override + public HikariPoolMXBean getHikariPoolMXBean() { + return pool; + } + + @Override + public boolean isRunning() { + return true; + } + }; + return HikariPerfWrapper.wrap(dataSource, () -> 0, () -> 0, () -> 0, () -> 0, null, null); + } +} diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresDatasourceConfig.input.json new file mode 100644 index 0000000000..ef9d09937f --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresDatasourceConfig.input.json @@ -0,0 +1,41 @@ +{ + "canonical": { + "database": " lowcoder ", + "username": " root ", + "password": "s3crét \"quoted\"", + "host": " db.example.com ", + "port": 5432, + "usingSsl": true, + "serverTimezone": "Europe/Bratislava", + "isReadonly": true, + "enableTurnOffPreparedStatement": true, + "extParams": { + "connectTimeout": 5000, + "allowPublicKeyRetrieval": "true", + "useCursorFetch": false, + "nested": { + "list": [ + 1, + 2.5, + null + ] + } + } + }, + "scalarsAsStrings": { + "host": "db.example.com", + "port": "5433", + "usingSsl": "true", + "isReadonly": "false", + "enableTurnOffPreparedStatement": "true" + }, + "getterNames": { + "host": "db.example.com", + "readonly": true, + "port": 5434.0 + }, + "minimal": { + "host": "db.example.com" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresDatasourceConfig.json b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresDatasourceConfig.json new file mode 100644 index 0000000000..1053472898 --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresDatasourceConfig.json @@ -0,0 +1,358 @@ +{ + "buildFrom" : { + "canonical" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "database" : " lowcoder ", + "username" : " root ", + "password" : "s3crét \"quoted\"", + "host" : " db.example.com ", + "port" : 5432, + "usingSsl" : true, + "serverTimezone" : "Europe/Bratislava", + "isReadonly" : true, + "enableTurnOffPreparedStatement" : true, + "extParams" : { + "connectTimeout" : 5000, + "allowPublicKeyRetrieval" : "true", + "useCursorFetch" : false, + "nested" : { + "list" : [ 1, 2.5, null ] + } + } + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "/database" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "java.lang.String", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "java.util.LinkedHashMap", + "/extParams/connectTimeout" : "java.lang.Integer", + "/extParams/allowPublicKeyRetrieval" : "java.lang.String", + "/extParams/useCursorFetch" : "java.lang.Boolean", + "/extParams/nested" : "java.util.LinkedHashMap", + "/extParams/nested/list" : "java.util.ArrayList", + "/extParams/nested/list/0" : "java.lang.Integer", + "/extParams/nested/list/1" : "java.lang.Double", + "/extParams/nested/list/2" : "null" + }, + "noView" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":5432,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Public" : {"database":"lowcoder","username":"root","host":"db.example.com","port":5432,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Internal" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":5432,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 5433, + "usingSsl" : true, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : true, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":5433,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":5433,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":5433,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false} + }, + "getterNames" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 5434, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":5434,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":5434,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":5434,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "minimal" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "empty" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : null, + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "unknownProperty" : "ignored: binds as form canonical" + }, + "PostgresConnector.resolveConfig" : { + "canonical" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "database" : " lowcoder ", + "username" : " root ", + "password" : "s3crét \"quoted\"", + "host" : " db.example.com ", + "port" : 5432, + "usingSsl" : true, + "serverTimezone" : "Europe/Bratislava", + "isReadonly" : true, + "enableTurnOffPreparedStatement" : true, + "extParams" : { + "connectTimeout" : 5000, + "allowPublicKeyRetrieval" : "true", + "useCursorFetch" : false, + "nested" : { + "list" : [ 1, 2.5, null ] + } + } + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "/database" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "java.lang.String", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "java.util.LinkedHashMap", + "/extParams/connectTimeout" : "java.lang.Integer", + "/extParams/allowPublicKeyRetrieval" : "java.lang.String", + "/extParams/useCursorFetch" : "java.lang.Boolean", + "/extParams/nested" : "java.util.LinkedHashMap", + "/extParams/nested/list" : "java.util.ArrayList", + "/extParams/nested/list/0" : "java.lang.Integer", + "/extParams/nested/list/1" : "java.lang.Double", + "/extParams/nested/list/2" : "null" + }, + "noView" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":5432,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Public" : {"database":"lowcoder","username":"root","host":"db.example.com","port":5432,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Internal" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":5432,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 5433, + "usingSsl" : true, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : true, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":5433,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":5433,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":5433,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false} + }, + "getterNames" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 5434, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":5434,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":5434,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":5434,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "minimal" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "empty" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : null, + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"","port":5432,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "unknownProperty" : "ignored: binds as form canonical" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresQueryConfig.input.json b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresQueryConfig.input.json new file mode 100644 index 0000000000..fc82a9e4ca --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresQueryConfig.input.json @@ -0,0 +1,65 @@ +{ + "raw": { + "sql": " select * from users where id = {{id}} ", + "disablePreparedStatement": true, + "mode": "SQL" + }, + "gui": { + "mode": "GUI", + "commandType": "INSERT", + "command": { + "table": "users", + "changeSet": [ + { + "key": "id", + "value": "{{1}}" + }, + { + "key": "name", + "value": "{{input.value}}" + } + ], + "allowMultiModify": true, + "filterBy": [ + { + "key": "id", + "operator": "=", + "value": "7" + } + ] + } + }, + "guiJavaNames": { + "mode": "GUI", + "guiStatementType": "UPDATE", + "guiStatementDetail": { + "table": "users", + "changeSet": [ + { + "key": "id", + "value": "{{1}}" + }, + { + "key": "name", + "value": "{{input.value}}" + } + ], + "allowMultiModify": true, + "filterBy": [ + { + "key": "id", + "operator": "=", + "value": "7" + } + ] + } + }, + "scalarsAsStrings": { + "sql": "select 1", + "disablePreparedStatement": "true" + }, + "sqlMissing": { + "mode": "SQL" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresQueryConfig.json b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresQueryConfig.json new file mode 100644 index 0000000000..d498b02920 --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/config-binding/PostgresQueryConfig.json @@ -0,0 +1,147 @@ +{ + "from" : { + "raw" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "sql" : " select * from users where id = {{id}} ", + "disablePreparedStatement" : true, + "mode" : "SQL", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : {"sql":" select * from users where id = {{id}} ","disablePreparedStatement":true,"mode":"SQL","commandType":null,"command":null,"guiMode":false}, + "Public" : {"sql":" select * from users where id = {{id}} ","disablePreparedStatement":true,"mode":"SQL","commandType":null,"command":null,"guiMode":false}, + "Internal" : {"sql":" select * from users where id = {{id}} ","disablePreparedStatement":true,"mode":"SQL","commandType":null,"command":null,"guiMode":false} + }, + "gui" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "GUI", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : {"sql":null,"disablePreparedStatement":false,"mode":"GUI","commandType":null,"command":null,"guiMode":true}, + "Public" : {"sql":null,"disablePreparedStatement":false,"mode":"GUI","commandType":null,"command":null,"guiMode":true}, + "Internal" : {"sql":null,"disablePreparedStatement":false,"mode":"GUI","commandType":null,"command":null,"guiMode":true} + }, + "guiJavaNames" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "GUI", + "guiStatementType" : "UPDATE", + "guiStatementDetail" : { + "table" : "users", + "changeSet" : [ { + "key" : "id", + "value" : "{{1}}" + }, { + "key" : "name", + "value" : "{{input.value}}" + } ], + "allowMultiModify" : true, + "filterBy" : [ { + "key" : "id", + "operator" : "=", + "value" : "7" + } ] + } + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "java.lang.String", + "/guiStatementDetail" : "java.util.LinkedHashMap", + "/guiStatementDetail/table" : "java.lang.String", + "/guiStatementDetail/changeSet" : "java.util.ArrayList", + "/guiStatementDetail/changeSet/0" : "java.util.LinkedHashMap", + "/guiStatementDetail/changeSet/0/key" : "java.lang.String", + "/guiStatementDetail/changeSet/0/value" : "java.lang.String", + "/guiStatementDetail/changeSet/1" : "java.util.LinkedHashMap", + "/guiStatementDetail/changeSet/1/key" : "java.lang.String", + "/guiStatementDetail/changeSet/1/value" : "java.lang.String", + "/guiStatementDetail/allowMultiModify" : "java.lang.Boolean", + "/guiStatementDetail/filterBy" : "java.util.ArrayList", + "/guiStatementDetail/filterBy/0" : "java.util.LinkedHashMap", + "/guiStatementDetail/filterBy/0/key" : "java.lang.String", + "/guiStatementDetail/filterBy/0/operator" : "java.lang.String", + "/guiStatementDetail/filterBy/0/value" : "java.lang.String" + }, + "noView" : {"sql":null,"disablePreparedStatement":false,"mode":"GUI","commandType":"UPDATE","command":{"table":"users","changeSet":[{"key":"id","value":"{{1}}"},{"key":"name","value":"{{input.value}}"}],"allowMultiModify":true,"filterBy":[{"key":"id","operator":"=","value":"7"}]},"guiMode":true}, + "Public" : {"sql":null,"disablePreparedStatement":false,"mode":"GUI","commandType":"UPDATE","command":{"table":"users","changeSet":[{"key":"id","value":"{{1}}"},{"key":"name","value":"{{input.value}}"}],"allowMultiModify":true,"filterBy":[{"key":"id","operator":"=","value":"7"}]},"guiMode":true}, + "Internal" : {"sql":null,"disablePreparedStatement":false,"mode":"GUI","commandType":"UPDATE","command":{"table":"users","changeSet":[{"key":"id","value":"{{1}}"},{"key":"name","value":"{{input.value}}"}],"allowMultiModify":true,"filterBy":[{"key":"id","operator":"=","value":"7"}]},"guiMode":true} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "sql" : "select 1", + "disablePreparedStatement" : true, + "mode" : null, + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "null", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"commandType":null,"command":null,"guiMode":false}, + "Public" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"commandType":null,"command":null,"guiMode":false}, + "Internal" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"commandType":null,"command":null,"guiMode":false} + }, + "sqlMissing" : { + "class" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "SQL", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.postgres.model.PostgresQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : {"sql":null,"disablePreparedStatement":false,"mode":"SQL","commandType":null,"command":null,"guiMode":false}, + "Public" : {"sql":null,"disablePreparedStatement":false,"mode":"SQL","commandType":null,"command":null,"guiMode":false}, + "Internal" : {"sql":null,"disablePreparedStatement":false,"mode":"SQL","commandType":null,"command":null,"guiMode":false} + }, + "empty" : { + "error" : "org.lowcoder.sdk.exception.PluginException: INVALID_QUERY_SETTINGS INVALID_PG_QUERY_CONFIG_EMPTY" + }, + "unknownProperty" : "ignored: binds as form raw" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/external-json/PostgresResultParser.rows.json b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/external-json/PostgresResultParser.rows.json new file mode 100644 index 0000000000..ecf8ce3b30 --- /dev/null +++ b/server/api-service/lowcoder-plugins/postgresPlugin/src/test/resources/json-contract/external-json/PostgresResultParser.rows.json @@ -0,0 +1,95 @@ +{ + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.util.LinkedHashMap", + "/0/json" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/json/zeta" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/json/int" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/json/negativeInt" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/json/intMax" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/json/intMaxPlusOne" : "com.fasterxml.jackson.databind.node.LongNode", + "/0/json/intMinMinusOne" : "com.fasterxml.jackson.databind.node.LongNode", + "/0/json/longMax" : "com.fasterxml.jackson.databind.node.LongNode", + "/0/json/longMaxPlusOne" : "com.fasterxml.jackson.databind.node.BigIntegerNode", + "/0/json/longMinMinusOne" : "com.fasterxml.jackson.databind.node.BigIntegerNode", + "/0/json/decimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/json/trailingZeroDecimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/json/zeroPointZero" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/json/negativeZero" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/json/exponent" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/json/exponentUpperCase" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/json/negativeExponent" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/json/longDecimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/json/true" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/0/json/false" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/0/json/null" : "com.fasterxml.jackson.databind.node.NullNode", + "/0/json/emptyObject" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/json/emptyArray" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/0/json/nested" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/json/nested/array" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/0/json/nested/array/0" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/json/nested/array/1" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/json/nested/array/2" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/0/json/nested/array/2/0" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/json/nested/array/2/1" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/json/nested/array/2/1/four" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/json/nested/array/3" : "com.fasterxml.jackson.databind.node.NullNode", + "/0/json/nested/array/4" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/0/json/nested/array/5" : "com.fasterxml.jackson.databind.node.LongNode", + "/0/json/nested/object" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/json/nested/object/z" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/json/nested/object/a" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/json/nested/object/a/deeper" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/0/json/nested/object/a/deeper/0" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/0/json/nested/object/a/deeper/1" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/json/unicode" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/json/escapes" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/json/key with escaped name" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/json/alpha" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/jsonb" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/jsonb/zeta" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/jsonb/alpha" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/0/jsonb/alpha/0" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/jsonb/alpha/1" : "com.fasterxml.jackson.databind.node.LongNode", + "/0/jsonb/alpha/2" : "com.fasterxml.jackson.databind.node.NullNode", + "/0/json$array" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/0/json$array/0" : "com.fasterxml.jackson.databind.node.IntNode", + "/0/json$array/1" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/json$array/2" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/0/json$array/2/three" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/0/json$scalar" : "com.fasterxml.jackson.databind.node.TextNode", + "/0/json$null" : "null", + "/0/_int4" : "[Ljava.lang.Integer;", + "/0/_text" : "[Ljava.lang.String;", + "/0/_numeric" : "[Ljava.math.BigDecimal;", + "/0/citext" : "java.lang.String", + "/0/timestamptz" : "java.lang.String", + "/0/timestamp" : "java.lang.String", + "/0/date" : "java.lang.String", + "/0/time" : "java.lang.String", + "/0/timetz" : "java.lang.String", + "/0/interval" : "java.lang.String", + "/0/uuid" : "java.util.UUID", + "/0/numeric" : "java.math.BigDecimal", + "/0/int8" : "java.lang.Long", + "/0/float8" : "java.lang.Double", + "/0/bool" : "java.lang.Boolean", + "/0/bytea" : "[B" + }, + "keyOrder" : { + "/0/json/emptyObject" : [ ], + "/0/json/nested/array/2/1" : [ "four" ], + "/0/json/nested/object/a/deeper/1" : [ ], + "/0/json/nested/object/a" : [ "deeper" ], + "/0/json/nested/object" : [ "z", "a" ], + "/0/json/nested" : [ "array", "object" ], + "/0/json" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ], + "/0/jsonb" : [ "zeta", "alpha" ], + "/0/json$array/2" : [ "three" ], + "/0" : [ "json", "jsonb", "json$array", "json$scalar", "json$null", "_int4", "_text", "_numeric", "citext", "timestamptz", "timestamp", "date", "time", "timetz", "interval", "uuid", "numeric", "int8", "float8", "bool", "bytea" ] + } + }, + "written" : {"queryCode":"OK","data":[{"json":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"jsonb":{"zeta":1,"alpha":[1.5,3000000001,null]},"json$array":[1,"two",{"three":3.0}],"json$scalar":"žluť","json$null":null,"_int4":[1,null,3],"_text":["a","žluť"],"_numeric":[1.50],"citext":"Case Insensitive","timestamptz":"2024-02-29T13:14:15.123+05:30","timestamp":"2024-02-29 13:14:15","date":"2024-02-29","time":"13:14:15","timetz":"13:14:15+02","interval":"1 years 2 mons 3 days 4 hours 5 mins 6.5 secs","uuid":"123e4567-e89b-12d3-a456-426614174000","numeric":10.50,"int8":9223372036854775807,"float8":0.1,"bool":true,"bytea":"AAH/"}],"success":true} +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/redisPlugin/pom.xml b/server/api-service/lowcoder-plugins/redisPlugin/pom.xml index f65a1d22d5..f9271f90c5 100644 --- a/server/api-service/lowcoder-plugins/redisPlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/redisPlugin/pom.xml @@ -45,7 +45,6 @@ org.testcontainers testcontainers - 1.18.0 test diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisConfigBindingContractTest.java new file mode 100644 index 0000000000..494a8ddc8f --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisConfigBindingContractTest.java @@ -0,0 +1,29 @@ +package org.lowcoder.plugin.redis; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.redis.model.RedisDatasourceConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code redisPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1), + * through the real entry points: {@code RedisDatasourceConfig.buildFrom}. {@code ConfigBinding} applies each entry + * point to every form of the input fixture (canonical names, alias or getter-derived names, scalars as strings, + * missing and empty values) and pins the bound state, the written views and the errors in the report fixture. + */ +public class RedisConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/redisPlugin/src/main/java/org/lowcoder/plugin/redis/model/RedisDatasourceConfig.java#RedisDatasourceConfig.buildFrom#toJson#1", + "lowcoder-plugins/redisPlugin/src/main/java/org/lowcoder/plugin/redis/model/RedisDatasourceConfig.java#RedisDatasourceConfig.buildFrom#fromJson#1"}) + @Test + public void datasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/RedisDatasourceConfig.input.json", "config-binding/RedisDatasourceConfig.json", + Map.of("buildFrom", RedisDatasourceConfig::buildFrom)); + } +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisContainerSupport.java b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisContainerSupport.java new file mode 100644 index 0000000000..40d0e6ecd4 --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisContainerSupport.java @@ -0,0 +1,128 @@ +package org.lowcoder.plugin.redis; + +import org.lowcoder.plugin.redis.model.RedisDatasourceConfig; +import org.lowcoder.sdk.contract.ContainerImages; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import redis.clients.jedis.Jedis; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +/** + * The Redis 7.2 containers of the L5-9 tests (default build, not heavy-container). Three servers, each started once on first + * use of its holder and removed by Testcontainers' reaper when the JVM exits, on the port Docker assigns: {@link Open} (no + * password), {@link Secured} (password {@link #PASSWORD}) and {@link Special} (a password with reserved URI characters, + * {@link #SPECIAL_PASSWORD}). A generic container, not a module class (see log-L5.md). Start is bounded twice: a log wait with a + * startup timeout, then a bounded ping loop. No Docker guard on purpose (owner decision D-7). Not a test. + */ +final class RedisContainerSupport { + + static final int REDIS_PORT = 6379; + static final String PASSWORD = "s3cret-pass"; + static final String SPECIAL_PASSWORD = "p@ss:w/rd#1%"; + private static final String READY_PATTERN = ".*Ready to accept connections.*"; + private static final Duration START_TIMEOUT = Duration.ofMinutes(2); + private static final int PING_ATTEMPTS = 30; + private static final long PING_PAUSE_MILLIS = 500; + private static final int PING_SOCKET_TIMEOUT_MILLIS = 5000; + + private RedisContainerSupport() { + } + + /** A started server and the way to reach it. */ + static final class Server { + private final GenericContainer container; + private final String password; + + private Server(String name, String password) { + this.password = password; + GenericContainer redis = new GenericContainer<>(DockerImageName.parse(ContainerImages.REDIS_7_2)).withExposedPorts(REDIS_PORT) + .waitingFor(Wait.forLogMessage(READY_PATTERN, 1).withStartupTimeout(START_TIMEOUT)); + if (password != null) { + redis.withCommand("redis-server", "--requirepass", password); + } + long start = System.nanoTime(); + redis.start(); + this.container = redis; + System.out.println("[RedisContainerSupport] " + name + " " + ContainerImages.REDIS_7_2 + " started in " + + (System.nanoTime() - start) / 1_000_000 + " ms on " + host() + ":" + port()); + awaitPing(name); + } + + String host() { + return container.getHost(); + } + + int port() { + return container.getMappedPort(REDIS_PORT); + } + + /** A host-mode datasource config for this server, with the given credentials. */ + RedisDatasourceConfig config(String username, String configuredPassword) { + Map values = new HashMap<>(); + values.put("host", host()); + values.put("port", port()); + if (username != null) { + values.put("username", username); + } + if (configuredPassword != null) { + values.put("password", configuredPassword); + } + return RedisDatasourceConfig.buildFrom(values); + } + + RedisDatasourceConfig config() { + return config(null, password); + } + + private void awaitPing(String name) { + RuntimeException last = null; + for (int attempt = 1; attempt <= PING_ATTEMPTS; attempt++) { + try (Jedis jedis = new Jedis(host(), port(), PING_SOCKET_TIMEOUT_MILLIS)) { + if (password != null) { + jedis.auth(password); + } + String pong = jedis.ping(); + System.out.println("[RedisContainerSupport] " + name + " ping answered on attempt " + attempt + ": " + pong); + return; + } catch (RuntimeException e) { + last = e; + try { + Thread.sleep(PING_PAUSE_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for Redis", interrupted); + } + } + } + throw new IllegalStateException("Redis " + name + " did not answer a ping after " + PING_ATTEMPTS + " attempts", last); + } + } + + /** No password. Started on first use. */ + static final class Open { + static final Server SERVER = new Server("open", null); + + private Open() { + } + } + + /** Password {@link #PASSWORD}. Started on first use. */ + static final class Secured { + static final Server SERVER = new Server("secured", PASSWORD); + + private Secured() { + } + } + + /** Password {@link #SPECIAL_PASSWORD}. Started on first use. */ + static final class Special { + static final Server SERVER = new Server("special", SPECIAL_PASSWORD); + + private Special() { + } + } +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineAuthContainerTest.java b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineAuthContainerTest.java new file mode 100644 index 0000000000..c05d3cee0c --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineAuthContainerTest.java @@ -0,0 +1,73 @@ +package org.lowcoder.plugin.redis; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.redis.model.RedisDatasourceConfig; +import org.lowcoder.sdk.models.DatasourceTestResult; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.redis.RedisContainerSupport.PASSWORD; +import static org.lowcoder.plugin.redis.RedisContainerSupport.SPECIAL_PASSWORD; + +/** + * Unit RD-6 (task L5-9), connection and authentication against real servers ({@link RedisContainerSupport}): {@code testConnection} + * with no password, the right and a wrong password, a user name, and a password with reserved URI characters (defect D5 against a + * server). + * + *

    Limits: the default user and the single {@code requirepass} password only; ACL users, TLS and clusters are not exercised. + */ +public class RedisEngineAuthContainerTest { + + static final String TAG = "[RedisEngineAuthContainerTest] "; + static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(30); + static final String DEFAULT_USER = "default"; + + private final RedisPlugin.RedisEngine engine = new RedisPlugin.RedisEngine(); + + private DatasourceTestResult test(String label, RedisDatasourceConfig config) { + DatasourceTestResult result = engine.testConnection(config).block(BLOCK_TIMEOUT); + System.out.println(TAG + label + ": success=" + result.isSuccess() + (result.isSuccess() ? "" : " message=" + result.getInvalidMessage(Locale.ENGLISH))); + return result; + } + + @Test + public void serverWithoutPasswordAcceptsAConfigWithoutOne() { + assertTrue(test("open, no password", RedisContainerSupport.Open.SERVER.config()).isSuccess()); + } + + @Test + public void rightPasswordSucceedsAndWrongOrMissingPasswordFails() { + RedisContainerSupport.Server server = RedisContainerSupport.Secured.SERVER; + assertTrue(test("secured, right password", server.config()).isSuccess()); + assertTrue(test("secured, right password and the default user", server.config(DEFAULT_USER, PASSWORD)).isSuccess()); + assertFalse(test("secured, wrong password", server.config(null, "wrong")).isSuccess()); + assertFalse(test("secured, no password", server.config(null, null)).isSuccess()); + assertFalse(test("secured, unknown user", server.config("nobody", PASSWORD)).isSuccess()); + } + + @Test + public void aPasswordThatIsSentToAServerWithoutOneFails() { + assertFalse(test("open, password sent", RedisContainerSupport.Open.SERVER.config(null, "unexpected")).isSuccess()); + } + + /** + * Pins defect D5 (analysis-plugins section 0.6; plan section 9 D1-D20 row) against a real server: the password + * {@code p@ss:w/rd#1%} is the server's, and the same password entered in a host-mode config fails to connect, because the + * credentials are put into the URI unencoded. The URI-mode config with the encoded password connects, which shows the + * password itself is right. A fix (encoding the credentials in host mode) changes the first assertion on purpose. + */ + @Test + public void hostModeConfigWithReservedCharactersInThePasswordFails_pinsD5() { + RedisContainerSupport.Server server = RedisContainerSupport.Special.SERVER; + assertFalse(test("special password, host mode", server.config()).isSuccess()); + String encoded = URLEncoder.encode(SPECIAL_PASSWORD, StandardCharsets.UTF_8); + RedisDatasourceConfig viaUri = RedisDatasourceConfig.buildFrom(Map.of("usingUri", true, "uri", "redis://:" + encoded + "@" + server.host() + ":" + server.port())); + assertTrue(test("special password, encoded in URI mode", viaUri).isSuccess()); + } +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineConnectionTest.java b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineConnectionTest.java new file mode 100644 index 0000000000..d516b425e3 --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineConnectionTest.java @@ -0,0 +1,119 @@ +package org.lowcoder.plugin.redis; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.redis.model.RedisDatasourceConfig; +import org.lowcoder.sdk.exception.PluginException; +import redis.clients.jedis.JedisPool; + +import java.lang.reflect.Field; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.redis.RedisError.REDIS_URL_ERROR; +import static org.lowcoder.plugin.redis.constants.RedisConstants.JEDIS_POOL_MAX_IDLE; +import static org.lowcoder.plugin.redis.constants.RedisConstants.JEDIS_POOL_MAX_TOTAL; +import static org.lowcoder.plugin.redis.constants.RedisConstants.JEDIS_POOL_MIN_EVICTABLE_IDLE_MILLIS; +import static org.lowcoder.plugin.redis.constants.RedisConstants.JEDIS_POOL_MIN_IDLE; +import static org.lowcoder.plugin.redis.constants.RedisConstants.JEDIS_POOL_TIME_BETWEEN_EVICTION_RUNS_MILLIS; + +/** + * Unit RD-5 (task L5-9): {@code RedisEngine.createConnection} and {@code destroyConnection}: the pool is built lazily (no server + * is contacted: the host is a reserved test address that nothing answers), with the pool settings of {@code buildPoolConfig}; + * an unparsable URI is reported; destroying null or twice does not throw. + * + *

    Limits: the pool settings are read from the Jedis pool's protected {@code internalPool} field by reflection (Jedis 3.3.0 has + * no public getter for them); borrowing behaviour against a server is RD-6. + */ +public class RedisEngineConnectionTest { + + static final String TAG = "[RedisEngineConnectionTest] "; + static final String UNREACHABLE_HOST = "192.0.2.1"; + static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(10); + + private final RedisPlugin.RedisEngine engine = new RedisPlugin.RedisEngine(); + + private static Object poolSetting(JedisPool pool, String getter) throws Exception { + Field internal = redis.clients.jedis.util.Pool.class.getDeclaredField("internalPool"); + internal.setAccessible(true); + Object generic = internal.get(pool); + return generic.getClass().getMethod(getter).invoke(generic); + } + + @Test + public void createConnectionBuildsALazyPoolWithTheConfiguredSettings() throws Exception { + long start = System.nanoTime(); + JedisPool pool = engine.createConnection(RedisDatasourceConfig.buildFrom(Map.of("host", UNREACHABLE_HOST, "port", 6379))).block(BLOCK_TIMEOUT); + long millis = (System.nanoTime() - start) / 1_000_000; + System.out.println(TAG + "pool built in " + millis + " ms: " + pool); + try { + assertNotNull(pool); + assertTrue(millis < 5_000, "no connection attempt: " + millis); + assertEquals(0, pool.getNumActive()); + assertEquals(JEDIS_POOL_MAX_TOTAL, poolSetting(pool, "getMaxTotal")); + assertEquals(JEDIS_POOL_MAX_IDLE, poolSetting(pool, "getMaxIdle")); + assertEquals(JEDIS_POOL_MIN_IDLE, poolSetting(pool, "getMinIdle")); + assertEquals(true, poolSetting(pool, "getTestOnBorrow")); + assertEquals(true, poolSetting(pool, "getTestOnReturn")); + assertEquals(true, poolSetting(pool, "getTestWhileIdle")); + assertEquals(false, poolSetting(pool, "getBlockWhenExhausted")); + assertEquals(Duration.ofSeconds(JEDIS_POOL_MIN_EVICTABLE_IDLE_MILLIS).toMillis(), poolSetting(pool, "getMinEvictableIdleTimeMillis")); + assertEquals(Duration.ofSeconds(JEDIS_POOL_TIME_BETWEEN_EVICTION_RUNS_MILLIS).toMillis(), poolSetting(pool, "getTimeBetweenEvictionRunsMillis")); + } finally { + engine.destroyConnection(pool).block(BLOCK_TIMEOUT); + } + } + + /** Observation: only an ArrayIndexOutOfBoundsException is mapped to REDIS_URL_ERROR; an unparsable URI reaches the caller as the raw URISyntaxException. */ + @Test + public void unparsableUriInUriModeReachesTheCallerAsAUriSyntaxException() { + RedisDatasourceConfig bad = RedisDatasourceConfig.buildFrom(Map.of("usingUri", true, "uri", "redis://bad host:1")); + Throwable thrown = assertThrows(Throwable.class, () -> engine.createConnection(bad).block(BLOCK_TIMEOUT)); + System.out.println(TAG + "unparsable uri: " + thrown); + Throwable root = thrown; + while (root.getCause() != null) { + root = root.getCause(); + } + assertInstanceOf(URISyntaxException.class, root); + } + + @Test + public void aUriWithAUserButNoPasswordIsMappedToRedisUrlError() { + RedisDatasourceConfig config = RedisDatasourceConfig.buildFrom(Map.of("usingUri", true, "uri", "redis://u@h:1")); + PluginException thrown = assertThrows(PluginException.class, () -> engine.createConnection(config).block(BLOCK_TIMEOUT)); + System.out.println(TAG + "user without password: " + thrown.getMessageKey() + " " + thrown.getArgs()[0]); + assertEquals("REDIS_URL_ERROR", thrown.getMessageKey()); + assertEquals(REDIS_URL_ERROR, thrown.getError()); + } + + /** Observation: other URIs that the Jedis pool refuses reach the caller as Jedis's or the JDK's own exceptions, not as REDIS_URL_ERROR. */ + @Test + public void otherUnusableUrisReachTheCallerAsRawExceptions() { + assertRaw("redis://:@h", redis.clients.jedis.exceptions.InvalidURIException.class); + assertRaw("redis:///", redis.clients.jedis.exceptions.InvalidURIException.class); + assertRaw("redis://h:1/x", NumberFormatException.class); + } + + private void assertRaw(String uri, Class expected) { + RedisDatasourceConfig config = RedisDatasourceConfig.buildFrom(Map.of("usingUri", true, "uri", uri)); + Throwable thrown = assertThrows(Throwable.class, () -> engine.createConnection(config).block(BLOCK_TIMEOUT), uri); + assertInstanceOf(expected, thrown, uri); + } + + @Test + public void destroyConnectionAcceptsNullAndCanRunTwice() { + engine.destroyConnection(null).block(BLOCK_TIMEOUT); + JedisPool pool = engine.createConnection(RedisDatasourceConfig.buildFrom(Map.of("host", UNREACHABLE_HOST))).block(BLOCK_TIMEOUT); + assertFalse(pool.isClosed()); + engine.destroyConnection(pool).block(BLOCK_TIMEOUT); + assertTrue(pool.isClosed()); + engine.destroyConnection(pool).block(BLOCK_TIMEOUT); + assertTrue(pool.isClosed()); + } +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineContainerTest.java b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineContainerTest.java new file mode 100644 index 0000000000..f3975dc63c --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineContainerTest.java @@ -0,0 +1,252 @@ +package org.lowcoder.plugin.redis; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.redis.model.RedisDatasourceConfig; +import org.lowcoder.plugin.redis.model.RedisQueryExecutionContext; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.models.QueryExecutionResult; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.exceptions.JedisDataException; + +import java.time.Duration; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit RD-6 (task L5-9), commands against a real Redis 7.2 ({@link RedisContainerSupport.Open}): every form command is built by + * {@code buildQueryExecutionContext}, run by {@code executeQuery}, and its effect read back, which proves the argument order + * of RD-2 semantically; raw commands; the reply conversion ({@code processCommandOutput}); the error result; connection + * failures. Each test uses its own key prefix, so the tests do not depend on order. + * + *

    Limits: single-statement commands only; authentication is in {@link RedisEngineAuthContainerTest}. + */ +public class RedisEngineContainerTest { + + static final String TAG = "[RedisEngineContainerTest] "; + static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(30); + static final String QUERY_CODE_OK = "OK"; + static final String OK_REPLY = "OK"; + + private static final RedisPlugin.RedisEngine ENGINE = new RedisPlugin.RedisEngine(); + private static final RedisDatasourceConfig CONFIG = RedisContainerSupport.Open.SERVER.config(); + private static JedisPool pool; + + @BeforeAll + static void connect() { + pool = ENGINE.createConnection(CONFIG).block(BLOCK_TIMEOUT); + } + + @AfterAll + static void disconnect() { + ENGINE.destroyConnection(pool).block(BLOCK_TIMEOUT); + } + + private static String prefix(String name) { + return name + ":" + UUID.randomUUID().toString().substring(0, 8) + ":"; + } + + private static QueryExecutionResult run(String type, String... fieldsAndValues) { + Map comp = new HashMap<>(); + for (int i = 0; i < fieldsAndValues.length; i += 2) { + comp.put(fieldsAndValues[i], fieldsAndValues[i + 1]); + } + Map form = new HashMap<>(); + form.put("compType", type); + form.put("comp", comp); + return execute(form); + } + + private static QueryExecutionResult raw(String command) { + Map form = new HashMap<>(); + form.put("compType", "RAW"); + form.put("comp", new HashMap<>(Map.of("command", command))); + return execute(form); + } + + private static QueryExecutionResult execute(Map form) { + RedisQueryExecutionContext context = ENGINE.buildQueryExecutionContext(CONFIG, form, Map.of(), null); + QueryExecutionResult result = ENGINE.executeQuery(pool, context).block(BLOCK_TIMEOUT); + System.out.println(TAG + context.getProtocolCommand() + " " + Arrays.toString(context.getArgs()) + " -> " + result.getQueryCode() + " " + result.getData()); + return result; + } + + private static Object data(QueryExecutionResult result) { + assertEquals(QUERY_CODE_OK, result.getQueryCode()); + return result.getData(); + } + + // ---- connection + + @Test + public void testConnectionSucceedsAgainstTheServer() { + DatasourceTestResult result = ENGINE.testConnection(CONFIG).block(BLOCK_TIMEOUT); + assertTrue(result.isSuccess()); + } + + @Test + public void testConnectionToAClosedPortReturnsAFailedResult() { + RedisDatasourceConfig closed = RedisDatasourceConfig.buildFrom(Map.of("host", RedisContainerSupport.Open.SERVER.host(), "port", 1)); + DatasourceTestResult result = ENGINE.testConnection(closed).block(BLOCK_TIMEOUT); + System.out.println(TAG + "closed port: success=" + result.isSuccess() + " message=" + result.getInvalidMessage(Locale.ENGLISH)); + assertFalse(result.isSuccess()); + } + + // ---- commands, by data type + + @Test + public void stringCommands() { + String p = prefix("str"); + assertEquals(OK_REPLY, data(run("SET", "key", p + "a", "value", "va"))); + assertEquals("va", data(run("GET", "key", p + "a"))); + assertEquals(OK_REPLY, data(run("SET", "key", p + "b", "value", "vb"))); + assertEquals(List.of("va"), data(run("MGET", "keys", p + "a"))); + assertEquals(Arrays.asList((String) null), data(run("MGET", "keys", p + "missing"))); + assertEquals(List.of(p + "a", p + "b"), ((List) data(run("KEYS", "pattern", p + "*"))).stream().sorted().toList()); + assertEquals("1", data(run("DEL", "key", p + "a"))); + assertNull(data(run("GET", "key", p + "a"))); + assertEquals("0", data(run("DEL", "key", p + "a"))); + } + + @Test + public void hashCommands() { + String p = prefix("hash"); + String key = p + "h"; + assertEquals("1", data(run("HSET", "key", key, "field", "f1", "value", "v1"))); + assertEquals("0", data(run("HSET", "key", key, "field", "f1", "value", "v1b")), "an existing field is an update: 0 new"); + assertEquals("0", data(run("HSETNX", "key", key, "field", "f1", "value", "ignored"))); + assertEquals("1", data(run("HSETNX", "key", key, "field", "f2", "value", "v2"))); + assertEquals("v1b", data(run("HGET", "key", key, "field", "f1"))); + assertEquals(List.of("v2"), data(run("HMGET", "key", key, "fields", "f2"))); + assertEquals("2", data(run("HLEN", "key", key))); + assertEquals(List.of("f1", "f2"), ((List) data(run("HKEYS", "key", key))).stream().sorted().toList()); + assertEquals(List.of("v1b", "v2"), ((List) data(run("HVALS", "key", key))).stream().sorted().toList()); + assertEquals(List.of("f1", "v1b", "f2", "v2"), data(run("HGETALL", "key", key))); + assertEquals("1", data(run("HDEL", "key", key, "field", "f1"))); + assertEquals("1", data(run("HLEN", "key", key))); + } + + @Test + public void listCommands() { + String p = prefix("list"); + String key = p + "l"; + assertEquals("1", data(run("LPUSH", "key", key, "value", "a"))); + assertEquals("2", data(run("LPUSH", "key", key, "value", "b"))); + assertEquals("2", data(run("LLEN", "key", key))); + assertEquals("b", data(run("LINDEX", "key", key, "index", "0"))); + assertEquals("a", data(run("LINDEX", "key", key, "index", "1"))); + assertEquals(List.of("b", "a"), data(run("LRANGE", "key", key, "start", "0", "stop", "-1"))); + assertEquals(List.of("b"), data(run("LRANGE", "key", key, "start", "0", "stop", "0"))); + assertEquals("1", data(run("LREM", "key", key, "count", "0", "value", "a"))); + assertEquals(List.of("b"), data(run("LRANGE", "key", key, "start", "0", "stop", "-1"))); + String source = p + "src"; + String destination = p + "dst"; + run("LPUSH", "key", source, "value", "x"); + run("LPUSH", "key", source, "value", "y"); + assertEquals("x", data(run("RPOPLPUSH", "source", source, "destination", destination)), "pops the tail of the source"); + assertEquals(List.of("x"), data(run("LRANGE", "key", destination, "start", "0", "stop", "-1"))); + assertEquals(List.of("y"), data(run("LRANGE", "key", source, "start", "0", "stop", "-1"))); + } + + @Test + public void setCommands() { + String p = prefix("set"); + String key = p + "s"; + assertEquals("1", data(run("SADD", "key", key, "member", "m1"))); + assertEquals("0", data(run("SADD", "key", key, "member", "m1"))); + assertEquals("1", data(run("SADD", "key", key, "member", "m2"))); + assertEquals("2", data(run("SCARD", "key", key))); + assertEquals(List.of("m1", "m2"), ((List) data(run("SMEMBERS", "key", key))).stream().sorted().toList()); + assertEquals("1", data(run("SISMEMBER", "key", key, "member", "m1"))); + assertEquals("0", data(run("SISMEMBER", "key", key, "member", "other"))); + assertEquals(2, ((List) data(run("SRANDMEMBER", "key", key, "count", "5"))).size(), "a count above the size returns every member"); + assertEquals(1, ((List) data(run("SRANDMEMBER", "key", key, "count", "1"))).size()); + assertEquals("1", data(run("SREM", "key", key, "member", "m1"))); + assertEquals(List.of("m2"), data(run("SMEMBERS", "key", key))); + } + + @Test + public void sortedSetCommands() { + String p = prefix("zset"); + String key = p + "z"; + assertEquals("1", data(run("ZADD", "key", key, "score", "1", "member", "a"))); + assertEquals("1", data(run("ZADD", "key", key, "score", "2", "member", "b"))); + assertEquals("1", data(run("ZADD", "key", key, "score", "3", "member", "c"))); + assertEquals("3", data(run("ZCARD", "key", key))); + assertEquals("2", data(run("ZCOUNT", "key", key, "min", "1", "max", "2"))); + assertEquals("1", data(run("ZCOUNT", "key", key, "min", "3", "max", "3"))); + assertEquals("0", data(run("ZCOUNT", "key", key, "min", "3", "max", "1")), "min above max matches nothing"); + assertEquals(List.of("a", "b", "c"), data(run("ZRANGE", "key", key, "start", "0", "stop", "-1"))); + assertEquals(List.of("b"), data(run("ZRANGE", "key", key, "start", "1", "stop", "1"))); + assertEquals(List.of("b", "c"), data(run("ZRANGEBYSCORE", "key", key, "min", "2", "max", "3"))); + assertEquals("1", data(run("ZRANK", "key", key, "member", "b"))); + assertEquals("2", data(run("ZSCORE", "key", key, "member", "b"))); + assertEquals("1", data(run("ZREM", "key", key, "member", "b"))); + assertEquals(List.of("a", "c"), data(run("ZRANGE", "key", key, "start", "0", "stop", "-1"))); + } + + // ---- raw commands and replies + + @Test + public void rawCommandsRunAndConvertTheirReplies() { + String p = prefix("raw"); + assertNull(data(raw("GET " + p + "missing")), "a missing key is a null result"); + assertEquals(OK_REPLY, data(raw("SET " + p + "k \"two words\""))); + assertEquals("two words", data(raw("get " + p + "k"))); + assertEquals("PONG", data(raw("PING"))); + assertEquals(Arrays.asList("two words", null), data(raw("MGET " + p + "k " + p + "missing")), "a list reply keeps a null element"); + assertEquals("9", data(raw("STRLEN " + p + "k")), "an integer reply is its text"); + assertEquals("1", data(raw("EXPIRE " + p + "k 100"))); + long ttl = Long.parseLong((String) data(raw("TTL " + p + "k"))); + assertTrue(ttl > 0 && ttl <= 100, "ttl " + ttl); + } + + @Test + public void serverErrorsBecomeAnErrorResultCarryingTheException() { + String p = prefix("err"); + run("SET", "key", p + "k", "value", "v"); + QueryExecutionResult result = run("LPUSH", "key", p + "k", "value", "x"); + assertEquals("REDIS_EXECUTION_ERROR", result.getQueryCode()); + assertEquals("REDIS_EXECUTION_ERROR", result.getMessageKey()); + JedisDataException cause = assertInstanceOf(JedisDataException.class, result.getMessageArgs()[0]); + assertTrue(cause.getMessage().startsWith("WRONGTYPE"), cause.getMessage()); + } + + // ---- empty and absent fields + + /** + * Observation (no row): an empty text field is sent as an empty argument, which the server accepts (the key and the value + * may be empty strings). The client form sends every field as text, an empty field being the empty string + * (redisQuery.tsx KeyInput and the other ParamsStringControl inputs, paramsControl.tsx:129), so this is what a user with a blank + * field gets: no coded error, a successful command on the empty key. + */ + @Test + public void emptyFieldsAreSentAsEmptyArguments() { + assertEquals(OK_REPLY, data(run("SET", "key", "", "value", ""))); + assertEquals("", data(run("GET", "key", ""))); + assertEquals("1", data(run("DEL", "key", ""))); + } + + /** + * Observation (no row): a field absent from the query config (an API call; the form always sends text) is a null argument and + * the command fails inside Jedis: the error result carries the raw exception, not a coded message. + */ + @Test + public void anAbsentFieldFailsInsideJedis() { + QueryExecutionResult result = run("SET", "key", prefix("absent") + "k"); + System.out.println(TAG + "absent value: " + result.getQueryCode() + " " + result.getMessageKey() + " " + Arrays.toString(result.getMessageArgs())); + assertEquals("REDIS_EXECUTION_ERROR", result.getQueryCode()); + assertInstanceOf(Throwable.class, result.getMessageArgs()[0]); + } +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineQueryConfigTest.java b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineQueryConfigTest.java new file mode 100644 index 0000000000..b3782452e9 --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisEngineQueryConfigTest.java @@ -0,0 +1,182 @@ +package org.lowcoder.plugin.redis; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.redis.model.RedisDatasourceConfig; +import org.lowcoder.plugin.redis.model.RedisQueryExecutionContext; +import org.lowcoder.sdk.exception.PluginException; +import redis.clients.jedis.Protocol; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_ARGUMENT_ERROR; + +/** + * Unit RD-3 (task L5-9): {@code RedisEngine.validateConfig} and {@code buildQueryExecutionContext}: the raw command text + * parser (quotes, whitespace, case, template rendering), the form branch's template rendering, and the failures of a blank + * or unknown raw command (defect D7). + * + *

    Limits: nothing is sent to a server (RD-6). + */ +public class RedisEngineQueryConfigTest { + + static final String TAG = "[RedisEngineQueryConfigTest] "; + static final String HOST = "cache.example.org"; + + private final RedisPlugin.RedisEngine engine = new RedisPlugin.RedisEngine(); + private final RedisDatasourceConfig datasource = RedisDatasourceConfig.buildFrom(Map.of("host", HOST)); + + private RedisQueryExecutionContext raw(String command, Map params) { + Map config = new HashMap<>(); + config.put("compType", "RAW"); + config.put("comp", new HashMap<>(Map.of("command", command))); + RedisQueryExecutionContext context = engine.buildQueryExecutionContext(datasource, config, params, null); + System.out.println(TAG + "raw [" + command + "] " + params + " -> " + context.getProtocolCommand() + " " + Arrays.toString(context.getArgs())); + return context; + } + + private RedisQueryExecutionContext raw(String command) { + return raw(command, Map.of()); + } + + private RedisQueryExecutionContext form(String type, Map comp, Map params) { + Map config = new HashMap<>(); + config.put("compType", type); + config.put("comp", new HashMap<>(comp)); + RedisQueryExecutionContext context = engine.buildQueryExecutionContext(datasource, config, params, null); + System.out.println(TAG + "form " + type + " " + comp + " " + params + " -> " + context.getProtocolCommand() + " " + Arrays.toString(context.getArgs())); + return context; + } + + // ---- validateConfig + + private Set invalids(Map values) { + return engine.validateConfig(RedisDatasourceConfig.buildFrom(values)); + } + + @Test + public void validateConfigRejectsLoopbackBlankHostMissingPortAndBlankUri() { + assertEquals(Set.of(), invalids(Map.of("host", HOST, "port", 6379))); + assertEquals(Set.of("INVALID_HOST", "PORT_EMPTY"), invalids(Map.of("host", "localhost"))); + assertEquals(Set.of("INVALID_HOST"), invalids(Map.of("host", "LocalHost", "port", 1))); + assertEquals(Set.of("INVALID_HOST"), invalids(Map.of("host", "127.0.0.1", "port", 1))); + assertEquals(Set.of("HOST_EMPTY_PLZ_CHECK", "PORT_EMPTY"), invalids(Map.of("host", " "))); + assertEquals(Set.of("HOST_EMPTY_PLZ_CHECK"), invalids(Map.of("port", 1))); + assertEquals(Set.of("REDIS_URL_EMPTY"), invalids(Map.of("usingUri", true, "uri", " "))); + assertEquals(Set.of(), invalids(Map.of("usingUri", true, "uri", "redis://cache.example.org:6379"))); + assertEquals(Set.of("INVALID_HOST", "REDIS_URL_EMPTY"), invalids(Map.of("usingUri", true, "host", "localhost")), "the host check also runs in URI mode"); + } + + // ---- raw command parser + + @Test + public void rawCommandIsUpperCasedAndSplitAtWhitespace() { + RedisQueryExecutionContext context = raw(" get some_key "); + assertEquals(Protocol.Command.GET, context.getProtocolCommand()); + assertArrayEquals(new String[] {"some_key"}, context.getArgs()); + RedisQueryExecutionContext none = raw("PING"); + assertEquals(Protocol.Command.PING, none.getProtocolCommand()); + assertNull(none.getArgs(), "no arguments: the args are absent, not an empty array"); + assertArrayEquals(new String[] {"k", "v"}, raw("set\tk\nv").getArgs(), "tabs and new lines separate arguments too"); + } + + @Test + public void doubleQuotedArgumentKeepsItsSpacesAndLosesTheQuotes() { + assertArrayEquals(new String[] {"k", "a b"}, raw("set k \"a b\"").getArgs()); + } + + /** Observation: single quotes are NOT removed, they stay part of the argument (and the text inside keeps its spaces). */ + @Test + public void singleQuotedArgumentKeepsItsQuotes() { + assertArrayEquals(new String[] {"k", "'a b'"}, raw("set k 'a b'").getArgs()); + assertArrayEquals(new String[] {"k", "'{\"a\":1}'"}, raw("set k '{\"a\":1}'").getArgs()); + } + + @Test + public void emptyDoubleQuotesAreAnEmptyArgument() { + assertArrayEquals(new String[] {"k", ""}, raw("set k \"\"").getArgs()); + } + + /** Observation: a rendered parameter value that contains spaces is split into several arguments (the render happens before the split). */ + @Test + public void templateValueWithSpacesIsSplitIntoSeveralArguments() { + assertArrayEquals(new String[] {"a", "b", "c"}, raw("set {{k}} {{v}}", Map.of("k", "a", "v", "b c")).getArgs()); + assertArrayEquals(new String[] {"a", "b c"}, raw("set {{k}} \"{{v}}\"", Map.of("k", "a", "v", "b c")).getArgs(), "quoted placeholder keeps the value whole"); + } + + // ---- D7 + + /** + * Pins defect D7 (analysis-plugins section 0.6; plan section 9 D1-D20 row): for a raw command, + * {@code Protocol.Command.valueOf((String) cmdAndArgs.get("cmd"))} throws {@code NullPointerException} for a blank command + * and {@code IllegalArgumentException} for an unknown one, so a raw JDK error reaches the user, and the + * {@code COMMAND_EMPTY} check that follows is unreachable for raw commands. A fix (a {@code PluginException}) changes this test + * on purpose. + */ + @Test + public void blankAndUnknownRawCommandsEscapeAsRawJdkErrors_pinsD7() { + for (String blank : List.of("", " ", "\t")) { + assertThrows(NullPointerException.class, () -> raw(blank), "[" + blank + "]"); + } + IllegalArgumentException unknown = assertThrows(IllegalArgumentException.class, () -> raw("flyhigh x")); + System.out.println(TAG + "unknown raw command: " + unknown.getMessage()); + } + + /** + * Pins defect D17 (analysis-plugins section 0.6; plan section 9 D1-D20 row) for the raw parser + * ({@code RedisPlugin.java}, {@code matcher.group().toUpperCase()}): under a Turkish default locale a lower-case + * {@code lindex} becomes a dotted capital I word, which is no command, so the raw command fails with the enum's error. The + * default locale is global state: restored in finally. A fix ({@code Locale.ROOT}) changes this test on purpose. + */ + @Test + public void lowerCaseRawCommandFailsUnderATurkishDefaultLocale_pinsD17() { + Locale saved = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + System.out.println(TAG + "default locale: " + Locale.getDefault()); + assertThrows(IllegalArgumentException.class, () -> raw("lindex k 0")); + assertEquals(Protocol.Command.LINDEX, raw("LINDEX k 0").getProtocolCommand(), "upper case is unaffected"); + } finally { + Locale.setDefault(saved); + } + assertEquals(Protocol.Command.LINDEX, raw("lindex k 0").getProtocolCommand(), "locale restored: " + Locale.getDefault()); + } + + // ---- form branch + + @Test + public void formModeRendersTemplatesInEachArgument() { + RedisQueryExecutionContext context = form("SET", Map.of("key", "user:{{id}}", "value", "{{name}}"), Map.of("id", 7, "name", "Ann")); + assertEquals(Protocol.Command.SET, context.getProtocolCommand()); + assertArrayEquals(new String[] {"user:7", "Ann"}, context.getArgs()); + } + + @Test + public void formModeKeepsAValueWithSpacesWhole() { + RedisQueryExecutionContext context = form("SET", Map.of("key", "k", "value", "{{v}}"), Map.of("v", "a b c")); + assertArrayEquals(new String[] {"k", "a b c"}, context.getArgs()); + } + + @Test + public void formModeUnknownTypeIsRejected() { + PluginException thrown = assertThrows(PluginException.class, () -> form("FLUSHALL", Map.of(), Map.of())); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals("INVALID_REDIS_REQUEST", thrown.getMessageKey()); + } + + @Test + public void callersConfigIsNotChanged() { + Map comp = new HashMap<>(Map.of("key", "{{k}}")); + Map config = new HashMap<>(Map.of("compType", "GET", "comp", comp)); + engine.buildQueryExecutionContext(datasource, config, Map.of("k", "x"), null); + assertEquals("{{k}}", ((Map) config.get("comp")).get("key")); + } +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisResultContractTest.java b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisResultContractTest.java new file mode 100644 index 0000000000..fb35fda0cf --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/RedisResultContractTest.java @@ -0,0 +1,101 @@ +package org.lowcoder.plugin.redis; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.redis.model.RedisQueryExecutionContext; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.models.QueryExecutionResult; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.Protocol; +import redis.clients.jedis.commands.ProtocolCommand; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The Redis row of the §4.6 producer table (docs/API_PAYLOAD_TEST_PLAN.md §4.6, task T8.2): + * {@code RedisEngine.executeQuery} with a pool whose connection answers each reply of {@link #OUTPUTS} to + * {@code sendCommand}, as Jedis decodes the RESP2 types. The engine puts {@code processCommandOutput(reply)} into the + * result ({@code RedisPlugin.java:221}): a bulk string becomes a {@code String} decoded as UTF-8, an array a + * {@code List}, an integer its text; an array that holds an array (as {@code SCAN} and {@code EXEC} return) + * or integers fails the cast to {@code byte[]}, and the engine returns a {@code REDIS_EXECUTION_ERROR} result (O76). Each case's report ({@link QueryResults#report}) is pinned in {@value #REPORT}. + * + *

    Limits: no Redis server runs; the replies are the Java objects Jedis returns for each RESP type, and the pool is + * never started. + */ +public class RedisResultContractTest { + + static final String REPORT = "query-results/RedisEngine.results.json"; + static final String KEY = "contract:key"; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map OUTPUTS = outputs(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final RedisPlugin.RedisEngine engine = new RedisPlugin.RedisEngine(); + + @Test + public void resultsAsPinned() { + Map report = new LinkedHashMap<>(); + OUTPUTS.forEach((name, output) -> report.put(name, report(output))); + String actual = ConfigBinding.write(report); + System.out.println("[RedisResultContractTest]\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + private Object report(Object output) { + RedisQueryExecutionContext context = RedisQueryExecutionContext.builder() + .protocolCommand(Protocol.Command.GET).args(new String[] {KEY}).build(); + try (JedisPool pool = pool(output)) { + QueryExecutionResult result = engine.executeQuery(pool, context).block(TIMEOUT); + return result == null ? null : QueryResults.report(result); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + } + + /** A pool that is never started: its connection answers {@code output} to every command without a server. */ + static JedisPool pool(Object output) { + Jedis jedis = new Jedis() { + @Override + public Object sendCommand(ProtocolCommand cmd, String... args) { + return output; + } + + @Override + public Object sendCommand(ProtocolCommand cmd) { + return output; + } + }; + return new JedisPool() { + @Override + public Jedis getResource() { + return jedis; + } + }; + } + + private static byte[] utf8(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } + + private static Map outputs() { + Map outputs = new LinkedHashMap<>(); + outputs.put("bulkString", utf8("{\"a\": 1.50, \"text\": \"žluť\"}")); + outputs.put("invalidUtf8", new byte[] {'a', (byte) 0xFF, 'b'}); + outputs.put("simpleStringOk", utf8("OK")); + outputs.put("integer", 3_000_000_001L); + outputs.put("nil", null); + outputs.put("array", Arrays.asList(utf8("one"), null, utf8("žluť"))); + outputs.put("emptyArray", List.of()); + outputs.put("nestedArray", List.of(utf8("0"), List.of(utf8("key1"), utf8("key2")))); + outputs.put("arrayOfIntegers", List.of(1L, 2L)); + return outputs; + } +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/model/RedisDatasourceConfigTest.java b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/model/RedisDatasourceConfigTest.java new file mode 100644 index 0000000000..3d5c126723 --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/model/RedisDatasourceConfigTest.java @@ -0,0 +1,176 @@ +package org.lowcoder.plugin.redis.model; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.BizException; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit RD-4 (task L5-9): {@code RedisDatasourceConfig}: {@code buildFrom}, the merge of an edited config into the stored one, + * and the secret fields' encrypt and decrypt, including the failure path and defect D8 (the secret is written to the log). + * + *

    Limits: the encrypt function here is a stand-in for the production one (a pure string function); its own strength is not + * under test. + */ +public class RedisDatasourceConfigTest { + + static final String TAG = "[RedisDatasourceConfigTest] "; + static final String HOST = "cache.example.org"; + static final String STORED_PASSWORD = "stored-secret"; + static final String STORED_URI = "redis://:stored-secret@cache.example.org:6379"; + static final String SECRET_IN_FAILURE = "do-not-log-me"; + + private static RedisDatasourceConfig config(Map values) { + return RedisDatasourceConfig.buildFrom(new HashMap<>(values)); + } + + @Test + public void buildFromReadsEveryField() { + RedisDatasourceConfig built = config(Map.of("host", HOST, "port", 6380, "usingSsl", true, "username", "u", "password", "p", "usingUri", false, "uri", "redis://x")); + assertEquals(HOST, built.getHost()); + assertEquals(6380L, built.getPort()); + assertTrue(built.isUsingSsl()); + assertEquals("u", built.getUsername()); + assertEquals("p", built.getPassword()); + assertFalse(built.isUsingUri()); + assertEquals("redis://x", built.getUri()); + RedisDatasourceConfig empty = config(Map.of()); + assertNull(empty.getHost()); + assertNull(empty.getPort()); + assertFalse(empty.isUsingSsl()); + } + + @Test + public void mergeKeepsTheStoredPasswordWhenTheUpdateHasNone() { + RedisDatasourceConfig stored = config(Map.of("host", "old", "port", 1, "password", STORED_PASSWORD)); + RedisDatasourceConfig merged = (RedisDatasourceConfig) stored.mergeWithUpdatedConfig(config(Map.of("host", HOST, "port", 6380, "username", "u", "usingSsl", true))); + assertEquals(HOST, merged.getHost()); + assertEquals(6380L, merged.getPort()); + assertEquals("u", merged.getUsername()); + assertTrue(merged.isUsingSsl()); + assertEquals(STORED_PASSWORD, merged.getPassword()); + assertFalse(merged.isUsingUri()); + assertNull(merged.getUri(), "host mode: the stored URI is not carried over"); + RedisDatasourceConfig replaced = (RedisDatasourceConfig) stored.mergeWithUpdatedConfig(config(Map.of("host", HOST, "password", "new"))); + assertEquals("new", replaced.getPassword()); + } + + @Test + public void mergeInUriModeKeepsTheStoredUriWhenTheUpdateHasNone() { + RedisDatasourceConfig stored = config(Map.of("usingUri", true, "uri", STORED_URI)); + RedisDatasourceConfig kept = (RedisDatasourceConfig) stored.mergeWithUpdatedConfig(config(Map.of("usingUri", true))); + assertTrue(kept.isUsingUri()); + assertEquals(STORED_URI, kept.getUri()); + RedisDatasourceConfig replaced = (RedisDatasourceConfig) stored.mergeWithUpdatedConfig(config(Map.of("usingUri", true, "uri", "redis://new:1"))); + assertEquals("redis://new:1", replaced.getUri()); + assertNull(replaced.getHost(), "URI mode: host fields are dropped"); + RedisDatasourceConfig fromHost = (RedisDatasourceConfig) config(Map.of("host", HOST, "password", STORED_PASSWORD)).mergeWithUpdatedConfig(config(Map.of("usingUri", true, "uri", "redis://n:1"))); + assertEquals("redis://n:1", fromHost.getUri()); + } + + @Test + public void mergeRejectsAForeignConfigTypeNamingItsClass() { + org.lowcoder.sdk.models.DatasourceConnectionConfig foreign = new FOREIGN(); + BizException thrown = assertThrows(BizException.class, () -> config(Map.of("host", HOST)).mergeWithUpdatedConfig(foreign)); + System.out.println(TAG + "foreign: " + thrown.getMessageKey() + " " + java.util.Arrays.toString(thrown.getArgs())); + assertEquals("INVALID_DATASOURCE_CONFIG_TYPE", thrown.getMessageKey()); + assertEquals("FOREIGN", thrown.getArgs()[0]); + } + + @Test + public void encryptAndDecryptApplyTheFunctionToPasswordAndUri() { + RedisDatasourceConfig built = config(Map.of("host", HOST, "password", "p", "uri", "u")); + Function encrypt = s -> s == null ? null : "enc(" + s + ")"; + Function decrypt = s -> s == null ? null : s.substring(4, s.length() - 1); + RedisDatasourceConfig encrypted = (RedisDatasourceConfig) built.doEncrypt(encrypt); + assertSame(built, encrypted); + assertEquals("enc(p)", encrypted.getPassword()); + assertEquals("enc(u)", encrypted.getUri()); + RedisDatasourceConfig decrypted = (RedisDatasourceConfig) encrypted.doDecrypt(decrypt); + assertEquals("p", decrypted.getPassword()); + assertEquals("u", decrypted.getUri()); + assertEquals(HOST, decrypted.getHost(), "the other fields are untouched"); + RedisDatasourceConfig none = (RedisDatasourceConfig) config(Map.of("host", HOST)).doEncrypt(encrypt); + assertNull(none.getPassword()); + assertNull(none.getUri()); + } + + private static ListAppender capture() { + Logger logger = (Logger) LoggerFactory.getLogger(RedisDatasourceConfig.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.ALL); + return appender; + } + + private static void release(ListAppender appender) { + ((Logger) LoggerFactory.getLogger(RedisDatasourceConfig.class)).detachAppender(appender); + appender.stop(); + } + + /** A config of another datasource type. */ + static final class FOREIGN implements org.lowcoder.sdk.models.DatasourceConnectionConfig { + @Override + public org.lowcoder.sdk.models.DatasourceConnectionConfig mergeWithUpdatedConfig(org.lowcoder.sdk.models.DatasourceConnectionConfig detailConfig) { + return this; + } + } + + private static final Function FAILING = s -> { + throw new IllegalStateException("cipher unavailable"); + }; + + @Test + public void whenTheFunctionThrowsTheConfigIsReturnedUnchanged() { + ListAppender appender = capture(); + try { + RedisDatasourceConfig built = config(Map.of("host", HOST, "password", SECRET_IN_FAILURE, "uri", "u")); + assertSame(built, built.doEncrypt(FAILING)); + assertSame(built, built.doDecrypt(FAILING)); + assertEquals(SECRET_IN_FAILURE, built.getPassword()); + assertEquals("u", built.getUri()); + assertEquals(2, appender.list.size(), "one error line per failed call"); + } finally { + release(appender); + } + } + + /** + * Pins defect D8, Redis half (analysis-plugins section 0.6; plan section 9 D1-D20 row): when encrypting or decrypting fails, + * the plain secret is written to the log ({@code log.error("fail to encrypt password: {}", password, e)}), in both + * methods. A fix (a log line without the secret) changes this test on purpose. + */ + @Test + public void aFailedEncryptOrDecryptWritesThePasswordToTheLog_pinsD8() { + ListAppender appender = capture(); + try { + RedisDatasourceConfig built = config(Map.of("host", HOST, "password", SECRET_IN_FAILURE)); + built.doEncrypt(FAILING); + built.doDecrypt(FAILING); + List lines = appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList(); + System.out.println(TAG + "log lines: " + lines); + assertEquals(2, lines.size()); + for (String line : lines) { + assertTrue(line.contains(SECRET_IN_FAILURE), line); + } + } finally { + release(appender); + } + } +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/utils/RedisQueryUtilsTest.java b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/utils/RedisQueryUtilsTest.java new file mode 100644 index 0000000000..2f2060fdba --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/utils/RedisQueryUtilsTest.java @@ -0,0 +1,174 @@ +package org.lowcoder.plugin.redis.utils; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.redis.commands.RedisCommand; +import org.lowcoder.sdk.exception.PluginException; +import redis.clients.jedis.Protocol; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_ARGUMENT_ERROR; + +/** + * Unit RD-2 (task L5-9): {@code RedisQueryUtils.convertRedisFormInputToRedisCommand} for all 34 form commands. The table + * {@link #SYNTAX} is written from the Redis command reference (not from the code): each command with its arguments in + * Redis order, in terms of the distinct form values of {@link #FORM_VALUES}, so a swapped pair (key and value, score and + * member, min and max) fails. Also the dispatch rules and the lower-case type (defect D17). + * + *

    Limits: only the command and the argument list are built; the server's reading of them is RD-6. + */ +public class RedisQueryUtilsTest { + + static final String TAG = "[RedisQueryUtilsTest] "; + static final Map FORM_VALUES = formValues(); + static final Map> SYNTAX = syntax(); + + private static Map formValues() { + Map values = new LinkedHashMap<>(); + for (String field : List.of("key", "keys", "value", "pattern", "field", "fields", "index", "count", "source", "destination", + "start", "stop", "member", "score", "min", "max")) { + values.put(field, "<" + field + ">"); + } + return values; + } + + /** Redis syntax per command, each argument named by its form field. */ + private static Map> syntax() { + Map> table = new LinkedHashMap<>(); + table.put("GET", List.of("key")); + table.put("SET", List.of("key", "value")); + table.put("DEL", List.of("key")); + table.put("KEYS", List.of("pattern")); + table.put("MGET", List.of("keys")); + table.put("HGET", List.of("key", "field")); + table.put("HMGET", List.of("key", "fields")); + table.put("HGETALL", List.of("key")); + table.put("HSET", List.of("key", "field", "value")); + table.put("HSETNX", List.of("key", "field", "value")); + table.put("HLEN", List.of("key")); + table.put("HDEL", List.of("key", "field")); + table.put("HKEYS", List.of("key")); + table.put("HVALS", List.of("key")); + table.put("LINDEX", List.of("key", "index")); + table.put("LLEN", List.of("key")); + table.put("LPUSH", List.of("key", "value")); + table.put("LREM", List.of("key", "count", "value")); + table.put("RPOPLPUSH", List.of("source", "destination")); + table.put("LRANGE", List.of("key", "start", "stop")); + table.put("SADD", List.of("key", "member")); + table.put("SCARD", List.of("key")); + table.put("SMEMBERS", List.of("key")); + table.put("SISMEMBER", List.of("key", "member")); + table.put("SRANDMEMBER", List.of("key", "count")); + table.put("SREM", List.of("key", "member")); + table.put("ZADD", List.of("key", "score", "member")); + table.put("ZCARD", List.of("key")); + table.put("ZCOUNT", List.of("key", "min", "max")); + table.put("ZRANGE", List.of("key", "start", "stop")); + table.put("ZRANGEBYSCORE", List.of("key", "min", "max")); + table.put("ZRANK", List.of("key", "member")); + table.put("ZREM", List.of("key", "member")); + table.put("ZSCORE", List.of("key", "member")); + return table; + } + + static Map form(String type) { + Map comp = new HashMap<>(FORM_VALUES); + Map form = new HashMap<>(); + form.put("compType", type); + form.put("comp", comp); + return form; + } + + @Test + public void everyCommandHasItsProtocolCommandAndArgumentsInRedisOrder() { + assertEquals(34, SYNTAX.size()); + for (Map.Entry> entry : SYNTAX.entrySet()) { + RedisCommand command = RedisQueryUtils.convertRedisFormInputToRedisCommand(form(entry.getKey())); + List expected = entry.getValue().stream().map(FORM_VALUES::get).toList(); + System.out.println(TAG + entry.getKey() + " -> " + command.getProtocolCommand() + " " + Arrays.toString(command.getArgs())); + assertEquals(Protocol.Command.valueOf(entry.getKey()), command.getProtocolCommand(), entry.getKey()); + assertEquals(expected, Arrays.asList(command.getArgs()), entry.getKey()); + } + } + + @Test + public void commandTypeIsMatchedInAnyCaseAndUnknownTypesAreRejectedWithTheirName() { + assertInstanceOf(RedisCommand.Get.class, RedisQueryUtils.convertRedisFormInputToRedisCommand(form("get"))); + assertInstanceOf(RedisCommand.Hgetall.class, RedisQueryUtils.convertRedisFormInputToRedisCommand(form("HgetAll"))); + for (String unknown : List.of("FLUSHALL", "", "RAW", "GETS")) { + PluginException thrown = assertThrows(PluginException.class, () -> RedisQueryUtils.convertRedisFormInputToRedisCommand(form(unknown)), unknown); + assertEquals(QUERY_ARGUMENT_ERROR, thrown.getError()); + assertEquals("INVALID_REDIS_REQUEST", thrown.getMessageKey()); + assertEquals(unknown, thrown.getArgs()[0]); + } + Map noType = new HashMap<>(); + assertEquals("INVALID_REDIS_REQUEST", assertThrows(PluginException.class, () -> RedisQueryUtils.convertRedisFormInputToRedisCommand(noType)).getMessageKey()); + } + + @Test + public void rawIsDetectedInAnyCase() { + assertTrue(RedisQueryUtils.isRawCommand(form("RAW"))); + assertTrue(RedisQueryUtils.isRawCommand(form("raw"))); + assertFalse(RedisQueryUtils.isRawCommand(form("GET"))); + assertFalse(RedisQueryUtils.isRawCommand(new HashMap<>())); + } + + /** + * Observation (no row): a field that is absent from the form is a null argument, and the argument list keeps the slot. + * The client form sends every field of the command as text (an empty field is the empty string, + * redisQuery.tsx KeyInput and the other inputs are ParamsStringControl, paramsControl.tsx:129), so only an API call omits one. + */ + @Test + public void aFieldAbsentFromTheFormIsANullArgument() { + Map form = new HashMap<>(); + form.put("compType", "SET"); + form.put("comp", new HashMap<>(Map.of("key", "k"))); + RedisCommand command = RedisQueryUtils.convertRedisFormInputToRedisCommand(form); + System.out.println(TAG + "SET with no value -> " + Arrays.toString(command.getArgs())); + assertEquals(Arrays.asList("k", null), Arrays.asList(command.getArgs())); + } + + /** + * Pins defect D17 (analysis-plugins section 0.6; plan section 9 D1-D20 row) for the dispatch + * ({@code RedisQueryUtils.java}, {@code commandType.toUpperCase()}): under a Turkish default locale the lower-case type + * {@code lindex} becomes a dotted capital I word and is rejected as unknown. The client sends upper-case types + * (redisQuery.tsx:22-57), which a Turkish locale does not change, so only an API call with a lower-case type reaches it. + * A fix ({@code Locale.ROOT}) changes this test on purpose. The default locale is global state: restored in finally. + */ + @Test + public void lowerCaseCommandTypeIsRejectedUnderATurkishDefaultLocale_pinsD17() { + Locale saved = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + System.out.println(TAG + "default locale: " + Locale.getDefault() + ", upper case of lindex: " + "lindex".toUpperCase()); + PluginException thrown = assertThrows(PluginException.class, () -> RedisQueryUtils.convertRedisFormInputToRedisCommand(form("lindex"))); + assertEquals("INVALID_REDIS_REQUEST", thrown.getMessageKey()); + assertInstanceOf(RedisCommand.Lindex.class, RedisQueryUtils.convertRedisFormInputToRedisCommand(form("LINDEX")), "the upper-case type the client sends is unaffected"); + } finally { + Locale.setDefault(saved); + } + assertInstanceOf(RedisCommand.Lindex.class, RedisQueryUtils.convertRedisFormInputToRedisCommand(form("lindex")), "locale restored: " + Locale.getDefault()); + } + + /** + * Observation (no row): the dispatch accepts a lower-case type, but {@code RedisCommand.getProtocolCommand} reads the type + * without upper-casing it, so the command it hands on fails with the enum's own {@code IllegalArgumentException}. The client + * sends upper case, so this is reachable by an API call only. + */ + @Test + public void lowerCaseTypeIsAcceptedByTheDispatchButNotByTheProtocolCommandLookup() { + RedisCommand command = RedisQueryUtils.convertRedisFormInputToRedisCommand(form("get")); + assertThrows(IllegalArgumentException.class, command::getProtocolCommand); + } +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/utils/RedisUriUtilsTest.java b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/utils/RedisUriUtilsTest.java new file mode 100644 index 0000000000..69862a3b14 --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/java/org/lowcoder/plugin/redis/utils/RedisUriUtilsTest.java @@ -0,0 +1,142 @@ +package org.lowcoder.plugin.redis.utils; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.redis.model.RedisDatasourceConfig; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit RD-1 (task L5-9): {@code RedisUriUtils.getURI}, the connection URI built from the datasource config: host and port, + * the default port, the credentials, URI mode, and the two known defects D5 (credentials not encoded) and D6 (the SSL switch + * is never used). + * + *

    Limits: only the URI is built; whether a server accepts it is RD-6. + */ +public class RedisUriUtilsTest { + + static final String TAG = "[RedisUriUtilsTest] "; + static final String HOST = "cache.example.org"; + static final long PORT = 6380; + static final long DEFAULT_PORT = 6379; + + private static RedisDatasourceConfig config(Map values) { + return RedisDatasourceConfig.buildFrom(new HashMap<>(values)); + } + + private static URI uri(Map values) throws URISyntaxException { + URI uri = RedisUriUtils.getURI(config(values)); + System.out.println(TAG + values + " -> " + uri); + return uri; + } + + @Test + public void hostAndPortBuildAPlainRedisUri() throws Exception { + URI uri = uri(Map.of("host", HOST, "port", PORT)); + assertEquals("redis://" + HOST + ":" + PORT, uri.toString()); + assertEquals("redis", uri.getScheme()); + assertEquals(HOST, uri.getHost()); + assertEquals(PORT, uri.getPort()); + assertNull(uri.getUserInfo()); + } + + @Test + public void portDefaultsTo6379() throws Exception { + assertEquals("redis://" + HOST + ":" + DEFAULT_PORT, uri(Map.of("host", HOST)).toString()); + } + + @Test + public void passwordAloneIsAnEmptyUserAndUserWithPasswordIsBoth() throws Exception { + assertEquals("redis://:pw@" + HOST + ":" + PORT, uri(Map.of("host", HOST, "port", PORT, "password", "pw")).toString()); + assertEquals("redis://alice:pw@" + HOST + ":" + PORT, uri(Map.of("host", HOST, "port", PORT, "username", "alice", "password", "pw")).toString()); + assertEquals("redis://" + HOST + ":" + PORT, uri(Map.of("host", HOST, "port", PORT, "password", " ")).toString(), "a blank password adds no credentials"); + } + + /** Observation, part of the D5 row: a user name without a password is silently dropped, so the server sees an anonymous connection. */ + @Test + public void userNameWithoutPasswordIsDropped_partOfTheD5Row() throws Exception { + URI uri = uri(Map.of("host", HOST, "port", PORT, "username", "alice")); + assertEquals("redis://" + HOST + ":" + PORT, uri.toString()); + assertNull(uri.getUserInfo()); + } + + @Test + public void uriModePassesTheStoredUriThroughAndRejectsAnInvalidOne() throws Exception { + assertEquals("redis://u:p@other:7000/2", uri(Map.of("usingUri", true, "uri", "redis://u:p@other:7000/2", "host", HOST)).toString()); + assertEquals("rediss://secure:6380", uri(Map.of("usingUri", true, "uri", "rediss://secure:6380")).toString()); + assertThrows(URISyntaxException.class, () -> uri(Map.of("usingUri", true, "uri", "redis://bad host:1"))); + } + + private static boolean roundTrips(String password) { + try { + URI uri = RedisUriUtils.getURI(config(Map.of("host", HOST, "port", PORT, "password", password))); + boolean same = (":" + password).equals(uri.getUserInfo()); + System.out.println(TAG + "password " + password + " -> " + uri + " userInfo=" + uri.getUserInfo() + " host=" + uri.getHost() + " roundTrips=" + same); + return same && HOST.equals(uri.getHost()); + } catch (URISyntaxException e) { + System.out.println(TAG + "password " + password + " -> URISyntaxException " + e.getMessage()); + return false; + } + } + + /** + * Pins defect D5 (analysis-plugins section 0.6; plan section 9 D1-D20 row): the credentials are put into the URI without + * URL-encoding, so a password with one of {@code @ / # %} does not come back as the same password (a syntax error, or a + * different user info or host). A fix (encoding the user name and password) changes this test on purpose. + */ + @Test + public void passwordsWithReservedCharactersDoNotRoundTrip_pinsD5() { + assertTrue(roundTrips("plain-Pass_1"), "a password without reserved characters is the control"); + assertTrue(roundTrips("a:b"), "a colon in the password survives: the user info keeps everything before the at sign"); + for (String password : List.of("a@b", "a/b", "a#b", "a%b")) { + assertFalse(roundTrips(password), password); + } + } + + private static boolean userRoundTrips(String username) { + try { + URI uri = RedisUriUtils.getURI(config(Map.of("host", HOST, "port", PORT, "username", username, "password", "pw"))); + boolean same = uri.getUserInfo() != null && Arrays.equals(new String[] {username, "pw"}, uri.getUserInfo().split(":", 2)); // the split Jedis applies: user before the first colon + System.out.println(TAG + "user " + username + " -> " + uri + " userInfo=" + uri.getUserInfo() + " host=" + uri.getHost() + " roundTrips=" + same); + return same && HOST.equals(uri.getHost()); + } catch (URISyntaxException e) { + System.out.println(TAG + "user " + username + " -> URISyntaxException " + e.getMessage()); + return false; + } + } + + /** + * Pins the user-name half of defect D5 (analysis-plugins section 0.6; plan section 9 D1-D20 row): the user name is put into the + * URI without URL-encoding either, so a user name with one of {@code @ : / # %} does not come back as the same user (a syntax + * error, or a different user info or host). A fix (encoding the user name) changes this test on purpose. + */ + @Test + public void userNamesWithReservedCharactersDoNotRoundTrip_pinsD5() { + assertTrue(userRoundTrips("alice_1"), "a user name without reserved characters is the control"); + for (String username : List.of("a@b", "a:b", "a/b", "a#b", "a%b")) { + assertFalse(userRoundTrips(username), username); + } + } + + /** + * Pins defect D6 (analysis-plugins section 0.6; plan section 9 D1-D20 row): {@code usingSsl} is stored and merged but never + * read when the URI is built, so selecting SSL still gives a clear-text {@code redis://} URI. A fix ({@code rediss://}) changes + * this test on purpose. + */ + @Test + public void sslSwitchIsIgnored_pinsD6() throws Exception { + URI withSsl = uri(Map.of("host", HOST, "port", PORT, "usingSsl", true)); + assertEquals("redis", withSsl.getScheme()); + assertEquals(uri(Map.of("host", HOST, "port", PORT, "usingSsl", false)), withSsl); + } +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/resources/json-contract/config-binding/RedisDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/redisPlugin/src/test/resources/json-contract/config-binding/RedisDatasourceConfig.input.json new file mode 100644 index 0000000000..8eeeca2452 --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/resources/json-contract/config-binding/RedisDatasourceConfig.input.json @@ -0,0 +1,20 @@ +{ + "full": { + "host": " redis.example.com ", + "port": 6379, + "usingSsl": true, + "username": "default", + "password": "pw", + "usingUri": false, + "uri": null + }, + "uri": { + "usingUri": true, + "uri": "rediss://default:pw@redis.example.com:6380/0" + }, + "scalarsAsStrings": { + "port": "6380", + "usingSsl": "false" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/resources/json-contract/config-binding/RedisDatasourceConfig.json b/server/api-service/lowcoder-plugins/redisPlugin/src/test/resources/json-contract/config-binding/RedisDatasourceConfig.json new file mode 100644 index 0000000000..558ceb80c6 --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/resources/json-contract/config-binding/RedisDatasourceConfig.json @@ -0,0 +1,109 @@ +{ + "buildFrom" : { + "full" : { + "class" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "host" : " redis.example.com ", + "port" : 6379, + "usingSsl" : true, + "username" : "default", + "password" : "pw", + "usingUri" : false, + "uri" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/usingUri" : "java.lang.Boolean", + "/uri" : "null" + }, + "noView" : {"host":" redis.example.com ","port":6379,"usingSsl":true,"username":"default","password":"pw","usingUri":false,"uri":null}, + "Public" : {"host":" redis.example.com ","port":6379,"usingSsl":true,"username":"default","usingUri":false}, + "Internal" : {"host":" redis.example.com ","port":6379,"usingSsl":true,"username":"default","password":"pw","usingUri":false,"uri":null} + }, + "uri" : { + "class" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "host" : null, + "port" : null, + "usingSsl" : false, + "username" : null, + "password" : null, + "usingUri" : true, + "uri" : "rediss://default:pw@redis.example.com:6380/0" + }, + "classes" : { + "" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/username" : "null", + "/password" : "null", + "/usingUri" : "java.lang.Boolean", + "/uri" : "java.lang.String" + }, + "noView" : {"host":null,"port":null,"usingSsl":false,"username":null,"password":null,"usingUri":true,"uri":"rediss://default:pw@redis.example.com:6380/0"}, + "Public" : {"host":null,"port":null,"usingSsl":false,"username":null,"usingUri":true}, + "Internal" : {"host":null,"port":null,"usingSsl":false,"username":null,"password":null,"usingUri":true,"uri":"rediss://default:pw@redis.example.com:6380/0"} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "host" : null, + "port" : 6380, + "usingSsl" : false, + "username" : null, + "password" : null, + "usingUri" : false, + "uri" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "/host" : "null", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/username" : "null", + "/password" : "null", + "/usingUri" : "java.lang.Boolean", + "/uri" : "null" + }, + "noView" : {"host":null,"port":6380,"usingSsl":false,"username":null,"password":null,"usingUri":false,"uri":null}, + "Public" : {"host":null,"port":6380,"usingSsl":false,"username":null,"usingUri":false}, + "Internal" : {"host":null,"port":6380,"usingSsl":false,"username":null,"password":null,"usingUri":false,"uri":null} + }, + "empty" : { + "class" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "host" : null, + "port" : null, + "usingSsl" : false, + "username" : null, + "password" : null, + "usingUri" : false, + "uri" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.redis.model.RedisDatasourceConfig", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/username" : "null", + "/password" : "null", + "/usingUri" : "java.lang.Boolean", + "/uri" : "null" + }, + "noView" : {"host":null,"port":null,"usingSsl":false,"username":null,"password":null,"usingUri":false,"uri":null}, + "Public" : {"host":null,"port":null,"usingSsl":false,"username":null,"usingUri":false}, + "Internal" : {"host":null,"port":null,"usingSsl":false,"username":null,"password":null,"usingUri":false,"uri":null} + }, + "unknownProperty" : "ignored: binds as form full" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/redisPlugin/src/test/resources/json-contract/query-results/RedisEngine.results.json b/server/api-service/lowcoder-plugins/redisPlugin/src/test/resources/json-contract/query-results/RedisEngine.results.json new file mode 100644 index 0000000000..da16e42c18 --- /dev/null +++ b/server/api-service/lowcoder-plugins/redisPlugin/src/test/resources/json-contract/query-results/RedisEngine.results.json @@ -0,0 +1,97 @@ +{ + "bulkString" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":"{\"a\": 1.50, \"text\": \"žluť\"}","success":true} + }, + "invalidUtf8" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":"a�b","success":true} + }, + "simpleStringOk" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":"OK","success":true} + }, + "integer" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":"3000000001","success":true} + }, + "nil" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","success":true} + }, + "array" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.lang.String", + "/1" : "null", + "/2" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":["one",null,"žluť"],"success":true} + }, + "emptyArray" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":[],"success":true} + }, + "nestedArray" : { + "queryCode" : "REDIS_EXECUTION_ERROR", + "messageKey" : "REDIS_EXECUTION_ERROR", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"REDIS_EXECUTION_ERROR","success":false} + }, + "arrayOfIntegers" : { + "queryCode" : "REDIS_EXECUTION_ERROR", + "messageKey" : "REDIS_EXECUTION_ERROR", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"REDIS_EXECUTION_ERROR","success":false} + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/AuthConfigTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/AuthConfigTest.java index 75704e1378..30bb873961 100644 --- a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/AuthConfigTest.java +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/AuthConfigTest.java @@ -5,8 +5,8 @@ import static org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType.DIGEST_AUTH; import static org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType.NO_AUTH; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; import org.lowcoder.sdk.plugin.restapi.auth.BasicAuthConfig; import org.lowcoder.sdk.plugin.restapi.auth.DefaultAuthConfig; @@ -34,11 +34,11 @@ public void testRestApiDatasourceConfigDeserialize4BasicAuthType() { } """; RestApiDatasourceConfig restApiDatasourceConfig = JsonUtils.fromJson(json, RestApiDatasourceConfig.class); - Assert.assertNotNull(restApiDatasourceConfig); + Assertions.assertNotNull(restApiDatasourceConfig); BasicAuthConfig basicAuthConfig = (BasicAuthConfig) restApiDatasourceConfig.getAuthConfig(); - Assert.assertEquals(BASIC_AUTH, basicAuthConfig.getType()); - Assert.assertEquals("jack", basicAuthConfig.getUsername()); - Assert.assertEquals("123456", basicAuthConfig.getPassword()); + Assertions.assertEquals(BASIC_AUTH, basicAuthConfig.getType()); + Assertions.assertEquals("jack", basicAuthConfig.getUsername()); + Assertions.assertEquals("123456", basicAuthConfig.getPassword()); } @Test @@ -60,11 +60,11 @@ public void testRestApiDatasourceConfigDeserialize4DigestAuthType() { } """; RestApiDatasourceConfig restApiDatasourceConfig = JsonUtils.fromJson(json, RestApiDatasourceConfig.class); - Assert.assertNotNull(restApiDatasourceConfig); + Assertions.assertNotNull(restApiDatasourceConfig); BasicAuthConfig basicAuthConfig = (BasicAuthConfig) restApiDatasourceConfig.getAuthConfig(); - Assert.assertEquals(DIGEST_AUTH, basicAuthConfig.getType()); - Assert.assertEquals("jack", basicAuthConfig.getUsername()); - Assert.assertEquals("123456", basicAuthConfig.getPassword()); + Assertions.assertEquals(DIGEST_AUTH, basicAuthConfig.getType()); + Assertions.assertEquals("jack", basicAuthConfig.getUsername()); + Assertions.assertEquals("123456", basicAuthConfig.getPassword()); } @Test @@ -86,9 +86,9 @@ public void testRestApiDatasourceConfigDeserialize4NoAuthType() { } """; RestApiDatasourceConfig restApiDatasourceConfig = JsonUtils.fromJson(json, RestApiDatasourceConfig.class); - Assert.assertNotNull(restApiDatasourceConfig); + Assertions.assertNotNull(restApiDatasourceConfig); NoneAuthConfig noneAuthConfig = (NoneAuthConfig) restApiDatasourceConfig.getAuthConfig(); - Assert.assertEquals(NO_AUTH, noneAuthConfig.getType()); + Assertions.assertEquals(NO_AUTH, noneAuthConfig.getType()); } @Test @@ -110,8 +110,8 @@ public void testRestApiDatasourceConfigDeserialize4DefaultAuthType() { } """; RestApiDatasourceConfig restApiDatasourceConfig = JsonUtils.fromJson(json, RestApiDatasourceConfig.class); - Assert.assertNotNull(restApiDatasourceConfig); + Assertions.assertNotNull(restApiDatasourceConfig); DefaultAuthConfig defaultAuthConfig = (DefaultAuthConfig) restApiDatasourceConfig.getAuthConfig(); - Assert.assertEquals(BEARER_TOKEN_AUTH, defaultAuthConfig.getType()); + Assertions.assertEquals(BEARER_TOKEN_AUTH, defaultAuthConfig.getType()); } } \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/EchoServerStub.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/EchoServerStub.java new file mode 100644 index 0000000000..372ef25a6c --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/EchoServerStub.java @@ -0,0 +1,247 @@ +package org.lowcoder.plugin.restapi; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A local stand-in for the parts of postman-echo.com that {@link RestApiEngineTest} uses, served by + * {@link RecordingHttpServer}, so the test needs no network access: + *

      + *
    • {@value #POST_PATH}: echoes the request as postman-echo does: {@code url} (as received, with its raw query), + * {@code args}, {@code data} (the body parsed as JSON when sent as JSON, else its text), {@code json}, {@code form} + * (url-encoded fields and multipart text parts) and {@code files} (multipart file parts as data URIs);
    • + *
    • {@value #BASIC_AUTH_PATH} and {@value #DIGEST_AUTH_PATH}: {@code {"authenticated":true}} for the credentials + * {@value #USERNAME}/{@value #PASSWORD}, else 401 (the digest path challenges first and checks the digest response of + * RFC 7616 with MD5 and {@code qop=auth});
    • + *
    • {@value #RESPONSE_HEADERS_PATH}: every query parameter as a response header and as a member of the JSON body.
    • + *
    + * + *

    Limits: only what the test asserts is reproduced (postman-echo also echoes headers and more); the digest nonce is + * fixed, and nonce counts are not checked for replay. + */ +final class EchoServerStub { + + static final String POST_PATH = "/post"; + static final String BASIC_AUTH_PATH = "/basic-auth"; + static final String DIGEST_AUTH_PATH = "/digest-auth"; + static final String RESPONSE_HEADERS_PATH = "/response-headers"; + static final String USERNAME = "postman"; + static final String PASSWORD = "password"; + + static final int OK = 200; + static final int UNAUTHORIZED = 401; + static final String AUTHENTICATED_BODY = "{\"authenticated\":true}"; + + private static final String REALM = "Users"; + private static final String NONCE = "f2a8d2c6b1e04a7f9c3d5e6b7a8c9d0e"; + private static final String QOP = "auth"; + private static final String AUTHORIZATION = "Authorization"; + private static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + private static final String HOST = "Host"; + private static final String JSON_UTF8 = "application/json; charset=utf-8"; + private static final String DEFAULT_FILE_TYPE = "application/octet-stream"; + private static final String BASIC_PREFIX = "Basic "; + private static final String DIGEST_PREFIX = "Digest "; + private static final String CRLF = "\r\n"; + private static final Pattern DIGEST_PARAM = Pattern.compile("(\\w+)=(?:\"([^\"]*)\"|([^,\\s]+))"); + private static final Pattern DISPOSITION_PARAM = Pattern.compile("(name|filename)=\"([^\"]*)\""); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private EchoServerStub() { + } + + static RecordingHttpServer start() { + return RecordingHttpServer.serve(Map.of( + POST_PATH, EchoServerStub::post, + BASIC_AUTH_PATH, EchoServerStub::basicAuth, + DIGEST_AUTH_PATH, EchoServerStub::digestAuth, + RESPONSE_HEADERS_PATH, EchoServerStub::responseHeaders)); + } + + private static Response post(Request request) { + String contentType = request.header(RecordingHttpServer.CONTENT_TYPE).stream().findFirst().orElse(""); + String mediaType = contentType.split(";")[0].trim().toLowerCase(Locale.ROOT); + ObjectNode echo = MAPPER.createObjectNode(); + echo.set("args", MAPPER.valueToTree(queryParameters(request))); + ObjectNode form = MAPPER.createObjectNode(); + ObjectNode files = MAPPER.createObjectNode(); + echo.put("data", ""); + echo.putNull("json"); + switch (mediaType) { + case "application/json" -> { + JsonNode json = parseOrNull(request.bodyText()); + if (json == null) { + echo.put("data", request.bodyText()); + } else { + echo.set("data", json); + echo.set("json", json); + } + } + case "application/x-www-form-urlencoded" -> decodeForm(request.bodyText()).forEach(form::put); + case "multipart/form-data" -> readMultipart(request.body(), boundary(contentType), form, files); + default -> echo.put("data", request.bodyText()); + } + echo.set("files", files); + echo.set("form", form); + echo.put("url", "http://" + request.header(HOST).get(0) + request.pathAndQuery()); + return json(OK, echo.toString()); + } + + private static Response basicAuth(Request request) { + String expected = BASIC_PREFIX + Base64.getEncoder() + .encodeToString((USERNAME + ":" + PASSWORD).getBytes(StandardCharsets.UTF_8)); + boolean authenticated = request.header(AUTHORIZATION).contains(expected); + return authenticated ? json(OK, AUTHENTICATED_BODY) : new Response(UNAUTHORIZED, Map.of(), null); + } + + private static Response digestAuth(Request request) { + String authorization = request.header(AUTHORIZATION).stream().findFirst().orElse(""); + if (authorization.startsWith(DIGEST_PREFIX) && validDigest(request, digestParameters(authorization))) { + return json(OK, AUTHENTICATED_BODY); + } + String challenge = DIGEST_PREFIX + "realm=\"" + REALM + "\", nonce=\"" + NONCE + "\", qop=\"" + QOP + "\""; + return new Response(UNAUTHORIZED, Map.of(WWW_AUTHENTICATE, List.of(challenge)), null); + } + + private static Response responseHeaders(Request request) { + Map parameters = queryParameters(request); + Map> headers = new LinkedHashMap<>(); + parameters.forEach((name, value) -> headers.put(name, List.of(value))); + try { + return new Response(OK, headers, MAPPER.writeValueAsBytes(parameters)); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + /** RFC 7616 with MD5: response = MD5(HA1:nonce:nc:cnonce:qop:HA2), or MD5(HA1:nonce:HA2) without qop. */ + private static boolean validDigest(Request request, Map digest) { + String path = request.pathAndQuery().split("\\?")[0]; + if (!USERNAME.equals(digest.get("username")) || !REALM.equals(digest.get("realm")) + || !NONCE.equals(digest.get("nonce")) || !path.equals(digest.get("uri"))) { + return false; + } + String ha1 = md5(USERNAME + ":" + REALM + ":" + PASSWORD); + String ha2 = md5(request.method() + ":" + digest.get("uri")); + String expected = digest.containsKey("qop") + ? md5(ha1 + ":" + NONCE + ":" + digest.get("nc") + ":" + digest.get("cnonce") + ":" + digest.get("qop") + ":" + ha2) + : md5(ha1 + ":" + NONCE + ":" + ha2); + return expected.equals(digest.get("response")); + } + + private static Map digestParameters(String authorization) { + Map parameters = new HashMap<>(); + Matcher matcher = DIGEST_PARAM.matcher(authorization.substring(DIGEST_PREFIX.length())); + while (matcher.find()) { + parameters.put(matcher.group(1), matcher.group(2) != null ? matcher.group(2) : matcher.group(3)); + } + return parameters; + } + + private static Map queryParameters(Request request) { + String[] pathAndQuery = request.pathAndQuery().split("\\?", 2); + return pathAndQuery.length < 2 ? Map.of() : decodeForm(pathAndQuery[1]); + } + + private static Map decodeForm(String encoded) { + Map fields = new LinkedHashMap<>(); + for (String pair : encoded.split("&")) { + if (!pair.isEmpty()) { + String[] nameAndValue = pair.split("=", 2); + fields.put(URLDecoder.decode(nameAndValue[0], StandardCharsets.UTF_8), + nameAndValue.length < 2 ? "" : URLDecoder.decode(nameAndValue[1], StandardCharsets.UTF_8)); + } + } + return fields; + } + + private static String boundary(String contentType) { + for (String parameter : contentType.split(";")) { + String trimmed = parameter.trim(); + if (trimmed.startsWith("boundary=")) { + return trimmed.substring("boundary=".length()).replace("\"", ""); + } + } + throw new IllegalArgumentException("multipart request without a boundary: " + contentType); + } + + /** Splits a multipart body into text fields ({@code form}) and file parts ({@code files}, as data URIs). */ + private static void readMultipart(byte[] body, String boundary, ObjectNode form, ObjectNode files) { + // ISO-8859-1 maps every byte to one char, so file bytes survive the round trip through String + String text = new String(body, StandardCharsets.ISO_8859_1); + for (String part : text.split("--" + Pattern.quote(boundary))) { + int headerEnd = part.indexOf(CRLF + CRLF); + if (headerEnd < 0) { + continue; + } + String headers = part.substring(0, headerEnd); + String content = part.substring(headerEnd + 2 * CRLF.length()); + if (content.endsWith(CRLF)) { + content = content.substring(0, content.length() - CRLF.length()); + } + Map disposition = new HashMap<>(); + String partType = DEFAULT_FILE_TYPE; + for (String header : headers.split(CRLF)) { + String lower = header.toLowerCase(Locale.ROOT); + if (lower.startsWith("content-disposition:")) { + Matcher matcher = DISPOSITION_PARAM.matcher(header); + while (matcher.find()) { + disposition.put(matcher.group(1), matcher.group(2)); + } + } else if (lower.startsWith("content-type:")) { + partType = header.substring("content-type:".length()).trim(); + } + } + byte[] bytes = content.getBytes(StandardCharsets.ISO_8859_1); + if (disposition.containsKey("filename")) { + files.put(disposition.get("filename"), "data:" + partType + ";base64," + Base64.getEncoder().encodeToString(bytes)); + } else if (disposition.containsKey("name")) { + form.put(disposition.get("name"), new String(bytes, StandardCharsets.UTF_8)); + } + } + } + + private static JsonNode parseOrNull(String text) { + try { + return MAPPER.readTree(text); + } catch (JsonProcessingException e) { + return null; + } + } + + private static Response json(int status, String body) { + return new Response(status, Map.of(RecordingHttpServer.CONTENT_TYPE, List.of(JSON_UTF8)), body.getBytes(StandardCharsets.UTF_8)); + } + + private static String md5(String text) { + try { + byte[] digest = MessageDigest.getInstance("MD5").digest(text.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte b : digest) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiBodyAndContentTypeTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiBodyAndContentTypeTest.java new file mode 100644 index 0000000000..72ff47da6b --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiBodyAndContentTypeTest.java @@ -0,0 +1,139 @@ +package org.lowcoder.plugin.restapi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; + +/** + * Content type validation, header, parameter and body merging of {@link RestApiExecutor}: what the datasource and the + * query each contribute, and who wins. The multipart file part and the encoding cases are in RestApiEngineTest. + */ +class RestApiBodyAndContentTypeTest { + + private static final String PATH = "/post"; + private static final String CONTENT_TYPE = "Content-Type"; + private static final String FORM = "application/x-www-form-urlencoded"; + private static final String JSON = "application/json"; + + private final RestApiCallSupport support = new RestApiCallSupport(); + private RecordingHttpServer server; + + @BeforeEach + void startServer() { + server = RecordingHttpServer.start(Map.of(PATH, RestApiCallSupport.json(200, "{}"))); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private RestApiDatasourceConfig.RestApiDatasourceConfigBuilder datasource() { + return RestApiDatasourceConfig.builder().url(server.baseUrl() + PATH); + } + + private RecordingHttpServer.Request send(RestApiDatasourceConfig datasource, Map query) { + int before = server.requests().size(); + support.run(datasource, query, RestApiCallSupport.visitor(null, null)); + assertThat(server.requests()).hasSize(before + 1); + return server.requests().get(before); + } + + private static List formFields(String body) { + return Arrays.stream(body.split("&")).sorted().toList(); + } + + @Test + void anInvalidContentTypeIsRejectedBeforeAnyRequest() { + RestApiDatasourceConfig datasource = datasource().headers(List.of(new Property(CONTENT_TYPE, "not a type"))).build(); + + PluginException error = assertThrows(PluginException.class, + () -> support.buildContext(datasource, Map.of("httpMethod", "POST"), RestApiCallSupport.visitor(null, null))); + + System.out.println("[RestApiBodyAndContentTypeTest] invalid content type -> " + error.getMessage()); + assertThat(error).hasMessageContaining("Invalid Content-Type").hasMessageContaining("not a type"); + assertThat(server.requests()).isEmpty(); + } + + @Test + void aBlankContentTypeIsAcceptedAndNoContentTypeHeaderIsSent() { + RecordingHttpServer.Request request = send(datasource().build(), Map.of("httpMethod", "GET")); + + assertThat(request.header(CONTENT_TYPE)).isEmpty(); + } + + @Test + void formFieldsOfTheDatasourceAreMergedWithThoseOfTheQueryAndTheQueryWinsForADuplicateKey() { + RestApiDatasourceConfig datasource = datasource().headers(List.of(new Property(CONTENT_TYPE, FORM))) + .bodyFormData(List.of(new Property("a", "1"), new Property("b", "2"))).build(); + + RecordingHttpServer.Request request = send(datasource, Map.of("httpMethod", "POST", + "bodyFormData", List.of(new Property("a", "9"), new Property("c", "3")))); + + System.out.println("[RestApiBodyAndContentTypeTest] form body " + request.bodyText()); + assertThat(formFields(request.bodyText())).containsExactly("a=9", "b=2", "c=3"); + } + + @Test + void headersAndParamsOfTheQueryOverrideThoseOfTheDatasourceAndBlankHeadersAreDropped() { + RestApiDatasourceConfig datasource = datasource() + .headers(List.of(new Property("X-A", "1"), new Property("X-Ds", "kept"))) + .params(List.of(new Property("p", "1"), new Property("q", "kept"))).build(); + + RecordingHttpServer.Request request = send(datasource, Map.of("httpMethod", "GET", + "headers", List.of(new Property("X-A", "2"), new Property("X-Blank-Value", " "), new Property(" ", "v")), + "params", List.of(new Property("p", "2")))); + + System.out.println("[RestApiBodyAndContentTypeTest] " + request.pathAndQuery() + " " + request.headers().keySet()); + assertThat(request.header("X-A")).containsExactly("2"); + assertThat(request.header("X-Ds")).containsExactly("kept"); + assertThat(request.header("X-Blank-Value")).isEmpty(); + assertThat(request.pathAndQuery().split("\\?")[1].split("&")).containsExactlyInAnyOrder("p=2", "q=kept"); + } + + @Test + void aGetSendsAnEmptyBodyEvenWhenTheQueryHasOne() { + RestApiDatasourceConfig datasource = datasource().headers(List.of(new Property(CONTENT_TYPE, JSON))).build(); + + RecordingHttpServer.Request request = send(datasource, Map.of("httpMethod", "GET", "body", "{\"ignored\":true}")); + + assertThat(request.method()).isEqualTo("GET"); + assertThat(request.bodyText()).isEmpty(); + } + + /** + * Only the unambiguous part is asserted: a POST without any content type is sent. What happens to its body is + * reported to the coordinator as probe P3 and not pinned (see log-L4.md, L4-9). + */ + @Test + void aPostWithoutAContentTypeIsSent() { + RecordingHttpServer.Request request = send(datasource().build(), Map.of("httpMethod", "POST", "body", "payload")); + + System.out.println("[RestApiBodyAndContentTypeTest] POST without content type: body '" + request.bodyText() + "' type " + request.header(CONTENT_TYPE)); + assertThat(request.method()).isEqualTo("POST"); + } + + @Test + void aJsonQueryBodyObjectIsMergedWithTheDatasourceFormFieldsAndAnArrayBodyIsSentUnchanged() { + RestApiDatasourceConfig datasource = datasource().headers(List.of(new Property(CONTENT_TYPE, JSON))) + .bodyFormData(List.of(new Property("only", "ds"))).build(); + + RecordingHttpServer.Request object = send(datasource, Map.of("httpMethod", "POST", "body", "{\"q\":1}")); + RecordingHttpServer.Request array = send(datasource, Map.of("httpMethod", "POST", "body", "[1,2]")); + + System.out.println("[RestApiBodyAndContentTypeTest] json object " + object.bodyText() + ", array " + array.bodyText()); + assertThat(object.bodyText()).isEqualTo("{\"q\":1,\"only\":\"ds\"}"); + assertThat(array.bodyText()).isEqualTo("[1,2]"); + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiBodyMergePrecedenceTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiBodyMergePrecedenceTest.java new file mode 100644 index 0000000000..7128e65f21 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiBodyMergePrecedenceTest.java @@ -0,0 +1,55 @@ +package org.lowcoder.plugin.restapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; + +/** + * DEFECT pinned (plan section 9 row "reproduced, to pin (L4-9 follow-up)", probe P2; D-6, fix deferred): the two body + * merges of {@link RestApiExecutor} use opposite precedence for a key that the datasource and the query both set. In a + * urlencoded or multipart body the query's value wins (mergeBody(List, List), RestApiExecutor.java:442-445). In a JSON + * body the datasource's value wins: mergeBody(QueryBody, List) overwrites the query's field with + * {@code objectNode.put} (:205-208). A user who overrides a datasource default in a JSON query sees the default sent. + * The obvious fix is one precedence for both (the query wins), which turns the JSON assertion red. + */ +class RestApiBodyMergePrecedenceTest { + + private static final String PATH = "/post"; + private static final String CONTENT_TYPE = "Content-Type"; + + private final RestApiCallSupport support = new RestApiCallSupport(); + + private String send(String contentType, Map query) { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of(PATH, RestApiCallSupport.json(200, "{}")))) { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url(server.baseUrl() + PATH) + .headers(List.of(new Property(CONTENT_TYPE, contentType))) + .bodyFormData(List.of(new Property("k", "fromDatasource"), new Property("only", "ds"))).build(); + support.run(datasource, query, RestApiCallSupport.visitor(null, null)); + assertThat(server.requests()).hasSize(1); + return server.requests().get(0).bodyText(); + } + } + + @Test + void inAJsonBodyTheDatasourceValueOverridesTheQueryValueForTheSameKey() { + String body = send("application/json", Map.of("httpMethod", "POST", "body", "{\"k\":\"fromQuery\",\"q\":1}")); + + System.out.println("[RestApiBodyMergePrecedenceTest] json body " + body); + assertThat(body).isEqualTo("{\"k\":\"fromDatasource\",\"q\":1,\"only\":\"ds\"}"); + } + + @Test + void inAFormBodyTheQueryValueOverridesTheDatasourceValueForTheSameKey() { + String body = send("application/x-www-form-urlencoded", Map.of("httpMethod", "POST", + "bodyFormData", List.of(new Property("k", "fromQuery")))); + + System.out.println("[RestApiBodyMergePrecedenceTest] form body " + body); + assertThat(body.split("&")).containsExactlyInAnyOrder("k=fromQuery", "only=ds"); + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiCallSupport.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiCallSupport.java new file mode 100644 index 0000000000..df1b5ae269 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiCallSupport.java @@ -0,0 +1,90 @@ +package org.lowcoder.plugin.restapi; + +import static java.util.Collections.emptyMap; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.lowcoder.plugin.restapi.model.RestApiQueryExecutionContext; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.query.QueryVisitorContext; +import org.springframework.http.HttpCookie; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import reactor.core.publisher.Mono; + +/** + * What the REST API executor tests of L4-9 share: an executor whose {@code CommonConfig} names the Lowcoder session + * cookie, a visitor context with cookies and an OAuth token Mono, and one call that builds the execution context and + * runs it against a {@link RecordingHttpServer} (a real WebClient, no network). New file; the existing + * RestApiEngineTest keeps its own helpers. + */ +final class RestApiCallSupport { + + static final String SESSION_COOKIE = "LOWCODER_SESSION"; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final String VISITOR_ID = "visitor-1"; + static final String ORG_ID = "org-1"; + static final int SYSTEM_PORT = 8080; + + private final RestApiExecutor executor; + + RestApiCallSupport() { + CommonConfig config = new CommonConfig(); + config.setCookieName(SESSION_COOKIE); + executor = new RestApiExecutor(config); + } + + static QueryVisitorContext visitor(MultiValueMap cookies, Mono> authToken) { + return new QueryVisitorContext(VISITOR_ID, ORG_ID, SYSTEM_PORT, cookies, authToken, Set.of()); + } + + static MultiValueMap cookies(String... nameValuePairs) { + MultiValueMap cookies = new LinkedMultiValueMap<>(); + for (int i = 0; i < nameValuePairs.length; i += 2) { + cookies.add(nameValuePairs[i], new HttpCookie(nameValuePairs[i], nameValuePairs[i + 1])); + } + return cookies; + } + + /** Builds the execution context only (the part that throws for an invalid query). */ + RestApiQueryExecutionContext buildContext(RestApiDatasourceConfig datasource, Map query, QueryVisitorContext visitor) { + return executor.doBuildQueryExecutionContext(datasource, query, emptyMap(), visitor); + } + + /** Builds the context and runs it; an error of the call is thrown by {@code block}. */ + QueryExecutionResult run(RestApiDatasourceConfig datasource, Map query, QueryVisitorContext visitor) { + return executor.doExecuteQuery(null, buildContext(datasource, query, visitor)).block(TIMEOUT); + } + + /** The error of a call that must fail (null if it did not). */ + Throwable failureOf(RestApiDatasourceConfig datasource, Map query, QueryVisitorContext visitor) { + try { + run(datasource, query, visitor); + return null; + } catch (Throwable t) { + return t; + } + } + + static Response json(int status, String body) { + return new Response(status, Map.of("Content-Type", List.of("application/json")), body.getBytes(StandardCharsets.UTF_8)); + } + + static Response redirect(int status, String location) { + return new Response(status, Map.of("Location", List.of(location)), null); + } + + /** The recorded requests whose path (without query) is {@code path}. */ + static List requestsTo(RecordingHttpServer server, String path) { + return server.requests().stream().filter(request -> request.pathAndQuery().split("\\?")[0].equals(path)).toList(); + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiConfigBindingContractTest.java new file mode 100644 index 0000000000..ef494155f6 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiConfigBindingContractTest.java @@ -0,0 +1,30 @@ +package org.lowcoder.plugin.restapi; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.restapi.model.RestApiQueryConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code restApiPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1), + * through the real entry points: {@code RestApiQueryConfig.from}, including the HTTP method by name and in lower + * case. {@code ConfigBinding} applies each entry point to every form of the input fixture (canonical names, alias + * or getter-derived names, scalars as strings, missing and empty values) and pins the bound state, the written + * views and the errors in the report fixture. + */ +public class RestApiConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/model/RestApiQueryConfig.java#RestApiQueryConfig.from#toJson#1", + "lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/model/RestApiQueryConfig.java#RestApiQueryConfig.from#fromJson#1"}) + @Test + public void queryConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/RestApiQueryConfig.input.json", "config-binding/RestApiQueryConfig.json", + Map.of("from", RestApiQueryConfig::from)); + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiCookieForwardingTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiCookieForwardingTest.java new file mode 100644 index 0000000000..1c5a16bc40 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiCookieForwardingTest.java @@ -0,0 +1,80 @@ +package org.lowcoder.plugin.restapi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.plugin.restapi.RestApiCallSupport.SESSION_COOKIE; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.springframework.util.MultiValueMap; +import org.springframework.http.HttpCookie; + +/** + * Cookie forwarding of {@link RestApiExecutor}: which of the visitor's request cookies reach the third-party API. The + * Lowcoder session cookie must never be one of them. Each test reads the {@code Cookie} header the local server + * recorded. + */ +class RestApiCookieForwardingTest { + + private static final String PATH = "/echo"; + private static final String SESSION_VALUE = "session-secret"; + + private final RestApiCallSupport support = new RestApiCallSupport(); + private RecordingHttpServer server; + + @BeforeEach + void startServer() { + server = RecordingHttpServer.start(Map.of(PATH, RestApiCallSupport.json(200, "{}"))); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private String cookieHeaderSentFor(RestApiDatasourceConfig.RestApiDatasourceConfigBuilder datasource, MultiValueMap cookies) { + support.run(datasource.url(server.baseUrl() + PATH).build(), Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(cookies, null)); + List requests = RestApiCallSupport.requestsTo(server, PATH); + assertThat(requests).hasSize(1); + String cookie = String.join("; ", requests.get(0).header("Cookie")); + System.out.println("[RestApiCookieForwardingTest] Cookie header sent: '" + cookie + "'"); + return cookie; + } + + @Test + void forwardAllCookiesSendsEveryCookieExceptTheLowcoderSessionCookie() { + String cookie = cookieHeaderSentFor(RestApiDatasourceConfig.builder().forwardAllCookies(true), + RestApiCallSupport.cookies(SESSION_COOKIE, SESSION_VALUE, "a", "1", "a", "2", "b", "3")); + + assertThat(cookie).contains("a=1", "a=2", "b=3").doesNotContain(SESSION_VALUE).doesNotContain(SESSION_COOKIE); + } + + @Test + void theAllowListForwardsOnlyTheListedCookiesAndNeverTheSessionCookieEvenWhenListed() { + String cookie = cookieHeaderSentFor(RestApiDatasourceConfig.builder().forwardCookies(Set.of("a", SESSION_COOKIE)), + RestApiCallSupport.cookies(SESSION_COOKIE, SESSION_VALUE, "a", "1", "b", "3")); + + assertThat(cookie).contains("a=1").doesNotContain("b=3").doesNotContain(SESSION_VALUE); + } + + @Test + void withoutRequestCookiesNoCookieHeaderIsSent() { + String cookie = cookieHeaderSentFor(RestApiDatasourceConfig.builder().forwardAllCookies(true).forwardCookies(Set.of("a")), null); + + assertThat(cookie).isEmpty(); + } + + @Test + void withAnEmptyAllowListAndForwardAllOffNoCookieIsSent() { + String cookie = cookieHeaderSentFor(RestApiDatasourceConfig.builder().forwardCookies(Set.of()), + RestApiCallSupport.cookies(SESSION_COOKIE, SESSION_VALUE, "a", "1")); + + assertThat(cookie).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiEmptyContentTypeBodyTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiEmptyContentTypeBodyTest.java new file mode 100644 index 0000000000..bc5cf738dc --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiEmptyContentTypeBodyTest.java @@ -0,0 +1,38 @@ +package org.lowcoder.plugin.restapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; + +/** + * DEFECT pinned (plan section 9 row "reproduced, to pin (L4-9 follow-up)", probe P3; D-6, fix deferred): a POST whose + * datasource and query set no content type drops the query body. buildBodyInserter returns an empty body for a blank + * content type (RestApiExecutor.java:478-479), so {@code body: "payload"} is never sent, and no Content-Type header is + * sent either. The user gets no error. The obvious fix is to send the body (or to default the content type), which + * turns this test red. + */ +class RestApiEmptyContentTypeBodyTest { + + private final RestApiCallSupport support = new RestApiCallSupport(); + + @Test + void aPostWithABodyAndNoContentTypeSendsAnEmptyBodyAndNoContentType() { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/post", RestApiCallSupport.json(200, "{}")))) { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url(server.baseUrl() + "/post").build(); + + support.run(datasource, Map.of("httpMethod", "POST", "body", "payload"), RestApiCallSupport.visitor(null, null)); + + RecordingHttpServer.Request request = server.requests().get(0); + System.out.println("[RestApiEmptyContentTypeBodyTest] " + request.method() + " body '" + request.bodyText() + + "' Content-Type " + request.header("Content-Type")); + assertThat(server.requests()).hasSize(1); + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.bodyText()).isEmpty(); + assertThat(request.header("Content-Type")).isEmpty(); + } + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiEngineTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiEngineTest.java index 65ac6ad529..919ee3f5d0 100644 --- a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiEngineTest.java +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiEngineTest.java @@ -4,15 +4,19 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import com.google.common.collect.ImmutableMap; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.lowcoder.plugin.restapi.model.RestApiQueryExecutionContext; import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.exception.PluginException; import org.lowcoder.sdk.models.Property; import org.lowcoder.sdk.models.QueryExecutionResult; import org.lowcoder.sdk.plugin.common.RestApiUriBuilder; import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; import org.lowcoder.sdk.plugin.restapi.auth.BasicAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; import org.lowcoder.sdk.query.QueryExecutionContext; import org.lowcoder.sdk.query.QueryVisitorContext; import reactor.core.publisher.Mono; @@ -24,18 +28,43 @@ import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; import static org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType.BASIC_AUTH; import static org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType.DIGEST_AUTH; +/** + * {@link RestApiExecutor} end to end against a local echo server ({@link EchoServerStub}) in place of postman-echo.com, + * so no network access is needed: request bodies by content type, form and multipart bodies, parameter encoding and + * binding, basic and digest authentication, and JSON response content types. + */ public class RestApiEngineTest { + private static final String WRONG_PASSWORD = "wrong"; + /** {@code RestApiExecutor.MAX_REDIRECTS} (private): calls 0 to 6 send a request, call 7 fails before sending one. */ + private static final int EXECUTOR_REQUEST_LIMIT = 7; + + private static RecordingHttpServer server; + private final RestApiExecutor executor = new RestApiExecutor(new CommonConfig()); private static final RestApiConnector connector = new RestApiConnector(); private final QueryVisitorContext queryVisitorContext = new QueryVisitorContext("userId1", "workspace1", 8080, null, null, null); + @BeforeAll + static void startServer() { + server = EchoServerStub.start(); + } + + @AfterAll + static void stopServer() { + server.close(); + } + + private static String url(String path) { + return server.baseUrl() + path; + } + @Test public void testUrlConcatenationWithUriBuilder() { URI test1 = RestApiUriBuilder.buildUri("http://google.com", "test", emptyMap(), emptyMap()); @@ -63,7 +92,7 @@ public void testUrlConcatenationWithUriBuilder() { @Test public void testPostWithApplicationJson() { RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder() - .url("https://postman-echo.com/post") + .url(url(EchoServerStub.POST_PATH)) .build(); Map queryConfig = ImmutableMap.of( "httpMethod", "POST", @@ -87,7 +116,7 @@ public void testPostWithApplicationJson() { @Test public void testPostWithTextPlain() { RestApiDatasourceConfig dsConfig = RestApiDatasourceConfig.builder() - .url("https://postman-echo.com/post") + .url(url(EchoServerStub.POST_PATH)) .build(); Map queryConfig = ImmutableMap.of( "httpMethod", "POST", @@ -110,7 +139,7 @@ public void testPostWithTextPlain() { @Test public void testMultipartFormDataWithTextType() { RestApiDatasourceConfig dsConfig = RestApiDatasourceConfig.builder() - .url("https://postman-echo.com/post") + .url(url(EchoServerStub.POST_PATH)) .build(); Map queryConfig = ImmutableMap.of( "httpMethod", "POST", @@ -137,7 +166,7 @@ public void testMultipartFormDataWithFileType() { """, "FILE"); RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder() - .url("https://postman-echo.com/post") + .url(url(EchoServerStub.POST_PATH)) .build(); Map queryConfig = ImmutableMap.of( "httpMethod", "POST", @@ -164,7 +193,7 @@ public void testMultipartFormDataWithFileType() { @Test public void testEncodingParams() { RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder() - .url("https://postman-echo.com/post") + .url(url(EchoServerStub.POST_PATH)) .build(); Map queryConfig = ImmutableMap.of( "httpMethod", "POST", @@ -180,7 +209,7 @@ public void testEncodingParams() { assertTrue(result.isSuccess()); assertNotNull(result.getData()); JsonNode url = ((ObjectNode) result.getData()).get("url"); - assertEquals("\"https://postman-echo.com/post?param=value+with+blank\"", url.toString()); + assertEquals("\"" + url(EchoServerStub.POST_PATH) + "?param=value+with+blank\"", url.toString()); }) .verifyComplete(); } @@ -188,7 +217,7 @@ public void testEncodingParams() { @Test public void testParamBinding() { RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder() - .url("https://postman-echo.com/post") + .url(url(EchoServerStub.POST_PATH)) .build(); Map queryConfig = ImmutableMap.of( @@ -228,11 +257,11 @@ public void testBasicAuth() { RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder() .headers(List.of(new Property("Content-Type", "application/json"))) .authConfig(BasicAuthConfig.builder() - .username("postman") - .password("password") + .username(EchoServerStub.USERNAME) + .password(EchoServerStub.PASSWORD) .type(BASIC_AUTH) .build()) - .url("https://postman-echo.com/basic-auth") + .url(url(EchoServerStub.BASIC_AUTH_PATH)) .build(); RestApiQueryExecutionContext context = executor.doBuildQueryExecutionContext(datasourceConfig, @@ -240,10 +269,10 @@ public void testBasicAuth() { StepVerifier.create(connector.doCreateConnection(datasourceConfig) .flatMap(apiConnection -> executor.doExecuteQuery(apiConnection, context))) .assertNext(result -> { - Assert.assertNotNull(result); - Assert.assertTrue(result.isSuccess()); - Assert.assertTrue(result.getData() instanceof ObjectNode); - Assert.assertEquals("{\"authenticated\":true}", result.getData().toString()); + assertNotNull(result); + assertTrue(result.isSuccess()); + assertTrue(result.getData() instanceof ObjectNode); + assertEquals("{\"authenticated\":true}", result.getData().toString()); }) .verifyComplete(); } @@ -253,12 +282,12 @@ public void testDigestAuth() { RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder() .headers(List.of(new Property("Content-Type", "application/json"))) .authConfig(BasicAuthConfig.builder() - .username("postman") - .password("password") + .username(EchoServerStub.USERNAME) + .password(EchoServerStub.PASSWORD) .type(DIGEST_AUTH) .build() ) - .url("https://postman-echo.com/digest-auth") + .url(url(EchoServerStub.DIGEST_AUTH_PATH)) .build(); RestApiQueryExecutionContext context = executor.doBuildQueryExecutionContext(datasourceConfig, @@ -266,18 +295,66 @@ public void testDigestAuth() { StepVerifier.create(connector.doCreateConnection(datasourceConfig) .flatMap(apiConnection -> executor.doExecuteQuery(apiConnection, context))) .assertNext(result -> { - Assert.assertNotNull(result); - Assert.assertTrue(result.isSuccess()); - Assert.assertTrue(result.getData() instanceof ObjectNode); - Assert.assertEquals("{\"authenticated\":true}", result.getData().toString()); + assertNotNull(result); + assertTrue(result.isSuccess()); + assertTrue(result.getData() instanceof ObjectNode); + assertEquals("{\"authenticated\":true}", result.getData().toString()); }) .verifyComplete(); } + @Test + public void testBasicAuthWithWrongPasswordIsNotAuthenticated() { + StepVerifier.create(authenticate(BASIC_AUTH, EchoServerStub.BASIC_AUTH_PATH, WRONG_PASSWORD)) + .assertNext(result -> { + System.out.println("[RestApiEngineTest] basic auth, wrong password: success=" + result.isSuccess() + ", data=" + result.getData()); + assertFalse(result.isSuccess()); + }) + .verifyComplete(); + } + + /** + * The server answers every digest attempt with a wrong password with a new 401 challenge, and the executor answers + * each challenge again until its limit of {@value #EXECUTOR_REQUEST_LIMIT} requests, then fails the query with a + * {@link PluginException} that names redirects (the executor shares one counter between redirects and digest retries). + */ + @Test + public void testDigestAuthWithWrongPasswordIsNotAuthenticated() { + long before = digestRequests(); + StepVerifier.create(authenticate(DIGEST_AUTH, EchoServerStub.DIGEST_AUTH_PATH, WRONG_PASSWORD)) + .expectErrorSatisfies(error -> { + long requests = digestRequests() - before; + System.out.println("[RestApiEngineTest] digest auth, wrong password: " + error + " after " + requests + " requests"); + assertEquals(EXECUTOR_REQUEST_LIMIT, requests, "requests sent before the executor gave up"); + assertInstanceOf(PluginException.class, error); + assertTrue(error.getMessage().contains("maximum HTTP redirects"), error.getMessage()); + }) + .verify(); + } + + private static long digestRequests() { + return server.requests().stream().filter(request -> request.pathAndQuery().equals(EchoServerStub.DIGEST_AUTH_PATH)).count(); + } + + private Mono authenticate(RestApiAuthType type, String path, String password) { + RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder() + .authConfig(BasicAuthConfig.builder() + .username(EchoServerStub.USERNAME) + .password(password) + .type(type) + .build()) + .url(url(path)) + .build(); + RestApiQueryExecutionContext context = executor.doBuildQueryExecutionContext(datasourceConfig, + Map.of("httpMethod", "GET"), emptyMap(), queryVisitorContext); + return connector.doCreateConnection(datasourceConfig) + .flatMap(apiConnection -> executor.doExecuteQuery(apiConnection, context)); + } + @Test public void responseJsonTypeTest() { RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder() - .url("https://postman-echo.com/response-headers?key=value") + .url(url(EchoServerStub.RESPONSE_HEADERS_PATH) + "?key=value") .build(); for (String contentType : List.of("application/hal+json", "application/problem+json", diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiExecutorCodecGuardTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiExecutorCodecGuardTest.java new file mode 100644 index 0000000000..b884f72935 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiExecutorCodecGuardTest.java @@ -0,0 +1,35 @@ +package org.lowcoder.plugin.restapi; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ClientCodecs; +import org.springframework.web.reactive.function.client.ExchangeStrategies; + +import java.lang.reflect.Field; + +/** + * Guard of the client codecs of {@link RestApiExecutor} (docs/API_PAYLOAD_TEST_PLAN.md §5.4, group + * {@code client-codec-guard}, site {@code RestApiExecutor.java:102}). It reads the real private instance field of an + * executor built as {@code RestApiEngineTest} builds it. + * + *

    Requests run with the {@code WebClientBuildHelper} limit, not the one declared at {@code RestApiExecutor.java:100-103} + * (plan §9, O11; evidence E14), so the guard pins the strategies a request really gets. + * + *

    Limit: it runs on this module's test classpath. The modules Spring adds to the codec mapper depend on the + * classpath, and in the server the plugin runs on the server's; the server-side guard covers that one. + */ +public class RestApiExecutorCodecGuardTest { + + private static final String STRATEGIES_FIELD = "exchangeStrategies"; + + @BoundarySites("lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.exchangeStrategies#codecs#1") + @Test + public void requestsUseDefaultClientCodecsWithTheHelperLimit() throws ReflectiveOperationException { + Field field = RestApiExecutor.class.getDeclaredField(STRATEGIES_FIELD); + field.setAccessible(true); + ExchangeStrategies declared = (ExchangeStrategies) field.get(new RestApiExecutor(new CommonConfig())); + ClientCodecs.assertDefaultClientCodecs("RestApiExecutor", + ClientCodecs.effectiveThroughWebClientBuildHelper(declared), ClientCodecs.WEB_CLIENT_BUILD_HELPER_DEFAULT_SIZE); + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiJsonBodyContractTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiJsonBodyContractTest.java new file mode 100644 index 0000000000..f04d43b95f --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiJsonBodyContractTest.java @@ -0,0 +1,98 @@ +package org.lowcoder.plugin.restapi; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.contract.RenderValues; +import org.lowcoder.sdk.contract.RepresentativeInput; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.query.QueryVisitorContext; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Group {@code downstream-encode}, its {@code restApiPlugin} rows (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.5): a + * body whose content type is {@value #JSON} is rendered into a node with the request parameters + * ({@code RestApiExecutor.buildQueryExecutionContext}, {@code renderMustacheJson}) and handed to the client codecs as + * that node ({@code buildBodyInserter}, {@code BodyInserters.fromValue}), which write it. A real executor POSTs each + * body of {@link #BODIES} with the parameters {@link RenderValues} to a local server ({@link RecordingHttpServer}); + * what the server received, the content type and the body as sent ({@link RecordingHttpServer.Request#jsonBodyReport}), + * is pinned by the §1.2 contract (number lexemes compared), one entry per body, in {@value #REPORT}. + * + *

    Limits: the body bytes are read as UTF-8 JSON; a special-JSON type is {@code tree-text}'s + * ({@code RestApiSpecialJsonBodyContractTest}), and form bodies are not JSON. + */ +public class RestApiJsonBodyContractTest { + + static final String REPORT = "downstream-encode/RestApiExecutor.jsonBody.json"; + static final String JSON = "application/json"; + static final String PATH = "/json"; + static final int OK = 200; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map BODIES = bodies(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + private static RecordingHttpServer server; + + private final RestApiExecutor executor = new RestApiExecutor(new CommonConfig()); + private final QueryVisitorContext visitorContext = new QueryVisitorContext("userId1", "workspace1", 8080, null, null, null); + + @BeforeAll + public static void startServer() { + server = RecordingHttpServer.start(Map.of(PATH, new Response(OK, Map.of(RecordingHttpServer.CONTENT_TYPE, List.of("text/plain")), + "ok".getBytes(StandardCharsets.UTF_8)))); + } + + @AfterAll + public static void stopServer() { + server.close(); + } + + @BoundarySites({ + "lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.buildQueryExecutionContext#renderMustacheJson#1", + "lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.buildBodyInserter#BodyInserters.fromValue#4"}) + @Test + public void jsonBodyAsSent() { + Map report = new LinkedHashMap<>(); + BODIES.forEach((name, body) -> report.put(name, send(body))); + String actual = ConfigBinding.write(report); + System.out.println("[RestApiJsonBodyContractTest] " + server.requests().size() + " requests\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** What the server received for one POST of {@code body}, or the error. */ + private Object send(String body) { + int before = server.requests().size(); + RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder().url(server.baseUrl()).build(); + Map queryConfig = Map.of("httpMethod", "POST", "path", PATH, "body", body, + "headers", List.of(Map.of("key", RecordingHttpServer.CONTENT_TYPE, "value", JSON))); + try { + executor.doExecuteQuery(null, executor.doBuildQueryExecutionContext(datasourceConfig, queryConfig, RenderValues.values(), visitorContext)) + .block(TIMEOUT); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + return server.requests().get(before).jsonBodyReport(); + } + + /** Every parameter as a member, the text ones also inside a string, and the §4.6 representative input. */ + static Map bodies() { + Map bodies = new LinkedHashMap<>(); + bodies.put("parameters", "{" + RenderValues.values().keySet().stream().map(name -> "\"" + name + "\": {{" + name + "}}") + .collect(Collectors.joining(", ")) + ", \"inText\": \"{{string}} {{dollar}}\", \"literal\": [1.50, 1e3, 2147483648]}"); + bodies.put("representativeInput", RepresentativeInput.TEXT.trim()); + return bodies; + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiMultipartParseErrorTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiMultipartParseErrorTest.java new file mode 100644 index 0000000000..198cb89473 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiMultipartParseErrorTest.java @@ -0,0 +1,79 @@ +package org.lowcoder.plugin.restapi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.query.QueryVisitorContext; + +/** + * DEFECT pinned, one level up (plan section 9 row "CONTENT_PARSE_ERROR is unformattable"; pinned at the sdk in + * DataUtilsContentParseErrorTest; D-6, fix deferred): what the user receives. The server builds a query's context with + * {@code buildQueryExecutionContextMono} (QueryExecutionServiceImpl.java:72), which wraps what the plugin throws as a coded + * {@code QUERY_ARGUMENT_ERROR}. A file field of a multipart query whose value is not valid upload data reaches + * {@code convertToMultiformFileValue} (RestApiExecutor.java:189), whose intended message cannot be built, so the coded + * message carries the IllegalArgumentException text of the failed formatting instead of the upload-format hint. Escaping + * the braces in locale_en.properties:183 turns the message assertions red. A local server (port 0, loopback) stands in for + * the datasource url and must see no request. + */ +class RestApiMultipartParseErrorTest { + + private static final String BAD_UPLOAD_VALUE = "this is not upload data"; + + private final RestApiExecutor executor = executor(); + + private static RestApiExecutor executor() { + CommonConfig config = new CommonConfig(); + config.setCookieName("LOWCODER_SESSION"); + return new RestApiExecutor(config); + } + + private static Map multipartQuery(String uploadValue) { + return Map.of("httpMethod", "POST", "headers", List.of(new Property("content-type", "multipart/form-data")), + "bodyFormData", List.of(new Property("file", uploadValue, "FILE")), "params", List.of()); + } + + @Test + void theUserGetsTheIllegalArgumentTextInsideACodedQueryArgumentErrorInsteadOfTheUploadHintPinsTheSection9Row() { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/upload", RestApiCallSupport.json(200, "{}")))) { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url(server.baseUrl() + "/upload").build(); + QueryVisitorContext visitor = RestApiCallSupport.visitor(null, null); + + Throwable reactive = org.junit.jupiter.api.Assertions.assertThrows(PluginException.class, + () -> executor.buildQueryExecutionContextMono(datasource, multipartQuery(BAD_UPLOAD_VALUE), Map.of(), visitor).block()); + Throwable blocking = org.junit.jupiter.api.Assertions.assertThrows(PluginException.class, + () -> executor.doBuildQueryExecutionContext(datasource, multipartQuery(BAD_UPLOAD_VALUE), Map.of(), visitor)); + + System.out.println("[RestApiMultipartParseErrorTest] user receives: " + ((PluginException) reactive).getError() + " / " + + ((PluginException) reactive).getMessageKey() + " / " + reactive.getMessage()); + for (Throwable failure : List.of(reactive, blocking)) { + PluginException coded = (PluginException) failure; + assertThat(coded.getError()).isEqualTo(PluginCommonError.INVALID_QUERY_SETTINGS); + assertThat(coded.getMessageKey()).isEqualTo("QUERY_ARGUMENT_ERROR"); + assertThat(coded.getMessage()).contains("can't parse argument number: data:base64 string"); + assertThat(coded.getMessage()).doesNotContain("Resolve upload data failed"); + } + assertThat(server.requests()).as("no request is made while the context is built").isEmpty(); + } + } + + @Test + void aFileValueThatParsesButHasANonTextDataFieldGetsItsOwnReadableMessage() { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url("http://example.invalid/upload").build(); + QueryVisitorContext visitor = new QueryVisitorContext("v", "o", 0, null, null, Set.of()); + + assertThatThrownBy(() -> executor.buildQueryExecutionContextMono(datasource, multipartQuery("{\"data\":1,\"name\":\"a\"}"), Map.of(), visitor).block()) + .isInstanceOf(PluginException.class) + .hasMessageContaining("Resolve upload data failed, data field is not a valid base64 string"); + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiOauthInheritTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiOauthInheritTest.java new file mode 100644 index 0000000000..6d95c69ad2 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiOauthInheritTest.java @@ -0,0 +1,148 @@ +package org.lowcoder.plugin.restapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.AuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.BasicAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.NoneAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.OAuthInheritAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; +import reactor.core.publisher.Mono; + +/** + * OAuth2 "inherit from login" of {@link RestApiExecutor}: the visitor's token properties are added to the request, an + * empty or failing token Mono stops the call before any request, and every other auth type leaves the token Mono + * alone. The token Mono is the one {@code QueryVisitorContext} carries. + */ +class RestApiOauthInheritTest { + + private static final String PATH = "/api"; + private static final String TOKEN_HEADER = "X-Token"; + private static final String TOKEN_PARAM = "tok"; + + private final RestApiCallSupport support = new RestApiCallSupport(); + private RecordingHttpServer server; + + @BeforeEach + void startServer() { + server = RecordingHttpServer.start(Map.of(PATH, RestApiCallSupport.json(200, "{}"))); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private RestApiDatasourceConfig datasource(AuthConfig auth) { + return RestApiDatasourceConfig.builder().url(server.baseUrl() + PATH) + .params(List.of(new Property("own", "1"), new Property(TOKEN_PARAM, "from-datasource"))) + .authConfig(auth).build(); + } + + private static AuthConfig oauthInherit() { + return OAuthInheritAuthConfig.builder().type(RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN).build(); + } + + private RecordingHttpServer.Request onlyRequest() { + List requests = server.requests(); + assertThat(requests).hasSize(1); + return requests.get(0); + } + + /** + * The token header reaches the request, over a datasource header of the same name. What happens to the token + * "param" properties is reported to the coordinator as probe P4 and not asserted (see log-L4.md, L4-9). + */ + @Test + void theTokenHeadersOfTheLoginAreAddedToTheRequest() { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url(server.baseUrl() + PATH) + .headers(List.of(new Property(TOKEN_HEADER, "from-datasource"), new Property("X-Own", "kept"))) + .authConfig(oauthInherit()).build(); + + support.run(datasource, Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, + Mono.just(List.of(new Property(TOKEN_HEADER, "from-login", "header"))))); + + RecordingHttpServer.Request request = onlyRequest(); + System.out.println("[RestApiOauthInheritTest] headers " + request.header(TOKEN_HEADER) + " / " + request.header("X-Own")); + assertThat(request.header(TOKEN_HEADER)).containsExactly("from-login"); + assertThat(request.header("X-Own")).containsExactly("kept"); + } + + @Test + void aTokenWithBothKindsOrWithOnlyOneKindStillSendsTheRequest() { + support.run(datasource(oauthInherit()), Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, + Mono.just(List.of(new Property("a", "1", "header"), new Property("b", "2", "param"))))); + support.run(datasource(oauthInherit()), Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, + Mono.just(List.of(new Property("c", "3", "param"))))); + System.out.println("[RestApiOauthInheritTest] request after a token with a param: " + server.requests().get(1).pathAndQuery()); + support.run(datasource(oauthInherit()), Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, + Mono.just(List.of(new Property("d", "4", "header"))))); + + assertThat(server.requests()).hasSize(3); + assertThat(server.requests().get(0).header("a")).containsExactly("1"); + assertThat(server.requests().get(1).header("c")).isEmpty(); + assertThat(server.requests().get(2).header("d")).containsExactly("4"); + } + + @Test + void anEmptyTokenMonoFailsWithAccessTokenMissingAndSendsNoRequest() { + Throwable failure = support.failureOf(datasource(oauthInherit()), Map.of("httpMethod", "GET"), + RestApiCallSupport.visitor(null, Mono.empty())); + + System.out.println("[RestApiOauthInheritTest] empty token -> " + failure); + assertThat(failure).isInstanceOf(PluginException.class).hasMessageContaining("$ACCESS_TOKEN parameter missing"); + assertThat(server.requests()).isEmpty(); + } + + @Test + void aFailingTokenMonoIsReportedAsAPluginExceptionAndSendsNoRequest() { + Throwable failure = support.failureOf(datasource(oauthInherit()), Map.of("httpMethod", "GET"), + RestApiCallSupport.visitor(null, Mono.error(new IllegalStateException("login expired")))); + + System.out.println("[RestApiOauthInheritTest] failing token -> " + failure); + assertThat(failure).isInstanceOf(PluginException.class).hasMessageContaining("login expired"); + assertThat(server.requests()).isEmpty(); + } + + @Test + void otherAuthTypesNeverSubscribeToTheTokenMono() { + AtomicInteger subscriptions = new AtomicInteger(); + Mono> token = Mono.defer(() -> { + subscriptions.incrementAndGet(); + return Mono.error(new IllegalStateException("must not be read")); + }); + AuthConfig basic = BasicAuthConfig.builder().type(RestApiAuthType.BASIC_AUTH).username("u").password("p").build(); + + support.run(datasource(basic), Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, token)); + support.run(datasource(NoneAuthConfig.builder().type(RestApiAuthType.NO_AUTH).build()), Map.of("httpMethod", "GET"), + RestApiCallSupport.visitor(null, token)); + support.run(datasource(null), Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, token)); + + assertThat(subscriptions).hasValue(0); + assertThat(server.requests()).hasSize(3); + } + + @Test + void theTokenStepOfOauthInheritReadsTheMonoExactlyOnce() { + AtomicInteger subscriptions = new AtomicInteger(); + Mono> token = Mono.defer(() -> { + subscriptions.incrementAndGet(); + return Mono.just(List.of(new Property(TOKEN_HEADER, "t", "header"))); + }); + + support.run(datasource(oauthInherit()), Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, token)); + + assertThat(subscriptions).hasValue(1); + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiOauthParamsTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiOauthParamsTest.java new file mode 100644 index 0000000000..e6f9118f4b --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiOauthParamsTest.java @@ -0,0 +1,46 @@ +package org.lowcoder.plugin.restapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.OAuthInheritAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; +import reactor.core.publisher.Mono; + +/** + * DEFECT pinned (plan section 9 row "reproduced, to pin (L4-9 follow-up)", probe P4; D-6, fix deferred): the token + * properties of type "param" that OAuth "inherit from login" adds never reach the URL. authByOauth2InheritFromLogin + * merges them into the context's urlParams (RestApiExecutor.java:309-315), but the request URI was built earlier in + * buildQueryExecutionContext (:161) and nothing reads urlParams afterwards. With a datasource param {@code tok=ds} and + * a token param {@code tok=T}, the request carries {@code tok=ds}, and a token param with a new key is not sent at + * all. (The token HEADERS do arrive: RestApiOauthInheritTest.) The obvious fix is to rebuild the URI after the token + * step; it turns this test red, and with it in place removing the {@code setUrlParams} call changes the result too. + */ +class RestApiOauthParamsTest { + + private final RestApiCallSupport support = new RestApiCallSupport(); + + @Test + void theTokenParamsAreNotSentAndTheDatasourceValueOfTheSameKeyIsSent() { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/p", RestApiCallSupport.json(200, "{}")))) { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url(server.baseUrl() + "/p") + .params(List.of(new Property("own", "1"), new Property("tok", "ds"))) + .authConfig(OAuthInheritAuthConfig.builder().type(RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN).build()).build(); + + support.run(datasource, Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, + Mono.just(List.of(new Property("tok", "T", "param"), new Property("fresh", "N", "param"))))); + + String pathAndQuery = server.requests().get(0).pathAndQuery(); + System.out.println("[RestApiOauthParamsTest] request " + pathAndQuery); + assertThat(server.requests()).hasSize(1); + // the order of the params follows an unordered map, so it is not asserted + assertThat(pathAndQuery.split("\\?")[1].split("&")).containsExactlyInAnyOrder("own=1", "tok=ds"); + } + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiRedirectTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiRedirectTest.java new file mode 100644 index 0000000000..1d43586b0e --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiRedirectTest.java @@ -0,0 +1,210 @@ +package org.lowcoder.plugin.restapi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.plugin.restapi.RestApiCallSupport.json; +import static org.lowcoder.plugin.restapi.RestApiCallSupport.redirect; +import static org.lowcoder.plugin.restapi.RestApiCallSupport.requestsTo; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.AuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.BasicAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** + * Redirect and digest-challenge handling of {@link RestApiExecutor#executeQuery}. The redirect target comes from the + * {@code Location} header as given. + * + *

    Pins the two halves of defect D14 (analysis-plugins section 0.6; plan section 9, row "D1-D20 of analysis-plugins + * 0.6"; fixes deferred under D-6): a relative {@code Location} is not resolved against the request, and a redirect to + * another origin re-sends the datasource's credentials and the forwarded cookies. + */ +class RestApiRedirectTest { + + /** {@code RestApiExecutor.MAX_REDIRECTS}: calls 0 to 6 send a request, call 7 fails before sending one. */ + private static final int REQUEST_LIMIT = 7; + private static final String HOST = "Host"; + private static final String USER = "user"; + private static final String PASSWORD = "secret"; + + private final RestApiCallSupport support = new RestApiCallSupport(); + + private static String absolute(RecordingHttpServer.Request request, String path) { + return "http://" + request.header(HOST).get(0) + path; + } + + private static RestApiDatasourceConfig get(RecordingHttpServer server, String path) { + return RestApiDatasourceConfig.builder().url(server.baseUrl() + path).build(); + } + + private QueryExecutionResult runGet(RestApiDatasourceConfig datasource) { + return support.run(datasource, Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, null)); + } + + @Test + void anAbsoluteRedirectIsFollowedAndTheAnswerOfTheTargetIsReturned() { + try (RecordingHttpServer server = RecordingHttpServer.serve(Map.of( + "/start", request -> redirect(302, absolute(request, "/target")), + "/target", request -> json(200, "{\"landed\":true}")))) { + + QueryExecutionResult result = runGet(get(server, "/start")); + + System.out.println("[RestApiRedirectTest] requests " + server.requests().stream().map(RecordingHttpServer.Request::pathAndQuery).toList()); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getData().toString()).isEqualTo("{\"landed\":true}"); + assertThat(server.requests()).extracting(RecordingHttpServer.Request::pathAndQuery).containsExactly("/start", "/target"); + } + } + + @Test + void aRedirectLoopEndsAfterSevenRequestsWithTheRedirectLimitError() { + try (RecordingHttpServer server = RecordingHttpServer.serve(Map.of( + "/loop", request -> redirect(302, absolute(request, "/loop"))))) { + + Throwable failure = support.failureOf(get(server, "/loop"), Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, null)); + + System.out.println("[RestApiRedirectTest] loop -> " + failure + " after " + server.requests().size() + " requests"); + assertThat(failure).isInstanceOf(PluginException.class).hasMessageContaining("maximum HTTP redirects"); + assertThat(server.requests()).hasSize(REQUEST_LIMIT); + } + } + + /** + * DEFECT D14, first half, pinned (analysis-plugins section 0.6; plan section 9 "D1-D20" row; D-6, fix deferred): a + * relative {@code Location: /next} is turned into a URI without a host ({@code new URI(redirectUrl)}, RestApiExecutor + * .java:278-283) and the next call is made with that URI as given, so it is not sent to the server that redirected. + * The client's default for a host-less URI is {@code localhost}, port 80: an uncontrolled port (a run of an earlier + * version of this test reached whatever listens there, and a disallowed-hosts entry for {@code localhost} did not stop + * it), and the request carries the datasource's headers and cookies. This test therefore never lets the second + * request leave the JVM: it calls the private {@code httpCall} through reflection with a WebClient whose exchange + * function is a stub (no connector), answers the first call 302 with {@code Location: /next} and records the URL of + * the second call. The pin: that URL is {@code /next}, not absolute, with no host. The obvious fix, + * {@code uri.resolve(redirectUrl)}, makes the second URL absolute on the original host and turns this test red. + * + *

    Limits: it pins the redirect decision of {@code httpCall} (a private method, signature read in the source) and + * not what a real network does with the host-less URI; it breaks, on purpose, if {@code httpCall}'s signature changes. + */ + @Test + void aRelativeLocationIsFollowedAsAHostlessUriTodayD14() throws ReflectiveOperationException { + List called = new CopyOnWriteArrayList<>(); + ExchangeFunction stub = request -> { + called.add(request.url()); + if (called.size() == 1) { + return Mono.just(ClientResponse.create(HttpStatus.FOUND, ExchangeStrategies.withDefaults()).header("Location", "/next").build()); + } + return Mono.just(ClientResponse.create(HttpStatus.OK, ExchangeStrategies.withDefaults()) + .header("Content-Type", "application/json").body("{}").build()); + }; + WebClient client = WebClient.builder().exchangeFunction(stub).build(); + Method httpCall = RestApiExecutor.class.getDeclaredMethod("httpCall", WebClient.class, HttpMethod.class, URI.class, + BodyInserter.class, int.class, AuthConfig.class, Consumer.class); + httpCall.setAccessible(true); + URI origin = URI.create("http://origin.invalid:8080/start"); + + Mono call = (Mono) httpCall.invoke(new RestApiExecutor(new CommonConfig()), client, HttpMethod.GET, origin, + BodyInserters.fromValue(new byte[0]), 0, null, (Consumer) headers -> { }); + call.block(RestApiCallSupport.TIMEOUT); + + System.out.println("[RestApiRedirectTest] URLs called for a relative Location: " + called); + assertThat(called).hasSize(2); + assertThat(called.get(0)).isEqualTo(origin); + assertThat(called.get(1)).hasToString("/next"); + assertThat(called.get(1).isAbsolute()).isFalse(); + assertThat(called.get(1).getHost()).isNull(); + } + + /** + * DEFECT D14, second half, pinned (analysis-plugins section 0.6; plan section 9 "D1-D20" row; D-6, fix deferred): the + * redirect is sent with the same WebClient, whose default headers and cookies are those of the datasource, so a + * redirect to another origin (here another port) carries the datasource's Authorization header and the forwarded + * cookie. The intended behaviour, dropping credentials when the host or port changes, would turn this test red. + */ + @Test + void aRedirectToAnotherOriginResendsTheAuthorizationHeaderAndTheForwardedCookiesD14() { + try (RecordingHttpServer other = RecordingHttpServer.serve(Map.of("/landing", request -> json(200, "{}")))) { + try (RecordingHttpServer origin = RecordingHttpServer.serve(Map.of( + "/start", request -> redirect(302, other.baseUrl() + "/landing")))) { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url(origin.baseUrl() + "/start") + .authConfig(BasicAuthConfig.builder().type(RestApiAuthType.BASIC_AUTH).username(USER).password(PASSWORD).build()) + .forwardCookies(Set.of("a")).build(); + + support.run(datasource, Map.of("httpMethod", "GET"), + RestApiCallSupport.visitor(RestApiCallSupport.cookies("a", "1"), null)); + + RecordingHttpServer.Request atOther = requestsTo(other, "/landing").get(0); + System.out.println("[RestApiRedirectTest] other origin received Authorization " + atOther.header("Authorization") + + " Cookie " + atOther.header("Cookie")); + assertThat(origin.port()).isNotEqualTo(other.port()); + assertThat(atOther.header("Authorization")).hasSize(1).allSatisfy(value -> assertThat(value).startsWith("Basic ")); + assertThat(atOther.header("Cookie")).containsExactly("a=1"); + } + } + } + + @Test + void aLocationThatIsNotAUriGivesAPluginExceptionAndNoSecondRequest() { + try (RecordingHttpServer server = RecordingHttpServer.serve(Map.of( + "/start", request -> redirect(302, "ht tp://x y")))) { + + Throwable failure = support.failureOf(get(server, "/start"), Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, null)); + + System.out.println("[RestApiRedirectTest] bad Location -> " + failure); + assertThat(failure).isInstanceOf(PluginException.class).hasMessageContaining("Illegal character in scheme name"); + assertThat(server.requests()).hasSize(1); + } + } + + @Test + void aDigestChallengeWithAMalformedHeaderGivesAPluginExceptionAndIsNotRetried() { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/digest", + new Response(401, Map.of("WWW-Authenticate", List.of("Digest garbage")), null)))) { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url(server.baseUrl() + "/digest") + .authConfig(BasicAuthConfig.builder().type(RestApiAuthType.DIGEST_AUTH).username(USER).password(PASSWORD).build()).build(); + + Throwable failure = support.failureOf(datasource, Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, null)); + + System.out.println("[RestApiRedirectTest] malformed digest challenge -> " + failure); + assertThat(failure).isInstanceOf(PluginException.class).hasMessageContaining("unexpected input"); + assertThat(server.requests()).hasSize(1); + } + } + + /** Behaviour, not a defect: without a challenge header a 401 is an ordinary answer, returned once. */ + @Test + void aPlain401UnderDigestAuthIsReturnedAsTheResultAndNotRetried() { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/digest", new Response(401, Map.of(), null)))) { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url(server.baseUrl() + "/digest") + .authConfig(BasicAuthConfig.builder().type(RestApiAuthType.DIGEST_AUTH).username(USER).password(PASSWORD).build()).build(); + + QueryExecutionResult result = runGet(datasource); + + System.out.println("[RestApiRedirectTest] plain 401 -> success=" + result.isSuccess() + " data=" + result.getData()); + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getData()).hasToString("401 UNAUTHORIZED"); + assertThat(server.requests()).hasSize(1); + } + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiRedirectWithoutLocationTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiRedirectWithoutLocationTest.java new file mode 100644 index 0000000000..461b822c9f --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiRedirectWithoutLocationTest.java @@ -0,0 +1,41 @@ +package org.lowcoder.plugin.restapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; + +/** + * DEFECT pinned (plan section 9 row "reproduced, to pin (L4-9 follow-up)", probe P1; D-6, fix deferred): an answer + * with a 3xx status and no {@code Location} header, for example 304 Not Modified, is treated as a redirect and + * {@code response.headers().header("Location").get(0)} (RestApiExecutor.java:276) reads an empty list. The caller does + * not get the 304 as a plain response; it gets {@code PluginException: REST API execution error: Index: 0.}, a message + * that names neither the status nor the header. One request is sent. The obvious fix is to treat a 3xx without a + * Location as an ordinary response, which turns this test red. + */ +class RestApiRedirectWithoutLocationTest { + + private final RestApiCallSupport support = new RestApiCallSupport(); + + @ParameterizedTest(name = "status {0}") + @ValueSource(ints = {301, 302, 304}) + void aRedirectStatusWithoutALocationFailsWithAnIndexMessage(int status) { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/moved", new Response(status, Map.of(), null)))) { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url(server.baseUrl() + "/moved").build(); + + Throwable failure = support.failureOf(datasource, Map.of("httpMethod", "GET"), RestApiCallSupport.visitor(null, null)); + + System.out.println("[RestApiRedirectWithoutLocationTest] " + status + " without Location -> " + failure); + assertThat(failure).isInstanceOf(PluginException.class).hasMessageContaining("Index: 0"); + assertThat(server.requests()).hasSize(1); + assertThat(List.of(failure.getMessage())).noneMatch(message -> message.contains("Location")); + } + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiResponseContractTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiResponseContractTest.java new file mode 100644 index 0000000000..995c65af13 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiResponseContractTest.java @@ -0,0 +1,132 @@ +package org.lowcoder.plugin.restapi; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.contract.RepresentativeInput; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.query.QueryExecutionContext; +import org.lowcoder.sdk.query.QueryVisitorContext; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Groups {@code external-json} and {@code header-json} of {@code RestApiExecutor}, and its row of the §4.6 producer + * table (docs/API_PAYLOAD_TEST_PLAN.md §4.6, §4.10, task T8.2). A real executor sends GET requests to a local server + * ({@link RecordingHttpServer}) that answers each case of {@link #RESPONSES}; the result is what a query of the + * REST API plugin returns: + *

      + *
    • the response headers, written by the production mapper ({@code toJsonThrows}) and read back into a tree + * ({@code RestApiExecutor.java:420-435}), with a header of two values and headers whose bytes are not ASCII;
    • + *
    • the body by content type ({@code RestApiExecutor.java:387-418}): JSON, a {@code +json} type and + * {@code x-ndjson} read into a tree; an image base64-encoded into a {@code byte[]}, which the mapper + * base64-encodes again (O7); raw bytes; trimmed text; no body; and a JSON body that does not parse;
    • + *
    • {@code QueryExecutionResult.ofRestApiResult}, with the {@code HTTP...} code of a status that is not 2xx.
    • + *
    + * Each case's report ({@link QueryResults#report}: the Java classes of {@code data} and the text the production mapper + * writes) is pinned in {@value #REPORT}, with the volatile {@code Date} header removed. + * + *

    Limits: the JDK server sends header values as the ISO-8859-1 bytes of their characters; the UTF-8 case therefore + * sends the UTF-8 bytes of {@value #UTF8_HEADER_TEXT} as such characters, which is what a server sending UTF-8 puts on + * the wire. + */ +public class RestApiResponseContractTest { + + static final String REPORT = "external-json/RestApiExecutor.responses.json"; + static final String CONTENT_TYPE = "Content-Type"; + static final String MULTI_HEADER = "X-Multi"; + static final String LATIN1_HEADER = "X-Latin1"; + static final String UTF8_HEADER = "X-Utf8-Bytes"; + static final String UTF8_HEADER_TEXT = "žluť"; + static final byte[] IMAGE_BYTES = {(byte) 0x89, 'P', 'N', 'G', 0, 1, (byte) 0xFF}; + static final int OK = 200; + static final int NO_CONTENT = 204; + static final int UNPROCESSABLE = 422; + static final int BAD_GATEWAY = 502; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map RESPONSES = responses(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + private static RecordingHttpServer server; + + private final RestApiExecutor executor = new RestApiExecutor(new CommonConfig()); + private final QueryVisitorContext visitorContext = new QueryVisitorContext("userId1", "workspace1", 8080, null, null, null); + + @BeforeAll + public static void startServer() { + server = RecordingHttpServer.start(RESPONSES); + } + + @AfterAll + public static void stopServer() { + server.close(); + } + + @BoundarySites({ + "lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.parseResponseDataInfo#readTree#1", + "lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.parseExecuteResultHeaders#toJsonThrows#1", + "lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.parseExecuteResultHeaders#readTree#1"}) + @Test + public void responsesAsPinned() { + Map report = new LinkedHashMap<>(); + RESPONSES.keySet().forEach(path -> report.put(path, report(execute(path)))); + String actual = ConfigBinding.write(report); + System.out.println("[RestApiResponseContractTest] " + server.requests().size() + " requests to " + server.baseUrl() + "\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + private Object report(Object outcome) { + return outcome instanceof QueryExecutionResult result + ? QueryResults.report(result, Set.of(QueryResults.DATE_HEADER)) : outcome; + } + + /** The result of a GET of {@code path}, or the error text when the query fails. */ + private Object execute(String path) { + RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder().url(server.baseUrl()).build(); + Map queryConfig = Map.of("httpMethod", "GET", "path", path); + try { + QueryExecutionContext context = executor.doBuildQueryExecutionContext(datasourceConfig, queryConfig, Map.of(), visitorContext); + return executor.doExecuteQuery(null, context).block(TIMEOUT); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + } + + private static Map responses() { + String latin1OfUtf8 = new String(UTF8_HEADER_TEXT.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + Map> jsonHeaders = new LinkedHashMap<>(); + jsonHeaders.put(CONTENT_TYPE, List.of("application/json")); + jsonHeaders.put(MULTI_HEADER, List.of("first", "second, with comma")); + jsonHeaders.put(LATIN1_HEADER, List.of("café")); + jsonHeaders.put(UTF8_HEADER, List.of(latin1OfUtf8)); + Map responses = new LinkedHashMap<>(); + responses.put("/json", new Response(OK, jsonHeaders, utf8(RepresentativeInput.TEXT))); + responses.put("/problem-json", new Response(UNPROCESSABLE, Map.of(CONTENT_TYPE, List.of("application/problem+json")), + utf8("{\"title\":\"Unprocessable\",\"status\":422,\"errors\":[{\"field\":\"name\",\"limit\":10.50}]}"))); + responses.put("/ndjson", new Response(OK, Map.of(CONTENT_TYPE, List.of("application/x-ndjson")), + utf8("{\"line\":1}\n{\"line\":2}\n"))); + responses.put("/image", new Response(OK, Map.of(CONTENT_TYPE, List.of("image/png")), IMAGE_BYTES)); + responses.put("/binary", new Response(OK, Map.of(CONTENT_TYPE, List.of("application/octet-stream")), IMAGE_BYTES)); + responses.put("/text", new Response(OK, Map.of(CONTENT_TYPE, List.of("text/plain;charset=UTF-8")), utf8(" plain žluť text 1.50 "))); + responses.put("/no-content", new Response(NO_CONTENT, Map.of(), null)); + responses.put("/invalid-json", new Response(BAD_GATEWAY, Map.of(CONTENT_TYPE, List.of("application/json")), utf8("{\"a\": 1,"))); + return responses; + } + + private static byte[] utf8(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiSpecialJsonBodyContractTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiSpecialJsonBodyContractTest.java new file mode 100644 index 0000000000..4e01e6a4bb --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiSpecialJsonBodyContractTest.java @@ -0,0 +1,111 @@ +package org.lowcoder.plugin.restapi; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.contract.RepresentativeInput; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.query.QueryVisitorContext; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Group {@code tree-text}, its {@code restApiPlugin} rows (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.4): a body + * whose content type is "special JSON" (a subtype with {@code -json}, {@code ContentTypeHelper.isSpecialJson}) is + * rendered into a node and sent as {@code node.toString()} ({@code RestApiExecutor.buildBodyInserter}), the text of + * Jackson's internal default mapper (E13), not the production mapper's. A real executor POSTs each body of + * {@link #BODIES} to a local server ({@link RecordingHttpServer}); the bytes it receives are pinned exactly, one line + * per body, with the content type it received, in {@value #REPORT}. + * + *

    Limits: the received bytes are decoded as UTF-8 for the fixture; a body that is not special JSON goes through the + * client codecs and is {@code downstream-encode}'s (T8.5). + */ +public class RestApiSpecialJsonBodyContractTest { + + static final String REPORT = "tree-text/RestApiExecutor.specialJsonBody.txt"; + static final String SPECIAL_JSON = "application/x-amz-json-1.1"; + static final String CONTENT_TYPE = "Content-Type"; + static final String PATH = "/special"; + static final String SEPARATOR = "\t"; + static final String NEWLINE = "\n"; + static final int OK = 200; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map PARAMS = params(); + static final Map BODIES = bodies(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + private static RecordingHttpServer server; + + private final RestApiExecutor executor = new RestApiExecutor(new CommonConfig()); + private final QueryVisitorContext visitorContext = new QueryVisitorContext("userId1", "workspace1", 8080, null, null, null); + + @BeforeAll + public static void startServer() { + server = RecordingHttpServer.start(Map.of(PATH, new Response(OK, Map.of(CONTENT_TYPE, List.of("text/plain")), "ok".getBytes(StandardCharsets.UTF_8)))); + } + + @AfterAll + public static void stopServer() { + server.close(); + } + + @BoundarySites({ + "lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java##import#1", + "lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.buildBodyInserter#BodyInserters.fromValue#3"}) + @Test + public void specialJsonBodyAsSent() { + StringBuilder text = new StringBuilder(); + BODIES.forEach((name, body) -> text.append(name).append(SEPARATOR).append(send(body)).append(NEWLINE)); + System.out.println("[RestApiSpecialJsonBodyContractTest] " + server.requests().size() + " requests\n" + text); + GOLDEN.assertText(REPORT, text.toString()); + } + + /** The content type and body the server received for one POST of {@code body}, or the error. */ + private String send(String body) { + int before = server.requests().size(); + RestApiDatasourceConfig datasourceConfig = RestApiDatasourceConfig.builder().url(server.baseUrl()).build(); + Map queryConfig = Map.of("httpMethod", "POST", "path", PATH, "body", body, + "headers", List.of(Map.of("key", CONTENT_TYPE, "value", SPECIAL_JSON))); + try { + executor.doExecuteQuery(null, executor.doBuildQueryExecutionContext(datasourceConfig, queryConfig, PARAMS, visitorContext)).block(TIMEOUT); + } catch (RuntimeException e) { + return "ERROR " + ConfigBinding.errorText(e); + } + Request request = server.requests().get(before); + return request.header(CONTENT_TYPE) + SEPARATOR + request.bodyText(); + } + + private static Map params() { + Map params = new LinkedHashMap<>(); + params.put("int", 1); + params.put("long", 3_000_000_001L); + params.put("str", "žluť \"quoted\" \\ 🐎"); + Map kinds = new LinkedHashMap<>(); + kinds.put("bigInteger", new BigInteger("9223372036854775808")); + kinds.put("bigDecimal", new BigDecimal("1.50")); + kinds.put("double", 0.1); + params.put("map", kinds); + return params; + } + + private static Map bodies() { + Map bodies = new LinkedHashMap<>(); + bodies.put("representativeInput", RepresentativeInput.TEXT.trim()); + bodies.put("parameters", "{\"a\": {{int}}, \"b\": [{{long}}, 1.50, 1e3], \"c\": \"{{str}}\", \"d\": {{map}}}"); + bodies.put("text", "plain text"); + return bodies; + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiUriBuilderTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiUriBuilderTest.java index 5223b6b2b6..e6f28576bc 100644 --- a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiUriBuilderTest.java +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiUriBuilderTest.java @@ -4,8 +4,8 @@ import java.util.Collections; import java.util.Map; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.lowcoder.sdk.exception.PluginException; import org.lowcoder.sdk.plugin.common.RestApiUriBuilder; @@ -14,27 +14,27 @@ public class RestApiUriBuilderTest { @Test public void testPathConcatenation() { URI uri = RestApiUriBuilder.buildUri("http://app.lowcoder.cloud", "path?", Collections.emptyMap(), Collections.emptyMap()); - Assert.assertEquals("http://app.lowcoder.cloud/path?", uri.toString()); + Assertions.assertEquals("http://app.lowcoder.cloud/path?", uri.toString()); } @Test public void testExtraSlashesInPath() { URI uri = RestApiUriBuilder.buildUri("http://app.lowcoder.cloud//", "/path", Collections.emptyMap(), Collections.emptyMap()); - Assert.assertEquals("http://app.lowcoder.cloud/path", uri.toString()); + Assertions.assertEquals("http://app.lowcoder.cloud/path", uri.toString()); } @Test public void testAddUrlParams() { URI uri2 = RestApiUriBuilder.buildUri("http://my_api:8080/", "//path?key1=%25", Collections.emptyMap(), Map.of("key2", "value2%")); - Assert.assertEquals("http://my_api:8080/path?key1=%25&key2=value2%25", uri2.toString()); + Assertions.assertEquals("http://my_api:8080/path?key1=%25&key2=value2%25", uri2.toString()); } @Test public void testExceptionCases() { Map paramsMap = Collections.emptyMap(); Map urlParams = Collections.emptyMap(); - Assert.assertThrows(PluginException.class, () -> { + Assertions.assertThrows(PluginException.class, () -> { URI uri = RestApiUriBuilder.buildUri("8080:app.lowcoder.cloud", "", paramsMap, urlParams); }); } diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiUrlSlashCollapseTest.java b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiUrlSlashCollapseTest.java new file mode 100644 index 0000000000..ef9a34ab49 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/java/org/lowcoder/plugin/restapi/RestApiUrlSlashCollapseTest.java @@ -0,0 +1,69 @@ +package org.lowcoder.plugin.restapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; + +/** + * DEFECT pinned, through the REST executor (plan section 9 row "REST url: // inside a query value collapsed to /"; the + * builder is pinned in the sdk by RestApiUriBuilderCollapseTest; D-6, fix deferred). {@code RestApiExecutor.java:161} hands the + * datasource url and the path field to {@code RestApiUriBuilder.buildUri}, whose {@code replaceAll} (RestApiUriBuilder.java:41) + * collapses every run of slashes except after {@code http:} / {@code https:}. The request line the local server (port 0, + * loopback) receives therefore differs from the text typed into the path field, and from the value a {@code {{ }}} expression + * puts there, while a value in the Parameters table arrives intact (it is added after the collapse). A fix that collapses only + * the part before the first {@code ?} turns the first two assertions red. + */ +class RestApiUrlSlashCollapseTest { + + private static final String PATH = "/p"; + private final RestApiExecutor executor = executor(); + + private static RestApiExecutor executor() { + CommonConfig config = new CommonConfig(); + config.setCookieName(RestApiCallSupport.SESSION_COOKIE); + return new RestApiExecutor(config); + } + + private String requestLineFor(Map query, Map requestParams) { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of(PATH, RestApiCallSupport.json(200, "{}")))) { + RestApiDatasourceConfig datasource = RestApiDatasourceConfig.builder().url(server.baseUrl()).build(); + + executor.doExecuteQuery(null, executor.doBuildQueryExecutionContext(datasource, query, requestParams, RestApiCallSupport.visitor(null, null))) + .block(RestApiCallSupport.TIMEOUT); + + assertThat(server.requests()).hasSize(1); + return server.requests().get(0).pathAndQuery(); + } + } + + @Test + void aDoubleSlashTypedIntoTheQueryStringOfThePathFieldArrivesAsOneSlashPinsTheSection9Row() { + String line = requestLineFor(Map.of("httpMethod", "GET", "path", "/p?u=a//b"), Map.of()); + + System.out.println("[RestApiUrlSlashCollapseTest] typed '/p?u=a//b' -> request line '" + line + "'"); + assertThat(line).isEqualTo("/p?u=a/b"); + } + + @Test + void aValueSubstitutedByAMustacheExpressionIntoThePathFieldArrivesCollapsedPinsTheSection9Row() { + String line = requestLineFor(Map.of("httpMethod", "GET", "path", "/p?u={{v}}"), Map.of("v", "a//b")); + + System.out.println("[RestApiUrlSlashCollapseTest] '/p?u={{v}}' with v='a//b' -> request line '" + line + "'"); + assertThat(line).isEqualTo("/p?u=a/b"); + } + + @Test + void aParametersTableValueIsAddedAfterTheCollapseAndArrivesIntact() { + String line = requestLineFor(Map.of("httpMethod", "GET", "path", "/p", "params", List.of(new Property("w", "a//b"))), Map.of()); + + System.out.println("[RestApiUrlSlashCollapseTest] Parameters table w='a//b' -> request line '" + line + "'"); + assertThat(line).isEqualTo("/p?w=a%2F%2Fb"); + } +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/config-binding/RestApiQueryConfig.input.json b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/config-binding/RestApiQueryConfig.input.json new file mode 100644 index 0000000000..070d5821bc --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/config-binding/RestApiQueryConfig.input.json @@ -0,0 +1,46 @@ +{ + "full": { + "httpMethod": "POST", + "disableEncodingParams": true, + "body": "{\"id\": {{id}}}", + "path": "/users/{{id}}", + "params": [ + { + "key": "page", + "value": "1" + }, + { + "key": "q", + "value": "{{input.text}}", + "type": "text" + } + ], + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyFormData": [ + { + "key": "f", + "value": "{{file}}", + "type": "file" + } + ], + "timeoutMs": 10000 + }, + "lowerCaseMethod": { + "httpMethod": "get", + "path": "/a" + }, + "unknownMethod": { + "httpMethod": "PROPFIND", + "timeoutMs": "2500" + }, + "longTimeout": { + "httpMethod": "GET", + "timeoutMs": 4294967296 + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/config-binding/RestApiQueryConfig.json b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/config-binding/RestApiQueryConfig.json new file mode 100644 index 0000000000..2d0fbe48bf --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/config-binding/RestApiQueryConfig.json @@ -0,0 +1,181 @@ +{ + "from" : { + "full" : { + "class" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "httpMethod" : "POST", + "disableEncodingParams" : true, + "body" : "{\"id\": {{id}}}", + "path" : "/users/{{id}}", + "params" : [ { + "@class" : "org.lowcoder.sdk.models.Property", + "key" : "page", + "value" : "1", + "type" : null + }, { + "@class" : "org.lowcoder.sdk.models.Property", + "key" : "q", + "value" : "{{input.text}}", + "type" : "text" + } ], + "headers" : [ { + "@class" : "org.lowcoder.sdk.models.Property", + "key" : "Content-Type", + "value" : "application/json", + "type" : null + } ], + "bodyFormData" : [ { + "@class" : "org.lowcoder.sdk.models.Property", + "key" : "f", + "value" : "{{file}}", + "type" : "file" + } ], + "timeoutMs" : 10000 + }, + "classes" : { + "" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "/httpMethod" : "org.springframework.http.HttpMethod", + "/disableEncodingParams" : "java.lang.Boolean", + "/body" : "java.lang.String", + "/path" : "java.lang.String", + "/params" : "java.util.ArrayList", + "/params/0" : "org.lowcoder.sdk.models.Property", + "/params/0/key" : "java.lang.String", + "/params/0/value" : "java.lang.String", + "/params/0/type" : "null", + "/params/1" : "org.lowcoder.sdk.models.Property", + "/params/1/key" : "java.lang.String", + "/params/1/value" : "java.lang.String", + "/params/1/type" : "java.lang.String", + "/headers" : "java.util.ArrayList", + "/headers/0" : "org.lowcoder.sdk.models.Property", + "/headers/0/key" : "java.lang.String", + "/headers/0/value" : "java.lang.String", + "/headers/0/type" : "null", + "/bodyFormData" : "java.util.ArrayList", + "/bodyFormData/0" : "org.lowcoder.sdk.models.Property", + "/bodyFormData/0/key" : "java.lang.String", + "/bodyFormData/0/value" : "java.lang.String", + "/bodyFormData/0/type" : "java.lang.String", + "/timeoutMs" : "java.lang.Long" + }, + "noView" : {"httpMethod":{},"disableEncodingParams":true,"body":"{\"id\": {{id}}}","path":"/users/{{id}}","params":[{"key":"page","value":"1","type":null,"multipartFileType":false},{"key":"q","value":"{{input.text}}","type":"text","multipartFileType":false}],"headers":[{"key":"Content-Type","value":"application/json","type":null,"multipartFileType":false}],"bodyFormData":[{"key":"f","value":"{{file}}","type":"file","multipartFileType":true}],"timeoutMs":10000}, + "Public" : {"httpMethod":{},"disableEncodingParams":true,"body":"{\"id\": {{id}}}","path":"/users/{{id}}","params":[{"key":"page","value":"1","type":null,"multipartFileType":false},{"key":"q","value":"{{input.text}}","type":"text","multipartFileType":false}],"headers":[{"key":"Content-Type","value":"application/json","type":null,"multipartFileType":false}],"bodyFormData":[{"key":"f","value":"{{file}}","type":"file","multipartFileType":true}],"timeoutMs":10000}, + "Internal" : {"httpMethod":{},"disableEncodingParams":true,"body":"{\"id\": {{id}}}","path":"/users/{{id}}","params":[{"key":"page","value":"1","type":null,"multipartFileType":false},{"key":"q","value":"{{input.text}}","type":"text","multipartFileType":false}],"headers":[{"key":"Content-Type","value":"application/json","type":null,"multipartFileType":false}],"bodyFormData":[{"key":"f","value":"{{file}}","type":"file","multipartFileType":true}],"timeoutMs":10000} + }, + "lowerCaseMethod" : { + "class" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "httpMethod" : "get", + "disableEncodingParams" : false, + "body" : null, + "path" : "/a", + "params" : null, + "headers" : null, + "bodyFormData" : null, + "timeoutMs" : 0 + }, + "classes" : { + "" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "/httpMethod" : "org.springframework.http.HttpMethod", + "/disableEncodingParams" : "java.lang.Boolean", + "/body" : "null", + "/path" : "java.lang.String", + "/params" : "null", + "/headers" : "null", + "/bodyFormData" : "null", + "/timeoutMs" : "java.lang.Long" + }, + "noView" : {"httpMethod":{},"disableEncodingParams":false,"body":null,"path":"/a","params":[],"headers":[],"bodyFormData":[],"timeoutMs":0}, + "Public" : {"httpMethod":{},"disableEncodingParams":false,"body":null,"path":"/a","params":[],"headers":[],"bodyFormData":[],"timeoutMs":0}, + "Internal" : {"httpMethod":{},"disableEncodingParams":false,"body":null,"path":"/a","params":[],"headers":[],"bodyFormData":[],"timeoutMs":0} + }, + "unknownMethod" : { + "class" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "httpMethod" : "PROPFIND", + "disableEncodingParams" : false, + "body" : null, + "path" : null, + "params" : null, + "headers" : null, + "bodyFormData" : null, + "timeoutMs" : 2500 + }, + "classes" : { + "" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "/httpMethod" : "org.springframework.http.HttpMethod", + "/disableEncodingParams" : "java.lang.Boolean", + "/body" : "null", + "/path" : "null", + "/params" : "null", + "/headers" : "null", + "/bodyFormData" : "null", + "/timeoutMs" : "java.lang.Long" + }, + "noView" : {"httpMethod":{},"disableEncodingParams":false,"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"timeoutMs":2500}, + "Public" : {"httpMethod":{},"disableEncodingParams":false,"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"timeoutMs":2500}, + "Internal" : {"httpMethod":{},"disableEncodingParams":false,"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"timeoutMs":2500} + }, + "longTimeout" : { + "class" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "httpMethod" : "GET", + "disableEncodingParams" : false, + "body" : null, + "path" : null, + "params" : null, + "headers" : null, + "bodyFormData" : null, + "timeoutMs" : 4294967296 + }, + "classes" : { + "" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "/httpMethod" : "org.springframework.http.HttpMethod", + "/disableEncodingParams" : "java.lang.Boolean", + "/body" : "null", + "/path" : "null", + "/params" : "null", + "/headers" : "null", + "/bodyFormData" : "null", + "/timeoutMs" : "java.lang.Long" + }, + "noView" : {"httpMethod":{},"disableEncodingParams":false,"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"timeoutMs":4294967296}, + "Public" : {"httpMethod":{},"disableEncodingParams":false,"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"timeoutMs":4294967296}, + "Internal" : {"httpMethod":{},"disableEncodingParams":false,"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"timeoutMs":4294967296} + }, + "empty" : { + "class" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "httpMethod" : null, + "disableEncodingParams" : false, + "body" : null, + "path" : null, + "params" : null, + "headers" : null, + "bodyFormData" : null, + "timeoutMs" : 0 + }, + "classes" : { + "" : "org.lowcoder.plugin.restapi.model.RestApiQueryConfig", + "/httpMethod" : "null", + "/disableEncodingParams" : "java.lang.Boolean", + "/body" : "null", + "/path" : "null", + "/params" : "null", + "/headers" : "null", + "/bodyFormData" : "null", + "/timeoutMs" : "java.lang.Long" + }, + "noView" : {"httpMethod":null,"disableEncodingParams":false,"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"timeoutMs":0}, + "Public" : {"httpMethod":null,"disableEncodingParams":false,"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"timeoutMs":0}, + "Internal" : {"httpMethod":null,"disableEncodingParams":false,"body":null,"path":null,"params":[],"headers":[],"bodyFormData":[],"timeoutMs":0} + }, + "unknownProperty" : "ignored: binds as form full" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/downstream-encode/RestApiExecutor.jsonBody.json b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/downstream-encode/RestApiExecutor.jsonBody.json new file mode 100644 index 0000000000..65b380a633 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/downstream-encode/RestApiExecutor.jsonBody.json @@ -0,0 +1,10 @@ +{ + "parameters" : { + "contentType" : [ "application/json" ], + "body" : {"int":1,"long":3000000001,"decimal":1.5,"exponent":1.0E20,"bool":true,"null":null,"string":"it's \"q\" \\ žluť","dollar":"cost $1","list":[1,"it's \"q\"",2.5,null,[true],{"k":1.5}],"map":{"zeta":"it's","decimal":1.5,"nested":[1,null]},"inText":"it's \"q\" \\ žluť cost $1","literal":[1.5,1000.0,2147483648]} + }, + "representativeInput" : { + "contentType" : [ "application/json" ], + "body" : "{\n \"zeta\": \"first key, so that sorted output would differ from input order\",\n \"int\": 1,\n \"negativeInt\": -40003,\n \"intMax\": 2147483647,\n \"intMaxPlusOne\": 2147483648,\n \"intMinMinusOne\": -2147483649,\n \"longMax\": 9223372036854775807,\n \"longMaxPlusOne\": 9223372036854775808,\n \"longMinMinusOne\": -9223372036854775809,\n \"decimal\": 1.5,\n \"trailingZeroDecimal\": 1.50,\n \"zeroPointZero\": 0.0,\n \"negativeZero\": -0,\n \"exponent\": 1e2,\n \"exponentUpperCase\": 1E+2,\n \"negativeExponent\": 2.5e-3,\n \"longDecimal\": 0.1000000000000000055511151231257827,\n \"true\": true,\n \"false\": false,\n \"null\": null,\n \"emptyObject\": {},\n \"emptyArray\": [],\n \"nested\": {\n \"array\": [1, \"two\", [3, {\"four\": 4.0}], null, true, 3000000001],\n \"object\": {\"z\": 1, \"a\": {\"deeper\": [[], {}]}}\n },\n \"unicode\": \"žluťoučký kůň \uD83D\uDC0E 中文\",\n \"escapes\": \"\\u00e9 \\u0000 \\n \\t \\\" \\\\ \\/ \\ud83d\\ude00\",\n \"k\\u0065y with escaped name\": \"escaped key\",\n \"alpha\": \"last key\"\n}" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/external-json/RestApiExecutor.responses.json b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/external-json/RestApiExecutor.responses.json new file mode 100644 index 0000000000..3d0331c891 --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/external-json/RestApiExecutor.responses.json @@ -0,0 +1,166 @@ +{ + "/json" : { + "queryCode" : "OK", + "headers" : { + "X-multi" : [ "first", "second, with comma" ], + "Content-type" : [ "application/json" ], + "X-utf8-bytes" : [ "žluÅ¥" ], + "X-latin1" : [ "café" ], + "Content-length" : [ "955" ] + }, + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/zeta" : "com.fasterxml.jackson.databind.node.TextNode", + "/int" : "com.fasterxml.jackson.databind.node.IntNode", + "/negativeInt" : "com.fasterxml.jackson.databind.node.IntNode", + "/intMax" : "com.fasterxml.jackson.databind.node.IntNode", + "/intMaxPlusOne" : "com.fasterxml.jackson.databind.node.LongNode", + "/intMinMinusOne" : "com.fasterxml.jackson.databind.node.LongNode", + "/longMax" : "com.fasterxml.jackson.databind.node.LongNode", + "/longMaxPlusOne" : "com.fasterxml.jackson.databind.node.BigIntegerNode", + "/longMinMinusOne" : "com.fasterxml.jackson.databind.node.BigIntegerNode", + "/decimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/trailingZeroDecimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/zeroPointZero" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/negativeZero" : "com.fasterxml.jackson.databind.node.IntNode", + "/exponent" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/exponentUpperCase" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/negativeExponent" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/longDecimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/true" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/false" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/null" : "com.fasterxml.jackson.databind.node.NullNode", + "/emptyObject" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/emptyArray" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/nested" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/nested/array" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/nested/array/0" : "com.fasterxml.jackson.databind.node.IntNode", + "/nested/array/1" : "com.fasterxml.jackson.databind.node.TextNode", + "/nested/array/2" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/nested/array/2/0" : "com.fasterxml.jackson.databind.node.IntNode", + "/nested/array/2/1" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/nested/array/2/1/four" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/nested/array/3" : "com.fasterxml.jackson.databind.node.NullNode", + "/nested/array/4" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/nested/array/5" : "com.fasterxml.jackson.databind.node.LongNode", + "/nested/object" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/nested/object/z" : "com.fasterxml.jackson.databind.node.IntNode", + "/nested/object/a" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/nested/object/a/deeper" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/nested/object/a/deeper/0" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/nested/object/a/deeper/1" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/unicode" : "com.fasterxml.jackson.databind.node.TextNode", + "/escapes" : "com.fasterxml.jackson.databind.node.TextNode", + "/key with escaped name" : "com.fasterxml.jackson.databind.node.TextNode", + "/alpha" : "com.fasterxml.jackson.databind.node.TextNode" + }, + "keyOrder" : { + "/emptyObject" : [ ], + "/nested/array/2/1" : [ "four" ], + "/nested/object/a/deeper/1" : [ ], + "/nested/object/a" : [ "deeper" ], + "/nested/object" : [ "z", "a" ], + "/nested" : [ "array", "object" ], + "" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ] + } + }, + "written" : {"queryCode":"OK","headers":{"X-multi":["first","second, with comma"],"Content-type":["application/json"],"X-utf8-bytes":["žluÅ¥"],"X-latin1":["café"],"Content-length":["955"]},"data":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"success":true} + }, + "/problem-json" : { + "queryCode" : "HTTPUNPROCESSABLE_ENTITY", + "headers" : { + "Content-type" : [ "application/problem+json" ], + "Content-length" : [ "80" ] + }, + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/title" : "com.fasterxml.jackson.databind.node.TextNode", + "/status" : "com.fasterxml.jackson.databind.node.IntNode", + "/errors" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/errors/0" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/errors/0/field" : "com.fasterxml.jackson.databind.node.TextNode", + "/errors/0/limit" : "com.fasterxml.jackson.databind.node.DoubleNode" + }, + "keyOrder" : { + "/errors/0" : [ "field", "limit" ], + "" : [ "title", "status", "errors" ] + } + }, + "written" : {"queryCode":"HTTPUNPROCESSABLE_ENTITY","headers":{"Content-type":["application/problem+json"],"Content-length":["80"]},"data":{"title":"Unprocessable","status":422,"errors":[{"field":"name","limit":10.5}]},"success":false} + }, + "/ndjson" : { + "queryCode" : "OK", + "headers" : { + "Content-type" : [ "application/x-ndjson" ], + "Content-length" : [ "22" ] + }, + "dataShape" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/line" : "com.fasterxml.jackson.databind.node.IntNode" + }, + "keyOrder" : { + "" : [ "line" ] + } + }, + "written" : {"queryCode":"OK","headers":{"Content-type":["application/x-ndjson"],"Content-length":["22"]},"data":{"line":1},"success":true} + }, + "/image" : { + "queryCode" : "OK", + "headers" : { + "Content-type" : [ "image/png" ], + "Content-length" : [ "7" ] + }, + "dataShape" : { + "classes" : { + "" : "[B" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","headers":{"Content-type":["image/png"],"Content-length":["7"]},"data":"aVZCT1J3QUIvdz09","success":true} + }, + "/binary" : { + "queryCode" : "OK", + "headers" : { + "Content-type" : [ "application/octet-stream" ], + "Content-length" : [ "7" ] + }, + "dataShape" : { + "classes" : { + "" : "[B" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","headers":{"Content-type":["application/octet-stream"],"Content-length":["7"]},"data":"iVBORwAB/w==","success":true} + }, + "/text" : { + "queryCode" : "OK", + "headers" : { + "Content-type" : [ "text/plain;charset=UTF-8" ], + "Content-length" : [ "26" ] + }, + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","headers":{"Content-type":["text/plain;charset=UTF-8"],"Content-length":["26"]},"data":"plain žluť text 1.50","success":true} + }, + "/no-content" : { + "queryCode" : "OK", + "headers" : { }, + "dataShape" : { + "classes" : { + "" : "java.lang.String" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","headers":{},"data":"204 NO_CONTENT","success":true} + }, + "/invalid-json" : { + "error" : "org.lowcoder.sdk.exception.PluginException: REST_API_EXECUTION_ERROR INVALID_JSON_FROM_RESPONSE" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/tree-text/RestApiExecutor.specialJsonBody.txt b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/tree-text/RestApiExecutor.specialJsonBody.txt new file mode 100644 index 0000000000..6b72a0c95a --- /dev/null +++ b/server/api-service/lowcoder-plugins/restApiPlugin/src/test/resources/json-contract/tree-text/RestApiExecutor.specialJsonBody.txt @@ -0,0 +1,3 @@ +representativeInput [application/x-amz-json-1.1] "{\n \"zeta\": \"first key, so that sorted output would differ from input order\",\n \"int\": 1,\n \"negativeInt\": -40003,\n \"intMax\": 2147483647,\n \"intMaxPlusOne\": 2147483648,\n \"intMinMinusOne\": -2147483649,\n \"longMax\": 9223372036854775807,\n \"longMaxPlusOne\": 9223372036854775808,\n \"longMinMinusOne\": -9223372036854775809,\n \"decimal\": 1.5,\n \"trailingZeroDecimal\": 1.50,\n \"zeroPointZero\": 0.0,\n \"negativeZero\": -0,\n \"exponent\": 1e2,\n \"exponentUpperCase\": 1E+2,\n \"negativeExponent\": 2.5e-3,\n \"longDecimal\": 0.1000000000000000055511151231257827,\n \"true\": true,\n \"false\": false,\n \"null\": null,\n \"emptyObject\": {},\n \"emptyArray\": [],\n \"nested\": {\n \"array\": [1, \"two\", [3, {\"four\": 4.0}], null, true, 3000000001],\n \"object\": {\"z\": 1, \"a\": {\"deeper\": [[], {}]}}\n },\n \"unicode\": \"žluťoučký kůň 🐎 中文\",\n \"escapes\": \"\\u00e9 \\u0000 \\n \\t \\\" \\\\ \\/ \\ud83d\\ude00\",\n \"k\\u0065y with escaped name\": \"escaped key\",\n \"alpha\": \"last key\"\n}" +parameters [application/x-amz-json-1.1] {"a":1,"b":[3000000001,1.5,1000.0],"c":"žluť \"quoted\" \\ 🐎","d":{"bigInteger":9223372036854775808,"bigDecimal":1.5,"double":0.1}} +text [application/x-amz-json-1.1] "plain text" diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/pom.xml b/server/api-service/lowcoder-plugins/smtpPlugin/pom.xml index add6d6d9b6..d76ced199f 100644 --- a/server/api-service/lowcoder-plugins/smtpPlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/smtpPlugin/pom.xml @@ -35,29 +35,19 @@ provided - - + - io.vertx - vertx-mail-client - 4.3.3 - test - - - io.netty - netty-codec-http - 4.1.77.Final - test - - - - - - - org.testcontainers - testcontainers - 1.18.0 + com.icegreen + greenmail + 2.0.1 test + + + junit + junit + + diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpConfigBindingContractTest.java new file mode 100644 index 0000000000..e7d9c17eb6 --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpConfigBindingContractTest.java @@ -0,0 +1,30 @@ +package org.lowcoder.plugins; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugins.SmtpPlugin.SmtpEngine; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code smtpPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1): + * {@code SmtpEngine.resolveConfig} binds {@code SmtpDatasourceConfig}, which has Lombok's {@code @Builder} and final + * fields but neither {@code @Jacksonized} nor a creator annotation, so Jackson must find the builder's all-arguments + * constructor through the parameter names module; the forms pin that, and the {@code int} port's coercions. + */ +public class SmtpConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.resolveConfig#toJson#1", + "lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.resolveConfig#fromJson#1"}) + @Test + public void datasourceConfig() { + SmtpEngine engine = new SmtpEngine(); + ConfigBinding.assertBinding(GOLDEN, "config-binding/SmtpDatasourceConfig.input.json", "config-binding/SmtpDatasourceConfig.json", + Map.of("SmtpEngine.resolveConfig", engine::resolveConfig)); + } +} diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpDatasourceConfigTest.java b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpDatasourceConfigTest.java new file mode 100644 index 0000000000..e86237a041 --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpDatasourceConfigTest.java @@ -0,0 +1,118 @@ +package org.lowcoder.plugins; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugins.SmtpPlugin.SmtpEngine; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit SM-3 (task L5-10): {@code SmtpEngine.resolveConfig} and {@code validateConfig}, and {@code SmtpDatasourceConfig}: merge of + * an edited config into the stored one, encrypt and decrypt of the password, and defect D18 (no type check in the merge). + * + *

    Limits: the encrypt function is a stand-in (a pure string function); a server is not involved. + */ +public class SmtpDatasourceConfigTest { + + static final String TAG = "[SmtpDatasourceConfigTest] "; + static final String HOST = "smtp.example.org"; + static final int PORT = 587; + + private final SmtpEngine engine = new SmtpEngine(); + + private SmtpDatasourceConfig config(Map values) { + return engine.resolveConfig(new HashMap<>(values)); + } + + @Test + public void resolveConfigBindsHostPortUserAndPassword() { + SmtpDatasourceConfig bound = config(Map.of("host", HOST, "port", PORT, "username", "u", "password", "p")); + assertEquals(HOST, bound.getHost()); + assertEquals(PORT, bound.getPort()); + assertEquals("u", bound.getUsername()); + assertEquals("p", bound.getPassword()); + SmtpDatasourceConfig minimal = config(Map.of("host", HOST)); + assertEquals(0, minimal.getPort(), "an absent port is 0"); + assertNull(minimal.getUsername()); + assertNull(minimal.getPassword()); + } + + @Test + public void validateConfigNamesABlankHostAndANegativePort() { + assertEquals(Set.of(), engine.validateConfig(config(Map.of("host", HOST, "port", PORT)))); + assertEquals(Set.of("HOST_EMPTY"), engine.validateConfig(config(Map.of("host", " ", "port", PORT)))); + assertEquals(Set.of("HOST_EMPTY"), engine.validateConfig(config(Map.of("port", PORT)))); + assertEquals(Set.of("INVALID_PORT"), engine.validateConfig(config(Map.of("host", HOST, "port", -1)))); + assertEquals(Set.of(), engine.validateConfig(config(Map.of("host", HOST, "port", 0))), "port 0 passes"); + assertEquals(Set.of("HOST_EMPTY", "INVALID_PORT"), engine.validateConfig(config(Map.of("port", -5)))); + } + + @Test + public void mergeKeepsTheStoredPasswordWhenTheUpdateHasNone() { + SmtpDatasourceConfig stored = config(Map.of("host", "old", "port", 25, "username", "old-user", "password", "stored")); + SmtpDatasourceConfig merged = (SmtpDatasourceConfig) stored.mergeWithUpdatedConfig(config(Map.of("host", HOST, "port", PORT, "username", "u"))); + assertEquals(HOST, merged.getHost()); + assertEquals(PORT, merged.getPort()); + assertEquals("u", merged.getUsername()); + assertEquals("stored", merged.getPassword()); + SmtpDatasourceConfig replaced = (SmtpDatasourceConfig) stored.mergeWithUpdatedConfig(config(Map.of("host", HOST, "port", PORT, "password", "new"))); + assertEquals("new", replaced.getPassword()); + assertNull(replaced.getUsername(), "the user always comes from the update"); + } + + /** A config of another datasource type. */ + static final class Foreign implements DatasourceConnectionConfig { + @Override + public DatasourceConnectionConfig mergeWithUpdatedConfig(DatasourceConnectionConfig detailConfig) { + return this; + } + } + + /** + * Pins defect D18 (analysis-plugins section 0.6; plan section 9 D1-D20 row): the merge casts the update to + * {@code SmtpDatasourceConfig} without an {@code instanceof} check, so a config of another type gives a + * {@code ClassCastException} where the other plugins give {@code INVALID_DATASOURCE_CONFIG_TYPE}. A fix changes this test on + * purpose. + */ + @Test + public void foreignConfigTypeIsAClassCastException_pinsD18() { + ClassCastException thrown = assertThrows(ClassCastException.class, () -> config(Map.of("host", HOST)).mergeWithUpdatedConfig(new Foreign())); + System.out.println(TAG + "foreign type: " + thrown.getMessage()); + } + + @Test + public void encryptAndDecryptApplyTheFunctionOnlyToANonBlankPassword() { + AtomicInteger calls = new AtomicInteger(); + Function encrypt = s -> { + calls.incrementAndGet(); + return "enc(" + s + ")"; + }; + Function decrypt = s -> { + calls.incrementAndGet(); + return s.substring(4, s.length() - 1); + }; + SmtpDatasourceConfig built = config(Map.of("host", HOST, "password", "p")); + assertSame(built, built.doEncrypt(encrypt)); + assertEquals("enc(p)", built.getPassword()); + assertSame(built, built.doDecrypt(decrypt)); + assertEquals("p", built.getPassword()); + assertEquals(2, calls.get()); + for (Map noSecret : java.util.List.of(Map.of("host", HOST), Map.of("host", HOST, "password", " "))) { + SmtpDatasourceConfig none = config(noSecret); + String before = none.getPassword(); + none.doEncrypt(encrypt); + none.doDecrypt(decrypt); + assertEquals(before, none.getPassword()); + } + assertEquals(2, calls.get(), "the function is not called for a null or blank password"); + } +} diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpEngineConnectionTest.java b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpEngineConnectionTest.java new file mode 100644 index 0000000000..eec05aaf16 --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpEngineConnectionTest.java @@ -0,0 +1,134 @@ +package org.lowcoder.plugins; + +import com.icegreen.greenmail.util.GreenMail; +import jakarta.mail.Session; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugins.SmtpPlugin.SmtpEngine; +import org.lowcoder.sdk.models.DatasourceTestResult; + +import java.net.ServerSocket; +import java.time.Duration; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugins.SmtpGreenMailSupport.LOGIN; +import static org.lowcoder.plugins.SmtpGreenMailSupport.LOOPBACK; +import static org.lowcoder.plugins.SmtpGreenMailSupport.PASSWORD; +import static org.lowcoder.plugins.SmtpGreenMailSupport.configMap; + +/** + * Unit SM-2 (task L5-10): {@code SmtpEngine.testConnection}, {@code createConnection} and {@code destroyConnection} against a + * real SMTP server (GreenMail, {@link SmtpGreenMailSupport}): good and bad credentials, the no-user branch, an unreachable port, + * the session's port setting, and closing the transport. + * + *

    Limits: plain SMTP only; the unreachable port is one that was free a moment ago (a closed server socket), so another + * process could in theory be listening on it; the test would then report a connection that succeeds. + */ +public class SmtpEngineConnectionTest { + + static final String TAG = "[SmtpEngineConnectionTest] "; + static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(30); + static final int DEFAULT_SMTP_PORT = 25; + + private static GreenMail server; + private final SmtpEngine engine = new SmtpEngine(); + + @BeforeAll + static void start() { + server = SmtpGreenMailSupport.startAuthenticating(); + } + + @AfterAll + static void stop() { + server.stop(); + } + + private DatasourceTestResult test(String label, Map config) { + DatasourceTestResult result = engine.testConnection(engine.resolveConfig(config)).block(BLOCK_TIMEOUT); + System.out.println(TAG + label + ": success=" + result.isSuccess() + (result.isSuccess() ? "" : " message=" + result.getInvalidMessage(Locale.ENGLISH))); + return result; + } + + @Test + public void correctCredentialsSucceedAndWrongOnesFail() { + assertTrue(test("good credentials", configMap(server, LOGIN, PASSWORD)).isSuccess()); + assertFalse(test("wrong password", configMap(server, LOGIN, "wrong")).isSuccess()); + assertFalse(test("unknown user", configMap(server, "nobody", PASSWORD)).isSuccess()); + } + + /** + * Observation: an empty user name (the form leaves the field as typed: an emptied field is the empty string) takes the with-credentials + * connect, which an authenticating server refuses. A user name that is absent altogether does not get that far: see + * {@link SmtpEngineOpenRelayTest} (D19). + */ + @Test + public void emptyUserNameAgainstAnAuthenticatingServerFails() { + assertFalse(test("empty user", configMap(server, "", "")).isSuccess()); + } + + @Test + public void unreachablePortGivesAFailedResult() throws Exception { + int closedPort; + try (ServerSocket socket = new ServerSocket(0)) { + closedPort = socket.getLocalPort(); + } + Map config = new java.util.HashMap<>(configMap(server, LOGIN, PASSWORD)); + config.put("port", closedPort); + assertFalse(test("closed port " + closedPort, config).isSuccess()); + } + + @Test + public void destroyConnectionClosesTheTransportAndCanRunTwice() { + Session session = engine.createConnection(engine.resolveConfig(configMap(server, LOGIN, PASSWORD))).block(BLOCK_TIMEOUT); + assertNotNull(session); + engine.destroyConnection(session).block(BLOCK_TIMEOUT); + engine.destroyConnection(session).block(BLOCK_TIMEOUT); + } + + /** + * Observation (reported to the coordinator): {@code Session.getTransport()} returns a new transport object on every call, so + * the transport that {@code testConnection} connects and the one {@code destroyConnection} closes are different objects: the + * close is a no-op on a transport that was never connected, and the connected one is left to the garbage collector. The + * mutation that removes the close call survives for the same reason. + */ + @Test + public void everyGetTransportCallIsANewObjectSoDestroyDoesNotCloseTheTestedConnection() throws Exception { + Session session = engine.createConnection(engine.resolveConfig(configMap(server, LOGIN, PASSWORD))).block(BLOCK_TIMEOUT); + jakarta.mail.Transport first = session.getTransport(); + first.connect(LOGIN, PASSWORD); + try { + assertTrue(first.isConnected()); + assertFalse(session.getTransport() == first); + engine.destroyConnection(session).block(BLOCK_TIMEOUT); + assertTrue(first.isConnected(), "destroyConnection did not close the connected transport"); + } finally { + first.close(); + } + } + + @Test + public void sessionCarriesHostPortAndAuthSettings() { + Session session = engine.createConnection(engine.resolveConfig(configMap(server, LOGIN, PASSWORD))).block(BLOCK_TIMEOUT); + assertEquals(LOOPBACK, session.getProperty("mail.smtp.host")); + assertEquals(server.getSmtp().getPort(), session.getProperties().get("mail.smtp.port")); + assertEquals(true, session.getProperties().get("mail.smtp.auth")); + assertEquals(LOGIN, session.getProperty("mail.smtp.username")); + assertEquals("smtp", session.getProperty("mail.transport.protocol")); + } + + @Test + public void portZeroOrNegativeFallsBackToTheDefaultSmtpPort() { + for (int port : new int[] {0, -1}) { + Map config = new java.util.HashMap<>(configMap(server, LOGIN, PASSWORD)); + config.put("port", port); + Session session = engine.createConnection(engine.resolveConfig(config)).block(BLOCK_TIMEOUT); + assertEquals(DEFAULT_SMTP_PORT, session.getProperties().get("mail.smtp.port"), "port " + port); + } + } +} diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpEngineDeliveryTest.java b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpEngineDeliveryTest.java new file mode 100644 index 0000000000..f37c3bf4f2 --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpEngineDeliveryTest.java @@ -0,0 +1,238 @@ +package org.lowcoder.plugins; + +import com.icegreen.greenmail.util.GreenMail; +import jakarta.mail.Message; +import jakarta.mail.Multipart; +import jakarta.mail.Part; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugins.SmtpPlugin.SmtpEngine; +import org.lowcoder.sdk.models.QueryExecutionResult; + +import java.io.InputStream; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugins.SmtpGreenMailSupport.LOGIN; +import static org.lowcoder.plugins.SmtpGreenMailSupport.PASSWORD; +import static org.lowcoder.plugins.SmtpGreenMailSupport.configMap; +import static org.lowcoder.plugins.SmtpGreenMailSupport.inboxOf; + +/** + * Unit SM-1 (task L5-10): a query executed against a real SMTP server (GreenMail, {@link SmtpGreenMailSupport}): the config is + * bound by {@code resolveConfig}, the query config rendered by {@code buildQueryExecutionContext}, the mail sent by + * {@code createConnection} + {@code executeQuery}, and the delivered message is read back from the recipients' mailboxes. + * + *

    Limits: plain SMTP only (no STARTTLS or SSL); the server stores what it receives and does not relay. + */ +public class SmtpEngineDeliveryTest { + + static final String TAG = "[SmtpEngineDeliveryTest] "; + static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(30); + static final long WAIT_MILLIS = 10_000; + static final String QUERY_CODE_OK = "OK"; + static final String FROM = "sender@example.com"; + static final String TO_1 = "to1@example.com"; + static final String TO_2 = "to2@example.com"; + static final String CC = "cc@example.com"; + static final String BCC = "bcc@example.com"; + static final String REPLY_TO = "reply@example.com"; + + private static GreenMail server; + private final SmtpEngine engine = new SmtpEngine(); + + @BeforeAll + static void start() { + server = SmtpGreenMailSupport.startAuthenticating(); + } + + @AfterAll + static void stop() { + server.stop(); + } + + @BeforeEach + void purge() throws Exception { + server.purgeEmailFromAllMailboxes(); + } + + private QueryExecutionResult send(Map config, Map queryConfig) { + var datasource = engine.resolveConfig(config); + Session session = engine.createConnection(datasource).block(BLOCK_TIMEOUT); + var context = engine.buildQueryExecutionContext(datasource, queryConfig, Map.of(), null); + QueryExecutionResult result = engine.executeQuery(session, context).block(BLOCK_TIMEOUT); + System.out.println(TAG + "sent " + queryConfig.get("subject") + " -> " + result.getQueryCode() + " " + result.getMessageKey()); + return result; + } + + private static Map query(String to, String subject, String content) { + Map query = new HashMap<>(); + query.put("from", FROM); + query.put("to", to); + query.put("subject", subject); + query.put("content", content); + return query; + } + + private QueryExecutionResult sendAuthenticated(Map queryConfig) { + return send(configMap(server, LOGIN, PASSWORD), queryConfig); + } + + private static MimeMessage only(String email) throws Exception { + List inbox = inboxOf(server, email); + assertEquals(1, inbox.size(), email + " inbox"); + return inbox.get(0); + } + + private static String addresses(Message message, Message.RecipientType type) throws Exception { + return message.getRecipients(type) == null ? null : Arrays.toString(message.getRecipients(type)); + } + + @Test + public void aMessageIsDeliveredWithItsAddressesSubjectAndHtmlBody() throws Exception { + Map queryConfig = query("[\"" + TO_1 + "\"]", "Quarterly report", "

    Hello world

    "); + queryConfig.put("cc", "[\"" + CC + "\"]"); + queryConfig.put("replyTo", "[\"" + REPLY_TO + "\"]"); + QueryExecutionResult result = sendAuthenticated(queryConfig); + assertEquals(QUERY_CODE_OK, result.getQueryCode()); + assertNull(result.getData()); + assertTrue(server.waitForIncomingEmail(WAIT_MILLIS, 2)); + + MimeMessage toCopy = only(TO_1); + MimeMessage ccCopy = only(CC); + for (MimeMessage message : List.of(toCopy, ccCopy)) { + assertEquals(FROM, ((InternetAddress) message.getFrom()[0]).getAddress()); + assertEquals("[" + TO_1 + "]", addresses(message, Message.RecipientType.TO)); + assertEquals("[" + CC + "]", addresses(message, Message.RecipientType.CC)); + assertEquals(REPLY_TO, ((InternetAddress) message.getReplyTo()[0]).getAddress()); + assertEquals("Quarterly report", message.getSubject()); + assertNotNull(message.getMessageID(), "Message-ID exists"); + assertNotNull(message.getSentDate(), "Date exists"); + Multipart multipart = (Multipart) message.getContent(); + assertEquals(1, multipart.getCount()); + assertTrue(multipart.getBodyPart(0).getContentType().startsWith("text/html"), multipart.getBodyPart(0).getContentType()); + assertEquals("

    Hello world

    ", multipart.getBodyPart(0).getContent()); + } + } + + @Test + public void everyAddressInToReceivesTheMail() throws Exception { + sendAuthenticated(query("[\"" + TO_1 + "\", \"" + TO_2 + "\"]", "two recipients", "x")); + assertTrue(server.waitForIncomingEmail(WAIT_MILLIS, 2)); + for (String email : List.of(TO_1, TO_2)) { + MimeMessage message = only(email); + assertEquals("[" + TO_1 + ", " + TO_2 + "]", addresses(message, Message.RecipientType.TO), "both appear in the header of " + email); + } + } + + @Test + public void bccRecipientReceivesTheMailButNoBccHeaderIsDelivered() throws Exception { + Map queryConfig = query("[\"" + TO_1 + "\"]", "blind copy", "x"); + queryConfig.put("bcc", "[\"" + BCC + "\"]"); + assertEquals(QUERY_CODE_OK, sendAuthenticated(queryConfig).getQueryCode()); + assertTrue(server.waitForIncomingEmail(WAIT_MILLIS, 2)); + MimeMessage blind = only(BCC); + MimeMessage visible = only(TO_1); + for (MimeMessage message : List.of(blind, visible)) { + assertNull(message.getHeader("Bcc"), "no Bcc header in the delivered copy"); + assertEquals("[" + TO_1 + "]", addresses(message, Message.RecipientType.TO)); + assertNull(message.getRecipients(Message.RecipientType.BCC)); + } + } + + @Test + public void attachmentsArriveWithTheirNameTypeAndBytes() throws Exception { + byte[] binary = new byte[256]; + for (int i = 0; i < binary.length; i++) { + binary[i] = (byte) i; + } + String encoded = Base64.getEncoder().encodeToString(binary); + String attachments = "[{\"name\": \"all-bytes.bin\", \"contentType\": \"application/octet-stream\", \"content\": \"" + encoded + "\"}," + + " {\"name\": \"note.txt\", \"contentType\": \"text/plain\", \"content\": \"" + Base64.getEncoder().encodeToString("hello".getBytes()) + "\"}]"; + Map queryConfig = query("[\"" + TO_1 + "\"]", "with attachments", "see attached"); + queryConfig.put("attachments", attachments); + assertEquals(QUERY_CODE_OK, sendAuthenticated(queryConfig).getQueryCode()); + assertTrue(server.waitForIncomingEmail(WAIT_MILLIS, 1)); + Multipart multipart = (Multipart) only(TO_1).getContent(); + assertEquals(3, multipart.getCount(), "body plus two attachments"); + Part first = multipart.getBodyPart(1); + assertEquals("all-bytes.bin", first.getFileName()); + assertEquals(Part.ATTACHMENT, first.getDisposition()); + assertTrue(first.getContentType().startsWith("application/octet-stream"), first.getContentType()); + try (InputStream in = first.getInputStream()) { + assertArrayEquals(binary, in.readAllBytes()); + } + Part second = multipart.getBodyPart(2); + assertEquals("note.txt", second.getFileName()); + assertTrue(second.getContentType().startsWith("text/plain"), second.getContentType()); + try (InputStream in = second.getInputStream()) { + assertEquals("hello", new String(in.readAllBytes())); + } + } + + /** + * The subject is set with {@code MimeMessage.setSubject(String)}, which encodes with the JVM's default charset (no charset is + * given). With a UTF-8 default (the case on a UTF-8 locale and on Java 18 and later) a non-ASCII subject survives; with any + * other default (probed: {@code LC_ALL=C}, US-ASCII) every non-ASCII letter arrives as a question mark. The test asserts the + * outcome that belongs to the charset of the JVM it runs in and prints both; the dependence on the environment is an + * observation reported to the coordinator. + */ + @Test + public void nonAsciiSubjectDependsOnTheJvmDefaultCharset() throws Exception { + String subject = "Příliš žluťoučký kůň"; + java.nio.charset.Charset charset = java.nio.charset.Charset.defaultCharset(); + sendAuthenticated(query("[\"" + TO_1 + "\"]", subject, "

    Žluťoučký

    ")); + assertTrue(server.waitForIncomingEmail(WAIT_MILLIS, 1)); + MimeMessage message = only(TO_1); + System.out.println(TAG + "default charset " + charset + ", delivered subject: " + message.getSubject()); + if (charset.equals(java.nio.charset.StandardCharsets.UTF_8)) { + assertEquals(subject, message.getSubject()); + } else { + assertEquals(subject.chars().mapToObj(c -> c < 128 ? String.valueOf((char) c) : "?").collect(java.util.stream.Collectors.joining()), message.getSubject()); + } + } + + @Test + public void wrongPasswordAndUnknownUserGiveAnErrorResultNotAnException() throws Exception { + QueryExecutionResult wrong = send(configMap(server, LOGIN, "wrong"), query("[\"" + TO_1 + "\"]", "bad password", "x")); + assertEquals("QUERY_EXECUTION_ERROR", wrong.getQueryCode()); + assertEquals("QUERY_EXECUTION_ERROR", wrong.getMessageKey()); + System.out.println(TAG + "wrong password message: " + wrong.getMessageArgs()[0]); + QueryExecutionResult unknown = send(configMap(server, "nobody", PASSWORD), query("[\"" + TO_1 + "\"]", "bad user", "x")); + assertEquals("QUERY_EXECUTION_ERROR", unknown.getQueryCode()); + assertEquals(0, server.getReceivedMessages().length); + } + + @Test + public void stoppedServerGivesAnErrorResult() { + GreenMail gone = SmtpGreenMailSupport.startAuthenticating(); + Map config = configMap(gone, LOGIN, PASSWORD); + gone.stop(); + QueryExecutionResult result = send(config, query("[\"" + TO_1 + "\"]", "no server", "x")); + assertEquals("QUERY_EXECUTION_ERROR", result.getQueryCode()); + System.out.println(TAG + "stopped server message: " + result.getMessageArgs()[0]); + } + + /** Observation: a message with no recipient is refused by the mail library before anything is sent; the result is an error result, nothing is delivered. */ + @Test + public void noRecipientGivesAnErrorResult() { + QueryExecutionResult result = sendAuthenticated(query("", "no recipient", "x")); + System.out.println(TAG + "no recipient: " + result.getQueryCode() + " " + (result.getMessageArgs() == null ? null : result.getMessageArgs()[0])); + assertEquals("QUERY_EXECUTION_ERROR", result.getQueryCode()); + assertEquals(0, server.getReceivedMessages().length); + } +} diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpEngineOpenRelayTest.java b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpEngineOpenRelayTest.java new file mode 100644 index 0000000000..d222c8c46b --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpEngineOpenRelayTest.java @@ -0,0 +1,95 @@ +package org.lowcoder.plugins; + +import com.icegreen.greenmail.util.GreenMail; +import jakarta.mail.Session; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugins.SmtpPlugin.SmtpEngine; +import org.lowcoder.sdk.models.QueryExecutionResult; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugins.SmtpGreenMailSupport.configMap; + +/** + * Unit SM-1 / SM-2 (task L5-10), the open relay and defect D19: against a server that does not ask for authentication, a config + * without a user, with an empty user and with a user. + * + *

    Limits: GreenMail's disabled-authentication mode; a real relay may answer differently to an AUTH attempt. + */ +public class SmtpEngineOpenRelayTest { + + static final String TAG = "[SmtpEngineOpenRelayTest] "; + static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(30); + static final long WAIT_MILLIS = 10_000; + + private static GreenMail server; + private final SmtpEngine engine = new SmtpEngine(); + + @BeforeAll + static void start() { + server = SmtpGreenMailSupport.startOpenRelay(); + } + + @AfterAll + static void stop() { + server.stop(); + } + + private QueryExecutionResult send(Map config) { + var datasource = engine.resolveConfig(config); + Session session = engine.createConnection(datasource).block(BLOCK_TIMEOUT); + Map query = new HashMap<>(); + query.put("from", "sender@example.com"); + query.put("to", "[\"rcpt@example.com\"]"); + query.put("subject", "open relay"); + query.put("content", "x"); + QueryExecutionResult result = engine.executeQuery(session, engine.buildQueryExecutionContext(datasource, query, Map.of(), null)).block(BLOCK_TIMEOUT); + System.out.println(TAG + "send " + config.keySet() + " -> " + result.getQueryCode() + " " + (result.getMessageArgs() == null ? null : result.getMessageArgs()[0])); + return result; + } + + /** + * Pins defect D19 (analysis-plugins section 0.6; plan section 9 D1-D20 row), as it behaves: {@code createConnection} puts the + * user name into {@code java.util.Properties} ({@code prop.put("mail.smtp.username", username)}), which refuses a null value, so a + * datasource saved without a user name fails with a raw {@code NullPointerException} before any server is contacted: not only is + * {@code mail.smtp.auth=true} always set, the no-user branch of {@code testConnection} ({@code session.getTransport().connect()}) + * and the open-relay case are unreachable. The analysis row words it differently (the client sends AUTH with a null user, or the + * test connects without credentials); that is not what happens. The form leaves the user name optional + * (client form.tsx:95-104, smtpDatasourceForm.tsx:26), so a user can save such a datasource. A fix (guarding the property) changes + * these assertions on purpose. + */ + @Test + public void aConfigWithoutAUserFailsWithANullPointerExceptionBeforeAnyServerIsContacted_pinsD19() { + Map config = configMap(server, null, null); + NullPointerException create = assertThrows(NullPointerException.class, () -> engine.createConnection(engine.resolveConfig(config))); + System.out.println(TAG + "createConnection without a user: " + create); + assertThrows(NullPointerException.class, () -> engine.testConnection(engine.resolveConfig(config)).block(BLOCK_TIMEOUT)); + assertEquals(0, server.getReceivedMessages().length); + } + + /** Observation: with an empty user name the connection is built (auth on) and a send through the open relay works. */ + @Test + public void emptyUserNameSendsThroughTheOpenRelay() throws Exception { + Map config = configMap(server, "", ""); + Session session = engine.createConnection(engine.resolveConfig(config)).block(BLOCK_TIMEOUT); + assertEquals(true, session.getProperties().get("mail.smtp.auth")); + QueryExecutionResult result = send(config); + System.out.println(TAG + "empty user: " + result.getQueryCode()); + assertEquals("OK", result.getQueryCode()); + assertTrue(server.waitForIncomingEmail(WAIT_MILLIS, 1)); + } + + @Test + public void configWithAUserAlsoWorksAgainstTheOpenRelay() { + QueryExecutionResult result = send(configMap(server, "anyone", "anything")); + System.out.println(TAG + "with a user: " + result.getQueryCode()); + assertEquals("OK", result.getQueryCode()); + } +} diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpGreenMailSupport.java b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpGreenMailSupport.java new file mode 100644 index 0000000000..b43a3ec81d --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpGreenMailSupport.java @@ -0,0 +1,73 @@ +package org.lowcoder.plugins; + +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.user.GreenMailUser; +import com.icegreen.greenmail.util.GreenMail; +import com.icegreen.greenmail.util.ServerSetup; +import jakarta.mail.internet.MimeMessage; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The in-process GreenMail SMTP servers of the L5-10 tests and the helpers around them. Not a container: GreenMail runs inside + * the test JVM. The server binds port 0, so the operating system assigns a free port, and the test reads the bound port back + * ({@code getSmtp().getPort()}; probed in GreenMail 2.0.1, which reports the real port): no fixed port, no port race. Not a test. + */ +final class SmtpGreenMailSupport { + + static final String LOOPBACK = "127.0.0.1"; + static final String LOGIN = "mailer"; + static final String PASSWORD = "mailer-pw"; + static final String LOGIN_EMAIL = "mailer@example.com"; + + private SmtpGreenMailSupport() { + } + + /** A started server that wants the login {@link #LOGIN} with {@link #PASSWORD} (SMTP AUTH is checked). */ + static GreenMail startAuthenticating() { + GreenMail server = new GreenMail(new ServerSetup(0, LOOPBACK, ServerSetup.PROTOCOL_SMTP)); + server.withConfiguration(GreenMailConfiguration.aConfig().withUser(LOGIN_EMAIL, LOGIN, PASSWORD)); + server.start(); + System.out.println("[SmtpGreenMailSupport] authenticating server on " + LOOPBACK + ":" + server.getSmtp().getPort()); + return server; + } + + /** A started open relay: authentication is disabled. */ + static GreenMail startOpenRelay() { + GreenMail server = new GreenMail(new ServerSetup(0, LOOPBACK, ServerSetup.PROTOCOL_SMTP)); + server.withConfiguration(GreenMailConfiguration.aConfig().withDisabledAuthentication()); + server.start(); + System.out.println("[SmtpGreenMailSupport] open relay on " + LOOPBACK + ":" + server.getSmtp().getPort()); + return server; + } + + /** The datasource config map of a server, with the credentials given (null: absent). */ + static Map configMap(GreenMail server, String username, String password) { + Map values = new HashMap<>(); + values.put("host", LOOPBACK); + values.put("port", server.getSmtp().getPort()); + if (username != null) { + values.put("username", username); + } + if (password != null) { + values.put("password", password); + } + return values; + } + + /** The messages in the inbox of {@code email} (the server creates a mailbox for each recipient it delivers to). */ + static List inboxOf(GreenMail server, String email) throws Exception { + GreenMailUser user = server.getManagers().getUserManager().getUserByEmail(email); + List messages = new ArrayList<>(); + if (user == null) { + return messages; + } + for (var stored : server.getManagers().getImapHostManager().getInbox(user).getMessages()) { + messages.add(stored.getMimeMessage()); + } + return messages; + } +} diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpListsContractTest.java b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpListsContractTest.java new file mode 100644 index 0000000000..92d0881b20 --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpListsContractTest.java @@ -0,0 +1,90 @@ +package org.lowcoder.plugins; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugins.SmtpPlugin.SmtpEngine; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.FieldTree; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Group {@code smtp-lists} (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.3): {@code SmtpEngine.buildQueryExecutionContext} + * renders the address lists ({@code to}, {@code cc}, {@code bcc}, {@code replyTo}) as JSON arrays of strings and the + * attachments as a JSON array of {@code Attachment} records, then reads both with {@code JsonUtils.fromJsonList}. Each + * case of {@link #CASES} is one query config rendered with {@link #PARAMS}; the report pins the built context's fields + * ({@link FieldTree}: the addresses by their text, the attachments by their components) or the error. Pinned in + * {@value #REPORT}. + * + *

    Limits: no mail is sent; {@code fromJsonList} logs and returns {@code null} for text it cannot read, which the + * engine treats as no addresses or no attachments, and the report shows that as an empty array or {@code null}. + */ +public class SmtpListsContractTest { + + static final String REPORT = "smtp-lists/SmtpEngine.buildQueryExecutionContext.json"; + static final String FROM = "sender@example.com"; + static final Map PARAMS = params(); + static final Map> CASES = cases(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final SmtpEngine engine = new SmtpEngine(); + + @BoundarySites({ + "lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.buildQueryExecutionContext#fromJsonList#1", + "lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.renderAsArrayAndParse#fromJsonList#1"}) + @Test + public void listsAsPinned() { + Map report = new LinkedHashMap<>(); + CASES.forEach((name, queryConfig) -> { + try { + report.put(name, FieldTree.of(engine.buildQueryExecutionContext(null, queryConfig, PARAMS, null)).get(FieldTree.VALUES_KEY)); + } catch (RuntimeException e) { + report.put(name, Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e))); + } + }); + String actual = ConfigBinding.write(report); + System.out.println("[SmtpListsContractTest]\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + private static Map params() { + Map params = new LinkedHashMap<>(); + params.put("to", "žluť "); + params.put("list", List.of("a@example.com", "b@example.com, c@example.com")); + params.put("name", "report \"2026\".csv"); + params.put("attachments", List.of(Map.of("name", "a.txt", "contentType", "text/plain", "content", "aGk="))); + params.put("count", 3_000_000_001L); + return params; + } + + private static Map config(String to, String cc, String attachments) { + Map config = new LinkedHashMap<>(); + config.put("from", FROM); + config.put("to", to); + config.put("cc", cc); + config.put("subject", "s"); + config.put("attachments", attachments); + return config; + } + + private static Map> cases() { + Map> cases = new LinkedHashMap<>(); + cases.put("arrays", config("[\"x@example.com\", \"{{to}}\"]", "{{list}}", + "[{\"name\": \"{{name}}\", \"contentType\": \"text/csv\", \"content\": \"YSxi\", \"extra\": 1}]")); + cases.put("attachmentWithoutExtraProperty", config("[\"x@example.com\"]", "", + "[{\"name\": \"{{name}}\", \"contentType\": \"text/csv\", \"content\": \"YSxi\"}]")); + cases.put("attachmentsParameter", config("[\"x@example.com\"]", "", "{{attachments}}")); + cases.put("singleAddressNotArray", config("x@example.com", "", "")); + cases.put("arrayWithNumber", config("[\"x@example.com\", {{count}}]", "", "")); + cases.put("attachmentNotObject", config("[\"x@example.com\"]", "", "[\"a.txt\"]")); + cases.put("attachmentNumberContent", config("[\"x@example.com\"]", "", "[{\"name\": \"n\", \"content\": {{count}}}]")); + cases.put("invalidAddress", config("[\"not an address@@\"]", "", "")); + cases.put("arrayWithNull", config("[\"x@example.com\", null]", "", "")); + return cases; + } +} diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpResultContractTest.java b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpResultContractTest.java new file mode 100644 index 0000000000..f8ec7acecc --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpResultContractTest.java @@ -0,0 +1,28 @@ +package org.lowcoder.plugins; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.models.QueryExecutionResult; + +/** + * The SMTP row of the §4.6 producer table (docs/API_PAYLOAD_TEST_PLAN.md §4.6, task T8.2): a sent mail is + * {@code QueryExecutionResult.success(null)} ({@code SmtpPlugin.java:217}), so the result has no {@code data} + * member at all ({@code @JsonInclude(NON_NULL)}). Pinned in {@value #REPORT}. + * + *

    Limits: no mail is sent; the test builds the value the anchored line builds. + */ +public class SmtpResultContractTest { + + static final String REPORT = "query-results/SmtpPlugin.result.json"; + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @Test + public void sentMailResultAsPinned() { + String actual = ConfigBinding.write(QueryResults.report(QueryExecutionResult.success(null))); + System.out.println("[SmtpResultContractTest]\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } +} diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpTreeTextContractTest.java b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpTreeTextContractTest.java new file mode 100644 index 0000000000..8e1c1457f2 --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/java/org/lowcoder/plugins/SmtpTreeTextContractTest.java @@ -0,0 +1,91 @@ +package org.lowcoder.plugins; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugins.SmtpPlugin.SmtpEngine; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.util.MustacheHelper; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Group {@code tree-text}, its {@code smtpPlugin} rows (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.4): + * {@code SmtpEngine.buildQueryExecutionContext} renders the attachments with {@code renderMustacheJsonString} and + * the address lists with {@code renderMustacheArrayJsonString} ({@code SmtpPlugin.java:174}, {@code :190}), whose + * text is {@code JsonNode#toString} (E13), then reads that text with {@code fromJsonList}. For each template of + * {@link #TEMPLATES} the line pins the text those helpers give for it with {@link #PARAMS}, and what the engine built + * from the same template as {@code to} and as {@code attachments}, in {@value #REPORT}. + * + *

    Limits: the rendered text is internal to the engine; the test renders it with the same helper the engine calls, + * and the engine's result shows it was read as rendered. + */ +public class SmtpTreeTextContractTest { + + static final String REPORT = "tree-text/SmtpEngine.renderedLists.txt"; + static final String SEPARATOR = "\t"; + static final String NEWLINE = "\n"; + static final Map PARAMS = params(); + static final Map TEMPLATES = templates(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + private final SmtpEngine engine = new SmtpEngine(); + + @BoundarySites({ + "lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.buildQueryExecutionContext#renderMustacheJsonString#1", + "lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.renderAsArrayAndParse#renderMustacheArrayJsonString#1"}) + @Test + public void renderedListsAsRead() { + StringBuilder text = new StringBuilder(); + TEMPLATES.forEach((name, template) -> text.append(name) + .append(SEPARATOR).append(MustacheHelper.renderMustacheArrayJsonString(template, PARAMS)) + .append(SEPARATOR).append(MustacheHelper.renderMustacheJsonString(template, PARAMS)) + .append(SEPARATOR).append(built(template)) + .append(NEWLINE)); + System.out.println("[SmtpTreeTextContractTest]\n" + text); + GOLDEN.assertText(REPORT, text.toString()); + } + + /** {@code to= attachments=} the engine built with {@code template} as both, or the error. */ + private String built(String template) { + Map queryConfig = new LinkedHashMap<>(); + queryConfig.put("from", "sender@example.com"); + queryConfig.put("to", template); + queryConfig.put("subject", "s"); + queryConfig.put("attachments", template); + try { + SmtpQueryExecutionContext context = engine.buildQueryExecutionContext(null, queryConfig, PARAMS, null); + return "to=" + Arrays.stream(context.getTo()).map(Object::toString).collect(Collectors.toList()) + + " attachments=" + context.getAttachments(); + } catch (RuntimeException e) { + return "ERROR " + ConfigBinding.errorText(e); + } + } + + private static Map params() { + Map params = new LinkedHashMap<>(); + params.put("to", "žluť "); + params.put("list", List.of("a@example.com", "b@example.com")); + Map attachment = new LinkedHashMap<>(); + attachment.put("name", "a \"1\".txt"); + attachment.put("contentType", "text/plain"); + attachment.put("content", "aGk="); + params.put("attachments", List.of(attachment)); + return params; + } + + private static Map templates() { + Map templates = new LinkedHashMap<>(); + templates.put("addressArray", "[\"x@example.com\", \"{{to}}\"]"); + templates.put("listParameter", "{{list}}"); + templates.put("attachmentArray", "[{\"name\": \"r\\u00e9sum\\u00e9.pdf\", \"contentType\": \"application/pdf\", \"content\": \"JVBERi0=\"}]"); + templates.put("attachmentsParameter", "{{attachments}}"); + templates.put("blank", ""); + return templates; + } +} diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/config-binding/SmtpDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/config-binding/SmtpDatasourceConfig.input.json new file mode 100644 index 0000000000..8bffb1bc14 --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/config-binding/SmtpDatasourceConfig.input.json @@ -0,0 +1,21 @@ +{ + "full": { + "host": "smtp.example.com", + "port": 587, + "username": "mailer@example.com", + "password": "pw" + }, + "scalarsAsStrings": { + "host": "smtp.example.com", + "port": "465" + }, + "decimalPort": { + "host": "smtp.example.com", + "port": 25.0 + }, + "longPort": { + "host": "smtp.example.com", + "port": 4294967296 + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/config-binding/SmtpDatasourceConfig.json b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/config-binding/SmtpDatasourceConfig.json new file mode 100644 index 0000000000..c4b06e52d4 --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/config-binding/SmtpDatasourceConfig.json @@ -0,0 +1,88 @@ +{ + "SmtpEngine.resolveConfig" : { + "full" : { + "class" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "host" : "smtp.example.com", + "port" : 587, + "username" : "mailer@example.com", + "password" : "pw" + }, + "classes" : { + "" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "/host" : "java.lang.String", + "/port" : "java.lang.Integer", + "/username" : "java.lang.String", + "/password" : "java.lang.String" + }, + "noView" : {"host":"smtp.example.com","port":587,"username":"mailer@example.com","password":"pw"}, + "Public" : {"host":"smtp.example.com","port":587,"username":"mailer@example.com"}, + "Internal" : {"host":"smtp.example.com","port":587,"username":"mailer@example.com","password":"pw"} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "host" : "smtp.example.com", + "port" : 465, + "username" : null, + "password" : null + }, + "classes" : { + "" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "/host" : "java.lang.String", + "/port" : "java.lang.Integer", + "/username" : "null", + "/password" : "null" + }, + "noView" : {"host":"smtp.example.com","port":465,"username":null,"password":null}, + "Public" : {"host":"smtp.example.com","port":465,"username":null}, + "Internal" : {"host":"smtp.example.com","port":465,"username":null,"password":null} + }, + "decimalPort" : { + "class" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "host" : "smtp.example.com", + "port" : 25, + "username" : null, + "password" : null + }, + "classes" : { + "" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "/host" : "java.lang.String", + "/port" : "java.lang.Integer", + "/username" : "null", + "/password" : "null" + }, + "noView" : {"host":"smtp.example.com","port":25,"username":null,"password":null}, + "Public" : {"host":"smtp.example.com","port":25,"username":null}, + "Internal" : {"host":"smtp.example.com","port":25,"username":null,"password":null} + }, + "longPort" : { + "error" : "org.lowcoder.sdk.exception.PluginException: DATASOURCE_ARGUMENT_ERROR INVALID_SMTP_CONFIG" + }, + "empty" : { + "class" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "host" : null, + "port" : 0, + "username" : null, + "password" : null + }, + "classes" : { + "" : "org.lowcoder.plugins.SmtpDatasourceConfig", + "/host" : "null", + "/port" : "java.lang.Integer", + "/username" : "null", + "/password" : "null" + }, + "noView" : {"host":null,"port":0,"username":null,"password":null}, + "Public" : {"host":null,"port":0,"username":null}, + "Internal" : {"host":null,"port":0,"username":null,"password":null} + }, + "unknownProperty" : "ignored: binds as form full" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/query-results/SmtpPlugin.result.json b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/query-results/SmtpPlugin.result.json new file mode 100644 index 0000000000..cab47ca63b --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/query-results/SmtpPlugin.result.json @@ -0,0 +1,10 @@ +{ + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "null" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","success":true} +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/smtp-lists/SmtpEngine.buildQueryExecutionContext.json b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/smtp-lists/SmtpEngine.buildQueryExecutionContext.json new file mode 100644 index 0000000000..3d9c350dbc --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/smtp-lists/SmtpEngine.buildQueryExecutionContext.json @@ -0,0 +1,112 @@ +{ + "arrays" : { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext", + "from" : "sender@example.com", + "to" : [ "x@example.com", "\"žluť\" " ], + "cc" : [ "a@example.com", "b@example.com", "c@example.com" ], + "bcc" : [ ], + "replyTo" : [ ], + "subject" : "s", + "content" : "", + "attachments" : [ { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext$Attachment", + "name" : "report \"2026\".csv", + "contentType" : "text/csv", + "content" : "YSxi" + } ], + "visitorId" : null + }, + "attachmentWithoutExtraProperty" : { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext", + "from" : "sender@example.com", + "to" : [ "x@example.com" ], + "cc" : [ ], + "bcc" : [ ], + "replyTo" : [ ], + "subject" : "s", + "content" : "", + "attachments" : [ { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext$Attachment", + "name" : "report \"2026\".csv", + "contentType" : "text/csv", + "content" : "YSxi" + } ], + "visitorId" : null + }, + "attachmentsParameter" : { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext", + "from" : "sender@example.com", + "to" : [ "x@example.com" ], + "cc" : [ ], + "bcc" : [ ], + "replyTo" : [ ], + "subject" : "s", + "content" : "", + "attachments" : [ { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext$Attachment", + "name" : "a.txt", + "contentType" : "text/plain", + "content" : "aGk=" + } ], + "visitorId" : null + }, + "singleAddressNotArray" : { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext", + "from" : "sender@example.com", + "to" : [ ], + "cc" : [ ], + "bcc" : [ ], + "replyTo" : [ ], + "subject" : "s", + "content" : "", + "attachments" : null, + "visitorId" : null + }, + "arrayWithNumber" : { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext", + "from" : "sender@example.com", + "to" : [ "x@example.com", "3000000001" ], + "cc" : [ ], + "bcc" : [ ], + "replyTo" : [ ], + "subject" : "s", + "content" : "", + "attachments" : null, + "visitorId" : null + }, + "attachmentNotObject" : { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext", + "from" : "sender@example.com", + "to" : [ "x@example.com" ], + "cc" : [ ], + "bcc" : [ ], + "replyTo" : [ ], + "subject" : "s", + "content" : "", + "attachments" : null, + "visitorId" : null + }, + "attachmentNumberContent" : { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext", + "from" : "sender@example.com", + "to" : [ "x@example.com" ], + "cc" : [ ], + "bcc" : [ ], + "replyTo" : [ ], + "subject" : "s", + "content" : "", + "attachments" : [ { + "@class" : "org.lowcoder.plugins.SmtpQueryExecutionContext$Attachment", + "name" : "n", + "contentType" : null, + "content" : "3000000001" + } ], + "visitorId" : null + }, + "invalidAddress" : { + "error" : "org.lowcoder.sdk.exception.PluginException: QUERY_ARGUMENT_ERROR QUERY_ARGUMENT_ERROR" + }, + "arrayWithNull" : { + "error" : "java.lang.NullPointerException: Cannot invoke \"String.length()\" because \"s\" is null" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/tree-text/SmtpEngine.renderedLists.txt b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/tree-text/SmtpEngine.renderedLists.txt new file mode 100644 index 0000000000..631ad749b4 --- /dev/null +++ b/server/api-service/lowcoder-plugins/smtpPlugin/src/test/resources/json-contract/tree-text/SmtpEngine.renderedLists.txt @@ -0,0 +1,5 @@ +addressArray ["x@example.com","žluť "] ["x@example.com","žluť "] to=[x@example.com, "žluť" ] attachments=null +listParameter ["a@example.com","b@example.com"] ["a@example.com","b@example.com"] to=[a@example.com, b@example.com] attachments=null +attachmentArray [{"name":"résumé.pdf","contentType":"application/pdf","content":"JVBERi0="}] [{"name":"résumé.pdf","contentType":"application/pdf","content":"JVBERi0="}] to=[] attachments=[Attachment[name=résumé.pdf, contentType=application/pdf, content=JVBERi0=]] +attachmentsParameter [{"name":"a \"1\".txt","contentType":"text/plain","content":"aGk="}] [{"name":"a \"1\".txt","contentType":"text/plain","content":"aGk="}] to=[] attachments=[Attachment[name=a "1".txt, contentType=text/plain, content=aGk=]] +blank [] {} to=[] attachments=null diff --git a/server/api-service/lowcoder-plugins/snowflakePlugin/pom.xml b/server/api-service/lowcoder-plugins/snowflakePlugin/pom.xml index 9b114063b7..767cecdc9c 100644 --- a/server/api-service/lowcoder-plugins/snowflakePlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/snowflakePlugin/pom.xml @@ -38,10 +38,10 @@ compile + - org.testcontainers - testcontainers - 1.18.0 + com.h2database + h2 test diff --git a/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeConfigBindingContractTest.java new file mode 100644 index 0000000000..c68dd63138 --- /dev/null +++ b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeConfigBindingContractTest.java @@ -0,0 +1,29 @@ +package org.lowcoder.plugin.snowflake; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code snowflakePlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1), + * through the real entry points: {@code SnowflakeConnector.resolveConfig}, the SDK default that binds {@code + * SnowflakeDatasourceConfig}, which has no creator annotation (C8-A7). {@code ConfigBinding} applies each entry + * point to every form of the input fixture (canonical names, alias or getter-derived names, scalars as strings, + * missing and empty values) and pins the bound state, the written views and the errors in the report fixture. + */ +public class SnowflakeConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#toJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#fromJson#1"}) + @Test + public void datasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/SnowflakeDatasourceConfig.input.json", "config-binding/SnowflakeDatasourceConfig.json", + Map.of("SnowflakeConnector.resolveConfig", new SnowflakeConnector()::resolveConfig)); + } +} diff --git a/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeConnectorConfigTest.java b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeConnectorConfigTest.java new file mode 100644 index 0000000000..532cefad5a --- /dev/null +++ b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeConnectorConfigTest.java @@ -0,0 +1,96 @@ +package org.lowcoder.plugin.snowflake; + +import com.zaxxer.hikari.HikariConfig; +import org.junit.jupiter.api.Test; + +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit SN-2 (task L5-7): what {@link SnowflakeConnector#setUpConfigs} puts on the Hikari config: the JDBC URL of the account + * and the {@code db}, {@code user} and {@code password} data-source properties, without a server. + * + *

    Limits: the URL is compared as text; the Snowflake driver is not asked to parse it (there is no Snowflake account in the + * tests). The data source form that produces the config is in the client, not here. + */ +public class SnowflakeConnectorConfigTest { + + static final String ACCOUNT = "xy-12345"; + static final String SUFFIX = ".snowflakecomputing.com"; + static final String DATABASE = "analytics"; + static final String USER = "loader"; + static final String PASSWORD = "secret"; + static final String DRIVER = "net.snowflake.client.jdbc.SnowflakeDriver"; + static final String URL_PREFIX = "jdbc:snowflake://"; + + private final SnowflakeConnector connector = new SnowflakeConnector(); + + private HikariConfig configured(String host, String database, String username, String password) { + SnowflakeDatasourceConfig datasource = SnowflakeDatasourceConfig.builder().host(host).database(database).username(username).password(password).build(); + HikariConfig config = new HikariConfig(); + connector.setUpConfigs(datasource, config); + System.out.println("[SnowflakeConnectorConfigTest] url " + config.getJdbcUrl() + ", properties " + describe(config.getDataSourceProperties())); + return config; + } + + private static String describe(Properties properties) { + return new java.util.TreeMap<>(properties).toString(); + } + + @Test + public void urlIsTheAccountHostAndPropertiesCarryDatabaseUserAndPassword() { + HikariConfig config = configured(ACCOUNT, DATABASE, USER, PASSWORD); + assertEquals(URL_PREFIX + ACCOUNT + SUFFIX + "/", config.getJdbcUrl()); + Properties properties = config.getDataSourceProperties(); + assertEquals(DATABASE, properties.get("db")); + assertEquals(USER, properties.get("user")); + assertEquals(PASSWORD, properties.get("password")); + assertEquals(3, properties.size(), "only db, user and password are set"); + assertEquals(DRIVER, connector.getJdbcDriver()); + } + + @Test + public void hostDatabaseAndUserAreTrimmedByTheConfigAndThePasswordIsNot() { + HikariConfig config = configured(" " + ACCOUNT + " ", " " + DATABASE + " ", " " + USER + " ", " " + PASSWORD + " "); + assertEquals(URL_PREFIX + ACCOUNT + SUFFIX + "/", config.getJdbcUrl()); + assertEquals(DATABASE, config.getDataSourceProperties().get("db")); + assertEquals(USER, config.getDataSourceProperties().get("user")); + assertEquals(" " + PASSWORD + " ", config.getDataSourceProperties().get("password")); + } + + @Test + public void blankDatabaseAndUserAreSetAsEmptyStrings() { + HikariConfig config = configured(ACCOUNT, null, null, PASSWORD); + assertEquals("", config.getDataSourceProperties().get("db")); + assertEquals("", config.getDataSourceProperties().get("user")); + } + + /** + * Shows what the account host is used for: it is put in front of the suffix as given. An account identifier that already + * ends in the suffix (the form's help text shows the full host name) gets it twice, and a value with a slash gives a URL + * with a path inside the host part. Asserted as today's text; reported to the coordinator, no defect claimed here. + */ + @Test + public void hostIsUsedAsGivenSoASuffixOrASlashEndsUpInTheUrl() { + assertEquals(URL_PREFIX + ACCOUNT + SUFFIX + SUFFIX + "/", configured(ACCOUNT + SUFFIX, DATABASE, USER, PASSWORD).getJdbcUrl(), "the suffix is added twice"); + assertEquals(URL_PREFIX + "xy/12345" + SUFFIX + "/", configured("xy/12345", DATABASE, USER, PASSWORD).getJdbcUrl(), "a slash stays in the host part"); + assertEquals(URL_PREFIX + SUFFIX + "/", configured(null, DATABASE, USER, PASSWORD).getJdbcUrl(), "no host gives an empty account"); + } + + /** + * Pins the plan section 9 row "Snowflake connector NPE on a null password (addDataSourceProperty)" (D-6: fix deferred): + * the config returns the password as stored, and a data source saved without one (the form's password field is not + * required, pages/datasource/form.tsx in the client) has {@code null}; {@code HikariConfig.addDataSourceProperty} puts it + * into a {@code Properties}, which rejects null values, so setting up the pool fails with a bare + * {@link NullPointerException}. A fix (skipping a null password, as the other connectors do) changes this test on purpose. + */ + @Test + public void nullPasswordFailsWithANullPointerException_pinsTheSection9Row() { + NullPointerException thrown = assertThrows(NullPointerException.class, () -> configured(ACCOUNT, DATABASE, USER, null)); + System.out.println("[SnowflakeConnectorConfigTest] null password: " + thrown + " at " + thrown.getStackTrace()[0] + " / " + thrown.getStackTrace()[1]); + assertEquals("java.util.concurrent.ConcurrentHashMap", thrown.getStackTrace()[0].getClassName(), "the Properties map of the Hikari config rejects the null value"); + assertEquals("", configured(ACCOUNT, DATABASE, USER, "").getDataSourceProperties().get("password"), "an empty password is accepted"); + } +} diff --git a/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeH2Support.java b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeH2Support.java new file mode 100644 index 0000000000..c7fd968b6e --- /dev/null +++ b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeH2Support.java @@ -0,0 +1,81 @@ +package org.lowcoder.plugin.snowflake; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Test support for the Snowflake executor unit of task L5-7: an in-memory H2 database (one per instance, named with a + * counter) behind a started Hikari pool wrapped in {@link HikariPerfWrapper}, which is all the executor needs from a data + * source ({@code SqlBasedQueryExecutor} only asks the wrapper for its pool). Snowflake itself is a hosted service and has no + * container, so H2 stands in for the SQL dialect and for {@code INFORMATION_SCHEMA}. Not a test. + */ +final class SnowflakeH2Support implements AutoCloseable { + + private static final AtomicInteger COUNTER = new AtomicInteger(); + private static final int POOL_SIZE = 2; + + private final HikariDataSource dataSource; + final HikariPerfWrapper pool; + + SnowflakeH2Support() { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl("jdbc:h2:mem:l57_" + COUNTER.incrementAndGet() + ";MODE=Regular;DB_CLOSE_DELAY=-1"); + config.setMaximumPoolSize(POOL_SIZE); + this.dataSource = new HikariDataSource(config); + this.pool = HikariPerfWrapper.wrap(dataSource, () -> 0, () -> 0, () -> 0, () -> 0, null, null); + } + + /** A plain connection of the same database; the caller closes it. */ + Connection connection() { + try { + return dataSource.getConnection(); + } catch (SQLException e) { + throw new IllegalStateException("cannot connect", e); + } + } + + /** Runs the statements on a plain connection of the same database. */ + void execute(String... statements) { + try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } catch (SQLException e) { + throw new IllegalStateException("statement failed", e); + } + } + + List> rows(String sql) { + List> rows = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) { + ResultSetMetaData metaData = resultSet.getMetaData(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= metaData.getColumnCount(); i++) { + row.put(metaData.getColumnLabel(i), resultSet.getObject(i)); + } + rows.add(row); + } + } catch (SQLException e) { + throw new IllegalStateException("read failed: " + sql, e); + } + return rows; + } + + @Override + public void close() { + dataSource.close(); + } +} diff --git a/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeQueryExecutorTest.java b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeQueryExecutorTest.java new file mode 100644 index 0000000000..4e64bccc8b --- /dev/null +++ b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/java/org/lowcoder/plugin/snowflake/SnowflakeQueryExecutorTest.java @@ -0,0 +1,207 @@ +package org.lowcoder.plugin.snowflake; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.DatasourceStructure.Column; +import org.lowcoder.sdk.models.DatasourceStructure.Table; +import org.lowcoder.sdk.models.DatasourceStructure.TableType; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.PluginCommonError.CONNECTION_ERROR; +import static org.lowcoder.sdk.exception.PluginCommonError.DATASOURCE_GET_STRUCTURE_ERROR; + +/** + * Unit SN-1 (task L5-7): {@link SnowflakeQueryExecutor} over an in-memory H2 database ({@link SnowflakeH2Support}): the forced + * non-prepared execution, the structure query and its schema filter, and the GUI-mode refusal. Snowflake is a hosted service, + * so H2 stands in for the SQL dialect and for {@code INFORMATION_SCHEMA} ({@code COLUMNS_QUERY} runs on it unchanged). + * + *

    Limits: H2's {@code INFORMATION_SCHEMA} also lists its own system tables (asserted where the schema filter is absent); + * what Snowflake returns for its account is not tested. + */ +public class SnowflakeQueryExecutorTest { + + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final String SCHEMA_KEY = "schema"; + static final String PUBLIC_SCHEMA = "PUBLIC"; + static final String OTHER_SCHEMA = "SALES"; + static final String INJECTION_SCHEMA = "PUBLIC' or '1'='1"; + + private final SnowflakeQueryExecutor executor = new SnowflakeQueryExecutor(); + + private static SnowflakeDatasourceConfig config(Map extParams) { + return SnowflakeDatasourceConfig.builder().host("account").database("db").username("u").password("p").extParams(extParams).build(); + } + + private static Map sqlConfig(String sql, Boolean disablePreparedStatement) { + Map config = new LinkedHashMap<>(); + config.put("mode", "SQL"); + config.put("sql", sql); + if (disablePreparedStatement != null) { + config.put("disablePreparedStatement", disablePreparedStatement); + } + return config; + } + + private Object run(SnowflakeH2Support h2, String sql, Boolean disablePreparedStatement, Map params) { + SnowflakeDatasourceConfig config = config(Map.of()); + SqlBasedQueryExecutionContext context = executor.buildQueryExecutionContext(config, sqlConfig(sql, disablePreparedStatement), params, null); + QueryExecutionResult result = executor.executeQuery(h2.pool, context).block(TIMEOUT); + assertNotNull(result); + System.out.println("[SnowflakeQueryExecutorTest] " + sql + " " + params + " -> " + result.getData()); + return result.getData(); + } + + private Set structureNames(SnowflakeH2Support h2, Map extParams) { + DatasourceStructure structure = executor.getStructure(h2.pool, config(extParams)).block(TIMEOUT); + Set names = structure.getTables().stream().map(Table::getName).collect(Collectors.toSet()); + System.out.println("[SnowflakeQueryExecutorTest] ext params " + extParams + " -> " + names.stream().sorted().limit(8).toList() + " (" + names.size() + " tables)"); + return names; + } + + private static void createTables(SnowflakeH2Support h2) { + h2.execute("create schema if not exists " + OTHER_SCHEMA, + "create table public.orders (id int, note varchar(20), created timestamp)", + "create table " + OTHER_SCHEMA + ".customers (name varchar(20), id int)"); + } + + // ---- non-prepared execution + + @Test + public void preparedStatementsAreAlwaysDisabledWhateverTheQueryConfigSays() { + SnowflakeDatasourceConfig config = SnowflakeDatasourceConfig.builder().host("a").database("d").enableTurnOffPreparedStatement(true).build(); + for (Boolean flag : new Boolean[] {Boolean.FALSE, Boolean.TRUE, null}) { + SqlBasedQueryExecutionContext context = executor.buildQueryExecutionContext(config, sqlConfig("select 1", flag), Map.of(), null); + System.out.println("[SnowflakeQueryExecutorTest] query config flag " + flag + " -> disablePreparedStatement " + context.isDisablePreparedStatement()); + assertTrue(context.isDisablePreparedStatement(), "flag " + flag); + assertEquals("select 1", context.getQuery()); + } + } + + @Test + public void placeholdersAreRenderedIntoTheSqlAndAnInsertReturnsNoGeneratedKeys() { + try (SnowflakeH2Support h2 = new SnowflakeH2Support()) { + h2.execute("create table t (id int auto_increment primary key, name varchar(30))"); + @SuppressWarnings("unchecked") + List> rows = (List>) run(h2, "select {{n}} + 1 as r, '{{s}}' as s from dual", false, Map.of("n", 41, "s", "abc")); + assertEquals(1, rows.size()); + assertEquals(42, ((Number) rows.get(0).get("R")).intValue()); + assertEquals("abc", rows.get(0).get("S"), "the value is rendered into the statement text"); + + Object inserted = run(h2, "insert into t (name) values ('{{s}}')", false, Map.of("s", "x")); + assertEquals(Map.of("affectedRows", 1), inserted, "no generatedKeys: the executor does not ask for them"); + assertEquals(List.of(Map.of("ID", 1, "NAME", "x")), h2.rows("select * from t")); + } + } + + // ---- structure + + @Test + public void structureNamesTablesBySchemaAndKeepsTheColumnOrder() { + try (SnowflakeH2Support h2 = new SnowflakeH2Support()) { + createTables(h2); + DatasourceStructure structure = executor.getStructure(h2.pool, config(Map.of(SCHEMA_KEY, PUBLIC_SCHEMA))).block(TIMEOUT); + Table orders = structure.getTables().stream().filter(t -> t.getName().equals("PUBLIC.ORDERS")).findFirst().orElseThrow(); + assertEquals(TableType.TABLE, orders.getType()); + assertEquals("PUBLIC", orders.getSchema()); + assertEquals(List.of("ID", "NOTE", "CREATED"), orders.getColumns().stream().map(Column::getName).toList(), "columns in ordinal order"); + assertEquals(List.of("INTEGER", "CHARACTER VARYING", "TIMESTAMP"), orders.getColumns().stream().map(Column::getType).toList()); + assertEquals(List.of(false, false, false), orders.getColumns().stream().map(Column::getIsAutogenerated).toList()); + assertEquals(List.of(), orders.getKeys()); + System.out.println("[SnowflakeQueryExecutorTest] PUBLIC.ORDERS columns " + orders.getColumns().stream().map(c -> c.getName() + ":" + c.getType()).toList()); + } + } + + @Test + public void schemaExtParamLimitsTheStructureAndWithoutItEverySchemaIsListed() { + try (SnowflakeH2Support h2 = new SnowflakeH2Support()) { + createTables(h2); + Set publicOnly = structureNames(h2, Map.of(SCHEMA_KEY, PUBLIC_SCHEMA)); + assertEquals(Set.of("PUBLIC.ORDERS"), publicOnly); + assertEquals(Set.of("SALES.CUSTOMERS"), structureNames(h2, Map.of(SCHEMA_KEY, OTHER_SCHEMA))); + for (Map blank : List.of(Map.of(), Map.of(SCHEMA_KEY, " "), Map.of(SCHEMA_KEY, "")) ) { + Set all = structureNames(h2, blank); + assertTrue(all.containsAll(Set.of("PUBLIC.ORDERS", "SALES.CUSTOMERS")), "ext params " + blank); + assertTrue(all.stream().anyMatch(n -> n.startsWith("INFORMATION_SCHEMA.")), "H2's system schema is listed too: " + blank); + DatasourceStructure structure = executor.getStructure(h2.pool, config(blank)).block(TIMEOUT); + Table orders = structure.getTables().stream().filter(t -> t.getName().equals("PUBLIC.ORDERS")).findFirst().orElseThrow(); + assertEquals(List.of("ID", "NOTE", "CREATED"), orders.getColumns().stream().map(Column::getName).toList(), "columns in ordinal order without the filter too: " + blank); + assertEquals("PUBLIC", orders.getSchema()); + } + } + } + + @Test + public void aFailingStructureReadIsAStructureErrorAndAClosedPoolAConnectionError() throws Exception { + try (SnowflakeH2Support h2 = new SnowflakeH2Support()) { + java.sql.Connection closed = h2.connection(); + closed.close(); + PluginException thrown = assertThrows(PluginException.class, () -> executor.getDatabaseMetadata(closed, config(Map.of()))); + System.out.println("[SnowflakeQueryExecutorTest] closed connection: " + thrown.getError() + " " + thrown.getMessageKey()); + assertEquals(DATASOURCE_GET_STRUCTURE_ERROR, thrown.getError()); + assertEquals("DATASOURCE_GET_STRUCTURE_ERROR", thrown.getMessageKey()); + } + SnowflakeH2Support closedPool = new SnowflakeH2Support(); + closedPool.close(); + PluginException thrown = assertThrows(PluginException.class, () -> executor.getStructure(closedPool.pool, config(Map.of())).block(TIMEOUT)); + System.out.println("[SnowflakeQueryExecutorTest] closed pool: " + thrown.getError() + " " + thrown.getMessageKey()); + assertEquals(CONNECTION_ERROR, thrown.getError()); + } + + /** + * Pins defect D20 (analysis-plugins section 0.6; plan section 9 D1-D20 row): the {@code schema} ext param (a free text field + * of the data source form, pages/datasource/form/snowflakeDatasourceForm.tsx in the client) is concatenated into the + * structure SQL, so a value such as {@code PUBLIC' or '1'='1} changes the statement and returns the tables of every schema. + * The analysis expected it to be rejected or escaped (red today); per D-6 the test asserts today's behaviour. A fix + * (escaping the quote) changes this test on purpose. + */ + @Test + public void schemaExtParamIsConcatenatedIntoTheSql_pinsD20() { + try (SnowflakeH2Support h2 = new SnowflakeH2Support()) { + createTables(h2); + Set names = structureNames(h2, Map.of(SCHEMA_KEY, INJECTION_SCHEMA)); + assertTrue(names.containsAll(Set.of("PUBLIC.ORDERS", "SALES.CUSTOMERS")), "the injected condition lists every schema: " + names); + assertTrue(names.stream().anyMatch(n -> n.startsWith("INFORMATION_SCHEMA.")), names.toString()); + assertFalse(names.equals(Set.of("PUBLIC.ORDERS"))); + } + } + + // ---- GUI mode + + /** + * Shows that GUI mode is refused with a bare {@link UnsupportedOperationException} instead of a {@code PluginException}: + * {@code parseSqlCommand} throws it for every type, from {@code buildQueryExecutionContext} and from + * {@code sanitizeQueryConfig}. The client does not offer GUI mode for Snowflake (the type is listed in + * NOT_SUPPORT_GUI_SQL_QUERY, comps/queries/sqlQuery/SQLQuery.tsx:264-269, and the mode selector is hidden for it, + * comps/queries/queryComp/queryPropertyView.tsx:378-379), so only a hand-made query config reaches this. Asserted as + * observed, no defect claimed. + */ + @Test + public void guiModeIsRefusedWithABareUnsupportedOperationException() { + Map guiConfig = new LinkedHashMap<>(); + guiConfig.put("mode", "GUI"); + guiConfig.put("commandType", "INSERT"); + guiConfig.put("command", Map.of("table", "t", "changeSet", Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", "a", "value", "1"))))); + assertThrows(UnsupportedOperationException.class, () -> executor.buildQueryExecutionContext(config(Map.of()), guiConfig, Map.of(), null)); + assertThrows(UnsupportedOperationException.class, () -> executor.sanitizeQueryConfig(guiConfig)); + for (String type : List.of("insert", "update", "delete", "bulk_insert", "bulk_update", "unknown")) { + assertThrows(UnsupportedOperationException.class, () -> executor.parseSqlCommand(type, Map.of()), type); + } + UnsupportedOperationException thrown = assertThrows(UnsupportedOperationException.class, () -> executor.parseSqlCommand("insert", Map.of())); + System.out.println("[SnowflakeQueryExecutorTest] GUI mode: " + thrown + ", message " + thrown.getMessage()); + assertEquals(null, thrown.getMessage()); + } +} diff --git a/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/resources/json-contract/config-binding/SnowflakeDatasourceConfig.input.json b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/resources/json-contract/config-binding/SnowflakeDatasourceConfig.input.json new file mode 100644 index 0000000000..f38542be78 --- /dev/null +++ b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/resources/json-contract/config-binding/SnowflakeDatasourceConfig.input.json @@ -0,0 +1,41 @@ +{ + "canonical": { + "database": " lowcoder ", + "username": " root ", + "password": "s3crét \"quoted\"", + "host": " db.example.com ", + "port": 443, + "usingSsl": true, + "serverTimezone": "Europe/Bratislava", + "isReadonly": true, + "enableTurnOffPreparedStatement": true, + "extParams": { + "connectTimeout": 5000, + "allowPublicKeyRetrieval": "true", + "useCursorFetch": false, + "nested": { + "list": [ + 1, + 2.5, + null + ] + } + } + }, + "scalarsAsStrings": { + "host": "db.example.com", + "port": "444", + "usingSsl": "true", + "isReadonly": "false", + "enableTurnOffPreparedStatement": "true" + }, + "getterNames": { + "host": "db.example.com", + "readonly": true, + "port": 445.0 + }, + "minimal": { + "host": "db.example.com" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/resources/json-contract/config-binding/SnowflakeDatasourceConfig.json b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/resources/json-contract/config-binding/SnowflakeDatasourceConfig.json new file mode 100644 index 0000000000..29985bdfbe --- /dev/null +++ b/server/api-service/lowcoder-plugins/snowflakePlugin/src/test/resources/json-contract/config-binding/SnowflakeDatasourceConfig.json @@ -0,0 +1,180 @@ +{ + "SnowflakeConnector.resolveConfig" : { + "canonical" : { + "class" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "database" : " lowcoder ", + "username" : " root ", + "password" : "s3crét \"quoted\"", + "host" : " db.example.com ", + "port" : 443, + "usingSsl" : true, + "serverTimezone" : "Europe/Bratislava", + "isReadonly" : true, + "enableTurnOffPreparedStatement" : true, + "extParams" : { + "connectTimeout" : 5000, + "allowPublicKeyRetrieval" : "true", + "useCursorFetch" : false, + "nested" : { + "list" : [ 1, 2.5, null ] + } + } + }, + "classes" : { + "" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "/database" : "java.lang.String", + "/username" : "java.lang.String", + "/password" : "java.lang.String", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "java.lang.String", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "java.util.LinkedHashMap", + "/extParams/connectTimeout" : "java.lang.Integer", + "/extParams/allowPublicKeyRetrieval" : "java.lang.String", + "/extParams/useCursorFetch" : "java.lang.Boolean", + "/extParams/nested" : "java.util.LinkedHashMap", + "/extParams/nested/list" : "java.util.ArrayList", + "/extParams/nested/list/0" : "java.lang.Integer", + "/extParams/nested/list/1" : "java.lang.Double", + "/extParams/nested/list/2" : "null" + }, + "noView" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":443,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Public" : {"database":"lowcoder","username":"root","host":"db.example.com","port":443,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true}, + "Internal" : {"database":"lowcoder","username":"root","password":"s3crét \"quoted\"","host":"db.example.com","port":443,"usingSsl":true,"serverTimezone":"Europe/Bratislava","enableTurnOffPreparedStatement":true,"extParams":{"connectTimeout":5000,"allowPublicKeyRetrieval":"true","useCursorFetch":false,"nested":{"list":[1,2.5,null]}},"readonly":true} + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 444, + "usingSsl" : true, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : true, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":444,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":444,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":444,"usingSsl":true,"serverTimezone":null,"enableTurnOffPreparedStatement":true,"extParams":{},"readonly":false} + }, + "getterNames" : { + "class" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : 445, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "java.lang.Long", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":445,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":445,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":445,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "minimal" : { + "class" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : "db.example.com", + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "java.lang.String", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"db.example.com","port":-1,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"db.example.com","port":-1,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"db.example.com","port":-1,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "empty" : { + "class" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "database" : null, + "username" : null, + "password" : null, + "host" : null, + "port" : null, + "usingSsl" : false, + "serverTimezone" : null, + "isReadonly" : false, + "enableTurnOffPreparedStatement" : false, + "extParams" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.snowflake.SnowflakeDatasourceConfig", + "/database" : "null", + "/username" : "null", + "/password" : "null", + "/host" : "null", + "/port" : "null", + "/usingSsl" : "java.lang.Boolean", + "/serverTimezone" : "null", + "/isReadonly" : "java.lang.Boolean", + "/enableTurnOffPreparedStatement" : "java.lang.Boolean", + "/extParams" : "null" + }, + "noView" : {"database":"","username":"","password":null,"host":"","port":-1,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Public" : {"database":"","username":"","host":"","port":-1,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false}, + "Internal" : {"database":"","username":"","password":null,"host":"","port":-1,"usingSsl":false,"serverTimezone":null,"enableTurnOffPreparedStatement":false,"extParams":{},"readonly":false} + }, + "unknownProperty" : "ignored: binds as form canonical" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/pom.xml b/server/api-service/lowcoder-plugins/sqlBasedPlugin/pom.xml index 142aefebb3..2ede0d9516 100644 --- a/server/api-service/lowcoder-plugins/sqlBasedPlugin/pom.xml +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/pom.xml @@ -36,6 +36,12 @@ ${revision} compile + + + com.h2database + h2 + test + diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorBindTest.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorBindTest.java new file mode 100644 index 0000000000..861d9092fc --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorBindTest.java @@ -0,0 +1,188 @@ +package org.lowcoder.plugin.sql; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.lowcoder.plugin.sql.H2SqlTestSupport.assertPluginError; +import static org.lowcoder.sdk.exception.PluginCommonError.PREPARED_STATEMENT_BIND_PARAMETERS_ERROR; + +/** + * Unit SB-1 (task L5-1): {@link GeneralSqlExecutor#bindParam} binds each Java type with the right setter, shown by a + * round trip through a real H2 table (what {@code SqlBindContractTest} cannot show: it records calls on a fake and never + * reaches Float/Double, byte[], Date/Time/Timestamp, nor the unsupported-type error). Pins defect D2 (the bind error + * cannot name the parameter: the name is always the empty string). + */ +public class GeneralSqlExecutorBindTest { + + static final String PARAM = "v"; + static final String BIND_ERROR_KEY = "PS_BIND_ERROR"; + static final String TABLE = "bind_target"; + /** 18 significant digits: more than a double holds, so a conversion through double is visible. */ + static final String BIG_DECIMAL = "123456789012345.123"; + + private static final String URL = H2SqlTestSupport.newUrl("bind"); + private static Connection connection; + private final GeneralSqlExecutor executor = new GeneralSqlExecutor(); + + @BeforeAll + static void open() { + connection = H2SqlTestSupport.open(URL); + } + + @AfterAll + static void close() throws Exception { + connection.close(); + } + + @BeforeEach + void freshTable() { + H2SqlTestSupport.run(connection, "drop table if exists " + TABLE, + "create table " + TABLE + " (id int, d decimal(30,15), b varbinary(16), dt date, tm time, ts timestamp, s varchar(200), i int)"); + } + + private void insert(String column, Object value) { + Map params = new HashMap<>(); + params.put(PARAM, value); + SqlBasedQueryExecutionContext context = SqlBasedQueryExecutionContext.builder() + .query("insert into " + TABLE + " (" + column + ") values ({{" + PARAM + "}})").requestParams(params).build(); + executor.execute(connection, context); + } + + private Object stored(String column) { + return H2SqlTestSupport.scalar(connection, "select " + column + " from " + TABLE); + } + + private String storedText(String column) { + return String.valueOf(H2SqlTestSupport.scalar(connection, "select cast(" + column + " as varchar) from " + TABLE + " where " + column + " is not null")); + } + + @Test + public void floatAndDoubleRoundTripWithoutBinaryNoise() { + insert("d", 0.1f); + assertEquals(0, new BigDecimal("0.1").compareTo((BigDecimal) stored("d")), "0.1f must be stored as 0.1, got " + stored("d")); + H2SqlTestSupport.run(connection, "delete from " + TABLE); + insert("d", 0.1d); + assertEquals(0, new BigDecimal("0.1").compareTo((BigDecimal) stored("d")), "0.1d must be stored as 0.1, got " + stored("d")); + System.out.println("[GeneralSqlExecutorBindTest] float and double 0.1 stored as 0.1 (BigDecimal of String.valueOf)"); + } + + @Test + public void bigDecimalRoundTripKeepsScale() { + insert("d", new BigDecimal(BIG_DECIMAL)); + assertEquals(0, new BigDecimal(BIG_DECIMAL).compareTo((BigDecimal) stored("d")), "stored " + stored("d")); + System.out.println("[GeneralSqlExecutorBindTest] BigDecimal " + BIG_DECIMAL + " round trip: " + stored("d")); + } + + @Test + public void bytesRoundTrip() { + byte[] bytes = {0, 1, 2, (byte) 0xFF, 42}; + insert("b", bytes); + assertArrayEquals(bytes, (byte[]) stored("b")); + System.out.println("[GeneralSqlExecutorBindTest] byte[] round trip: " + Arrays.toString((byte[]) stored("b"))); + } + + @Test + public void dateTimeAndTimestampEachKeepTheirOwnPrecision() { + insert("dt", Date.valueOf("2024-02-29")); + insert("tm", Time.valueOf("13:14:15")); + insert("ts", Timestamp.valueOf("2024-02-29 13:14:15")); + assertEquals("2024-02-29", storedText("dt")); + assertEquals("13:14:15", storedText("tm")); + assertEquals("2024-02-29 13:14:15", storedText("ts")); + System.out.println("[GeneralSqlExecutorBindTest] date, time, timestamp: " + storedText("dt") + " | " + storedText("tm") + " | " + storedText("ts")); + } + + @Test + public void mapAndCollectionAreBoundAsJsonText() { + insert("s", Map.of("k", 1)); + assertEquals("{\"k\":1}", stored("s")); + H2SqlTestSupport.run(connection, "delete from " + TABLE); + insert("s", List.of(1, "two")); + assertEquals("[1,\"two\"]", stored("s")); + System.out.println("[GeneralSqlExecutorBindTest] map and list stored as JSON text: " + stored("s")); + } + + @Test + public void nullIsBoundAsSqlNullIntoTypedColumns() { + insert("i", null); + insert("s", null); + assertEquals(2L, H2SqlTestSupport.scalar(connection, "select count(*) from " + TABLE)); + assertEquals(2L, H2SqlTestSupport.scalar(connection, "select count(*) from " + TABLE + " where i is null and s is null")); + System.out.println("[GeneralSqlExecutorBindTest] two rows with SQL NULL in the integer and in the varchar column"); + } + + /** + * A driver that cannot describe its parameters (the case the production code catches, as Oracle can): the statement + * wrapper refuses {@code getParameterMetaData}, so null must still be bound, as {@code Types.NULL}. + */ + @Test + public void nullFallsBackToTypesNullWhenTheDriverCannotDescribeTheParameter() { + List setNulls = new ArrayList<>(); + Connection refusing = (Connection) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {Connection.class}, + (proxy, method, args) -> { + Object result = invoke(connection, method, args); + if (!method.getName().equals("prepareStatement")) { + return result; + } + return Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {PreparedStatement.class}, + (statementProxy, statementMethod, statementArgs) -> { + if (statementMethod.getName().equals("getParameterMetaData")) { + throw new SQLFeatureNotSupportedException("no parameter metadata"); + } + if (statementMethod.getName().equals("setNull")) { + setNulls.add(Arrays.toString(statementArgs)); + } + return invoke(result, statementMethod, statementArgs); + }); + }); + Map params = new HashMap<>(); + params.put(PARAM, null); + executor.execute(refusing, SqlBasedQueryExecutionContext.builder() + .query("insert into " + TABLE + " (s) values ({{" + PARAM + "}})").requestParams(params).build()); + assertEquals(List.of("[1, " + Types.NULL + "]"), setNulls); + assertEquals(1L, H2SqlTestSupport.scalar(connection, "select count(*) from " + TABLE + " where s is null")); + System.out.println("[GeneralSqlExecutorBindTest] no parameter metadata: setNull calls " + setNulls); + } + + private static Object invoke(Object target, java.lang.reflect.Method method, Object[] args) throws Throwable { + try { + return method.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + /** Pins defect D2: the parameter name in the error is always the empty string; a fix changes this test on purpose. */ + @Test + public void unsupportedTypeIsRejectedAsBindErrorWithoutNamingTheParameter() { + PluginException thrown = assertPluginError(PREPARED_STATEMENT_BIND_PARAMETERS_ERROR, BIND_ERROR_KEY, + () -> insert("s", UUID.randomUUID())); + assertEquals("", thrown.getArgs()[0], "D2: the parameter name is empty today"); + assertEquals("UUID", thrown.getArgs()[1]); + assertEquals(0L, H2SqlTestSupport.scalar(connection, "select count(*) from " + TABLE), "nothing may be stored"); + System.out.println("[GeneralSqlExecutorBindTest] UUID rejected: " + Arrays.toString(thrown.getArgs()) + " (D2: empty name)"); + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorSingleRowGuardTest.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorSingleRowGuardTest.java new file mode 100644 index 0000000000..4309e0776c --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorSingleRowGuardTest.java @@ -0,0 +1,154 @@ +package org.lowcoder.plugin.sql; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.UpdateOrDeleteSingleCommandRenderResult; + +import java.sql.Connection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.lowcoder.plugin.sql.H2SqlTestSupport.assertPluginError; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; + +/** + * Unit SB-3 (task L5-1): a GUI update or delete with {@code allowMultiModify=false} first runs a {@code count} select + * with the same filter and refuses to run when more than one row matches. GUI commands are the sdk PostgreSQL ones, built + * through {@link SqlBasedQueryExecutor#buildQueryExecutionContext}; rows are verified with plain JDBC. + */ +public class GeneralSqlExecutorSingleRowGuardTest { + + static final String TABLE = "people"; + static final String MORE_THAN_ONE_KEY = "AFFECT_MORE_THAN_ONE_ROWS_FOR_SINGLE_COMMAND"; + static final String COUNT_KEY = "FAIL_TO_GET_AFFECTED_ROW_COUNT"; + static final int CHANGED = 9; + static final int ORIGINAL = 0; + + private static final String URL = H2SqlTestSupport.newUrl("guard"); + private static Connection connection; + private final GeneralSqlExecutor generalSqlExecutor = new GeneralSqlExecutor(); + private final H2SqlTestSupport.H2Executor executor = new H2SqlTestSupport.H2Executor(generalSqlExecutor); + private final H2SqlTestSupport.H2Config datasource = new H2SqlTestSupport.H2Config("localhost", "db", false, null); + + @BeforeAll + static void open() { + connection = H2SqlTestSupport.open(URL); + } + + @AfterAll + static void close() throws Exception { + connection.close(); + } + + /** + * The changed column is numeric on purpose: the PostgreSQL command renders a string value in dollar quotes, which H2 does + * not parse (a string update belongs to the PostgreSQL container tests, PG-6). Rows: id 1 and 2 in group 1, id 3 in group 2. */ + @BeforeEach + void freshTable() { + H2SqlTestSupport.run(connection, "drop table if exists " + TABLE, + "create table " + TABLE + " (id int, grp int, marker int)", + "insert into " + TABLE + " values (1, 1, " + ORIGINAL + "), (2, 1, " + ORIGINAL + "), (3, 2, " + ORIGINAL + ")"); + } + + private static Map filterOn(String column, boolean allowMultiModify) { + return Map.of("table", TABLE, "allowMultiModify", allowMultiModify, + "filterBy", List.of(Map.of("column", column, "condition", "=", "value", "{{key}}"))); + } + + private static Map updateDetail(String column, boolean allowMultiModify) { + Map detail = new java.util.HashMap<>(filterOn(column, allowMultiModify)); + detail.put("changeSet", Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", "marker", "value", CHANGED)))); + return detail; + } + + private QueryExecutionResult run(String type, Map detail, int key) { + SqlBasedQueryExecutionContext context = executor.buildQueryExecutionContext(datasource, + H2SqlTestSupport.guiConfig(type, detail), Map.of("key", key), null); + return generalSqlExecutor.execute(connection, context); + } + + private long count(String where) { + return (Long) H2SqlTestSupport.scalar(connection, "select count(*) from " + TABLE + " where " + where); + } + + @Test + public void updateMatchingTwoRowsIsRefusedAndChangesNothing() { + assertPluginError(QUERY_EXECUTION_ERROR, MORE_THAN_ONE_KEY, () -> run(H2SqlTestSupport.GUI_UPDATE, updateDetail("grp", false), 1)); + assertEquals(3L, count("marker = " + ORIGINAL), "no row may change when the guard refuses"); + System.out.println("[GeneralSqlExecutorSingleRowGuardTest] update of 2 matching rows refused, 3 rows unchanged"); + } + + @Test + public void updateMatchingOneRowChangesExactlyThatRow() { + QueryExecutionResult result = run(H2SqlTestSupport.GUI_UPDATE, updateDetail("grp", false), 2); + assertEquals(Map.of("affectedRows", 1), result.getData()); + assertEquals(1L, count("marker = " + CHANGED + " and id = 3")); + assertEquals(1L, count("marker = " + CHANGED)); + System.out.println("[GeneralSqlExecutorSingleRowGuardTest] update of 1 matching row: " + result.getData()); + } + + @Test + public void deleteMatchingTwoRowsIsRefusedAndChangesNothing() { + assertPluginError(QUERY_EXECUTION_ERROR, MORE_THAN_ONE_KEY, () -> run(H2SqlTestSupport.GUI_DELETE, filterOn("grp", false), 1)); + assertEquals(3L, count("1 = 1"), "no row may be deleted when the guard refuses"); + System.out.println("[GeneralSqlExecutorSingleRowGuardTest] delete of 2 matching rows refused, 3 rows remain"); + } + + @Test + public void deleteMatchingOneRowDeletesExactlyThatRow() { + QueryExecutionResult result = run(H2SqlTestSupport.GUI_DELETE, filterOn("grp", false), 2); + assertEquals(Map.of("affectedRows", 1), result.getData()); + assertEquals(2L, count("1 = 1")); + assertEquals(0L, count("id = 3")); + System.out.println("[GeneralSqlExecutorSingleRowGuardTest] delete of 1 matching row: " + result.getData()); + } + + @Test + public void allowMultiModifyUpdatesEveryMatchingRowWithoutAGuard() { + QueryExecutionResult result = run(H2SqlTestSupport.GUI_UPDATE, updateDetail("grp", true), 1); + assertEquals(Map.of("affectedRows", 2), result.getData()); + assertEquals(2L, count("marker = " + CHANGED)); + System.out.println("[GeneralSqlExecutorSingleRowGuardTest] allowMultiModify update: " + result.getData()); + } + + @Test + public void allowMultiModifyDeletesEveryMatchingRowWithoutAGuard() { + QueryExecutionResult result = run(H2SqlTestSupport.GUI_DELETE, filterOn("grp", true), 1); + assertEquals(Map.of("affectedRows", 2), result.getData()); + assertEquals(1L, count("1 = 1")); + System.out.println("[GeneralSqlExecutorSingleRowGuardTest] allowMultiModify delete: " + result.getData()); + } + + @Test + public void nonNumericCountIsFailToGetAffectedRowCountAndTheStatementDoesNotRun() { + GuiSqlCommand command = new GuiSqlCommand() { + @Override + public GuiSqlCommandRenderResult render(Map requestMap) { + return new UpdateOrDeleteSingleCommandRenderResult("select 'x' as count", Collections.emptyList(), + "update " + TABLE + " set marker = " + CHANGED, Collections.emptyList()); + } + + @Override + public boolean isInsertCommand() { + return false; + } + + @Override + public Set extractMustacheKeys() { + return Set.of(); + } + }; + SqlBasedQueryExecutionContext context = SqlBasedQueryExecutionContext.builder().guiSqlCommand(command).requestParams(Map.of()).build(); + assertPluginError(QUERY_EXECUTION_ERROR, COUNT_KEY, () -> generalSqlExecutor.execute(connection, context)); + assertEquals(3L, count("marker = " + ORIGINAL), "the update must not run on an unreadable count"); + System.out.println("[GeneralSqlExecutorSingleRowGuardTest] text count refused, rows unchanged"); + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorSortPlaceholderTest.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorSortPlaceholderTest.java new file mode 100644 index 0000000000..c338dd9eb9 --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorSortPlaceholderTest.java @@ -0,0 +1,117 @@ +package org.lowcoder.plugin.sql; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import java.sql.Connection; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.lowcoder.plugin.sql.H2SqlTestSupport.assertPluginError; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; + +/** + * Unit SB-2 (task L5-1): the {@code {{x}}} parameter whose value is a map with a {@code sort} key is rewritten into + * {@code ASC}/{@code DESC} in the SQL text (a placeholder cannot bind a keyword). Run on H2 through the executor of + * PostgreSQL ({@link H2SqlTestSupport.MutableParamsGeneralSqlExecutor}); the default executor is pinned to defect D1. + */ +public class GeneralSqlExecutorSortPlaceholderTest { + + static final String TABLE = "sort_items"; + static final String SORT_KEY = "sort"; + + private static final String URL = H2SqlTestSupport.newUrl("sort"); + private static Connection connection; + private final GeneralSqlExecutor postgresStyle = new H2SqlTestSupport.MutableParamsGeneralSqlExecutor(); + + @BeforeAll + static void open() { + connection = H2SqlTestSupport.open(URL); + } + + @AfterAll + static void close() throws Exception { + connection.close(); + } + + @BeforeEach + void freshTable() { + H2SqlTestSupport.run(connection, "drop table if exists " + TABLE, + "create table " + TABLE + " (id int, grp int)", + "insert into " + TABLE + " values (1, 1), (2, 1), (3, 2), (4, 2)"); + } + + private QueryExecutionResult run(GeneralSqlExecutor executor, String sql, Map params) { + return executor.execute(connection, SqlBasedQueryExecutionContext.builder().query(sql).requestParams(params).build()); + } + + private List ids(QueryExecutionResult result) { + @SuppressWarnings("unchecked") + List> rows = (List>) result.getData(); + return rows.stream().map(row -> row.get("id")).toList(); + } + + @Test + public void sortDescOrdersRowsDescendingAndIsCaseInsensitive() { + List ids = ids(run(postgresStyle, "select id from " + TABLE + " order by id {{dir}}", Map.of("dir", Map.of(SORT_KEY, "desc")))); + assertEquals(List.of(4, 3, 2, 1), ids); + List upper = ids(run(postgresStyle, "select id from " + TABLE + " order by id {{dir}}", Map.of("dir", Map.of(SORT_KEY, "DESC")))); + assertEquals(List.of(4, 3, 2, 1), upper); + System.out.println("[GeneralSqlExecutorSortPlaceholderTest] desc and DESC -> " + ids); + } + + @Test + public void invalidSortValueFallsBackToAscAndTheTableSurvives() { + List ids = ids(run(postgresStyle, "select id from " + TABLE + " order by id {{dir}}", + Map.of("dir", Map.of(SORT_KEY, "x; drop table " + TABLE)))); + assertEquals(List.of(1, 2, 3, 4), ids); + assertEquals(4L, H2SqlTestSupport.scalar(connection, "select count(*) from " + TABLE), "the table must still hold its rows"); + System.out.println("[GeneralSqlExecutorSortPlaceholderTest] injected sort value ran as ASC: " + ids); + } + + @Test + public void sortMapAfterAnotherParameterReplacesTheRightPlaceholder() { + List ids = ids(run(postgresStyle, "select id from " + TABLE + " where id > {{min}} order by id {{dir}}", + Map.of("min", 1, "dir", Map.of(SORT_KEY, "desc")))); + assertEquals(List.of(4, 3, 2), ids); + System.out.println("[GeneralSqlExecutorSortPlaceholderTest] bind then sort -> " + ids); + } + + @Test + public void sortMapBeforeAnotherParameterKeepsTheOtherBind() { + List ids = ids(run(postgresStyle, "select id from " + TABLE + " order by id {{dir}}, grp limit {{n}}", + Map.of("n", 2, "dir", Map.of(SORT_KEY, "desc")))); + assertEquals(List.of(4, 3), ids); + System.out.println("[GeneralSqlExecutorSortPlaceholderTest] sort then bind -> " + ids); + } + + @Test + public void twoSortMapsAreBothRewritten() { + List ids = ids(run(postgresStyle, "select id from " + TABLE + " order by grp {{d1}}, id {{d2}}", + Map.of("d1", Map.of(SORT_KEY, "desc"), "d2", Map.of(SORT_KEY, "asc")))); + assertEquals(List.of(3, 4, 1, 2), ids); + System.out.println("[GeneralSqlExecutorSortPlaceholderTest] two sort maps -> " + ids); + } + + /** + * Pins defect D1: the default {@code getPreparedStatementInput} (MySQL, Oracle, MSSQL) hands {@code Stream.toList()} + * to the rewrite, which calls {@code remove} on it: {@code UnsupportedOperationException}, wrapped as + * QUERY_EXECUTION_ERROR with a null message argument. A fix changes this test on purpose. The same executor still runs + * a query without a sort map. + */ + @Test + public void defaultExecutorCannotRewriteTheSortPlaceholder() { + GeneralSqlExecutor defaultExecutor = new GeneralSqlExecutor(); + Throwable thrown = assertPluginError(QUERY_EXECUTION_ERROR, H2SqlTestSupport.QUERY_ERROR_KEY, + () -> run(defaultExecutor, "select id from " + TABLE + " order by id {{dir}}", Map.of("dir", Map.of(SORT_KEY, "desc")))); + assertNull(((org.lowcoder.sdk.exception.PluginException) thrown).getArgs()[0], "D1: UnsupportedOperationException has no message"); + assertEquals(List.of(2, 3, 4), ids(run(defaultExecutor, "select id from " + TABLE + " where id > {{min}} order by id", Map.of("min", 1)))); + System.out.println("[GeneralSqlExecutorSortPlaceholderTest] D1 pinned: default executor fails on a sort map, runs without one"); + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorStatementTest.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorStatementTest.java new file mode 100644 index 0000000000..b8d7a9580c --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/GeneralSqlExecutorStatementTest.java @@ -0,0 +1,119 @@ +package org.lowcoder.plugin.sql; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import java.sql.Connection; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.sql.H2SqlTestSupport.assertPluginError; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; + +/** + * Unit SB-4 (task L5-1): prepared versus plain statements, generated keys, error wrapping and connection hand-back of + * {@link GeneralSqlExecutor} / {@link SqlBasedQueryExecutor#blockingExecuteQuery}, on H2. + */ +public class GeneralSqlExecutorStatementTest { + + static final String TABLE = "stmt_keys"; + static final String VALUE = "v"; + static final String INVALID_SQL = "selec nothing from nowhere"; + static final String QUOTED_VALUE = "x' || 'y"; + static final String POOL_NAME = "GeneralSqlExecutorStatementTest"; + static final int FAILING_RUNS = 3; + + private static final String URL = H2SqlTestSupport.newUrl("stmt"); + private static Connection connection; + + @BeforeAll + static void open() { + connection = H2SqlTestSupport.open(URL); + } + + @AfterAll + static void close() throws Exception { + connection.close(); + } + + @BeforeEach + void freshTable() { + H2SqlTestSupport.run(connection, "drop table if exists " + TABLE, + "create table " + TABLE + " (id bigint generated by default as identity, v varchar(50))"); + } + + private static SqlBasedQueryExecutionContext context(String sql, Map params, boolean disablePreparedStatement) { + return SqlBasedQueryExecutionContext.builder().query(sql).requestParams(params).disablePreparedStatement(disablePreparedStatement).build(); + } + + @Test + public void disabledPreparedStatementRendersTheValueIntoTheSqlText() { + String sql = "select '{{" + VALUE + "}}' as v"; + Map params = Map.of(VALUE, QUOTED_VALUE); + Object prepared = rowValue(new GeneralSqlExecutor().execute(connection, context(sql, params, false))); + Object plain = rowValue(new GeneralSqlExecutor().execute(connection, context(sql, params, true))); + assertEquals(QUOTED_VALUE, prepared, "bound: the value stays one literal"); + assertEquals("xy", plain, "rendered: the value became part of the SQL text"); + System.out.println("[GeneralSqlExecutorStatementTest] bound -> " + prepared + " ; rendered into the SQL -> " + plain); + } + + private static Object rowValue(QueryExecutionResult result) { + @SuppressWarnings("unchecked") + List> rows = (List>) result.getData(); + assertEquals(1, rows.size()); + return rows.get(0).get(VALUE); + } + + @Test + public void generatedKeysAreReturnedOnTheCapablePathsAndOmittedWhenNotSupported() { + String insert = "insert into " + TABLE + " (v) values ('a')"; + Map keyed = Map.of("affectedRows", 1, "generatedKeys", List.of(1L)); + assertEquals(keyed, new GeneralSqlExecutor(true).execute(connection, context(insert, Map.of(), true)).getData(), "plain, supported"); + assertEquals(Map.of("affectedRows", 1), new GeneralSqlExecutor(false).execute(connection, context(insert, Map.of(), true)).getData(), + "plain, generated keys not supported (the Snowflake variant)"); + QueryExecutionResult prepared = new GeneralSqlExecutor().execute(connection, + context("insert into " + TABLE + " (v) values ({{v}})", Map.of(VALUE, "b"), false)); + assertEquals(Map.of("affectedRows", 1, "generatedKeys", List.of(3L)), prepared.getData(), "prepared"); + System.out.println("[GeneralSqlExecutorStatementTest] keys: " + keyed + " ; none when unsupported ; prepared " + prepared.getData()); + } + + @Test + public void invalidSqlIsWrappedAsQueryExecutionErrorInBothModes() { + for (boolean plain : new boolean[] {false, true}) { + assertPluginError(QUERY_EXECUTION_ERROR, H2SqlTestSupport.QUERY_ERROR_KEY, + () -> new GeneralSqlExecutor().execute(connection, context(INVALID_SQL, Map.of(), plain))); + } + System.out.println("[GeneralSqlExecutorStatementTest] invalid SQL gave QUERY_EXECUTION_ERROR (prepared and plain)"); + } + + @Test + public void connectionsGoBackToThePoolAfterSuccessAndAfterFailure() { + H2SqlTestSupport.H2Connector connector = new H2SqlTestSupport.H2Connector(URL, H2SqlTestSupport.H2_DRIVER, POOL_NAME, 2); + H2SqlTestSupport.H2Executor executor = new H2SqlTestSupport.H2Executor(new GeneralSqlExecutor()); + HikariPerfWrapper wrapper = connector.blockingCreateConnection(new H2SqlTestSupport.H2Config("h", "d", false, null)); + try { + for (int i = 0; i < FAILING_RUNS; i++) { + assertPluginError(QUERY_EXECUTION_ERROR, H2SqlTestSupport.QUERY_ERROR_KEY, + () -> executor.blockingExecuteQuery(wrapper, context(INVALID_SQL, Map.of(), false))); + } + assertEquals(0, wrapper.getActiveConnections(), "a failed statement must not keep its connection"); + executor.blockingExecuteQuery(wrapper, context("select 1 as v", Map.of(), false)); + assertEquals(0, wrapper.getActiveConnections(), "a finished statement must not keep its connection"); + System.out.println("[GeneralSqlExecutorStatementTest] active connections after " + FAILING_RUNS + " failures and one success: " + + wrapper.getActiveConnections() + " of " + wrapper.getTotalConnections()); + } finally { + connector.blockingDestroyConnection(wrapper); + } + assertTrue(((HikariDataSource) wrapper.getHikariDataSource()).isClosed()); + assertFalse(((HikariDataSource) wrapper.getHikariDataSource()).isRunning()); + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/H2SqlTestSupport.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/H2SqlTestSupport.java new file mode 100644 index 0000000000..6f519a7bd1 --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/H2SqlTestSupport.java @@ -0,0 +1,194 @@ +package org.lowcoder.plugin.sql; + +import com.zaxxer.hikari.HikariConfig; +import org.junit.jupiter.api.Assertions; +import org.lowcoder.sdk.exception.PluginError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedDatasourceConnectionConfig; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresDeleteCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresUpdateCommand; +import org.junit.jupiter.api.function.Executable; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Shared helpers of the L5-1 tests (task L5-1, units SB-1 to SB-6): an in-memory H2 database in PostgreSQL mode with + * lower-case names (the label of {@code count(1) as count} is then {@code count}, the key the single-row guard reads), + * and a minimal connector, executor and datasource config over it. Not a test. + */ +final class H2SqlTestSupport { + + static final String H2_DRIVER = "org.h2.Driver"; + static final String H2_USER = "sa"; + static final String GUI_UPDATE = "update"; + static final String GUI_DELETE = "delete"; + static final String GUI_MODE = "GUI"; + static final String SQL_MODE = "SQL"; + static final String QUERY_ERROR_KEY = "QUERY_EXECUTION_ERROR"; + static final String SQL_KEY = "sql"; + static final String MODE_KEY = "mode"; + static final String PREPARED_OFF_KEY = "disablePreparedStatement"; + static final String COMMAND_TYPE_KEY = "commandType"; + static final String COMMAND_KEY = "command"; + + private static final AtomicInteger DB_COUNTER = new AtomicInteger(); + + private H2SqlTestSupport() { + } + + /** A URL of a database no other test class shares; it lives as long as the JVM ({@code DB_CLOSE_DELAY=-1}). */ + static String newUrl(String owner) { + return "jdbc:h2:mem:" + owner + "_" + DB_COUNTER.incrementAndGet() + + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"; + } + + static Connection open(String url) { + try { + return DriverManager.getConnection(url, H2_USER, ""); + } catch (SQLException e) { + throw new IllegalStateException("cannot open " + url, e); + } + } + + static void run(Connection connection, String... statements) { + try (Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } catch (SQLException e) { + throw new IllegalStateException("setup statement failed", e); + } + } + + /** Reads rows with plain JDBC (not the code under test); values as {@code getObject} gives them. */ + static List> rows(Connection connection, String sql) { + List> rows = new ArrayList<>(); + try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) { + ResultSetMetaData metaData = resultSet.getMetaData(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= metaData.getColumnCount(); i++) { + row.put(metaData.getColumnLabel(i), resultSet.getObject(i)); + } + rows.add(row); + } + } catch (SQLException e) { + throw new IllegalStateException("read failed: " + sql, e); + } + return rows; + } + + static Object scalar(Connection connection, String sql) { + List> rows = rows(connection, sql); + Assertions.assertEquals(1, rows.size(), "one row expected for " + sql); + return rows.get(0).values().iterator().next(); + } + + /** Asserts that {@code action} throws a {@link PluginException} of this error and message key; returns it. */ + static PluginException assertPluginError(PluginError error, String messageKey, Executable action) { + PluginException thrown = Assertions.assertThrows(PluginException.class, action); + Assertions.assertEquals(error, thrown.getError(), "error code of " + thrown); + Assertions.assertEquals(messageKey, thrown.getMessageKey(), "message key of " + thrown); + return thrown; + } + + static Map sqlConfig(String sql) { + Map config = new LinkedHashMap<>(); + config.put(MODE_KEY, SQL_MODE); + config.put(SQL_KEY, sql); + return config; + } + + static Map guiConfig(String type, Map command) { + Map config = new LinkedHashMap<>(); + config.put(MODE_KEY, GUI_MODE); + config.put(COMMAND_TYPE_KEY, type); + config.put(COMMAND_KEY, command); + return config; + } + + /** The datasource config of the tests: only the fields the code under test reads. */ + static final class H2Config extends SqlBasedDatasourceConnectionConfig { + + H2Config(String host, String database, boolean enableTurnOffPreparedStatement, Map extParams) { + super(database, H2_USER, "", host, null, false, null, false, enableTurnOffPreparedStatement, extParams); + } + + @Override + protected long defaultPort() { + return 9092L; + } + } + + /** A connector over one H2 URL; registers the Hikari pool as an MBean under {@code poolName} while it lives. */ + static class H2Connector extends SqlBasedConnector { + + private final String url; + private final String driver; + private final String poolName; + + H2Connector(String url, String driver, String poolName, int maxPoolSize) { + super(maxPoolSize); + this.url = url; + this.driver = driver; + this.poolName = poolName; + } + + @Override + protected String getJdbcDriver() { + return driver; + } + + @Override + protected void setUpConfigs(H2Config datasourceConfig, HikariConfig config) { + config.setJdbcUrl(url); + config.setUsername(H2_USER); + config.setPoolName(poolName); + config.setRegisterMbeans(true); + } + } + + /** An executor whose GUI commands are the sdk PostgreSQL update and delete; structure is an empty one. */ + static class H2Executor extends SqlBasedQueryExecutor { + + H2Executor(GeneralSqlExecutor generalSqlExecutor) { + super(generalSqlExecutor); + } + + @Override + protected GuiSqlCommand parseSqlCommand(String guiStatementType, Map detail) { + return switch (guiStatementType) { + case GUI_UPDATE -> PostgresUpdateCommand.from(detail); + case GUI_DELETE -> PostgresDeleteCommand.from(detail); + default -> throw new IllegalArgumentException("not a command of this test executor: " + guiStatementType); + }; + } + + @Override + protected DatasourceStructure getDatabaseMetadata(Connection connection, SqlBasedDatasourceConnectionConfig connectionConfig) { + return new DatasourceStructure(List.of()); + } + } + + /** The executor of PostgreSQL ({@code PostgresExecutor.getPreparedStatementInput}): the same input with a mutable list. */ + static class MutableParamsGeneralSqlExecutor extends GeneralSqlExecutor { + + @Override + protected StatementInput getPreparedStatementInput(String query, Map requestParams) { + StatementInput input = super.getPreparedStatementInput(query, requestParams); + return StatementInput.fromSql(input.isPreparedStatement(), input.getSql(), new ArrayList<>(input.getParams())); + } + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBasedConnectorTest.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBasedConnectorTest.java new file mode 100644 index 0000000000..727a7bae46 --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBasedConnectorTest.java @@ -0,0 +1,189 @@ +package org.lowcoder.plugin.sql; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import javax.management.MBeanServer; +import javax.management.ObjectName; +import java.lang.management.ManagementFactory; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.sql.H2SqlTestSupport.assertPluginError; +import static org.lowcoder.sdk.exception.PluginCommonError.CONNECTION_ERROR; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_EXECUTION_ERROR; + +/** + * Unit SB-6 (task L5-1): {@link SqlBasedConnector} and the pool-state parts of {@link SqlBasedQueryExecutor} on H2: + * pool creation, ext-param filtering, test and destroy (observed through the pool's JMX registration, which Hikari + * removes on close), the unknown driver, {@code validateConfig}, a closed pool and the structure call. + * Finding checked here: defect D3 (NPE on a null host) does not apply to this class, because + * {@code SqlBasedDatasourceConnectionConfig.getHost()} returns the host trimmed to the empty string. + */ +public class SqlBasedConnectorTest { + + static final int POOL_SIZE = 3; + static final String VALID_PROPERTY = "IGNORECASE"; + static final String HOST_EMPTY = "HOST_EMPTY_PLZ_CHECK"; + static final String HOST_COLON = "HOST_WITH_COLON"; + static final String DATABASE_EMPTY = "DATABASE_NAME_EMPTY"; + static final String CLOSED_MESSAGE = "hikari datasource closed."; + static final String VALID_HOST = "db.example.org"; + static final String VALID_DATABASE = "app"; + + private static final MBeanServer MBEANS = ManagementFactory.getPlatformMBeanServer(); + + private final H2SqlTestSupport.H2Executor executor = new H2SqlTestSupport.H2Executor(new GeneralSqlExecutor()); + + private static H2SqlTestSupport.H2Connector connector(String poolName) { + return new H2SqlTestSupport.H2Connector(H2SqlTestSupport.newUrl("connector"), H2SqlTestSupport.H2_DRIVER, poolName, POOL_SIZE); + } + + private static H2SqlTestSupport.H2Config config(Map extParams) { + return new H2SqlTestSupport.H2Config(VALID_HOST, VALID_DATABASE, false, extParams); + } + + private static boolean poolRegistered(String poolName) throws Exception { + return MBEANS.isRegistered(new ObjectName("com.zaxxer.hikari:type=Pool (" + poolName + ")")); + } + + private static SqlBasedQueryExecutionContext select(String sql) { + return SqlBasedQueryExecutionContext.builder().query(sql).requestParams(Map.of()).build(); + } + + @Test + public void createdPoolRunsAgainstH2AndAppliesTheConnectorSettings() throws Exception { + String poolName = "SqlBasedConnectorTest-create"; + H2SqlTestSupport.H2Connector connector = connector(poolName); + HikariPerfWrapper wrapper = connector.blockingCreateConnection(config(null)); + try { + HikariDataSource dataSource = (HikariDataSource) wrapper.getHikariDataSource(); + assertTrue(dataSource.isRunning()); + assertEquals(H2SqlTestSupport.H2_DRIVER, dataSource.getDriverClassName()); + assertEquals(POOL_SIZE, dataSource.getMaximumPoolSize(), "the constructor's pool size must reach Hikari"); + assertTrue(poolRegistered(poolName), "a live pool is registered"); + assertEquals(List.of(Map.of("one", 1)), executor.blockingExecuteQuery(wrapper, select("select 1 as one")).getData()); + System.out.println("[SqlBasedConnectorTest] pool running, driver " + dataSource.getDriverClassName() + ", max size " + + dataSource.getMaximumPoolSize() + ", idle " + wrapper.getIdleConnections()); + } finally { + connector.blockingDestroyConnection(wrapper); + } + } + + @Test + public void blankAndNullExtParamsAreSkippedAndAValidOneIsPassedOn() { + Map ext = new LinkedHashMap<>(); + ext.put("", "ignored-by-blank-key"); + ext.put(" ", "ignored-by-blank-key"); + ext.put("NULL_VALUE", null); + ext.put("BLANK_VALUE", " "); + ext.put(VALID_PROPERTY, "TRUE"); + H2SqlTestSupport.H2Connector connector = connector("SqlBasedConnectorTest-ext"); + HikariPerfWrapper wrapper = connector.blockingCreateConnection(config(ext)); + try { + assertEquals(Set.of(VALID_PROPERTY), wrapper.getDatasourceProperties().keySet()); + assertEquals("TRUE", wrapper.getDatasourceProperties().get(VALID_PROPERTY)); + System.out.println("[SqlBasedConnectorTest] data source properties: " + wrapper.getDatasourceProperties()); + } finally { + connector.blockingDestroyConnection(wrapper); + } + } + + @Test + public void testConnectionSucceedsAndClosesItsPool() throws Exception { + String poolName = "SqlBasedConnectorTest-test"; + DatasourceTestResult result = connector(poolName).testConnection(config(null)).block(); + assertNotNull(result); + assertTrue(result.isSuccess()); + assertFalse(poolRegistered(poolName), "the pool made for a test must be closed (leak)"); + System.out.println("[SqlBasedConnectorTest] test connection succeeded, pool closed"); + } + + @Test + public void destroyConnectionIsSafeForNullAndClosesARealPool() throws Exception { + String poolName = "SqlBasedConnectorTest-destroy"; + H2SqlTestSupport.H2Connector connector = connector(poolName); + connector.destroyConnection(null).block(); + HikariPerfWrapper wrapper = connector.blockingCreateConnection(config(null)); + connector.destroyConnection(wrapper).block(); + assertTrue(((HikariDataSource) wrapper.getHikariDataSource()).isClosed()); + assertFalse(poolRegistered(poolName)); + System.out.println("[SqlBasedConnectorTest] destroy(null) safe, destroy(pool) closed it"); + } + + @Test + public void unknownDriverIsLoadSqlJdbcError() { + H2SqlTestSupport.H2Connector connector = new H2SqlTestSupport.H2Connector(H2SqlTestSupport.newUrl("nodriver"), "no.such.Driver", + "SqlBasedConnectorTest-nodriver", POOL_SIZE); + assertPluginError(QUERY_EXECUTION_ERROR, "LOAD_SQL_JDBC_ERROR", () -> connector.blockingCreateConnection(config(null))); + } + + @Test + public void validateConfigNamesEachInvalidField() { + H2SqlTestSupport.H2Connector connector = connector("SqlBasedConnectorTest-validate"); + assertEquals(Set.of(), connector.validateConfig(new H2SqlTestSupport.H2Config(VALID_HOST, VALID_DATABASE, false, null))); + assertEquals(Set.of(HOST_COLON), connector.validateConfig(new H2SqlTestSupport.H2Config("db:5432", VALID_DATABASE, false, null))); + assertEquals(Set.of(HOST_COLON), connector.validateConfig(new H2SqlTestSupport.H2Config("db/app", VALID_DATABASE, false, null))); + assertEquals(Set.of(DATABASE_EMPTY), connector.validateConfig(new H2SqlTestSupport.H2Config(VALID_HOST, " ", false, null))); + assertEquals(Set.of(HOST_EMPTY), connector.validateConfig(new H2SqlTestSupport.H2Config(" ", VALID_DATABASE, false, null))); + assertEquals(Set.of(HOST_COLON, DATABASE_EMPTY), connector.validateConfig(new H2SqlTestSupport.H2Config("a:b/c", "", false, null))); + System.out.println("[SqlBasedConnectorTest] validateConfig: each invalid field gives its own message key"); + } + + /** + * Defect D3 (a blank host adds HOST_EMPTY and then {@code host.contains} throws a NullPointerException) does not apply + * here: the config's {@code getHost()} trims null to "", so a null host gives only HOST_EMPTY_PLZ_CHECK. Pins that. + */ + @Test + public void nullHostAndNullDatabaseGiveOnlyTheEmptyMessages() { + H2SqlTestSupport.H2Connector connector = connector("SqlBasedConnectorTest-nullhost"); + Set invalids = connector.validateConfig(new H2SqlTestSupport.H2Config(null, null, false, null)); + assertEquals(Set.of(HOST_EMPTY, DATABASE_EMPTY), invalids); + System.out.println("[SqlBasedConnectorTest] null host and database: " + invalids + " (no NullPointerException)"); + } + + @Test + public void executingOnAClosedPoolIsAConnectionErrorNotAHang() { + H2SqlTestSupport.H2Connector connector = connector("SqlBasedConnectorTest-closed"); + HikariPerfWrapper wrapper = connector.blockingCreateConnection(config(null)); + connector.blockingDestroyConnection(wrapper); + PluginException thrown = assertPluginError(CONNECTION_ERROR, "CONNECTION_ERROR", () -> executor.blockingExecuteQuery(wrapper, select("select 1"))); + assertEquals(CLOSED_MESSAGE, thrown.getArgs()[0], "the executor's own check must answer, not the driver's"); + PluginException structure = assertPluginError(CONNECTION_ERROR, "CONNECTION_ERROR", + () -> executor.blockingGetStructure(wrapper, config(null))); + assertEquals(CLOSED_MESSAGE, structure.getArgs()[0]); + System.out.println("[SqlBasedConnectorTest] closed pool: " + thrown.getArgs()[0]); + } + + @Test + public void aMissingDataSourceIsAConnectionErrorForStructure() { + HikariPerfWrapper wrapper = HikariPerfWrapper.wrap(null, () -> 0, () -> 0, () -> 0, () -> 0, java.util.Properties::new, java.util.Properties::new); + PluginException thrown = assertPluginError(CONNECTION_ERROR, "CONNECTION_ERROR", () -> executor.blockingGetStructure(wrapper, config(null))); + assertEquals(CLOSED_MESSAGE, thrown.getArgs()[0]); + } + + @Test + public void getStructureReturnsTheSubclassStructureAndReturnsTheConnection() { + H2SqlTestSupport.H2Connector connector = connector("SqlBasedConnectorTest-structure"); + HikariPerfWrapper wrapper = connector.blockingCreateConnection(config(null)); + try { + DatasourceStructure structure = executor.blockingGetStructure(wrapper, config(null)); + assertEquals(List.of(), structure.getTables()); + assertEquals(0, wrapper.getActiveConnections(), "the structure call must hand its connection back"); + System.out.println("[SqlBasedConnectorTest] structure " + structure + ", active connections " + wrapper.getActiveConnections()); + } finally { + connector.blockingDestroyConnection(wrapper); + } + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBasedQueryExecutorContextTest.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBasedQueryExecutorContextTest.java new file mode 100644 index 0000000000..faabe42dae --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBasedQueryExecutorContextTest.java @@ -0,0 +1,123 @@ +package org.lowcoder.plugin.sql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresUpdateCommand; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.plugin.sql.H2SqlTestSupport.assertPluginError; +import static org.lowcoder.sdk.exception.PluginCommonError.INVALID_QUERY_SETTINGS; +import static org.lowcoder.sdk.exception.PluginCommonError.QUERY_ARGUMENT_ERROR; + +/** + * Unit SB-5 (task L5-1): {@link SqlBasedQueryExecutor#buildQueryExecutionContext} and + * {@link SqlBasedQueryExecutor#sanitizeQueryConfig}: what decides whether parameters are bound or rendered into the SQL. + */ +public class SqlBasedQueryExecutorContextTest { + + static final String EMPTY_CONFIG_KEY = "EMPTY_SQL_QUERY_CONFIG"; + static final Map PARAMS = Map.of("x", 1); + + private final H2SqlTestSupport.H2Executor executor = new H2SqlTestSupport.H2Executor(new GeneralSqlExecutor()); + + private static H2SqlTestSupport.H2Config datasource(boolean enableTurnOffPreparedStatement) { + return new H2SqlTestSupport.H2Config("localhost", "db", enableTurnOffPreparedStatement, null); + } + + private SqlBasedQueryExecutionContext build(boolean datasourceFlag, Map queryConfig) { + return executor.buildQueryExecutionContext(datasource(datasourceFlag), queryConfig, PARAMS, null); + } + + private static Map updateDetail() { + return Map.of("table", "t", "allowMultiModify", true, + "changeSet", Map.of("compType", "KEY_VALUE_PAIRS", "comp", List.of(Map.of("column", "a", "value", "{{c}}"))), + "filterBy", List.of(Map.of("column", "id", "condition", "=", "value", "{{key}}"))); + } + + @Test + public void commentsAreStrippedSoNoPhantomParameterRemains() { + SqlBasedQueryExecutionContext context = build(false, H2SqlTestSupport.sqlConfig("select 1 -- {{x}}\n from dual")); + System.out.println("[SqlBasedQueryExecutorContextTest] query after comment removal: [" + context.getQuery() + "]"); + assertFalse(context.getQuery().contains("{{"), "a mustache inside a comment must not survive: " + context.getQuery()); + assertTrue(context.getQuery().contains("select 1")); + assertEquals(PARAMS, context.getRequestParams()); + assertNull(context.getGuiSqlCommand()); + } + + @Test + public void blankAndCommentOnlySqlIsSqlEmpty() { + for (String sql : new String[] {"", " ", "-- nothing {{x}}"}) { + assertPluginError(QUERY_ARGUMENT_ERROR, "SQL_EMPTY", () -> build(false, H2SqlTestSupport.sqlConfig(sql))); + } + System.out.println("[SqlBasedQueryExecutorContextTest] blank and comment-only SQL -> SQL_EMPTY"); + } + + @Test + public void guiModeWithoutATypeIsGuiCommandTypeEmpty() { + for (String type : new String[] {null, "", " "}) { + assertPluginError(QUERY_ARGUMENT_ERROR, "GUI_COMMAND_TYPE_EMPTY", + () -> build(false, H2SqlTestSupport.guiConfig(type, updateDetail()))); + } + } + + @Test + public void guiModeWithoutADetailIsInvalidGuiParam() { + assertPluginError(QUERY_ARGUMENT_ERROR, "INVALID_GUI_PARAM", () -> build(false, H2SqlTestSupport.guiConfig(H2SqlTestSupport.GUI_UPDATE, null))); + assertPluginError(QUERY_ARGUMENT_ERROR, "INVALID_GUI_PARAM", () -> build(false, H2SqlTestSupport.guiConfig(H2SqlTestSupport.GUI_UPDATE, Map.of()))); + } + + @Test + public void guiModeBuildsTheCommandAndNoQuery() { + SqlBasedQueryExecutionContext context = build(true, H2SqlTestSupport.guiConfig(H2SqlTestSupport.GUI_UPDATE, updateDetail())); + assertInstanceOf(PostgresUpdateCommand.class, context.getGuiSqlCommand()); + assertNull(context.getQuery()); + assertEquals(PARAMS, context.getRequestParams()); + assertFalse(context.isDisablePreparedStatement(), "GUI commands are always prepared"); + System.out.println("[SqlBasedQueryExecutorContextTest] GUI context: " + context.getGuiSqlCommand().getClass().getSimpleName()); + } + + @Test + public void preparedStatementIsDisabledOnlyWhenBothFlagsAreSet() { + for (boolean datasourceFlag : new boolean[] {false, true}) { + for (boolean queryFlag : new boolean[] {false, true}) { + Map queryConfig = new HashMap<>(H2SqlTestSupport.sqlConfig("select {{x}}")); + queryConfig.put(H2SqlTestSupport.PREPARED_OFF_KEY, queryFlag); + boolean disabled = build(datasourceFlag, queryConfig).isDisablePreparedStatement(); + System.out.println("[SqlBasedQueryExecutorContextTest] datasource flag " + datasourceFlag + ", query flag " + queryFlag + + " -> prepared statement disabled: " + disabled); + assertEquals(datasourceFlag && queryFlag, disabled, "datasource " + datasourceFlag + ", query " + queryFlag); + } + } + } + + @Test + public void emptyQueryConfigIsRejected() { + assertPluginError(INVALID_QUERY_SETTINGS, EMPTY_CONFIG_KEY, () -> build(false, Map.of())); + assertPluginError(INVALID_QUERY_SETTINGS, EMPTY_CONFIG_KEY, () -> build(false, null)); + assertPluginError(INVALID_QUERY_SETTINGS, EMPTY_CONFIG_KEY, () -> executor.sanitizeQueryConfig(Map.of())); + } + + @Test + public void sanitizeQueryConfigReturnsTheMustacheKeysForSql() { + Map sanitized = executor.sanitizeQueryConfig(H2SqlTestSupport.sqlConfig("select {{a}}, {{ b }} from t where c = {{a}}")); + System.out.println("[SqlBasedQueryExecutorContextTest] sql fields: " + sanitized); + assertEquals(Set.of("{{a}}", "{{ b }}"), sanitized.get("fields")); + assertEquals(Set.of(), executor.sanitizeQueryConfig(H2SqlTestSupport.sqlConfig("select 1")).get("fields")); + } + + @Test + public void sanitizeQueryConfigReturnsTheMustacheKeysForGui() { + Map sanitized = executor.sanitizeQueryConfig(H2SqlTestSupport.guiConfig(H2SqlTestSupport.GUI_UPDATE, updateDetail())); + System.out.println("[SqlBasedQueryExecutorContextTest] gui fields: " + sanitized); + assertEquals(Set.of("{{c}}", "{{key}}"), sanitized.get("fields")); + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBasedQueryExecutorDefectPinTest.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBasedQueryExecutorDefectPinTest.java new file mode 100644 index 0000000000..be55fa8683 --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBasedQueryExecutorDefectPinTest.java @@ -0,0 +1,53 @@ +package org.lowcoder.plugin.sql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import java.sql.Connection; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lowcoder.sdk.exception.PluginCommonError.PREPARED_STATEMENT_BIND_PARAMETERS_ERROR; + +/** + * Pins two production defects found in L5-1 (plan section 9, rows "removeQueryComments removes -- comments only" and + * "SqlQueryConfig.getSql() throws a NullPointerException without a sql key"; owner decision D-6: the fixes are deferred). + * A fix changes these tests on purpose. + */ +public class SqlBasedQueryExecutorDefectPinTest { + + static final String BLOCK_COMMENT_SQL = "select 1 as one /* {{y}} */"; + + private final H2SqlTestSupport.H2Executor executor = new H2SqlTestSupport.H2Executor(new GeneralSqlExecutor()); + private final H2SqlTestSupport.H2Config datasource = new H2SqlTestSupport.H2Config("localhost", "db", false, null); + + /** + * Pins the block-comment defect: {@code removeQueryComments} strips {@code --} comments only, so the query keeps the + * block comment with its mustache; the prepared statement then turns {@code {{y}}} into a placeholder inside the + * comment, which the database does not count, and the bind of {@code y} fails as PREPARED_STATEMENT_BIND_PARAMETERS_ERROR. + */ + @Test + public void mustacheInsideABlockCommentIsKeptAndTheBindFails() throws Exception { + SqlBasedQueryExecutionContext context = executor.buildQueryExecutionContext(datasource, + H2SqlTestSupport.sqlConfig(BLOCK_COMMENT_SQL), Map.of("y", 5), null); + assertEquals(BLOCK_COMMENT_SQL, context.getQuery(), "the block comment is not removed"); + try (Connection connection = H2SqlTestSupport.open(H2SqlTestSupport.newUrl("blockcomment"))) { + PluginException thrown = assertThrows(PluginException.class, () -> new GeneralSqlExecutor().execute(connection, context)); + assertEquals(PREPARED_STATEMENT_BIND_PARAMETERS_ERROR, thrown.getError()); + System.out.println("[SqlBasedQueryExecutorDefectPinTest] block comment kept, bind failed: " + thrown.getMessage()); + } + } + + /** Pins the missing-sql defect: a SQL-mode config without a {@code sql} key is a NullPointerException, not SQL_EMPTY. */ + @Test + public void configWithoutSqlKeyIsANullPointerExceptionNotSqlEmpty() { + Map config = Map.of(H2SqlTestSupport.MODE_KEY, H2SqlTestSupport.SQL_MODE); + NullPointerException thrown = assertThrows(NullPointerException.class, + () -> executor.buildQueryExecutionContext(datasource, config, Map.of(), null)); + assertTrue(thrown.getMessage().contains("sql"), thrown.getMessage()); + System.out.println("[SqlBasedQueryExecutorDefectPinTest] no sql key: " + thrown.getMessage()); + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBindContractTest.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBindContractTest.java new file mode 100644 index 0000000000..e62d6e98af --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlBindContractTest.java @@ -0,0 +1,59 @@ +package org.lowcoder.plugin.sql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.UpdateCount; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.RenderValues; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Group {@code downstream-render}, its {@code sqlBasedPlugin} row (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.5): + * {@link GeneralSqlExecutor#execute} runs {@value #QUERY} as a prepared statement with each value of + * {@link RenderValues} as {@code v}, and {@code bindParam} binds it; a list or a map is bound as the text the production + * mapper writes for it ({@code toJson}). The connection ({@link FakeJdbc}) records every {@code set...} call, and the + * exact text of those calls is pinned, one line per value ({@link GoldenJson#assertText}), in {@value #FIXTURE}. + * + *

    Limits: the calls are recorded, not sent to a driver; a null asks the driver for the parameter type first, which + * {@link FakeJdbc} refuses, so the fallback {@code setNull(i, Types.NULL)} is what is pinned. + */ +public class SqlBindContractTest { + + static final String FIXTURE = "downstream-render/GeneralSqlExecutor.binds.txt"; + static final String QUERY = "update items set v = {{v}} where id = 1"; + static final String VALUE_PARAM = "v"; + static final String SEPARATOR = "\t"; + static final String NEWLINE = "\n"; + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites("lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/GeneralSqlExecutor.java#GeneralSqlExecutor.bindParam#toJson#1") + @Test + public void bindsAsSent() { + StringBuilder text = new StringBuilder(); + RenderValues.values().forEach((name, value) -> text.append(name).append(SEPARATOR).append(binds(value)).append(NEWLINE)); + System.out.println("[SqlBindContractTest] " + FIXTURE + "\n" + text); + GOLDEN.assertText(FIXTURE, text.toString()); + } + + /** The {@code set...} calls the statement got for {@code value}, or the error in their place. */ + private static String binds(Object value) { + Map params = new HashMap<>(); + params.put(VALUE_PARAM, value); + SqlBasedQueryExecutionContext context = SqlBasedQueryExecutionContext.builder().query(QUERY).requestParams(params).build(); + List binds = new ArrayList<>(); + try { + new GeneralSqlExecutor().execute(FakeJdbc.connection(List.of(new UpdateCount(1, null)), binds), context); + return String.join(SEPARATOR, binds); + } catch (RuntimeException e) { + return "ERROR " + ConfigBinding.errorText(e); + } + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlConfigBindingContractTest.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlConfigBindingContractTest.java new file mode 100644 index 0000000000..174e0c42f2 --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlConfigBindingContractTest.java @@ -0,0 +1,29 @@ +package org.lowcoder.plugin.sql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; + +import java.util.Map; + +/** + * The {@code sqlBasedPlugin} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1), + * through the real entry points: {@code SqlQueryConfig.from}, whose {@code @JsonAlias} names {@code commandType} + * and {@code command} are one form and the Java names another. {@code ConfigBinding} applies each entry point to + * every form of the input fixture (canonical names, alias or getter-derived names, scalars as strings, missing and + * empty values) and pins the bound state, the written views and the errors in the report fixture. + */ +public class SqlConfigBindingContractTest { + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/SqlQueryConfig.java#SqlQueryConfig.from#toJson#1", + "lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/SqlQueryConfig.java#SqlQueryConfig.from#fromJson#1"}) + @Test + public void queryConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/SqlQueryConfig.input.json", "config-binding/SqlQueryConfig.json", + Map.of("from", SqlQueryConfig::from)); + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlResultContractTest.java b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlResultContractTest.java new file mode 100644 index 0000000000..972ba80459 --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/java/org/lowcoder/plugin/sql/SqlResultContractTest.java @@ -0,0 +1,98 @@ +package org.lowcoder.plugin.sql; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.Column; +import org.lowcoder.sdk.contract.FakeJdbc.Rows; +import org.lowcoder.sdk.contract.FakeJdbc.StatementResult; +import org.lowcoder.sdk.contract.FakeJdbc.UpdateCount; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.plugin.common.sql.SqlBasedQueryExecutionContext; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.ResultSet; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * The SQL plugins' row of the §4.6 producer table (docs/API_PAYLOAD_TEST_PLAN.md §4.6, task T8.2): + * {@link GeneralSqlExecutor#execute} on a connection ({@link FakeJdbc}) whose statement yields each case of + * {@link #CASES} (fresh for each run), run once as a prepared statement and once as plain SQL. The value put into + * {@code QueryExecutionResult.data} is one entry per statement result, unwrapped when there is one: rows parsed by + * {@code ResultSetParser} as {@code List}, or a {@code HashMap} of {@code affectedRows} and, when the driver + * returns any, {@code generatedKeys} as {@code getObject} gives them; a result of one {@code GENERATED_KEYS} row whose + * value is null is dropped. Each case's report ({@link QueryResults#report}) is pinned in {@value #REPORT}; the leaf + * classes a driver can return are pinned by {@code ResultSetParserContractTest} in {@code lowcoder-sdk}. + * + *

    Limits: the statement results come from {@link FakeJdbc}, not a driver; GUI commands and the single-row + * update/delete check are not run. + */ +public class SqlResultContractTest { + + static final String REPORT = "query-results/GeneralSqlExecutor.results.json"; + static final String QUERY = "select * from items"; + static final String PREPARED = "prepared"; + static final String PLAIN = "plain"; + static final String GENERATED_KEYS = "GENERATED_KEYS"; + static final List ITEM_COLUMNS = List.of(new Column("zeta", "VARCHAR"), new Column("id", "BIGINT"), + new Column("price", "DECIMAL"), new Column("ratio", "DOUBLE"), new Column("active", "BIT"), + new Column("created", "datetime"), new Column("note", "VARCHAR")); + static final Map>> CASES = cases(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @Test + public void resultsAsPinned() { + Map report = new LinkedHashMap<>(); + CASES.forEach((name, results) -> { + Map modes = new LinkedHashMap<>(); + modes.put(PREPARED, report(results.get(), false)); + modes.put(PLAIN, report(results.get(), true)); + report.put(name, modes); + }); + String actual = ConfigBinding.write(report); + System.out.println("[SqlResultContractTest] " + CASES.size() + " cases\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + private Object report(List results, boolean disablePreparedStatement) { + SqlBasedQueryExecutionContext context = SqlBasedQueryExecutionContext.builder() + .query(QUERY).requestParams(Map.of()).disablePreparedStatement(disablePreparedStatement).build(); + try { + return QueryResults.report(new GeneralSqlExecutor().execute(FakeJdbc.connection(results), context)); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + } + + private static ResultSet items() { + return FakeJdbc.resultSet(ITEM_COLUMNS, List.of( + Arrays.asList("first", 3_000_000_001L, new BigDecimal("10.50"), 0.1, true, + java.sql.Timestamp.valueOf("2024-02-29 13:14:15"), "žluť"), + Arrays.asList("second", 2L, new BigDecimal("1E+3"), 1e20, false, null, null))); + } + + private static ResultSet keys(Object... keys) { + return FakeJdbc.resultSet(List.of(new Column(GENERATED_KEYS, "BIGINT")), Arrays.stream(keys).map(List::of).toList()); + } + + private static Map>> cases() { + Map>> cases = new LinkedHashMap<>(); + cases.put("select", () -> List.of(new Rows(items()))); + cases.put("selectEmpty", () -> List.of(new Rows(FakeJdbc.resultSet(ITEM_COLUMNS, List.of())))); + cases.put("insertWithKeys", () -> List.of(new UpdateCount(2, keys(7L, BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE))))); + cases.put("updateWithoutKeys", () -> List.of(new UpdateCount(1, keys()))); + cases.put("updateNoKeySet", () -> List.of(new UpdateCount(0, null))); + cases.put("selectThenUpdate", () -> List.of(new Rows(items()), new UpdateCount(1, null))); + cases.put("nullGeneratedKeyRowDropped", () -> List.of( + new Rows(FakeJdbc.resultSet(List.of(new Column(GENERATED_KEYS, "BIGINT")), List.of(Arrays.asList((Object) null)))), + new UpdateCount(1, null))); + return cases; + } +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/config-binding/SqlQueryConfig.input.json b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/config-binding/SqlQueryConfig.input.json new file mode 100644 index 0000000000..fc82a9e4ca --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/config-binding/SqlQueryConfig.input.json @@ -0,0 +1,65 @@ +{ + "raw": { + "sql": " select * from users where id = {{id}} ", + "disablePreparedStatement": true, + "mode": "SQL" + }, + "gui": { + "mode": "GUI", + "commandType": "INSERT", + "command": { + "table": "users", + "changeSet": [ + { + "key": "id", + "value": "{{1}}" + }, + { + "key": "name", + "value": "{{input.value}}" + } + ], + "allowMultiModify": true, + "filterBy": [ + { + "key": "id", + "operator": "=", + "value": "7" + } + ] + } + }, + "guiJavaNames": { + "mode": "GUI", + "guiStatementType": "UPDATE", + "guiStatementDetail": { + "table": "users", + "changeSet": [ + { + "key": "id", + "value": "{{1}}" + }, + { + "key": "name", + "value": "{{input.value}}" + } + ], + "allowMultiModify": true, + "filterBy": [ + { + "key": "id", + "operator": "=", + "value": "7" + } + ] + } + }, + "scalarsAsStrings": { + "sql": "select 1", + "disablePreparedStatement": "true" + }, + "sqlMissing": { + "mode": "SQL" + }, + "empty": {} +} diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/config-binding/SqlQueryConfig.json b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/config-binding/SqlQueryConfig.json new file mode 100644 index 0000000000..540195792c --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/config-binding/SqlQueryConfig.json @@ -0,0 +1,194 @@ +{ + "from" : { + "raw" : { + "class" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "sql" : " select * from users where id = {{id}} ", + "disablePreparedStatement" : true, + "mode" : "SQL", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : {"sql":"select * from users where id = {{id}}","disablePreparedStatement":true,"mode":"SQL","guiStatementType":null,"guiStatementDetail":null,"guiMode":false}, + "Public" : {"sql":"select * from users where id = {{id}}","disablePreparedStatement":true,"mode":"SQL","guiStatementType":null,"guiStatementDetail":null,"guiMode":false}, + "Internal" : {"sql":"select * from users where id = {{id}}","disablePreparedStatement":true,"mode":"SQL","guiStatementType":null,"guiStatementDetail":null,"guiMode":false} + }, + "gui" : { + "class" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "GUI", + "guiStatementType" : "INSERT", + "guiStatementDetail" : { + "table" : "users", + "changeSet" : [ { + "key" : "id", + "value" : "{{1}}" + }, { + "key" : "name", + "value" : "{{input.value}}" + } ], + "allowMultiModify" : true, + "filterBy" : [ { + "key" : "id", + "operator" : "=", + "value" : "7" + } ] + } + }, + "classes" : { + "" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "java.lang.String", + "/guiStatementDetail" : "java.util.LinkedHashMap", + "/guiStatementDetail/table" : "java.lang.String", + "/guiStatementDetail/changeSet" : "java.util.ArrayList", + "/guiStatementDetail/changeSet/0" : "java.util.LinkedHashMap", + "/guiStatementDetail/changeSet/0/key" : "java.lang.String", + "/guiStatementDetail/changeSet/0/value" : "java.lang.String", + "/guiStatementDetail/changeSet/1" : "java.util.LinkedHashMap", + "/guiStatementDetail/changeSet/1/key" : "java.lang.String", + "/guiStatementDetail/changeSet/1/value" : "java.lang.String", + "/guiStatementDetail/allowMultiModify" : "java.lang.Boolean", + "/guiStatementDetail/filterBy" : "java.util.ArrayList", + "/guiStatementDetail/filterBy/0" : "java.util.LinkedHashMap", + "/guiStatementDetail/filterBy/0/key" : "java.lang.String", + "/guiStatementDetail/filterBy/0/operator" : "java.lang.String", + "/guiStatementDetail/filterBy/0/value" : "java.lang.String" + }, + "noView" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.sql.SqlQueryConfig[\"sql\"])" + }, + "Public" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.sql.SqlQueryConfig[\"sql\"])" + }, + "Internal" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.sql.SqlQueryConfig[\"sql\"])" + } + }, + "guiJavaNames" : { + "class" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "GUI", + "guiStatementType" : "UPDATE", + "guiStatementDetail" : { + "table" : "users", + "changeSet" : [ { + "key" : "id", + "value" : "{{1}}" + }, { + "key" : "name", + "value" : "{{input.value}}" + } ], + "allowMultiModify" : true, + "filterBy" : [ { + "key" : "id", + "operator" : "=", + "value" : "7" + } ] + } + }, + "classes" : { + "" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "java.lang.String", + "/guiStatementDetail" : "java.util.LinkedHashMap", + "/guiStatementDetail/table" : "java.lang.String", + "/guiStatementDetail/changeSet" : "java.util.ArrayList", + "/guiStatementDetail/changeSet/0" : "java.util.LinkedHashMap", + "/guiStatementDetail/changeSet/0/key" : "java.lang.String", + "/guiStatementDetail/changeSet/0/value" : "java.lang.String", + "/guiStatementDetail/changeSet/1" : "java.util.LinkedHashMap", + "/guiStatementDetail/changeSet/1/key" : "java.lang.String", + "/guiStatementDetail/changeSet/1/value" : "java.lang.String", + "/guiStatementDetail/allowMultiModify" : "java.lang.Boolean", + "/guiStatementDetail/filterBy" : "java.util.ArrayList", + "/guiStatementDetail/filterBy/0" : "java.util.LinkedHashMap", + "/guiStatementDetail/filterBy/0/key" : "java.lang.String", + "/guiStatementDetail/filterBy/0/operator" : "java.lang.String", + "/guiStatementDetail/filterBy/0/value" : "java.lang.String" + }, + "noView" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.sql.SqlQueryConfig[\"sql\"])" + }, + "Public" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.sql.SqlQueryConfig[\"sql\"])" + }, + "Internal" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.sql.SqlQueryConfig[\"sql\"])" + } + }, + "scalarsAsStrings" : { + "class" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "sql" : "select 1", + "disablePreparedStatement" : true, + "mode" : null, + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "/sql" : "java.lang.String", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "null", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"guiStatementType":null,"guiStatementDetail":null,"guiMode":false}, + "Public" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"guiStatementType":null,"guiStatementDetail":null,"guiMode":false}, + "Internal" : {"sql":"select 1","disablePreparedStatement":true,"mode":null,"guiStatementType":null,"guiStatementDetail":null,"guiMode":false} + }, + "sqlMissing" : { + "class" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "fields" : { + "@class" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "sql" : null, + "disablePreparedStatement" : false, + "mode" : "SQL", + "guiStatementType" : null, + "guiStatementDetail" : null + }, + "classes" : { + "" : "org.lowcoder.plugin.sql.SqlQueryConfig", + "/sql" : "null", + "/disablePreparedStatement" : "java.lang.Boolean", + "/mode" : "java.lang.String", + "/guiStatementType" : "null", + "/guiStatementDetail" : "null" + }, + "noView" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.sql.SqlQueryConfig[\"sql\"])" + }, + "Public" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.sql.SqlQueryConfig[\"sql\"])" + }, + "Internal" : { + "error" : "com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke \"String.trim()\" because \"this.sql\" is null (through reference chain: org.lowcoder.plugin.sql.SqlQueryConfig[\"sql\"])" + } + }, + "empty" : { + "error" : "org.lowcoder.sdk.exception.PluginException: INVALID_QUERY_SETTINGS EMPTY_SQL_QUERY_CONFIG" + }, + "unknownProperty" : "ignored: binds as form raw" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/downstream-render/GeneralSqlExecutor.binds.txt b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/downstream-render/GeneralSqlExecutor.binds.txt new file mode 100644 index 0000000000..88195e94d3 --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/downstream-render/GeneralSqlExecutor.binds.txt @@ -0,0 +1,10 @@ +int setInt(1, java.lang.Integer:1) +long setLong(1, java.lang.Long:3000000001) +decimal setBigDecimal(1, java.math.BigDecimal:1.5) +exponent setBigDecimal(1, java.math.BigDecimal:1.0E+20) +bool setBoolean(1, java.lang.Boolean:true) +null setNull(1, java.lang.Integer:0) +string setString(1, java.lang.String:it's "q" \ žluť) +dollar setString(1, java.lang.String:cost $1) +list setString(1, java.lang.String:[1,"it's \"q\"",2.5,null,[true],{"k":1.50}]) +map setString(1, java.lang.String:{"zeta":"it's","decimal":1.50,"nested":[1,null]}) diff --git a/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/query-results/GeneralSqlExecutor.results.json b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/query-results/GeneralSqlExecutor.results.json new file mode 100644 index 0000000000..c0db000d2c --- /dev/null +++ b/server/api-service/lowcoder-plugins/sqlBasedPlugin/src/test/resources/json-contract/query-results/GeneralSqlExecutor.results.json @@ -0,0 +1,270 @@ +{ + "select" : { + "prepared" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.util.LinkedHashMap", + "/0/zeta" : "java.lang.String", + "/0/id" : "java.lang.Long", + "/0/price" : "java.math.BigDecimal", + "/0/ratio" : "java.lang.Double", + "/0/active" : "java.lang.Boolean", + "/0/created" : "java.lang.String", + "/0/note" : "java.lang.String", + "/1" : "java.util.LinkedHashMap", + "/1/zeta" : "java.lang.String", + "/1/id" : "java.lang.Long", + "/1/price" : "java.math.BigDecimal", + "/1/ratio" : "java.lang.Double", + "/1/active" : "java.lang.Boolean", + "/1/created" : "null", + "/1/note" : "null" + }, + "keyOrder" : { + "/0" : [ "zeta", "id", "price", "ratio", "active", "created", "note" ], + "/1" : [ "zeta", "id", "price", "ratio", "active", "created", "note" ] + } + }, + "written" : {"queryCode":"OK","data":[{"zeta":"first","id":3000000001,"price":10.50,"ratio":0.1,"active":true,"created":"2024-02-29 13:14:15","note":"žluť"},{"zeta":"second","id":2,"price":1E+3,"ratio":1.0E20,"active":false,"created":null,"note":null}],"success":true} + }, + "plain" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.util.LinkedHashMap", + "/0/zeta" : "java.lang.String", + "/0/id" : "java.lang.Long", + "/0/price" : "java.math.BigDecimal", + "/0/ratio" : "java.lang.Double", + "/0/active" : "java.lang.Boolean", + "/0/created" : "java.lang.String", + "/0/note" : "java.lang.String", + "/1" : "java.util.LinkedHashMap", + "/1/zeta" : "java.lang.String", + "/1/id" : "java.lang.Long", + "/1/price" : "java.math.BigDecimal", + "/1/ratio" : "java.lang.Double", + "/1/active" : "java.lang.Boolean", + "/1/created" : "null", + "/1/note" : "null" + }, + "keyOrder" : { + "/0" : [ "zeta", "id", "price", "ratio", "active", "created", "note" ], + "/1" : [ "zeta", "id", "price", "ratio", "active", "created", "note" ] + } + }, + "written" : {"queryCode":"OK","data":[{"zeta":"first","id":3000000001,"price":10.50,"ratio":0.1,"active":true,"created":"2024-02-29 13:14:15","note":"žluť"},{"zeta":"second","id":2,"price":1E+3,"ratio":1.0E20,"active":false,"created":null,"note":null}],"success":true} + } + }, + "selectEmpty" : { + "prepared" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":[],"success":true} + }, + "plain" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList" + }, + "keyOrder" : { } + }, + "written" : {"queryCode":"OK","data":[],"success":true} + } + }, + "insertWithKeys" : { + "prepared" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.HashMap", + "/affectedRows" : "java.lang.Integer", + "/generatedKeys" : "java.util.ArrayList", + "/generatedKeys/0" : "java.lang.Long", + "/generatedKeys/1" : "java.math.BigInteger" + }, + "keyOrder" : { + "" : [ "affectedRows", "generatedKeys" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":2,"generatedKeys":[7,9223372036854775808]},"success":true} + }, + "plain" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.HashMap", + "/affectedRows" : "java.lang.Integer", + "/generatedKeys" : "java.util.ArrayList", + "/generatedKeys/0" : "java.lang.Long", + "/generatedKeys/1" : "java.math.BigInteger" + }, + "keyOrder" : { + "" : [ "affectedRows", "generatedKeys" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":2,"generatedKeys":[7,9223372036854775808]},"success":true} + } + }, + "updateWithoutKeys" : { + "prepared" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.HashMap", + "/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":1},"success":true} + }, + "plain" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.HashMap", + "/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":1},"success":true} + } + }, + "updateNoKeySet" : { + "prepared" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.HashMap", + "/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":0},"success":true} + }, + "plain" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.HashMap", + "/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":0},"success":true} + } + }, + "selectThenUpdate" : { + "prepared" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.util.ArrayList", + "/0/0" : "java.util.LinkedHashMap", + "/0/0/zeta" : "java.lang.String", + "/0/0/id" : "java.lang.Long", + "/0/0/price" : "java.math.BigDecimal", + "/0/0/ratio" : "java.lang.Double", + "/0/0/active" : "java.lang.Boolean", + "/0/0/created" : "java.lang.String", + "/0/0/note" : "java.lang.String", + "/0/1" : "java.util.LinkedHashMap", + "/0/1/zeta" : "java.lang.String", + "/0/1/id" : "java.lang.Long", + "/0/1/price" : "java.math.BigDecimal", + "/0/1/ratio" : "java.lang.Double", + "/0/1/active" : "java.lang.Boolean", + "/0/1/created" : "null", + "/0/1/note" : "null", + "/1" : "java.util.HashMap", + "/1/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "/0/0" : [ "zeta", "id", "price", "ratio", "active", "created", "note" ], + "/0/1" : [ "zeta", "id", "price", "ratio", "active", "created", "note" ], + "/1" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":[[{"zeta":"first","id":3000000001,"price":10.50,"ratio":0.1,"active":true,"created":"2024-02-29 13:14:15","note":"žluť"},{"zeta":"second","id":2,"price":1E+3,"ratio":1.0E20,"active":false,"created":null,"note":null}],{"affectedRows":1}],"success":true} + }, + "plain" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.util.ArrayList", + "/0/0" : "java.util.LinkedHashMap", + "/0/0/zeta" : "java.lang.String", + "/0/0/id" : "java.lang.Long", + "/0/0/price" : "java.math.BigDecimal", + "/0/0/ratio" : "java.lang.Double", + "/0/0/active" : "java.lang.Boolean", + "/0/0/created" : "java.lang.String", + "/0/0/note" : "java.lang.String", + "/0/1" : "java.util.LinkedHashMap", + "/0/1/zeta" : "java.lang.String", + "/0/1/id" : "java.lang.Long", + "/0/1/price" : "java.math.BigDecimal", + "/0/1/ratio" : "java.lang.Double", + "/0/1/active" : "java.lang.Boolean", + "/0/1/created" : "null", + "/0/1/note" : "null", + "/1" : "java.util.HashMap", + "/1/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "/0/0" : [ "zeta", "id", "price", "ratio", "active", "created", "note" ], + "/0/1" : [ "zeta", "id", "price", "ratio", "active", "created", "note" ], + "/1" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":[[{"zeta":"first","id":3000000001,"price":10.50,"ratio":0.1,"active":true,"created":"2024-02-29 13:14:15","note":"žluť"},{"zeta":"second","id":2,"price":1E+3,"ratio":1.0E20,"active":false,"created":null,"note":null}],{"affectedRows":1}],"success":true} + } + }, + "nullGeneratedKeyRowDropped" : { + "prepared" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.HashMap", + "/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":1},"success":true} + }, + "plain" : { + "queryCode" : "OK", + "dataShape" : { + "classes" : { + "" : "java.util.HashMap", + "/affectedRows" : "java.lang.Integer" + }, + "keyOrder" : { + "" : [ "affectedRows" ] + } + }, + "written" : {"queryCode":"OK","data":{"affectedRows":1},"success":true} + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-sdk/pom.xml b/server/api-service/lowcoder-sdk/pom.xml index ee1c73b5b6..8d41876d69 100644 --- a/server/api-service/lowcoder-sdk/pom.xml +++ b/server/api-service/lowcoder-sdk/pom.xml @@ -33,12 +33,12 @@ compile - junit - junit + org.junit.jupiter + junit-jupiter test - + org.springframework spring-test @@ -185,6 +185,40 @@ + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + test-jar + + + + + org/lowcoder/sdk/contract/** + org/lowcoder/sdk/test/JsonFileReader*.class + org/lowcoder/sdk/config/dynamic/ConfigCenterForTest*.class + + + + org/lowcoder/sdk/contract/**/*Test.class + org/lowcoder/sdk/contract/**/*Test$*.class + + + + + + + + UTF-8 UTF-8 diff --git a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/models/DatasourceStructure.java b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/models/DatasourceStructure.java index 365c2c2e3d..33e3ff292c 100644 --- a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/models/DatasourceStructure.java +++ b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/models/DatasourceStructure.java @@ -20,13 +20,11 @@ package org.lowcoder.sdk.models; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.google.gson.InstanceCreator; import jakarta.annotation.Nonnull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import java.lang.reflect.Type; import java.util.List; import java.util.Map; @@ -166,15 +164,4 @@ public Template(String title, String body) { } - /** - * Instance creator is required while de-serialising using Gson as key instance can't be invoked with - * no-args constructor - */ - public static class KeyInstanceCreator implements InstanceCreator { - @Override - public Key createInstance(Type type) { - return () -> null; - } - } - } diff --git a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/HttpUtils.java b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/HttpUtils.java deleted file mode 100644 index 0bfdb0c163..0000000000 --- a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/HttpUtils.java +++ /dev/null @@ -1,112 +0,0 @@ -package org.lowcoder.sdk.util; - -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.collections4.MapUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.http.HttpEntity; -import org.apache.http.NameValuePair; -import org.apache.http.client.entity.UrlEncodedFormEntity; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpUriRequest; -import org.apache.http.client.utils.URIBuilder; -import org.apache.http.entity.ContentType; -import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.message.BasicNameValuePair; -import org.apache.http.util.EntityUtils; -import org.springframework.http.HttpMethod; - -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; - -import static org.apache.http.HttpHeaders.CONTENT_TYPE; -import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; - -@Slf4j -public class HttpUtils { - - private static final CloseableHttpClient HTTP_CLIENT = HttpClients.createDefault(); - - public static String request(HttpMethod method, String uri, Map params, Map headers, String jsonBody) { - return switch (method.name()) { - case HttpGet.METHOD_NAME -> get(uri, params, headers); - case HttpPost.METHOD_NAME -> post(uri, params, headers, jsonBody); - default -> throw new IllegalStateException("Unexpected value: " + method); - }; - } - - public static String get(String uri, Map params, Map headers) { - HttpGet get = new HttpGet(buildUri(uri, params)); - if (MapUtils.isNotEmpty(headers)) { - for (Entry entry : headers.entrySet()) { - get.setHeader(entry.getKey(), entry.getValue()); - } - } - return execute(get); - } - - public static String post(String uri, Map params, Map headers, String jsonBody) { - HttpPost post; - if (MapUtils.isNotEmpty(headers) && APPLICATION_FORM_URLENCODED_VALUE.equals(headers.get(CONTENT_TYPE))) { - post = new HttpPost(buildUri(uri, null)); - if (MapUtils.isNotEmpty(params)) { - List pairs = new ArrayList<>(); - for (Entry entry : params.entrySet()) { - pairs.add(new BasicNameValuePair(entry.getKey(), entry.getValue())); - } - try { - post.setEntity(new UrlEncodedFormEntity(pairs)); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } - } - } else { - post = new HttpPost(buildUri(uri, params)); - } - - if (MapUtils.isNotEmpty(headers)) { - for (Entry entry : headers.entrySet()) { - post.setHeader(entry.getKey(), entry.getValue()); - } - } - if (StringUtils.isNotBlank(jsonBody)) { - HttpEntity entity = new StringEntity(jsonBody, ContentType.APPLICATION_JSON); - post.setEntity(entity); - } - return execute(post); - } - - private static String execute(HttpUriRequest request) { - try { - CloseableHttpResponse response = HTTP_CLIENT.execute(request); - return EntityUtils.toString(response.getEntity()); - } catch (IOException e) { - log.error("http get error.{}", request, e); - throw new RuntimeException(e); - } - } - - private static URI buildUri(String uri, Map params) { - try { - URIBuilder uriBuilder = new URIBuilder(uri); - if (MapUtils.isNotEmpty(params)) { - for (Entry entry : params.entrySet()) { - uriBuilder.addParameter(entry.getKey(), entry.getValue()); - } - } - return uriBuilder.build(); - } catch (URISyntaxException e) { - log.error("build uri error.{},{}", uri, JsonUtils.toJson(params), e); - throw new RuntimeException(e); - } - } -} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/AuthPropertiesTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/AuthPropertiesTest.java new file mode 100644 index 0000000000..24c01b5fe5 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/AuthPropertiesTest.java @@ -0,0 +1,129 @@ +package org.lowcoder.sdk.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.auth.AbstractAuthConfig; +import org.lowcoder.sdk.auth.EmailAuthConfig; +import org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig; +import org.lowcoder.sdk.auth.constants.AuthTypeConstants; +import org.lowcoder.sdk.constants.AuthSourceConstants; + +/** + * {@link AuthProperties}: the register switch of an auth way and the conversion of the {@code auth.*} properties into the + * auth configs offered at login (the saas mode, where no config is stored in the database). + */ +public class AuthPropertiesTest { + + private static AuthProperties.AuthWay way(boolean enable, Boolean enableRegister) { + AuthProperties.AuthWay way = new AuthProperties.AuthWay(); + way.setEnable(enable); + way.setEnableRegister(enableRegister); + return way; + } + + @Test + public void registerIsAllowedOnlyWhenTheWayIsEnabledAndRegisterIsNotSwitchedOff() { + // enable x enableRegister (null = not configured: follows enable) + assertFalse(way(false, null).isEnableRegister(), "disabled, register unset"); + assertTrue(way(true, null).isEnableRegister(), "enabled, register unset follows enable"); + assertFalse(way(false, true).isEnableRegister(), "disabled way cannot register even if register is true"); + assertFalse(way(false, false).isEnableRegister()); + assertTrue(way(true, true).isEnableRegister()); + assertFalse(way(true, false).isEnableRegister(), "register switched off"); + } + + @Test + public void nothingIsOfferedWhenNoProviderIsEnabled() { + AuthProperties properties = new AuthProperties(); + properties.getGoogle().setClientId("g-id"); + properties.getGithub().setClientSecret("h-secret"); + + assertEquals(List.of(), properties.getAuthConfigs(), "a provider with credentials but not enabled is not offered"); + } + + @Test + public void anEnabledEmailWayIsOfferedAsAFormConfigWithItsRegisterSwitch() { + AuthProperties properties = new AuthProperties(); + properties.getEmail().setEnable(true); + properties.getEmail().setEnableRegister(false); + + List configs = properties.getAuthConfigs(); + + assertEquals(1, configs.size()); + AbstractAuthConfig email = configs.get(0); + System.out.println("[AuthPropertiesTest] email -> source " + email.getSource() + ", type " + email.getAuthType() + ", enable " + email.isEnable() + + ", register " + email.isEnableRegister()); + assertInstanceOf(EmailAuthConfig.class, email); + assertEquals(AuthSourceConstants.EMAIL, email.getSource()); + assertEquals(AuthTypeConstants.FORM, email.getAuthType()); + assertTrue(email.isEnable()); + assertFalse(email.isEnableRegister()); + assertEquals(AuthSourceConstants.EMAIL, email.getId(), "an unsaved config is identified by its source"); + } + + @Test + public void googleAndGithubAreOfferedAfterEmailWithTheirOwnCredentialsSourceAndType() { + AuthProperties properties = new AuthProperties(); + properties.getEmail().setEnable(true); + properties.getGoogle().setEnable(true); + properties.getGoogle().setClientId("google-id"); + properties.getGoogle().setClientSecret("google-secret"); + properties.getGithub().setEnable(true); + properties.getGithub().setEnableRegister(false); + properties.getGithub().setClientId("github-id"); + properties.getGithub().setClientSecret("github-secret"); + + List configs = properties.getAuthConfigs(); + + System.out.println("[AuthPropertiesTest] " + configs.stream().map(c -> c.getSource() + "/" + c.getAuthType()).toList()); + assertEquals(List.of(AuthSourceConstants.EMAIL, AuthSourceConstants.GOOGLE, AuthSourceConstants.GITHUB), configs.stream().map(AbstractAuthConfig::getSource).toList()); + Oauth2SimpleAuthConfig google = (Oauth2SimpleAuthConfig) configs.get(1); + Oauth2SimpleAuthConfig github = (Oauth2SimpleAuthConfig) configs.get(2); + assertEquals(AuthTypeConstants.GOOGLE, google.getAuthType()); + assertEquals(AuthSourceConstants.GOOGLE_NAME, google.getSourceName()); + assertEquals("google-id", google.getClientId()); + assertEquals("google-secret", google.getClientSecret()); + assertTrue(google.isEnable()); + assertTrue(google.isEnableRegister(), "register unset follows enable"); + assertEquals(AuthTypeConstants.GITHUB, github.getAuthType()); + assertEquals(AuthSourceConstants.GITHUB_NAME, github.getSourceName()); + assertEquals("github-id", github.getClientId()); + assertEquals("github-secret", github.getClientSecret()); + assertTrue(github.isEnable()); + assertFalse(github.isEnableRegister(), "register switched off"); + } + + @Test + public void aGoogleWayWithRegisterSwitchedOffIsOfferedWithoutRegister() { + AuthProperties properties = new AuthProperties(); + properties.getGoogle().setEnable(true); + properties.getGoogle().setEnableRegister(false); + + List configs = properties.getAuthConfigs(); + + assertEquals(1, configs.size()); + assertTrue(configs.get(0).isEnable()); + assertFalse(configs.get(0).isEnableRegister()); + } + + @Test + public void onlyTheEnabledOnesAreOfferedWhateverTheOthersHold() { + AuthProperties properties = new AuthProperties(); + properties.getGithub().setEnable(true); + properties.getGithub().setClientId("github-id"); + properties.getGoogle().setClientId("google-id"); + + List configs = properties.getAuthConfigs(); + + assertEquals(List.of(AuthSourceConstants.GITHUB), configs.stream().map(AbstractAuthConfig::getSource).toList()); + assertNull(properties.getWorkspaceCreation(), "not configured"); + assertNull(properties.getApiKey().getSecret()); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/CommonConfigTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/CommonConfigTest.java new file mode 100644 index 0000000000..de3cd44da9 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/CommonConfigTest.java @@ -0,0 +1,100 @@ +package org.lowcoder.sdk.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.constants.WorkspaceMode; + +/** + * {@link CommonConfig}: the human-readable size normalisation behind the three {@code max*Size} settings, the combination + * of the two CORS settings, and the small derived values. + */ +public class CommonConfigTest { + + @Test + public void sizesWithAUnitAreNormalisedToNumberLetterAndBAndOtherTextIsKept() { + Map expected = new LinkedHashMap<>(); + expected.put("10m", "10MB"); + expected.put("10M", "10MB"); + expected.put("10 MB", "10MB"); + expected.put(" 1.5 g ", "1.5GB"); + expected.put("2k", "2KB"); + expected.put("3T", "3TB"); + expected.put("5MB", "5MB"); + expected.put("5mb", "5MB"); + expected.put("512", "512"); + expected.put("7 b", "7"); + expected.put("abc", "abc"); + expected.put("10 xb", "10 xb"); + expected.put("-5m", "-5m"); + expected.put("1.m", "1.m"); + expected.put("", ""); + expected.forEach((input, normalized) -> { + System.out.println("[CommonConfigTest] '" + input + "' -> '" + CommonConfig.normalizeDataUnits(input) + "'"); + assertEquals(normalized, CommonConfig.normalizeDataUnits(input), "'" + input + "'"); + }); + } + + @Test + public void theThreeSizeSettersNormaliseAndTheDefaultsAreTwentyMegabytes() { + CommonConfig config = new CommonConfig(); + assertEquals("20MB", config.getMaxUploadSize()); + assertEquals("20MB", config.getMaxQueryRequestSize()); + assertEquals("20MB", config.getMaxQueryResponseSize()); + assertEquals(300, config.getMaxQueryTimeout()); + + config.setMaxUploadSize("5m"); + config.setMaxQueryRequestSize("1 g"); + config.setMaxQueryResponseSize("100k"); + + assertEquals("5MB", config.getMaxUploadSize()); + assertEquals("1GB", config.getMaxQueryRequestSize()); + assertEquals("100KB", config.getMaxQueryResponseSize()); + } + + @Test + public void corsDomainsFromTheListAndTheCommaStringAreCombinedListFirst() { + CommonConfig.Security security = new CommonConfig.Security(); + assertEquals(List.of(), security.getAllCorsAllowedDomains(), "nothing configured"); + + security.setCorsAllowedDomainString("a.com,b.com"); + assertEquals(List.of("a.com", "b.com"), security.getAllCorsAllowedDomains()); + + security.setCorsAllowedDomains(List.of("x.com")); + assertEquals(List.of("x.com", "a.com", "b.com"), security.getAllCorsAllowedDomains()); + + security.setCorsAllowedDomainString(" "); + assertEquals(List.of("x.com"), security.getAllCorsAllowedDomains(), "a blank string adds nothing"); + + security.setCorsAllowedDomains(List.of()); + security.setCorsAllowedDomainString("*"); + assertEquals(List.of("*"), security.getAllCorsAllowedDomains(), "the default of the application yaml"); + } + + @Test + public void derivedValuesFollowTheirSettings() { + CommonConfig config = new CommonConfig(); + assertTrue(config.isSelfHost()); + assertFalse(config.isEnterpriseMode(), "the default workspace mode is saas"); + config.setCloud(true); + config.getWorkspace().setMode(WorkspaceMode.ENTERPRISE); + assertFalse(config.isSelfHost()); + assertTrue(config.isEnterpriseMode()); + + assertEquals(24L * 3600, config.getCookie().getMaxAgeInSeconds(), "one day by default"); + config.getCookie().setMaxAgeInHours(2); + assertEquals(7200L, config.getCookie().getMaxAgeInSeconds()); + + assertEquals(List.of(), config.getSecurity().getForbiddenEndpoints(), "unset gives an empty list, not null"); + CommonConfig.ApiEndpoint endpoint = new CommonConfig.ApiEndpoint(); + endpoint.setUri("/x"); + config.getSecurity().setForbiddenEndpoints(List.of(endpoint)); + assertEquals(List.of(endpoint), config.getSecurity().getForbiddenEndpoints()); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/CorsAllowedDomainsTrimTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/CorsAllowedDomainsTrimTest.java new file mode 100644 index 0000000000..94239b21fe --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/CorsAllowedDomainsTrimTest.java @@ -0,0 +1,53 @@ +package org.lowcoder.sdk.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.web.cors.CorsConfiguration; + +/** + * DEFECT pinned (plan section 9 row "CORS domains split without trimming: ' b.com' never matches (CommonConfig:112, + * SecurityConfig:226)"; D-6, fix deferred). The environment variable LOWCODER_CORS_DOMAINS (application.yaml:75, documented in + * app.json:32 as "If there are multiple domains, please separate them with commas") reaches + * {@code CommonConfig.Security.corsAllowedDomainString}, which {@code getAllCorsAllowedDomains} splits on "," without + * trimming (CommonConfig.java:112). "https://a.com, https://b.com" therefore yields the entry {@code " https://b.com"}, and + * SecurityConfig.java:226 passes the list to {@code CorsConfiguration.setAllowedOriginPatterns}, which then rejects the origin + * https://b.com. A fix that trims each entry turns the rejection assertions red. + */ +public class CorsAllowedDomainsTrimTest { + + private static CorsConfiguration corsFor(String corsAllowedDomainString) { + CommonConfig.Security security = new CommonConfig.Security(); + security.setCorsAllowedDomainString(corsAllowedDomainString); + CorsConfiguration cors = new CorsConfiguration(); + cors.setAllowedOriginPatterns(security.getAllCorsAllowedDomains()); + return cors; + } + + @Test + public void aCommaFollowedByABlankKeepsTheBlankInTheEntryAndTheOriginIsRejected() { + CommonConfig.Security security = new CommonConfig.Security(); + security.setCorsAllowedDomainString("https://a.com, https://b.com"); + CorsConfiguration cors = corsFor("https://a.com, https://b.com"); + + System.out.println("[CorsAllowedDomainsTrimTest] entries " + security.getAllCorsAllowedDomains().stream().map(e -> "'" + e + "'").toList() + + ", a.com -> " + cors.checkOrigin("https://a.com") + ", b.com -> " + cors.checkOrigin("https://b.com")); + assertEquals(List.of("https://a.com", " https://b.com"), security.getAllCorsAllowedDomains()); + assertEquals("https://a.com", cors.checkOrigin("https://a.com")); + assertNull(cors.checkOrigin("https://b.com"), "the second domain of the documented comma list never matches"); + } + + @Test + public void theSameListWithoutTheBlankAcceptsBothOrigins() { + CorsConfiguration cors = corsFor("https://a.com,https://b.com"); + + assertEquals("https://a.com", cors.checkOrigin("https://a.com")); + assertEquals("https://b.com", cors.checkOrigin("https://b.com")); + assertNull(cors.checkOrigin("https://c.com")); + assertNotNull(corsFor("*").checkOrigin("https://anything.example"), "the default of the application yaml allows every origin"); + } +} diff --git a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/config/dynamic/ConfigCenterForTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/dynamic/ConfigCenterForTest.java similarity index 100% rename from server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/config/dynamic/ConfigCenterForTest.java rename to server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/config/dynamic/ConfigCenterForTest.java diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/BoundarySites.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/BoundarySites.java new file mode 100644 index 0000000000..ae0ec1b182 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/BoundarySites.java @@ -0,0 +1,24 @@ +package org.lowcoder.sdk.contract; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * The JSON boundary sites a test method exercises (docs/API_PAYLOAD_TEST_PLAN.md §4.10, §6.2): the stable row keys, + * {@code ###}, of {@code lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-sites.tsv} (its fifth column). + * + *

    {@code JsonBoundaryGateTest} reads this annotation from test sources, so the keys must be string + * literals and the annotated method must be a {@code @Test} method under one of its group's test source roots + * ({@code lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-groups.tsv}). Limit: it records that a test claims a site, not that the test + * reaches it; every test calls the site's real entry point (plan §4.7, §4.9), which the reviewer checks. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface BoundarySites { + + String[] value(); +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/CanonicalJson.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/CanonicalJson.java new file mode 100644 index 0000000000..d4e656c49e --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/CanonicalJson.java @@ -0,0 +1,390 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; + +/** + * Compares JSON documents by the JSON contract of docs/API_PAYLOAD_TEST_PLAN.md §1.2, and Java values bound from + * JSON by their runtime classes. + * + *

    Part of the contract, so a difference is a failure: property names and presence, value types, null + * versus absent, number lexemes ({@code 1} versus {@code 1.0}), duplicate keys and the order of the values of one + * key, array order. Not part of the contract, so a difference is only a report: whitespace (not even + * reported), the order of different object members, and the escape form of strings and keys. + * + *

    The documents are read with Jackson's streaming {@link JsonParser}, never with a mapper: number tokens keep + * their lexeme ({@link JsonParser#getText()}) and every object keeps all its members, duplicates included, as a list + * of (key, value) pairs. Members are grouped by key; groups are compared regardless of key order, and values inside + * a group in order. + * + *

    Limits: rendered text (SQL, Mongo, query strings) is compared exactly with {@link GoldenJson#assertText}, not + * here. {@link #compareJava} compares leaf values with {@code equals}, so it is meant for values that JSON binding + * produces (maps, collections, arrays, numbers, strings, booleans, records); a class without {@code equals} is + * compared by identity. + */ +public final class CanonicalJson { + + public static final String ROOT_PATH = "$"; + public static final String REPORT_PREFIX = "[contract] report "; + private static final JsonFactory FACTORY = JsonFactory.builder().build(); + private static final char QUOTE = '"'; + private static final char ESCAPE = '\\'; + + private CanonicalJson() { + } + + /** A parsed JSON value. */ + public sealed interface Node permits ObjectValue, ArrayValue, ScalarValue { + } + + /** One object member as written, with the key's raw source text (escapes included). */ + public record Member(String key, String rawKey, Node value) { + } + + public record ObjectValue(List members) implements Node { + } + + public record ArrayValue(List elements) implements Node { + } + + public enum ScalarKind {STRING, NUMBER, TRUE, FALSE, NULL} + + /** A scalar: {@code value} is the decoded string or the number lexeme; {@code raw} is the source text. */ + public record ScalarValue(ScalarKind kind, String value, String raw) implements Node { + } + + /** Contract differences ({@code failures}) and differences outside the contract ({@code reports}). */ + public record Comparison(List failures, List reports) { + + public boolean equivalent() { + return failures.isEmpty(); + } + } + + /** Parses one JSON document, keeping number lexemes, duplicate members and raw string text. */ + public static Node parse(String json) { + try (JsonParser parser = FACTORY.createParser(json)) { + JsonToken first = parser.nextToken(); + if (first == null) { + throw new IllegalArgumentException("empty JSON document"); + } + Node node = read(parser, json); + if (parser.nextToken() != null) { + throw new IllegalArgumentException("trailing content after the JSON document at " + + parser.currentTokenLocation()); + } + return node; + } catch (IOException e) { + throw new UncheckedIOException("not a JSON document: " + e.getMessage(), e); + } + } + + private static Node read(JsonParser parser, String source) throws IOException { + JsonToken token = parser.currentToken(); + switch (token) { + case START_OBJECT -> { + List members = new ArrayList<>(); + while (parser.nextToken() == JsonToken.FIELD_NAME) { + String key = parser.currentName(); + String rawKey = rawText(parser, source); + parser.nextToken(); + members.add(new Member(key, rawKey, read(parser, source))); + } + return new ObjectValue(members); + } + case START_ARRAY -> { + List elements = new ArrayList<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + elements.add(read(parser, source)); + } + return new ArrayValue(elements); + } + case VALUE_STRING -> { + String value = parser.getText(); + return new ScalarValue(ScalarKind.STRING, value, rawText(parser, source)); + } + case VALUE_NUMBER_INT, VALUE_NUMBER_FLOAT -> { + String lexeme = parser.getText(); + return new ScalarValue(ScalarKind.NUMBER, lexeme, lexeme); + } + case VALUE_TRUE -> { + return new ScalarValue(ScalarKind.TRUE, "true", "true"); + } + case VALUE_FALSE -> { + return new ScalarValue(ScalarKind.FALSE, "false", "false"); + } + case VALUE_NULL -> { + return new ScalarValue(ScalarKind.NULL, "null", "null"); + } + default -> throw new IllegalStateException("unexpected token " + token + " at " + parser.currentTokenLocation()); + } + } + + /** + * The source text of the current string or field-name token, quotes included. It scans from the token's opening + * quote to the first unescaped closing quote, because the parser's current location may already lie past the + * following separator and value. + */ + private static String rawText(JsonParser parser, String source) { + int start = (int) parser.currentTokenLocation().getCharOffset(); + if (start < 0 || start >= source.length() || source.charAt(start) != QUOTE) { + throw new IllegalStateException("no string token at offset " + start); + } + int index = start + 1; + while (index < source.length() && source.charAt(index) != QUOTE) { + index += source.charAt(index) == ESCAPE ? 2 : 1; + } + return source.substring(start, Math.min(index + 1, source.length())); + } + + /** Compares two JSON documents by the §1.2 contract. */ + public static Comparison compare(String expectedJson, String actualJson) { + List failures = new ArrayList<>(); + List reports = new ArrayList<>(); + compareNodes(ROOT_PATH, parse(expectedJson), parse(actualJson), failures, reports); + return new Comparison(List.copyOf(failures), List.copyOf(reports)); + } + + /** + * Fails with an {@link AssertionError} listing every contract difference; prints the differences outside the + * contract to standard output, prefixed with {@link #REPORT_PREFIX}. + */ + public static void assertEquivalent(String expectedJson, String actualJson) { + Comparison comparison = compare(expectedJson, actualJson); + comparison.reports().forEach(report -> System.out.println(REPORT_PREFIX + report)); + if (!comparison.equivalent()) { + throw new AssertionError("JSON differs from the contract:\n " + String.join("\n ", comparison.failures()) + + "\nexpected: " + expectedJson + "\nactual: " + actualJson); + } + } + + private static void compareNodes(String path, Node expected, Node actual, List failures, List reports) { + if (expected instanceof ObjectValue expectedObject && actual instanceof ObjectValue actualObject) { + compareObjects(path, expectedObject, actualObject, failures, reports); + } else if (expected instanceof ArrayValue expectedArray && actual instanceof ArrayValue actualArray) { + compareArrays(path, expectedArray, actualArray, failures, reports); + } else if (expected instanceof ScalarValue expectedScalar && actual instanceof ScalarValue actualScalar + && expectedScalar.kind() == actualScalar.kind()) { + compareScalars(path, expectedScalar, actualScalar, failures, reports); + } else { + failures.add(path + ": type differs, expected " + describe(expected) + ", actual " + describe(actual)); + } + } + + private static void compareObjects(String path, ObjectValue expected, ObjectValue actual, List failures, + List reports) { + Map> expectedGroups = groupByKey(expected); + Map> actualGroups = groupByKey(actual); + for (String key : expectedGroups.keySet()) { + if (!actualGroups.containsKey(key)) { + failures.add(memberPath(path, key) + ": missing property"); + } + } + for (String key : actualGroups.keySet()) { + if (!expectedGroups.containsKey(key)) { + failures.add(memberPath(path, key) + ": unexpected property"); + } + } + for (Map.Entry> group : expectedGroups.entrySet()) { + List actualMembers = actualGroups.get(group.getKey()); + if (actualMembers == null) { + continue; + } + List expectedMembers = group.getValue(); + String keyPath = memberPath(path, group.getKey()); + if (expectedMembers.size() != actualMembers.size()) { + failures.add(keyPath + ": key occurs " + expectedMembers.size() + " time(s) in expected, " + + actualMembers.size() + " in actual (duplicate keys are part of the contract)"); + } + for (int i = 0; i < Math.min(expectedMembers.size(), actualMembers.size()); i++) { + String occurrencePath = expectedMembers.size() > 1 ? keyPath + "#" + (i + 1) : keyPath; + Member expectedMember = expectedMembers.get(i); + Member actualMember = actualMembers.get(i); + if (!expectedMember.rawKey().equals(actualMember.rawKey())) { + reports.add(occurrencePath + ": key escape form differs, expected " + expectedMember.rawKey() + + ", actual " + actualMember.rawKey()); + } + compareNodes(occurrencePath, expectedMember.value(), actualMember.value(), failures, reports); + } + } + List expectedOrder = new ArrayList<>(expectedGroups.keySet()); + List actualOrder = new ArrayList<>(actualGroups.keySet()); + expectedOrder.retainAll(actualGroups.keySet()); + actualOrder.retainAll(expectedGroups.keySet()); + if (!expectedOrder.equals(actualOrder)) { + reports.add(path + ": member order differs, expected " + expectedOrder + ", actual " + actualOrder); + } + } + + private static Map> groupByKey(ObjectValue object) { + Map> groups = new LinkedHashMap<>(); + for (Member member : object.members()) { + groups.computeIfAbsent(member.key(), ignored -> new ArrayList<>()).add(member); + } + return groups; + } + + private static void compareArrays(String path, ArrayValue expected, ArrayValue actual, List failures, + List reports) { + if (expected.elements().size() != actual.elements().size()) { + failures.add(path + ": array length differs, expected " + expected.elements().size() + ", actual " + + actual.elements().size()); + } + for (int i = 0; i < Math.min(expected.elements().size(), actual.elements().size()); i++) { + compareNodes(path + "[" + i + "]", expected.elements().get(i), actual.elements().get(i), failures, reports); + } + } + + private static void compareScalars(String path, ScalarValue expected, ScalarValue actual, List failures, + List reports) { + if (!expected.value().equals(actual.value())) { + String what = expected.kind() == ScalarKind.NUMBER ? "number lexeme" : "value"; + failures.add(path + ": " + what + " differs, expected " + expected.raw() + ", actual " + actual.raw()); + } else if (!expected.raw().equals(actual.raw())) { + reports.add(path + ": string escape form differs, expected " + expected.raw() + ", actual " + actual.raw()); + } + } + + private static String describe(Node node) { + if (node instanceof ScalarValue scalar) { + return scalar.kind().name().toLowerCase() + " " + scalar.raw(); + } + return node instanceof ObjectValue ? "object" : "array"; + } + + private static String memberPath(String path, String key) { + return path + "." + key; + } + + /** + * Compares two Java values deeply, including the runtime class of every value and container: {@code Integer} + * versus {@code Long} and {@code HashSet} versus {@code LinkedHashSet} are differences. Ordered containers + * (lists, arrays, {@link LinkedHashSet}, {@link SortedSet}, {@link LinkedHashMap}, {@link SortedMap}) are compared + * in iteration order. Other maps are compared by key, then value by value; the elements of other sets are matched + * one to one with an element that compares equal by this same deep comparison, so classes and order nested inside + * a set element count too. + */ + public static List compareJava(Object expected, Object actual) { + List failures = new ArrayList<>(); + compareJava(ROOT_PATH, expected, actual, failures); + return List.copyOf(failures); + } + + /** Fails with an {@link AssertionError} listing every difference found by {@link #compareJava(Object, Object)}. */ + public static void assertSameJava(Object expected, Object actual) { + List failures = compareJava(expected, actual); + if (!failures.isEmpty()) { + throw new AssertionError("Java values differ:\n " + String.join("\n ", failures) + + "\nexpected: " + expected + "\nactual: " + actual); + } + } + + private static void compareJava(String path, Object expected, Object actual, List failures) { + if (expected == null || actual == null) { + if (expected != actual) { + failures.add(path + ": expected " + typed(expected) + ", actual " + typed(actual)); + } + return; + } + if (expected.getClass() != actual.getClass()) { + failures.add(path + ": class differs, expected " + typed(expected) + ", actual " + typed(actual)); + return; + } + if (expected instanceof Map expectedMap) { + compareMaps(path, expectedMap, (Map) actual, failures); + } else if (expected instanceof Collection expectedCollection) { + compareCollections(path, expectedCollection, (Collection) actual, failures); + } else if (expected.getClass().isArray()) { + compareCollections(path, arrayToList(expected), arrayToList(actual), failures); + } else if (!Objects.equals(expected, actual)) { + failures.add(path + ": value differs, expected " + typed(expected) + ", actual " + typed(actual)); + } + } + + private static void compareMaps(String path, Map expected, Map actual, List failures) { + for (Object key : expected.keySet()) { + if (!actual.containsKey(key)) { + failures.add(path + "." + key + ": missing key " + typed(key)); + } + } + for (Object key : actual.keySet()) { + if (!expected.containsKey(key)) { + failures.add(path + "." + key + ": unexpected key " + typed(key)); + } + } + if (isOrdered(expected) && !new ArrayList<>(expected.keySet()).equals(new ArrayList<>(actual.keySet()))) { + failures.add(path + ": key order differs, expected " + expected.keySet() + ", actual " + actual.keySet()); + } + for (Map.Entry entry : expected.entrySet()) { + if (actual.containsKey(entry.getKey())) { + compareJava(path + "." + entry.getKey(), entry.getValue(), actual.get(entry.getKey()), failures); + } + } + } + + private static void compareCollections(String path, Collection expected, Collection actual, List failures) { + if (expected.size() != actual.size()) { + failures.add(path + ": size differs, expected " + expected.size() + ", actual " + actual.size()); + } + if (isOrdered(expected)) { + Iterator expectedValues = expected.iterator(); + Iterator actualValues = actual.iterator(); + for (int i = 0; expectedValues.hasNext() && actualValues.hasNext(); i++) { + compareJava(path + "[" + i + "]", expectedValues.next(), actualValues.next(), failures); + } + return; + } + List unmatched = new ArrayList<>(actual); + for (Object expectedValue : expected) { + Iterator candidates = unmatched.iterator(); + boolean matched = false; + while (!matched && candidates.hasNext()) { + if (compareJava(expectedValue, candidates.next()).isEmpty()) { + candidates.remove(); + matched = true; + } + } + if (!matched) { + failures.add(path + ": missing element " + typed(expectedValue)); + } + } + unmatched.forEach(value -> failures.add(path + ": unexpected element " + typed(value))); + } + + private static boolean isOrdered(Map map) { + return map instanceof LinkedHashMap || map instanceof SortedMap; + } + + private static boolean isOrdered(Collection collection) { + return !(collection instanceof Set) || collection instanceof LinkedHashSet || collection instanceof SortedSet; + } + + /** Array contents as a list, so arrays are compared element by element. */ + private static List arrayToList(Object array) { + List values = new ArrayList<>(); + for (int i = 0; i < Array.getLength(array); i++) { + values.add(Array.get(array, i)); + } + return values; + } + + private static String typed(Object value) { + return value == null ? "null" : value + " (" + value.getClass().getName() + ")"; + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/CanonicalJsonTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/CanonicalJsonTest.java new file mode 100644 index 0000000000..122439337d --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/CanonicalJsonTest.java @@ -0,0 +1,205 @@ +package org.lowcoder.sdk.contract; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Self-test of {@link CanonicalJson} (docs/API_PAYLOAD_TEST_PLAN.md §8.1): every contract difference of §1.2 must be + * detected, and every excluded difference must pass and be reported. + */ +public class CanonicalJsonTest { + + private static final String LONG_TIMESTAMP = "1767225600"; + private static final String NANO_TIMESTAMP = "1767225600.000000000"; + + private static CanonicalJson.Comparison compareVerbose(String name, String expected, String actual) { + CanonicalJson.Comparison comparison = CanonicalJson.compare(expected, actual); + System.out.println("[CanonicalJsonTest] " + name + ": expected=" + expected + " actual=" + actual + + " failures=" + comparison.failures() + " reports=" + comparison.reports()); + return comparison; + } + + private static void assertDetected(String name, String expected, String actual, String failureFragment) { + CanonicalJson.Comparison comparison = compareVerbose(name, expected, actual); + assertThat(comparison.equivalent()).as(name + " must be a contract difference").isFalse(); + assertThat(comparison.failures()).as(name).anySatisfy(failure -> assertThat(failure).contains(failureFragment)); + } + + private static void assertExcludedAndReported(String name, String expected, String actual, String reportFragment) { + CanonicalJson.Comparison comparison = compareVerbose(name, expected, actual); + assertThat(comparison.failures()).as(name + " must not be a contract difference").isEmpty(); + assertThat(comparison.reports()).as(name + " must be reported").anySatisfy(report -> assertThat(report).contains(reportFragment)); + } + + @Test + public void integerVersusDecimalLexemeIsDetected() { + assertDetected("1 vs 1.0", "{\"n\":1}", "{\"n\":1.0}", "$.n: number lexeme differs, expected 1, actual 1.0"); + } + + @Test + public void trailingFractionZerosAreDetected() { + assertDetected("timestamp lexeme", "{\"t\":" + NANO_TIMESTAMP + "}", "{\"t\":" + LONG_TIMESTAMP + "}", "number lexeme differs"); + } + + @Test + public void duplicateVersusSingleKeyIsDetected() { + assertDetected("duplicate key", "{\"a\":1,\"a\":2}", "{\"a\":2}", "key occurs 2 time(s) in expected, 1 in actual"); + } + + @Test + public void orderOfDuplicateValuesIsDetected() { + assertDetected("duplicate order", "{\"a\":1,\"b\":0,\"a\":2}", "{\"a\":2,\"b\":0,\"a\":1}", "$.a#1: number lexeme differs"); + } + + @Test + public void missingPropertyIsDetected() { + assertDetected("missing property", "{\"a\":1,\"b\":2}", "{\"a\":1}", "$.b: missing property"); + } + + @Test + public void unexpectedPropertyIsDetected() { + assertDetected("unexpected property", "{\"a\":1}", "{\"a\":1,\"b\":2}", "$.b: unexpected property"); + } + + @Test + public void nullVersusAbsentIsDetected() { + assertDetected("null vs absent", "{\"a\":null}", "{}", "$.a: missing property"); + } + + @Test + public void stringVersusNumberIsDetected() { + assertDetected("\"1\" vs 1", "{\"a\":\"1\"}", "{\"a\":1}", "$.a: type differs, expected string \"1\", actual number 1"); + } + + @Test + public void reorderedArrayIsDetected() { + assertDetected("array order", "[1,2]", "[2,1]", "$[0]: number lexeme differs"); + } + + @Test + public void arrayLengthIsDetected() { + assertDetected("array length", "[1,2]", "[1]", "$: array length differs, expected 2, actual 1"); + } + + @Test + public void nestedValueIsDetectedWithItsPath() { + assertDetected("nested", "{\"a\":{\"b\":[{\"c\":true}]}}", "{\"a\":{\"b\":[{\"c\":false}]}}", "$.a.b[0].c: type differs"); + } + + @Test + public void whitespaceIsIgnored() { + CanonicalJson.Comparison comparison = compareVerbose("whitespace", "{\"a\":[1,2]}", "{ \"a\" : [ 1 , 2 ] }\n"); + assertThat(comparison.failures()).isEmpty(); + assertThat(comparison.reports()).isEmpty(); + } + + @Test + public void whitespaceBeforeStringValuesIsNotReportedAsEscapeForm() { + CanonicalJson.Comparison comparison = compareVerbose("pretty string members", + "{\"name\":\"x\",\"q\":\"a\\\"b\"}", "{\n \"name\" : \"x\",\n \"q\": \"a\\\"b\"\n}"); + assertThat(comparison.failures()).isEmpty(); + assertThat(comparison.reports()).isEmpty(); + } + + @Test + public void memberOrderIsExcludedAndReported() { + assertExcludedAndReported("member order", "{\"a\":1,\"b\":2}", "{\"b\":2,\"a\":1}", "$: member order differs, expected [a, b], actual [b, a]"); + } + + @Test + public void stringEscapeFormIsExcludedAndReported() { + assertExcludedAndReported("escape form", "{\"s\":\"é\"}", "{\"s\":\"\\u00e9\"}", "$.s: string escape form differs"); + } + + @Test + public void keyEscapeFormIsExcludedAndReported() { + assertExcludedAndReported("key escape form", "{\"é\":1}", "{\"\\u00e9\":1}", "key escape form differs"); + } + + @Test + public void assertEquivalentFailsWithEveryDifference() { + assertThatThrownBy(() -> CanonicalJson.assertEquivalent("{\"a\":1,\"b\":2}", "{\"a\":1.0}")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("$.a: number lexeme differs") + .hasMessageContaining("$.b: missing property"); + } + + @Test + public void invalidJsonIsRejected() { + assertThatThrownBy(() -> CanonicalJson.parse("{\"a\":")).isInstanceOf(RuntimeException.class); + assertThatThrownBy(() -> CanonicalJson.parse("{} {}")).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("trailing content"); + } + + @Test + public void integerVersusLongIsDetectedOnTheReadSide() { + List failures = CanonicalJson.compareJava(Map.of("n", 1), Map.of("n", 1L)); + System.out.println("[CanonicalJsonTest] Integer vs Long: " + failures); + assertThat(failures).anySatisfy(failure -> assertThat(failure) + .contains("$.n: class differs, expected 1 (java.lang.Integer), actual 1 (java.lang.Long)")); + } + + @Test + public void hashSetVersusLinkedHashSetIsDetected() { + Set hashSet = new HashSet<>(List.of("a", "b")); + Set linkedHashSet = new LinkedHashSet<>(List.of("a", "b")); + List failures = CanonicalJson.compareJava(hashSet, linkedHashSet); + System.out.println("[CanonicalJsonTest] HashSet vs LinkedHashSet: " + failures); + assertThat(failures).anySatisfy(failure -> assertThat(failure).contains("class differs") + .contains("java.util.HashSet").contains("java.util.LinkedHashSet")); + } + + @Test + public void orderOfOrderedContainersIsDetected() { + Map expected = new LinkedHashMap<>(); + expected.put("a", 1); + expected.put("b", 2); + Map actual = new LinkedHashMap<>(); + actual.put("b", 2); + actual.put("a", 1); + List failures = CanonicalJson.compareJava(List.of(expected, new ArrayList<>(List.of(1, 2))), + List.of(actual, new ArrayList<>(List.of(2, 1)))); + System.out.println("[CanonicalJsonTest] ordered containers: " + failures); + assertThat(failures).anySatisfy(failure -> assertThat(failure).contains("$[0]: key order differs")); + assertThat(failures).anySatisfy(failure -> assertThat(failure).contains("$[1][0]: value differs")); + } + + @Test + public void unorderedSetElementsAreComparedDeeply() { + Map linked = new LinkedHashMap<>(); + linked.put("a", 1); + linked.put("b", 2); + Map reordered = new LinkedHashMap<>(); + reordered.put("b", 2); + reordered.put("a", 1); + Set withHashMap = new HashSet<>(List.of(new HashMap<>(linked))); + Set withLinkedMap = new HashSet<>(List.of(linked)); + Set withReorderedMap = new HashSet<>(List.of(reordered)); + List classFailures = CanonicalJson.compareJava(withHashMap, withLinkedMap); + List orderFailures = CanonicalJson.compareJava(withLinkedMap, withReorderedMap); + System.out.println("[CanonicalJsonTest] set of HashMap vs set of LinkedHashMap: " + classFailures); + System.out.println("[CanonicalJsonTest] set of LinkedHashMaps in different order: " + orderFailures); + assertThat(classFailures).anySatisfy(failure -> assertThat(failure).contains("missing element")); + assertThat(orderFailures).anySatisfy(failure -> assertThat(failure).contains("missing element")); + } + + @Test + public void equalJavaValuesPassIncludingUnorderedContainers() { + Map expected = new HashMap<>(Map.of("a", new HashSet<>(List.of(1L, 2L)), "b", new int[]{1, 2})); + Map actual = new HashMap<>(Map.of("b", new int[]{1, 2}, "a", new HashSet<>(List.of(2L, 1L)))); + assertThat(CanonicalJson.compareJava(expected, actual)).isEmpty(); + assertThatThrownBy(() -> CanonicalJson.assertSameJava(Map.of("a", 1), Map.of("a", 2))) + .isInstanceOf(AssertionError.class).hasMessageContaining("$.a: value differs"); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ClientCodecs.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ClientCodecs.java new file mode 100644 index 0000000000..7d68ccf619 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ClientCodecs.java @@ -0,0 +1,164 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.lowcoder.sdk.util.JsonUtils; +import org.lowcoder.sdk.webclient.WebClientBuildHelper; +import org.springframework.http.codec.DecoderHttpMessageReader; +import org.springframework.http.codec.EncoderHttpMessageWriter; +import org.springframework.http.codec.HttpMessageReader; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; + +import java.lang.reflect.Method; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Spring's default client codecs as the application builds them (docs/API_PAYLOAD_TEST_PLAN.md §1.1, §3.2, §5.4). + * + *

    All five {@code codecs(...)} constructions of the application set only {@code maxInMemorySize} on the default + * codecs: {@code WebClientBuildHelper.java:131-134} (20 MB by default), {@code JsLibraryController.java:61-64} (-1), + * {@code DatasourcePluginClient.java:42-45} (-1), {@code GraphQLExecutor.java:87-90} (10 MB) and + * {@code RestApiExecutor.java:100-103} (-1). {@link #strategies(int)} builds the same thing, so tests can encode and + * decode with the client codecs without a network client. + * + *

    Effective limit (plan §9, O11; evidence E14). {@code JsLibraryController}, {@code GraphQLExecutor} and + * {@code RestApiExecutor} pass their strategies to a builder from {@code WebClientBuildHelper}, whose own + * {@code codecs(...)} configurer sets its 20 MB default when the client is built. With Spring WebFlux 6.1.5 that + * configurer reaches the decoder instance shared with the declared strategies, so every request runs with 20 MB and the + * declared value is overwritten. {@link #effectiveThroughWebClientBuildHelper} returns what a request gets. + * + *

    {@link #DEFAULT_CODEC_PROFILE} pins the configuration of the mapper inside those codecs as observed in E8 + * (docs/tools/plan-evidence.out). Spring builds that mapper with {@code Jackson2ObjectMapperBuilder}, which adds the + * well-known modules found on the classpath, so the profile depends on the classpath it is built on. + */ +public final class ClientCodecs { + + public static final int UNLIMITED = -1; + public static final int WEB_CLIENT_BUILD_HELPER_DEFAULT_SIZE = 20 * 1024 * 1024; + private static final String INIT_EXCHANGE_STRATEGIES = "initExchangeStrategies"; + + /** E8: the codec mapper writes dates as timestamps, reads them as nanoseconds, ignores unknown properties. */ + public static final MapperProfile DEFAULT_CODEC_PROFILE = new MapperProfile(true, true, false, + List.of("com.fasterxml.jackson.datatype.jdk8.Jdk8Module", "jackson-module-parameter-names", + "jackson-datatype-jsr310")); + + private ClientCodecs() { + } + + /** The settings of a mapper that the plan's guards compare: three features and the registered modules. */ + public record MapperProfile(boolean writeDatesAsTimestamps, boolean readDateTimestampsAsNanoseconds, + boolean failOnUnknownProperties, List moduleIds) { + } + + public static MapperProfile profile(ObjectMapper mapper) { + return new MapperProfile(mapper.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS), + mapper.isEnabled(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS), + mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES), + mapper.getRegisteredModuleIds().stream().map(String::valueOf).collect(Collectors.toList())); + } + + /** + * The strategies a {@link WebClient} built by the application from {@code declared} really uses: a builder from + * {@code WebClientBuildHelper.builder().systemProxy().toWebClientBuilder()} with {@code exchangeStrategies(declared)}, + * as {@code JsLibraryController.java:120-124}, {@code GraphQLExecutor.java:246-269} and + * {@code RestApiExecutor.java:220-244} build it. Their other builder calls (headers, cookies, filters, SSL, timeout, + * disallowed hosts) do not touch the codecs. + * + *

    Limit: it reads Spring's non-public {@code DefaultWebClientBuilder#initExchangeStrategies}, so a Spring upgrade + * may require adjusting the reflection. Like production, it mutates the decoder shared with {@code declared}. + */ + public static ExchangeStrategies effectiveThroughWebClientBuildHelper(ExchangeStrategies declared) { + return effective(WebClientBuildHelper.builder().systemProxy().toWebClientBuilder().exchangeStrategies(declared)); + } + + /** The strategies {@code builder.build()} would give its client. */ + public static ExchangeStrategies effective(WebClient.Builder builder) { + try { + Method init = builder.getClass().getDeclaredMethod(INIT_EXCHANGE_STRATEGIES); + init.setAccessible(true); + return (ExchangeStrategies) init.invoke(builder); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("cannot read the exchange strategies of " + builder.getClass(), e); + } + } + + /** The exchange strategies of {@code ExchangeStrategies.builder().codecs(c -> c.defaultCodecs().maxInMemorySize(n))}. */ + public static ExchangeStrategies strategies(int maxInMemorySize) { + return ExchangeStrategies.builder() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(maxInMemorySize)) + .build(); + } + + public static Jackson2JsonEncoder encoder(ExchangeStrategies strategies) { + return encoder(strategies.messageWriters()); + } + + public static Jackson2JsonDecoder decoder(ExchangeStrategies strategies) { + return decoder(strategies.messageReaders()); + } + + /** + * The client-codec guard of plan §5.4 for one real construction, read from the application's own object: + * its encoder and decoder mappers are two separate instances, neither is the production mapper, both have + * {@link #DEFAULT_CODEC_PROFILE}, the decoder has {@code expectedMaxInMemorySize}, and {@link #strategies(int)} + * builds an equal configuration. + */ + public static void assertDefaultClientCodecs(String construction, ExchangeStrategies real, int expectedMaxInMemorySize) { + Jackson2JsonEncoder encoder = encoder(real); + Jackson2JsonDecoder decoder = decoder(real); + MapperProfile encoderProfile = profile(encoder.getObjectMapper()); + MapperProfile decoderProfile = profile(decoder.getObjectMapper()); + System.out.println("[client-codec-guard] " + construction + ": encoder " + encoderProfile + ", decoder " + + decoderProfile + ", maxInMemorySize " + decoder.getMaxInMemorySize()); + ObjectMapper production = JsonUtils.getObjectMapper(); + assertTrue(construction + ": encoder mapper must not be the production mapper", encoder.getObjectMapper() != production); + assertTrue(construction + ": decoder mapper must not be the production mapper", decoder.getObjectMapper() != production); + assertTrue(construction + ": encoder and decoder must have separate mappers", encoder.getObjectMapper() != decoder.getObjectMapper()); + assertEquals(construction + ": decoder maxInMemorySize", expectedMaxInMemorySize, decoder.getMaxInMemorySize()); + assertEquals(construction + ": encoder mapper", DEFAULT_CODEC_PROFILE, encoderProfile); + assertEquals(construction + ": decoder mapper", DEFAULT_CODEC_PROFILE, decoderProfile); + ExchangeStrategies rebuilt = strategies(expectedMaxInMemorySize); + assertEquals(construction + ": ClientCodecs rebuild", encoderProfile, profile(encoder(rebuilt).getObjectMapper())); + assertEquals(construction + ": ClientCodecs rebuild maxInMemorySize", expectedMaxInMemorySize, + decoder(rebuilt).getMaxInMemorySize()); + } + + private static void assertTrue(String message, boolean condition) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static void assertEquals(String message, Object expected, Object actual) { + if (!expected.equals(actual)) { + throw new AssertionError(message + ": expected " + expected + ", actual " + actual); + } + } + + /** The Jackson JSON encoder among {@code writers}; fails if there is none. */ + public static Jackson2JsonEncoder encoder(List> writers) { + return writers.stream() + .filter(writer -> writer instanceof EncoderHttpMessageWriter encoderWriter + && encoderWriter.getEncoder() instanceof Jackson2JsonEncoder) + .map(writer -> (Jackson2JsonEncoder) ((EncoderHttpMessageWriter) writer).getEncoder()) + .findFirst() + .orElseThrow(() -> new AssertionError("no Jackson2JsonEncoder among " + writers)); + } + + /** The Jackson JSON decoder among {@code readers}; fails if there is none. */ + public static Jackson2JsonDecoder decoder(List> readers) { + return readers.stream() + .filter(reader -> reader instanceof DecoderHttpMessageReader decoderReader + && decoderReader.getDecoder() instanceof Jackson2JsonDecoder) + .map(reader -> (Jackson2JsonDecoder) ((DecoderHttpMessageReader) reader).getDecoder()) + .findFirst() + .orElseThrow(() -> new AssertionError("no Jackson2JsonDecoder among " + readers)); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ClientCodecsTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ClientCodecsTest.java new file mode 100644 index 0000000000..9621e1370c --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ClientCodecsTest.java @@ -0,0 +1,69 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.util.JsonUtils; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.web.reactive.function.client.ExchangeStrategies; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Self-test of {@link ClientCodecs}: the client-codec guard must reject each kind of drift it exists for + * (docs/API_PAYLOAD_TEST_PLAN.md §5.4). The configurations below are wrong on purpose. + */ +public class ClientCodecsTest { + + private static final int TEN_BYTES = 10; + + @Test + public void strategiesExposeSeparateEncoderAndDecoderMappers() { + ExchangeStrategies strategies = ClientCodecs.strategies(ClientCodecs.UNLIMITED); + assertThat(ClientCodecs.encoder(strategies).getObjectMapper()) + .isNotSameAs(ClientCodecs.decoder(strategies).getObjectMapper()); + assertThat(ClientCodecs.decoder(strategies).getMaxInMemorySize()).isEqualTo(ClientCodecs.UNLIMITED); + } + + @Test + public void guardRejectsTheProductionMapper() { + ObjectMapper production = JsonUtils.getObjectMapper(); + ExchangeStrategies drifted = ExchangeStrategies.builder().codecs(configurer -> { + configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(production)); + configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(new ObjectMapper())); + }).build(); + assertRejected(drifted, ClientCodecs.UNLIMITED, "encoder mapper must not be the production mapper"); + } + + @Test + public void guardRejectsOneSharedMapper() { + ObjectMapper shared = new ObjectMapper(); + ExchangeStrategies drifted = ExchangeStrategies.builder().codecs(configurer -> { + configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(shared)); + configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(shared)); + }).build(); + assertRejected(drifted, ClientCodecs.UNLIMITED, "encoder and decoder must have separate mappers"); + } + + @Test + public void guardRejectsAnotherBufferLimit() { + assertRejected(ClientCodecs.strategies(TEN_BYTES), ClientCodecs.UNLIMITED, "decoder maxInMemorySize: expected -1, actual 10"); + } + + @Test + public void guardRejectsAnotherMapperConfiguration() { + ExchangeStrategies drifted = ExchangeStrategies.builder().codecs(configurer -> { + configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(new ObjectMapper())); + configurer.defaultCodecs().maxInMemorySize(ClientCodecs.UNLIMITED); + }).build(); + assertRejected(drifted, ClientCodecs.UNLIMITED, "encoder mapper: expected " + ClientCodecs.DEFAULT_CODEC_PROFILE); + } + + private static void assertRejected(ExchangeStrategies drifted, int expectedSize, String messageFragment) { + assertThatThrownBy(() -> ClientCodecs.assertDefaultClientCodecs("drifted", drifted, expectedSize)) + .isInstanceOf(AssertionError.class) + .satisfies(error -> System.out.println("[ClientCodecsTest] rejected: " + error.getMessage())) + .hasMessageContaining(messageFragment); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ConfigBinding.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ConfigBinding.java new file mode 100644 index 0000000000..f06b9533ec --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ConfigBinding.java @@ -0,0 +1,161 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.util.RawValue; +import org.lowcoder.sdk.config.JsonViews; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.util.JsonUtils; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; + +/** + * Group {@code config-binding} of docs/API_PAYLOAD_TEST_PLAN.md §4.7 and §4.10: a datasource or query config map + * becomes a config object through {@code fromJson(toJson(map), Type.class)}, so a Jackson upgrade that changes + * creator selection, aliases, coercion of scalars, unknown properties or polymorphic type ids changes what a plugin + * connects with or runs. + * + *

    The input fixture is a JSON object of named forms, each one config map as the client sends it (for + * example canonical names, alias names, scalars as strings). It is read by the production mapper, as the server + * reads a request or MongoDB hands back a stored config. Each named entry point (the real {@code buildFrom}, + * {@code from}, {@code resolveConfig} or dispatch) is applied to each form, and the report pins, per entry point and + * form: + *

      + *
    • {@value #CLASS_KEY}, and the bound state read from the fields ({@link FieldTree}: {@value #FIELDS_KEY} and + * {@value #CLASSES_KEY}), so getter defaults cannot hide a binding change;
    • + *
    • the object written by the production mapper with no view, the {@code Public} view and the {@code Internal} + * view ({@value #NO_VIEW_KEY}, {@value #PUBLIC_KEY}, {@value #INTERNAL_KEY}), as the exact text it writes;
    • + *
    • or {@value #ERROR_KEY}, the exception of the entry point or of a write ({@link #errorText});
    • + *
    • {@value #UNKNOWN_PROPERTY_KEY}: the first form with the extra key {@value #UNKNOWN_PROPERTY} added, which + * must bind as without it while {@code FAIL_ON_UNKNOWN_PROPERTIES} stays off.
    • + *
    + * The report is compared with the golden by the §1.2 contract ({@link GoldenJson#assertJson}). + * + *

    Limits: an entry point is called with a fresh copy of the form; what it does after binding (rendering, + * connecting) is the business of other groups. The written text is embedded raw, so its number lexemes are pinned, + * but the canonical comparison ignores member order. + */ +public final class ConfigBinding { + + public static final String CLASS_KEY = "class"; + public static final String FIELDS_KEY = "fields"; + public static final String CLASSES_KEY = "classes"; + public static final String NO_VIEW_KEY = "noView"; + public static final String PUBLIC_KEY = "Public"; + public static final String INTERNAL_KEY = "Internal"; + public static final String ERROR_KEY = "error"; + public static final String UNKNOWN_PROPERTY_KEY = "unknownProperty"; + public static final String UNKNOWN_PROPERTY = "contractUnknownProperty"; + public static final String UNKNOWN_PROPERTY_IGNORED = "ignored: binds as form "; + private static final String REPORT_PREFIX = "[ConfigBinding] "; + + private static final ObjectMapper MAPPER = JsonUtils.getObjectMapper(); + + private ConfigBinding() { + } + + /** + * Applies every entry point to every form of {@code inputFixture} and compares the report with + * {@code reportFixture}. Returns the report, for further assertions. + */ + public static Map assertBinding(GoldenJson golden, String inputFixture, String reportFixture, + Map, ?>> entryPoints) { + Map> forms = forms(golden.read(inputFixture)); + Map report = report(forms, entryPoints); + String actual = write(report); + System.out.println(REPORT_PREFIX + inputFixture + " -> " + reportFixture + "\n" + actual); + golden.assertJson(reportFixture, actual); + return report; + } + + /** Two named entry points of one config class, in this order. */ + public static Map, ?>> entryPoints(String firstName, Function, ?> first, + String secondName, Function, ?> second) { + Map, ?>> entryPoints = new LinkedHashMap<>(); + entryPoints.put(firstName, first); + entryPoints.put(secondName, second); + return entryPoints; + } + + /** The named forms of an input fixture, read by the production mapper. */ + public static Map> forms(String json) { + try { + return MAPPER.readValue(json, new TypeReference>>() { + }); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("config-binding input is not an object of forms: " + e.getOriginalMessage(), e); + } + } + + /** {@code {entry point: {form: result, ..., "unknownProperty": ...}}}. */ + public static Map report(Map> forms, + Map, ?>> entryPoints) { + if (forms.isEmpty() || entryPoints.isEmpty()) { + throw new IllegalArgumentException("config-binding needs at least one form and one entry point"); + } + Map report = new LinkedHashMap<>(); + entryPoints.forEach((name, entryPoint) -> { + Map results = new LinkedHashMap<>(); + forms.forEach((form, config) -> results.put(form, result(entryPoint, config))); + Map.Entry> first = forms.entrySet().iterator().next(); + Map withUnknown = new LinkedHashMap<>(first.getValue()); + withUnknown.put(UNKNOWN_PROPERTY, RepresentativeInput.map()); + Map unknown = result(entryPoint, withUnknown); + results.put(UNKNOWN_PROPERTY_KEY, write(unknown).equals(write(results.get(first.getKey()))) + ? UNKNOWN_PROPERTY_IGNORED + first.getKey() : unknown); + report.put(name, results); + }); + return report; + } + + /** The bound object of one entry point and form, or its error. */ + public static Map result(Function, ?> entryPoint, Map config) { + Map result = new LinkedHashMap<>(); + Object bound; + try { + bound = entryPoint.apply(new LinkedHashMap<>(config)); + } catch (RuntimeException e) { + result.put(ERROR_KEY, errorText(e)); + return result; + } + result.put(CLASS_KEY, bound == null ? JavaValueWalker.NULL : bound.getClass().getName()); + Map state = FieldTree.of(bound); + result.put(FIELDS_KEY, state.get(FieldTree.VALUES_KEY)); + result.put(CLASSES_KEY, state.get(FieldTree.CLASSES_KEY)); + result.put(NO_VIEW_KEY, written(bound, null)); + result.put(PUBLIC_KEY, written(bound, JsonViews.Public.class)); + result.put(INTERNAL_KEY, written(bound, JsonViews.Internal.class)); + return result; + } + + /** + * The text of a failure as tests pin it: a {@link PluginException} by its class, error and message key (its + * message is localized), anything else by its class and message. + */ + public static String errorText(Throwable error) { + if (error instanceof PluginException plugin) { + return plugin.getClass().getName() + ": " + plugin.getError() + " " + plugin.getMessageKey(); + } + return error.getClass().getName() + ": " + error.getMessage(); + } + + /** The report as pretty-printed text of the production mapper. */ + public static String write(Object report) { + try { + return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(report); + } catch (JsonProcessingException e) { + throw new IllegalStateException("cannot write config-binding report", e); + } + } + + private static Object written(Object bound, Class view) { + try { + return new RawValue(view == null ? MAPPER.writeValueAsString(bound) : MAPPER.writerWithView(view).writeValueAsString(bound)); + } catch (JsonProcessingException | RuntimeException e) { + return Map.of(ERROR_KEY, errorText(e)); + } + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ConfigBindingTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ConfigBindingTest.java new file mode 100644 index 0000000000..af7027c730 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ConfigBindingTest.java @@ -0,0 +1,111 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.annotation.JsonView; +import com.fasterxml.jackson.databind.util.RawValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.lowcoder.sdk.config.JsonViews; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.util.JsonUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Self-test of {@link ConfigBinding}: views, errors, the unknown-property probe, and a golden that no longer matches + * the bound state failing. + */ +public class ConfigBindingTest { + + static final String INPUT = "config-binding/Sample.input.json"; + static final String REPORT = "config-binding/Sample.json"; + static final String ENTRY = "fromJson"; + static final String FORMS = "{\"canonical\":{\"host\":\"h\",\"port\":5432,\"secret\":\"s\"},\"empty\":{}}"; + + /** A config with an {@code Internal}-only secret, bound as the plugins bind theirs. */ + public static class Sample { + public String host; + public Long port; + @JsonView(JsonViews.Internal.class) + public String secret; + } + + @TempDir + Path folder; + + static final Function, ?> FROM_JSON = map -> { + if (map.isEmpty()) { + throw new PluginException(PluginCommonError.DATASOURCE_ARGUMENT_ERROR, "DATASOURCE_CONFIG_ERROR"); + } + return JsonUtils.fromJson(JsonUtils.toJson(map), Sample.class); + }; + + @Test + @SuppressWarnings("unchecked") + public void reportPinsStateViewsErrorsAndUnknownProperty() { + Map report = ConfigBinding.report(ConfigBinding.forms(FORMS), Map.of(ENTRY, FROM_JSON)); + System.out.println("[ConfigBindingTest] " + ConfigBinding.write(report)); + Map results = (Map) report.get(ENTRY); + Map canonical = (Map) results.get("canonical"); + assertThat(canonical.get(ConfigBinding.CLASS_KEY)).isEqualTo(Sample.class.getName()); + assertThat(((Map) canonical.get(ConfigBinding.CLASSES_KEY))).containsEntry("/port", Long.class.getName()); + assertThat(((RawValue) canonical.get(ConfigBinding.PUBLIC_KEY)).rawValue()).isEqualTo("{\"host\":\"h\",\"port\":5432}"); + assertThat(((RawValue) canonical.get(ConfigBinding.INTERNAL_KEY)).rawValue()).isEqualTo("{\"host\":\"h\",\"port\":5432,\"secret\":\"s\"}"); + assertThat(results.get("empty")).isEqualTo(Map.of(ConfigBinding.ERROR_KEY, + PluginException.class.getName() + ": DATASOURCE_ARGUMENT_ERROR DATASOURCE_CONFIG_ERROR")); + assertThat(results.get(ConfigBinding.UNKNOWN_PROPERTY_KEY)).isEqualTo(ConfigBinding.UNKNOWN_PROPERTY_IGNORED + "canonical"); + } + + /** An unknown property that changes the result is reported with that result, not as ignored. */ + @Test + @SuppressWarnings("unchecked") + public void unknownPropertyThatChangesTheResultIsReported() { + Function, ?> strict = map -> { + if (map.containsKey(ConfigBinding.UNKNOWN_PROPERTY)) { + throw new IllegalArgumentException("unknown property"); + } + return FROM_JSON.apply(map); + }; + Map report = ConfigBinding.report(ConfigBinding.forms(FORMS), Map.of(ENTRY, strict)); + Object unknown = ((Map) report.get(ENTRY)).get(ConfigBinding.UNKNOWN_PROPERTY_KEY); + System.out.println("[ConfigBindingTest] strict entry point: " + unknown); + assertThat(unknown).isEqualTo(Map.of(ConfigBinding.ERROR_KEY, IllegalArgumentException.class.getName() + ": unknown property")); + } + + /** The golden of one binding no longer matches when the binding changes the Java class of a value. */ + @Test + public void bindingChangeFailsTheGolden() throws IOException { + Path root = Files.createDirectory(folder.resolve("fixtures")); + write(root.resolve(INPUT), FORMS); + GoldenJson golden = new GoldenJson(root, null, false); + String pinned = ConfigBinding.write(ConfigBinding.report(ConfigBinding.forms(FORMS), Map.of(ENTRY, FROM_JSON))); + write(root.resolve(REPORT), pinned); + ConfigBinding.assertBinding(golden, INPUT, REPORT, Map.of(ENTRY, FROM_JSON)); + + Function, ?> intPort = map -> { + Sample sample = (Sample) FROM_JSON.apply(map); + return Map.of("host", sample.host, "port", sample.port == null ? 0 : sample.port.intValue()); + }; + assertThatThrownBy(() -> ConfigBinding.assertBinding(golden, INPUT, REPORT, Map.of(ENTRY, intPort))) + .isInstanceOf(AssertionError.class).hasMessageContaining("$.fromJson.canonical.class"); + } + + @Test + public void inputMustBeAnObjectOfForms() { + assertThatThrownBy(() -> ConfigBinding.forms("[1]")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ConfigBinding.report(Map.of(), Map.of(ENTRY, FROM_JSON))).isInstanceOf(IllegalArgumentException.class); + } + + private static void write(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content, StandardCharsets.UTF_8); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ContainerImages.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ContainerImages.java new file mode 100644 index 0000000000..d53fe8805b --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ContainerImages.java @@ -0,0 +1,51 @@ +package org.lowcoder.sdk.contract; + +/** + * Every container image a test starts, each pinned by digest (docs/COVERAGE_GATE_PLAN.md §7, task P0-4). A tag such as + * {@code mysql:8.0} can be moved to a new build and a digest cannot, so a test names its image only through these + * constants ({@code DockerImageName.parse(ContainerImages.MYSQL_8_0)}), and docs/coverage-gate/image-pin-check.sh fails + * on any other image name in test sources. + * + *

    Each value is {@code :@sha256:}: the tag says which line the image belongs to, the digest which + * build. Docker pulls by the digest. A Testcontainers module class ({@code MongoDBContainer}, {@code MySQLContainer}, + * {@code PostgreSQLContainer}, ...) does not accept a digest-pinned name as its image without + * {@code .asCompatibleSubstituteFor("")} (it fails with "Failed to verify that image ... is a compatible + * substitute"); {@code GenericContainer} needs nothing. The digests were read on 2026-10-04: for the images already on + * the build machine, the build that was measured (the registry's tag had moved on for {@code mongo:7.0} and + * {@code postgres:16-alpine}); for the others, the registry's index digest. Changing a digest is a change of its own, + * with a full build. + * + *

    Licences (confirmed from each image's documentation, 2026-10-04): MySQL GPLv2; PostgreSQL License; Redis 7.2.4 + * BSD-3; MongoDB SSPL; ClickHouse Apache-2.0; SQL Server under its EULA (Developer/Express editions, no production use); + * Elasticsearch 8.15 Elastic License 2.0; Oracle Database Free under the Oracle Free Use Terms (the image packaging + * Apache-2.0). They are used for tests only (owner decision D-5). + */ +public final class ContainerImages { + + /** The server's tests: the line of the embedded mongod they ran against before (4.0.2). */ + public static final String MONGO_4_0 = + "mongo:4.0.28@sha256:4ca81c89ad08f4cfa9906005126112bffe8fb363800466ef5e50f6238f6f6af1"; + /** mongoPlugin's container tests. */ + public static final String MONGO_7_0 = + "mongo:7.0@sha256:d5b3ca8c3f3cdce78d44870dc0871b76d5235e9b2ad4ea6bea5d1fbff8027703"; + public static final String REDIS_7_2 = + "redis:7.2.4-alpine@sha256:c8bb255c3559b3e458766db810aa7b3c7af1235b204cfdb304e79ff388fe1a5a"; + public static final String POSTGRES_16 = + "postgres:16-alpine@sha256:20edbde7749f822887a1a022ad526fde0a47d6b2be9a8364433605cf65099416"; + public static final String MYSQL_8_0 = + "mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b"; + public static final String CLICKHOUSE_24_8 = + "clickhouse/clickhouse-server:24.8-alpine@sha256:b002e56ed5c16e224c312527f6fcba7e77216fec5d7a88a7828f59efc614feb5"; + /** Heavy: started only by tests tagged {@code heavy-container} (task P0-6). */ + public static final String MSSQL_2022 = + "mcr.microsoft.com/mssql/server:2022-CU27-ubuntu-22.04@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090"; + /** Heavy: started only by tests tagged {@code heavy-container} (task P0-6). */ + public static final String ELASTICSEARCH_8_15 = + "docker.elastic.co/elasticsearch/elasticsearch:8.15.5@sha256:8870d5b7b86235fa790c9112464f5fd3b693f89d73e757a630f8b5cda350f95b"; + /** Heavy: started only by tests tagged {@code heavy-container} (task P0-6). */ + public static final String ORACLE_FREE_23 = + "gvenzl/oracle-free:23-slim-faststart@sha256:f5ff19033860d662c821cb04eb10483fa94f14f78eae252d054291ea07028093"; + + private ContainerImages() { + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ContainerImagesTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ContainerImagesTest.java new file mode 100644 index 0000000000..7525ed2641 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/ContainerImagesTest.java @@ -0,0 +1,51 @@ +package org.lowcoder.sdk.contract; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Self-test of {@link ContainerImages}: every image a test can start is pinned by digest. Catches a constant added or + * edited as a bare tag ({@code mysql:8.0}), which Docker would resolve to whatever build the tag points at that day. + */ +public class ContainerImagesTest { + + /** {@code :@sha256:<64 hex digits>}; the name may carry a registry host and path. */ + static final Pattern PINNED = Pattern.compile("[a-z0-9.\\-/]+:[A-Za-z0-9._\\-]+@sha256:[0-9a-f]{64}"); + + static List constants() throws IllegalAccessException { + List values = new ArrayList<>(); + for (Field field : ContainerImages.class.getDeclaredFields()) { + int modifiers = field.getModifiers(); + if (Modifier.isPublic(modifiers) && Modifier.isStatic(modifiers) && field.getType() == String.class) { + values.add(field.getName() + "=" + field.get(null)); + } + } + return values; + } + + @Test + void everyImageIsTagAndDigest() throws IllegalAccessException { + List constants = constants(); + System.out.println("[ContainerImagesTest] " + constants.size() + " images: " + constants); + assertThat(constants).isNotEmpty(); + for (String constant : constants) { + String image = constant.substring(constant.indexOf('=') + 1); + assertThat(PINNED.matcher(image).matches()).as("pinned by tag and digest: %s", constant).isTrue(); + } + } + + @Test + void patternRejectsATagWithoutDigest() { + System.out.println("[ContainerImagesTest] mysql:8.0 and mysql@sha256: must both be rejected"); + assertThat(PINNED.matcher("mysql:8.0").matches()).isFalse(); + assertThat(PINNED.matcher("mysql@sha256:" + "a".repeat(64)).matches()).isFalse(); + assertThat(PINNED.matcher("mysql:8.0@sha256:" + "a".repeat(64)).matches()).isTrue(); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/FakeJdbc.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/FakeJdbc.java new file mode 100644 index 0000000000..5f96011495 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/FakeJdbc.java @@ -0,0 +1,250 @@ +package org.lowcoder.sdk.contract; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.sql.Array; +import java.sql.Blob; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.Statement; +import java.sql.Time; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * JDBC objects that answer fixed rows, so the plugins' result parsing ({@code ResultSetParser}, + * {@code PostgresResultParser}, {@code GeneralSqlExecutor}, {@code ClickHouseQueryExecutor}) runs on the values a + * driver hands out without a database (docs/API_PAYLOAD_TEST_PLAN.md §4.6, task T8.2). + * + *

    A {@link #resultSet} answers {@code getObject} with the cell as given, and the typed getters the parsers call + * ({@code getDate}, {@code getTime}, {@code getString}, {@code getBlob}, {@code getArray}, {@code getObject(i, type)}) + * from it the way a driver converts. A {@link #connection} hands out one statement that yields the given + * {@link StatementResult}s in order, through {@code execute}, {@code getResultSet}, {@code getUpdateCount}, + * {@code getMoreResults} and {@code getGeneratedKeys}; given a bind log, it records every {@code set...(index, value)} + * call of a prepared statement as {@code setString(1, java.lang.String:text)}, in call order. {@link SqlArray} is an + * array value as a driver hands it out. + * + *

    Limits: only the methods named here are implemented; any other call fails with + * {@link SQLFeatureNotSupportedException} naming the method, so a parser that starts calling another method fails + * the test instead of reading a default. The conversions are those of the JDBC specification, not of one driver. + */ +public final class FakeJdbc { + + /** {@code getUpdateCount} when the current result is a result set or there are no more results. */ + public static final int NO_UPDATE_COUNT = -1; + private static final String SETTER_PREFIX = "set"; + + /** A column: the label and name the parser reads, and the database type name it switches on. */ + public record Column(String label, String typeName) { + } + + /** + * A cell whose {@code getObject} throws an {@link SQLException} with {@code message} (as MySQL does for a TIME + * value that is a duration) and whose {@code getString} gives {@code text}. + */ + public record FailingCell(String message, String text) { + } + + /** One result of a statement: rows, or an update count with the generated keys (none: {@code null}). */ + public sealed interface StatementResult permits Rows, UpdateCount { + } + + public record Rows(ResultSet resultSet) implements StatementResult { + } + + public record UpdateCount(int count, ResultSet generatedKeys) implements StatementResult { + } + + /** An array value as a driver hands it out: the elements, and their base type for {@code getBaseType(Name)}. */ + public record SqlArray(String baseTypeName, int baseType, Object[] elements) implements Array { + @Override + public String getBaseTypeName() { + return baseTypeName; + } + + @Override + public int getBaseType() { + return baseType; + } + + @Override + public Object getArray() { + return elements.clone(); + } + + @Override + public Object getArray(Map> map) { + return getArray(); + } + + @Override + public Object getArray(long index, int count) { + return Arrays.copyOfRange(elements, (int) index - 1, (int) index - 1 + count); + } + + @Override + public Object getArray(long index, int count, Map> map) { + return getArray(index, count); + } + + @Override + public ResultSet getResultSet() throws SQLException { + throw unsupported(Array.class, "getResultSet"); + } + + @Override + public ResultSet getResultSet(Map> map) throws SQLException { + throw unsupported(Array.class, "getResultSet"); + } + + @Override + public ResultSet getResultSet(long index, int count) throws SQLException { + throw unsupported(Array.class, "getResultSet"); + } + + @Override + public ResultSet getResultSet(long index, int count, Map> map) throws SQLException { + throw unsupported(Array.class, "getResultSet"); + } + + @Override + public void free() { + } + } + + private FakeJdbc() { + } + + /** A result set of {@code rows}, each a list of cells in column order. */ + public static ResultSet resultSet(List columns, List> rows) { + ResultSetMetaData metaData = proxy(ResultSetMetaData.class, (self, method, args) -> switch (method.getName()) { + case "getColumnCount" -> columns.size(); + case "getColumnLabel", "getColumnName" -> columns.get((int) args[0] - 1).label(); + case "getColumnTypeName" -> columns.get((int) args[0] - 1).typeName(); + default -> throw unsupported(ResultSetMetaData.class, method.getName()); + }); + int[] row = {-1}; + return proxy(ResultSet.class, (self, method, args) -> switch (method.getName()) { + case "getMetaData" -> metaData; + case "next" -> ++row[0] < rows.size(); + case "close" -> null; + case "getObject" -> args.length == 1 ? object(rows.get(row[0]).get((int) args[0] - 1)) + : typed(rows.get(row[0]).get((int) args[0] - 1), (Class) args[1]); + case "getString" -> string(rows.get(row[0]).get((int) args[0] - 1)); + case "getDate" -> date(rows.get(row[0]).get((int) args[0] - 1)); + case "getTime" -> time(rows.get(row[0]).get((int) args[0] - 1)); + case "getBlob" -> typed(rows.get(row[0]).get((int) args[0] - 1), Blob.class); + case "getArray" -> typed(rows.get(row[0]).get((int) args[0] - 1), Array.class); + default -> throw unsupported(ResultSet.class, method.getName()); + }); + } + + /** A connection whose every statement (plain or prepared) yields {@code results} in order. */ + public static Connection connection(List results) { + return connection(results, null); + } + + /** + * A connection whose every statement yields {@code results} in order and records each parameter it is given in + * {@code binds} ({@code null}: parameters are refused). + */ + public static Connection connection(List results, List binds) { + return proxy(Connection.class, (self, method, args) -> switch (method.getName()) { + case "createStatement", "prepareStatement" -> statement(method.getReturnType(), results, binds); + case "close" -> null; + case "isClosed" -> false; + default -> throw unsupported(Connection.class, method.getName()); + }); + } + + private static Object statement(Class type, List results, List binds) { + List remaining = new ArrayList<>(results); + return proxy(type, (self, method, args) -> switch (method.getName()) { + case "execute" -> !remaining.isEmpty() && remaining.get(0) instanceof Rows; + case "getResultSet" -> remaining.get(0) instanceof Rows rows ? rows.resultSet() : null; + case "getUpdateCount" -> !remaining.isEmpty() && remaining.get(0) instanceof UpdateCount count ? count.count() : NO_UPDATE_COUNT; + case "getGeneratedKeys" -> remaining.get(0) instanceof UpdateCount count ? count.generatedKeys() : null; + case "getMoreResults" -> { + remaining.remove(0); + yield !remaining.isEmpty() && remaining.get(0) instanceof Rows; + } + case "close" -> null; + default -> { + if (binds == null || !method.getName().startsWith(SETTER_PREFIX) || args.length < 2 || !(args[0] instanceof Integer index)) { + throw unsupported(type, method.getName()); + } + binds.add(method.getName() + "(" + index + ", " + describe(args[1]) + ")"); + yield null; + } + }); + } + + /** {@code class:value} of a bound parameter. */ + private static String describe(Object value) { + return value == null ? String.valueOf((Object) null) : value.getClass().getName() + ":" + value; + } + + private static Object object(Object cell) throws SQLException { + if (cell instanceof FailingCell failing) { + throw new SQLException(failing.message()); + } + return cell; + } + + private static Object typed(Object cell, Class type) throws SQLException { + Object value = object(cell); + if (value == null || type.isInstance(value)) { + return value; + } + throw new SQLException("cell of " + value.getClass().getName() + " is not a " + type.getName()); + } + + private static String string(Object cell) { + if (cell instanceof FailingCell failing) { + return failing.text(); + } + return cell == null ? null : cell.toString(); + } + + private static java.sql.Date date(Object cell) throws SQLException { + Object value = object(cell); + if (value instanceof java.util.Date date) { + return new java.sql.Date(date.getTime()); + } + if (value instanceof LocalDate localDate) { + return java.sql.Date.valueOf(localDate); + } + return (java.sql.Date) typed(value, java.sql.Date.class); + } + + private static Time time(Object cell) throws SQLException { + Object value = object(cell); + if (value instanceof java.util.Date date) { + return new Time(date.getTime()); + } + return (Time) typed(value, Time.class); + } + + private static T proxy(Class type, InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(FakeJdbc.class.getClassLoader(), new Class[] {type}, (self, method, args) -> { + if (method.getDeclaringClass() == Object.class) { + return switch (method.getName()) { + case "equals" -> self == args[0]; + case "hashCode" -> System.identityHashCode(self); + default -> "FakeJdbc " + type.getSimpleName(); + }; + } + return handler.invoke(self, method, args == null ? new Object[0] : args); + })); + } + + private static SQLFeatureNotSupportedException unsupported(Class type, String method) { + return new SQLFeatureNotSupportedException("FakeJdbc does not implement " + type.getSimpleName() + "." + method); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/FieldTree.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/FieldTree.java new file mode 100644 index 0000000000..2efd3e2061 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/FieldTree.java @@ -0,0 +1,132 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The Java state of a bound object, read from its fields, not its getters (docs/API_PAYLOAD_TEST_PLAN.md §4.7): what + * Jackson put into the object, before any getter default ({@code getPort()} returning the default port for a + * {@code null} field, {@code trimToEmpty}, {@code emptyIfNull}) hides it. + * + *

    {@link #of} gives {@code {"values": tree, "classes": {pointer: class}}}: + *

      + *
    • objects of {@link #DESCENDED_PACKAGE} classes and records become maps of their instance fields, declared + * class first, superclass fields after, in declaration order, with {@value #CLASS_KEY} naming the class;
    • + *
    • maps keep their iteration order; lists and arrays their order; other collections (sets) are sorted by the + * text of their elements, so a hash order does not reach a fixture;
    • + *
    • strings, numbers, booleans, enums (by name), {@code byte[]} and {@link JsonNode} are leaves as they are; any + * other object is a leaf by its {@code toString()};
    • + *
    • {@code classes} records the class of every original value by JSON pointer (RFC 6901), sets and beans + * included, so a value that changes class while keeping its text is caught.
    • + *
    + * + *

    Limits: static, synthetic and {@code @Slf4j} logger fields are skipped, and so is a superclass field hidden by a + * subclass field of the same name; a value met again on the current path + * (a cycle) is the leaf {@value #CYCLE}; objects outside {@link #DESCENDED_PACKAGE} are not descended, so their + * private state is pinned only as far as {@code toString()} shows it. + */ +public final class FieldTree { + + public static final String VALUES_KEY = "values"; + public static final String CLASSES_KEY = JavaValueWalker.CLASSES_KEY; + public static final String CLASS_KEY = "@class"; + public static final String DESCENDED_PACKAGE = "org.lowcoder."; + public static final String CYCLE = ""; + private static final String LOGGER_FIELD = "log"; + + private FieldTree() { + } + + /** The values and classes of {@code value}, as described above. */ + public static Map of(Object value) { + Map classes = new LinkedHashMap<>(); + Object tree = convert(JavaValueWalker.ROOT_POINTER, value, classes, Collections.newSetFromMap(new IdentityHashMap<>())); + Map result = new LinkedHashMap<>(); + result.put(VALUES_KEY, tree); + result.put(CLASSES_KEY, classes); + return result; + } + + private static Object convert(String pointer, Object value, Map classes, Set path) { + classes.put(pointer, value == null ? JavaValueWalker.NULL : value.getClass().getName()); + if (value == null || value instanceof String || value instanceof Number || value instanceof Boolean + || value instanceof byte[] || value instanceof JsonNode) { + return value; + } + if (value instanceof Enum constant) { + return constant.name(); + } + if (!path.add(value)) { + return CYCLE; + } + try { + if (value instanceof Map map) { + Map converted = new LinkedHashMap<>(); + map.forEach((key, entry) -> converted.put(String.valueOf(key), + convert(pointer + "/" + JavaValueWalker.escape(String.valueOf(key)), entry, classes, path))); + return converted; + } + if (value instanceof List list) { + return elements(pointer, list, classes, path); + } + if (value instanceof Collection collection) { + List sorted = new ArrayList<>(collection); + sorted.sort(Comparator.comparing(String::valueOf)); + return elements(pointer, sorted, classes, path); + } + if (value.getClass().isArray()) { + List elements = new ArrayList<>(); + for (int i = 0; i < Array.getLength(value); i++) { + elements.add(Array.get(value, i)); + } + return elements(pointer, elements, classes, path); + } + if (value.getClass().getName().startsWith(DESCENDED_PACKAGE) || value.getClass().isRecord()) { + return fields(pointer, value, classes, path); + } + return String.valueOf(value); + } finally { + path.remove(value); + } + } + + private static List elements(String pointer, List elements, Map classes, Set path) { + List converted = new ArrayList<>(); + for (int i = 0; i < elements.size(); i++) { + converted.add(convert(pointer + "/" + i, elements.get(i), classes, path)); + } + return converted; + } + + private static Map fields(String pointer, Object bean, Map classes, Set path) { + Map converted = new LinkedHashMap<>(); + converted.put(CLASS_KEY, bean.getClass().getName()); + for (Class type = bean.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic() || LOGGER_FIELD.equals(field.getName()) + || converted.containsKey(field.getName())) { + continue; + } + field.setAccessible(true); + try { + converted.put(field.getName(), convert(pointer + "/" + JavaValueWalker.escape(field.getName()), field.get(bean), classes, path)); + } catch (IllegalAccessException e) { + throw new IllegalStateException("cannot read field " + type.getName() + "." + field.getName(), e); + } + } + } + return converted; + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/FieldTreeTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/FieldTreeTest.java new file mode 100644 index 0000000000..e365f30d68 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/FieldTreeTest.java @@ -0,0 +1,93 @@ +package org.lowcoder.sdk.contract; + +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Self-test of {@link FieldTree}: it reads fields, not getters, records every class by pointer, sorts sets, and stops + * at cycles and at classes outside {@link FieldTree#DESCENDED_PACKAGE}. + */ +public class FieldTreeTest { + + static final long DEFAULT_PORT = 3306L; + + enum Mode { GUI } + + record Pair(String left, Object right) { + } + + static class Base { + private final String hidden = "base"; + private final Long port; + + Base(Long port) { + this.port = port; + } + + /** A getter default that a getter-based dump would show instead of the bound {@code null}. */ + public long getPort() { + return port == null ? DEFAULT_PORT : port; + } + } + + static class Config extends Base { + private static final String CONSTANT = "not dumped"; + private final String hidden = "subclass"; + private final Mode mode = Mode.GUI; + private final Set cookies = new LinkedHashSet<>(List.of("b", "a")); + private final Map extra = new LinkedHashMap<>(Map.of("big", BigInteger.TWO.pow(64))); + private final StringBuilder notDescended = new StringBuilder("text"); + private final Pair pair = new Pair("l", 1.5); + private Object self; + + Config() { + super(null); + } + } + + @Test + @SuppressWarnings("unchecked") + public void dumpsFieldsWithClassesAndOrder() { + Config config = new Config(); + config.self = config; + Map tree = FieldTree.of(config); + System.out.println("[FieldTreeTest] " + ConfigBinding.write(tree)); + Map values = (Map) tree.get(FieldTree.VALUES_KEY); + Map classes = (Map) tree.get(FieldTree.CLASSES_KEY); + + assertThat(new ArrayList<>(values.keySet())).containsExactly(FieldTree.CLASS_KEY, "hidden", "mode", "cookies", "extra", + "notDescended", "pair", "self", "port"); + assertThat(values.get("port")).as("the field, not getPort()'s default").isNull(); + assertThat(values.get("hidden")).as("the subclass field hides the superclass one").isEqualTo("subclass"); + assertThat(values.get("mode")).isEqualTo("GUI"); + assertThat(values.get("cookies")).isEqualTo(List.of("a", "b")); + assertThat(values.get("notDescended")).isEqualTo("text"); + assertThat(values.get("self")).isEqualTo(FieldTree.CYCLE); + assertThat((Map) values.get("pair")).containsEntry(FieldTree.CLASS_KEY, Pair.class.getName()) + .containsEntry("right", 1.5); + + assertThat(classes).containsEntry("", Config.class.getName()) + .containsEntry("/port", JavaValueWalker.NULL) + .containsEntry("/cookies", LinkedHashSet.class.getName()) + .containsEntry("/extra/big", BigInteger.class.getName()) + .containsEntry("/pair/right", Double.class.getName()) + .containsEntry("/self", Config.class.getName()) + .doesNotContainKey("/CONSTANT"); + } + + @Test + public void leavesStayAsTheyAre() { + assertThat(FieldTree.of(null).get(FieldTree.VALUES_KEY)).isNull(); + assertThat(FieldTree.of(7L).get(FieldTree.VALUES_KEY)).isEqualTo(7L); + assertThat(FieldTree.of(new int[] {1, 2}).get(FieldTree.VALUES_KEY)).isEqualTo(List.of(1, 2)); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/GoldenJson.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/GoldenJson.java new file mode 100644 index 0000000000..bee47f64e1 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/GoldenJson.java @@ -0,0 +1,157 @@ +package org.lowcoder.sdk.contract; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Golden fixtures of the JSON contract (docs/API_PAYLOAD_TEST_PLAN.md §4, §7, §8.2). + * + *

    Fixtures live under {@link #FIXTURE_DIRECTORY} of the module under test, addressed by a path relative to it, + * for example {@code types/org.lowcoder.domain.folder.model.Folder.S1.json}. + * + *

      + *
    • Overlay. With {@code -D}{@value #OVERLAY_PROPERTY}{@code =}, a fixture present in that directory + * (same relative path) is used instead of the module's. The mutation check of §8.2 changes fixtures there, so + * repository files are never touched.
    • + *
    • Creation only. A missing fixture fails the test. With {@code -D}{@value #CREATE_MISSING_PROPERTY} + * {@code =true} the actual content is written to the module's fixture path first, and the test still fails, so + * a created fixture is always reviewed. An existing fixture is never written.
    • + *
    + * + *

    JSON fixtures are compared with {@link CanonicalJson#assertEquivalent}; text fixtures (rendered SQL, Mongo, + * query strings) exactly. + * + *

    Fixture paths must be relative and stay inside the fixture directory (and the overlay), both lexically and once + * the symbolic links of their existing part are followed; anything else is an {@link IllegalArgumentException}. A + * link created between that check and the read or write is not caught. + * + *

    Limits: the module directory is the {@code basedir} system property that surefire and failsafe set, or the + * working directory when it is absent; a test run from another directory resolves fixtures elsewhere. Created + * fixtures hold the actual text unchanged, without pretty-printing, so their number lexemes and escapes are the + * ones produced. + */ +public final class GoldenJson { + + public static final String FIXTURE_DIRECTORY = "src/test/resources/json-contract"; + public static final String OVERLAY_PROPERTY = "contract.fixtureOverlay"; + public static final String CREATE_MISSING_PROPERTY = "contract.createMissing"; + public static final String BASEDIR_PROPERTY = "basedir"; + public static final String WORKING_DIRECTORY_PROPERTY = "user.dir"; + + private final Path fixtureRoot; + private final Path overlayRoot; + private final boolean createMissing; + + public GoldenJson(Path fixtureRoot, Path overlayRoot, boolean createMissing) { + this.fixtureRoot = fixtureRoot; + this.overlayRoot = overlayRoot; + this.createMissing = createMissing; + } + + /** The fixtures of the module under test, configured by the system properties above. */ + public static GoldenJson forModule() { + Path moduleDirectory = Path.of(System.getProperty(BASEDIR_PROPERTY, System.getProperty(WORKING_DIRECTORY_PROPERTY))); + String overlay = System.getProperty(OVERLAY_PROPERTY); + return new GoldenJson(moduleDirectory.resolve(FIXTURE_DIRECTORY), + overlay == null || overlay.isBlank() ? null : Path.of(overlay), + Boolean.parseBoolean(System.getProperty(CREATE_MISSING_PROPERTY))); + } + + /** Compares {@code actualJson} with the JSON fixture at {@code relativePath} by the §1.2 contract. */ + public void assertJson(String relativePath, String actualJson) { + CanonicalJson.assertEquivalent(expected(relativePath, actualJson), actualJson); + } + + /** Compares {@code actualText} with the text fixture at {@code relativePath} exactly. */ + public void assertText(String relativePath, String actualText) { + String expected = expected(relativePath, actualText); + if (!expected.equals(actualText)) { + throw new AssertionError("rendered text differs from fixture " + relativePath + + "\nexpected: " + expected + "\nactual: " + actualText); + } + } + + /** Reads a fixture used as test input, from the overlay when it has one. */ + public String read(String relativePath) { + Path fixture = resolve(relativePath); + if (!Files.exists(fixture)) { + throw new AssertionError("missing input fixture " + fixture); + } + return readFile(fixture); + } + + /** The fixture file used for {@code relativePath}: the overlay's when it exists there, else the module's. */ + public Path resolve(String relativePath) { + if (overlayRoot != null) { + Path overlaid = inside(overlayRoot, relativePath); + if (Files.exists(overlaid)) { + System.out.println("[contract] fixture " + relativePath + " read from overlay " + overlaid); + return overlaid; + } + } + return inside(fixtureRoot, relativePath); + } + + /** {@code root} resolved with {@code relativePath}, which must be relative and must not leave {@code root}. */ + private static Path inside(Path root, String relativePath) { + Path relative = Path.of(relativePath); + if (relative.isAbsolute()) { + throw new IllegalArgumentException("fixture path must be relative to " + root + ": " + relativePath); + } + Path normalizedRoot = root.toAbsolutePath().normalize(); + Path resolved = normalizedRoot.resolve(relative).normalize(); + if (!resolved.startsWith(normalizedRoot) || resolved.equals(normalizedRoot) || leavesThroughLink(normalizedRoot, resolved)) { + throw new IllegalArgumentException("fixture path must stay inside " + root + ": " + relativePath); + } + return resolved; + } + + /** + * Whether the deepest existing part of {@code resolved} lies outside {@code root} once symbolic links are + * followed. A root that does not exist yet has no links to follow. + */ + private static boolean leavesThroughLink(Path root, Path resolved) { + if (!Files.exists(root)) { + return false; + } + Path existing = resolved; + while (existing != null && !Files.exists(existing)) { + existing = existing.getParent(); + } + try { + return existing == null || !existing.toRealPath().startsWith(root.toRealPath()); + } catch (IOException e) { + throw new UncheckedIOException("cannot resolve fixture path " + resolved, e); + } + } + + private String expected(String relativePath, String actual) { + Path fixture = resolve(relativePath); + if (Files.exists(fixture)) { + return readFile(fixture); + } + Path target = inside(fixtureRoot, relativePath); + if (!createMissing) { + throw new AssertionError("missing fixture " + target + "; run with -D" + CREATE_MISSING_PROPERTY + + "=true to create it from the actual value, then review it\nactual: " + actual); + } + try { + Files.createDirectories(target.getParent()); + Files.writeString(target, actual, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("cannot create fixture " + target, e); + } + throw new AssertionError("created fixture " + target + " from the actual value; review it and run again"); + } + + private static String readFile(Path fixture) { + try { + return Files.readString(fixture, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("cannot read fixture " + fixture, e); + } + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/GoldenJsonTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/GoldenJsonTest.java new file mode 100644 index 0000000000..1f3f064c9c --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/GoldenJsonTest.java @@ -0,0 +1,146 @@ +package org.lowcoder.sdk.contract; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Self-test of {@link GoldenJson}: fixture lookup, the overlay, and the creation-only policy (plan §7, §8.2). + * + *

    {@link #moduleFixtureMatches()} reads the real module fixture {@value #SELFTEST_FIXTURE} through + * {@link GoldenJson#forModule()}, so the §8.2 mutation check can be run against it with + * {@code -Dcontract.fixtureOverlay}. + */ +public class GoldenJsonTest { + + private static final String SELFTEST_FIXTURE = "selftest/golden.json"; + private static final String SELFTEST_ACTUAL = "{\"name\":\"GoldenJsonTest\",\"count\":1001,\"ratio\":1.50}"; + private static final String TYPE_FIXTURE = "types/example.S1.json"; + private static final String TEXT_FIXTURE = "boundary/example/render.txt"; + + @TempDir + Path folder; + + private Path fixtures() throws IOException { + return Files.createDirectory(folder.resolve("fixtures")); + } + + private static void write(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content, StandardCharsets.UTF_8); + } + + @Test + public void moduleFixtureMatches() { + GoldenJson golden = GoldenJson.forModule(); + System.out.println("[GoldenJsonTest] module fixture " + golden.resolve(SELFTEST_FIXTURE)); + golden.assertJson(SELFTEST_FIXTURE, SELFTEST_ACTUAL); + } + + @Test + public void matchingFixturePassesAndDifferenceFails() throws IOException { + Path root = fixtures(); + write(root.resolve(TYPE_FIXTURE), "{\"a\":1}"); + GoldenJson golden = new GoldenJson(root, null, false); + golden.assertJson(TYPE_FIXTURE, "{ \"a\": 1 }"); + assertThatThrownBy(() -> golden.assertJson(TYPE_FIXTURE, "{\"a\":1.0}")) + .isInstanceOf(AssertionError.class).hasMessageContaining("$.a: number lexeme differs"); + } + + @Test + public void overlayFixtureWinsOverModuleFixture() throws IOException { + Path root = fixtures(); + Path overlay = Files.createDirectory(folder.resolve("overlay")); + write(root.resolve(TYPE_FIXTURE), "{\"a\":1}"); + write(overlay.resolve(TYPE_FIXTURE), "{\"b\":1}"); + GoldenJson golden = new GoldenJson(root, overlay, false); + assertThat(golden.resolve(TYPE_FIXTURE)).isEqualTo(overlay.resolve(TYPE_FIXTURE)); + assertThatThrownBy(() -> golden.assertJson(TYPE_FIXTURE, "{\"a\":1}")) + .isInstanceOf(AssertionError.class).hasMessageContaining("$.b: missing property"); + assertThat(golden.resolve(TEXT_FIXTURE)).as("a fixture absent from the overlay comes from the module") + .isEqualTo(root.resolve(TEXT_FIXTURE)); + } + + @Test + public void missingFixtureFailsWithoutCreating() throws IOException { + Path root = fixtures(); + GoldenJson golden = new GoldenJson(root, null, false); + assertThatThrownBy(() -> golden.assertJson(TYPE_FIXTURE, "{\"a\":1}")) + .isInstanceOf(AssertionError.class).hasMessageContaining("missing fixture") + .hasMessageContaining(GoldenJson.CREATE_MISSING_PROPERTY); + assertThat(root.resolve(TYPE_FIXTURE)).doesNotExist(); + } + + @Test + public void createMissingWritesTheActualTextAndStillFails() throws IOException { + Path root = fixtures(); + GoldenJson golden = new GoldenJson(root, null, true); + String actual = "{\"n\":1.50,\"s\":\"\\u00e9\"}"; + assertThatThrownBy(() -> golden.assertJson(TYPE_FIXTURE, actual)) + .isInstanceOf(AssertionError.class).hasMessageContaining("created fixture"); + assertThat(Files.readString(root.resolve(TYPE_FIXTURE), StandardCharsets.UTF_8)).isEqualTo(actual); + golden.assertJson(TYPE_FIXTURE, actual); + } + + @Test + public void existingFixtureIsNeverOverwritten() throws IOException { + Path root = fixtures(); + write(root.resolve(TYPE_FIXTURE), "{\"a\":1}"); + GoldenJson golden = new GoldenJson(root, null, true); + assertThatThrownBy(() -> golden.assertJson(TYPE_FIXTURE, "{\"a\":2}")).isInstanceOf(AssertionError.class); + assertThat(Files.readString(root.resolve(TYPE_FIXTURE), StandardCharsets.UTF_8)).isEqualTo("{\"a\":1}"); + } + + @Test + public void textFixturesAreComparedExactly() throws IOException { + Path root = fixtures(); + write(root.resolve(TEXT_FIXTURE), "WHERE a = '{\"k\":\"é\"}'"); + GoldenJson golden = new GoldenJson(root, null, false); + golden.assertText(TEXT_FIXTURE, "WHERE a = '{\"k\":\"é\"}'"); + assertThatThrownBy(() -> golden.assertText(TEXT_FIXTURE, "WHERE a = '{\"k\":\"\\u00e9\"}'")) + .isInstanceOf(AssertionError.class).hasMessageContaining("rendered text differs"); + } + + @Test + public void fixturePathsCannotLeaveTheFixtureRoot() throws IOException { + Path root = fixtures(); + GoldenJson golden = new GoldenJson(root, null, true); + String escaping = "../outside.json"; + String absolute = root.resolve("types/absolute.json").toAbsolutePath().toString(); + assertThatThrownBy(() -> golden.assertJson(escaping, "{}")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("must stay inside"); + assertThatThrownBy(() -> golden.assertJson(absolute, "{}")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("must be relative"); + assertThat(root.resolve(escaping).normalize()).as("nothing created outside the root").doesNotExist(); + } + + @Test + public void fixturePathsCannotLeaveTheFixtureRootThroughASymlink() throws IOException { + Path root = fixtures(); + Path outside = Files.createDirectory(folder.resolve("outside")); + Files.createSymbolicLink(root.resolve("link"), outside); + GoldenJson golden = new GoldenJson(root, null, true); + assertThatThrownBy(() -> golden.assertJson("link/created.json", "{}")) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("must stay inside"); + assertThat(outside.resolve("created.json")).as("nothing created through the symlink").doesNotExist(); + } + + @Test + public void readReturnsInputFixturesFromTheOverlay() throws IOException { + Path root = fixtures(); + Path overlay = Files.createDirectory(folder.resolve("overlay")); + write(root.resolve(TYPE_FIXTURE), "module"); + write(overlay.resolve(TYPE_FIXTURE), "overlay"); + assertThat(new GoldenJson(root, overlay, false).read(TYPE_FIXTURE)).isEqualTo("overlay"); + assertThat(new GoldenJson(root, null, false).read(TYPE_FIXTURE)).isEqualTo("module"); + assertThatThrownBy(() -> new GoldenJson(root, null, false).read(TEXT_FIXTURE)) + .isInstanceOf(AssertionError.class).hasMessageContaining("missing input fixture"); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/JacksonAnnotationGuard.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/JacksonAnnotationGuard.java new file mode 100644 index 0000000000..851bdf6cae --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/JacksonAnnotationGuard.java @@ -0,0 +1,222 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.fasterxml.jackson.annotation.JsonView; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.cfg.MapperConfig; +import com.fasterxml.jackson.databind.introspect.AnnotatedClass; +import com.fasterxml.jackson.databind.introspect.AnnotatedMember; +import com.fasterxml.jackson.databind.introspect.AnnotatedWithParams; +import com.fasterxml.jackson.databind.introspect.AnnotationMap; +import com.fasterxml.jackson.databind.util.ClassUtil; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.stream.Collectors; + +/** + * Fails on per-property Jackson configuration that the contract tests are not organized for + * (docs/API_PAYLOAD_TEST_PLAN.md §2, gate rule 6 of §6.1). + * + *

    It reads the annotations of a type as Jackson's own introspection sees them, through the {@link AnnotatedClass} + * of the given mapper's serialization and deserialization configurations (so mix-ins and annotations inherited from + * supertypes are included, and spelling, imports and fully qualified names do not matter): the class annotations, + * {@link AnnotatedMember#getAllAnnotations()} of every field, member method, static method, constructor and factory, + * and the annotations of their parameters. Enum constants are read by reflection, because Jackson's + * {@link AnnotatedClass} does not list static fields. + * + *

    Every annotation of the packages {@link #JACKSON_ANNOTATION_PACKAGES} must be in {@link #ALLOWED}; + * {@link JsonDeserialize} is allowed only with {@value #BUILDER_ATTRIBUTE} set and no other attribute set. + * + *

    Limits: it inspects only the types it is given; following properties to further types is the caller's job (the + * payload walker in {@code lowcoder-server}). A {@code @JsonPOJOBuilder} class must be passed on its own. Jackson + * configuration that is not an annotation (modules, custom serializers registered in code, mapper features) is not + * seen; the codec guards of §5.4 pin that. Member methods with more than two parameters are not collected by + * Jackson, so their annotations are not seen either. + */ +public final class JacksonAnnotationGuard { + + public static final Set JACKSON_ANNOTATION_PACKAGES = Set.of( + "com.fasterxml.jackson.annotation", "com.fasterxml.jackson.databind.annotation"); + public static final Set> ALLOWED = Set.of( + JsonProperty.class, JsonAlias.class, JsonIgnore.class, JsonView.class, JsonInclude.class, + JsonCreator.class, JsonTypeInfo.class, JsonSubTypes.class, JsonTypeName.class, + JsonIgnoreProperties.class, JsonPOJOBuilder.class, JsonDeserialize.class); + public static final String BUILDER_ATTRIBUTE = "builder"; + private static final String CLASS_MEMBER = ""; + private static final String ANNOTATION_PREFIX = "@"; + + private JacksonAnnotationGuard() { + } + + /** One Jackson annotation found on {@code member} of {@code type}; {@code allowed} says whether §2 permits it. */ + public record Occurrence(String type, String member, Annotation annotation, boolean allowed) { + + public String annotationName() { + return ANNOTATION_PREFIX + annotation.annotationType().getSimpleName(); + } + + @Override + public String toString() { + return type + " " + member + " " + annotation; + } + } + + /** Every Jackson annotation of {@code type}, allowed or not, ordered by member and annotation. */ + public static List occurrences(ObjectMapper mapper, Class type) { + SortedSet seen = new TreeSet<>(); + List result = new ArrayList<>(); + for (MapperConfig config : List.of(mapper.getSerializationConfig(), mapper.getDeserializationConfig())) { + AnnotatedClass annotatedClass = config.introspectClassAnnotations(config.constructType(type)).getClassInfo(); + add(type, CLASS_MEMBER, classAnnotations(config, annotatedClass), seen, result); + annotatedClass.fields().forEach(field -> add(type, field.getFullName(), annotations(field.getAllAnnotations()), seen, result)); + annotatedClass.memberMethods().forEach(method -> addWithParameters(type, method, seen, result)); + annotatedClass.getStaticMethods().forEach(method -> addWithParameters(type, method, seen, result)); + annotatedClass.getFactoryMethods().forEach(method -> addWithParameters(type, method, seen, result)); + annotatedClass.getConstructors().forEach(constructor -> addWithParameters(type, constructor, seen, result)); + if (annotatedClass.getDefaultConstructor() != null) { + addWithParameters(type, annotatedClass.getDefaultConstructor(), seen, result); + } + } + if (type.isEnum()) { + for (Field field : type.getDeclaredFields()) { + if (field.isEnumConstant()) { + add(type, type.getName() + "#" + field.getName(), List.of(field.getAnnotations()), seen, result); + } + } + } + result.sort((a, b) -> (a.member() + a.annotationName()).compareTo(b.member() + b.annotationName())); + return result; + } + + /** The simple names ({@code @JsonProperty}) of every Jackson annotation of {@code type}. */ + public static SortedSet annotationNames(ObjectMapper mapper, Class type) { + return occurrences(mapper, type).stream().map(Occurrence::annotationName).collect(Collectors.toCollection(TreeSet::new)); + } + + /** The Jackson annotations of {@code types} that §2 does not allow. */ + public static List violations(ObjectMapper mapper, Collection> types) { + return types.stream().flatMap(type -> occurrences(mapper, type).stream()).filter(o -> !o.allowed()).toList(); + } + + /** Fails with every violation of {@code types}, one per line. */ + public static void assertAllowed(ObjectMapper mapper, Collection> types) { + List violations = violations(mapper, types); + System.out.println("[jackson-annotation-guard] " + types.size() + " type(s) inspected, " + violations.size() + " violation(s)"); + if (!violations.isEmpty()) { + throw new AssertionError("Jackson annotations outside the allow-list of docs/API_PAYLOAD_TEST_PLAN.md §2:\n" + + violations.stream().map(Occurrence::toString).collect(Collectors.joining("\n"))); + } + } + + /** Whether §2 allows {@code annotation}; annotations outside {@link #JACKSON_ANNOTATION_PACKAGES} are not judged. */ + public static boolean isAllowed(Annotation annotation) { + Class annotationType = annotation.annotationType(); + if (!ALLOWED.contains(annotationType)) { + return false; + } + return annotationType != JsonDeserialize.class || onlyBuilderSet(annotation); + } + + private static boolean isJackson(Annotation annotation) { + return JACKSON_ANNOTATION_PACKAGES.contains(annotation.annotationType().getPackageName()); + } + + /** Whether {@code annotation} sets {@value #BUILDER_ATTRIBUTE} and leaves every other attribute at its default. */ + private static boolean onlyBuilderSet(Annotation annotation) { + for (Method attribute : annotation.annotationType().getDeclaredMethods()) { + boolean isDefault = isDefault(annotation, attribute); + if (attribute.getName().equals(BUILDER_ATTRIBUTE) == isDefault) { + return false; + } + } + return true; + } + + private static boolean isDefault(Annotation annotation, Method attribute) { + try { + return Objects.deepEquals(attribute.getDefaultValue(), attribute.invoke(annotation)); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new IllegalStateException("cannot read " + attribute + " of " + annotation, e); + } + } + + private static void addWithParameters(Class type, AnnotatedWithParams member, SortedSet seen, List result) { + String name = member.getFullName() + "(" + member.getParameterCount() + " parameter(s))"; + add(type, name, annotations(member.getAllAnnotations()), seen, result); + for (int i = 0; i < member.getParameterCount(); i++) { + add(type, name + " parameter " + i, annotations(member.getParameterAnnotations(i)), seen, result); + } + } + + /** + * The class annotations Jackson resolved for {@code annotatedClass}. Jackson keeps one or two of them in holders + * that cannot be iterated, so the candidate types are read by reflection from where Jackson takes class + * annotations (the class, its superclasses and interfaces, their mix-ins, and the contents of + * {@link JacksonAnnotationsInside} bundles), and each is then looked up in Jackson's resolved annotations. + */ + private static Iterable classAnnotations(MapperConfig config, AnnotatedClass annotatedClass) { + Set> candidates = new LinkedHashSet<>(); + List> sources = new ArrayList<>(); + sources.add(annotatedClass.getRawType()); + sources.addAll(ClassUtil.findSuperTypes(annotatedClass.getRawType(), null)); + for (Class source : sources) { + addCandidates(source.getAnnotations(), candidates); + Class mixIn = config.findMixInClassFor(source); + if (mixIn != null) { + addCandidates(mixIn.getAnnotations(), candidates); + ClassUtil.findSuperTypes(mixIn, null).forEach(mixInSuper -> addCandidates(mixInSuper.getAnnotations(), candidates)); + } + } + List resolved = new ArrayList<>(); + for (Class candidate : candidates) { + Annotation annotation = annotatedClass.getAnnotation(candidate); + if (annotation != null) { + resolved.add(annotation); + } + } + return resolved; + } + + private static void addCandidates(Annotation[] annotations, Set> candidates) { + for (Annotation annotation : annotations) { + if (candidates.add(annotation.annotationType()) && annotation.annotationType().isAnnotationPresent(JacksonAnnotationsInside.class)) { + addCandidates(annotation.annotationType().getAnnotations(), candidates); + } + } + } + + /** Jackson leaves the map {@code null} for a member or parameter without annotations. */ + private static Iterable annotations(AnnotationMap map) { + return map == null ? List.of() : map.annotations(); + } + + private static void add(Class type, String member, Iterable annotations, SortedSet seen, List result) { + for (Annotation annotation : annotations) { + if (isJackson(annotation) && seen.add(member + " " + annotation)) { + result.add(new Occurrence(type.getName(), member, annotation, isAllowed(annotation))); + } + } + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/JacksonAnnotationGuardTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/JacksonAnnotationGuardTest.java new file mode 100644 index 0000000000..a3117b4571 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/JacksonAnnotationGuardTest.java @@ -0,0 +1,241 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonView; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.deser.std.StringDeserializer; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.util.JsonUtils; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Self-test of {@link JacksonAnnotationGuard} (docs/API_PAYLOAD_TEST_PLAN.md §2, §8.1): it must fail on a + * {@code @JsonFormat} type, wherever Jackson's introspection finds the annotation, and pass the allow-list. + */ +public class JacksonAnnotationGuardTest { + + private static final ObjectMapper PRODUCTION = JsonUtils.getObjectMapper(); + private static final String JSON_FORMAT = "@JsonFormat"; + + static class FormattedDate { + @JsonFormat(shape = JsonFormat.Shape.STRING) + public Instant at; + } + + static class View { + } + + static class AllowedType { + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + public String password; + @JsonIgnore + public String internal; + @JsonView(View.class) + @JsonInclude(JsonInclude.Include.NON_NULL) + public String name; + + @JsonCreator + AllowedType(@JsonProperty("name") String name) { + this.name = name; + } + } + + @JsonDeserialize(builder = BuiltType.Builder.class) + static class BuiltType { + public String value; + + @JsonPOJOBuilder(withPrefix = "") + static class Builder { + private String value; + + public Builder value(String value) { + this.value = value; + return this; + } + + public BuiltType build() { + BuiltType built = new BuiltType(); + built.value = value; + return built; + } + } + } + + static class CustomDeserializer { + @JsonDeserialize(using = StringDeserializer.class) + public String value; + } + + static class BareDeserialize { + @JsonDeserialize + public String value; + } + + static class SerializedBySuperclass { + @JsonSerialize(using = ToStringSerializer.class) + public Object getValue() { + return 1; + } + } + + static class InheritsSerializer extends SerializedBySuperclass { + @Override + public Object getValue() { + return 2; + } + } + + static class FormattedCreatorParameter { + public final Instant at; + + @JsonCreator + FormattedCreatorParameter(@JsonProperty("at") @JsonFormat(shape = JsonFormat.Shape.STRING) Instant at) { + this.at = at; + } + } + + enum WithDefault { + KNOWN, + @JsonEnumDefaultValue + UNKNOWN + } + + @Retention(RetentionPolicy.RUNTIME) + @JacksonAnnotationsInside + @JsonFormat(shape = JsonFormat.Shape.STRING) + @interface Bundled { + } + + static class BundledFormat { + @Bundled + public Instant at; + } + + @JsonFormat(shape = JsonFormat.Shape.OBJECT) + @JsonIgnoreProperties(ignoreUnknown = true) + static class ClassFormatted { + public String value; + } + + static class InheritsClassFormat extends ClassFormatted { + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + interface Formatted { + } + + static class ImplementsFormatted implements Formatted { + public String someValue; + } + + @JsonPropertyOrder({"at"}) + abstract static class ClassFormatMixIn { + } + + static class Plain { + public Instant at; + } + + abstract static class FormatMixIn { + @JsonFormat(shape = JsonFormat.Shape.STRING) + public Instant at; + } + + private static List violationsOf(ObjectMapper mapper, Class type) { + List violations = JacksonAnnotationGuard.violations(mapper, List.of(type)); + System.out.println("[JacksonAnnotationGuardTest] " + type.getSimpleName() + ": occurrences=" + + JacksonAnnotationGuard.occurrences(mapper, type) + " violations=" + violations); + return violations; + } + + private static void assertViolation(ObjectMapper mapper, Class type, String annotationName) { + assertThat(violationsOf(mapper, type)).as(type.getSimpleName()) + .anySatisfy(violation -> assertThat(violation.annotationName()).isEqualTo(annotationName)); + } + + @Test + public void jsonFormatTypeFails() { + assertThatThrownBy(() -> JacksonAnnotationGuard.assertAllowed(PRODUCTION, List.of(FormattedDate.class))) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("§2") + .hasMessageContaining(FormattedDate.class.getName()) + .hasMessageContaining("JsonFormat"); + } + + @Test + public void allowListPasses() { + JacksonAnnotationGuard.assertAllowed(PRODUCTION, List.of(AllowedType.class, BuiltType.class, BuiltType.Builder.class)); + assertThat(JacksonAnnotationGuard.annotationNames(PRODUCTION, AllowedType.class)) + .containsExactly("@JsonCreator", "@JsonIgnore", "@JsonInclude", "@JsonProperty", "@JsonView"); + assertThat(JacksonAnnotationGuard.annotationNames(PRODUCTION, BuiltType.class)).containsExactly("@JsonDeserialize"); + assertThat(JacksonAnnotationGuard.annotationNames(PRODUCTION, BuiltType.Builder.class)).containsExactly("@JsonPOJOBuilder"); + } + + @Test + public void jsonDeserializeIsAllowedOnlyWithBuilder() { + assertViolation(PRODUCTION, CustomDeserializer.class, "@JsonDeserialize"); + assertViolation(PRODUCTION, BareDeserialize.class, "@JsonDeserialize"); + } + + @Test + public void annotationInheritedFromSuperclassIsFound() { + assertViolation(PRODUCTION, InheritsSerializer.class, "@JsonSerialize"); + } + + @Test + public void creatorParameterAnnotationIsFound() { + assertViolation(PRODUCTION, FormattedCreatorParameter.class, JSON_FORMAT); + } + + @Test + public void enumConstantAnnotationIsFound() { + assertViolation(PRODUCTION, WithDefault.class, "@JsonEnumDefaultValue"); + } + + @Test + public void annotationBundleIsExpanded() { + assertViolation(PRODUCTION, BundledFormat.class, JSON_FORMAT); + } + + @Test + public void classAnnotationsAreFoundOnTypeSupertypeAndMixIn() { + assertViolation(PRODUCTION, ClassFormatted.class, JSON_FORMAT); + assertViolation(PRODUCTION, InheritsClassFormat.class, JSON_FORMAT); + assertViolation(PRODUCTION, ImplementsFormatted.class, "@JsonNaming"); + ObjectMapper withMixIn = new ObjectMapper().addMixIn(Plain.class, ClassFormatMixIn.class); + assertViolation(withMixIn, Plain.class, "@JsonPropertyOrder"); + } + + @Test + public void bundleMarkerIsItselfOutsideTheAllowList() { + assertViolation(PRODUCTION, BundledFormat.class, "@JacksonAnnotationsInside"); + } + + @Test + public void mixInOfTheGivenMapperIsSeen() { + ObjectMapper withMixIn = new ObjectMapper().addMixIn(Plain.class, FormatMixIn.class); + assertThat(violationsOf(PRODUCTION, Plain.class)).isEmpty(); + assertViolation(withMixIn, Plain.class, JSON_FORMAT); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/JavaValueWalker.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/JavaValueWalker.java new file mode 100644 index 0000000000..8514d35fdc --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/JavaValueWalker.java @@ -0,0 +1,73 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The Java shape of a value read from JSON (docs/API_PAYLOAD_TEST_PLAN.md §4.6, §4.9): the class of every value by + * JSON pointer (RFC 6901), and the iteration order of every object's keys. The canonical JSON comparison ignores both, + * so a test that depends on which Java class a number or a container becomes pins this shape in a fixture. + * + *

    Limits: maps, lists and {@link JsonNode} containers are descended; any other object, a bean included, is one + * leaf recorded by its class. + */ +public final class JavaValueWalker { + + public static final String ROOT_POINTER = ""; + public static final String NULL = "null"; + public static final String CLASSES_KEY = "classes"; + public static final String KEY_ORDER_KEY = "keyOrder"; + + private JavaValueWalker() { + } + + /** {@code {"classes": {pointer: class}, "keyOrder": {pointer: [keys]}}} of {@code value}, in walk order. */ + public static Map shape(Object value) { + Map classes = new LinkedHashMap<>(); + Map> keyOrder = new LinkedHashMap<>(); + walk(ROOT_POINTER, value, classes, keyOrder); + Map shape = new LinkedHashMap<>(); + shape.put(CLASSES_KEY, classes); + shape.put(KEY_ORDER_KEY, keyOrder); + return shape; + } + + /** Records the class of {@code value} at {@code pointer}, and recurses into maps, lists and JSON containers. */ + public static void walk(String pointer, Object value, Map classes, Map> keyOrder) { + classes.put(pointer, value == null ? NULL : value.getClass().getName()); + if (value instanceof Map map) { + List keys = new ArrayList<>(); + map.forEach((key, entry) -> { + keys.add(String.valueOf(key)); + walk(pointer + "/" + escape(String.valueOf(key)), entry, classes, keyOrder); + }); + keyOrder.put(pointer, keys); + } else if (value instanceof List list) { + for (int i = 0; i < list.size(); i++) { + walk(pointer + "/" + i, list.get(i), classes, keyOrder); + } + } else if (value instanceof JsonNode node && node.isObject()) { + List keys = new ArrayList<>(); + for (Iterator> fields = node.fields(); fields.hasNext(); ) { + Map.Entry field = fields.next(); + keys.add(field.getKey()); + walk(pointer + "/" + escape(field.getKey()), field.getValue(), classes, keyOrder); + } + keyOrder.put(pointer, keys); + } else if (value instanceof JsonNode node && node.isArray()) { + for (int i = 0; i < node.size(); i++) { + walk(pointer + "/" + i, node.get(i), classes, keyOrder); + } + } + } + + /** RFC 6901 escaping of one reference token. */ + public static String escape(String token) { + return token.replace("~", "~0").replace("/", "~1"); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/PayloadBoundsTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/PayloadBoundsTest.java new file mode 100644 index 0000000000..2bf7e9d2fc --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/PayloadBoundsTest.java @@ -0,0 +1,161 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.exc.StreamConstraintsException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.util.JsonUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The read limits of the production mapper (docs/API_PAYLOAD_TEST_PLAN.md §4.8, R11, E3), on a DSL + * {@code Map}: {@code JsonUtils} sets no {@code StreamReadConstraints}, so the Jackson defaults apply and + * can change with an upgrade. Each limit is read at its value and one past it: nesting depth 1000, a number of 1000 + * digits, a string of 20,000,000 characters. The configured values are asserted too, so a changed default is named + * even where the behaviour still happens to pass. + * + *

    Inputs are generated as streams ({@link #repeated}), never held as text; the largest test holds the parsed + * 20,000,000-character string and Jackson's buffer for it. These tests run in every build (owner decision C10-A6). + * Limits: the codec's 20 MB {@code maxInMemorySize} is the server module's {@code PayloadBoundsTest}; the other + * Jackson 2.15 has only these three read constraints; the document- and name-length limits of later versions are not + * seen here until an upgrade adds them to {@code StreamReadConstraints}. + */ +public class PayloadBoundsTest { + + static final int MAX_NESTING_DEPTH = 1000; + static final int MAX_NUMBER_LENGTH = 1000; + static final int MAX_STRING_LENGTH = 20_000_000; + static final String DSL_KEY = "dsl"; + static final String DSL_PREFIX = "{\"" + DSL_KEY + "\":"; + static final String DSL_SUFFIX = "}"; + static final TypeReference> DSL = new TypeReference<>() { + }; + + private static final ObjectMapper MAPPER = JsonUtils.getObjectMapper(); + + @Test + public void theMapperUsesTheJacksonDefaultLimits() { + StreamReadConstraints constraints = MAPPER.getFactory().streamReadConstraints(); + System.out.println("[PayloadBoundsTest] read constraints: nesting " + constraints.getMaxNestingDepth() + ", number " + + constraints.getMaxNumberLength() + ", string " + constraints.getMaxStringLength()); + assertEquals(MAX_NESTING_DEPTH, constraints.getMaxNestingDepth()); + assertEquals(MAX_NUMBER_LENGTH, constraints.getMaxNumberLength()); + assertEquals(MAX_STRING_LENGTH, constraints.getMaxStringLength()); + } + + @Test + public void nestingDepthAtTheLimitIsRead() throws IOException { + Map dsl = read(nested(MAX_NESTING_DEPTH)); + int arrays = 0; + for (Object value = dsl.get(DSL_KEY); value instanceof List list; value = list.isEmpty() ? null : list.get(0)) { + arrays++; + } + System.out.println("[PayloadBoundsTest] depth " + MAX_NESTING_DEPTH + ": read, " + arrays + " nested arrays inside the DSL object"); + assertEquals(MAX_NESTING_DEPTH - 1, arrays, "the DSL object is level 1, the arrays the rest"); + } + + @Test + public void nestingDepthBeyondTheLimitFails() { + assertRejected("depth " + (MAX_NESTING_DEPTH + 1), nested(MAX_NESTING_DEPTH + 1)); + } + + @Test + public void numberOfMaximumLengthIsRead() throws IOException { + Object number = read(dslValue(repeated("9", MAX_NUMBER_LENGTH))).get(DSL_KEY); + System.out.println("[PayloadBoundsTest] number of " + MAX_NUMBER_LENGTH + " digits: read as " + number.getClass().getName()); + assertEquals(BigInteger.class, number.getClass()); + assertEquals(MAX_NUMBER_LENGTH, number.toString().length()); + } + + @Test + public void numberBeyondTheMaximumLengthFails() { + assertRejected("number of " + (MAX_NUMBER_LENGTH + 1) + " digits", dslValue(repeated("9", MAX_NUMBER_LENGTH + 1))); + } + + @Test + public void stringOfMaximumLengthIsRead() throws IOException { + long start = System.nanoTime(); + Object string = read(dslValue(quoted(MAX_STRING_LENGTH))).get(DSL_KEY); + System.out.println("[PayloadBoundsTest] string of " + MAX_STRING_LENGTH + " characters: read in " + + (System.nanoTime() - start) / 1_000_000 + " ms"); + assertEquals(String.class, string.getClass()); + assertEquals(MAX_STRING_LENGTH, ((String) string).length()); + } + + @Test + public void stringBeyondTheMaximumLengthFails() { + assertRejected("string of " + (MAX_STRING_LENGTH + 1) + " characters", dslValue(quoted(MAX_STRING_LENGTH + 1))); + } + + private static Map read(InputStream json) throws IOException { + try (json) { + return MAPPER.readValue(json, DSL); + } + } + + private static void assertRejected(String what, InputStream json) { + StreamConstraintsException e = assertThrows(StreamConstraintsException.class, () -> read(json)); + System.out.println("[PayloadBoundsTest] " + what + ": " + e.getClass().getName() + ": " + e.getOriginalMessage()); + } + + /** A DSL object whose value nests arrays, so the document's depth is {@code depth} (the object is level 1). */ + static InputStream nested(int depth) { + return dslValue(sequence(repeated("[", depth - 1), repeated("]", depth - 1))); + } + + /** {@code {"dsl":}}. */ + static InputStream dslValue(InputStream value) { + return sequence(text(DSL_PREFIX), value, text(DSL_SUFFIX)); + } + + /** A JSON string of {@code length} characters. */ + static InputStream quoted(int length) { + return sequence(text("\""), repeated("a", length), text("\"")); + } + + /** {@code unit} (ASCII) {@code count} times, generated while it is read. */ + static InputStream repeated(String unit, long count) { + byte[] bytes = unit.getBytes(StandardCharsets.US_ASCII); + long total = bytes.length * count; + return new InputStream() { + private long position; + + @Override + public int read() { + return position < total ? bytes[(int) (position++ % bytes.length)] : -1; + } + + @Override + public int read(byte[] buffer, int offset, int length) { + if (position >= total) { + return -1; + } + int n = (int) Math.min(length, total - position); + for (int i = 0; i < n; i++) { + buffer[offset + i] = bytes[(int) (position++ % bytes.length)]; + } + return n; + } + }; + } + + static InputStream text(String ascii) { + return repeated(ascii, 1); + } + + static InputStream sequence(InputStream... parts) { + return new SequenceInputStream(Collections.enumeration(List.of(parts))); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/PluginConfigPolymorphismTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/PluginConfigPolymorphismTest.java new file mode 100644 index 0000000000..54a52a4d28 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/PluginConfigPolymorphismTest.java @@ -0,0 +1,148 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.introspect.AnnotatedClass; +import com.fasterxml.jackson.databind.jsontype.NamedType; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.JsonViews; +import org.lowcoder.sdk.plugin.common.ssl.SslConfig; +import org.lowcoder.sdk.plugin.restapi.auth.AuthConfig; +import org.lowcoder.sdk.util.JsonUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Polymorphism of the SDK's datasource config hierarchies {@link AuthConfig} ({@code @JsonTypeInfo} property + * {@code type}) and {@link SslConfig} (property {@code sslCertVerificationType}), docs/API_PAYLOAD_TEST_PLAN.md §4.4, + * task T7.1. Both declare their subtypes with {@code @JsonSubTypes}, {@code visible = true} and a {@code defaultImpl}. + * + *

    For each base, the inputs of {@code polymorphism/.inputs.json} (one per registered id, plus an enum constant + * that is no registered id, an unknown id and a missing id) are read with the production mapper as the base type, and + * {@code polymorphism/.json} pins per input: the bound class or the exception class; what the production mapper + * writes for the bound value as the base type with no view and under {@code Public} and {@code Internal}, the type-id + * occurrences included (§4.4: the id and the visible property are two {@code type} keys, as O3 shows for + * {@code AbstractAuthConfig}), and with the {@code SECRET-} values under {@code Public} checked absent (§4.5); the + * written output read back and written again (a client sending a config back, O62); and the + * class a bare {@code new ObjectMapper()} binds, since annotation-registered ids, unlike {@code JsonUtils}' registered + * auth ids (E2), need no mapper configuration. {@link #registeredIdsAsPinned} pins the id → class registration. + * + *

    Limits: the values are written as the base type, as the configs nested in a datasource config are; what a + * datasource config itself writes and binds is {@code config-binding}'s (WP8). + */ +public class PluginConfigPolymorphismTest { + + static final String DIRECTORY = "polymorphism/"; + static final String INPUTS_SUFFIX = ".inputs.json"; + static final String OUTCOMES_SUFFIX = ".json"; + static final String SECRET_MARKER = "SECRET-"; + static final String CLASS = "class"; + static final String NO_VIEW = "noView"; + static final String PUBLIC = "Public"; + static final String INTERNAL = "Internal"; + static final String BARE_MAPPER = "bareMapperClass"; + static final String READ_BACK = "readBack"; + static final String FAILS_WITH = "failsWith "; + + private static final ObjectMapper MAPPER = JsonUtils.getObjectMapper(); + private static final ObjectMapper BARE = new ObjectMapper(); + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @Test + public void authConfigCasesAsPinned() throws IOException { + assertCases(AuthConfig.class); + } + + @Test + public void sslConfigCasesAsPinned() throws IOException { + assertCases(SslConfig.class); + } + + @Test + public void registeredIdsAsPinned() throws IOException { + Map> ids = new TreeMap<>(); + for (Class base : List.of(AuthConfig.class, SslConfig.class)) { + AnnotatedClass annotated = MAPPER.getDeserializationConfig().introspectClassAnnotations(base).getClassInfo(); + Map byId = new TreeMap<>(); + for (NamedType subtype : MAPPER.getSubtypeResolver().collectAndResolveSubtypesByTypeId(MAPPER.getDeserializationConfig(), annotated)) { + if (subtype.hasName()) { + byId.put(subtype.getName(), subtype.getType().getName()); + } + } + ids.put(base.getName(), byId); + } + System.out.println("[PluginConfigPolymorphismTest] registered ids " + ids); + GOLDEN.assertJson(DIRECTORY + "registered-ids.json", MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(ids)); + } + + /** + * Reads every input of {@code base}, and compares the outcomes, written as JSON text so that the written outputs + * keep their duplicate keys, with {@code polymorphism/.json}. + */ + private static void assertCases(Class base) throws IOException { + JsonNode inputs = BARE.readTree(GOLDEN.read(DIRECTORY + base.getSimpleName() + INPUTS_SUFFIX)); + List cases = new ArrayList<>(); + Iterator> entries = inputs.fields(); + while (entries.hasNext()) { + Map.Entry entry = entries.next(); + String input = entry.getValue().toString(); + StringBuilder outcome = new StringBuilder("{").append(quote(BARE_MAPPER)).append(':').append(quote(boundClass(BARE, input, base))); + Object bound; + try { + bound = MAPPER.readValue(input, base); + } catch (IOException e) { + outcome.append(',').append(quote(CLASS)).append(':').append(quote(FAILS_WITH + e.getClass().getName())).append('}'); + cases.add(quote(entry.getKey()) + ":" + outcome); + System.out.println("[PluginConfigPolymorphismTest] " + base.getSimpleName() + " " + entry.getKey() + ": " + outcome); + continue; + } + ObjectWriter writer = MAPPER.writerFor(base); + String publicOutput = writer.withView(JsonViews.Public.class).writeValueAsString(bound); + assertThat(publicOutput).as("§4.5: Public output of " + base.getSimpleName() + " " + entry.getKey()).doesNotContain(SECRET_MARKER); + String output = writer.writeValueAsString(bound); + outcome.append(',').append(quote(CLASS)).append(':').append(quote(bound.getClass().getName())) + .append(',').append(quote(NO_VIEW)).append(':').append(output) + .append(',').append(quote(READ_BACK)).append(':').append(readBack(writer, output, base)) + .append(',').append(quote(PUBLIC)).append(':').append(publicOutput) + .append(',').append(quote(INTERNAL)).append(':').append(writer.withView(JsonViews.Internal.class).writeValueAsString(bound)) + .append('}'); + System.out.println("[PluginConfigPolymorphismTest] " + base.getSimpleName() + " " + entry.getKey() + ": " + outcome); + cases.add(quote(entry.getKey()) + ":" + outcome); + } + GOLDEN.assertJson(DIRECTORY + base.getSimpleName() + OUTCOMES_SUFFIX, "{" + String.join(",", cases) + "}"); + } + + /** {@code output} read again as {@code base} and written again: what a client that sends the output back gets. */ + private static String readBack(ObjectWriter writer, String output, Class base) { + try { + Object again = MAPPER.readValue(output, base); + return "{" + quote(CLASS) + ":" + quote(again.getClass().getName()) + "," + quote(NO_VIEW) + ":" + writer.writeValueAsString(again) + "}"; + } catch (IOException e) { + return quote(FAILS_WITH + e.getClass().getName()); + } + } + + private static String boundClass(ObjectMapper mapper, String input, Class base) { + try { + return mapper.readValue(input, base).getClass().getName(); + } catch (IOException e) { + return FAILS_WITH + e.getClass().getName(); + } + } + + private static String quote(String text) { + try { + return BARE.writeValueAsString(text); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/QueryResults.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/QueryResults.java new file mode 100644 index 0000000000..e6e28409ca --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/QueryResults.java @@ -0,0 +1,78 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.util.RawValue; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.util.JsonUtils; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * The §4.6 producer pin of docs/API_PAYLOAD_TEST_PLAN.md: what a plugin puts into {@link QueryExecutionResult} and + * what the production mapper writes for it. The server wraps the result in {@code QueryResultView} (its own pin is + * task T5.3); this one is the plugin side, where the Java values are made. + * + *

    {@link #report} gives {@code {"queryCode", "messageKey", "headers", "dataShape", "written"}}: + *

      + *
    • {@value #MESSAGE_KEY_KEY} (error results): the message key, which the mapper does not write (its arguments, + * which can hold hosts and ports, are not pinned);
    • + *
    • {@value #DATA_SHAPE_KEY}: the class of every value of {@code data} by JSON pointer and the key order of + * every object ({@link JavaValueWalker}), so a value that changes class while its JSON stays the same is seen; + *
    • + *
    • {@value #WRITTEN_KEY}: the exact text the production mapper writes for the whole result;
    • + *
    • {@value #HEADERS_KEY} (REST and GraphQL results) with the given volatile header names removed, from the + * headers and from the written text.
    • + *
    + * + *

    Limits: the walk descends maps, lists and JSON trees; any other value (arrays, JDBC objects, Google API response + * objects) is one leaf recorded by its class, and its content is pinned through the written text only. + */ +public final class QueryResults { + + public static final String QUERY_CODE_KEY = "queryCode"; + public static final String MESSAGE_KEY_KEY = "messageKey"; + public static final String HEADERS_KEY = "headers"; + public static final String DATA_SHAPE_KEY = "dataShape"; + public static final String WRITTEN_KEY = "written"; + public static final String ERROR_KEY = "error"; + /** The header every answer of the JDK server carries with the current time. */ + public static final String DATE_HEADER = "Date"; + + private QueryResults() { + } + + /** The report of one result, with {@code volatileHeaders} removed from its headers first. */ + public static Map report(QueryExecutionResult result, Set volatileHeaders) { + if (result.getHeaders() instanceof ObjectNode headers) { + volatileHeaders.forEach(headers::remove); + } + Map report = new LinkedHashMap<>(); + report.put(QUERY_CODE_KEY, result.getQueryCode()); + if (result.getLocaleMessage() != null) { + report.put(MESSAGE_KEY_KEY, result.getLocaleMessage().messageKey()); + } + if (result.getHeaders() != null) { + report.put(HEADERS_KEY, result.getHeaders()); + } + report.put(DATA_SHAPE_KEY, JavaValueWalker.shape(result.getData())); + report.put(WRITTEN_KEY, written(result)); + return report; + } + + /** The report of a result without headers. */ + public static Map report(QueryExecutionResult result) { + return report(result, Set.of()); + } + + /** The text the production mapper writes for {@code value}, embedded raw, or the error of the write. */ + public static Object written(Object value) { + try { + return new RawValue(JsonUtils.getObjectMapper().writeValueAsString(value)); + } catch (JsonProcessingException | RuntimeException e) { + return Map.of(ERROR_KEY, ConfigBinding.errorText(e)); + } + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/RecordingHttpServer.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/RecordingHttpServer.java new file mode 100644 index 0000000000..a84a2afdd3 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/RecordingHttpServer.java @@ -0,0 +1,146 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.databind.util.RawValue; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A local HTTP server that answers by path and records every request, so a plugin's real HTTP client code (WebClient, + * Elasticsearch's REST client) runs end to end without network access (docs/API_PAYLOAD_TEST_PLAN.md §4.6, §4.10; the + * same technique as the {@code npm-outbound} tests of §4.9). The answer to a path is either fixed ({@link #start}) or + * computed from the request ({@link #serve}), e.g. an echo of what the client sent. + * + *

    Limits: it binds {@code 127.0.0.1} on a free port; the JDK server adds a {@code Date} header to every answer (the + * tests drop it as volatile) and writes header values one byte per character, so a header value is sent as the + * ISO-8859-1 bytes of its characters. A path without a response answers 404 with an empty body. + */ +public final class RecordingHttpServer implements AutoCloseable { + + public static final String LOOPBACK = "127.0.0.1"; + public static final int NOT_FOUND = 404; + public static final String CONTENT_TYPE = "Content-Type"; + public static final String CONTENT_TYPE_KEY = "contentType"; + public static final String BODY_KEY = "body"; + /** {@code sendResponseHeaders}' length for a response without a body. */ + private static final int NO_BODY = -1; + private static final int ANY_PORT = 0; + private static final int BACKLOG = 0; + private static final int STOP_DELAY_SECONDS = 0; + + /** A fixed answer: status, headers (each name with its values in order) and body ({@code null}: none). */ + public record Response(int status, Map> headers, byte[] body) { + } + + /** Computes the answer to a request; the request is recorded before the handler runs. */ + @FunctionalInterface + public interface Handler { + Response answer(Request request); + } + + /** A recorded request: method, path with query, headers as the server received them, and body bytes. */ + public record Request(String method, String pathAndQuery, Map> headers, byte[] body) { + + /** The values of header {@code name}, whatever the case of its name (none: an empty list). */ + public List header(String name) { + return headers.entrySet().stream().filter(header -> name.equalsIgnoreCase(header.getKey())) + .map(Map.Entry::getValue).findFirst().orElse(List.of()); + } + + /** The body bytes decoded as UTF-8. */ + public String bodyText() { + return new String(body, StandardCharsets.UTF_8); + } + + /** + * The request as a {@code downstream-encode} report pins it: the content type it was sent with, and the body, + * which must be JSON, embedded as received ({@link RawValue}), so the report keeps its number lexemes and its + * escapes as the client wrote them. + */ + public Map jsonBodyReport() { + Map report = new LinkedHashMap<>(); + report.put(CONTENT_TYPE_KEY, header(CONTENT_TYPE)); + report.put(BODY_KEY, new RawValue(bodyText())); + return report; + } + } + + private final HttpServer server; + private final List requests = Collections.synchronizedList(new ArrayList<>()); + + private RecordingHttpServer(Map handlers) throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getByName(LOOPBACK), ANY_PORT), BACKLOG); + server.createContext("/", exchange -> answer(exchange, handlers)); + server.start(); + } + + /** Starts a server answering {@code responses} by exact path (without query). */ + public static RecordingHttpServer start(Map responses) { + Map handlers = new LinkedHashMap<>(); + responses.forEach((path, response) -> handlers.put(path, request -> response)); + return serve(handlers); + } + + /** Starts a server answering each request with the handler of its exact path (without query). */ + public static RecordingHttpServer serve(Map handlers) { + try { + return new RecordingHttpServer(handlers); + } catch (IOException e) { + throw new UncheckedIOException("cannot start the local HTTP server", e); + } + } + + /** {@code http://127.0.0.1:}. */ + public String baseUrl() { + return "http://" + LOOPBACK + ":" + server.getAddress().getPort(); + } + + public int port() { + return server.getAddress().getPort(); + } + + /** The requests received so far, in arrival order. */ + public List requests() { + synchronized (requests) { + return List.copyOf(requests); + } + } + + @Override + public void close() { + server.stop(STOP_DELAY_SECONDS); + } + + private void answer(HttpExchange exchange, Map handlers) throws IOException { + try (exchange) { + byte[] body = exchange.getRequestBody().readAllBytes(); + Map> headers = new LinkedHashMap<>(); + exchange.getRequestHeaders().forEach((name, values) -> headers.put(name, List.copyOf(values))); + String query = exchange.getRequestURI().getRawQuery(); + Request request = new Request(exchange.getRequestMethod(), + exchange.getRequestURI().getRawPath() + (query == null ? "" : "?" + query), headers, body); + requests.add(request); + Handler handler = handlers.get(exchange.getRequestURI().getPath()); + if (handler == null) { + exchange.sendResponseHeaders(NOT_FOUND, NO_BODY); + return; + } + Response response = handler.answer(request); + response.headers().forEach((name, values) -> values.forEach(value -> exchange.getResponseHeaders().add(name, value))); + exchange.sendResponseHeaders(response.status(), response.body() == null ? NO_BODY : response.body().length); + if (response.body() != null) { + exchange.getResponseBody().write(response.body()); + } + } + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/RenderValues.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/RenderValues.java new file mode 100644 index 0000000000..fb7acf32ee --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/RenderValues.java @@ -0,0 +1,44 @@ +package org.lowcoder.sdk.contract; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The request-parameter values of group {@code downstream-render} (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.5): one + * value of every JSON type as a plugin receives it, by name. Lists and maps hold a string with quotes, a null, a + * nested container and a {@link BigDecimal} with a trailing zero, so the text the production mapper ({@code toJson}) + * writes for them shows its escapes, nulls and number lexemes; the strings hold an apostrophe, double quotes, a + * backslash, non-ASCII letters and a dollar sign, the characters SQL text and regular-expression replacements treat + * specially. + */ +public final class RenderValues { + + public static final String LIST = "list"; + public static final String MAP = "map"; + + private RenderValues() { + } + + /** A fresh map of the values, in a fixed order. */ + public static Map values() { + Map values = new LinkedHashMap<>(); + values.put("int", 1); + values.put("long", 3_000_000_001L); + values.put("decimal", 1.5); + values.put("exponent", 1.0e20); + values.put("bool", true); + values.put("null", null); + values.put("string", "it's \"q\" \\ žluť"); + values.put("dollar", "cost $1"); + values.put(LIST, Arrays.asList(1, "it's \"q\"", 2.5, null, List.of(true), Map.of("k", new BigDecimal("1.50")))); + Map map = new LinkedHashMap<>(); + map.put("zeta", "it's"); + map.put("decimal", new BigDecimal("1.50")); + map.put("nested", Arrays.asList(1, null)); + values.put(MAP, map); + return values; + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/RepresentativeInput.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/RepresentativeInput.java new file mode 100644 index 0000000000..230a264fc5 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/contract/RepresentativeInput.java @@ -0,0 +1,62 @@ +package org.lowcoder.sdk.contract; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import org.lowcoder.sdk.util.JsonUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * The §4.6 representative input of docs/API_PAYLOAD_TEST_PLAN.md for the modules other than {@code lowcoder-server}: + * numbers on both sides of 2^31 and 2^63, trailing-zero decimals, exponents, booleans, nulls, nested and empty + * containers, Unicode and escapes, with keys in an order that sorting would change. + * + *

    It is the classpath resource {@value #RESOURCE} of this test-jar, a byte-identical copy of + * {@code lowcoder-server/src/test/resources/json-contract/dynamic/representative.input.json}; + * {@code RepresentativeInputContractTest} in {@code lowcoder-server} fails when the two differ. Limit: being a + * classpath resource, it has no fixture overlay ({@link GoldenJson}); the §8.2 mutation checks change the expected + * fixtures instead. + */ +public final class RepresentativeInput { + + public static final String RESOURCE = "representative.input.json"; + /** The input text, exactly as in the resource. */ + public static final String TEXT = load(); + + private RepresentativeInput() { + } + + /** The input read by the production mapper into {@code Map}, as plugins read query configs. */ + public static Map map() { + try { + return JsonUtils.getObjectMapper().readValue(TEXT, new TypeReference>() { + }); + } catch (IOException e) { + throw new UncheckedIOException("cannot read " + RESOURCE, e); + } + } + + /** The input read by the production mapper into a tree. */ + public static JsonNode node() { + try { + return JsonUtils.getObjectMapper().readTree(TEXT); + } catch (IOException e) { + throw new UncheckedIOException("cannot read " + RESOURCE, e); + } + } + + private static String load() { + try (InputStream stream = RepresentativeInput.class.getResourceAsStream(RESOURCE)) { + if (stream == null) { + throw new IllegalStateException("missing classpath resource " + RESOURCE + " next to " + RepresentativeInput.class.getName()); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("cannot read " + RESOURCE, e); + } + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/exception/BizExceptionAndExceptionUtilsTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/exception/BizExceptionAndExceptionUtilsTest.java new file mode 100644 index 0000000000..a6a92cdfcb --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/exception/BizExceptionAndExceptionUtilsTest.java @@ -0,0 +1,163 @@ +package org.lowcoder.sdk.exception; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.util.ExceptionUtils; +import org.springframework.http.HttpHeaders; + +import reactor.core.publisher.Mono; + +/** + * {@link BizException} (status and code from its error, the messages in a locale, the lazily created headers) and + * {@link ExceptionUtils} (the factories, and the wrap/propagate rule: keep an exception that already is, or is caused by, + * a {@link BaseException}; wrap anything else with its message; never swallow a fatal error). + */ +public class BizExceptionAndExceptionUtilsTest { + + private static final String KEY_WITH_ARG = "DATASOURCE_TEST_GENERIC_ERROR"; + private static final String KEY_WITHOUT_ARG = "FILE_NOT_EXIST"; + private static final String ENGLISH_WITH_ARG = "Failed to connect data source: boom."; + private static final String ENGLISH_WITHOUT_ARG = "Invalid parameter: the file does not exist."; + + @Test + public void statusAndCodeComeFromTheErrorAndTheMessageIsLocalised() { + BizException exception = new BizException(BizError.NOT_AUTHORIZED, KEY_WITH_ARG, "boom"); + + System.out.println("[BizExceptionAndExceptionUtilsTest] " + exception.getHttpStatus() + " / " + exception.getBizErrorCode() + " / " + exception.getMessage()); + assertEquals(BizError.NOT_AUTHORIZED.getHttpErrorCode(), exception.getHttpStatus()); + assertEquals(401, exception.getHttpStatus()); + assertEquals(BizError.NOT_AUTHORIZED.getBizErrorCode(), exception.getBizErrorCode()); + assertEquals(5001, exception.getBizErrorCode()); + assertEquals(ENGLISH_WITH_ARG, exception.getMessage()); + assertEquals(ENGLISH_WITH_ARG, exception.getMessage(Locale.ENGLISH)); + assertEquals(KEY_WITH_ARG, exception.getMessageKey()); + assertArrayEquals(new Object[] {"boom"}, exception.getArgs()); + assertSame(BizError.NOT_AUTHORIZED, exception.getError()); + assertNull(exception.getHeaders()); + } + + @Test + public void withoutAnErrorTheStatusIsFiveHundredTheCodeMinusOneAndTheMessageThatOfTheSuperConstructor() { + BizException exception = new BizException(null, KEY_WITHOUT_ARG); + + assertEquals(500, exception.getHttpStatus()); + assertEquals(-1, exception.getBizErrorCode()); + assertEquals(ENGLISH_WITHOUT_ARG, exception.getMessage()); + assertEquals(ENGLISH_WITHOUT_ARG, exception.getMessage(Locale.FRENCH), + "with no error the locale is ignored and the English text of the constructor stays"); + } + + @Test + public void aMessageInAnotherLocaleUsesThatBundleAndAnUnknownKeyFallsBackToTheServiceBusyText() { + BizException german = new BizException(BizError.INVALID_PARAMETER, KEY_WITHOUT_ARG); + BizException unknown = new BizException(BizError.INVALID_PARAMETER, "NO_SUCH_KEY_FOR_THE_TEST"); + + System.out.println("[BizExceptionAndExceptionUtilsTest] de: " + german.getMessage(Locale.GERMAN) + " / unknown: " + unknown.getMessage()); + assertTrue(!german.getMessage(Locale.GERMAN).equals(ENGLISH_WITHOUT_ARG), "the German bundle has its own text"); + assertEquals("Oops! Service is busy, please try again later.", unknown.getMessage()); + } + + @Test + public void headersAreCreatedOnFirstUseAndBlankNamesOrValuesAreIgnored() { + BizException exception = new BizException(BizError.INVALID_PARAMETER, KEY_WITHOUT_ARG); + + exception.addHeader("", "v"); + exception.addHeader("Name", " "); + exception.addHeader(null, "v"); + exception.addHeader("Name", null); + assertNull(exception.getHeaders(), "nothing was added"); + exception.addHeader("X-Test", "one"); + exception.addHeader("X-Test", "two"); + + assertEquals(List.of("one", "two"), exception.getHeaders().get("X-Test")); + HttpHeaders given = new HttpHeaders(); + given.add("Retry-After", "5"); + BizException withHeaders = new BizException(BizError.INVALID_PARAMETER, KEY_WITHOUT_ARG, given); + assertSame(given, withHeaders.getHeaders()); + withHeaders.addHeader("Retry-After", "6"); + assertEquals(List.of("5", "6"), given.get("Retry-After")); + } + + @Test + public void theExceptionFactoriesBuildTheRightTypesAndMonosSignalAnErrorOnSubscription() { + BizException biz = ExceptionUtils.ofException(BizError.INVALID_PARAMETER, KEY_WITH_ARG, "boom"); + PluginException plugin = ExceptionUtils.ofPluginException(PluginCommonError.QUERY_ARGUMENT_ERROR, KEY_WITH_ARG, "boom"); + + assertEquals(ENGLISH_WITH_ARG, biz.getMessage()); + assertEquals(ENGLISH_WITH_ARG, plugin.getMessage()); + assertSame(BizError.INVALID_PARAMETER, assertThrows(BizException.class, () -> ExceptionUtils.ofError(BizError.INVALID_PARAMETER, KEY_WITH_ARG, "x").block()).getError()); + assertSame(BizError.INVALID_PARAMETER, assertThrows(BizException.class, () -> ExceptionUtils.deferredError(BizError.INVALID_PARAMETER, KEY_WITH_ARG, "x").block()).getError()); + assertSame(PluginCommonError.QUERY_ARGUMENT_ERROR, + assertThrows(PluginException.class, () -> ExceptionUtils.ofPluginError(PluginCommonError.QUERY_ARGUMENT_ERROR, KEY_WITH_ARG, "x").block()).getError()); + HttpHeaders headers = new HttpHeaders(); + headers.add("X", "1"); + BizException withHeaders = assertThrows(BizException.class, () -> ExceptionUtils.ofErrorWithHeaders(BizError.INVALID_PARAMETER, KEY_WITH_ARG, headers, "x").block()); + assertSame(headers, withHeaders.getHeaders()); + } + + @Test + public void deferredErrorBuildsItsExceptionOnlyWhenSubscribedAndOfErrorBuildsItAtOnce() { + Mono deferred = ExceptionUtils.deferredError(BizError.INVALID_PARAMETER, KEY_WITH_ARG, "x"); + // two subscriptions give two different exception instances (built per subscription) + Throwable first = assertThrows(BizException.class, deferred::block); + Throwable second = assertThrows(BizException.class, deferred::block); + assertTrue(first != second); + } + + @Test + public void wrapKeepsABaseExceptionOrOneCausedByAndWrapsOthersWithTheirMessage() { + BizException biz = new BizException(BizError.NOT_AUTHORIZED, KEY_WITHOUT_ARG); + PluginException plugin = new PluginException(PluginCommonError.QUERY_ARGUMENT_ERROR, KEY_WITHOUT_ARG); + Exception causedByBiz = new RuntimeException("outer", biz); + Exception causedByDeeper = new RuntimeException("outer", new IllegalStateException("middle", biz)); + Exception plain = new IllegalStateException("boom"); + + assertSame(biz, ExceptionUtils.wrapException(BizError.INVALID_PARAMETER, KEY_WITH_ARG, biz)); + assertSame(plugin, ExceptionUtils.wrapException(PluginCommonError.QUERY_ARGUMENT_ERROR, KEY_WITH_ARG, plugin)); + assertSame(biz, ExceptionUtils.wrapException(BizError.INVALID_PARAMETER, KEY_WITH_ARG, causedByBiz), "the direct cause is unwrapped"); + BaseException wrappedDeep = ExceptionUtils.wrapException(BizError.INVALID_PARAMETER, KEY_WITH_ARG, causedByDeeper); + System.out.println("[BizExceptionAndExceptionUtilsTest] cause two levels down -> " + wrappedDeep.getClass().getSimpleName() + ": " + wrappedDeep.getMessage()); + assertTrue(wrappedDeep != biz, "only one level of cause is looked at"); + BizException wrappedPlain = assertInstanceOf(BizException.class, ExceptionUtils.wrapException(BizError.INVALID_PARAMETER, KEY_WITH_ARG, plain)); + assertEquals(ENGLISH_WITH_ARG, wrappedPlain.getMessage(), "the original message becomes the message argument"); + assertSame(BizError.INVALID_PARAMETER, wrappedPlain.getError()); + PluginException wrappedPluginPlain = assertInstanceOf(PluginException.class, + ExceptionUtils.wrapException(PluginCommonError.QUERY_ARGUMENT_ERROR, KEY_WITH_ARG, plain)); + assertEquals(ENGLISH_WITH_ARG, wrappedPluginPlain.getMessage()); + } + + @Test + public void propagateGivesAnErrorMonoWithTheSameRuleAsWrap() { + BizException biz = new BizException(BizError.NOT_AUTHORIZED, KEY_WITHOUT_ARG); + PluginException plugin = new PluginException(PluginCommonError.QUERY_ARGUMENT_ERROR, KEY_WITHOUT_ARG); + + assertSame(biz, assertThrows(BizException.class, () -> ExceptionUtils.propagateError(BizError.INVALID_PARAMETER, KEY_WITH_ARG, biz).block())); + assertSame(biz, assertThrows(BizException.class, () -> ExceptionUtils.propagateError(BizError.INVALID_PARAMETER, KEY_WITH_ARG, new RuntimeException(biz)).block())); + assertSame(plugin, assertThrows(PluginException.class, () -> ExceptionUtils.propagateError(PluginCommonError.QUERY_ARGUMENT_ERROR, KEY_WITH_ARG, plugin).block())); + BizException wrapped = assertThrows(BizException.class, () -> ExceptionUtils.propagateError(BizError.INVALID_PARAMETER, KEY_WITH_ARG, new IllegalStateException("boom")).block()); + assertEquals(ENGLISH_WITH_ARG, wrapped.getMessage()); + PluginException wrappedPlugin = assertThrows(PluginException.class, + () -> ExceptionUtils.propagateError(PluginCommonError.QUERY_ARGUMENT_ERROR, KEY_WITH_ARG, new IllegalStateException("boom")).block()); + assertEquals(ENGLISH_WITH_ARG, wrappedPlugin.getMessage()); + } + + @Test + public void aFatalErrorIsRethrownByWrapAndPropagateInsteadOfBeingWrapped() { + LinkageError fatal = new LinkageError("fatal for the test"); + + assertSame(fatal, assertThrows(LinkageError.class, () -> ExceptionUtils.wrapException(BizError.INVALID_PARAMETER, KEY_WITH_ARG, fatal))); + assertSame(fatal, assertThrows(LinkageError.class, () -> ExceptionUtils.wrapException(PluginCommonError.QUERY_ARGUMENT_ERROR, KEY_WITH_ARG, fatal))); + assertSame(fatal, assertThrows(LinkageError.class, () -> ExceptionUtils.propagateError(BizError.INVALID_PARAMETER, KEY_WITH_ARG, fatal))); + assertSame(fatal, assertThrows(LinkageError.class, () -> ExceptionUtils.propagateError(PluginCommonError.QUERY_ARGUMENT_ERROR, KEY_WITH_ARG, fatal))); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/helpers/MustacheHelperTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/helpers/MustacheHelperTest.java index 45a4128ff2..5d410faa7a 100644 --- a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/helpers/MustacheHelperTest.java +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/helpers/MustacheHelperTest.java @@ -2,8 +2,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.lowcoder.sdk.util.JsonUtils; import org.lowcoder.sdk.util.MustacheHelper; @@ -37,8 +37,8 @@ private void check(String template, List expectedTokens, Set exp @Test public void testParse() { - Assert.assertArrayEquals(new String[] {"{{token1}}"}, tokenize(" {{token1}} \n".trim()).toArray()); - Assert.assertArrayEquals(new String[] {"token1"}, tokenize(" token1 \n".trim()).toArray()); + Assertions.assertArrayEquals(new String[] {"{{token1}}"}, tokenize(" {{token1}} \n".trim()).toArray()); + Assertions.assertArrayEquals(new String[] {"token1"}, tokenize(" token1 \n".trim()).toArray()); } @Test @@ -317,9 +317,9 @@ public void testReplaceMustacheWithQuestionMarkMore() { param2 = new HashMap<>(param); sql = MustacheHelper.replaceMustacheWithQuestionMarkMore(sql, keys, param); - Assert.assertEquals("select * from user where name like ?", sql); - Assert.assertEquals(Lists.newArrayList("name"), keys); - Assert.assertEquals(buildMap(param2), param); + Assertions.assertEquals("select * from user where name like ?", sql); + Assertions.assertEquals(Lists.newArrayList("name"), keys); + Assertions.assertEquals(buildMap(param2), param); // sql = "select * from user where name like '{{name}}'"; @@ -329,9 +329,9 @@ public void testReplaceMustacheWithQuestionMarkMore() { param2 = new HashMap<>(param); sql = MustacheHelper.replaceMustacheWithQuestionMarkMore(sql, keys, param); - Assert.assertEquals("select * from user where name like ?", sql); - Assert.assertEquals(Lists.newArrayList("generateKey_0"), keys); - Assert.assertEquals(buildMap(param2, "generateKey_0", "lv"), param); + Assertions.assertEquals("select * from user where name like ?", sql); + Assertions.assertEquals(Lists.newArrayList("generateKey_0"), keys); + Assertions.assertEquals(buildMap(param2, "generateKey_0", "lv"), param); // sql = "select * from user where name like '{{name}}%'"; @@ -341,9 +341,9 @@ public void testReplaceMustacheWithQuestionMarkMore() { param2 = new HashMap<>(param); sql = MustacheHelper.replaceMustacheWithQuestionMarkMore(sql, keys, param); - Assert.assertEquals("select * from user where name like ?", sql); - Assert.assertEquals(Lists.newArrayList("generateKey_0"), keys); - Assert.assertEquals(buildMap(param2, "generateKey_0", "lv%"), param); + Assertions.assertEquals("select * from user where name like ?", sql); + Assertions.assertEquals(Lists.newArrayList("generateKey_0"), keys); + Assertions.assertEquals(buildMap(param2, "generateKey_0", "lv%"), param); // sql = """ @@ -355,10 +355,10 @@ public void testReplaceMustacheWithQuestionMarkMore() { param2 = new HashMap<>(param); sql = MustacheHelper.replaceMustacheWithQuestionMarkMore(sql, keys, param); - Assert.assertEquals(""" + Assertions.assertEquals(""" select * from user where name like "123" and name like ?""", sql); - Assert.assertEquals(Lists.newArrayList("generateKey_0"), keys); - Assert.assertEquals(buildMap(param2, "generateKey_0", "%lv%huichao%"), param); + Assertions.assertEquals(Lists.newArrayList("generateKey_0"), keys); + Assertions.assertEquals(buildMap(param2, "generateKey_0", "%lv%huichao%"), param); // sql = """ @@ -370,10 +370,10 @@ public void testReplaceMustacheWithQuestionMarkMore() { param2 = new HashMap<>(param); sql = MustacheHelper.replaceMustacheWithQuestionMarkMore(sql, keys, param); - Assert.assertEquals(""" + Assertions.assertEquals(""" select * from user where name like "123" and name like ?""", sql); - Assert.assertEquals(Lists.newArrayList("generateKey_0"), keys); - Assert.assertEquals(buildMap(param2, "generateKey_0", """ + Assertions.assertEquals(Lists.newArrayList("generateKey_0"), keys); + Assertions.assertEquals(buildMap(param2, "generateKey_0", """ a"%lv"%huichao%\""""), param); // @@ -387,9 +387,9 @@ public void testReplaceMustacheWithQuestionMarkMore() { param2 = new HashMap<>(param); sql = MustacheHelper.replaceMustacheWithQuestionMarkMore(sql, keys, param); - Assert.assertEquals("select * from user where name like ? and name like ?", sql); - Assert.assertEquals(Lists.newArrayList("name", "generateKey_0"), keys); - Assert.assertEquals(buildMap(param2, "generateKey_0", """ + Assertions.assertEquals("select * from user where name like ? and name like ?", sql); + Assertions.assertEquals(Lists.newArrayList("name", "generateKey_0"), keys); + Assertions.assertEquals(buildMap(param2, "generateKey_0", """ a"%lv"%huichao%\""""), param); // @@ -402,9 +402,9 @@ public void testReplaceMustacheWithQuestionMarkMore() { param2 = new HashMap<>(param); sql = MustacheHelper.replaceMustacheWithQuestionMarkMore(sql, keys, param); - Assert.assertEquals("select * from user where name like ? and name like ?", sql); - Assert.assertEquals(Lists.newArrayList("first", "generateKey_0"), keys); - Assert.assertEquals(buildMap(param2, "generateKey_0", """ + Assertions.assertEquals("select * from user where name like ? and name like ?", sql); + Assertions.assertEquals(Lists.newArrayList("first", "generateKey_0"), keys); + Assertions.assertEquals(buildMap(param2, "generateKey_0", """ a"%lv"%huichao%\""""), param); } @@ -437,28 +437,28 @@ public void testRenderMustacheJsonString() { @Test public void typeJudgment() { Object nullObj = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("null", Map.of())); - Assert.assertNull(nullObj); + Assertions.assertNull(nullObj); Object number = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("1", Map.of())); - Assert.assertTrue(number instanceof Integer); + Assertions.assertTrue(number instanceof Integer); Object b = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("true", Map.of())); - Assert.assertTrue(b instanceof Boolean); + Assertions.assertTrue(b instanceof Boolean); Object array = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("['abc', 1]", Map.of())); - Assert.assertTrue(array instanceof Collection); + Assertions.assertTrue(array instanceof Collection); Object array2 = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("[abc, cde]", Map.of())); - Assert.assertTrue(array2 instanceof Collection); + Assertions.assertTrue(array2 instanceof Collection); Object o = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("{'a': 1, 'b': 'xx'}", Map.of())); - Assert.assertTrue(o instanceof Map); + Assertions.assertTrue(o instanceof Map); Object o2 = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("{a:1, b:2}", Map.of())); - Assert.assertTrue(o2 instanceof Map); + Assertions.assertTrue(o2 instanceof Map); Object o3 = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("{{3}}3", Map.of("3", 3))); - Assert.assertEquals("33", o3); + Assertions.assertEquals("33", o3); Object o4 = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("{{3}}3", Map.of("3", "3"))); - Assert.assertEquals("33", o4); + Assertions.assertEquals("33", o4); Object o5 = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("{{t}}rue", Map.of("t", "t"))); - Assert.assertEquals("true", o5); + Assertions.assertEquals("true", o5); Object o6 = JsonUtils.jsonNodeToObject(MustacheHelper.renderMustacheJson("[1, {{3}}3]", Map.of("3", 3))); Object[] objects = ((Collection) o6).toArray(); - Assert.assertEquals(1, objects[0]); - Assert.assertEquals("33", objects[1]); + Assertions.assertEquals(1, objects[0]); + Assertions.assertEquals("33", objects[1]); } } diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/models/DatasourceStructureOrderingTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/models/DatasourceStructureOrderingTest.java new file mode 100644 index 0000000000..c1bc642cac --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/models/DatasourceStructureOrderingTest.java @@ -0,0 +1,162 @@ +package org.lowcoder.sdk.models; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.models.DatasourceStructure.Column; +import org.lowcoder.sdk.models.DatasourceStructure.ForeignKey; +import org.lowcoder.sdk.models.DatasourceStructure.Key; +import org.lowcoder.sdk.models.DatasourceStructure.PrimaryKey; +import org.lowcoder.sdk.models.DatasourceStructure.Table; +import org.lowcoder.sdk.models.DatasourceStructure.TableType; +import org.lowcoder.sdk.models.DatasourceStructure.Template; +import org.lowcoder.sdk.util.JsonUtils; + +/** Ordering of the keys and columns of a datasource structure, and the shape of the structure payload. */ +class DatasourceStructureOrderingTest { + + private static final List COLUMNS = List.of("id"); + /** large enough for TimSort to merge runs and check the comparator contract (it does so from 32 elements) */ + private static final int TIMSORT_MERGE_SIZE = 40; + + private static PrimaryKey pk(String name) { + return new PrimaryKey(name, COLUMNS); + } + + private static ForeignKey fk(String name) { + return new ForeignKey(name, COLUMNS, COLUMNS); + } + + private static Column column(String name) { + return new Column(name, "int", null, false); + } + + private static List names(List keys) { + List names = new ArrayList<>(); + for (Key key : keys) { + names.add(key.getType().substring(0, 2) + ":" + (key instanceof PrimaryKey p ? p.getName() : ((ForeignKey) key).getName())); + } + return names; + } + + @Test + void keysSortPrimaryKeysBeforeForeignKeys() { + List keys = new ArrayList<>(List.of(fk("f1"), pk("p1"), fk("f2"), pk("p2"))); + + Collections.sort(keys); + + assertThat(names(keys)).containsExactly("pr:p1", "pr:p2", "fo:f1", "fo:f2"); + assertThat(pk("p").compareTo(fk("f"))).isNegative(); + assertThat(fk("f").compareTo(pk("p"))).isPositive(); + System.out.println("[DatasourceStructureOrderingTest] sorted keys " + names(keys)); + } + + @Test + void primaryKeysSortByNameAndANullNamedOneSortsAfterNamedOnes() { + List keys = new ArrayList<>(List.of(pk("b"), pk(null), pk("a"))); + + Collections.sort(keys); + + assertThat(names(keys)).containsExactly("pr:a", "pr:b", "pr:null"); + assertThat(pk("a").compareTo(pk("b"))).isNegative(); + assertThat(pk("b").compareTo(pk("a"))).isPositive(); + assertThat(pk("a").compareTo(pk(null))).as("a named key is before a null-named one").isNegative(); + assertThat(pk(null).compareTo(pk("a"))).isPositive(); + System.out.println("[DatasourceStructureOrderingTest] sorted primary keys " + names(keys)); + } + + @Test + void foreignKeysCompareEqualAndKeepTheirOrder() { + List keys = new ArrayList<>(List.of(fk("z"), fk("a"), fk("m"))); + + assertThat(fk("z").compareTo(fk("a"))).isZero(); + Collections.sort(keys); + + assertThat(names(keys)).as("a stable sort keeps the insertion order of equal keys").containsExactly("fo:z", "fo:a", "fo:m"); + } + + /** + * Pins the plan section 9 row on the {@code Key.compareTo} comparator contract (D-6, fix deferred): two primary keys + * with null names compare 1 in both directions, and a fixed alternating list of 40 primary keys (null name on even + * positions) makes {@code Collections.sort} (TimSort, JDK 17) throw "Comparison method violates its general + * contract". The sort exception depends on the JDK's TimSort and is deterministic for this fixed list on JDK 17. + * A fix changes this test on purpose. + */ + @Test + void twoNullNamedPrimaryKeysCompareOneInBothDirectionsAndBreakCollectionsSort() { + PrimaryKey first = pk(null); + PrimaryKey second = pk(null); + + assertThat(first.compareTo(second)).isEqualTo(1); + assertThat(second.compareTo(first)).as("antisymmetry is violated: both directions are 1").isEqualTo(1); + + List keys = new ArrayList<>(); + for (int i = 0; i < TIMSORT_MERGE_SIZE; i++) { + keys.add(pk(i % 2 == 0 ? null : "k" + i)); + } + assertThatThrownBy(() -> Collections.sort(keys)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Comparison method violates its general contract"); + System.out.println("[DatasourceStructureOrderingTest] sorting " + TIMSORT_MERGE_SIZE + " keys with alternating null names throws IllegalArgumentException (pinned)"); + } + + @Test + void columnsSortByNameAndANullNamedOtherSortsFirst() { + List columns = new ArrayList<>(List.of(column("b"), column("c"), column("a"))); + + Collections.sort(columns); + + assertThat(columns).extracting(Column::getName).containsExactly("a", "b", "c"); + assertThat(column("a").compareTo(column(null))).as("against a null-named other the column is greater").isEqualTo(1); + } + + /** + * Pins today's behaviour (not a defect: column names come from database metadata and are never null): + * {@code Column.compareTo} dereferences its own name, so a null-named column throws a NullPointerException. + */ + @Test + void columnWithoutNameThrowsNullPointerExceptionWhenCompared() { + assertThatThrownBy(() -> column(null).compareTo(column("a"))).isInstanceOf(NullPointerException.class); + } + + @Test + void tableAppendsColumnsAndKeysAndTheStructurePayloadOmitsTemplates() { + Table table = new Table(TableType.TABLE, "public", "users", new ArrayList<>(), new ArrayList<>(), + List.of(new Template("select", "select * from users"))); + table.addColumn(column("id")); + table.addColumn(column("name")); + table.addKey(pk("users_pk")); + table.addKey(fk("users_fk")); + + assertThat(table.getColumns()).extracting(Column::getName).containsExactly("id", "name"); + assertThat(table.getKeys()).hasSize(2); + + String json = JsonUtils.toJson(new DatasourceStructure(List.of(table))); + System.out.println("[DatasourceStructureOrderingTest] structure json " + json); + assertThat(json).contains("\"type\":\"TABLE\"").contains("\"type\":\"primary key\"").contains("\"type\":\"foreign key\""); + assertThat(json).as("templates are @JsonIgnore: they must not leak into the structure payload") + .doesNotContain("templates").doesNotContain("select * from users"); + } + + @Test + void templateConstructorsKeepTheConfigurationOfTheirType() { + List properties = List.of(new Property("k", "v")); + Map map = Map.of("a", 1); + + Template withProperties = new Template("t1", "body1", properties); + Template withMap = new Template("t2", "body2", map); + Template withoutConfiguration = new Template("t3", "body3"); + + assertThat(withProperties.getConfiguration()).isSameAs(properties); + assertThat(withMap.getConfiguration()).isSameAs(map); + assertThat(withoutConfiguration.getConfiguration()).isNull(); + assertThat(withoutConfiguration.getTitle()).isEqualTo("t3"); + assertThat(withoutConfiguration.getBody()).isEqualTo("body3"); + assertThat(new DatasourceStructure().getTables()).isNull(); + assertThat(TableType.valueOf("COLLECTION")).isEqualTo(TableType.COLLECTION); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/models/JsDatasourceConnectionConfigTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/models/JsDatasourceConnectionConfigTest.java new file mode 100644 index 0000000000..137f3f490a --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/models/JsDatasourceConnectionConfigTest.java @@ -0,0 +1,333 @@ +package org.lowcoder.sdk.models; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; +import org.slf4j.LoggerFactory; + +/** + * Merge, password stripping and encryption of {@link JsDatasourceConnectionConfig}, the config of dynamic (JS) + * datasource plugins. The stored config carries the plugin {@code definition}; the update never does. + */ +class JsDatasourceConnectionConfigTest { + + private static final String PLUGIN_TYPE = "jsPlugin"; + private static final String KEY_HOST = "host"; + private static final String KEY_PASSWORD = "password"; + private static final String KEY_DYN_USER = "dynUser"; + private static final String KEY_DYN_PASSWORD = "dynPassword"; + private static final String KEY_DYN_CONFIG = "dynamicParamsConfig"; + private static final String KEY_DYN_DEF = "dynamicParamsDef"; + private static final String KEY_EXTRA = "extra"; + private static final String KEY_AUTH = "authConfig"; + private static final String STORED_PASSWORD = "stored-secret"; + private static final String NEW_PASSWORD = "new-secret"; + + private static Map param(String key, String type) { + Map param = new HashMap<>(); + param.put("key", key); + param.put("type", type); + return param; + } + + private static Map definition() { + Map dataSourceConfig = new HashMap<>(); + dataSourceConfig.put("params", List.of(param(KEY_HOST, "text"), param(KEY_PASSWORD, "password"))); + Map definition = new HashMap<>(); + definition.put("dataSourceConfig", dataSourceConfig); + return definition; + } + + private static List dynamicDef() { + return new ArrayList<>(List.of(param(KEY_DYN_USER, "text"), param(KEY_DYN_PASSWORD, "password"))); + } + + private static JsDatasourceConnectionConfig config(String type, Object definition, Map values) { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + config.setType(type); + config.setDefinition(definition); + config.putAll(values); + return config; + } + + private static Map values(Object... keyValues) { + Map map = new HashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + map.put((String) keyValues[i], keyValues[i + 1]); + } + return map; + } + + private static JsDatasourceConnectionConfig stored(Map values) { + return config(PLUGIN_TYPE, definition(), values); + } + + private static JsDatasourceConnectionConfig update(Map values) { + return config(PLUGIN_TYPE, null, values); + } + + private static JsDatasourceConnectionConfig merge(JsDatasourceConnectionConfig stored, JsDatasourceConnectionConfig update) { + return (JsDatasourceConnectionConfig) stored.mergeWithUpdatedConfig(update); + } + + @SuppressWarnings("unchecked") + private static Map dynamicConfigOf(JsDatasourceConnectionConfig config) { + return (Map) config.get(KEY_DYN_CONFIG); + } + + @Test + void mergeKeepsStoredStaticPasswordWhenUpdateHasNone() { + JsDatasourceConnectionConfig merged = merge(stored(values(KEY_HOST, "old", KEY_PASSWORD, STORED_PASSWORD)), + update(values(KEY_HOST, "new"))); + + assertThat(merged.get(KEY_PASSWORD)).isEqualTo(STORED_PASSWORD); + assertThat(merged.get(KEY_HOST)).isEqualTo("new"); + System.out.println("[JsDatasourceConnectionConfigTest] password kept on edit without password: " + merged); + } + + @Test + void mergeReplacesStaticPasswordWhenUpdateSendsOne() { + JsDatasourceConnectionConfig merged = merge(stored(values(KEY_PASSWORD, STORED_PASSWORD)), + update(values(KEY_PASSWORD, NEW_PASSWORD))); + + assertThat(merged.get(KEY_PASSWORD)).isEqualTo(NEW_PASSWORD); + System.out.println("[JsDatasourceConnectionConfigTest] new password wins over the stored one"); + } + + @Test + void mergeTakesNonPasswordStaticKeyFromUpdateAndDropsKeysOutsideDefinition() { + JsDatasourceConnectionConfig merged = merge( + stored(values(KEY_HOST, "stored-host", "stale", "stored-stale")), + update(values("outside", "ignored"))); + + assertThat(merged).containsKey(KEY_HOST); + assertThat(merged.get(KEY_HOST)).as("host absent in the update must not fall back to the stored host").isNull(); + assertThat(merged).doesNotContainKeys("stale", "outside"); + System.out.println("[JsDatasourceConnectionConfigTest] non-password key comes from the update only: " + merged); + } + + @Test + void mergeHandlesDynamicParamsDefinitionAndConfig() { + JsDatasourceConnectionConfig storedConfig = stored(values( + KEY_DYN_DEF, dynamicDef(), + KEY_DYN_CONFIG, values(KEY_DYN_USER, "old-user", KEY_DYN_PASSWORD, STORED_PASSWORD))); + List updatedDef = List.of(param("updatedDefMarker", "text")); + + // update sends no dynamic password + JsDatasourceConnectionConfig keepsOld = merge(storedConfig, update(values( + KEY_DYN_DEF, updatedDef, + KEY_DYN_CONFIG, values(KEY_DYN_USER, "new-user")))); + assertThat(dynamicConfigOf(keepsOld)).containsEntry(KEY_DYN_PASSWORD, STORED_PASSWORD) + .containsEntry(KEY_DYN_USER, "new-user"); + assertThat(keepsOld.get(KEY_DYN_DEF)).as("dynamicParamsDef comes from the update").isEqualTo(updatedDef); + + // update sends a dynamic password; a non-password dynamic key missing in the update is null, not the stored value + JsDatasourceConnectionConfig replaces = merge(storedConfig, update(values( + KEY_DYN_CONFIG, values(KEY_DYN_PASSWORD, NEW_PASSWORD)))); + assertThat(dynamicConfigOf(replaces)).containsEntry(KEY_DYN_PASSWORD, NEW_PASSWORD).containsEntry(KEY_DYN_USER, null); + + // update without any dynamicParamsConfig at all + JsDatasourceConnectionConfig noDynamicConfig = merge(storedConfig, update(values())); + assertThat(dynamicConfigOf(noDynamicConfig)).containsEntry(KEY_DYN_PASSWORD, STORED_PASSWORD); + System.out.println("[JsDatasourceConnectionConfigTest] dynamic merge: " + keepsOld + " / " + replaces + " / " + noDynamicConfig); + } + + @Test + void mergeRejectsNonJsConfig() { + DatasourceConnectionConfig other = new DatasourceConnectionConfig() { + @Override + public DatasourceConnectionConfig mergeWithUpdatedConfig(DatasourceConnectionConfig detailConfig) { + return this; + } + }; + assertThatThrownBy(() -> stored(values()).mergeWithUpdatedConfig(other)) + .isInstanceOfSatisfying(BizException.class, + e -> assertThat(e.getError()).isEqualTo(BizError.INVALID_DATASOURCE_CONFIG_TYPE)); + System.out.println("[JsDatasourceConnectionConfigTest] non-Js update rejected"); + } + + @Test + void mergeRejectsDifferentPluginType() { + JsDatasourceConnectionConfig otherType = config("otherPlugin", null, values()); + assertThatThrownBy(() -> stored(values()).mergeWithUpdatedConfig(otherType)) + .isInstanceOfSatisfying(BizException.class, + e -> assertThat(e.getError()).isEqualTo(BizError.INVALID_DATASOURCE_CONFIG_TYPE)); + System.out.println("[JsDatasourceConnectionConfigTest] update of another plugin type rejected"); + } + + static Stream extraCases() { + return Stream.of( + Arguments.of(values(KEY_EXTRA, "stored"), values(), true, "stored"), + Arguments.of(values(), values(KEY_EXTRA, "updated"), true, "updated"), + Arguments.of(values(KEY_EXTRA, "stored"), values(KEY_EXTRA, "updated"), true, "updated"), + Arguments.of(values(KEY_EXTRA, "stored"), values(KEY_EXTRA, null), true, "stored"), + Arguments.of(values(), values(), false, null)); + } + + @ParameterizedTest + @MethodSource("extraCases") + void mergeKeepsExtraOnlyWhenOneSideHasIt(Map storedValues, Map updateValues, + boolean expectPresent, String expectedValue) { + JsDatasourceConnectionConfig merged = merge(stored(storedValues), update(updateValues)); + + assertThat(merged.containsKey(KEY_EXTRA)).isEqualTo(expectPresent); + assertThat(merged.getExtra()).isEqualTo(expectedValue); + System.out.println("[JsDatasourceConnectionConfigTest] extra " + storedValues + " + " + updateValues + " -> " + merged.getExtra()); + } + + static Stream authConfigCases() { + Map storedAuth = values("type", "STORED"); + Map updatedAuth = values("type", "UPDATED"); + return Stream.of( + Arguments.of(true, true, updatedAuth), + Arguments.of(true, false, null), + Arguments.of(false, true, updatedAuth), + Arguments.of(false, false, null)); + } + + @ParameterizedTest + @MethodSource("authConfigCases") + void mergeAuthConfigTruthTable(boolean storedHasAuth, boolean updateHasAuth, Map expectedAuth) { + Map storedValues = storedHasAuth ? values(KEY_AUTH, values("type", "STORED")) : values(); + Map updateValues = updateHasAuth ? values(KEY_AUTH, values("type", "UPDATED")) : values(); + + JsDatasourceConnectionConfig merged = merge(stored(storedValues), update(updateValues)); + + assertThat(merged.containsKey(KEY_AUTH)).isEqualTo(expectedAuth != null); + assertThat(merged.get(KEY_AUTH)).isEqualTo(expectedAuth); + System.out.println("[JsDatasourceConnectionConfigTest] authConfig stored=" + storedHasAuth + " update=" + updateHasAuth + " -> " + merged.get(KEY_AUTH)); + } + + @Test + void mergeWithNullDefinitionYieldsNoStaticKeysAndLogsTheMissingDefinition() { + Logger logger = (Logger) LoggerFactory.getLogger(JsDatasourceConnectionConfig.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + JsDatasourceConnectionConfig merged = merge(config(PLUGIN_TYPE, null, values(KEY_HOST, "old", KEY_PASSWORD, STORED_PASSWORD)), + update(values(KEY_HOST, "new", KEY_PASSWORD, NEW_PASSWORD))); + + assertThat(merged).doesNotContainKeys(KEY_HOST, KEY_PASSWORD); + assertThat(appender.list).anySatisfy(event -> { + assertThat(event.getLevel()).isEqualTo(Level.ERROR); + assertThat(event.getFormattedMessage()).isEqualTo("definition is null: " + PLUGIN_TYPE); + }); + System.out.println("[JsDatasourceConnectionConfigTest] null definition: no static keys, logged: " + appender.list.get(0).getFormattedMessage()); + } finally { + logger.detachAppender(appender); + } + } + + @Test + void removePasswordsRemovesStaticAndDynamicPasswordsOnly() { + JsDatasourceConnectionConfig config = stored(values( + KEY_HOST, "h", KEY_PASSWORD, STORED_PASSWORD, + KEY_DYN_DEF, dynamicDef(), + KEY_DYN_CONFIG, values(KEY_DYN_USER, "u", KEY_DYN_PASSWORD, STORED_PASSWORD))); + + config.removePasswords(); + + assertThat(config).containsEntry(KEY_HOST, "h").doesNotContainKey(KEY_PASSWORD); + assertThat(dynamicConfigOf(config)).containsEntry(KEY_DYN_USER, "u").doesNotContainKey(KEY_DYN_PASSWORD); + System.out.println("[JsDatasourceConnectionConfigTest] after removePasswords: " + config); + } + + @Test + void removePasswordsToleratesMissingDynamicParamsConfig() { + JsDatasourceConnectionConfig config = stored(values(KEY_PASSWORD, STORED_PASSWORD, KEY_DYN_DEF, dynamicDef())); + + config.removePasswords(); + + assertThat(config).doesNotContainKeys(KEY_PASSWORD, KEY_DYN_CONFIG); + System.out.println("[JsDatasourceConnectionConfigTest] removePasswords without dynamicParamsConfig: " + config); + } + + @Test + void encryptAndDecryptApplyFunctionOnceToStringPasswordValuesOnly() { + AtomicInteger calls = new AtomicInteger(); + JsDatasourceConnectionConfig config = stored(values( + KEY_HOST, "plain-host", KEY_PASSWORD, "pw", + KEY_DYN_DEF, dynamicDef(), + KEY_DYN_CONFIG, values(KEY_DYN_USER, "plain-user", KEY_DYN_PASSWORD, "dynpw"))); + + DatasourceConnectionConfig encrypted = config.doEncrypt(s -> { + calls.incrementAndGet(); + return "enc(" + s + ")"; + }); + + assertThat(encrypted).isSameAs(config); + assertThat(calls).as("one call per password value, static + dynamic").hasValue(2); + assertThat(config).containsEntry(KEY_PASSWORD, "enc(pw)").containsEntry(KEY_HOST, "plain-host"); + assertThat(dynamicConfigOf(config)).containsEntry(KEY_DYN_PASSWORD, "enc(dynpw)").containsEntry(KEY_DYN_USER, "plain-user"); + + DatasourceConnectionConfig decrypted = config.doDecrypt(s -> s.substring("enc(".length(), s.length() - 1)); + assertThat(decrypted).isSameAs(config); + assertThat(config).containsEntry(KEY_PASSWORD, "pw"); + assertThat(dynamicConfigOf(config)).containsEntry(KEY_DYN_PASSWORD, "dynpw"); + System.out.println("[JsDatasourceConnectionConfigTest] encrypt/decrypt roundtrip: " + config); + } + + @Test + void encryptSkipsNonStringPasswordValues() { + AtomicInteger calls = new AtomicInteger(); + JsDatasourceConnectionConfig config = stored(values(KEY_PASSWORD, 12345)); + + config.doEncrypt(s -> { + calls.incrementAndGet(); + return "enc(" + s + ")"; + }); + + assertThat(calls).hasValue(0); + assertThat(config.get(KEY_PASSWORD)).isEqualTo(12345); + System.out.println("[JsDatasourceConnectionConfigTest] non-String password value left alone"); + } + + static Stream oauthInheritCases() { + return Stream.of( + Arguments.of("no authConfig", null, false, null), + Arguments.of("inherit from login", values("type", RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN.name(), "authId", "auth-1"), true, "auth-1"), + Arguments.of("other type", values("type", RestApiAuthType.BASIC_AUTH.name(), "authId", "auth-2"), false, null)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("oauthInheritCases") + void isOauth2InheritFromLoginAndAuthId(String label, Map authConfig, boolean expectedInherit, String expectedAuthId) { + JsDatasourceConnectionConfig config = authConfig == null ? stored(values()) : stored(values(KEY_AUTH, new HashMap<>(authConfig))); + + assertThat(config.isOauth2InheritFromLogin()).isEqualTo(expectedInherit); + assertThat(config.getAuthId()).isEqualTo(expectedAuthId); + System.out.println("[JsDatasourceConnectionConfigTest] " + label + " -> inherit=" + expectedInherit + ", authId=" + expectedAuthId); + } + + /** + * Pins the plan section 9 row "JsDatasourceConnectionConfig.isOauth2InheritFromLogin ... throws a NullPointerException + * when the stored authConfig map has no type key" (D-6, fix deferred): the authConfig is user-supplied JSON and + * line 212 does {@code get("type").equals(...)}. A fix of that row changes this test on purpose. + */ + @Test + void isOauth2InheritFromLoginAndGetAuthIdThrowWhenAuthConfigHasNoType() { + JsDatasourceConnectionConfig config = stored(values(KEY_AUTH, new HashMap<>(values("authId", "auth-3")))); + + assertThatThrownBy(config::isOauth2InheritFromLogin).isInstanceOf(NullPointerException.class); + assertThatThrownBy(config::getAuthId).isInstanceOf(NullPointerException.class); + System.out.println("[JsDatasourceConnectionConfigTest] authConfig without type -> NullPointerException (plan section 9 row, pinned)"); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/models/RestBodyFormFileDataTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/models/RestBodyFormFileDataTest.java new file mode 100644 index 0000000000..60a93afe18 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/models/RestBodyFormFileDataTest.java @@ -0,0 +1,44 @@ +package org.lowcoder.sdk.models; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.plugin.restapi.MultipartFormData; + +/** {@link RestBodyFormFileData}: the three ways to build a multipart entry and what each says about its type. */ +public class RestBodyFormFileDataTest { + + @Test + public void theTextConstructorsCarryNoFileDataAndTheFileConstructorMarksTheEntryAsAFile() { + RestBodyFormFileData plain = new RestBodyFormFileData("k", "v"); + RestBodyFormFileData typed = new RestBodyFormFileData("k", "v", "FILE"); + MultipartFormData part = new MultipartFormData(); + List parts = List.of(part); + RestBodyFormFileData file = new RestBodyFormFileData("k", parts); + + assertNull(plain.getFileData()); + assertNull(plain.getType()); + assertFalse(plain.isMultipartFileType()); + assertNull(typed.getFileData()); + assertTrue(typed.isMultipartFileType()); + assertEquals("v", typed.getValue()); + assertSame(parts, file.getFileData()); + assertEquals("k", file.getKey()); + assertNull(file.getValue()); + assertEquals("FILE", file.getType()); + assertTrue(file.isMultipartFileType()); + } + + @Test + public void theFileTypeIsRecognisedWhateverItsCase() { + assertTrue(new Property("k", "v", "file").isMultipartFileType()); + assertTrue(new Property("k", "v", "File").isMultipartFileType()); + assertFalse(new Property("k", "v", "TEXT").isMultipartFileType()); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/DatasourceConfigMergeAndCryptoTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/DatasourceConfigMergeAndCryptoTest.java new file mode 100644 index 0000000000..a367a35e79 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/DatasourceConfigMergeAndCryptoTest.java @@ -0,0 +1,445 @@ +package org.lowcoder.sdk.plugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.sdk.auth.AbstractAuthConfig; +import org.lowcoder.sdk.auth.EmailAuthConfig; +import org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig; +import org.lowcoder.sdk.auth.constants.AuthTypeConstants; +import org.lowcoder.sdk.auth.constants.Oauth2Constants; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.plugin.common.ssl.DisableVerifySslConfig; +import org.lowcoder.sdk.plugin.common.ssl.SslCertVerificationType; +import org.lowcoder.sdk.plugin.common.ssl.SslConfig; +import org.lowcoder.sdk.plugin.common.ssl.SslHelper; +import org.lowcoder.sdk.plugin.common.ssl.VerifySelfSignedCertSslConfig; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.AuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.BasicAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.OAuthInheritAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; + +/** + * Merge and encryption of the REST API and GraphQL datasource configs and of the auth / ssl configs they hold, plus + * the simple OAuth2 auth config and {@link SslHelper}. Secrets must survive an edit that does not resend them, and + * every secret must pass through the encrypt function exactly once. + */ +class DatasourceConfigMergeAndCryptoTest { + + private static final String STORED_PASSWORD = "stored-pw"; + private static final String NEW_PASSWORD = "new-pw"; + private static final String STORED_CERT = "stored-cert"; + private static final String NEW_CERT = "new-cert"; + private static final String CLIENT_ID = "client-1"; + private static final String OLD_SECRET = "old-secret"; + private static final String NEW_SECRET = "new-secret"; + private static final Function ENCRYPT = s -> s + "|enc"; + private static final Function DECRYPT = s -> s.substring(0, s.length() - "|enc".length()); + + /** + * Self-signed test certificate (CN=lowcoder-test-cert, RSA 2048, valid until 2126), generated once offline with + * {@code openssl req -x509 -newkey rsa:2048 -nodes -days 36500 -subj /CN=lowcoder-test-cert}; the private key was discarded. + */ + private static final String TEST_CERT_PEM = "" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIDHTCCAgWgAwIBAgIUD0FPOCIXricK4D5BHhVnkjmXZxYwDQYJKoZIhvcNAQEL\n" + + "BQAwHTEbMBkGA1UEAwwSbG93Y29kZXItdGVzdC1jZXJ0MCAXDTI2MTAwNDE4MzY1\n" + + "OVoYDzIxMjYwOTEwMTgzNjU5WjAdMRswGQYDVQQDDBJsb3djb2Rlci10ZXN0LWNl\n" + + "cnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCgMqiAYniA/wkuwMzs\n" + + "RzfvqR4yCCgu5eX0RuWy9hPHKxml6NubsaKL80e5J21ggmtQD7RI+3M48aDCGP7A\n" + + "EZJnfKjba+4UKaieQgrSdw7Sy58rljnVfKfsfDFdVmCOXkhjSIusohbDCiSdxG3b\n" + + "FQpd+hjvGyCMPgwp3qZn4u7QU876fhSgSRl9Uc6TsR8P7K73bKJ+4vzD7+TB5abi\n" + + "i20Gc0cBE7SW26HJE3BKQaSbZ+IMdf3wFCPyKyKPRYaTbyZvsIGAbty6tFIggpDf\n" + + "Wjp1N3+bznSpd6t/ck0pqBFBQE/otu7cWD14J9yzD71GiUyWo7mfTCPe0rfoIxj+\n" + + "9XhBAgMBAAGjUzBRMB0GA1UdDgQWBBR7z/sKuaWqy14Xf0cAO0+vO26yIjAfBgNV\n" + + "HSMEGDAWgBR7z/sKuaWqy14Xf0cAO0+vO26yIjAPBgNVHRMBAf8EBTADAQH/MA0G\n" + + "CSqGSIb3DQEBCwUAA4IBAQBJhDE8fVd1mrS0OGCaiVCoIayuB7RHzhbobWj9RzOk\n" + + "M/LcndxsyZB1NAWgQXHDqy2SUx8IMVexBdzjy5RHT9Q/AMOZWJYvHTdOngrCD0JZ\n" + + "hzvLcYCozc49n8fPQzC84D50YY/oRM9i+yfKH0HzpNOEU0KZ5gIXh6EPAvc3pXH8\n" + + "5jygly5r/YVdb5zmvAXG22+p25iZDQG7wnww9Swhei5vujWdDE4tYVH4g1xwPOXb\n" + + "HOk33q6QugBDCmDO3iUyPnCC5Cita3dC6HRbMcwMSnsRJZ0o/dV8QS1WXYutOOYY\n" + + "k3q0IUvVGf3MQ3uRtsrtwUD3Xq6/19wl1xIbSzSk+kQU\n" + + "-----END CERTIFICATE-----\n" + ; + private static final String TEST_CERT_SUBJECT = "CN=lowcoder-test-cert"; + + private static BasicAuthConfig basic(String username, String password) { + return BasicAuthConfig.builder().username(username).password(password).type(RestApiAuthType.BASIC_AUTH).build(); + } + + private static OAuthInheritAuthConfig inherit(String authId) { + return OAuthInheritAuthConfig.builder().authId(authId).type(RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN).build(); + } + + private static VerifySelfSignedCertSslConfig selfSigned(String cert) { + return VerifySelfSignedCertSslConfig.builder().sslCertVerificationType(SslCertVerificationType.VERIFY_SELF_SIGNED_CERT) + .selfSignedCert(cert).build(); + } + + private static RestApiDatasourceConfig rest(AuthConfig auth, SslConfig ssl) { + return RestApiDatasourceConfig.builder().authConfig(auth).sslConfig(ssl).build(); + } + + private static GraphQLDatasourceConfig graphql(AuthConfig auth) { + return GraphQLDatasourceConfig.builder().authConfig(auth).build(); + } + + private static Function counting(AtomicInteger calls) { + return s -> { + calls.incrementAndGet(); + return ENCRYPT.apply(s); + }; + } + + // ---- REST / GraphQL merge ---- + + @Test + void restMergeRejectsOtherConfigClass() { + assertThatThrownBy(() -> rest(null, null).mergeWithUpdatedConfig(graphql(null))) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getError()).isEqualTo(BizError.INVALID_DATASOURCE_CONFIG_TYPE); + assertThat(e.getArgs()).containsExactly("GraphQLDatasourceConfig"); + }); + System.out.println("[DatasourceConfigMergeAndCryptoTest] rest merge rejects a GraphQL update"); + } + + @Test + void graphqlMergeRejectsOtherConfigClass() { + assertThatThrownBy(() -> graphql(null).mergeWithUpdatedConfig(rest(null, null))) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getError()).isEqualTo(BizError.INVALID_DATASOURCE_CONFIG_TYPE); + assertThat(e.getArgs()).containsExactly("RestApiDatasourceConfig"); + }); + System.out.println("[DatasourceConfigMergeAndCryptoTest] graphql merge rejects a REST update"); + } + + @Test + void restMergeMergesStoredAuthAndSslIntoUpdate() { + RestApiDatasourceConfig update = rest(basic("user2", null), selfSigned(null)); + + DatasourceConnectionConfig merged = rest(basic("user1", STORED_PASSWORD), selfSigned(STORED_CERT)).mergeWithUpdatedConfig(update); + + assertThat(merged).isSameAs(update); + BasicAuthConfig auth = (BasicAuthConfig) update.getAuthConfig(); + assertThat(auth.getUsername()).isEqualTo("user2"); + assertThat(auth.getPassword()).as("password not resent: stored one kept").isEqualTo(STORED_PASSWORD); + VerifySelfSignedCertSslConfig ssl = (VerifySelfSignedCertSslConfig) update.getSslConfig(); + assertThat(ssl.getSelfSignedCert()).as("cert not resent: stored one kept").isEqualTo(STORED_CERT); + System.out.println("[DatasourceConfigMergeAndCryptoTest] rest merge kept stored password and cert"); + } + + @Test + void restMergeLeavesUpdateUntouchedWhenStoredHasNoAuthOrSsl() { + BasicAuthConfig updateAuth = basic("user", NEW_PASSWORD); + VerifySelfSignedCertSslConfig updateSsl = selfSigned(NEW_CERT); + RestApiDatasourceConfig update = rest(updateAuth, updateSsl); + + rest(null, null).mergeWithUpdatedConfig(update); + + assertThat(update.getAuthConfig()).isSameAs(updateAuth); + assertThat(update.getSslConfig()).isSameAs(updateSsl); + System.out.println("[DatasourceConfigMergeAndCryptoTest] rest merge without stored auth/ssl keeps the update's objects"); + } + + @Test + void graphqlMergeMergesStoredAuth() { + GraphQLDatasourceConfig update = graphql(basic("user2", null)); + + DatasourceConnectionConfig merged = graphql(basic("user1", STORED_PASSWORD)).mergeWithUpdatedConfig(update); + + assertThat(merged).isSameAs(update); + assertThat(((BasicAuthConfig) update.getAuthConfig()).getPassword()).isEqualTo(STORED_PASSWORD); + + BasicAuthConfig updateAuth = basic("user", NEW_PASSWORD); + GraphQLDatasourceConfig noStored = graphql(updateAuth); + graphql(null).mergeWithUpdatedConfig(noStored); + assertThat(noStored.getAuthConfig()).isSameAs(updateAuth); + System.out.println("[DatasourceConfigMergeAndCryptoTest] graphql merge kept stored password"); + } + + // ---- REST / GraphQL encryption ---- + + @Test + void restEncryptAndDecryptReachAuthAndSslExactlyOnce() { + AtomicInteger calls = new AtomicInteger(); + BasicAuthConfig auth = basic("user", "pw"); + VerifySelfSignedCertSslConfig ssl = selfSigned("cert"); + RestApiDatasourceConfig config = rest(auth, ssl); + + DatasourceConnectionConfig encrypted = config.doEncrypt(counting(calls)); + + assertThat(encrypted).isSameAs(config); + assertThat(calls).as("auth password + ssl cert, once each").hasValue(2); + assertThat(auth.getPassword()).isEqualTo("pw|enc"); + assertThat(ssl.getSelfSignedCert()).isEqualTo("cert|enc"); + + assertThat(config.doDecrypt(DECRYPT)).isSameAs(config); + assertThat(auth.getPassword()).isEqualTo("pw"); + assertThat(ssl.getSelfSignedCert()).isEqualTo("cert"); + System.out.println("[DatasourceConfigMergeAndCryptoTest] rest encrypt/decrypt reached auth and ssl once"); + } + + @Test + void restEncryptAndDecryptDoNothingWithoutAuthAndSsl() { + AtomicInteger calls = new AtomicInteger(); + RestApiDatasourceConfig config = rest(null, null); + + assertThat(config.doEncrypt(counting(calls))).isSameAs(config); + assertThat(config.doDecrypt(counting(calls))).isSameAs(config); + + assertThat(calls).hasValue(0); + System.out.println("[DatasourceConfigMergeAndCryptoTest] rest crypto with null auth/ssl is a no-op"); + } + + @Test + void graphqlEncryptAndDecryptReachAuthOnce() { + AtomicInteger calls = new AtomicInteger(); + BasicAuthConfig auth = basic("user", "pw"); + GraphQLDatasourceConfig config = graphql(auth); + + assertThat(config.doEncrypt(counting(calls))).isSameAs(config); + assertThat(calls).hasValue(1); + assertThat(auth.getPassword()).isEqualTo("pw|enc"); + assertThat(config.doDecrypt(DECRYPT)).isSameAs(config); + assertThat(auth.getPassword()).isEqualTo("pw"); + + GraphQLDatasourceConfig noAuth = graphql(null); + assertThat(noAuth.doEncrypt(counting(calls))).isSameAs(noAuth); + assertThat(noAuth.doDecrypt(counting(calls))).isSameAs(noAuth); + assertThat(calls).as("no auth: function never called").hasValue(1); + System.out.println("[DatasourceConfigMergeAndCryptoTest] graphql encrypt/decrypt reached auth once"); + } + + @Test + void fromReturnsSameInstanceOrNull() { + RestApiDatasourceConfig rest = rest(null, null); + GraphQLDatasourceConfig graphql = graphql(null); + + assertThat(RestApiDatasourceConfig.from(rest)).isSameAs(rest); + assertThat(RestApiDatasourceConfig.from(graphql)).isNull(); + assertThat(GraphQLDatasourceConfig.from(graphql)).isSameAs(graphql); + assertThat(GraphQLDatasourceConfig.from(rest)).isNull(); + System.out.println("[DatasourceConfigMergeAndCryptoTest] from() casts or returns null"); + } + + // ---- BasicAuthConfig ---- + + static Stream basicMergeCases() { + Supplier noPassword = () -> basic("user2", null); + Supplier newPassword = () -> basic("user2", NEW_PASSWORD); + return Stream.of( + Arguments.of("update without password keeps stored", noPassword, "user2", STORED_PASSWORD), + Arguments.of("update with password replaces stored", newPassword, "user2", NEW_PASSWORD)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("basicMergeCases") + void basicAuthMergeKeepsOldPasswordOnlyWhenUpdateHasNone(String label, Supplier update, String expectedUser, String expectedPassword) { + AuthConfig merged = basic("user1", STORED_PASSWORD).mergeWithUpdatedConfig(update.get()); + + BasicAuthConfig basic = (BasicAuthConfig) merged; + assertThat(basic.getUsername()).isEqualTo(expectedUser); + assertThat(basic.getPassword()).isEqualTo(expectedPassword); + assertThat(basic.getType()).isEqualTo(RestApiAuthType.BASIC_AUTH); + System.out.println("[DatasourceConfigMergeAndCryptoTest] basic merge: " + label); + } + + @Test + void basicAuthMergeReturnsUpdatedConfigWhenTypeChangedOrCleared() { + OAuthInheritAuthConfig other = inherit("auth-1"); + + assertThat(basic("user1", STORED_PASSWORD).mergeWithUpdatedConfig(other)).isSameAs(other); + assertThat(basic("user1", STORED_PASSWORD).mergeWithUpdatedConfig(null)).isNull(); + System.out.println("[DatasourceConfigMergeAndCryptoTest] basic merge with another auth type returns the update as is"); + } + + @Test + void basicAuthEncryptAndDecryptTransformThePassword() { + BasicAuthConfig auth = basic("user", "pw"); + + auth.doEncrypt(ENCRYPT); + assertThat(auth.getPassword()).isEqualTo("pw|enc"); + auth.doDecrypt(DECRYPT); + assertThat(auth.getPassword()).isEqualTo("pw"); + assertThat(auth.getUsername()).isEqualTo("user"); + System.out.println("[DatasourceConfigMergeAndCryptoTest] basic auth password encrypt/decrypt roundtrip"); + } + + // ---- OAuthInheritAuthConfig ---- + + static Stream inheritMergeCases() { + OAuthInheritAuthConfig sameTypeUpdate = inherit("auth-new"); + BasicAuthConfig otherType = basic("u", "p"); + return Stream.of( + Arguments.of("same type: update's authId, new instance", sameTypeUpdate, false, "auth-new"), + Arguments.of("other type: update returned as is", otherType, true, null), + Arguments.of("null update: null", null, true, null)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("inheritMergeCases") + void oauthInheritMerge(String label, AuthConfig update, boolean returnedAsIs, String expectedAuthId) { + AuthConfig merged = inherit("auth-old").mergeWithUpdatedConfig(update); + + if (returnedAsIs) { + assertThat(merged).isSameAs(update); + } else { + assertThat(merged).isNotSameAs(update).isInstanceOf(OAuthInheritAuthConfig.class); + assertThat(((OAuthInheritAuthConfig) merged).getAuthId()).isEqualTo(expectedAuthId); + assertThat(merged.getType()).isEqualTo(RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN); + } + System.out.println("[DatasourceConfigMergeAndCryptoTest] oauth inherit merge: " + label); + } + + // ---- VerifySelfSignedCertSslConfig ---- + + static Stream selfSignedMergeCases() { + DisableVerifySslConfig disabled = new DisableVerifySslConfig(SslCertVerificationType.DISABLED); + return Stream.of( + Arguments.of("update without cert keeps stored cert", selfSigned(null), false, STORED_CERT), + Arguments.of("update with cert replaces stored cert", selfSigned(NEW_CERT), false, NEW_CERT), + Arguments.of("update of another ssl type returned as is", disabled, true, null), + Arguments.of("null update returned as is", null, true, null)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("selfSignedMergeCases") + void selfSignedSslMerge(String label, SslConfig update, boolean returnedAsIs, String expectedCert) { + SslConfig merged = selfSigned(STORED_CERT).mergeWithUpdatedConfig(update); + + if (returnedAsIs) { + assertThat(merged).isSameAs(update); + } else { + assertThat(merged).isInstanceOf(VerifySelfSignedCertSslConfig.class); + assertThat(((VerifySelfSignedCertSslConfig) merged).getSelfSignedCert()).isEqualTo(expectedCert); + assertThat(merged.getSslCertVerificationType()).isEqualTo(SslCertVerificationType.VERIFY_SELF_SIGNED_CERT); + } + System.out.println("[DatasourceConfigMergeAndCryptoTest] self-signed ssl merge: " + label); + } + + @Test + void selfSignedSslEncryptAndDecryptTransformTheCert() { + VerifySelfSignedCertSslConfig ssl = selfSigned("cert"); + + ssl.doEncrypt(ENCRYPT); + assertThat(ssl.getSelfSignedCert()).isEqualTo("cert|enc"); + ssl.doDecrypt(DECRYPT); + assertThat(ssl.getSelfSignedCert()).isEqualTo("cert"); + System.out.println("[DatasourceConfigMergeAndCryptoTest] self-signed cert encrypt/decrypt roundtrip"); + } + + // ---- Oauth2SimpleAuthConfig ---- + + private static Oauth2SimpleAuthConfig simple(String authType, String clientSecret) { + return Oauth2SimpleAuthConfig.builder().authType(authType).clientId(CLIENT_ID).clientSecret(clientSecret).build(); + } + + static Stream authorizeUrlCases() { + return Stream.of( + Arguments.of(AuthTypeConstants.GOOGLE, Oauth2Constants.GOOGLE_AUTHORIZE_URL), + Arguments.of(AuthTypeConstants.GITHUB, Oauth2Constants.GITHUB_AUTHORIZE_URL), + Arguments.of(AuthTypeConstants.ORY, Oauth2Constants.ORY_AUTHORIZE_URL), + Arguments.of(AuthTypeConstants.KEYCLOAK, Oauth2Constants.KEYCLOAK_AUTHORIZE_URL), + Arguments.of(AuthTypeConstants.GENERIC, Oauth2Constants.GENERIC_AUTHORIZE_URL)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("authorizeUrlCases") + void oauth2SimpleAuthorizeUrlSubstitutesOnlyTheClientId(String authType, String template) { + String url = simple(authType, OLD_SECRET).getAuthorizeUrl(); + + assertThat(url).isEqualTo(template.replace(Oauth2Constants.CLIENT_ID_PLACEHOLDER, CLIENT_ID)); + assertThat(url).contains("client_id=" + CLIENT_ID).doesNotContain(Oauth2Constants.CLIENT_ID_PLACEHOLDER); + assertThat(url).as("redirect url and state are rendered by the front end").contains(Oauth2Constants.REDIRECT_URL_PLACEHOLDER) + .contains(Oauth2Constants.STATE_PLACEHOLDER); + System.out.println("[DatasourceConfigMergeAndCryptoTest] authorize url " + authType + ": " + url); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {AuthTypeConstants.FORM, "UNKNOWN"}) + void oauth2SimpleAuthorizeUrlIsNullForOtherAuthTypes(String authType) { + assertThat(simple(authType, OLD_SECRET).getAuthorizeUrl()).isNull(); + System.out.println("[DatasourceConfigMergeAndCryptoTest] no authorize url for " + authType); + } + + @ParameterizedTest(name = "[{index}] blank secret: \"{0}\"") + @NullAndEmptySource + @ValueSource(strings = {" "}) + void oauth2SimpleMergeKeepsOldClientSecretWhenBlank(String blankSecret) { + Oauth2SimpleAuthConfig updated = simple(AuthTypeConstants.GITHUB, blankSecret); + + updated.merge(simple(AuthTypeConstants.GITHUB, OLD_SECRET)); + + assertThat(updated.getClientSecret()).isEqualTo(OLD_SECRET); + System.out.println("[DatasourceConfigMergeAndCryptoTest] blank client secret replaced by the old one"); + } + + @Test + void oauth2SimpleMergeKeepsNewSecretAndIgnoresOldConfigOfAnotherClass() { + Oauth2SimpleAuthConfig withSecret = simple(AuthTypeConstants.GITHUB, NEW_SECRET); + withSecret.merge(simple(AuthTypeConstants.GITHUB, OLD_SECRET)); + assertThat(withSecret.getClientSecret()).isEqualTo(NEW_SECRET); + + Oauth2SimpleAuthConfig blank = simple(AuthTypeConstants.GITHUB, null); + AbstractAuthConfig other = EmailAuthConfig.builder().id("email").build(); + blank.merge(other); + assertThat(blank.getClientSecret()).isNull(); + System.out.println("[DatasourceConfigMergeAndCryptoTest] new client secret kept; other config class ignored"); + } + + @Test + void oauth2SimpleEncryptAndDecryptTransformTheClientSecret() { + Oauth2SimpleAuthConfig config = simple(AuthTypeConstants.GITHUB, "secret"); + + config.doEncrypt(ENCRYPT); + assertThat(config.getClientSecret()).isEqualTo("secret|enc"); + config.doDecrypt(DECRYPT); + assertThat(config.getClientSecret()).isEqualTo("secret"); + System.out.println("[DatasourceConfigMergeAndCryptoTest] client secret encrypt/decrypt roundtrip"); + } + + // ---- SslHelper ---- + + @ParameterizedTest(name = "[{index}] blank certificate: \"{0}\"") + @NullAndEmptySource + @ValueSource(strings = {" ", "\n"}) + void parseCertificateRejectsBlankInput(String blank) { + assertThatThrownBy(() -> SslHelper.parseCertificate(blank)) + .isInstanceOfSatisfying(BizException.class, e -> assertThat(e.getError()).isEqualTo(BizError.CERTIFICATE_IS_EMPTY)); + System.out.println("[DatasourceConfigMergeAndCryptoTest] blank certificate rejected"); + } + + @Test + void parseCertificateAcceptsPemWithAndWithoutMarkerLines() throws CertificateException { + X509Certificate withMarkers = SslHelper.parseCertificate(TEST_CERT_PEM); + String bare = TEST_CERT_PEM.replace("-----BEGIN CERTIFICATE-----", "").replace("-----END CERTIFICATE-----", ""); + X509Certificate withoutMarkers = SslHelper.parseCertificate(bare); + + assertThat(withMarkers.getSubjectX500Principal().getName()).isEqualTo(TEST_CERT_SUBJECT); + assertThat(withoutMarkers).isEqualTo(withMarkers); + System.out.println("[DatasourceConfigMergeAndCryptoTest] parsed " + withMarkers.getSubjectX500Principal()); + } + + @Test + void parseCertificateRejectsGarbage() { + assertThatThrownBy(() -> SslHelper.parseCertificate("bm90IGEgY2VydGlmaWNhdGU=")) + .isInstanceOf(CertificateException.class); + System.out.println("[DatasourceConfigMergeAndCryptoTest] base64 garbage rejected with CertificateException"); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/SdkConfigBindingContractTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/SdkConfigBindingContractTest.java new file mode 100644 index 0000000000..533d6b597e --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/SdkConfigBindingContractTest.java @@ -0,0 +1,56 @@ +package org.lowcoder.sdk.plugin; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; + +import java.util.Map; + +/** + * The {@code lowcoder-sdk} rows of group {@code config-binding} (docs/API_PAYLOAD_TEST_PLAN.md §4.7, task T8.1): the + * datasource configs the SDK declares, bound by their own {@code buildFrom} ({@link ConfigBinding}). The forms of + * each input fixture cover the shapes clients send: canonical names, scalars as strings, getter-derived names + * ({@code readonly} for the creator's {@code isReadonly}), and for the HTTP configs every {@code AuthConfig} and + * {@code SslConfig} type id, a missing id ({@code defaultImpl}) and an unknown one. + * + *

    {@code DatasourceConnector.resolveConfig}, the default the SQL connectors use, is claimed by the plugin tests that + * call it through their real connectors ({@code MysqlConfigBindingContractTest} and the Mssql, Postgres, Oracle and + * Snowflake ones). + */ +public class SdkConfigBindingContractTest { + + static final String BUILD_FROM = "buildFrom"; + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites({ + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/mysql/MysqlDatasourceConfig.java#MysqlDatasourceConfig.buildFrom#toJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/mysql/MysqlDatasourceConfig.java#MysqlDatasourceConfig.buildFrom#fromJson#1"}) + @Test + public void mysqlDatasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/MysqlDatasourceConfig.input.json", "config-binding/MysqlDatasourceConfig.json", + Map.of(BUILD_FROM, MysqlDatasourceConfig::buildFrom)); + } + + @BoundarySites({ + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/RestApiDatasourceConfig.java#RestApiDatasourceConfig.buildFrom#toJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/RestApiDatasourceConfig.java#RestApiDatasourceConfig.buildFrom#fromJson#1"}) + @Test + public void restApiDatasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/RestApiDatasourceConfig.input.json", "config-binding/RestApiDatasourceConfig.json", + Map.of(BUILD_FROM, RestApiDatasourceConfig::buildFrom)); + } + + @BoundarySites({ + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/graphql/GraphQLDatasourceConfig.java#GraphQLDatasourceConfig.buildFrom#toJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/graphql/GraphQLDatasourceConfig.java#GraphQLDatasourceConfig.buildFrom#fromJson#1"}) + @Test + public void graphQLDatasourceConfig() { + ConfigBinding.assertBinding(GOLDEN, "config-binding/GraphQLDatasourceConfig.input.json", "config-binding/GraphQLDatasourceConfig.json", + Map.of(BUILD_FROM, GraphQLDatasourceConfig::buildFrom)); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/BlockingDatasourceConnectorTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/BlockingDatasourceConnectorTest.java new file mode 100644 index 0000000000..024dc315fa --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/BlockingDatasourceConnectorTest.java @@ -0,0 +1,48 @@ +package org.lowcoder.sdk.plugin.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.plugin.common.ConnectorTestTypes.TestBlockingConnector; +import org.lowcoder.sdk.plugin.common.ConnectorTestTypes.TestConfig; + +/** {@link BlockingDatasourceConnector}: the blocking methods of a plugin run on the shared plugin scheduler, never on the caller's thread. */ +public class BlockingDatasourceConnectorTest { + + private static final String SCHEDULER_THREAD_PREFIX = "plugin-executor"; + + private final TestBlockingConnector connector = new TestBlockingConnector(); + + @Test + public void createTestAndDestroyRunOnThePluginSchedulerWithTheirArguments() { + String connection = connector.createConnection(new TestConfig("db")).block(); + String createdOn = connector.threads.get("create"); + DatasourceTestResult result = connector.testConnection(new TestConfig("db")).block(); + connector.destroyConnection(connection).block(); + + System.out.println("[BlockingDatasourceConnectorTest] threads " + connector.threads); + assertEquals("blocking:db", connection); + assertTrue(result.isSuccess()); + assertEquals("blocking:db", connector.given.get("destroy")); + assertEquals("blocking:db", connector.given.get("test"), "the test creates its own connection with the same config"); + assertTrue(createdOn.startsWith(SCHEDULER_THREAD_PREFIX), "create ran on " + createdOn); + for (String name : new String[] {"create", "test", "destroy"}) { + assertTrue(connector.threads.get(name).startsWith(SCHEDULER_THREAD_PREFIX), name + " ran on " + connector.threads.get(name)); + assertTrue(!connector.threads.get(name).equals(Thread.currentThread().getName())); + } + } + + @Test + public void aFailureInsideABlockingMethodIsAnErrorSignalNotAThrowOnTheCaller() { + IllegalStateException failure = new IllegalStateException("blocked"); + connector.failure = failure; + + assertSame(failure, assertThrows(IllegalStateException.class, () -> connector.createConnection(new TestConfig("db")).block())); + assertSame(failure, assertThrows(IllegalStateException.class, () -> connector.testConnection(new TestConfig("db")).block())); + assertSame(failure, assertThrows(IllegalStateException.class, () -> connector.destroyConnection("c").block())); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/ConnectorTestTypes.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/ConnectorTestTypes.java new file mode 100644 index 0000000000..d693bb9fc8 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/ConnectorTestTypes.java @@ -0,0 +1,152 @@ +package org.lowcoder.sdk.plugin.common; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.query.QueryExecutionContext; +import org.lowcoder.sdk.query.QueryVisitorContext; + +import reactor.core.publisher.Mono; + +/** + * Minimal plugin types for the contract tests of {@link QueryExecutor}, {@link DatasourceConnector} and + * {@link BlockingDatasourceConnector}: a config, a second config of another type, a query context, and implementations + * whose behaviour the test sets through public fields. + */ +final class ConnectorTestTypes { + + private ConnectorTestTypes() { + } + + /** The config type the test plugin works with; its Jackson binding is the default one (fields by name). */ + public static class TestConfig implements DatasourceConnectionConfig { + public String host; + + public TestConfig() { + } + + TestConfig(String host) { + this.host = host; + } + + @Override + public DatasourceConnectionConfig mergeWithUpdatedConfig(DatasourceConnectionConfig detailConfig) { + return detailConfig; + } + } + + /** A config of another datasource type. */ + static final class OtherConfig implements DatasourceConnectionConfig { + @Override + public DatasourceConnectionConfig mergeWithUpdatedConfig(DatasourceConnectionConfig detailConfig) { + return detailConfig; + } + } + + static final class TestContext extends QueryExecutionContext { + final String sql; + + TestContext(String sql) { + this.sql = sql; + } + } + + /** A query context of a type this executor does not work with. */ + static final class OtherContext extends QueryExecutionContext { + } + + static final class TestExecutor implements QueryExecutor { + Supplier build = () -> new TestContext("select 1"); + Function> execute = context -> Mono.just(QueryExecutionResult.success(context.sql)); + Mono structure = null; + + @Override + public TestContext buildQueryExecutionContext(TestConfig datasourceConfig, Map queryConfig, Map requestParams, + QueryVisitorContext queryVisitorContext) { + return build.get(); + } + + @Override + public Mono executeQuery(String connection, TestContext queryExecutionContext) { + return execute.apply(queryExecutionContext); + } + + @Override + public Mono getStructure(String connection, TestConfig connectionConfig) { + return structure != null ? structure : QueryExecutor.super.getStructure(connection, connectionConfig); + } + } + + static class TestConnector implements DatasourceConnector { + Mono create = Mono.just("connection"); + Set invalid = Set.of(); + Mono test = Mono.just(DatasourceTestResult.testSuccess()); + + @Override + public Set validateConfig(TestConfig config) { + return invalid; + } + + @Override + public Mono testConnection(TestConfig config) { + return test; + } + + @Override + public Mono createConnection(TestConfig connectionConfig) { + return create; + } + + @Override + public Mono destroyConnection(String connection) { + return Mono.empty(); + } + } + + /** Records on which thread each blocking method ran, and what it was given. */ + static final class TestBlockingConnector extends BlockingDatasourceConnector { + final Map threads = new HashMap<>(); + final Map given = new HashMap<>(); + RuntimeException failure; + + @Override + public Set validateConfig(TestConfig config) { + return Set.of(); + } + + @Override + protected DatasourceTestResult blockingTestConnection(String connection) { + threads.put("test", Thread.currentThread().getName()); + given.put("test", connection); + if (failure != null) { + throw failure; + } + return DatasourceTestResult.testSuccess(); + } + + @Override + protected void blockingDestroyConnection(String connection) { + threads.put("destroy", Thread.currentThread().getName()); + given.put("destroy", connection); + if (failure != null) { + throw failure; + } + } + + @Override + protected String blockingCreateConnection(TestConfig connectionConfig) { + threads.put("create", Thread.currentThread().getName()); + if (failure != null) { + throw failure; + } + return "blocking:" + connectionConfig.host; + } + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/DatasourceConnectorContractTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/DatasourceConnectorContractTest.java new file mode 100644 index 0000000000..4757397d2d --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/DatasourceConnectorContractTest.java @@ -0,0 +1,87 @@ +package org.lowcoder.sdk.plugin.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeoutException; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.lowcoder.sdk.plugin.common.ConnectorTestTypes.OtherConfig; +import org.lowcoder.sdk.plugin.common.ConnectorTestTypes.TestConfig; +import org.lowcoder.sdk.plugin.common.ConnectorTestTypes.TestConnector; + +import reactor.core.publisher.Mono; + +/** + * The default methods of {@link DatasourceConnector}: the config binding by the type the plugin declares, and how a failure + * to create a connection is reported. The real ten-second timeout operator is assembled by every call but not waited for; + * its mapping is checked with a {@link TimeoutException} signalled at once. + */ +public class DatasourceConnectorContractTest { + + private final TestConnector connector = new TestConnector(); + private final TestConfig config = new TestConfig("h"); + + @Test + public void theConfigIsBoundToTheTypeThePluginDeclaresAndAMissingOneIsRejected() { + TestConfig bound = connector.resolveConfig(Map.of("host", "db.example.com")); + + assertEquals("db.example.com", bound.host); + PluginException failure = assertThrows(PluginException.class, () -> connector.resolveConfig(null)); + assertEquals("DATASOURCE_CONFIG_ERROR", failure.getMessageKey()); + assertEquals(PluginCommonError.DATASOURCE_ARGUMENT_ERROR, failure.getError()); + } + + @Test + public void validateAndTestDelegateToThePlugin() { + connector.invalid = Set.of("HOST_EMPTY"); + DatasourceTestResult failed = DatasourceTestResult.testFail("no route"); + connector.test = Mono.just(failed); + + assertEquals(Set.of("HOST_EMPTY"), connector.doValidateConfig(config)); + assertSame(failed, connector.doTestConnection(config).block()); + } + + @Test + public void aConnectionIsCreatedAndEveryFailureIsReportedAsACodedPluginException() { + assertEquals("connection", connector.doCreateConnection(config).block()); + + PluginException coded = new PluginException(PluginCommonError.QUERY_ARGUMENT_ERROR, "QUERY_ARGUMENT_ERROR", "mine"); + connector.create = Mono.error(coded); + assertSame(coded, assertThrows(PluginException.class, () -> connector.doCreateConnection(config).block())); + + connector.create = Mono.error(new TimeoutException("slow")); + PluginException timeout = assertThrows(PluginException.class, () -> connector.doCreateConnection(config).block()); + assertEquals("DATASOURCE_TIMEOUT_ERROR", timeout.getMessageKey()); + assertEquals(PluginCommonError.DATASOURCE_TIMEOUT_ERROR, timeout.getError()); + + connector.create = Mono.error(new IllegalStateException("refused")); + PluginException failed = assertThrows(PluginException.class, () -> connector.doCreateConnection(config).block()); + System.out.println("[DatasourceConnectorContractTest] " + failed.getError() + " " + failed.getMessageKey() + ": " + failed.getMessage()); + assertEquals("PLUGIN_CREATE_CONNECTION_FAILED", failed.getMessageKey()); + assertEquals(PluginCommonError.QUERY_EXECUTION_ERROR, failed.getError()); + assertTrue(failed.getMessage().contains("refused"), failed.getMessage()); + } + + @Test + public void theTokenDetailIsUnsupportedByDefault() { + assertThrows(UnsupportedOperationException.class, () -> connector.resolveTokenDetail(Map.of())); + } + + /** Observation, see {@link QueryExecutorContractTest}: the type checks of the {@code do...} methods are unchecked casts. */ + @Test + public void aConfigOfTheWrongTypeFailsWithARawClassCastExceptionAtTheCallIntoThePlugin() { + OtherConfig wrong = new OtherConfig(); + + assertThrows(ClassCastException.class, () -> connector.doValidateConfig(wrong)); + assertThrows(ClassCastException.class, () -> connector.doTestConnection(wrong)); + assertThrows(ClassCastException.class, () -> connector.doCreateConnection(wrong)); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/QueryExecutorContractTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/QueryExecutorContractTest.java new file mode 100644 index 0000000000..5d1002a7f0 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/QueryExecutorContractTest.java @@ -0,0 +1,125 @@ +package org.lowcoder.sdk.plugin.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.common.ConnectorTestTypes.OtherConfig; +import org.lowcoder.sdk.plugin.common.ConnectorTestTypes.OtherContext; +import org.lowcoder.sdk.plugin.common.ConnectorTestTypes.TestConfig; +import org.lowcoder.sdk.plugin.common.ConnectorTestTypes.TestContext; +import org.lowcoder.sdk.plugin.common.ConnectorTestTypes.TestExecutor; + +import reactor.core.publisher.Mono; + +/** + * The default methods of {@link QueryExecutor}, which every plugin inherits: how a failure of the plugin's own method is + * reported (kept when it already is a {@link PluginException} or {@link BizException}, coded otherwise), the default structure + * and the default query-config sanitising. + */ +public class QueryExecutorContractTest { + + private final TestExecutor executor = new TestExecutor(); + private final TestConfig config = new TestConfig("h"); + + @Test + public void theContextIsBuiltByThePluginAndAFailureIsWrappedAsAQueryArgumentErrorUnlessItAlreadyIsACodedOne() { + TestContext context = executor.doBuildQueryExecutionContext(config, Map.of(), Map.of(), null); + assertEquals("select 1", context.sql); + assertEquals("select 1", executor.buildQueryExecutionContextMono(config, Map.of(), Map.of(), null).block().sql); + + executor.build = () -> { + throw new IllegalStateException("bad setting"); + }; + PluginException blocking = assertThrows(PluginException.class, () -> executor.doBuildQueryExecutionContext(config, Map.of(), Map.of(), null)); + PluginException reactive = assertThrows(PluginException.class, () -> executor.buildQueryExecutionContextMono(config, Map.of(), Map.of(), null).block()); + System.out.println("[QueryExecutorContractTest] wrapped: " + blocking.getError() + " " + blocking.getMessageKey() + ": " + blocking.getMessage()); + for (PluginException failure : List.of(blocking, reactive)) { + assertEquals(PluginCommonError.INVALID_QUERY_SETTINGS, failure.getError()); + assertEquals("QUERY_ARGUMENT_ERROR", failure.getMessageKey()); + assertTrue(failure.getMessage().contains("bad setting"), failure.getMessage()); + } + + PluginException coded = new PluginException(PluginCommonError.QUERY_ARGUMENT_ERROR, "QUERY_ARGUMENT_ERROR", "mine"); + executor.build = () -> { + throw coded; + }; + assertSame(coded, assertThrows(PluginException.class, () -> executor.doBuildQueryExecutionContext(config, Map.of(), Map.of(), null))); + assertSame(coded, assertThrows(PluginException.class, () -> executor.buildQueryExecutionContextMono(config, Map.of(), Map.of(), null).block())); + } + + @Test + public void executionKeepsPluginAndBizExceptionsAndCodesEverythingElse() { + QueryExecutionResult ok = executor.doExecuteQuery("c", new TestContext("select 2")).block(); + assertEquals("select 2", ok.getData()); + + PluginException plugin = new PluginException(PluginCommonError.QUERY_ARGUMENT_ERROR, "QUERY_ARGUMENT_ERROR", "p"); + BizException biz = new BizException(BizError.INVALID_PARAMETER, "FILE_NOT_EXIST"); + executor.execute = context -> Mono.error(plugin); + assertSame(plugin, assertThrows(PluginException.class, () -> executor.doExecuteQuery("c", new TestContext("q")).block())); + executor.execute = context -> Mono.error(biz); + assertSame(biz, assertThrows(BizException.class, () -> executor.doExecuteQuery("c", new TestContext("q")).block())); + executor.execute = context -> Mono.error(new IllegalStateException("db is down")); + PluginException wrapped = assertThrows(PluginException.class, () -> executor.doExecuteQuery("c", new TestContext("q")).block()); + + System.out.println("[QueryExecutorContractTest] execution error: " + wrapped.getError() + " " + wrapped.getMessageKey() + ": " + wrapped.getMessage()); + assertEquals(PluginCommonError.QUERY_EXECUTION_ERROR, wrapped.getError()); + assertEquals("QUERY_EXECUTION_ERROR", wrapped.getMessageKey()); + assertTrue(wrapped.getMessage().contains("db is down"), wrapped.getMessage()); + } + + @Test + public void theStructureIsEmptyByDefaultAndItsFailuresAreCodedExceptForPluginExceptions() { + assertNull(executor.doGetStructure("c", config).block(), "the default has no structure"); + + DatasourceStructure structure = new DatasourceStructure(List.of()); + executor.structure = Mono.just(structure); + assertSame(structure, executor.doGetStructure("c", config).block()); + + PluginException plugin = new PluginException(PluginCommonError.QUERY_ARGUMENT_ERROR, "QUERY_ARGUMENT_ERROR", "p"); + executor.structure = Mono.error(plugin); + assertSame(plugin, assertThrows(PluginException.class, () -> executor.doGetStructure("c", config).block())); + executor.structure = Mono.error(new BizException(BizError.INVALID_PARAMETER, "FILE_NOT_EXIST")); + PluginException wrapped = assertThrows(PluginException.class, () -> executor.doGetStructure("c", config).block()); + assertEquals("DATASOURCE_GET_STRUCTURE_ERROR", wrapped.getMessageKey(), "a BizException is not kept here, unlike in the execution"); + assertEquals(PluginCommonError.QUERY_EXECUTION_ERROR, wrapped.getError()); + } + + @Test + public void theQueryConfigIsNotSanitisedByDefault() { + Map queryConfig = Map.of("password", "secret"); + + assertSame(queryConfig, executor.sanitizeQueryConfig(queryConfig)); + } + + /** + * Observation (callers: only a datasource stored with a config of another type could reach it): the casts of the + * generic parameters are unchecked, so the {@code catch (ClassCastException)} blocks of the {@code do...} methods never + * run, and a config or context of the wrong type fails later, where the bridge method calls into the plugin. Building + * the context wraps it (the call is inside the try / the Mono), the other entry points throw the raw ClassCastException. + */ + @Test + public void aConfigOrContextOfTheWrongTypeGivesACodedErrorOnlyWhereTheCallIsWrapped() { + OtherConfig wrongConfig = new OtherConfig(); + + PluginException built = assertThrows(PluginException.class, () -> executor.doBuildQueryExecutionContext(wrongConfig, Map.of(), Map.of(), null)); + PluginException builtMono = assertThrows(PluginException.class, () -> executor.buildQueryExecutionContextMono(wrongConfig, Map.of(), Map.of(), null).block()); + assertEquals("QUERY_ARGUMENT_ERROR", built.getMessageKey()); + assertEquals("QUERY_ARGUMENT_ERROR", builtMono.getMessageKey()); + assertInstanceOf(ClassCastException.class, assertThrows(ClassCastException.class, () -> executor.doGetStructure("c", wrongConfig))); + assertInstanceOf(ClassCastException.class, assertThrows(ClassCastException.class, () -> executor.doExecuteQuery("c", new OtherContext()))); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/RestApiUriBuilderCollapseTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/RestApiUriBuilderCollapseTest.java new file mode 100644 index 0000000000..25209e1513 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/RestApiUriBuilderCollapseTest.java @@ -0,0 +1,76 @@ +package org.lowcoder.sdk.plugin.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.webclient.WebClientBuildHelper; + +/** + * DEFECT pinned (plan section 9 row "REST url: // inside a query value collapsed to /"; D-6, fix deferred). + * {@code RestApiUriBuilder.buildUri} runs {@code url.replaceAll("(? built '" + built.getRawQuery() + "', server received '" + arrived + "'"); + assertEquals("u=a/b&v=http://x/y", built.getRawQuery()); + assertEquals("/p?u=a/b&v=http://x/y", arrived.toString(), "the received value is not the entered one"); + } + + @Test + public void aValueBroughtInByAMustacheExpressionIsCollapsedToo() { + URI built = RestApiUriBuilder.buildUri("https://h.example/p?u={{v}}", Map.of("v", "a//b"), Map.of()); + + assertEquals("u=a/b", built.getRawQuery()); + } + + @Test + public void thePathCleanupAndTheSchemeSeparatorsBehaveAsTheCollapseIsMeantTo() { + assertEquals("/p/q", RestApiUriBuilder.buildUri("https://h.example/p//q", Map.of(), Map.of()).getRawPath()); + assertEquals("/p/q/r", RestApiUriBuilder.buildUri("https://h.example//p///q//r", Map.of(), Map.of()).getRawPath()); + assertEquals("https://h.example/p", RestApiUriBuilder.buildUri("https://h.example/p", Map.of(), Map.of()).toString()); + assertEquals("http://h.example/p", RestApiUriBuilder.buildUri("http://h.example/p", Map.of(), Map.of()).toString()); + } + + @Test + public void anEntryOfTheParametersTableIsAddedAfterTheCollapseAndArrivesIntact() { + Map parameters = new LinkedHashMap<>(); + parameters.put("w", "a//b"); + + URI built = RestApiUriBuilder.buildUri("https://h.example/p", Map.of(), parameters); + URI arrived = received(built); + + System.out.println("[RestApiUriBuilderCollapseTest] Parameters-table value 'a//b' -> built '" + built.getRawQuery() + "', server received '" + arrived + "'"); + assertEquals("w=a%2F%2Fb", built.getRawQuery()); + assertEquals("/p?w=a%2F%2Fb", arrived.toString()); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/RestApiUriBuilderTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/RestApiUriBuilderTest.java new file mode 100644 index 0000000000..2898cec3aa --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/RestApiUriBuilderTest.java @@ -0,0 +1,75 @@ +package org.lowcoder.sdk.plugin.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.http.client.utils.URIBuilder; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; + +/** {@link RestApiUriBuilder}: how the url of a REST query is put together from prefix, suffix, parameters and mustache values. */ +public class RestApiUriBuilderTest { + + private static final Map NO_PARAMS = Map.of(); + private static final Map NO_URL_PARAMS = Map.of(); + + @Test + public void prefixAndSuffixAreTrimmedAndJoinedWithExactlyOneSlash() { + assertEquals("https://h/x", RestApiUriBuilder.buildUri("https://h", "x", NO_PARAMS, NO_URL_PARAMS).toString()); + assertEquals("https://h/x", RestApiUriBuilder.buildUri(" https://h ", " x ", NO_PARAMS, NO_URL_PARAMS).toString()); + assertEquals("https://h/x", RestApiUriBuilder.buildUri("https://h", "/x", NO_PARAMS, NO_URL_PARAMS).toString()); + assertEquals("https://h/x", RestApiUriBuilder.buildUri("https://h/", "x", NO_PARAMS, NO_URL_PARAMS).toString(), "the doubled slash is collapsed"); + assertEquals("https://h", RestApiUriBuilder.buildUri("https://h", "", NO_PARAMS, NO_URL_PARAMS).toString()); + assertEquals("https://h/x", RestApiUriBuilder.buildUri("", "https://h/x", NO_PARAMS, NO_URL_PARAMS).toString(), "no prefix: the suffix is the url"); + } + + @Test + public void anEmptyUrlIsRejectedWithItsOwnMessage() { + PluginException failure = assertThrows(PluginException.class, () -> RestApiUriBuilder.buildUri("", " ", NO_PARAMS, NO_URL_PARAMS)); + PluginException direct = assertThrows(PluginException.class, () -> RestApiUriBuilder.buildUri("", NO_PARAMS, NO_URL_PARAMS)); + + assertEquals("REQUEST_URL_EMPTY", failure.getMessageKey()); + assertEquals(PluginCommonError.QUERY_ARGUMENT_ERROR, failure.getError()); + assertEquals("REQUEST_URL_EMPTY", direct.getMessageKey()); + } + + @Test + public void mustacheValuesAreRenderedIntoTheUrl() { + URI uri = RestApiUriBuilder.buildUri("https://{{host}}/items/{{id}}", Map.of("host", "api.example.com", "id", 42), NO_URL_PARAMS); + + assertEquals("https://api.example.com/items/42", uri.toString()); + } + + @Test + public void urlParametersAreAppendedInOrderAfterTheQueryOfTheUrlAndBlankNamesAreSkipped() throws Exception { + Map params = new LinkedHashMap<>(); + params.put("a", "1"); + params.put("", "skipped"); + params.put(" ", "skipped too"); + params.put("b", "x y&z"); + + URI uri = RestApiUriBuilder.buildUri("https://h/p?q=0", NO_PARAMS, params); + + System.out.println("[RestApiUriBuilderTest] " + uri); + assertEquals("q=0&a=1&b=x+y%26z", uri.getRawQuery()); + assertEquals("x y&z", new URIBuilder(uri).getQueryParams().get(2).getValue(), "the value is encoded and decodes back to what was entered"); + assertEquals("/p", uri.getPath()); + assertEquals("h", uri.getHost()); + } + + @Test + public void anUnparsableUrlIsRejectedAsInvalidRequestUrl() { + for (String url : new String[] {"http://[bad", "http://a b/"}) { + PluginException failure = assertThrows(PluginException.class, () -> RestApiUriBuilder.buildUri(url, NO_PARAMS, NO_URL_PARAMS), url); + + System.out.println("[RestApiUriBuilderTest] '" + url + "' -> " + failure.getMessageKey()); + assertEquals("INVALID_REQUEST_URL", failure.getMessageKey()); + assertEquals(PluginCommonError.QUERY_ARGUMENT_ERROR, failure.getError()); + } + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/SqlQueryUtilsTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/SqlQueryUtilsTest.java new file mode 100644 index 0000000000..afe8b87f86 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/SqlQueryUtilsTest.java @@ -0,0 +1,106 @@ +package org.lowcoder.sdk.plugin.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Unit tests of the {@code --} comment handling and the insert detection of {@link SqlQueryUtils}. + * Block comments ({@code /* ... *}{@code /}) are pinned by L5's SqlBasedQueryExecutorDefectPinTest and not repeated here. + */ +class SqlQueryUtilsTest { + + private static String strip(String query) { + String result = SqlQueryUtils.removeQueryComments(query); + System.out.println("[SqlQueryUtilsTest] [" + query.replace("\n", "\\n") + "] -> [" + result.replace("\n", "\\n") + "]"); + return result; + } + + @ParameterizedTest(name = "[{index}] {0}") + @CsvSource(delimiter = '|', value = { + "trailing comment|select 1 -- why|select 1", + "comment ends at newline, next line kept|select 1 -- why\\nfrom t|select 1 \\nfrom t", + "two comment lines|select 1 --a\\n--b\\nfrom t|select 1 \\n\\nfrom t", + "comment right after code|select 1--why\\nfrom t|select 1\\nfrom t" + }) + void removeQueryCommentsStripsDashCommentToEndOfLineAndKeepsNextLine(String label, String query, String expected) { + assertThat(strip(query.replace("\\n", "\n"))).isEqualTo(expected.replace("\\n", "\n")); + } + + @Test + void removeQueryCommentsKeepsDashesInsideSingleAndDoubleQuotes() { + assertThat(strip("select 'a--b' from t")).isEqualTo("select 'a--b' from t"); + assertThat(strip("select \"a--b\" from t")).isEqualTo("select \"a--b\" from t"); + assertThat(strip("select 'a--b' -- why")).as("a comment after the literal is still removed").isEqualTo("select 'a--b'"); + } + + @ParameterizedTest + @ValueSource(strings = {"a - b", "a-b", "select - 1", "a-b-c", "x = -1"}) + void removeQueryCommentsKeepsSingleDashes(String query) { + assertThat(strip(query)).isEqualTo(query); + } + + @ParameterizedTest(name = "[{index}] \"{0}\"") + @CsvSource(delimiter = '|', nullValues = "NULL", value = { + "''|''", "' '|''", "-- only a comment|''", "--|''", "---|''", "select 1 --|select 1", " select 1 |select 1" + }) + void removeQueryCommentsHandlesEdgeInputsAndTrimsTheResult(String query, String expected) { + assertThat(strip(query)).isEqualTo(expected.replace("''", "")); + } + + @ParameterizedTest(name = "[{index}] {0} -> {1}") + @CsvSource(delimiter = '|', value = { + "insert into t values (1)|true", + "INSERT INTO t VALUES (1)|true", + " insert into t values (1)|true", + "insert|true", + "select 1; insert into t values (1)|true", + "insert into t values (1); select 1|false", + "select 1;|false", + "update t set a = 1|false", + "select 1|false", + "''|false" + }) + void isInsertQueryDecidesByTheLastStatementCaseInsensitive(String query, boolean expected) { + boolean actual = SqlQueryUtils.isInsertQuery(query.equals("''") ? "" : query); + System.out.println("[SqlQueryUtilsTest] isInsertQuery [" + query + "] = " + actual); + assertThat(actual).isEqualTo(expected); + } + + /** + * Pins the plan section 9 row "SqlQueryUtils.isInsertQuery(";") throws" (D-6, fix deferred): a query made of + * semicolons only splits into an empty array and the last-element access fails. A fix changes this test on purpose. + */ + @ParameterizedTest + @ValueSource(strings = {";", ";;"}) + void isInsertQueryThrowsArrayIndexOutOfBoundsForSemicolonOnlyQueries(String query) { + assertThatThrownBy(() -> SqlQueryUtils.isInsertQuery(query)).isInstanceOf(ArrayIndexOutOfBoundsException.class); + System.out.println("[SqlQueryUtilsTest] isInsertQuery [" + query + "] throws ArrayIndexOutOfBoundsException (plan section 9 row, pinned)"); + } + + /** + * Pins the plan section 9 row "removeQueryComments: two - inside a -- comment delete query characters before the + * comment" (D-6, fix deferred, source: lane L4, L4-3 plan). The counter is not reset while inside the comment, so the + * second hyphen inside the comment deletes the last character already written. A fix changes this test on purpose. + */ + @Test + void removeQueryCommentsCommentWithTwoDashesDeletesPrecedingQueryCharacter() { + assertThat(strip("select 1--a-b-c\nfrom t")).as("today's behaviour: the 1 is lost").isEqualTo("select \nfrom t"); + assertThat(strip("select 1 -- a-b-c\nfrom t")).as("today's behaviour: the space before the comment is lost").isEqualTo("select 1\nfrom t"); + } + + /** + * Pins the plan section 9 row "removeQueryComments: a quote inside a -- comment flips the quote state" (D-6, fix + * deferred, source: lane L4, L4-3 plan): the apostrophe in the comment makes the later literal look unquoted, so its + * {@code --} starts a comment and the rest of the query is cut. A fix changes this test on purpose. + */ + @Test + void removeQueryCommentsQuoteInsideCommentFlipsQuoteState() { + assertThat(strip("select 1 -- don't\nfrom t where x='--y'")).as("today's behaviour: the query is cut inside the literal") + .isEqualTo("select 1 \nfrom t where x='"); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/sql/ResultSetParserContractTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/sql/ResultSetParserContractTest.java new file mode 100644 index 0000000000..c7f3702a65 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/sql/ResultSetParserContractTest.java @@ -0,0 +1,256 @@ +package org.lowcoder.sdk.plugin.common.sql; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.Column; +import org.lowcoder.sdk.contract.FakeJdbc.FailingCell; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.models.QueryExecutionResult; + +import javax.sql.rowset.serial.SerialArray; +import javax.sql.rowset.serial.SerialBlob; +import javax.sql.rowset.serial.SerialClob; +import javax.sql.rowset.serial.SerialException; +import javax.sql.rowset.serial.SerialRef; +import javax.sql.rowset.serial.SerialStruct; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.StringReader; +import java.io.Writer; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.sql.NClob; +import java.sql.Ref; +import java.sql.ResultSet; +import java.sql.RowId; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLXML; +import java.sql.Struct; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.UUID; + +/** + * The SQL leaf values of the §4.6 producer table (docs/API_PAYLOAD_TEST_PLAN.md §4.6, task T8.2): + * {@link ResultSetParser#parseRows} over one row whose columns hold every value class a JDBC driver hands out through + * {@code getObject} (JDBC 4.2 default mapping, the {@code java.sql} interfaces as JDK {@code Serial*} classes or + * minimal implementations, and {@link UUID}), plus the columns the parser converts by type name ({@code date}, + * {@code datetime}, {@code timestamp}, {@code year}, {@code blob}, and a MySQL {@code TIME} duration). Each column's + * report is the Java class of the parsed value and the text the production mapper writes for it alone, so one value + * that fails to write does not hide the others ({@code Clob}, {@code NClob} and {@code Array} do, O76); the whole row + * is reported as a query result too, and fails to write with them. Pinned in + * {@value #REPORT}. + * + *

    Limits: the values are those of {@link FakeJdbc} and the JDK, not of a real driver (a driver's own {@code Array} + * or {@code Clob} class writes its own getters); the default time zone is UTC while the test runs, since + * {@code java.sql.Time} and the date conversions use it. + */ +public class ResultSetParserContractTest { + + static final String REPORT = "query-results/ResultSetParser.leaves.json"; + static final String LEAF_TYPE = "OTHER"; + static final String ROW_KEY = "row"; + static final String COLUMNS_KEY = "columns"; + static final String CLASS_KEY = "class"; + static final String TYPE_NAME_KEY = "typeName"; + static final String MYSQL_DURATION_MESSAGE = "The value '30:00:00' is an invalid TIME value. JDBC Time objects represent a wall-clock time"; + static final Instant INSTANT = Instant.parse("2024-02-29T13:14:15.123Z"); + static final byte[] BYTES = {0, 1, (byte) 0xFF, 'a'}; + static final String TEXT = "žluťoučký kůň 🐎"; + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + private static TimeZone defaultZone; + + @BeforeAll + public static void useUtc() { + defaultZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + + @AfterAll + public static void restoreZone() { + TimeZone.setDefault(defaultZone); + } + + @Test + public void leafValuesAsPinned() throws SQLException { + Map cells = cells(); + List columns = new ArrayList<>(); + cells.keySet().forEach(label -> columns.add(new Column(label, typeName(label)))); + ResultSet resultSet = FakeJdbc.resultSet(columns, List.of(new ArrayList<>(cells.values()))); + + List> rows = ResultSetParser.parseRows(resultSet); + Map perColumn = new LinkedHashMap<>(); + rows.get(0).forEach((label, value) -> { + Map column = new LinkedHashMap<>(); + column.put(TYPE_NAME_KEY, typeName(label)); + column.put(CLASS_KEY, value == null ? String.valueOf((Object) null) : value.getClass().getName()); + column.put(QueryResults.WRITTEN_KEY, QueryResults.written(value)); + perColumn.put(label, column); + }); + Map report = new LinkedHashMap<>(); + report.put(COLUMNS_KEY, perColumn); + report.put(ROW_KEY, QueryResults.report(QueryExecutionResult.success(rows))); + String actual = ConfigBinding.write(report); + System.out.println("[ResultSetParserContractTest] " + rows.size() + " row, " + perColumn.size() + " columns\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** The column's type name: the parser's own names for the converted columns, {@value #LEAF_TYPE} for the leaves. */ + private static String typeName(String label) { + return switch (label) { + case "date", "datetime", "timestamp", "year", "blob" -> label; + case "timeDuration", "time" -> "TIME"; + default -> LEAF_TYPE; + }; + } + + private static Map cells() throws SQLException { + Map cells = new LinkedHashMap<>(); + cells.put("string", TEXT); + cells.put("bigDecimal", new BigDecimal("1.50")); + cells.put("bigDecimalExponent", new BigDecimal("1E+3")); + cells.put("boolean", Boolean.TRUE); + cells.put("integer", Integer.MAX_VALUE); + cells.put("long", Long.MAX_VALUE); + cells.put("float", 1.1f); + cells.put("double", 0.1d); + cells.put("bytes", BYTES); + cells.put("sqlDate", new java.sql.Date(INSTANT.toEpochMilli())); + cells.put("time", new Time(INSTANT.toEpochMilli())); + cells.put("sqlTimestamp", Timestamp.from(INSTANT)); + cells.put("clob", new SerialClob(TEXT.toCharArray())); + cells.put("nclob", new TestNClob(TEXT)); + cells.put("blobLeaf", new SerialBlob(BYTES)); + cells.put("array", new SerialArray(new FakeJdbc.SqlArray("int4", Types.INTEGER, new Integer[] {1, 2, null}))); + cells.put("struct", new SerialStruct(new TestStruct("point", new Object[] {1, "two"}), Map.of())); + cells.put("ref", new SerialRef(new TestRef("point", "referenced"))); + cells.put("rowId", new TestRowId(BYTES)); + cells.put("sqlxml", new TestSqlXml("" + TEXT + "")); + cells.put("uuid", UUID.fromString("123e4567-e89b-12d3-a456-426614174000")); + cells.put("null", null); + cells.put("date", new java.sql.Date(INSTANT.toEpochMilli())); + cells.put("datetime", Timestamp.from(INSTANT)); + cells.put("timestamp", Timestamp.from(INSTANT)); + cells.put("year", new java.sql.Date(INSTANT.toEpochMilli())); + cells.put("blob", new SerialBlob(BYTES)); + cells.put("timeDuration", new FailingCell(MYSQL_DURATION_MESSAGE, "30:00:00")); + return cells; + } + + /** An {@code NClob} as {@link SerialClob} holds one. */ + static final class TestNClob extends SerialClob implements NClob { + TestNClob(String text) throws SQLException { + super(text.toCharArray()); + } + } + + /** The struct a driver hands to {@link SerialStruct}. */ + record TestStruct(String typeName, Object[] attributes) implements Struct { + @Override + public String getSQLTypeName() { + return typeName; + } + + @Override + public Object[] getAttributes() { + return attributes.clone(); + } + + @Override + public Object[] getAttributes(Map> map) { + return getAttributes(); + } + } + + /** The reference a driver hands to {@link SerialRef}. */ + record TestRef(String baseTypeName, Object object) implements Ref { + @Override + public String getBaseTypeName() { + return baseTypeName; + } + + @Override + public Object getObject(Map> map) { + return object; + } + + @Override + public Object getObject() { + return object; + } + + @Override + public void setObject(Object value) throws SQLException { + throw new SQLFeatureNotSupportedException("TestRef.setObject"); + } + } + + /** A row id of fixed bytes. */ + record TestRowId(byte[] bytes) implements RowId { + @Override + public byte[] getBytes() { + return bytes.clone(); + } + } + + /** A readable XML value; writing to it is not supported. */ + record TestSqlXml(String xml) implements SQLXML { + @Override + public void free() { + } + + @Override + public InputStream getBinaryStream() { + return new java.io.ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public OutputStream setBinaryStream() throws SQLException { + throw new SerialException("TestSqlXml is read-only"); + } + + @Override + public Reader getCharacterStream() { + return new StringReader(xml); + } + + @Override + public Writer setCharacterStream() throws SQLException { + throw new SerialException("TestSqlXml is read-only"); + } + + @Override + public String getString() { + return xml; + } + + @Override + public void setString(String value) throws SQLException { + throw new SerialException("TestSqlXml is read-only"); + } + + @Override + public T getSource(Class sourceClass) throws SQLException { + throw new SQLFeatureNotSupportedException("TestSqlXml.getSource"); + } + + @Override + public T setResult(Class resultClass) throws SQLException { + throw new SQLFeatureNotSupportedException("TestSqlXml.setResult"); + } + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/sql/SqlBasedConfigMergeTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/sql/SqlBasedConfigMergeTest.java new file mode 100644 index 0000000000..1873bbb424 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/sql/SqlBasedConfigMergeTest.java @@ -0,0 +1,178 @@ +package org.lowcoder.sdk.plugin.common.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig; +import org.slf4j.LoggerFactory; + +/** + * Merge and encryption of {@link SqlBasedDatasourceConnectionConfig}, exercised through its concrete + * {@link MysqlDatasourceConfig}. + */ +class SqlBasedConfigMergeTest { + + private static final String STORED_PASSWORD = "stored-secret"; + private static final String NEW_PASSWORD = "new-secret"; + private static final long MYSQL_DEFAULT_PORT = 3306L; + private static final long UPDATED_PORT = 3307L; + private static final String ENCRYPT_FAILURE = "encrypt backend down"; + private static final String DECRYPT_FAILURE = "bad ciphertext"; + + private Logger logger; + private ListAppender appender; + + @BeforeEach + void captureLog() { + logger = (Logger) LoggerFactory.getLogger(SqlBasedDatasourceConnectionConfig.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void releaseLog() { + logger.detachAppender(appender); + } + + private static MysqlDatasourceConfig stored(String password) { + return new MysqlDatasourceConfig("db1", "user1", password, "host1", 3306L, false, "tz1", false, false, Map.of("stored", "1")); + } + + private static MysqlDatasourceConfig update(String password, Long port) { + return new MysqlDatasourceConfig("db2", "user2", password, "host2", port, true, "UTC", true, true, Map.of("updated", "2")); + } + + @Test + void mergeKeepsOldPasswordWhenUpdateHasNoneAndTakesAllOtherFieldsFromUpdate() { + DatasourceConnectionConfig merged = stored(STORED_PASSWORD).mergeWithUpdatedConfig(update(null, UPDATED_PORT)); + + MysqlDatasourceConfig config = (MysqlDatasourceConfig) merged; + assertThat(config.getPassword()).as("password not resent: stored one kept").isEqualTo(STORED_PASSWORD); + assertThat(config.getDatabase()).isEqualTo("db2"); + assertThat(config.getUsername()).isEqualTo("user2"); + assertThat(config.getHost()).isEqualTo("host2"); + assertThat(config.getPort()).isEqualTo(UPDATED_PORT); + assertThat(config.isUsingSsl()).isTrue(); + assertThat(config.getServerTimezone()).isEqualTo("UTC"); + assertThat(config.isReadonly()).isTrue(); + assertThat(config.isEnableTurnOffPreparedStatement()).isTrue(); + assertThat(config.getExtParams()).isEqualTo(Map.of("updated", "2")); + System.out.println("[SqlBasedConfigMergeTest] merged: host=" + config.getHost() + " port=" + config.getPort() + " db=" + config.getDatabase()); + } + + @Test + void mergeReplacesPasswordWhenUpdateSendsOne() { + MysqlDatasourceConfig merged = (MysqlDatasourceConfig) stored(STORED_PASSWORD).mergeWithUpdatedConfig(update(NEW_PASSWORD, UPDATED_PORT)); + + assertThat(merged.getPassword()).isEqualTo(NEW_PASSWORD); + System.out.println("[SqlBasedConfigMergeTest] new password wins over the stored one"); + } + + @Test + void mergeReturnsNewInstanceOfSameRuntimeClassAndPinsDefaultPortWhenUpdateHasNone() { + MysqlDatasourceConfig storedConfig = stored(STORED_PASSWORD); + DatasourceConnectionConfig merged = storedConfig.mergeWithUpdatedConfig(update(null, null)); + + assertThat(merged).isInstanceOf(MysqlDatasourceConfig.class).isNotSameAs(storedConfig); + assertThat(((MysqlDatasourceConfig) merged).getPort()) + .as("merge passes getPort(), so a null port in the update becomes the default port") + .isEqualTo(MYSQL_DEFAULT_PORT); + System.out.println("[SqlBasedConfigMergeTest] merged class " + merged.getClass().getSimpleName() + ", port " + ((MysqlDatasourceConfig) merged).getPort()); + } + + @Test + void mergeRejectsNonSqlConfig() { + DatasourceConnectionConfig other = new DatasourceConnectionConfig() { + @Override + public DatasourceConnectionConfig mergeWithUpdatedConfig(DatasourceConnectionConfig detailConfig) { + return this; + } + }; + + assertThatThrownBy(() -> stored(STORED_PASSWORD).mergeWithUpdatedConfig(other)) + .isInstanceOfSatisfying(BizException.class, + e -> assertThat(e.getError()).isEqualTo(BizError.INVALID_DATASOURCE_CONFIG_TYPE)); + System.out.println("[SqlBasedConfigMergeTest] non-SQL update rejected"); + } + + @Test + void encryptAndDecryptApplyFunctionToPassword() { + MysqlDatasourceConfig config = stored("pw"); + + assertThat(config.doEncrypt(s -> s + "|enc")).isSameAs(config); + assertThat(config.getPassword()).isEqualTo("pw|enc"); + assertThat(config.doDecrypt(s -> s.substring(0, s.length() - "|enc".length()))).isSameAs(config); + assertThat(config.getPassword()).isEqualTo("pw"); + System.out.println("[SqlBasedConfigMergeTest] password encrypt/decrypt roundtrip"); + } + + private static Function failing(String message) { + return s -> { + throw new IllegalStateException(message); + }; + } + + @Test + void encryptFailureReturnsConfigUnchangedAndDoesNotPropagate() { + MysqlDatasourceConfig config = stored("pw"); + + DatasourceConnectionConfig result = config.doEncrypt(failing(ENCRYPT_FAILURE)); + + assertThat(result).isSameAs(config); + assertThat(config.getPassword()).isEqualTo("pw"); + System.out.println("[SqlBasedConfigMergeTest] encrypt failure swallowed, config unchanged"); + } + + @Test + void decryptFailureReturnsConfigUnchangedAndDoesNotPropagate() { + MysqlDatasourceConfig config = stored("pw"); + + DatasourceConnectionConfig result = config.doDecrypt(failing(DECRYPT_FAILURE)); + + assertThat(result).isSameAs(config); + assertThat(config.getPassword()).isEqualTo("pw"); + System.out.println("[SqlBasedConfigMergeTest] decrypt failure swallowed, config unchanged"); + } + + /** + * Pins the plan section 9 row "password logged when encryption fails (sql config; also ES, Redis, Google Sheets)", + * D-6, fix deferred: the error log of a failed encryption carries the password in clear. A fix of that row changes + * this test on purpose. The wording of the log line is not asserted. + */ + @Test + void encryptFailureLogsThePasswordInClear_pinsPlanSection9PasswordLogged() { + stored("super-secret-pw").doEncrypt(failing(ENCRYPT_FAILURE)); + + assertPasswordInErrorLog("super-secret-pw", ENCRYPT_FAILURE); + } + + /** Same plan section 9 row as above, for the decrypt branch (it logs the still-encrypted value it was given). */ + @Test + void decryptFailureLogsThePasswordValueInClear_pinsPlanSection9PasswordLogged() { + stored("cipher-or-secret-value").doDecrypt(failing(DECRYPT_FAILURE)); + + assertPasswordInErrorLog("cipher-or-secret-value", DECRYPT_FAILURE); + } + + private void assertPasswordInErrorLog(String password, String failureMessage) { + assertThat(appender.list).hasSize(1); + ILoggingEvent event = appender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.ERROR); + assertThat(event.getFormattedMessage()).contains(password); + assertThat(event.getThrowableProxy().getMessage()).isEqualTo(failureMessage); + System.out.println("[SqlBasedConfigMergeTest] error log leaks the password: " + event.getFormattedMessage()); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/sql/StructureParserTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/sql/StructureParserTest.java new file mode 100644 index 0000000000..9f349e0ad2 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/common/sql/StructureParserTest.java @@ -0,0 +1,62 @@ +package org.lowcoder.sdk.plugin.common.sql; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.ResultSet; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.FakeJdbc; +import org.lowcoder.sdk.contract.FakeJdbc.Column; +import org.lowcoder.sdk.models.DatasourceStructure.Table; +import org.lowcoder.sdk.models.DatasourceStructure.TableType; + +/** {@link StructureParser}: the rows of the structure query (one per column) become tables with their columns. */ +public class StructureParserTest { + + private static final List COLUMNS = List.of(new Column("table_name", "VARCHAR"), new Column("column_name", "VARCHAR"), + new Column("data_type", "VARCHAR")); + + private static ResultSet rows(List... rows) { + return FakeJdbc.resultSet(COLUMNS, List.of(rows)); + } + + @Test + public void rowsAreGroupedIntoTablesWithTheirColumnsInRowOrder() throws Exception { + ResultSet resultSet = rows(List.of("ORDERS", "ID", "NUMBER"), List.of("USERS", "NAME", "VARCHAR2"), List.of("ORDERS", "TOTAL", "NUMBER"), + List.of("ORDERS", "CREATED", "DATE")); + + List

    tables = StructureParser.parseColumns(resultSet); + + Map byName = tables.stream().collect(Collectors.toMap(Table::getName, Function.identity())); + System.out.println("[StructureParserTest] " + tables); + assertEquals(2, tables.size()); + Table orders = byName.get("ORDERS"); + assertEquals(TableType.TABLE, orders.getType()); + assertNull(orders.getSchema()); + assertEquals(List.of("ID", "TOTAL", "CREATED"), orders.getColumns().stream().map(c -> c.getName()).toList()); + assertEquals(List.of("NUMBER", "NUMBER", "DATE"), orders.getColumns().stream().map(c -> c.getType()).toList()); + assertEquals(List.of(), orders.getKeys()); + assertEquals(List.of("NAME"), byName.get("USERS").getColumns().stream().map(c -> c.getName()).toList()); + assertNull(orders.getColumns().get(0).getDefaultValue()); + assertNull(orders.getColumns().get(0).getIsAutogenerated()); + } + + @Test + public void anEmptyResultGivesNoTables() throws Exception { + assertEquals(List.of(), StructureParser.parseColumns(rows())); + } + + @Test + public void theQueryNamesTheThreeAliasesTheParserReads() { + for (String alias : new String[] {"as \"table_name\"", "as \"column_name\"", "as \"data_type\""}) { + assertTrue(StructureParser.QUERY_STRUCTURE_SQL.contains(alias), alias); + } + assertTrue(StructureParser.QUERY_STRUCTURE_SQL.contains("hidden_column = 'NO'"), "hidden columns are left out"); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/http/RawHttpBodyContractTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/http/RawHttpBodyContractTest.java new file mode 100644 index 0000000000..3d6f3a85fd --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/http/RawHttpBodyContractTest.java @@ -0,0 +1,98 @@ +package org.lowcoder.sdk.plugin.http; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.QueryResults; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.contract.RepresentativeInput; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.webclient.WebClientBuildHelper; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Group {@code downstream-encode}, its {@code lowcoder-sdk} rows (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.5): for + * a JSON content type, {@code RawHttpHandlerHelper.buildBodyInserter} reads the body text into a node + * ({@code DataUtils.parseJsonBody}, {@code renderMustacheJson} without parameters) and hands that node to the client + * codecs ({@code BodyInserters.fromValue}), which write it. Each body of {@link #BODIES} is POSTed with that inserter by + * a client from {@link WebClientBuildHelper}, as the plugins build theirs, to a local server + * ({@link RecordingHttpServer}); what the server received, the content type and the body as sent, is pinned by the §1.2 + * contract (number lexemes compared) in {@value #REPORT}. + * + *

    Limits: no production class calls {@code RawHttpHandlerHelper} (it is SDK API for plugins), so the request is + * sent by the test's own client; the body bytes are read as UTF-8 JSON. + */ +public class RawHttpBodyContractTest { + + static final String REPORT = "downstream-encode/RawHttpHandlerHelper.jsonBody.json"; + static final String PATH = "/raw"; + static final int OK = 200; + static final Duration TIMEOUT = Duration.ofSeconds(20); + static final Map BODIES = bodies(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + private static RecordingHttpServer server; + + @BeforeAll + public static void startServer() { + server = RecordingHttpServer.start(Map.of(PATH, new Response(OK, Map.of(RecordingHttpServer.CONTENT_TYPE, List.of("text/plain")), + "ok".getBytes(StandardCharsets.UTF_8)))); + } + + @AfterAll + public static void stopServer() { + server.close(); + } + + @BoundarySites({ + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpHandlerHelper.java#RawHttpHandlerHelper.buildBodyInserter#BodyInserters.fromValue#3", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java#DataUtils.parseJsonBody#renderMustacheJson#1"}) + @Test + public void jsonBodyAsSent() { + Map report = new LinkedHashMap<>(); + BODIES.forEach((name, body) -> report.put(name, send(body))); + String actual = ConfigBinding.write(report); + System.out.println("[RawHttpBodyContractTest] " + server.requests().size() + " requests\n" + actual); + GOLDEN.assertJson(REPORT, actual); + } + + /** What the server received for one POST of {@code body}, or the error. */ + private static Object send(String body) { + int before = server.requests().size(); + WebClient client = WebClientBuildHelper.builder().build(); + try { + client.post().uri(server.baseUrl() + PATH).header(RecordingHttpServer.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(RawHttpHandlerHelper.buildBodyInserter(HttpMethod.POST, true, MediaType.APPLICATION_JSON_VALUE, body, List.of())) + .retrieve().toBodilessEntity().block(TIMEOUT); + } catch (RuntimeException e) { + return Map.of(QueryResults.ERROR_KEY, ConfigBinding.errorText(e)); + } + return server.requests().get(before).jsonBodyReport(); + } + + /** + * The §4.6 representative input, number lexemes of every kind with text, a body with a parameter left unrendered, + * and text that is not JSON. + */ + private static Map bodies() { + Map bodies = new LinkedHashMap<>(); + bodies.put("representativeInput", RepresentativeInput.TEXT.trim()); + bodies.put("literals", "{\"c\": [1, 1.50, 1e3, 1E+2, 2147483648, 3000000001, 0.1], " + + "\"t\": \"it's \\\"q\\\" \\\\ žluť\"}"); + bodies.put("unrenderedParameter", "{\"a\": {{missing}}, \"b\": \"{{missing}}\", \"c\": [1.50, 1e3, 2147483648, 9223372036854775808]}"); + bodies.put("notJson", "plain text"); + return bodies; + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/http/RawHttpRequestTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/http/RawHttpRequestTest.java new file mode 100644 index 0000000000..a29775532b --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/http/RawHttpRequestTest.java @@ -0,0 +1,66 @@ +package org.lowcoder.sdk.plugin.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.models.Property; +import org.springframework.http.HttpMethod; + +/** {@link RawHttpRequest}: rendering of the mustache values in the path, the properties and the body, and its validity check. */ +public class RawHttpRequestTest { + + private static final class Request extends RawHttpRequest { + Request(HttpMethod method, String body, String path, List params, List headers, List bodyFormData) { + super(method, body, path, params, headers, bodyFormData); + } + } + + @Test + public void pathPropertiesAndBodyAreRenderedAndTheTypeOfAPropertyIsKept() { + Request request = new Request(HttpMethod.POST, "{\"name\":\"{{who}}\"}", "/users/{{id}}", + List.of(new Property("q-{{k}}", "{{who}}")), List.of(new Property("X-Token", "t-{{id}}", "TEXT")), + List.of(new Property("f", "{{who}}", "FILE"))); + + request.renderParams(Map.of("who", "Ann", "id", 7, "k", "key")); + + System.out.println("[RawHttpRequestTest] body " + request.getBody() + ", path " + request.getPath()); + assertEquals("{\"name\":\"Ann\"}", request.getBody()); + assertEquals("/users/7", request.getPath()); + assertEquals(List.of(new Property("q-key", "Ann", null)), request.getParams()); + assertEquals(List.of(new Property("X-Token", "t-7", "TEXT")), request.getHeaders()); + assertEquals(List.of(new Property("f", "Ann", "FILE")), request.getBodyFormData()); + assertEquals(HttpMethod.POST, request.getHttpMethod()); + } + + @Test + public void missingListsBecomeEmptyListsAndABlankBodyStaysBlank() { + Request request = new Request(HttpMethod.GET, null, "/x", null, null, null); + + request.renderParams(Map.of()); + + assertEquals(List.of(), request.getParams()); + assertEquals(List.of(), request.getHeaders()); + assertEquals(List.of(), request.getBodyFormData()); + System.out.println("[RawHttpRequestTest] null body renders as '" + request.getBody() + "'"); + assertEquals("/x", request.getPath()); + Request emptyBody = new Request(HttpMethod.POST, "", "/x", List.of(), List.of(), List.of()); + emptyBody.renderParams(Map.of()); + System.out.println("[RawHttpRequestTest] empty body renders as '" + emptyBody.getBody() + "'"); + } + + @Test + public void aRequestNeedsAMethodAndANonBlankPath() { + assertFalse(new Request(HttpMethod.GET, null, "/x", null, null, null).hasInvalidData()); + assertTrue(new Request(null, null, "/x", null, null, null).hasInvalidData()); + assertTrue(new Request(HttpMethod.GET, null, null, null, null, null).hasInvalidData()); + assertTrue(new Request(HttpMethod.GET, null, " ", null, null, null).hasInvalidData()); + assertTrue(new Request(HttpMethod.GET, null, "", null, null, null).hasInvalidData()); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/restapi/DataUtilsContentParseErrorTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/restapi/DataUtilsContentParseErrorTest.java new file mode 100644 index 0000000000..ce77b13abc --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/restapi/DataUtilsContentParseErrorTest.java @@ -0,0 +1,57 @@ +package org.lowcoder.sdk.plugin.restapi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Locale; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.util.LocaleUtils; + +/** + * DEFECT pinned (plan section 9 row "CONTENT_PARSE_ERROR is unformattable"; D-6, fix deferred). The text of + * {@code CONTENT_PARSE_ERROR} (locale_en.properties:183 and locale_zh.properties:183) contains + * {@code {data:base64 string, name:string}}, which {@code MessageFormat} reads as a format element. Building any + * {@link PluginException} with that key therefore throws an {@link IllegalArgumentException} from the constructor + * ({@code LocaleUtils.getMessage}), so the three sites that mean to report a bad upload value (DataUtils.java:193 and + * DataUtils.java:196, called from RestApiExecutor.java:189) throw the IAE instead. Escaping the braces in the two bundle + * lines turns every assertion here red. + */ +public class DataUtilsContentParseErrorTest { + + private static final String IAE_TEXT = "can't parse argument number: data:base64 string"; + + private static IllegalArgumentException convert(String value) { + return assertThrows(IllegalArgumentException.class, () -> DataUtils.convertToMultiformFileValue(value, Map.of()), value); + } + + @Test + public void everyPathThatBuildsTheContentParseErrorThrowsAnIllegalArgumentExceptionPinsTheSection9Row() { + // DataUtils.java:196: valid JSON that is neither an object nor an array + IllegalArgumentException scalar = convert("5"); + // DataUtils.java:193: a NullPointerException inside the try (no data key / no name key) and a parse failure are wrapped + IllegalArgumentException noData = convert("{\"name\":\"a.txt\"}"); + IllegalArgumentException noName = convert("{\"data\":\"QQ==\"}"); + IllegalArgumentException notJson = convert("{"); + + for (IllegalArgumentException failure : new IllegalArgumentException[] {scalar, noData, noName, notJson}) { + System.out.println("[DataUtilsContentParseErrorTest] " + failure.getMessage()); + assertEquals(IAE_TEXT, failure.getMessage()); + } + } + + @Test + public void theMessageCannotBeBuiltInAnyLocalePinsTheSection9Row() { + IllegalArgumentException constructor = assertThrows(IllegalArgumentException.class, + () -> new PluginException(PluginCommonError.DATASOURCE_ARGUMENT_ERROR, "CONTENT_PARSE_ERROR")); + IllegalArgumentException english = assertThrows(IllegalArgumentException.class, () -> LocaleUtils.getMessage(Locale.ENGLISH, "CONTENT_PARSE_ERROR")); + IllegalArgumentException chinese = assertThrows(IllegalArgumentException.class, () -> LocaleUtils.getMessage(Locale.CHINESE, "CONTENT_PARSE_ERROR")); + + assertEquals(IAE_TEXT, constructor.getMessage()); + assertEquals(IAE_TEXT, english.getMessage()); + assertEquals(IAE_TEXT, chinese.getMessage()); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/restapi/DataUtilsEmptyJsonBodyTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/restapi/DataUtilsEmptyJsonBodyTest.java new file mode 100644 index 0000000000..4a3b7e0abb --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/restapi/DataUtilsEmptyJsonBodyTest.java @@ -0,0 +1,66 @@ +package org.lowcoder.sdk.plugin.restapi; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.plugin.http.RawHttpHandlerHelper; +import org.lowcoder.sdk.util.JsonUtils; +import org.lowcoder.sdk.webclient.WebClientBuildHelper; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; + +/** + * Observation (probe P1, ruled "behaviour" because nothing differs on the wire for a request-derived empty body). + * {@code DataUtils.parseJsonBody} tests {@code "" == body} (DataUtils.java:95), which is reference equality, and + * {@code RestApiExecutor} (RestApiExecutor.java:128) passes {@code trimToEmpty(queryConfig.getBody())} to + * {@code RawHttpHandlerHelper.buildBodyInserter}. An empty body that Jackson read from the saved query is the interned + * {@code ""} (Jackson returns the literal for an empty string), so it goes out as an empty body exactly like an absent one. + * Only an empty String that is another object (here {@code new String("")}) is rendered as JSON and goes out as {@code {}}: + * a latent difference that no request-derived value produces today. The test sends to a local server (port 0, loopback) and + * reads what arrived. + */ +public class DataUtilsEmptyJsonBodyTest { + + private static final String PATH = "/b"; + + private record Saved(String body) { + } + + private static Request post(String body) { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of(PATH, new Response(200, Map.of(), null)))) { + WebClientBuildHelper.builder().build().post().uri(server.baseUrl() + PATH).contentType(MediaType.APPLICATION_JSON) + .body(RawHttpHandlerHelper.buildBodyInserter(HttpMethod.POST, true, MediaType.APPLICATION_JSON_VALUE, StringUtils.trimToEmpty(body), List.of())) + .retrieve().toBodilessEntity().block(); + return server.requests().get(0); + } + } + + @Test + public void anEmptyBodyFromTheSavedQueryGoesOutEmptyLikeAnAbsentOneAndOnlyANonLiteralEmptyStringBecomesBraces() { + String absent = JsonUtils.fromJson("{}", Saved.class).body(); + String saved = JsonUtils.fromJson("{\"body\":\"\"}", Saved.class).body(); + String nonLiteral = new String(""); + + Request fromAbsent = post(absent); + Request fromSaved = post(saved); + Request fromNonLiteral = post(nonLiteral); + + System.out.println("[DataUtilsEmptyJsonBodyTest] absent '" + fromAbsent.bodyText() + "' " + fromAbsent.header("Content-Length") + + "; saved empty string '" + fromSaved.bodyText() + "' " + fromSaved.header("Content-Length") + + "; new String(\"\") '" + fromNonLiteral.bodyText() + "' " + fromNonLiteral.header("Content-Length")); + assertEquals("", fromAbsent.bodyText()); + assertEquals(List.of("0"), fromAbsent.header("Content-Length")); + assertEquals("", fromSaved.bodyText(), "Jackson hands out the interned empty string"); + assertEquals(List.of("0"), fromSaved.header("Content-Length")); + assertEquals("{}", fromNonLiteral.bodyText(), "latent: reference comparison does not see this empty string"); + assertEquals(List.of("2"), fromNonLiteral.header("Content-Length")); + assertEquals(List.of(MediaType.APPLICATION_JSON_VALUE), fromSaved.header("Content-Type").stream().map(v -> v.split(";")[0]).toList()); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/restapi/DataUtilsTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/restapi/DataUtilsTest.java new file mode 100644 index 0000000000..ebe1cb9ec5 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/restapi/DataUtilsTest.java @@ -0,0 +1,202 @@ +package org.lowcoder.sdk.plugin.restapi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.RecordingHttpServer; +import org.lowcoder.sdk.contract.RecordingHttpServer.Request; +import org.lowcoder.sdk.contract.RecordingHttpServer.Response; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.exception.ServerException; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.models.RestBodyFormFileData; +import org.lowcoder.sdk.webclient.WebClientBuildHelper; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * {@link DataUtils}: the form-urlencoded and multipart bodies the REST plugins send, and the parsing of the upload values of + * the multipart file fields. Bodies are sent to a local server (port 0, loopback) and read back there, so the assertions are + * about what goes on the wire. + */ +public class DataUtilsTest { + + private static final String HELLO_BASE64 = "aGVsbG8="; + + private static Request send(BodyInserter inserter, String contentType) { + try (RecordingHttpServer server = RecordingHttpServer.start(Map.of("/b", new Response(200, Map.of(), null)))) { + WebClient.RequestBodySpec spec = WebClientBuildHelper.builder().build().post().uri(server.baseUrl() + "/b"); + if (contentType != null) { + spec.contentType(MediaType.parseMediaType(contentType)); + } + spec.body(inserter).retrieve().toBodilessEntity().block(); + return server.requests().get(0); + } + } + + /** The client wraps what the body inserter throws in a WebClientRequestException; this returns the wrapped exception of the given type. */ + private static T causeOfType(Class type, org.junit.jupiter.api.function.Executable action) { + Throwable thrown = assertThrows(RuntimeException.class, action); + for (Throwable t = thrown; t != null; t = t.getCause()) { + if (type.isInstance(t)) { + return type.cast(t); + } + } + throw new AssertionError("no " + type.getSimpleName() + " in the cause chain of " + thrown, thrown); + } + + private final DataUtils dataUtils = DataUtils.getInstance(); + + @Test + public void theInstanceIsShared() { + assertSame(DataUtils.getInstance(), DataUtils.getInstance()); + } + + @Test + public void aNullBodyListSendsAnEmptyBody() { + Request request = send(dataUtils.buildBodyInserter(null, MediaType.APPLICATION_FORM_URLENCODED_VALUE, true), MediaType.APPLICATION_FORM_URLENCODED_VALUE); + + assertEquals("", request.bodyText()); + assertEquals(List.of("0"), request.header("Content-Length")); + for (String other : new String[] {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.TEXT_PLAIN_VALUE}) { + Request sent = send(dataUtils.buildBodyInserter(null, other, true), other.equals(MediaType.TEXT_PLAIN_VALUE) ? other : null); + assertEquals("", sent.bodyText(), other); + } + } + + @Test + public void formUrlencodedJoinsTheEntriesAndEncodesTheValuesButNeverTheKeys() { + List body = List.of(new Property("a", "1"), new Property("b c", "x y&z=1"), new Property(null, "dropped")); + + assertEquals("a=1&b c=x+y%26z%3D1", dataUtils.parseFormData(body, true)); + assertEquals("a=1&b c=x y&z=1", dataUtils.parseFormData(body, false), "with encoding switched off the value is sent as it is"); + Request request = send(dataUtils.buildBodyInserter(body, MediaType.APPLICATION_FORM_URLENCODED_VALUE, true), MediaType.APPLICATION_FORM_URLENCODED_VALUE); + System.out.println("[DataUtilsTest] urlencoded body on the wire: '" + request.bodyText() + "'"); + assertEquals("a=1&b c=x+y%26z%3D1", request.bodyText()); + assertEquals(List.of(MediaType.APPLICATION_FORM_URLENCODED_VALUE), request.header("Content-Type").stream().map(v -> v.split(";")[0]).toList()); + } + + @Test + public void anEmptyOrAllKeylessFormListSendsAnEmptyBody() { + assertEquals("", dataUtils.parseFormData(null, true)); + assertEquals("", dataUtils.parseFormData(List.of(), true)); + assertEquals("", dataUtils.parseFormData(List.of(new Property(null, "x")), true), "entries without a key are dropped"); + Request request = send(dataUtils.buildBodyInserter(List.of(new Property(null, "x")), MediaType.APPLICATION_FORM_URLENCODED_VALUE, true), + MediaType.APPLICATION_FORM_URLENCODED_VALUE); + assertEquals("", request.bodyText()); + } + + @Test + public void multipartSendsTextPartsAndFilePartsDecodedFromBase64WithTheirFileName() { + MultipartFormData file = new MultipartFormData(); + file.setName("a.txt"); + file.setData(HELLO_BASE64 + " "); + List body = List.of(new Property("t", "text value"), new RestBodyFormFileData("f", List.of(file)), + new Property(" ", "blank key part is dropped"), new Property("", "empty key part is dropped")); + + Request request = send(dataUtils.buildBodyInserter(body, MediaType.MULTIPART_FORM_DATA_VALUE, true), null); + + String text = request.bodyText(); + System.out.println("[DataUtilsTest] multipart content type " + request.header("Content-Type") + ", body:\n" + text); + assertTrue(request.header("Content-Type").get(0).startsWith("multipart/form-data;boundary=")); + assertTrue(text.contains("Content-Disposition: form-data; name=\"t\"")); + assertTrue(text.contains("text value")); + assertTrue(text.contains("Content-Disposition: form-data; name=\"f\"; filename=\"a.txt\"")); + assertTrue(text.contains("hello"), "the file part carries the decoded bytes, not the base64 text"); + assertTrue(!text.contains(HELLO_BASE64)); + assertTrue(!text.contains("blank key part")); + assertTrue(!text.contains("empty key part")); + } + + @Test + public void anEmptyMultipartListSendsAnEmptyBody() { + Request request = send(dataUtils.parseMultipartFileData(List.of()), null); + + assertEquals("", request.bodyText()); + assertEquals("", send(dataUtils.parseMultipartFileData(null), null).bodyText()); + } + + @Test + public void aFilePartWithoutAFileNameKeepsAnEmptyFileName() { + MultipartFormData file = new MultipartFormData(); + file.setData(HELLO_BASE64); + + Request request = send(dataUtils.parseMultipartFileData(List.of(new RestBodyFormFileData("f", List.of(file)))), null); + + System.out.println("[DataUtilsTest] file part without name:\n" + request.bodyText()); + assertTrue(request.bodyText().contains("name=\"f\""), request.bodyText()); + assertTrue(request.bodyText().contains("hello")); + } + + @Test + public void aFileFieldThatIsNotFileDataAndInvalidBase64AreRejectedWhenTheBodyIsWritten() { + Property notFileData = new Property("f", "x", "FILE"); + MultipartFormData broken = new MultipartFormData(); + broken.setName("a.txt"); + broken.setData("this is not base64!"); + + ServerException wrongType = causeOfType(ServerException.class, () -> send(dataUtils.parseMultipartFileData(List.of(notFileData)), null)); + PluginException badBase64 = causeOfType(PluginException.class, + () -> send(dataUtils.parseMultipartFileData(List.of(new RestBodyFormFileData("f", List.of(broken)))), null)); + + System.out.println("[DataUtilsTest] " + wrongType.getMessage() + " / " + badBase64.getMessageKey() + ": " + badBase64.getMessage()); + assertTrue(wrongType.getMessage().contains("invalid data type for MultipartForm")); + assertEquals("FAIL_TO_PARSE_BASE64_STRING", badBase64.getMessageKey()); + assertEquals(PluginCommonError.DATASOURCE_ARGUMENT_ERROR, badBase64.getError()); + } + + @Test + public void anyOtherContentTypeSendsTheListItself() { + List body = List.of(new Property("a", "1")); + + Request request = send(dataUtils.buildBodyInserter(body, MediaType.TEXT_PLAIN_VALUE, true), MediaType.APPLICATION_JSON_VALUE); + + System.out.println("[DataUtilsTest] other content type body: " + request.bodyText()); + assertTrue(request.bodyText().contains("\"key\":\"a\""), request.bodyText()); + assertTrue(request.bodyText().contains("\"value\":\"1\""), request.bodyText()); + } + + @Test + public void parseJsonBodyKeepsNonStringsAndAnInternedEmptyStringBecomesEmptyBytes() { + Object number = 5; + + assertSame(number, DataUtils.parseJsonBody(number)); + assertEquals(null, DataUtils.parseJsonBody(null)); + assertEquals(0, ((byte[]) DataUtils.parseJsonBody("")).length); + assertEquals("{\"a\":1}", DataUtils.parseJsonBody("{\"a\":1}").toString()); + } + + @Test + public void uploadValuesAreParsedFromAJsonObjectOrAnArrayOfThem() { + List one = DataUtils.convertToMultiformFileValue("{\"data\":\"{{d}}\",\"name\":\"{{n}}\"}", Map.of("d", HELLO_BASE64, "n", "a.txt")); + List many = DataUtils.convertToMultiformFileValue( + "[{\"data\":\"QQ==\",\"name\":\"a\"},5,{\"data\":\"Qg==\",\"name\":\"b\"},\"x\"]", Map.of()); + + assertEquals(1, one.size()); + assertEquals(HELLO_BASE64, one.get(0).getData()); + assertEquals("a.txt", one.get(0).getName()); + assertEquals(List.of("a", "b"), many.stream().map(MultipartFormData::getName).toList(), "elements that are not objects are dropped"); + assertEquals(List.of("QQ==", "Qg=="), many.stream().map(MultipartFormData::getData).toList()); + assertEquals(List.of(), DataUtils.convertToMultiformFileValue("[]", Map.of())); + } + + @Test + public void anUploadValueWhoseDataOrNameIsNotATextIsRejectedWithItsOwnMessage() { + PluginException data = assertThrows(PluginException.class, () -> DataUtils.convertToMultiformFileValue("{\"data\":1,\"name\":\"a\"}", Map.of())); + PluginException name = assertThrows(PluginException.class, () -> DataUtils.convertToMultiformFileValue("{\"data\":\"QQ==\",\"name\":2}", Map.of())); + + assertEquals("MULTIFORM_DATA_IS_NOT_STRING", data.getMessageKey()); + assertEquals("MULTIFORM_NAME_IS_NOT_STRING", name.getMessageKey()); + assertEquals(PluginCommonError.DATASOURCE_ARGUMENT_ERROR, data.getError()); + assertEquals("Resolve upload data failed, data field is not a valid base64 string", data.getMessage()); + assertEquals("Resolve upload data failed, name field is not a valid string", name.getMessage()); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sheet/SheetChangeSetTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sheet/SheetChangeSetTest.java new file mode 100644 index 0000000000..b37e862a5e --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sheet/SheetChangeSetTest.java @@ -0,0 +1,214 @@ +package org.lowcoder.sdk.plugin.sheet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.lowcoder.sdk.plugin.common.constant.Constants.CHANGE_SET_FORM_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.COMP_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.COMP_TYPE_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.RECORD_FORM_KEY; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.sheet.changeset.SheetBulkObjectChangeSet; +import org.lowcoder.sdk.plugin.sheet.changeset.SheetChangeSet; +import org.lowcoder.sdk.plugin.sheet.changeset.SheetChangeSetRow; +import org.lowcoder.sdk.plugin.sheet.changeset.SheetChangeSetRows; +import org.lowcoder.sdk.plugin.sheet.changeset.SheetKeyValuePairChangeSet; +import org.lowcoder.sdk.plugin.sheet.changeset.SheetObjectChangeSet; +import org.lowcoder.sdk.plugin.sqlcommand.changeset.KeyValuePairChangeSet; + +/** + * Parsing and rendering of the change sets of the sheet (Google Sheets) GUI commands: the twins of the + * {@code sqlcommand.changeset} classes. The sqlcommand classes are covered by ChangeSetParseTest; where the twins + * differ, this class says so. + */ +class SheetChangeSetTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static void assertPluginError(Runnable action, String messageKey, Object... args) { + assertThatThrownBy(action::run).isInstanceOfSatisfying(PluginException.class, e -> { + assertThat(e.getError()).isEqualTo(PluginCommonError.INVALID_GUI_SETTINGS); + assertThat(e.getMessageKey()).isEqualTo(messageKey); + assertThat(e.getArgs()).containsExactly(args); + }); + System.out.println("[SheetChangeSetTest] rejected with " + messageKey); + } + + private static Map changeSet(String type, Object comp) { + Map map = new HashMap<>(); + if (type != null) { + map.put(COMP_TYPE_KEY, type); + } + if (comp != null) { + map.put(COMP_KEY, comp); + } + return map; + } + + private static Map detail(Object changeSetValue) { + Map detail = new HashMap<>(); + if (changeSetValue != null) { + detail.put(CHANGE_SET_FORM_KEY, changeSetValue); + } + return detail; + } + + private static Map pair(Object column, Object value) { + Map pair = new HashMap<>(); + pair.put("column", column); + pair.put("value", value); + return pair; + } + + private static JsonNode json(String text) { + try { + return MAPPER.readTree(text); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new IllegalArgumentException(text, e); + } + } + + private static Object valueOf(SheetChangeSetRow row, String column) { + return row.getItem(column).renderedValue(); + } + + static Stream parseErrorCases() { + return Stream.of( + Arguments.of("change set missing", detail(null), "GUI_OPERATION_DATA_EMPTY", new Object[0]), + Arguments.of("change set not a map", detail("text"), "GUI_OPERATION_DATA_EMPTY", new Object[0]), + Arguments.of("change set an empty map", detail(new HashMap<>()), "GUI_OPERATION_DATA_EMPTY", new Object[0]), + Arguments.of("type missing", detail(changeSet(null, List.of())), "GUI_OPERATION_DATA_TYPE_ERROR", new Object[0]), + Arguments.of("type blank", detail(changeSet(" ", List.of())), "GUI_OPERATION_DATA_TYPE_ERROR", new Object[0]), + Arguments.of("type unknown (reported upper-cased)", detail(changeSet("foo", List.of())), "GUI_INVALID_DATA_TYPE", new Object[]{"FOO"}), + Arguments.of("object type with a number", detail(changeSet("OBJECT", 5)), "GUI_INVALID_PARAM", new Object[]{"Integer"})); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("parseErrorCases") + void sheetParseChangeSetRejectsMalformedChangeSets(String label, Map detail, String messageKey, Object[] args) { + assertPluginError(() -> SheetChangeSet.parseChangeSet(detail), messageKey, args); + } + + @Test + void sheetParseChangeSetBuildsTheTypeNamedInTheDetailIgnoringCase() { + assertThat(SheetChangeSet.parseChangeSet(detail(changeSet("key_value_pairs", List.of(pair("a", "1")))))).isInstanceOf(SheetKeyValuePairChangeSet.class); + assertThat(SheetChangeSet.parseChangeSet(detail(changeSet("object", "{\"a\":1}")))).isInstanceOf(SheetObjectChangeSet.class); + System.out.println("[SheetChangeSetTest] key_value_pairs -> SheetKeyValuePairChangeSet, object -> SheetObjectChangeSet"); + } + + /** + * Pins the twin of the plan section 9 row "ChangeSet.parseChangeSet: an object-type change set without data throws a + * NullPointerException instead of a PluginException" (D-6, fix deferred): the sheet class has the same + * {@code data.getClass()} and is named in that row. A fix changes this test on purpose. + */ + @Test + void sheetParseChangeSetObjectTypeWithoutDataThrowsNullPointerException() { + assertThatThrownBy(() -> SheetChangeSet.parseChangeSet(detail(changeSet("OBJECT", null)))).isInstanceOf(NullPointerException.class); + System.out.println("[SheetChangeSetTest] object type without data -> NullPointerException (plan section 9 U5 row twin, pinned)"); + } + + @Test + void sheetKeyValueChangeSetRendersValuesThroughMustacheJsonAndKeepsTheLastDuplicate() { + List comp = new ArrayList<>(List.of(pair("n", "{{n}}"), pair("text", "plain"), pair("list", "[1, {{n}}]"), pair("n", "{{m}}"))); + + SheetChangeSetRow row = new SheetKeyValuePairChangeSet(comp).render(Map.of("n", 5, "m", 6)); + + assertThat(row.getColumns()).containsExactlyInAnyOrder("n", "text", "list"); + assertThat(valueOf(row, "n")).as("the last of two entries for a column wins, rendered").isEqualTo(6); + assertThat(valueOf(row, "text")).isEqualTo("plain"); + assertThat(valueOf(row, "list")).isEqualTo(List.of(1, 5)); + assertThat(row.isEmpty()).isFalse(); + assertThat(row.iterator().hasNext()).isTrue(); + System.out.println("[SheetChangeSetTest] rendered row: n=" + valueOf(row, "n") + " text=" + valueOf(row, "text") + " list=" + valueOf(row, "list")); + } + + @Test + void sheetKeyValueChangeSetRejectsNonMapElementsAndBlankColumns() { + assertPluginError(() -> new SheetKeyValuePairChangeSet(List.of("text")), "GUI_CHANGE_SET_TYPE_ERROR", "String"); + assertPluginError(() -> new SheetKeyValuePairChangeSet(List.of(pair(" ", "1"))), "GUI_CHANGE_SET_FIELD_EMPTY"); + assertPluginError(() -> new SheetKeyValuePairChangeSet(List.of(pair(null, "1"))), "GUI_CHANGE_SET_FIELD_EMPTY"); + } + + /** + * Pins the plan section 9 row "KeyValuePairChangeSet / SheetKeyValuePairChangeSet throw a NullPointerException for + * an entry without a value" (D-6, fix deferred): {@code Collectors.toMap} rejects the null value, so a GUI + * command that sets a column to NULL fails when the change set is built. A fix changes this test on purpose. + */ + @Test + void keyValueChangeSetsThrowNullPointerExceptionForAnEntryWithoutValue() { + List withoutValue = List.of(pair("a", null)); + + assertThatThrownBy(() -> new SheetKeyValuePairChangeSet(withoutValue)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new KeyValuePairChangeSet(withoutValue)).isInstanceOf(NullPointerException.class); + System.out.println("[SheetChangeSetTest] entry without value -> NullPointerException in both classes (plan section 9 row, pinned)"); + } + + /** + * Pins today's behaviour, not a defect: the twins differ. The sheet change set accepts a {@code comp} that is not a + * list as an empty change set, while the sqlcommand {@link KeyValuePairChangeSet} rejects it with GUI_INVALID_PARAM. + */ + @Test + void sheetKeyValueChangeSetAcceptsANonListCompAsEmptyWhereTheSqlTwinRejectsIt() { + SheetChangeSetRow row = new SheetKeyValuePairChangeSet("not a list").render(Map.of()); + + assertThat(row.isEmpty()).isTrue(); + assertThat(row.getColumns()).isEmpty(); + assertThatThrownBy(() -> new KeyValuePairChangeSet("not a list")).isInstanceOfSatisfying(PluginException.class, + e -> assertThat(e.getMessageKey()).isEqualTo("GUI_INVALID_PARAM")); + System.out.println("[SheetChangeSetTest] sheet twin: non-list comp -> empty row; sql twin: GUI_INVALID_PARAM"); + } + + @Test + void sheetBulkObjectChangeSetParsesAndRendersRows() { + Map detail = new HashMap<>(); + detail.put(RECORD_FORM_KEY, "[{\"a\":\"{{v}}\",\"b\":1},{\"a\":2,\"b\":3}]"); + + SheetChangeSetRows rows = SheetBulkObjectChangeSet.parseBulkRecords(detail).render(Map.of("v", "x")); + + assertThat(rows.isEmpty()).isFalse(); + List list = new ArrayList<>(); + rows.forEach(list::add); + assertThat(list).hasSize(2); + assertThat(valueOf(list.get(0), "a")).isEqualTo("x"); + assertThat(valueOf(list.get(1), "b")).isEqualTo(3); + assertThat(list.get(0).getColumns()).containsExactlyInAnyOrder("a", "b"); + assertThat(new SheetBulkObjectChangeSet("[]").render(Map.of()).isEmpty()).isTrue(); + } + + @Test + void sheetBulkObjectChangeSetRejectsMissingRecordsAndMalformedJson() { + Map wrongType = new HashMap<>(); + wrongType.put(RECORD_FORM_KEY, 5); + assertPluginError(() -> SheetBulkObjectChangeSet.parseBulkRecords(wrongType), "GUI_CHANGE_SET_EMPTY"); + assertPluginError(() -> SheetBulkObjectChangeSet.parseBulkRecords(Map.of()), "GUI_CHANGE_SET_EMPTY"); + assertPluginError(() -> new SheetBulkObjectChangeSet("{\"a\":1}").render(Map.of()), "GUI_INVALID_JSON_ARRAY_FORMAT"); + // a request map whose lookup fails reaches the catch around the mustache rendering + Map failing = new HashMap<>() { + @Override + public Object get(Object key) { + throw new IllegalStateException("lookup failed"); + } + }; + assertPluginError(() -> new SheetBulkObjectChangeSet("{{x}}").render(failing), "GUI_INVALID_JSON_ARRAY_FORMAT"); + } + + @Test + void sheetRowsAndRowRejectNonArrayAndNonObjectJson() { + assertPluginError(() -> SheetChangeSetRows.fromJsonNode(json("{\"a\":1}")), "GUI_INVALID_JSON_ARRAY_FORMAT"); + assertPluginError(() -> SheetChangeSetRows.fromJsonNode(json("[1,2]")), "GUI_INVALID_JSON_MAP_TYPE"); + assertPluginError(() -> SheetChangeSetRow.fromJsonNode(json("\"text\"")), "GUI_INVALID_JSON_MAP_TYPE"); + assertThat(SheetChangeSetRow.fromJsonNode(json("{\"a\":1,\"b\":null}")).getItem("b").renderedValue()).isNull(); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/GuiSqlCommandTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/GuiSqlCommandTest.java new file mode 100644 index 0000000000..541f81b64f --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/GuiSqlCommandTest.java @@ -0,0 +1,68 @@ +package org.lowcoder.sdk.plugin.sqlcommand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.lowcoder.sdk.plugin.common.constant.Constants.ALLOW_MULTI_MODIFY_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.TABLE_KEY; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand.GuiSqlCommandRenderResult; + +/** The shared parsing helpers and defaults of the {@link GuiSqlCommand} interface. */ +class GuiSqlCommandTest { + + private static final GuiSqlCommand MINIMAL = new GuiSqlCommand() { + @Override + public GuiSqlCommandRenderResult render(Map requestMap) { + return new GuiSqlCommandRenderResult("select 1", List.of()); + } + + @Override + public boolean isInsertCommand() { + return false; + } + + @Override + public Set extractMustacheKeys() { + return Set.of(); + } + }; + + @Test + void parseTableReturnsTheTableAndRejectsMissingOrBlank() { + assertThat(GuiSqlCommand.parseTable(Map.of(TABLE_KEY, "users"))).isEqualTo("users"); + for (Map detail : List.of(Map.of(), Map.of(TABLE_KEY, " "))) { + assertThatThrownBy(() -> GuiSqlCommand.parseTable(detail)).isInstanceOfSatisfying(PluginException.class, e -> { + assertThat(e.getError()).isEqualTo(PluginCommonError.INVALID_GUI_SETTINGS); + assertThat(e.getMessageKey()).isEqualTo("GUI_FIELD_EMPTY"); + }); + } + } + + @Test + void parseAllowMultiModifyDefaultsToFalseAndReadsBooleansAndBooleanStrings() { + assertThat(GuiSqlCommand.parseAllowMultiModify(Map.of())).as("absent means one row only").isFalse(); + assertThat(GuiSqlCommand.parseAllowMultiModify(Map.of(ALLOW_MULTI_MODIFY_KEY, true))).isTrue(); + assertThat(GuiSqlCommand.parseAllowMultiModify(Map.of(ALLOW_MULTI_MODIFY_KEY, "true"))).isTrue(); + assertThat(GuiSqlCommand.parseAllowMultiModify(Map.of(ALLOW_MULTI_MODIFY_KEY, false))).isFalse(); + } + + @Test + void defaultsRenderWithBindParametersAndRefuseToEscapeWithoutADialect() { + assertThat(MINIMAL.isRenderWithRawSql()).isFalse(); + assertThatThrownBy(() -> MINIMAL.escapeStrFunc().escape("x")).isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void renderResultExposesSqlAndBindParams() { + GuiSqlCommandRenderResult result = new GuiSqlCommandRenderResult("select ?", List.of(1)); + + assertThat(result.sql()).isEqualTo("select ?"); + assertThat(result.bindParams()).containsExactly(1); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ChangeSetParseTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ChangeSetParseTest.java new file mode 100644 index 0000000000..44d70a6610 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ChangeSetParseTest.java @@ -0,0 +1,228 @@ +package org.lowcoder.sdk.plugin.sqlcommand.changeset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.lowcoder.sdk.plugin.common.constant.Constants.CHANGE_SET_FORM_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.COMP_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.COMP_TYPE_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.PRIMARY_KEY_FORM_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.RECORD_FORM_KEY; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; + +/** + * Parsing and rendering of the change sets behind the GUI insert/update/upsert commands. The non-list {@code comp} + * message of {@link KeyValuePairChangeSet} is pinned by KeyValuePairChangeSetErrorMessageContractTest and not repeated. + */ +class ChangeSetParseTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static void assertPluginError(Runnable action, String messageKey, Object... args) { + assertThatThrownBy(action::run).isInstanceOfSatisfying(PluginException.class, e -> { + assertThat(e.getError()).isEqualTo(PluginCommonError.INVALID_GUI_SETTINGS); + assertThat(e.getMessageKey()).isEqualTo(messageKey); + assertThat(e.getArgs()).containsExactly(args); + }); + System.out.println("[ChangeSetParseTest] rejected with " + messageKey); + } + + /** a request map whose lookups fail, to reach the catch blocks around the mustache rendering */ + private static Map failingRequestMap() { + return new HashMap<>() { + @Override + public Object get(Object key) { + throw new IllegalStateException("lookup failed"); + } + + @Override + public boolean containsKey(Object key) { + throw new IllegalStateException("lookup failed"); + } + }; + } + + private static Map changeSet(String type, Object comp) { + Map map = new HashMap<>(); + if (type != null) { + map.put(COMP_TYPE_KEY, type); + } + if (comp != null) { + map.put(COMP_KEY, comp); + } + return map; + } + + private static Map pair(Object column, Object value) { + Map pair = new HashMap<>(); + pair.put("column", column); + pair.put("value", value); + return pair; + } + + static Stream parseErrorCases() { + Map missingKey = new HashMap<>(); + Map notAMap = new HashMap<>(); + notAMap.put(CHANGE_SET_FORM_KEY, "text"); + Map emptyMap = new HashMap<>(); + emptyMap.put(CHANGE_SET_FORM_KEY, new HashMap<>()); + Map noType = new HashMap<>(); + noType.put(CHANGE_SET_FORM_KEY, changeSet(null, List.of())); + Map blankType = new HashMap<>(); + blankType.put(CHANGE_SET_FORM_KEY, changeSet(" ", List.of())); + Map unknownType = new HashMap<>(); + unknownType.put(CHANGE_SET_FORM_KEY, changeSet("foo", List.of())); + Map objectWithNumber = new HashMap<>(); + objectWithNumber.put(CHANGE_SET_FORM_KEY, changeSet("OBJECT", 5)); + return Stream.of( + Arguments.of("change set missing", missingKey, "GUI_OPERATION_DATA_EMPTY", new Object[0]), + Arguments.of("change set not a map", notAMap, "GUI_OPERATION_DATA_EMPTY", new Object[0]), + Arguments.of("change set an empty map", emptyMap, "GUI_OPERATION_DATA_EMPTY", new Object[0]), + Arguments.of("type missing", noType, "GUI_OPERATION_DATA_TYPE_ERROR", new Object[0]), + Arguments.of("type blank", blankType, "GUI_OPERATION_DATA_TYPE_ERROR", new Object[0]), + Arguments.of("type unknown (reported upper-cased)", unknownType, "GUI_INVALID_DATA_TYPE", new Object[]{"FOO"}), + Arguments.of("object type with a number", objectWithNumber, "GUI_INVALID_PARAM", new Object[]{"Integer"})); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("parseErrorCases") + void parseChangeSetRejectsMalformedChangeSets(String label, Map detail, String messageKey, Object[] args) { + assertPluginError(() -> ChangeSet.parseChangeSet(detail), messageKey, args); + } + + @Test + void parseChangeSetBuildsTheTypeNamedInTheDetailIgnoringCaseAndHonoursTheKeyName() { + Map detail = new HashMap<>(); + detail.put(CHANGE_SET_FORM_KEY, changeSet("key_value_pairs", List.of(pair("a", 1)))); + detail.put("other", changeSet("object", "{\"a\":1}")); + + assertThat(ChangeSet.parseChangeSet(detail)).isInstanceOf(KeyValuePairChangeSet.class); + assertThat(ChangeSet.parseChangeSet(detail, "other")).isInstanceOf(ObjectChangeSet.class); + System.out.println("[ChangeSetParseTest] key_value_pairs -> KeyValuePairChangeSet, object -> ObjectChangeSet"); + } + + /** + * Pins the plan section 9 row "ChangeSet.parseChangeSet: an object-type change set without data throws a + * NullPointerException instead of a PluginException" (D-6, fix deferred, source: lane L4, L4-3 plan). A fix + * changes this test on purpose. + */ + @Test + void parseChangeSetObjectTypeWithoutDataThrowsNullPointerException() { + Map detail = new HashMap<>(); + detail.put(CHANGE_SET_FORM_KEY, changeSet("OBJECT", null)); + + assertThatThrownBy(() -> ChangeSet.parseChangeSet(detail)).isInstanceOf(NullPointerException.class); + System.out.println("[ChangeSetParseTest] object type without data -> NullPointerException (plan section 9 row, pinned)"); + } + + @Test + void keyValuePairChangeSetRejectsNonMapElementsAndBlankColumns() { + assertPluginError(() -> new KeyValuePairChangeSet(List.of("text")), "GUI_CHANGE_SET_TYPE_ERROR", "String"); + assertPluginError(() -> new KeyValuePairChangeSet(List.of(pair(" ", 1))), "GUI_CHANGE_SET_FIELD_EMPTY"); + assertPluginError(() -> new KeyValuePairChangeSet(List.of(pair(null, 1))), "GUI_CHANGE_SET_FIELD_EMPTY"); + } + + @Test + void keyValuePairChangeSetKeepsTheLastValueOfADuplicateColumnAndTheColumnOrder() { + List comp = new ArrayList<>(List.of(pair("a", 1), pair("b", 2), pair("a", 3))); + + ChangeSetRow row = new KeyValuePairChangeSet(comp).render(Map.of()); + + assertThat(row.getColumns()).containsExactly("a", "b"); + assertThat(row.getItem("a").guiSqlValue().getValue()).isEqualTo(3); + } + + @Test + void keyValuePairChangeSetMustacheKeysComeFromStringValuesOnly() { + KeyValuePairChangeSet changeSet = new KeyValuePairChangeSet(List.of(pair("a", "{{x}}"), pair("b", 5), pair("c", "p {{y}}"))); + + assertThat(changeSet.extractMustacheKeys()).containsExactlyInAnyOrder("{{x}}", "{{y}}"); + } + + @Test + void objectChangeSetRendersAJsonObjectAndRejectsOtherJson() { + ChangeSetRow row = new ObjectChangeSet("{\"a\":\"{{v}}\",\"b\":2}").render(Map.of("v", "x")); + + assertThat(row.getColumns()).containsExactly("a", "b"); + assertThat(row.getItem("a").guiSqlValue().getValue()).isEqualTo("x"); + assertThat(new ObjectChangeSet("{\"a\":\"{{v}}\"}").extractMustacheKeys()).containsExactly("{{v}}"); + assertPluginError(() -> new ObjectChangeSet("[1]").render(Map.of()), "GUI_INVALID_JSON_MAP_TYPE"); + // malformed text is read leniently as a text node and then rejected by the row factory, with the same key + assertPluginError(() -> new ObjectChangeSet("not json").render(Map.of()), "GUI_INVALID_JSON_MAP_TYPE"); + } + + @Test + void aRenderingFailureOfAnObjectOrBulkChangeSetIsReportedAsInvalidJson() { + assertPluginError(() -> new ObjectChangeSet("{{x}}").render(failingRequestMap()), "GUI_INVALID_JSON_MAP_TYPE"); + assertPluginError(() -> new BulkObjectChangeSet("{{x}}").render(failingRequestMap()), "GUI_INVALID_JSON_ARRAY_FORMAT"); + } + + @Test + void bulkObjectChangeSetRendersRowsAndRejectsNonArrayJson() { + ChangeSetRows rows = new BulkObjectChangeSet("[{\"a\":\"{{v}}\"},{\"a\":2}]").render(Map.of("v", "x")); + + assertThat(rows.size()).isEqualTo(2); + assertThat(rows.getColumns()).containsExactly("a"); + assertThat(rows.stream().map(r -> r.getItem("a").guiSqlValue().getValue())).containsExactly("x", 2); + assertPluginError(() -> new BulkObjectChangeSet("{\"a\":1}").render(Map.of()), "GUI_INVALID_JSON_ARRAY_FORMAT"); + assertPluginError(() -> new BulkObjectChangeSet("not json").render(Map.of()), "GUI_INVALID_JSON_ARRAY_FORMAT"); + assertThat(new BulkObjectChangeSet("{{rows}}").extractMustacheKeys()).containsExactly("{{rows}}"); + } + + @Test + void parseBulkRecordsAndPrimaryKeyRequireStrings() { + Map detail = new HashMap<>(); + detail.put(RECORD_FORM_KEY, "[]"); + detail.put(PRIMARY_KEY_FORM_KEY, "id"); + assertThat(BulkObjectChangeSet.parseBulkRecords(detail)).isEqualTo("[]"); + assertThat(BulkObjectChangeSet.parsePrimaryKey(detail)).isEqualTo("id"); + + Map wrongTypes = new HashMap<>(); + wrongTypes.put(RECORD_FORM_KEY, 5); + wrongTypes.put(PRIMARY_KEY_FORM_KEY, List.of()); + assertPluginError(() -> BulkObjectChangeSet.parseBulkRecords(wrongTypes), "GUI_CHANGE_SET_EMPTY"); + assertPluginError(() -> BulkObjectChangeSet.parsePrimaryKey(wrongTypes), "GUI_PRIMARY_KEY_EMPTY"); + assertPluginError(() -> BulkObjectChangeSet.parseBulkRecords(Map.of()), "GUI_CHANGE_SET_EMPTY"); + assertPluginError(() -> BulkObjectChangeSet.parsePrimaryKey(Map.of()), "GUI_PRIMARY_KEY_EMPTY"); + } + + private static JsonNode json(String text) { + try { + return MAPPER.readTree(text); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new IllegalArgumentException(text, e); + } + } + + private static ChangeSetRows rows(String json) { + JsonNode node = json(json); + return ChangeSetRows.fromJsonNode(node); + } + + @Test + void changeSetRowsCheckColumnAlignmentByNamesAndCount() { + assertThat(rows("[{\"a\":1,\"b\":2},{\"b\":3,\"a\":4}]").checkRowColumnAligned()).as("same names, other order").isTrue(); + assertThat(rows("[{\"a\":1},{\"b\":2}]").checkRowColumnAligned()).as("same count, other names").isFalse(); + assertThat(rows("[{\"a\":1,\"b\":2},{\"a\":3}]").checkRowColumnAligned()).as("other count").isFalse(); + assertThat(rows("[]").isEmpty()).isTrue(); + } + + @Test + void changeSetRowsAndRowsRejectNonArrayAndNonObjectNodes() { + assertPluginError(() -> ChangeSetRows.fromJsonNode(json("{\"a\":1}")), "GUI_INVALID_JSON_ARRAY_FORMAT"); + assertPluginError(() -> ChangeSetRows.fromJsonNode(json("[1,2]")), "GUI_INVALID_JSON_MAP_TYPE"); + assertPluginError(() -> new ChangeSetRow(json("\"text\"")), "GUI_INVALID_JSON_MAP_TYPE"); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/KeyValuePairChangeSetErrorMessageContractTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/KeyValuePairChangeSetErrorMessageContractTest.java new file mode 100644 index 0000000000..2bf6ebebd1 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/KeyValuePairChangeSetErrorMessageContractTest.java @@ -0,0 +1,72 @@ +package org.lowcoder.sdk.plugin.sqlcommand.changeset; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.exception.PluginException; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.fail; + +/** + * The {@code lowcoder-sdk} row of group {@code error-message-json} (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.5): + * a key-value change set whose {@code comp} is not a list fails with {@code GUI_INVALID_PARAM}, and the production + * mapper writes the {@code comp} it got ({@code toJson}) into the message. For one {@code comp} of every non-list JSON + * type the exact text is pinned, one line per value ({@link GoldenJson#assertText}): the message argument and + * the English message the user sees, in {@value #FIXTURE}. + * + *

    Limits: the localized messages of other locales are not pinned; they take the same argument. + */ +public class KeyValuePairChangeSetErrorMessageContractTest { + + static final String FIXTURE = "error-message-json/KeyValuePairChangeSet.messages.txt"; + static final String SEPARATOR = "\t"; + static final String NEWLINE = "\n"; + static final String EXPECTED_MESSAGE_KEY = "GUI_INVALID_PARAM"; + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites("lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/KeyValuePairChangeSet.java#KeyValuePairChangeSet.parseColumnValueMap#toJson#1") + @Test + public void nonListCompMessage() { + StringBuilder text = new StringBuilder(); + comps().forEach((name, comp) -> { + try { + new KeyValuePairChangeSet(comp); + fail("a " + name + " comp must not make a change set"); + } catch (PluginException e) { + if (!EXPECTED_MESSAGE_KEY.equals(e.getMessageKey())) { + throw new AssertionError("unexpected message key for " + name + ": " + e.getMessageKey(), e); + } + text.append(name).append(SEPARATOR).append(Arrays.toString(e.getArgs())) + .append(SEPARATOR).append(e.getMessage()).append(NEWLINE); + } + }); + System.out.println("[KeyValuePairChangeSetErrorMessageContractTest] " + FIXTURE + "\n" + text); + GOLDEN.assertText(FIXTURE, text.toString()); + } + + /** One {@code comp} of every non-list JSON type, by name. */ + private static Map comps() { + Map comps = new LinkedHashMap<>(); + comps.put("int", 1); + comps.put("long", 3_000_000_001L); + comps.put("decimal", 1.5); + comps.put("exponent", 1.0e20); + comps.put("bigDecimal", new BigDecimal("1.50")); + comps.put("bool", true); + comps.put("null", null); + comps.put("string", "it's \"q\" \\ žluť"); + Map map = new LinkedHashMap<>(); + map.put("zeta", "it's"); + map.put("decimal", new BigDecimal("1.50")); + map.put("nested", Arrays.asList(1, null, List.of(true))); + comps.put("map", map); + return comps; + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/GuiSqlCommandRenderTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/GuiSqlCommandRenderTest.java new file mode 100644 index 0000000000..88100b2093 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/GuiSqlCommandRenderTest.java @@ -0,0 +1,425 @@ +package org.lowcoder.sdk.plugin.sqlcommand.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.lowcoder.sdk.plugin.common.constant.Constants.ALLOW_MULTI_MODIFY_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.CHANGE_SET_FORM_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.CHANGE_SET_TYPE_KEY_VALUE_PAIRS; +import static org.lowcoder.sdk.plugin.common.constant.Constants.CHANGE_SET_TYPE_OBJECT; +import static org.lowcoder.sdk.plugin.common.constant.Constants.COMP_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.COMP_TYPE_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.INSERT_CHANGE_SET_FORM_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.PRIMARY_KEY_FORM_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.RECORD_FORM_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.TABLE_KEY; +import static org.lowcoder.sdk.plugin.common.constant.Constants.UPDATE_CHANGE_SET_FORM_KEY; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand.GuiSqlCommandRenderResult; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlBulkInsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlBulkUpdateCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlDeleteCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlInsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlUpdateCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlUpsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresBulkInsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresBulkUpdateCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresDeleteCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresInsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresUpdateCommand; + +/** + * Rendering of the GUI SQL commands (insert, update, delete, bulk insert/update, upsert) for MySQL and PostgreSQL, + * built through the public {@code from(commandDetail)} factories like the query executor does. Complements + * PostgresCommandTest and MysqlGuiCommandTest, which render happy paths only: this class covers the error branches, + * the multi-modify safety flag, the single-row guard result and the dialect differences. + */ +class GuiSqlCommandRenderTest { + + private static final String TABLE = "users"; + private static final String TEMPLATE_TABLE = "{{tbl}}"; + private static final Map TABLE_PARAM = Map.of("tbl", TABLE); + private static final Map NO_PARAMS = Map.of(); + /** the random-tag dollar quoting of {@code SqlGuiUtils.POSTGRES_SQL_STR_ESCAPE}; {@code group} is the number of the capture group holding the tag */ + private static String pgQuoted(String value, int group) { + return "\\$([A-Za-z]{7})\\$" + value + "\\$\\" + group + "\\$"; + } + + // ---- command detail builders ---- + + private static Map keyValueChangeSet(Object... columnValue) { + List comp = new ArrayList<>(); + for (int i = 0; i < columnValue.length; i += 2) { + Map item = new HashMap<>(); + item.put("column", columnValue[i]); + item.put("value", columnValue[i + 1]); + comp.add(item); + } + Map changeSet = new HashMap<>(); + changeSet.put(COMP_TYPE_KEY, CHANGE_SET_TYPE_KEY_VALUE_PAIRS); + changeSet.put(COMP_KEY, comp); + return changeSet; + } + + private static Map objectChangeSet(String json) { + Map changeSet = new HashMap<>(); + changeSet.put(COMP_TYPE_KEY, CHANGE_SET_TYPE_OBJECT); + changeSet.put(COMP_KEY, json); + return changeSet; + } + + private static Map filter(String column, String condition, Object value) { + Map map = new HashMap<>(); + map.put("column", column); + map.put("condition", condition); + map.put("value", value); + return map; + } + + private static Map detail(String table, boolean allowMultiModify, List> filters, Map changeSet) { + Map detail = new HashMap<>(); + detail.put(TABLE_KEY, table); + detail.put(ALLOW_MULTI_MODIFY_KEY, allowMultiModify); + if (filters != null) { + detail.put("filterBy", filters); + } + if (changeSet != null) { + detail.put(CHANGE_SET_FORM_KEY, changeSet); + } + return detail; + } + + private static Map bulkDetail(String records, String primaryKey) { + Map detail = new HashMap<>(); + detail.put(TABLE_KEY, TABLE); + detail.put(RECORD_FORM_KEY, records); + if (primaryKey != null) { + detail.put(PRIMARY_KEY_FORM_KEY, primaryKey); + } + return detail; + } + + private static GuiSqlCommandRenderResult print(String label, GuiSqlCommandRenderResult result) { + System.out.println("[GuiSqlCommandRenderTest] " + label + " -> [" + result.sql().replace("\n", "\\n") + "] " + result.bindParams() + + (result instanceof UpdateOrDeleteSingleCommandRenderResult single ? " guard=[" + single.getSelectQuery() + "] " + single.getSelectBindParams() : "")); + return result; + } + + private static void assertPluginError(Runnable action, PluginCommonError error, String messageKey, Object... args) { + assertThatThrownBy(action::run).isInstanceOfSatisfying(PluginException.class, e -> { + assertThat(e.getError()).isEqualTo(error); + assertThat(e.getMessageKey()).isEqualTo(messageKey); + assertThat(e.getArgs()).containsExactly(args); + }); + } + + // ---- delete ---- + + @Test + void postgresDeleteWithoutFilterRespectsTheMultiModifyFlag() { + GuiSqlCommandRenderResult guarded = print("pg delete, no filter, multi off", + PostgresDeleteCommand.from(detail(TABLE, false, List.of(), null)).render(NO_PARAMS)); + assertThat(guarded).isInstanceOf(UpdateOrDeleteSingleCommandRenderResult.class); + assertThat(guarded.sql()).isEqualTo("delete from users"); + assertThat(((UpdateOrDeleteSingleCommandRenderResult) guarded).getSelectQuery()).isEqualTo("select count(1) as count from users"); + + GuiSqlCommandRenderResult plain = print("pg delete, no filter, multi on", + PostgresDeleteCommand.from(detail(TABLE, true, List.of(), null)).render(NO_PARAMS)); + assertThat(plain).isNotInstanceOf(UpdateOrDeleteSingleCommandRenderResult.class); + assertThat(plain.sql()).isEqualTo("delete from users"); + assertThat(plain.bindParams()).isEmpty(); + } + + @Test + void postgresDeleteWithFilterBuildsTheGuardFromTheSameWhereAndRendersTheTable() { + List> filters = List.of(filter("id", "=", 5)); + + GuiSqlCommandRenderResult guarded = print("pg delete, filter, multi off", + PostgresDeleteCommand.from(detail(TEMPLATE_TABLE, false, filters, null)).render(TABLE_PARAM)); + assertThat(guarded).isInstanceOf(UpdateOrDeleteSingleCommandRenderResult.class); + assertThat(guarded.sql()).isEqualTo("delete from users where \"id\" = 5"); + UpdateOrDeleteSingleCommandRenderResult single = (UpdateOrDeleteSingleCommandRenderResult) guarded; + assertThat(single.getSelectQuery()).isEqualTo("select count(1) as count from users where \"id\" = 5"); + + GuiSqlCommandRenderResult plain = print("pg delete, filter, multi on", + PostgresDeleteCommand.from(detail(TEMPLATE_TABLE, true, filters, null)).render(TABLE_PARAM)); + assertThat(plain).isNotInstanceOf(UpdateOrDeleteSingleCommandRenderResult.class); + assertThat(plain.sql()).isEqualTo("delete from users where \"id\" = 5"); + } + + /** + * Pins the plan section 9 row "PostgresDeleteCommand.render without filters and without multi-modify builds the + * guard's count query from the unrendered table" (D-6, fix deferred, source: lane L4, L4-3 plan): the delete is + * rendered ({@code users}) but the count query keeps the mustache ({@code {{tbl}}}). A fix changes this test on purpose. + */ + @Test + void postgresDeleteWithoutFilterBuildsTheCountQueryFromTheUnrenderedTable() { + UpdateOrDeleteSingleCommandRenderResult result = (UpdateOrDeleteSingleCommandRenderResult) print("pg delete, mustache table, no filter", + PostgresDeleteCommand.from(detail(TEMPLATE_TABLE, false, List.of(), null)).render(TABLE_PARAM)); + + assertThat(result.sql()).isEqualTo("delete from users"); + assertThat(result.getSelectQuery()).as("today's behaviour: the table is not rendered").isEqualTo("select count(1) as count from {{tbl}}"); + } + + @Test + void mysqlDeleteAddsLimitOneUnlessMultiModifyIsOn() { + List> filters = List.of(filter("id", "=", 5)); + + assertThat(print("mysql delete, filter, multi off", MysqlDeleteCommand.from(detail(TEMPLATE_TABLE, false, filters, null)).render(TABLE_PARAM)).sql()) + .isEqualTo("delete from users where `id` = ? limit 1"); + GuiSqlCommandRenderResult multi = print("mysql delete, filter, multi on", MysqlDeleteCommand.from(detail(TABLE, true, filters, null)).render(NO_PARAMS)); + assertThat(multi.sql()).isEqualTo("delete from users where `id` = ? "); + assertThat(multi.bindParams()).containsExactly(5); + + GuiSqlCommandRenderResult noFilterSingle = print("mysql delete, no filter, multi off", MysqlDeleteCommand.from(detail(TABLE, false, List.of(), null)).render(NO_PARAMS)); + assertThat(noFilterSingle.sql()).isEqualTo("delete from users limit 1"); + assertThat(noFilterSingle.bindParams()).isEmpty(); + assertThat(print("mysql delete, no filter, multi on", MysqlDeleteCommand.from(detail(TABLE, true, List.of(), null)).render(NO_PARAMS)).sql()) + .isEqualTo("delete from users"); + } + + // ---- update ---- + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void updateWithAnEmptyChangeSetIsRejectedForBothDialects(boolean allowMultiModify) { + Map mysql = detail(TABLE, allowMultiModify, List.of(filter("id", "=", 1)), keyValueChangeSet()); + + assertPluginError(() -> MysqlUpdateCommand.from(mysql).render(NO_PARAMS), PluginCommonError.INVALID_UPDATE_COMMAND, "UPDATE_DATA_EMPTY"); + assertPluginError(() -> PostgresUpdateCommand.from(mysql).render(NO_PARAMS), PluginCommonError.INVALID_UPDATE_COMMAND, "UPDATE_DATA_EMPTY"); + } + + @Test + void mysqlUpdateWithFilterBindsSetValuesBeforeFilterValuesAndLimitsToOneRow() { + Map detail = detail(TEMPLATE_TABLE, false, List.of(filter("id", "=", 5)), keyValueChangeSet("name", "jack", "age", 30)); + + GuiSqlCommandRenderResult result = print("mysql update, filter, multi off", MysqlUpdateCommand.from(detail).render(TABLE_PARAM)); + + assertThat(result.sql()).isEqualTo("update users set `name`=?,`age`=? where `id` = ? limit 1"); + assertThat(result.bindParams()).containsExactly("jack", 30, 5); + assertThat(result).isNotInstanceOf(UpdateOrDeleteSingleCommandRenderResult.class); + } + + @Test + void postgresUpdateGuardsASingleRowWhenMultiModifyIsOffAndIsPlainWhenOn() { + Map single = detail(TABLE, false, List.of(filter("id", "=", 5)), keyValueChangeSet("age", 30)); + GuiSqlCommandRenderResult guarded = print("pg update, filter, multi off", PostgresUpdateCommand.from(single).render(NO_PARAMS)); + assertThat(guarded).isInstanceOf(UpdateOrDeleteSingleCommandRenderResult.class); + assertThat(guarded.sql()).isEqualTo("update users set \"age\"=30 where \"id\" = 5"); + assertThat(((UpdateOrDeleteSingleCommandRenderResult) guarded).getSelectQuery()).isEqualTo("select count(1) as count from users where \"id\" = 5"); + + GuiSqlCommandRenderResult noFilter = print("pg update, no filter, multi off", + PostgresUpdateCommand.from(detail(TABLE, false, List.of(), keyValueChangeSet("age", 30))).render(NO_PARAMS)); + assertThat(noFilter).as("without a filter the guard still counts every row").isInstanceOf(UpdateOrDeleteSingleCommandRenderResult.class); + assertThat(noFilter.sql()).isEqualTo("update users set \"age\"=30"); + assertThat(((UpdateOrDeleteSingleCommandRenderResult) noFilter).getSelectQuery()).isEqualTo("select count(1) as count from users"); + + GuiSqlCommandRenderResult multi = print("pg update, filter, multi on", + PostgresUpdateCommand.from(detail(TABLE, true, List.of(filter("id", "=", 5)), keyValueChangeSet("age", 30))).render(NO_PARAMS)); + assertThat(multi).isNotInstanceOf(UpdateOrDeleteSingleCommandRenderResult.class); + assertThat(multi.sql()).isEqualTo("update users set \"age\"=30 where \"id\" = 5"); + } + + /** + * Pins the plan section 9 row "UpdateCommand.render without filters returns a plain result before appendLimit and + * without the single-row guard" (D-6, fix deferred, source: lane L4, L4-3 plan): a MySQL GUI update with + * multi-modify off and no filter changes every row. Both halves are asserted: no {@code limit 1} and a plain + * {@link GuiSqlCommandRenderResult}, so the executor's single-row guard is skipped. The delete in the same case is + * the contrast. A fix changes this test on purpose. + */ + @Test + void mysqlUpdateWithoutFilterAndWithoutMultiModifyHasNoLimitAndNoSingleRowGuard() { + GuiSqlCommandRenderResult update = print("mysql update, no filter, multi off", + MysqlUpdateCommand.from(detail(TABLE, false, List.of(), keyValueChangeSet("age", 30))).render(NO_PARAMS)); + + assertThat(update.sql()).as("today's behaviour: every row is updated").isEqualTo("update users set `age`=?"); + assertThat(update.sql()).doesNotContain("limit"); + assertThat(update.bindParams()).containsExactly(30); + assertThat(update).as("today's behaviour: the single-row guard result is not used") + .isNotInstanceOf(UpdateOrDeleteSingleCommandRenderResult.class); + + // contrast: the same situation for a delete is limited to one row (MySQL) or guarded (PostgreSQL) + assertThat(MysqlDeleteCommand.from(detail(TABLE, false, List.of(), null)).render(NO_PARAMS).sql()).endsWith(" limit 1"); + assertThat(PostgresDeleteCommand.from(detail(TABLE, false, List.of(), null)).render(NO_PARAMS)) + .isInstanceOf(UpdateOrDeleteSingleCommandRenderResult.class); + } + + // ---- insert ---- + + @Test + void insertRendersPlaceholdersForMysqlAndDollarQuotedLiteralsForPostgres() { + Map detail = detail(TABLE, false, null, keyValueChangeSet("name", "jack", "age", 30)); + + GuiSqlCommandRenderResult mysql = print("mysql insert", MysqlInsertCommand.from(detail).render(NO_PARAMS)); + assertThat(mysql.sql()).isEqualTo("insert into users (`name`,`age`) values (?,?)"); + assertThat(mysql.bindParams()).containsExactly("jack", 30); + + GuiSqlCommandRenderResult postgres = print("pg insert", PostgresInsertCommand.from(detail).render(NO_PARAMS)); + assertThat(postgres.sql()).matches("insert into users \\(\"name\",\"age\"\\) values \\(" + pgQuoted("jack", 1) + ",30\\);"); + assertThat(postgres.bindParams()).isEmpty(); + } + + @Test + void insertWithAnEmptyChangeSetIsRejected() { + Map empty = detail(TABLE, false, null, keyValueChangeSet()); + + assertPluginError(() -> MysqlInsertCommand.from(empty).render(NO_PARAMS), PluginCommonError.INVALID_INSERT_COMMAND, "INSERT_DATA_EMPTY"); + assertPluginError(() -> PostgresInsertCommand.from(empty).render(NO_PARAMS), PluginCommonError.INVALID_INSERT_COMMAND, "INSERT_DATA_EMPTY"); + } + + @Test + void insertWithAnObjectChangeSetRendersTheJsonObjectAndRejectsNonObjects() { + GuiSqlCommandRenderResult result = print("mysql insert, object change set", + MysqlInsertCommand.from(detail(TABLE, false, null, objectChangeSet("{\"a\":1,\"b\":\"x\"}"))).render(NO_PARAMS)); + assertThat(result.sql()).isEqualTo("insert into users (`a`,`b`) values (?,?)"); + assertThat(result.bindParams()).containsExactly(1, "x"); + + assertPluginError(() -> MysqlInsertCommand.from(detail(TABLE, false, null, objectChangeSet("[1,2]"))).render(NO_PARAMS), + PluginCommonError.INVALID_GUI_SETTINGS, "GUI_INVALID_JSON_MAP_TYPE"); + assertPluginError(() -> MysqlInsertCommand.from(detail(TABLE, false, null, objectChangeSet("not json"))).render(NO_PARAMS), + PluginCommonError.INVALID_GUI_SETTINGS, "GUI_INVALID_JSON_MAP_TYPE"); + } + + // ---- bulk insert ---- + + @Test + void bulkInsertRendersOneValuesGroupPerRowInBothDialects() { + Map detail = bulkDetail("[{\"a\":1,\"b\":\"x\"},{\"a\":2,\"b\":\"y\"}]", null); + + GuiSqlCommandRenderResult mysql = print("mysql bulk insert", MysqlBulkInsertCommand.from(detail).render(NO_PARAMS)); + assertThat(mysql.sql()).isEqualTo("insert into users (`a`,`b`) values (?,?),(?,?)"); + assertThat(mysql.bindParams()).containsExactly(1, "x", 2, "y"); + + GuiSqlCommandRenderResult postgres = print("pg bulk insert", PostgresBulkInsertCommand.from(detail).render(NO_PARAMS)); + assertThat(postgres.sql()).matches("insert into users \\(\"a\",\"b\"\\) values \\(1," + pgQuoted("x", 1) + "\\),\\(2," + + pgQuoted("y", 2) + "\\)"); + assertThat(postgres.bindParams()).isEmpty(); + } + + @Test + void bulkInsertRejectsEmptyMisalignedAndMalformedRecords() { + assertPluginError(() -> MysqlBulkInsertCommand.from(bulkDetail("[]", null)).render(NO_PARAMS), + PluginCommonError.INVALID_INSERT_COMMAND, "INSERT_DATA_EMPTY"); + assertPluginError(() -> MysqlBulkInsertCommand.from(bulkDetail("[{\"a\":1},{\"b\":2}]", null)).render(NO_PARAMS), + PluginCommonError.INVALID_INSERT_COMMAND, "INVALID_INSERT_DATA"); + assertPluginError(() -> MysqlBulkInsertCommand.from(bulkDetail("[{\"a\":1,\"b\":1},{\"a\":2}]", null)).render(NO_PARAMS), + PluginCommonError.INVALID_INSERT_COMMAND, "INVALID_INSERT_DATA"); + assertPluginError(() -> MysqlBulkInsertCommand.from(bulkDetail("not json", null)).render(NO_PARAMS), + PluginCommonError.INVALID_GUI_SETTINGS, "GUI_INVALID_JSON_ARRAY_FORMAT"); + assertPluginError(() -> MysqlBulkInsertCommand.from(bulkDetail("{\"a\":1}", null)).render(NO_PARAMS), + PluginCommonError.INVALID_GUI_SETTINGS, "GUI_INVALID_JSON_ARRAY_FORMAT"); + Map noRecords = new HashMap<>(); + noRecords.put(TABLE_KEY, TABLE); + assertPluginError(() -> MysqlBulkInsertCommand.from(noRecords), PluginCommonError.INVALID_GUI_SETTINGS, "GUI_CHANGE_SET_EMPTY"); + } + + // ---- bulk update ---- + + @Test + void bulkUpdateRendersCaseWhenPerColumnWithBindsInOrderForMysql() { + Map detail = bulkDetail("[{\"id\":1,\"name\":\"a\"},{\"id\":2,\"name\":\"b\"}]", "id"); + + GuiSqlCommandRenderResult result = print("mysql bulk update", MysqlBulkUpdateCommand.from(detail).render(NO_PARAMS)); + + assertThat(result.sql()).isEqualTo("UPDATE users set\n`name` = CASE WHEN `id` = ? THEN ? WHEN `id` = ? THEN ? ELSE `name` END\nwhere id in (?,?)"); + assertThat(result.bindParams()).as("pk and value per WHEN, then the pks of the where").containsExactly(1, "a", 2, "b", 1, 2); + } + + @Test + void bulkUpdateRendersLiteralsForPostgres() { + Map detail = bulkDetail("[{\"id\":1,\"qty\":5},{\"id\":2,\"qty\":6}]", "id"); + + GuiSqlCommandRenderResult result = print("pg bulk update", PostgresBulkUpdateCommand.from(detail).render(NO_PARAMS)); + + assertThat(result.sql()).isEqualTo("UPDATE users set\n\"qty\" = CASE WHEN \"id\" = 1 THEN 5 WHEN \"id\" = 2 THEN 6 ELSE \"qty\" END\nwhere id in (1,2)"); + assertThat(result.bindParams()).isEmpty(); + } + + @Test + void bulkUpdateRejectsEmptyRecordsAMissingPrimaryKeyInARowAndAMissingPrimaryKeySetting() { + assertPluginError(() -> MysqlBulkUpdateCommand.from(bulkDetail("[]", "id")).render(NO_PARAMS), + PluginCommonError.INVALID_INSERT_COMMAND, "UPDATE_DATA_EMPTY"); + assertPluginError(() -> MysqlBulkUpdateCommand.from(bulkDetail("[{\"id\":1,\"a\":1},{\"a\":2}]", "id")).render(NO_PARAMS), + PluginCommonError.INVALID_INSERT_COMMAND, "BULK_UPDATE_DATA_NOT_CONTAIN_PRIMARY_KEY"); + assertPluginError(() -> MysqlBulkUpdateCommand.from(bulkDetail("[{\"id\":1}]", null)), + PluginCommonError.INVALID_GUI_SETTINGS, "GUI_PRIMARY_KEY_EMPTY"); + } + + // ---- upsert ---- + + private static Map upsertDetail(Map insertChangeSet, Map updateChangeSet) { + Map detail = new HashMap<>(); + detail.put(TABLE_KEY, TABLE); + detail.put(INSERT_CHANGE_SET_FORM_KEY, insertChangeSet); + detail.put(UPDATE_CHANGE_SET_FORM_KEY, updateChangeSet); + return detail; + } + + @Test + void mysqlUpsertWithAnUpdateSetUsesOnDuplicateKeyUpdate() { + GuiSqlCommandRenderResult result = print("mysql upsert", + MysqlUpsertCommand.from(upsertDetail(keyValueChangeSet("id", 1, "name", "a"), keyValueChangeSet("name", "b"))).render(NO_PARAMS)); + + assertThat(result.sql()).isEqualTo("insert into users (`id`,`name`) values (?,?) on duplicate key update `name`=?"); + assertThat(result.bindParams()).containsExactly(1, "a", "b"); + } + + @Test + void mysqlUpsertWithAnEmptyUpdateSetBecomesInsertIgnore() { + GuiSqlCommandRenderResult result = print("mysql upsert, no update set", + MysqlUpsertCommand.from(upsertDetail(keyValueChangeSet("id", 1), keyValueChangeSet())).render(NO_PARAMS)); + + assertThat(result.sql()).isEqualTo("insert ignore into users (`id`) values (?)"); + assertThat(result.bindParams()).containsExactly(1); + } + + @Test + void mysqlUpsertRejectsAnEmptyInsertSetAndMissingChangeSets() { + assertPluginError(() -> MysqlUpsertCommand.from(upsertDetail(keyValueChangeSet(), keyValueChangeSet("a", 1))).render(NO_PARAMS), + PluginCommonError.INVALID_UPSERT_COMMAND, "UPSERT_DATA_EMPTY"); + assertPluginError(() -> MysqlUpsertCommand.from(upsertDetail(null, keyValueChangeSet("a", 1))), + PluginCommonError.INVALID_GUI_SETTINGS, "GUI_OPERATION_DATA_EMPTY"); + } + + // ---- shared behaviour ---- + + @Test + void mustacheKeysOfEveryCommandComeFromItsFiltersAndChangeSets() { + Map filtered = detail(TABLE, false, List.of(filter("id", "=", "{{fid}}")), keyValueChangeSet("a", "{{val}}")); + + assertThat(MysqlUpdateCommand.from(filtered).extractMustacheKeys()).containsExactlyInAnyOrder("{{fid}}", "{{val}}"); + assertThat(MysqlDeleteCommand.from(filtered).extractMustacheKeys()).containsExactly("{{fid}}"); + assertThat(MysqlInsertCommand.from(filtered).extractMustacheKeys()).containsExactly("{{val}}"); + assertThat(MysqlBulkInsertCommand.from(bulkDetail("{{rows}}", null)).extractMustacheKeys()).containsExactly("{{rows}}"); + assertThat(MysqlBulkUpdateCommand.from(bulkDetail("{{rows}}", "id")).extractMustacheKeys()).containsExactly("{{rows}}"); + assertThat(MysqlUpsertCommand.from(upsertDetail(keyValueChangeSet("a", "{{i}}"), keyValueChangeSet("b", "{{u}}"))).extractMustacheKeys()) + .containsExactlyInAnyOrder("{{i}}", "{{u}}"); + } + + @Test + void onlyInsertAndUpsertCommandsAreInsertCommands() { + Map detail = detail(TABLE, false, List.of(filter("id", "=", 1)), keyValueChangeSet("a", 1)); + List inserting = List.of(MysqlInsertCommand.from(detail), MysqlBulkInsertCommand.from(bulkDetail("[]", null)), + MysqlUpsertCommand.from(upsertDetail(keyValueChangeSet("a", 1), keyValueChangeSet()))); + List notInserting = List.of(MysqlUpdateCommand.from(detail), MysqlDeleteCommand.from(detail), + MysqlBulkUpdateCommand.from(bulkDetail("[]", "id"))); + + assertThat(inserting).allSatisfy(command -> assertThat(command.isInsertCommand()).isTrue()); + assertThat(notInserting).allSatisfy(command -> assertThat(command.isInsertCommand()).isFalse()); + } + + @Test + void fromRejectsABlankTable() { + assertPluginError(() -> MysqlDeleteCommand.from(detail(" ", false, List.of(), null)), PluginCommonError.INVALID_GUI_SETTINGS, "GUI_FIELD_EMPTY"); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/mysql/MysqlGuiCommandTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/mysql/MysqlGuiCommandTest.java index 5ee0d32d62..8871d0cf2a 100644 --- a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/mysql/MysqlGuiCommandTest.java +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/mysql/MysqlGuiCommandTest.java @@ -1,7 +1,7 @@ package org.lowcoder.sdk.plugin.sqlcommand.command.mysql; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.lowcoder.sdk.plugin.sqlcommand.changeset.BulkObjectChangeSet; import org.lowcoder.sdk.plugin.sqlcommand.changeset.KeyValuePairChangeSet; import org.lowcoder.sdk.plugin.sqlcommand.filter.FilterSet; @@ -31,7 +31,7 @@ public void testInsertCommand() { var command = new MysqlInsertCommand("user", changeSet); var render = command.render(Map.of("email", email, "info", infoMap)); - Assert.assertEquals("insert into user (`id`,`name`,`email`,`info`) values (?,?,?,?)", render.sql()); + Assertions.assertEquals("insert into user (`id`,`name`,`email`,`info`) values (?,?,?,?)", render.sql()); assertThat(render.bindParams()).isEqualTo(List.of(Integer.parseInt(id), name, email, toJson(infoMap))); assertThat(command.extractMustacheKeys()).isEqualTo(Set.of("{{ email }}", "{{ info }}")); @@ -62,7 +62,7 @@ public void testUpdateCommand() { var command = new MysqlUpdateCommand("user", changeSet, filterSet, false); var render = command.render(Map.of("email", email, "info", infoMap, "name", name, "ids", List.of(1, 2, 5))); - Assert.assertEquals( + Assertions.assertEquals( "update user set `id`=?,`name`=?,`email`=?,`info`=? " + "where `name` = ? and `status` > ? and `id` IN (1,2,5) and `phone` IS null limit 1", render.sql()); @@ -104,7 +104,7 @@ public void testUpsertCommand() { var command = new MysqlUpsertCommand("user", changeSet, changeSet2); var render = command.render(Map.of("email", email, "info", infoMap, "id", Integer.parseInt(id), "name", name)); - Assert.assertEquals( + Assertions.assertEquals( "insert into user (`id`,`name`,`email`,`info`) values (?,?,?,?) on duplicate key update `id`=?,`name`=?,`email`=?,`info`=?", render.sql()); @@ -140,7 +140,7 @@ public void testDeleteCommand() { var command = new MysqlDeleteCommand("user", filterSet, false); var render = command.render(Map.of("email", email, "info", infoMap, "name", name, "ids", List.of(1, 2, 5))); - Assert.assertEquals( + Assertions.assertEquals( "delete from user where `name` = ? and `status` > ? and `id` IN (1,2,5) and `phone` IS null limit 1", render.sql()); @@ -171,7 +171,7 @@ public void testBulkInsertCommand() { var command = new MysqlBulkInsertCommand("user", new BulkObjectChangeSet(toJson(insertList))); var render = command.render(Map.of("email", email, "info", infoMap)); - Assert.assertEquals( + Assertions.assertEquals( "insert into user (`id`,`name`,`email`,`info`) values (?,?,?,?),(?,?,?,?)", render.sql()); @@ -208,7 +208,7 @@ public void testBulkUpdateCommand() { new BulkObjectChangeSet(toJson(updateList)), "id"); var render = command.render(Map.of("rose", "rose", "email", "jack@jack.com", "id1", 1)); - Assert.assertEquals( + Assertions.assertEquals( """ UPDATE user set `name` = CASE WHEN `id` = ? THEN ? WHEN `id` = ? THEN ? ELSE `name` END, diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/postgres/PostgresCommandTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/postgres/PostgresCommandTest.java index 63562e013a..a6f137bb67 100644 --- a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/postgres/PostgresCommandTest.java +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/command/postgres/PostgresCommandTest.java @@ -2,8 +2,7 @@ import com.google.common.collect.ImmutableMap; import org.assertj.core.api.Assertions; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand.GuiSqlCommandRenderResult; import org.lowcoder.sdk.plugin.sqlcommand.changeset.BulkObjectChangeSet; import org.lowcoder.sdk.plugin.sqlcommand.changeset.KeyValuePairChangeSet; @@ -45,7 +44,7 @@ public EscapeSql escapeStrFunc() { GuiSqlCommandRenderResult render = insertCommand.render(Map.of("email", email, "info", infoMap)); - Assert.assertEquals(""" + org.junit.jupiter.api.Assertions.assertEquals(""" insert into user ("id","name","email","info") values (12312,$$jack$$,$$jack@gmail.com$$,$${"age":35,"job":"sales"}$$);""", render.sql()); Assertions.assertThat(render.bindParams()).isEqualTo(List.of()); @@ -83,18 +82,18 @@ public EscapeSql escapeStrFunc() { String updateSql = renderResult.sql(); List updateBindParams = renderResult.bindParams(); - Assert.assertEquals( + org.junit.jupiter.api.Assertions.assertEquals( """ update user set "id"=12312,"name"=$$jack$$,"email"=$$jack@gmail.com$$,"info"=$${"age":35,"job":"sales"}$$ where "name" = $$jack$$ and "status" > 1 and "id" IN (1,2,5) and "phone" IS null\s""", updateSql); Assertions.assertThat(updateBindParams).isEqualTo(List.of()); - Assert.assertTrue(renderResult instanceof UpdateOrDeleteSingleCommandRenderResult); + org.junit.jupiter.api.Assertions.assertTrue(renderResult instanceof UpdateOrDeleteSingleCommandRenderResult); var pgUpdateRenderResult = (UpdateOrDeleteSingleCommandRenderResult) renderResult; String selectQuery = pgUpdateRenderResult.getSelectQuery(); List selectBindParams = pgUpdateRenderResult.getSelectBindParams(); - Assert.assertEquals(""" + org.junit.jupiter.api.Assertions.assertEquals(""" select count(1) as count from user where "name" = $$jack$$ and "status" > 1 and "id" IN (1,2,5) and "phone" IS null\s""", selectQuery); Assertions.assertThat(selectBindParams).isEqualTo(List.of()); @@ -121,17 +120,17 @@ public EscapeSql escapeStrFunc() { var renderResult = command.render(Map.of("list", List.of(1, 2, 3))); String sql = renderResult.sql(); - Assert.assertEquals( + org.junit.jupiter.api.Assertions.assertEquals( """ delete from user where "name" = $$jack$$ and "status" > 1 and "id" IN (1,2,3) and "phone" IS null\s""", sql); - Assert.assertTrue(renderResult instanceof UpdateOrDeleteSingleCommandRenderResult); + org.junit.jupiter.api.Assertions.assertTrue(renderResult instanceof UpdateOrDeleteSingleCommandRenderResult); var pgUpdateRenderResult = (UpdateOrDeleteSingleCommandRenderResult) renderResult; String selectQuery = pgUpdateRenderResult.getSelectQuery(); List selectBindParams = pgUpdateRenderResult.getSelectBindParams(); - Assert.assertEquals(""" + org.junit.jupiter.api.Assertions.assertEquals(""" select count(1) as count from user where "name" = $$jack$$ and "status" > 1 and "id" IN (1,2,3) and "phone" IS null\s""", selectQuery); Assertions.assertThat(selectBindParams).isEqualTo(List.of()); @@ -162,7 +161,7 @@ public EscapeSql escapeStrFunc() { }; var render = command.render(Map.of("email", email, "info", infoMap)); - Assert.assertEquals( + org.junit.jupiter.api.Assertions.assertEquals( """ insert into user ("id","name","email","info") values (12312,$$jack$$,$$jack@gmail.com$$,$${"age":35,"job":"sales"}$$),(12312,$$jack$$,$$jack@gmail.com$$,$${"age":35,"job":"sales"}$$)""", render.sql()); @@ -193,7 +192,7 @@ public EscapeSql escapeStrFunc() { }; var render = command.render(Map.of()); - Assert.assertEquals( + org.junit.jupiter.api.Assertions.assertEquals( """ UPDATE user set "name" = CASE WHEN "id" = 1 THEN $$jack$$ WHEN "id" = 2 THEN $$rose$$ ELSE "name" END, diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/filter/FilterSetRenderTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/filter/FilterSetRenderTest.java new file mode 100644 index 0000000000..9ff525f501 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/plugin/sqlcommand/filter/FilterSetRenderTest.java @@ -0,0 +1,250 @@ +package org.lowcoder.sdk.plugin.sqlcommand.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.sdk.exception.PluginCommonError; +import org.lowcoder.sdk.exception.PluginException; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand.GuiSqlCommandRenderResult; +import org.lowcoder.sdk.plugin.sqlcommand.command.mysql.MysqlDeleteCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresDeleteCommand; +import org.lowcoder.sdk.plugin.sqlcommand.filter.FilterSet.RawFilterCondition; +import org.lowcoder.sdk.util.SqlGuiUtils.GuiSqlValue.EscapeSql; + +/** Rendering and parsing of the GUI filter conditions of {@link FilterSet} (the where clause of GUI update/delete). */ +class FilterSetRenderTest { + + private static final String FRONT = "`"; + private static final String BACK = "`"; + private static final EscapeSql QUOTE = s -> "'" + s + "'"; + private static final Map NO_PARAMS = Map.of(); + + private static FilterSet filterSet(Object... columnConditionValue) { + FilterSet set = new FilterSet(); + for (int i = 0; i < columnConditionValue.length; i += 3) { + set.addCondition((String) columnConditionValue[i], (String) columnConditionValue[i + 1], columnConditionValue[i + 2]); + } + return set; + } + + private static GuiSqlCommandRenderResult render(FilterSet set, Map params, boolean raw) { + GuiSqlCommandRenderResult result = set.render(params, FRONT, BACK, raw, QUOTE); + System.out.println("[FilterSetRenderTest] raw=" + raw + " -> [" + result.sql() + "] " + result.bindParams()); + return result; + } + + private static void assertPluginError(Runnable action, PluginCommonError error, String messageKey, Object... args) { + assertThatThrownBy(action::run).isInstanceOfSatisfying(PluginException.class, e -> { + assertThat(e.getError()).isEqualTo(error); + assertThat(e.getMessageKey()).isEqualTo(messageKey); + assertThat(e.getArgs()).containsExactly(args); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"=", "!=", ">", "<", "<=", ">="}) + void comparisonOperatorsRenderAsPlaceholderOrLiteral(String operator) { + FilterSet set = filterSet("age", operator, 18); + + GuiSqlCommandRenderResult bound = render(set, NO_PARAMS, false); + assertThat(bound.sql()).isEqualTo(" where `age` " + operator + " ? "); + assertThat(bound.bindParams()).containsExactly(18); + + GuiSqlCommandRenderResult raw = render(set, NO_PARAMS, true); + assertThat(raw.sql()).isEqualTo(" where `age` " + operator + " 18"); + assertThat(raw.bindParams()).isEmpty(); + } + + @Test + void comparisonValueIsRenderedFromTheRequestAndEscapedInRawMode() { + FilterSet set = filterSet("name", "=", "{{who}}"); + + assertThat(render(set, Map.of("who", "jack"), false).bindParams()).containsExactly("jack"); + assertThat(render(set, Map.of("who", "jack"), true).sql()).isEqualTo(" where `name` = 'jack'"); + } + + static Stream isCases() { + return Stream.of( + Arguments.of("IS", null, " where `c` IS null "), + Arguments.of("IS NOT", null, " where `c` IS NOT null "), + Arguments.of("IS", Boolean.TRUE, " where `c` IS true "), + Arguments.of("IS NOT", Boolean.FALSE, " where `c` IS NOT false "), + Arguments.of("IS", "null", " where `c` IS null "), + Arguments.of("IS", "true", " where `c` IS true ")); + } + + @ParameterizedTest(name = "[{index}] {0} {1}") + @MethodSource("isCases") + void isAndIsNotRenderOnlyNullOrBoolean(String operator, Object value, String expectedSql) { + GuiSqlCommandRenderResult result = render(filterSet("c", operator, value), NO_PARAMS, false); + + assertThat(result.sql()).isEqualTo(expectedSql); + assertThat(result.bindParams()).isEmpty(); + } + + @Test + void isRejectsAValueThatIsNeitherNullNorBoolean() { + assertPluginError(() -> render(filterSet("c", "IS", 5), NO_PARAMS, false), + PluginCommonError.INVALID_IN_OPERATOR_SETTINGS, "INVALID_IS"); + } + + @ParameterizedTest + @ValueSource(strings = {"IN", "NOT IN"}) + void inAndNotInRenderListsWithQuotedStringsAndJsonForNestedValues(String operator) { + List list = List.of(1, "a", true, List.of(2), Map.of("k", "v")); + + GuiSqlCommandRenderResult result = render(filterSet("id", operator, "{{list}}"), Map.of("list", list), false); + + assertThat(result.sql()).isEqualTo(" where `id` " + operator + " (1,'a',true,[2],{\"k\":\"v\"})"); + assertThat(result.bindParams()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"IN", "NOT IN"}) + void inWithAnEmptyListRendersFalseInsteadOfInvalidSql(String operator) { + GuiSqlCommandRenderResult result = render(filterSet("id", operator, "{{list}}"), Map.of("list", List.of()), false); + + assertThat(result.sql()).isEqualTo(" where false"); + } + + private static Map deleteDetail(String operator) { + Map filter = condition("id", operator, "{{value}}"); + Map detail = new HashMap<>(); + detail.put("table", "users"); + detail.put("allowMultiModify", true); + detail.put("filterBy", List.of(filter)); + return detail; + } + + /** + * Pins the plan section 9 row "FilterSet IN / NOT IN renders the list as raw SQL in every dialect, prepared or not, + * String elements quoted without the escapeSql the other operators use" (SQL injection, D-6, fix deferred): an + * element {@code x' OR '1'='1} ends up verbatim inside the rendered SQL and is not a bind parameter, for a MySQL + * command (prepared statements) and a PostgreSQL command (raw SQL). A fix changes this test on purpose. + */ + @Test + void inListStringElementsAreWrittenUnescapedIntoTheSqlForMysqlAndPostgres() { + String injection = "x' OR '1'='1"; + Map request = Map.of("value", List.of(injection)); + + GuiSqlCommandRenderResult mysql = MysqlDeleteCommand.from(deleteDetail("IN")).render(request); + GuiSqlCommandRenderResult postgres = PostgresDeleteCommand.from(deleteDetail("IN")).render(request); + + System.out.println("[FilterSetRenderTest] mysql IN: " + mysql.sql() + " " + mysql.bindParams()); + System.out.println("[FilterSetRenderTest] postgres IN: " + postgres.sql() + " " + postgres.bindParams()); + assertThat(mysql.sql()).as("today's behaviour: the element is part of the SQL text").isEqualTo("delete from users where `id` IN ('x' OR '1'='1')"); + assertThat(mysql.bindParams()).as("and not a bind parameter").isEmpty(); + assertThat(postgres.sql()).isEqualTo("delete from users where \"id\" IN ('x' OR '1'='1')"); + assertThat(postgres.bindParams()).isEmpty(); + } + + /** The contrast to the pin above: the {@code =} operator with the same value is bound (MySQL) or dollar-quoted (PostgreSQL). */ + @Test + void equalsOperatorWithTheSameValueIsBoundOrEscaped() { + String injection = "x' OR '1'='1"; + Map request = Map.of("value", injection); + + GuiSqlCommandRenderResult mysql = MysqlDeleteCommand.from(deleteDetail("=")).render(request); + GuiSqlCommandRenderResult postgres = PostgresDeleteCommand.from(deleteDetail("=")).render(request); + + System.out.println("[FilterSetRenderTest] mysql =: " + mysql.sql() + " " + mysql.bindParams()); + System.out.println("[FilterSetRenderTest] postgres =: " + postgres.sql() + " " + postgres.bindParams()); + assertThat(mysql.sql()).isEqualTo("delete from users where `id` = ? "); + assertThat(mysql.bindParams()).containsExactly(injection); + assertThat(postgres.sql()).matches("delete from users where \"id\" = \\$([A-Za-z]{7})\\$x' OR '1'='1\\$\\1\\$"); + assertThat(postgres.bindParams()).isEmpty(); + } + + @Test + void inRejectsAValueThatIsNotAList() { + assertPluginError(() -> render(filterSet("id", "IN", 5), NO_PARAMS, false), + PluginCommonError.INVALID_IN_OPERATOR_SETTINGS, "INVALID_IN"); + } + + @Test + void unknownOperatorIsRejectedWithItsName() { + assertPluginError(() -> render(filterSet("c", "LIKE", "x"), NO_PARAMS, false), + PluginCommonError.INVALID_GUI_SETTINGS, "GUI_INVALID_FILTER_FIELD", "LIKE"); + } + + @Test + void conditionsAreJoinedWithAndAndARawConditionIsWrittenVerbatim() { + FilterSet set = filterSet("a", "=", 1); + // a RawFilterCondition is column + condition + value concatenated with no binding and no escaping, by design + set.addCondition(new RawFilterCondition("id", " > ", "5 or 1=1")); + + GuiSqlCommandRenderResult result = render(set, NO_PARAMS, false); + + assertThat(result.sql()).isEqualTo(" where `a` = ? and id > 5 or 1=1"); + assertThat(result.bindParams()).containsExactly(1); + } + + @Test + void columnDelimitersAreTheCallersAndAnEmptySetRendersNothing() { + assertThat(filterSet("a", "=", 1).render(NO_PARAMS, "[", "]", true, QUOTE).sql()).isEqualTo(" where [a] = 1"); + + GuiSqlCommandRenderResult empty = render(new FilterSet(), NO_PARAMS, false); + assertThat(empty.sql()).isEmpty(); + assertThat(empty.bindParams()).isEmpty(); + } + + @Test + void addConditionOverloadsAppendAndMustacheKeysComeFromStringValuesOnly() { + FilterSet set = new FilterSet(); + set.addCondition("a", "=", "{{x}}"); + set.addCondition(new FilterSet.FilterCondition("b", "=", 5)); + set.addCondition("c", "=", "plain {{y}} and {{z}}"); + + assertThat(set).hasSize(3); + assertThat(set.extractMustacheKeys()).containsExactlyInAnyOrder("{{x}}", "{{y}}", "{{z}}"); + } + + private static Map condition(String column, String condition, Object value) { + Map map = new HashMap<>(); + map.put("column", column); + map.put("condition", condition); + map.put("value", value); + return map; + } + + @Test + void parseFilterSetBuildsConditionsAndUpperCasesTheOperator() { + Map detail = Map.of("filterBy", List.of(condition("id", "in", "{{ids}}"), condition("name", "=", "x"))); + + FilterSet set = FilterSet.parseFilterSet(detail); + + assertThat(set).hasSize(2); + assertThat(set.get(0).getCondition()).isEqualTo("IN"); + assertThat(set.get(0).getColumn()).isEqualTo("id"); + assertThat(set.get(1).getValue()).isEqualTo("x"); + } + + static Stream parseErrorCases() { + List notMaps = new ArrayList<>(); + notMaps.add("text"); + return Stream.of( + Arguments.of("filterBy missing", Map.of(), "GUI_FILTER_FIELD_EMPTY", new Object[0]), + Arguments.of("filterBy not a list", Map.of("filterBy", "x"), "GUI_INVALID_FILTER_FIELD", new Object[]{"String"}), + Arguments.of("element not a map", Map.of("filterBy", notMaps), "GUI_INVALID_FILTER_FIELD", new Object[]{"String"}), + Arguments.of("blank column", Map.of("filterBy", List.of(condition(" ", "=", 1))), "GUI_INVALID_FILTER_CONDITION", new Object[0]), + Arguments.of("blank condition", Map.of("filterBy", List.of(condition("a", "", 1))), "GUI_INVALID_FILTER_CONDITION", new Object[0]), + Arguments.of("missing condition", Map.of("filterBy", List.of(condition("a", null, 1))), "GUI_INVALID_FILTER_CONDITION", new Object[0])); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("parseErrorCases") + void parseFilterSetRejectsMalformedFilters(String label, Map detail, String messageKey, Object[] args) { + assertPluginError(() -> FilterSet.parseFilterSet(detail), PluginCommonError.INVALID_GUI_SETTINGS, messageKey, args); + System.out.println("[FilterSetRenderTest] parse error: " + label + " -> " + messageKey); + } +} diff --git a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/test/JsonFileReader.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/test/JsonFileReader.java similarity index 100% rename from server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/test/JsonFileReader.java rename to server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/test/JsonFileReader.java diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/CookieHelperTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/CookieHelperTest.java index dcbe5a5968..6f9554df93 100644 --- a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/CookieHelperTest.java +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/CookieHelperTest.java @@ -1,6 +1,6 @@ package org.lowcoder.sdk.util; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.lowcoder.sdk.config.CommonConfig; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; @@ -10,11 +10,11 @@ import java.time.Duration; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Guards the contract that {@link CookieHelper#clearCookie(ServerWebExchange)} really deletes the cookie that @@ -49,7 +49,7 @@ private MockServerWebExchange exchange(String referer) { private ResponseCookie onlyCookie(ServerWebExchange exchange) { ResponseCookie cookie = exchange.getResponse().getCookies().getFirst(COOKIE_NAME); - assertNotNull("expected a Set-Cookie for " + COOKIE_NAME, cookie); + assertNotNull(cookie, "expected a Set-Cookie for " + COOKIE_NAME); return cookie; } @@ -116,13 +116,13 @@ private void assertSaveAndClearAgree(String referer) { ResponseCookie cleared = onlyCookie(clearExchange); String context = "referer=" + referer; - assertEquals(context, saved.getName(), cleared.getName()); - assertEquals(context, saved.getPath(), cleared.getPath()); - assertEquals(context, saved.getDomain(), cleared.getDomain()); - assertEquals(context, saved.isHttpOnly(), cleared.isHttpOnly()); - assertEquals(context, saved.isSecure(), cleared.isSecure()); - assertEquals(context, saved.getSameSite(), cleared.getSameSite()); - assertEquals(context, Duration.ZERO, cleared.getMaxAge()); + assertEquals(saved.getName(), cleared.getName(), context); + assertEquals(saved.getPath(), cleared.getPath(), context); + assertEquals(saved.getDomain(), cleared.getDomain(), context); + assertEquals(saved.isHttpOnly(), cleared.isHttpOnly(), context); + assertEquals(saved.isSecure(), cleared.isSecure(), context); + assertEquals(saved.getSameSite(), cleared.getSameSite(), context); + assertEquals(Duration.ZERO, cleared.getMaxAge(), context); } /** @@ -165,8 +165,8 @@ public void clearCookieSerialisesAsExpired() { cookieHelper(false).clearCookie(exchange); String serialised = onlyCookie(exchange).toString(); - assertTrue(serialised, serialised.contains("Max-Age=0")); - assertTrue(serialised, serialised.contains("Expires=")); + assertTrue(serialised.contains("Max-Age=0"), serialised); + assertTrue(serialised.contains("Expires="), serialised); } @Test diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/DateTimeUtilsTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/DateTimeUtilsTest.java new file mode 100644 index 0000000000..03ad9ab19c --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/DateTimeUtilsTest.java @@ -0,0 +1,76 @@ +package org.lowcoder.sdk.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.TimeZone; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** {@link DateTimeUtils}: the day format (which follows the JVM default time zone) and the conversion to {@link Instant}. */ +public class DateTimeUtilsTest { + + private static final long LAST_MILLI_OF_DAY_ONE_UTC = 86_399_999L; + private static final long ONE_HOUR_MILLIS = 3_600_000L; + private TimeZone previous; + + @BeforeEach + public void remember() { + previous = TimeZone.getDefault(); + } + + @AfterEach + public void restore() { + TimeZone.setDefault(previous); + } + + @Test + public void theDayIsFormattedInTheDefaultTimeZoneSoTheSameInstantCanShowTwoDates() { + Date late = new Date(LAST_MILLI_OF_DAY_ONE_UTC); + Date early = new Date(ONE_HOUR_MILLIS); + + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + String utcLate = DateTimeUtils.format(late); + String utcEarly = DateTimeUtils.format(early); + TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Auckland")); + String aucklandLate = DateTimeUtils.format(late); + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")); + String losAngelesEarly = DateTimeUtils.format(early); + + System.out.println("[DateTimeUtilsTest] 23:59:59.999 UTC -> UTC " + utcLate + ", Auckland " + aucklandLate + "; 01:00 UTC -> UTC " + utcEarly + ", Los Angeles " + losAngelesEarly); + assertEquals("1970-01-01", utcLate); + assertEquals("1970-01-01", utcEarly); + assertEquals("1970-01-02", aucklandLate, "23:59 UTC is 12:00 the next day in UTC+12"); + assertEquals("1969-12-31", losAngelesEarly, "01:00 UTC is 17:00 the previous day in UTC-8"); + } + + @Test + public void aDateConvertsToTheInstantOfTheSameMillisecond() { + Date date = new Date(1_700_000_000_123L); + + assertEquals(Instant.ofEpochMilli(1_700_000_000_123L), DateTimeUtils.toInstant(date)); + } + + @Test + public void anObjectConvertsOnlyWhenItIsAnInstantOrADate() { + Instant instant = Instant.parse("2024-02-29T12:00:00Z"); + + assertSame(instant, DateTimeUtils.toInstant((Object) instant)); + assertEquals(Instant.ofEpochMilli(5), DateTimeUtils.toInstant((Object) new Date(5))); + assertNull(DateTimeUtils.toInstant((Object) "2024-02-29")); + assertNull(DateTimeUtils.toInstant((Object) 5L)); + assertNull(DateTimeUtils.toInstant((Object) null)); + } + + @Test + public void theDateTimeFormatterUsesTheTwentyFourHourClock() { + assertEquals("2024-02-29 13:05:09", DateTimeUtils.DATE_TIME_FORMAT.format(LocalDateTime.of(2024, 2, 29, 13, 5, 9))); + assertEquals("2024-01-02 00:00:00", DateTimeUtils.DATE_TIME_FORMAT.format(LocalDateTime.of(2024, 1, 2, 0, 0, 0))); + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/DownstreamRenderContractTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/DownstreamRenderContractTest.java new file mode 100644 index 0000000000..680dca752b --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/DownstreamRenderContractTest.java @@ -0,0 +1,209 @@ +package org.lowcoder.sdk.util; + +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.contract.BoundarySites; +import org.lowcoder.sdk.contract.ConfigBinding; +import org.lowcoder.sdk.contract.GoldenJson; +import org.lowcoder.sdk.contract.RenderValues; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand; +import org.lowcoder.sdk.plugin.sqlcommand.GuiSqlCommand.GuiSqlCommandRenderResult; +import org.lowcoder.sdk.plugin.sqlcommand.command.UpdateOrDeleteSingleCommandRenderResult; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresBulkInsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresBulkUpdateCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresInsertCommand; +import org.lowcoder.sdk.plugin.sqlcommand.command.postgres.PostgresUpdateCommand; +import org.lowcoder.sdk.util.SqlGuiUtils.GuiSqlValue; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Group {@code downstream-render}, its {@code lowcoder-sdk} rows (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T8.5): + * values written by the production mapper ({@code toJson}) into SQL text and bind values. Every entry point renders + * {@link #VALUES} ({@link RenderValues}), one value of every JSON type, and the exact text it produces is pinned, one line per value + * ({@link GoldenJson#assertText}), under {@value #DIRECTORY}: + *
      + *
    • {@code MustacheHelper}: a value rendered into a template, a list rendered into {@code in (...)}, and values + * combined inside a quoted SQL string, the three {@code toJson} sites of prepared-statement rendering;
    • + *
    • {@code GuiSqlValue.getValue} (the bind value) and {@code getConcatSqlStr} (the SQL text);
    • + *
    • the seven callers of {@code getConcatSqlStr}, each run through its Postgres GUI command (the dialect that + * renders raw SQL): insert, update, bulk insert, bulk update (where and both case-when values), and the filter + * set (a comparison and an {@code in} list, whose collection text is the {@code getCollectionStr} site).
    • + *
    + * + *

    Limits: the Postgres string escape wraps text in a random dollar-quote tag + * ({@code SqlGuiUtils.POSTGRES_SQL_STR_ESCAPE}); the fixture has each tag replaced by {@value #TAG} ({@link #DOLLAR_TAG}). + * The SQL is pinned as rendered; no database runs it. + */ +public class DownstreamRenderContractTest { + + static final String DIRECTORY = "downstream-render/"; + static final String SEPARATOR = "\t"; + static final String NEWLINE = "\n"; + static final String TAG = "$tag$"; + /** The random tag of {@code POSTGRES_SQL_STR_ESCAPE}: seven letters between dollar signs. */ + static final Pattern DOLLAR_TAG = Pattern.compile("\\$[A-Za-z]{7}\\$"); + static final String VALUE_PARAM = "v"; + static final String TABLE = "items"; + static final String PRIMARY_KEY = "id"; + /** The values every site renders, by name. */ + static final Map VALUES = RenderValues.values(); + + private static final GoldenJson GOLDEN = GoldenJson.forModule(); + + @BoundarySites("lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java#MustacheHelper.convertToStringValue#toJson#1") + @Test + public void renderMustacheString() { + assertLines("MustacheHelper.renderMustacheString.txt", value -> MustacheHelper.renderMustacheString("v = {{v}}", params(value))); + } + + @BoundarySites("lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java#MustacheHelper.replaceParamWithInOperator#toJson#1") + @Test + public void inOperator() { + assertLines("MustacheHelper.inOperator.txt", value -> prepared("select * from items where id in ({{v}})", Collections.singletonList(value))); + } + + @BoundarySites("lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java#MustacheHelper.generateNewValue#toJson#1") + @Test + public void valueInsideQuotes() { + assertLines("MustacheHelper.valueInsideQuotes.txt", value -> prepared("select * from items where name like '%{{v}}%'", value)); + } + + @BoundarySites({ + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java#SqlGuiUtils.GuiSqlValue.getValue#toJson#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java#SqlGuiUtils.GuiSqlValue.getConcatSqlStr#toJson#1"}) + @Test + public void guiSqlValue() { + assertLines("SqlGuiUtils.GuiSqlValue.txt", value -> { + GuiSqlValue guiSqlValue = GuiSqlValue.from(value); + Object bindValue = guiSqlValue.getValue(); + return (bindValue == null ? "null" : bindValue.getClass().getName() + ":" + bindValue) + + SEPARATOR + guiSqlValue.getConcatSqlStr(SqlGuiUtils.POSTGRES_SQL_STR_ESCAPE); + }); + } + + @BoundarySites("lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/InsertCommand.java#InsertCommand.buildRawSql#getConcatSqlStr#1") + @Test + public void insertCommand() { + assertLines("InsertCommand.txt", value -> sql(PostgresInsertCommand.from(detail(keyValueChangeSet())))); + } + + @BoundarySites({ + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/UpdateCommand.java#UpdateCommand.appendSet#getConcatSqlStr#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/filter/FilterSet.java#FilterSet.renderCondition#getConcatSqlStr#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/filter/FilterSet.java#FilterSet.getCollectionStr#toJson#1"}) + @Test + public void updateCommandWithFilters() { + assertLines("UpdateCommand.txt", value -> { + Map detail = detail(keyValueChangeSet()); + detail.put("allowMultiModify", true); + detail.put("filterBy", List.of(filter(PRIMARY_KEY, "=", "{{v}}"), filter("tags", "in", "{{list}}"))); + return sql(PostgresUpdateCommand.from(detail)); + }); + } + + @BoundarySites("lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkInsertCommand.java#BulkInsertCommand.render#getConcatSqlStr#1") + @Test + public void bulkInsertCommand() { + assertLines("BulkInsertCommand.txt", value -> { + Map detail = detail(null); + detail.put("records", "[{\"id\": 1, \"v\": {{v}}}, {\"id\": 2, \"v\": {{list}}}]"); + return sql(PostgresBulkInsertCommand.from(detail)); + }); + } + + @BoundarySites({ + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkUpdateCommand.java#BulkUpdateCommand.appendWhere#getConcatSqlStr#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkUpdateCommand.java#BulkUpdateCommand.appendCaseWhen#getConcatSqlStr#1", + "lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkUpdateCommand.java#BulkUpdateCommand.appendCaseWhen#getConcatSqlStr#2"}) + @Test + public void bulkUpdateCommand() { + assertLines("BulkUpdateCommand.txt", value -> { + Map detail = detail(null); + detail.put("records", "[{\"id\": {{v}}, \"v\": {{v}}}, {\"id\": {{map}}, \"v\": {{list}}}]"); + detail.put("primaryKey", PRIMARY_KEY); + return sql(PostgresBulkUpdateCommand.from(detail)); + }); + } + + /** One line per value, {@code nametext}, or the error in place of the text; dollar-quote tags replaced. */ + private static void assertLines(String fixture, Function render) { + StringBuilder text = new StringBuilder(); + VALUES.forEach((name, value) -> { + String rendered; + try { + CURRENT.set(value); + rendered = render.apply(value); + } catch (RuntimeException e) { + rendered = "ERROR " + ConfigBinding.errorText(e); + } finally { + CURRENT.remove(); + } + text.append(name).append(SEPARATOR).append(DOLLAR_TAG.matcher(rendered.replace(NEWLINE, " ")).replaceAll(Matcher.quoteReplacement(TAG))).append(NEWLINE); + }); + System.out.println("[DownstreamRenderContractTest] " + fixture + "\n" + text); + GOLDEN.assertText(DIRECTORY + fixture, text.toString()); + } + + /** The value being rendered, for the GUI commands, which read it as the request parameter {@code v}. */ + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + /** {@code sqlbinds}, with a single-row command's select and its statement. */ + private static String sql(GuiSqlCommand command) { + GuiSqlCommandRenderResult result = command.render(params(CURRENT.get())); + String text = result.sql() + SEPARATOR + result.bindParams(); + if (result instanceof UpdateOrDeleteSingleCommandRenderResult single) { + text += SEPARATOR + single.getSelectQuery() + SEPARATOR + single.getSelectBindParams(); + } + return text; + } + + /** The prepared SQL, the remaining binding keys and the values bound to them. */ + private static String prepared(String sql, Object value) { + Map params = params(value); + List keys = new ArrayList<>(MustacheHelper.extractMustacheKeysInOrder(sql)); + String prepared = MustacheHelper.doPrepareStatement(sql, keys, params); + List bound = keys.stream().map(params::get).toList(); + return prepared + SEPARATOR + keys + SEPARATOR + bound; + } + + /** The request parameters: the value as {@code v}, and a list and a map of mixed values. */ + private static Map params(Object value) { + Map params = new HashMap<>(); + params.put(VALUE_PARAM, value); + params.put(RenderValues.LIST, VALUES.get(RenderValues.LIST)); + params.put(RenderValues.MAP, VALUES.get(RenderValues.MAP)); + return params; + } + + private static Map keyValueChangeSet() { + Map changeSet = new LinkedHashMap<>(); + changeSet.put("compType", "KEY_VALUE_PAIRS"); + changeSet.put("comp", List.of(Map.of("column", "v", "value", "{{v}}"), Map.of("column", "tags", "value", "{{list}}"))); + return changeSet; + } + + private static Map detail(Map changeSet) { + Map detail = new HashMap<>(); + detail.put("table", TABLE); + if (changeSet != null) { + detail.put("changeSet", changeSet); + } + return detail; + } + + private static Map filter(String column, String condition, String value) { + Map filter = new LinkedHashMap<>(); + filter.put("column", column); + filter.put("condition", condition); + filter.put("value", value); + return filter; + } +} diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/EmailUtilsTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/EmailUtilsTest.java index f13679627a..b8bc19bb38 100644 --- a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/EmailUtilsTest.java +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/EmailUtilsTest.java @@ -1,15 +1,15 @@ package org.lowcoder.sdk.util; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Locale; -import org.junit.After; -import org.junit.Test; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; import org.lowcoder.sdk.constants.AuthSourceConstants; /** @@ -30,7 +30,7 @@ public class EmailUtilsTest { /** Restores whatever locale the JVM started with, so the Turkish test cannot leak into the others. */ private final Locale originalDefault = Locale.getDefault(); - @After + @AfterEach public void restoreDefaultLocale() { Locale.setDefault(originalDefault); } @@ -39,13 +39,13 @@ private void checkNormalize(String input, String expected) { String actual = EmailUtils.normalize(input); System.out.printf("normalize(%s) -> %s (expected %s)%n", quote(input), quote(actual), quote(expected)); - assertEquals("normalize(" + quote(input) + ")", expected, actual); + assertEquals(expected, actual, "normalize(" + quote(input) + ")"); } private void checkLooksLikeEmail(String input, boolean expected) { boolean actual = EmailUtils.looksLikeEmail(input); System.out.printf("looksLikeEmail(%s) -> %s (expected %s)%n", quote(input), actual, expected); - assertEquals("looksLikeEmail(" + quote(input) + ")", expected, actual); + assertEquals(expected, actual, "looksLikeEmail(" + quote(input) + ")"); } private static String quote(String value) { @@ -90,8 +90,8 @@ public void normalizeReturnsEmptyStringForNullAndBlank() { @Test public void normalizeDoesNotStripInteriorWhitespace() { checkNormalize(" John Doe@Example.com ", "john doe@example.com"); - assertFalse("a value with interior whitespace must still fail the shape check", - EmailUtils.looksLikeEmail(EmailUtils.normalize(" John Doe@Example.com "))); + assertFalse(EmailUtils.looksLikeEmail(EmailUtils.normalize(" John Doe@Example.com ")), + "a value with interior whitespace must still fail the shape check"); } /** @@ -109,10 +109,10 @@ public void normalizeIsIndependentOfTheDefaultLocale() { System.out.printf("under tr-TR: toLowerCase() -> %s, EmailUtils.normalize() -> %s%n", quote(localeSensitive), quote(normalized)); - assertEquals("normalize must use Locale.ROOT", "info@example.com", normalized); - assertFalse("sanity: the default-locale lower-case really does differ under tr-TR, " - + "so this test is actually exercising the difference", - localeSensitive.equals(normalized)); + assertEquals("info@example.com", normalized, "normalize must use Locale.ROOT"); + assertFalse(localeSensitive.equals(normalized), + "sanity: the default-locale lower-case really does differ under tr-TR, " + + "so this test is actually exercising the difference"); } // -------------------------------------------------- normalizeIfEmailSource @@ -141,8 +141,8 @@ public void normalizeIfEmailSourceLeavesOpaqueSubjectsByteExact() { String actual = EmailUtils.normalizeIfEmailSource(source, GITHUB_SUBJECT); System.out.printf("normalizeIfEmailSource(%s, %s) -> %s%n", quote(source), quote(GITHUB_SUBJECT), quote(actual)); - assertSame("source " + quote(source) + " must pass the subject through untouched", - GITHUB_SUBJECT, actual); + assertSame(GITHUB_SUBJECT, actual, + "source " + quote(source) + " must pass the subject through untouched"); } } @@ -156,7 +156,7 @@ public void normalizeIfEmailSourcePreservesNull() { for (String source : new String[] { AuthSourceConstants.EMAIL, AuthSourceConstants.GOOGLE, null }) { String actual = EmailUtils.normalizeIfEmailSource(source, null); System.out.printf("normalizeIfEmailSource(%s, null) -> %s%n", quote(source), quote(actual)); - assertNull("null must survive for source " + quote(source), actual); + assertNull(actual, "null must survive for source " + quote(source)); } } @@ -210,8 +210,8 @@ public void looksLikeEmailRejectsAnyWhitespaceAndRequiresNormalizeFirst() { checkLooksLikeEmail("user\t@example.com", false); checkLooksLikeEmail(" " + CANONICAL + " ", false); - assertTrue("normalize() first is what makes a padded address acceptable", - EmailUtils.looksLikeEmail(EmailUtils.normalize(" " + CANONICAL + " "))); + assertTrue(EmailUtils.looksLikeEmail(EmailUtils.normalize(" " + CANONICAL + " ")), + "normalize() first is what makes a padded address acceptable"); } /** diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/MediaTypeAndUriUtilsTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/MediaTypeAndUriUtilsTest.java new file mode 100644 index 0000000000..5194a5ac92 --- /dev/null +++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/MediaTypeAndUriUtilsTest.java @@ -0,0 +1,126 @@ +package org.lowcoder.sdk.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * {@link MediaTypeUtils} (the content type a stored material is served with) and {@link UriUtils} (the referer and the + * cookie domain of a request). + */ +public class MediaTypeAndUriUtilsTest { + + private static MockServerWebExchange exchange(String url, String referer) { + MockServerHttpRequest.BaseBuilder builder = MockServerHttpRequest.get(url); + if (referer != null) { + builder.header(UriUtils.REFERER, referer); + } + return MockServerWebExchange.from(builder.build()); + } + + @Test + public void knownLowerCaseExtensionsMapToTheirMediaTypeAndOthersToTheDefault() { + assertEquals(MediaType.IMAGE_JPEG, MediaTypeUtils.parse("photo.jpg")); + assertEquals(MediaType.IMAGE_JPEG, MediaTypeUtils.parse("photo.jpeg")); + assertEquals(MediaType.IMAGE_GIF, MediaTypeUtils.parse("a.gif")); + assertEquals(MediaType.IMAGE_PNG, MediaTypeUtils.parse("logo.png")); + assertEquals(MediaType.APPLICATION_PDF, MediaTypeUtils.parse("doc.pdf")); + assertEquals(new MediaType("image", "svg+xml"), MediaTypeUtils.parse("icon.svg")); + assertEquals(MediaType.APPLICATION_OCTET_STREAM, MediaTypeUtils.parse("archive.zip")); + assertEquals(MediaType.IMAGE_PNG, MediaTypeUtils.parse("many.dots.in.the.name.png"), "the last extension counts"); + assertEquals(MediaType.TEXT_PLAIN, MediaTypeUtils.parse("notes.txt", MediaType.TEXT_PLAIN), "the given default replaces octet-stream"); + assertNull(MediaTypeUtils.parse("notes.txt", null), "a null default is allowed"); + assertEquals(MediaType.IMAGE_PNG, MediaTypeUtils.getMediaType("png")); + assertEquals(MediaType.APPLICATION_OCTET_STREAM, MediaTypeUtils.getMediaType("exe")); + } + + @Test + public void aFileNameWithoutADotIsTreatedAsItsOwnExtensionAndABlankNameIsRejected() { + // behaviour (ruled): split on "." leaves the whole name as the last part + assertEquals(MediaType.IMAGE_PNG, MediaTypeUtils.parse("png")); + assertEquals(MediaType.APPLICATION_OCTET_STREAM, MediaTypeUtils.parse("README")); + assertThrows(IllegalArgumentException.class, () -> MediaTypeUtils.parse("")); + assertThrows(IllegalArgumentException.class, () -> MediaTypeUtils.parse(" ")); + assertThrows(IllegalArgumentException.class, () -> MediaTypeUtils.parse(null)); + } + + /** + * DEFECT pinned (new plan section 9 row "MediaTypeUtils is case-sensitive: IMG.JPG is served as + * application/octet-stream"; D-6, fix deferred). {@code getMediaType} switches on the extension as written + * (MediaTypeUtils.java:34-41), and the only caller, MaterialController.download (MaterialController.java:59), passes the + * stored file name as uploaded (MaterialApiServiceImpl.upload keeps it unchanged), so a file named {@code IMG_0001.JPG} or + * {@code LOGO.PNG} is served with {@code application/octet-stream}, also for the inline preview. A fix that lower-cases + * the extension turns these assertions red. + */ + @Test + public void anUpperCaseExtensionIsNotRecognisedAndGetsOctetStreamD() { + for (String name : new String[] {"LOGO.PNG", "IMG_0001.JPG", "Photo.Jpeg", "SCAN.PDF", "ICON.SVG", "a.GIF"}) { + MediaType type = MediaTypeUtils.parse(name); + System.out.println("[MediaTypeAndUriUtilsTest] " + name + " -> " + type); + assertEquals(MediaType.APPLICATION_OCTET_STREAM, type, name); + } + } + + @Test + public void theRefererDomainIsTheLowerCasedHostOrEmpty() { + assertEquals("app.example.com", UriUtils.getRefererDomainFromRequest(exchange("http://x/", "https://App.Example.COM:8443/path?q=1"))); + assertEquals("", UriUtils.getRefererDomainFromRequest(exchange("http://x/", null))); + assertEquals("", UriUtils.getRefererDomainFromRequest(exchange("http://x/", " "))); + assertEquals("", UriUtils.getRefererDomainFromRequest(exchange("http://x/", "mailto:someone@example.com")), "a referer without a host"); + assertEquals(URI.create("https://a.b/c"), UriUtils.getRefererURI(exchange("http://x/", "https://a.b/c").getRequest())); + assertNull(UriUtils.getRefererURI(exchange("http://x/", null).getRequest())); + } + + /** + * Observation (callers reported to the coordinator): the referer header is client-controlled and is parsed with + * {@code URI.create}, so a value that is not a valid URI (a space is enough) throws an IllegalArgumentException. Callers: + * GlobalContextFilter.java:111 (every request, inside the context write) and CookieHelper.java:50 (the session cookie). + */ + @Test + public void anInvalidRefererThrowsAnIllegalArgumentExceptionToItsCaller() { + for (String referer : new String[] {"http://bad host/", "http://a b", ""); + } + + @Test + void noReferer_andAParsableReferer_reachTheHandler() { + assertThat(send(null).getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(send(GOOD_REFERER).getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(handled.get()).isEqualTo(2); + } + + /** + * Pins plan §9 row "unparsable Referer breaks every request": the request never reaches the handler and the client + * gets an error status instead of the normal answer. The assertions state what the client receives (see the output + * line of this test for the status and body). + */ + @ParameterizedTest + @MethodSource("unparsableReferers") + void unparsableReferer_failsEveryRequestBeforeTheHandler_pinsTheSection9Row(String referer) { + MockServerHttpResponse response = send(referer); + + assertThat(handled.get()).as("the handler is never reached").isZero(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(lastBody).as("generic coded error, not the answer to the request").contains("\"code\":" + BizError.INTERNAL_SERVER_ERROR.getBizErrorCode()); + } + + /** The same parse in {@code CookieHelper} (CookieHelper:50): saving or clearing the auth cookie throws. */ + @ParameterizedTest + @MethodSource("unparsableReferers") + void unparsableReferer_makesTheCookieHelperThrow_pinsTheSection9Row(String referer) { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(PATH).header(HttpHeaders.REFERER, referer)); + + assertThatThrownBy(() -> cookieHelper.saveCookie("token", exchange)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(referer); + assertThatThrownBy(() -> cookieHelper.clearCookie(exchange)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining(referer); + } + + @Test + void cookieHelper_withAParsableReferer_marksTheCookieSecureForHttps() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(PATH).header(HttpHeaders.REFERER, GOOD_REFERER)); + + cookieHelper.saveCookie("token", exchange); + + List cookies = exchange.getResponse().getCookies().values().stream().flatMap(List::stream).toList(); + assertThat(cookies).singleElement().satisfies(c -> { + assertThat(c.isSecure()).isTrue(); + assertThat(c.getSameSite()).isEqualTo("None"); + }); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/filter/RequestFiltersTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/filter/RequestFiltersTest.java new file mode 100644 index 0000000000..ee28750584 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/filter/RequestFiltersTest.java @@ -0,0 +1,138 @@ +package org.lowcoder.api.framework.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.NoSuchElementException; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.lowcoder.api.framework.filter.FilterTestSupport.ChainProbe; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.domain.user.model.UserStatus; +import org.lowcoder.domain.user.service.UserStatusService; +import org.lowcoder.sdk.constants.Authentication; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.WebFilter; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** Tests of the small filters: {@link UserBanFilter}, {@link RequestCostFilter}, {@link ReactiveRequestContextFilter}. */ +class RequestFiltersTest { + + private SessionUserService sessionUserService; + private UserStatusService userStatusService; + private UserBanFilter banFilter; + + @BeforeEach + void setUp() { + sessionUserService = mock(SessionUserService.class); + userStatusService = mock(UserStatusService.class); + banFilter = new UserBanFilter(); + ReflectionTestUtils.setField(banFilter, "sessionUserService", sessionUserService); + ReflectionTestUtils.setField(banFilter, "userStatusService", userStatusService); + } + + // ------------------------------------------------------------------ UserBan + + /** Catches a banned user passing: the chain must NOT be invoked and the error is USER_BANNED. */ + @Test + void userBan_bannedUser_failsWithUserBanned_andTheChainIsNotInvoked() { + when(sessionUserService.getVisitorId()).thenReturn(Mono.just("user-1")); + when(userStatusService.findByUserId("user-1")).thenReturn(Mono.just(UserStatus.builder().banned(true).build())); + ChainProbe chain = new ChainProbe(); + + StepVerifier.create(banFilter.filter(FilterTestSupport.exchange("/api/x"), chain)) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(BizError.USER_BANNED); + System.out.println("[RequestFiltersTest] banned user -> " + error.getMessage()); + }) + .verify(); + + assertThat(chain.invoked).hasValue(0); + } + + /** Catches the status lookup for anonymous visitors, and users without a status row (not banned) being blocked. */ + @Test + void userBan_anonymousUserIsSkipped_missingStatusIsNotBanned_unbannedPasses() { + ChainProbe chain = new ChainProbe(); + + when(sessionUserService.getVisitorId()).thenReturn(Mono.just(Authentication.ANONYMOUS_USER_ID)); + StepVerifier.create(banFilter.filter(FilterTestSupport.exchange("/api/x"), chain)).verifyComplete(); + verify(userStatusService, never()).findByUserId(org.mockito.ArgumentMatchers.anyString()); + assertThat(chain.invoked).as("anonymous passes").hasValue(1); + + when(sessionUserService.getVisitorId()).thenReturn(Mono.just("user-2")); + when(userStatusService.findByUserId("user-2")).thenReturn(Mono.empty()); + StepVerifier.create(banFilter.filter(FilterTestSupport.exchange("/api/x"), chain)).verifyComplete(); + assertThat(chain.invoked).as("no status row passes").hasValue(2); + + when(userStatusService.findByUserId("user-2")).thenReturn(Mono.just(UserStatus.builder().banned(false).build())); + StepVerifier.create(banFilter.filter(FilterTestSupport.exchange("/api/x"), chain)).verifyComplete(); + assertThat(chain.invoked).as("not banned passes").hasValue(3); + System.out.println("[RequestFiltersTest] anonymous, missing status and unbanned users reach the chain"); + } + + /** Catches a filter order that no longer follows {@link FilterOrder}. */ + @ParameterizedTest + @EnumSource(value = FilterOrder.class, names = {"REQUEST_COST", "API_DELAY_FILTER", "USER_BAN"}) + void orderedFilters_useTheirFilterOrder(FilterOrder order) { + Ordered filter = switch (order) { + case REQUEST_COST -> new RequestCostFilter(); + case API_DELAY_FILTER -> new APIDelayFilter(); + case USER_BAN -> banFilter; + default -> throw new IllegalArgumentException(order.name()); + }; + + assertThat(filter.getOrder()).isEqualTo(order.getOrder()); + System.out.println("[RequestFiltersTest] " + filter.getClass().getSimpleName() + " order " + filter.getOrder()); + } + + // -------------------------------------------------------------- RequestCost + + /** Catches the cost header missing or duplicated: it is added when the response is committed. */ + @Test + void requestCost_addsOneCostHeader_atCommit() { + MockServerWebExchange exchange = FilterTestSupport.exchange("/api/x"); + WebFilter filter = new RequestCostFilter(); + + StepVerifier.create(filter.filter(exchange, ex -> ex.getResponse().setComplete())).verifyComplete(); + + assertThat(exchange.getResponse().getHeaders().get("X-REQUEST-COST")).hasSize(1); + assertThat(exchange.getResponse().getHeaders().getFirst("X-REQUEST-COST")).matches("\\d+ms"); + System.out.println("[RequestFiltersTest] cost header " + exchange.getResponse().getHeaders().getFirst("X-REQUEST-COST")); + } + + // -------------------------------------------------- ReactiveRequestContext + + /** Catches the request not being put into the Reactor context for the chain. */ + @Test + void reactiveRequestContext_putsTheRequestIntoTheContext_forTheChain() { + MockServerWebExchange exchange = FilterTestSupport.exchange("/api/context"); + AtomicReference seen = new AtomicReference<>(); + WebFilter filter = new ReactiveRequestContextFilter(); + + StepVerifier.create(filter.filter(exchange, ex -> ReactiveRequestContextHolder.getRequest().doOnNext(seen::set).then())).verifyComplete(); + + assertThat(seen.get()).isSameAs(exchange.getRequest()); + } + + /** Pins today's behaviour (no §9 row): outside the filter the holder fails with NoSuchElementException. */ + @Test + void reactiveRequestContextHolder_withoutTheFilter_failsWithNoSuchElement() { + StepVerifier.create(ReactiveRequestContextHolder.getRequest()).expectError(NoSuchElementException.class).verify(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/filter/ThrottlingFilterTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/filter/ThrottlingFilterTest.java new file mode 100644 index 0000000000..fc07245033 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/filter/ThrottlingFilterTest.java @@ -0,0 +1,157 @@ +package org.lowcoder.api.framework.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.framework.filter.FilterTestSupport.ChainProbe; +import org.lowcoder.sdk.config.dynamic.Conf; +import org.lowcoder.sdk.config.dynamic.ConfigCenter; +import org.lowcoder.sdk.config.dynamic.ConfigInstance; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.springframework.test.util.ReflectionTestUtils; + +import com.google.common.util.concurrent.RateLimiter; + +import reactor.test.StepVerifier; + +/** + * Tests of {@link ThrottlingFilter}. A limiter grants every request for one second after it was created or its rate + * changed; the tests end that window through {@code ReflectionTestUtils} on the wrapper's {@code initMillis} so they + * are deterministic. Limit: after the window two requests in a row are throttled only while they arrive within the + * same second (the lowest rate is one request per second), so the permit is taken right before the check. + */ +class ThrottlingFilterTest { + + private static final String URL_A = "/api/a"; + private static final String URL_B = "/api/b"; + + private final AtomicReference> urlLimits = new AtomicReference<>(Collections.emptyMap()); + + private ThrottlingFilter filter(int defaultRate) { + ThrottlingFilter filter = new ThrottlingFilter(); + ReflectionTestUtils.setField(filter, "defaultApiRateLimit", defaultRate); + ReflectionTestUtils.setField(filter, "urlRateLimiter", (java.util.function.Supplier>) urlLimits::get); + return filter; + } + + @SuppressWarnings("unchecked") + private static Object wrapper(ThrottlingFilter filter, String url) { + return ((Map) ReflectionTestUtils.getField(filter, "rateLimiterMap")).get(url); + } + + private static double rate(Object wrapper) { + return ((RateLimiter) ReflectionTestUtils.getField(wrapper, "rateLimiter")).getRate(); + } + + private static void endGraceWindow(Object wrapper) { + ReflectionTestUtils.setField(wrapper, "initMillis", 0L); + } + + private void call(ThrottlingFilter filter, String url, ChainProbe chain) { + StepVerifier.create(filter.filter(FilterTestSupport.exchange(url), chain)).verifyComplete(); + } + + private void expectThrottled(ThrottlingFilter filter, String url, ChainProbe chain) { + int before = chain.invoked.get(); + StepVerifier.create(filter.filter(FilterTestSupport.exchange(url), chain)) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(BizError.REQUEST_THROTTLED); + System.out.println("[ThrottlingFilterTest] " + url + " -> " + error.getMessage()); + }) + .verify(); + assertThat(chain.invoked).as("a throttled request does not reach the chain").hasValue(before); + } + + /** Catches no throttling at all, and one shared bucket for every URL. */ + @Test + void defaultRate_throttlesAfterTheGraceWindow_perUrl() { + ThrottlingFilter filter = filter(1); + ChainProbe chain = new ChainProbe(); + + call(filter, URL_A, chain); + call(filter, URL_A, chain); + assertThat(chain.invoked).as("the grace window grants every request").hasValue(2); + + endGraceWindow(wrapper(filter, URL_A)); + call(filter, URL_A, chain); + expectThrottled(filter, URL_A, chain); + + call(filter, URL_B, chain); + assertThat(wrapper(filter, URL_B)).as("own limiter per URL").isNotSameAs(wrapper(filter, URL_A)); + assertThat(chain.invoked).hasValue(4); + } + + /** Catches the per-URL limit being ignored: the configured rate replaces the default for that URL only. */ + @Test + void urlRateLimiter_overridesTheDefaultRate_forThatUrlOnly() { + urlLimits.set(Map.of(URL_A, 7)); + ThrottlingFilter filter = filter(1); + ChainProbe chain = new ChainProbe(); + + call(filter, URL_A, chain); + call(filter, URL_B, chain); + + assertThat(rate(wrapper(filter, URL_A))).isCloseTo(7.0, org.assertj.core.api.Assertions.within(1e-6)); + assertThat(rate(wrapper(filter, URL_B))).isCloseTo(1.0, org.assertj.core.api.Assertions.within(1e-6)); + System.out.println("[ThrottlingFilterTest] rates a=" + rate(wrapper(filter, URL_A)) + " b=" + rate(wrapper(filter, URL_B))); + } + + /** + * Catches the rate change: an unchanged rate keeps the same limiter (and its state); a changed rate updates it in + * place and restarts the grace window, so a throttled URL is granted again right after the change. + */ + @Test + void rateChange_updatesTheLimiterInPlace_andRestartsTheGraceWindow() { + ThrottlingFilter filter = filter(1); + ChainProbe chain = new ChainProbe(); + call(filter, URL_A, chain); + Object limiter = wrapper(filter, URL_A); + call(filter, URL_A, chain); + assertThat(wrapper(filter, URL_A)).as("unchanged rate keeps the limiter").isSameAs(limiter); + + endGraceWindow(limiter); + call(filter, URL_A, chain); + expectThrottled(filter, URL_A, chain); + assertThat((long) ReflectionTestUtils.getField(limiter, "initMillis")).isZero(); + + urlLimits.set(Map.of(URL_A, 50)); + call(filter, URL_A, chain); + + assertThat(wrapper(filter, URL_A)).as("updated in place").isSameAs(limiter); + assertThat(rate(limiter)).isCloseTo(50.0, org.assertj.core.api.Assertions.within(1e-6)); + assertThat((long) ReflectionTestUtils.getField(limiter, "initMillis")).as("grace window restarted").isPositive(); + } + + /** Catches the init reading the wrong config key, types or default. */ + @Test + @SuppressWarnings("unchecked") + void init_readsTheUrlRateLimiterFromTheThresholdConfig() { + ConfigCenter configCenter = mock(ConfigCenter.class); + ConfigInstance threshold = mock(ConfigInstance.class); + Conf> conf = mock(Conf.class); + when(configCenter.threshold()).thenReturn(threshold); + when(threshold.ofMap(eq("urlRateLimiter"), eq(String.class), eq(Integer.class), eq(Map.of()))).thenReturn(conf); + when(conf.get()).thenReturn(new HashMap<>(Map.of(URL_A, 9))); + ThrottlingFilter filter = new ThrottlingFilter(); + ReflectionTestUtils.setField(filter, "defaultApiRateLimit", 2); + ReflectionTestUtils.setField(filter, "configCenter", configCenter); + + ReflectionTestUtils.invokeMethod(filter, "init"); + call(filter, URL_A, new ChainProbe()); + + verify(threshold).ofMap(eq("urlRateLimiter"), eq(String.class), eq(Integer.class), eq(Map.of())); + assertThat(rate(wrapper(filter, URL_A))).isCloseTo(9.0, org.assertj.core.api.Assertions.within(1e-6)); + assertThat(filter.getOrder()).isEqualTo(FilterOrder.THROTTLING.getOrder()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/filter/UserSessionPersistenceFilterTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/filter/UserSessionPersistenceFilterTest.java new file mode 100644 index 0000000000..14e7b2a058 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/filter/UserSessionPersistenceFilterTest.java @@ -0,0 +1,444 @@ +package org.lowcoder.api.framework.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.lowcoder.api.authentication.request.AuthRequest; +import org.lowcoder.api.authentication.request.AuthRequestFactory; +import org.lowcoder.api.authentication.request.oauth2.OAuth2RequestContext; +import org.lowcoder.api.authentication.service.AuthenticationApiServiceImpl; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.domain.authentication.AuthenticationService; +import org.lowcoder.domain.authentication.FindAuthConfig; +import org.lowcoder.domain.authentication.context.AuthRequestContext; +import org.lowcoder.domain.user.model.AuthUser; +import org.lowcoder.domain.user.model.Connection; +import org.lowcoder.domain.user.model.ConnectionAuthToken; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.sdk.auth.AbstractAuthConfig; +import org.lowcoder.sdk.util.CookieHelper; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit tests of {@link UserSessionPersistenceFilter}: session resolution, authentication context, validity + * extension and the OAuth access-token refresh. + * + *

    Defects pinned (owner decision D-6: today's behaviour is asserted; a fix changes the test on purpose): + *

      + *
    • plan §9 row "UserSessionPersistenceFilter: ... the request is dropped with an empty response": an expired + * token whose auth config cannot be found or looked up makes the filter complete empty without calling the + * chain;
    • + *
    • plan §9 row "UserSessionPersistenceFilter: ... NPE for a connection with a null authId ... without a + * stored token".
    • + *
    + * Expiry is always computed relative to {@code Instant.now()} with a one-day margin, so no test depends on timing. + */ +@ExtendWith(MockitoExtension.class) +class UserSessionPersistenceFilterTest { + + private static final String COOKIE_TOKEN = "cookie-token"; + private static final String USER_ID = "user-1"; + private static final String ORG_ID = "org-1"; + private static final String OTHER_ORG_ID = "org-2"; + private static final String OAUTH_AUTH_ID = "oauth-cfg"; + private static final String OTHER_AUTH_ID = "other-cfg"; + private static final String DEFAULT_AUTH_ID = AuthenticationService.DEFAULT_AUTH_CONFIG.getId(); + private static final String REFRESH_TOKEN = "refresh-1"; + private static final String EVENT_CHAIN = "chain"; + private static final String EVENT_EXTEND = "extend"; + private static final long ONE_DAY_SECONDS = 24L * 3600L; + + @Mock private SessionUserService sessionUserService; + @Mock private UserService userService; + @Mock private CookieHelper cookieHelper; + @Mock private AuthenticationService authenticationService; + @Mock private AuthenticationApiServiceImpl authenticationApiService; + @Mock private AuthRequestFactory authRequestFactory; + @Mock private AuthRequest authRequest; + + private UserSessionPersistenceFilter filter; + private ServerWebExchange exchange; + private final List events = new ArrayList<>(); + private final AtomicReference seenAuthentication = new AtomicReference<>(); + + private static class StubAuthConfig extends AbstractAuthConfig { + StubAuthConfig(String id) { + super(id, "GOOGLE", "GOOGLE", true, true, "STUB"); + } + } + + @BeforeEach + void setUp() { + filter = new UserSessionPersistenceFilter(sessionUserService, userService, cookieHelper, authenticationService, + authenticationApiService, authRequestFactory); + exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/x").build()); + lenient().when(cookieHelper.getCookieToken(exchange)).thenReturn(COOKIE_TOKEN); + lenient().when(sessionUserService.extendValidity(COOKIE_TOKEN)) + .thenReturn(Mono.fromRunnable(() -> events.add(EVENT_EXTEND))); + } + + // ---------------------------------------------------------------- helpers + + /** A chain that records its invocation and the authentication visible in its reactor context. */ + private final WebFilterChain recordingChain = ex -> Mono.defer(() -> { + events.add(EVENT_CHAIN); + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .doOnNext(seenAuthentication::set) + .then(); + }); + + private void sessionIs(User user) { + when(sessionUserService.resolveSessionUserFromCookie(COOKIE_TOKEN)).thenReturn(Mono.just(user)); + } + + private static Connection connection(String authId, ConnectionAuthToken token, String... orgIds) { + return Connection.builder().authId(authId).source("GOOGLE").rawId("raw-" + authId).name("conn") + .orgIds(new HashSet<>(Set.of(orgIds))).authConnectionAuthToken(token) + .tokens(new HashSet<>(Set.of("t1", "t2"))).build(); + } + + private static ConnectionAuthToken token(long expireAt, String refreshToken) { + return ConnectionAuthToken.builder().accessToken("access").expireAt(expireAt).refreshToken(refreshToken).build(); + } + + private static long expiredAt() { + return Instant.now().getEpochSecond() - ONE_DAY_SECONDS; + } + + private static long validUntil() { + return Instant.now().getEpochSecond() + ONE_DAY_SECONDS; + } + + private static User userWith(String activeAuthId, Connection... connections) { + return User.builder().id(USER_ID).name("User One").activeAuthId(activeAuthId) + .connections(new HashSet<>(Set.of(connections))).build(); + } + + private static User expiredOauthUser(String refreshToken, String... orgIds) { + return userWith(OAUTH_AUTH_ID, connection(OAUTH_AUTH_ID, token(expiredAt(), refreshToken), orgIds)); + } + + private void configFound(String orgId, String authId) { + when(authenticationService.findAllAuthConfigs(orgId, true)) + .thenReturn(Flux.just(new FindAuthConfig(new StubAuthConfig(authId), null))); + } + + private void assertChainRanAs(User user) { + assertThat(events).containsExactly(EVENT_CHAIN, EVENT_EXTEND); + assertThat(seenAuthentication.get().getPrincipal()).isSameAs(user); + } + + // ----------------------------------------------------------- session found + + /** Catches anonymous requests being dropped, authenticated, or extending a session that does not exist. */ + @Test + void filter_noSession_chainContinuesUnauthenticated_andNoValidityExtension() { + when(sessionUserService.resolveSessionUserFromCookie(COOKIE_TOKEN)).thenReturn(Mono.empty()); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertThat(events).containsExactly(EVENT_CHAIN); + assertThat(seenAuthentication.get()).isNull(); + verify(sessionUserService, never()).extendValidity(anyString()); + System.out.println("[UserSessionPersistenceFilterTest] no session -> chain ran once, no authentication, no extendValidity"); + } + + /** Catches a missing authentication in the chain's context, or the validity never being extended after the chain. */ + @Test + void filter_sessionFound_putsTheUserInTheContext_andExtendsValidityAfterTheChain() { + User user = userWith(DEFAULT_AUTH_ID, connection(DEFAULT_AUTH_ID, token(0L, null), ORG_ID)); + sessionIs(user); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertChainRanAs(user); + System.out.println("[UserSessionPersistenceFilterTest] events=" + events + " principal=" + seenAuthentication.get().getPrincipal()); + } + + /** Catches a failed request extending the session: the extension only runs after the chain completed. */ + @Test + void filter_chainError_propagates_andDoesNotExtendValidity() { + User user = userWith(DEFAULT_AUTH_ID, connection(DEFAULT_AUTH_ID, token(0L, null), ORG_ID)); + sessionIs(user); + WebFilterChain failing = ex -> Mono.defer(() -> { + events.add(EVENT_CHAIN); + return Mono.error(new IllegalStateException("handler failed")); + }); + + StepVerifier.create(filter.filter(exchange, failing)).expectError(IllegalStateException.class).verify(); + + assertThat(events).containsExactly(EVENT_CHAIN); + System.out.println("[UserSessionPersistenceFilterTest] chain error propagated, events=" + events); + } + + // ------------------------------------------------------- no refresh needed + + /** Catches a refresh attempted for a user whose active auth id has no connection. */ + @Test + void filter_noConnectionMatchingTheActiveAuthId_continuesWithoutRefresh() { + User user = userWith("missing-auth-id", connection(OAUTH_AUTH_ID, token(expiredAt(), REFRESH_TOKEN), ORG_ID)); + sessionIs(user); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertChainRanAs(user); + verifyNoInteractions(authenticationService); + System.out.println("[UserSessionPersistenceFilterTest] no active connection -> no auth config lookup"); + } + + /** Catches password (default EMAIL) logins being sent to an OAuth refresh. */ + @Test + void filter_defaultEmailConnection_isNeverRefreshed() { + User user = userWith(DEFAULT_AUTH_ID, connection(DEFAULT_AUTH_ID, token(expiredAt(), REFRESH_TOKEN), ORG_ID)); + sessionIs(user); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertChainRanAs(user); + verifyNoInteractions(authenticationService); + System.out.println("[UserSessionPersistenceFilterTest] default connection with expired token -> not refreshed"); + } + + /** Catches an access token without expiry (expireAt 0) being refreshed. */ + @Test + void filter_expireAtZero_isNeverRefreshed() { + User user = userWith(OAUTH_AUTH_ID, connection(OAUTH_AUTH_ID, token(0L, REFRESH_TOKEN), ORG_ID)); + sessionIs(user); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertChainRanAs(user); + verifyNoInteractions(authenticationService); + System.out.println("[UserSessionPersistenceFilterTest] expireAt=0 -> not refreshed"); + } + + /** Catches a still valid token being refreshed on every request. */ + @Test + void filter_notYetExpiredToken_isNotRefreshed() { + User user = userWith(OAUTH_AUTH_ID, connection(OAUTH_AUTH_ID, token(validUntil(), REFRESH_TOKEN), ORG_ID)); + sessionIs(user); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertChainRanAs(user); + verifyNoInteractions(authenticationService); + System.out.println("[UserSessionPersistenceFilterTest] token valid for a day -> not refreshed"); + } + + /** Catches a refresh without any organisation to resolve the auth config in. */ + @Test + void filter_expiredTokenWithoutOrgIds_isNotRefreshed() { + User user = expiredOauthUser(REFRESH_TOKEN); + sessionIs(user); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertChainRanAs(user); + verifyNoInteractions(authenticationService); + System.out.println("[UserSessionPersistenceFilterTest] expired token, no org ids -> not refreshed"); + } + + // ----------------------------------------------------------------- refresh + + /** Catches stale tokens: the refresh runs through the auth request and the refreshed connection is persisted. */ + @Test + void filter_expiredToken_refreshesViaTheAuthRequest_updatesTheConnection_andPersistsTheUser() { + User user = expiredOauthUser(REFRESH_TOKEN, ORG_ID); + User persisted = User.builder().id(USER_ID).name("persisted").build(); + sessionIs(user); + configFound(ORG_ID, OAUTH_AUTH_ID); + when(authRequestFactory.build(any())).thenReturn(Mono.just(authRequest)); + AuthUser refreshed = AuthUser.builder().uid("raw").build(); + when(authRequest.refresh(REFRESH_TOKEN)).thenReturn(Mono.just(refreshed)); + when(userService.update(USER_ID, user)).thenReturn(Mono.just(persisted)); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + ArgumentCaptor context = ArgumentCaptor.forClass(AuthRequestContext.class); + verify(authRequestFactory).build(context.capture()); + assertThat(context.getValue()).isInstanceOf(OAuth2RequestContext.class); + assertThat(context.getValue().getOrgId()).isEqualTo(ORG_ID); + assertThat(context.getValue().getAuthConfig().getId()).isEqualTo(OAUTH_AUTH_ID); + assertThat(refreshed.getAuthContext()).isSameAs(context.getValue()); + verify(authenticationApiService).updateConnection(refreshed, user); + verify(userService).update(USER_ID, user); + assertChainRanAs(persisted); + System.out.println("[UserSessionPersistenceFilterTest] refreshed, connection updated, request continued as " + persisted.getName()); + } + + /** Catches the auth config being chosen by position: only the config with the connection's auth id is used. */ + @Test + void filter_expiredToken_usesOnlyTheAuthConfigMatchingTheConnection() { + User user = expiredOauthUser(REFRESH_TOKEN, ORG_ID); + sessionIs(user); + when(authenticationService.findAllAuthConfigs(ORG_ID, true)).thenReturn(Flux.just( + new FindAuthConfig(new StubAuthConfig(OTHER_AUTH_ID), null), + new FindAuthConfig(new StubAuthConfig(OAUTH_AUTH_ID), null))); + when(authRequestFactory.build(any())).thenReturn(Mono.just(authRequest)); + when(authRequest.refresh(REFRESH_TOKEN)).thenReturn(Mono.just(AuthUser.builder().uid("raw").build())); + when(userService.update(eq(USER_ID), any())).thenReturn(Mono.just(user)); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + ArgumentCaptor context = ArgumentCaptor.forClass(AuthRequestContext.class); + verify(authRequestFactory, times(1)).build(context.capture()); + assertThat(context.getValue().getAuthConfig().getId()).isEqualTo(OAUTH_AUTH_ID); + System.out.println("[UserSessionPersistenceFilterTest] built the request for " + context.getValue().getAuthConfig().getId()); + } + + // ------------------------------------------------------------ refresh failure + + private void assertRefreshFailedButRequestContinued(User user) { + verify(sessionUserService).removeUserSession("t1"); + verify(sessionUserService).removeUserSession("t2"); + verify(userService, never()).update(anyString(), any()); + assertChainRanAs(user); + } + + private void refreshableSetup(User user) { + sessionIs(user); + configFound(ORG_ID, OAUTH_AUTH_ID); + when(authRequestFactory.build(any())).thenReturn(Mono.just(authRequest)); + lenient().when(sessionUserService.removeUserSession(anyString())).thenReturn(Mono.empty()); + } + + /** + * Catches a dead session surviving. Today the connection's sessions are removed and the request still continues + * as the old user (not a logout of the current request). + */ + @Test + void filter_expiredToken_emptyRefreshToken_removesTheConnectionsTokens_andContinuesAsTheOldUser() { + User user = expiredOauthUser("", ORG_ID); + refreshableSetup(user); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + verify(authRequest, never()).refresh(anyString()); + assertRefreshFailedButRequestContinued(user); + System.out.println("[UserSessionPersistenceFilterTest] empty refresh token -> sessions t1,t2 removed, request continued"); + } + + /** Catches a provider error propagating to the client, or leaving the old sessions valid. */ + @Test + void filter_expiredToken_refreshFails_removesTheConnectionsTokens_andContinuesAsTheOldUser() { + User user = expiredOauthUser(REFRESH_TOKEN, ORG_ID); + refreshableSetup(user); + when(authRequest.refresh(REFRESH_TOKEN)).thenReturn(Mono.error(new IllegalStateException("provider down"))); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertRefreshFailedButRequestContinued(user); + System.out.println("[UserSessionPersistenceFilterTest] provider error -> sessions removed, request continued"); + } + + /** Catches an empty refresh answer (null after block) being treated as success. */ + @Test + void filter_expiredToken_refreshReturnsNothing_removesTheConnectionsTokens() { + User user = expiredOauthUser(REFRESH_TOKEN, ORG_ID); + refreshableSetup(user); + when(authRequest.refresh(REFRESH_TOKEN)).thenReturn(Mono.empty()); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertRefreshFailedButRequestContinued(user); + System.out.println("[UserSessionPersistenceFilterTest] empty refresh answer -> sessions removed, request continued"); + } + + // ------------------------------------------------------- pinned defects + + /** + * Pins the plan §9 defect "UserSessionPersistenceFilter: ... the request is dropped with an empty response": + * with an expired token and no auth config of the connection's id, the refresh yields no user, so the chain is + * never called, validity is not extended and the filter's Mono completes empty. A fix changes this test on purpose. + */ + @Test + void filter_expiredToken_noMatchingAuthConfig_dropsTheRequest_pinsDroppedRequestDefect() { + User user = expiredOauthUser(REFRESH_TOKEN, ORG_ID); + sessionIs(user); + configFound(ORG_ID, OTHER_AUTH_ID); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertThat(events).isEmpty(); + verify(sessionUserService, never()).extendValidity(anyString()); + verifyNoInteractions(authRequestFactory); + System.out.println("[UserSessionPersistenceFilterTest] no matching config -> chain never called, empty completion (today's behaviour)"); + } + + /** Pins the same plan §9 dropped-request defect for a failing auth-config lookup (error swallowed into empty). */ + @Test + void filter_expiredToken_authConfigLookupFails_dropsTheRequest_pinsDroppedRequestDefect() { + User user = expiredOauthUser(REFRESH_TOKEN, ORG_ID); + sessionIs(user); + when(authenticationService.findAllAuthConfigs(ORG_ID, true)).thenReturn(Flux.error(new IllegalStateException("db down"))); + + StepVerifier.create(filter.filter(exchange, recordingChain)).verifyComplete(); + + assertThat(events).isEmpty(); + verify(sessionUserService, never()).extendValidity(anyString()); + System.out.println("[UserSessionPersistenceFilterTest] lookup error -> chain never called, empty completion (today's behaviour)"); + } + + /** + * Pins the plan §9 NPE defect (connection with a null authId): the lookup of the active connection dereferences + * {@code getAuthId()}, so every request of that session fails and the chain is not called. A fix changes this test. + */ + @Test + void filter_connectionWithNullAuthId_failsWithNpe_pinsNullAuthIdDefect() { + User user = userWith(OAUTH_AUTH_ID, connection(null, token(validUntil(), REFRESH_TOKEN), ORG_ID)); + sessionIs(user); + + StepVerifier.create(filter.filter(exchange, recordingChain)).expectError(NullPointerException.class).verify(); + + assertThat(events).isEmpty(); + System.out.println("[UserSessionPersistenceFilterTest] null authId connection -> NullPointerException, chain not called"); + } + + /** + * Pins the plan §9 NPE defect (non-default connection without a stored token): reading the expiry dereferences + * the missing token, so the request fails and the chain is not called. A fix changes this test on purpose. + */ + @Test + void filter_nonDefaultConnectionWithoutStoredToken_failsWithNpe_pinsMissingTokenDefect() { + User user = userWith(OAUTH_AUTH_ID, connection(OAUTH_AUTH_ID, null, ORG_ID)); + sessionIs(user); + + StepVerifier.create(filter.filter(exchange, recordingChain)).expectError(NullPointerException.class).verify(); + + assertThat(events).isEmpty(); + System.out.println("[UserSessionPersistenceFilterTest] connection without token -> NullPointerException, chain not called"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/LowcoderPluginManagerTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/LowcoderPluginManagerTest.java new file mode 100644 index 0000000000..22924bb74a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/LowcoderPluginManagerTest.java @@ -0,0 +1,256 @@ +package org.lowcoder.api.framework.plugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.lowcoder.api.framework.plugin.PluginTestSupport.RecordingPlugin; +import org.lowcoder.api.framework.plugin.PluginTestSupport.SameIdPlugin; +import org.lowcoder.api.framework.plugin.PluginTestSupport.SecondPlugin; +import org.lowcoder.api.framework.plugin.endpoint.PluginEndpointHandler; +import org.lowcoder.infra.config.model.ServerConfig; +import org.lowcoder.infra.config.repository.ServerConfigRepository; +import org.lowcoder.plugin.api.LowcoderPlugin; +import org.lowcoder.plugin.api.LowcoderServices; +import org.lowcoder.plugin.api.PluginEndpoint; +import org.lowcoder.plugin.api.event.LowcoderEvent; +import org.lowcoder.sdk.config.CommonConfig; +import org.springframework.boot.system.ApplicationHome; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.test.util.ReflectionTestUtils; + +import reactor.core.publisher.Mono; + +/** + * Tests of {@link LowcoderPluginManager}, {@link PluginExecutor} and {@link SharedPluginServices}. + * Plugins loaded from a real jar record, inside {@code load()}, the thread name, context class loader, environment and + * services they were given; the test reads them through a bounded {@code future.get}. The {@code loadOrder} sort is + * not observable (every plugin gets its own thread and the order of thread starts is not a guarantee), so it has no test. + */ +class LowcoderPluginManagerTest { + + private static final long WAIT_SECONDS = 20; + + @TempDir + Path tempDir; + + private final LowcoderServices services = mock(LowcoderServices.class); + + private StandardEnvironment environment(Map properties) { + StandardEnvironment env = new StandardEnvironment(); + env.getPropertySources().addFirst(new MapPropertySource("test-properties", properties)); + // a source that cannot enumerate its names must be skipped, not fail the environment scan + env.getPropertySources().addLast(new org.springframework.core.env.PropertySource("opaque", new Object()) { + @Override + public Object getProperty(String name) { + return null; + } + }); + return env; + } + + private PluginLoader jarLoader(String dirName) throws IOException { + Path root = tempDir.resolve(dirName); + PluginTestSupport.jar(root, "rec.jar", List.of(RecordingPlugin.class), RecordingPlugin.class); + PluginTestSupport.jar(root, "second.jar", List.of(SecondPlugin.class), RecordingPlugin.class, SecondPlugin.class); + CommonConfig common = new CommonConfig(); + common.setPluginDirs(List.of(root.toString())); + ApplicationHome home = mock(ApplicationHome.class); + return new PathBasedPluginLoader(common, home); + } + + @SuppressWarnings("unchecked") + private static Object[] recorded(LowcoderPlugin plugin) throws Exception { + return ((CompletableFuture) plugin.pluginInfo()).get(WAIT_SECONDS, TimeUnit.SECONDS); + } + + /** + * Catches the plugin thread not being named after the plugin, not getting the plugin's class loader as context + * class loader, or getting the wrong environment slice (prefix {@code PLUGIN__}, dashes as underscores). + */ + @Test + void postConstruct_startsOneNamedThreadPerPlugin_withItsClassLoaderEnvironmentAndServices() throws Exception { + PluginLoader loader = jarLoader("manager-jars"); + Map props = new HashMap<>(); + props.put("PLUGIN_RECORDING_PLUGIN_API_KEY", "k-1"); + props.put("PLUGIN_RECORDING_PLUGIN_MODE", "fast"); + props.put("PLUGIN_SECOND_PLUGIN_OTHER", "x"); + props.put("PLUGIN_RECORDING_PLUGINX", "no-underscore-after-id"); + props.put("UNRELATED", "u"); + LowcoderPluginManager manager = new LowcoderPluginManager(services, loader, environment(props)); + + ReflectionTestUtils.invokeMethod(manager, "loadPlugins"); + + List infos = manager.getLoadedPluginsInfo(); + assertThat(infos).hasSize(2); + System.out.println("[LowcoderPluginManagerTest] infos " + infos); + LowcoderPlugin recording = pluginById(manager, "recording-plugin"); + LowcoderPlugin second = pluginById(manager, "second-plugin"); + + Object[] rec = recorded(recording); + assertThat(rec[0]).isEqualTo("recording-plugin"); + assertThat(rec[1]).isSameAs(recording.getClass().getClassLoader()).isInstanceOf(PluginClassLoader.class); + assertThat(rec[1]).isNotSameAs(Thread.currentThread().getContextClassLoader()); + assertThat(rec[2]).isEqualTo(Map.of("API_KEY", "k-1", "MODE", "fast")); + assertThat(rec[3]).isSameAs(services); + + Object[] sec = recorded(second); + assertThat(sec[0]).isEqualTo("second-plugin"); + assertThat(sec[2]).isEqualTo(Map.of("OTHER", "x")); + System.out.println("[LowcoderPluginManagerTest] recording env " + rec[2] + ", second env " + sec[2]); + } + + @SuppressWarnings("unchecked") + private LowcoderPlugin pluginById(LowcoderPluginManager manager, String id) { + Map plugins = (Map) ReflectionTestUtils.getField(manager, "plugins"); + return plugins.get(id); + } + + /** Catches a later plugin with an already registered id replacing the first one. */ + @Test + void register_duplicatePluginId_keepsTheFirst() { + LowcoderPlugin first = new RecordingPlugin(); + LowcoderPlugin duplicate = new SameIdPlugin(); + PluginLoader loader = mock(PluginLoader.class); + when(loader.loadPlugins()).thenReturn(List.of(first, duplicate)); + LowcoderPluginManager manager = new LowcoderPluginManager(services, loader, environment(Map.of())); + + ReflectionTestUtils.invokeMethod(manager, "loadPlugins"); + + List infos = manager.getLoadedPluginsInfo(); + assertThat(infos).hasSize(1); + assertThat(infos.get(0).toString()).contains("records its load").doesNotContain("same id"); + System.out.println("[LowcoderPluginManagerTest] " + infos); + } + + /** Catches a null or empty loader result failing the start-up. */ + @Test + void register_nullOrEmptyLoaderResult_registersNothing() { + PluginLoader loader = mock(PluginLoader.class); + when(loader.loadPlugins()).thenReturn(null).thenReturn(List.of()); + LowcoderPluginManager manager = new LowcoderPluginManager(services, loader, environment(Map.of())); + + ReflectionTestUtils.invokeMethod(manager, "loadPlugins"); + ReflectionTestUtils.invokeMethod(manager, "loadPlugins"); + + assertThat(manager.getLoadedPluginsInfo()).isEmpty(); + System.out.println("[LowcoderPluginManagerTest] null and empty results -> no plugins"); + } + + /** Catches an unload failure of one plugin stopping the unload of the others. */ + @Test + void unloadPlugins_unloadsEveryPlugin_evenWhenOneThrows() { + LowcoderPlugin failing = mock(LowcoderPlugin.class); + when(failing.pluginId()).thenReturn("failing"); + when(failing.loadOrder()).thenReturn(0); + doThrow(new IllegalStateException("unload failed")).when(failing).unload(); + LowcoderPlugin healthy = mock(LowcoderPlugin.class); + when(healthy.pluginId()).thenReturn("healthy"); + when(healthy.loadOrder()).thenReturn(1); + PluginLoader loader = mock(PluginLoader.class); + when(loader.loadPlugins()).thenReturn(List.of(failing, healthy)); + LowcoderPluginManager manager = new LowcoderPluginManager(services, loader, environment(Map.of())); + ReflectionTestUtils.invokeMethod(manager, "loadPlugins"); + + manager.unloadPlugins(); + + verify(failing).unload(); + verify(healthy).unload(); + System.out.println("[LowcoderPluginManagerTest] both plugins unloaded despite the failure"); + } + + /** Catches {@code run} not calling load with the given environment and services, for a plugin returning false too. */ + @Test + void executor_run_loadsThePluginWithItsEnvironmentAndServices() { + LowcoderPlugin plugin = mock(LowcoderPlugin.class); + when(plugin.pluginId()).thenReturn("exec-plugin"); + Map env = Map.of("A", "b"); + when(plugin.load(env, services)).thenReturn(true); + + PluginExecutor executor = new PluginExecutor(plugin, env, services); + executor.run(); + + assertThat(executor.getName()).isEqualTo("exec-plugin"); + assertThat(executor.getContextClassLoader()).isSameAs(plugin.getClass().getClassLoader()); + verify(plugin).load(env, services); + + LowcoderPlugin refusing = mock(LowcoderPlugin.class); + when(refusing.pluginId()).thenReturn("refusing"); + new PluginExecutor(refusing, env, services).run(); + verify(refusing).load(env, services); + System.out.println("[LowcoderPluginManagerTest] executor ran " + executor.getName()); + } + + // ------------------------------------------------------ SharedPluginServices + + private SharedPluginServices shared(PluginEndpointHandler handler, ServerConfigRepository repository) { + SharedPluginServices shared = new SharedPluginServices(handler); + ReflectionTestUtils.setField(shared, "serverConfigRepository", repository); + return shared; + } + + /** Catches events not reaching every listener, in registration order. */ + @Test + @SuppressWarnings("unchecked") + void sharedServices_publishEvents_notifiesEveryListenerInOrder() { + SharedPluginServices shared = shared(mock(PluginEndpointHandler.class), mock(ServerConfigRepository.class)); + List calls = new java.util.ArrayList<>(); + Consumer first = event -> calls.add("first"); + Consumer second = event -> calls.add("second"); + shared.registerEventListener(first); + shared.registerEventListener(second); + + ReflectionTestUtils.invokeMethod(shared, "publishEvents", mock(LowcoderEvent.class)); + + assertThat(calls).containsExactly("first", "second"); + System.out.println("[LowcoderPluginManagerTest] listeners called " + calls); + } + + /** Catches endpoint registration not being forwarded with its prefix and list. */ + @Test + void sharedServices_registerEndpoints_delegatesToTheHandler() { + PluginEndpointHandler handler = mock(PluginEndpointHandler.class); + List endpoints = List.of(mock(PluginEndpoint.class)); + + shared(handler, mock(ServerConfigRepository.class)).registerEndpoints("pfx", endpoints); + + verify(handler).registerEndpoints("pfx", endpoints); + System.out.println("[LowcoderPluginManagerTest] registerEndpoints delegated"); + } + + /** Catches config being written under the wrong key/value, or read through the wrong field. */ + @Test + void sharedServices_setConfig_andGetConfig_goThroughTheRepository() { + ServerConfigRepository repository = mock(ServerConfigRepository.class); + java.util.concurrent.atomic.AtomicBoolean upsertSubscribed = new java.util.concurrent.atomic.AtomicBoolean(); + when(repository.upsert("k", "v")).thenReturn(Mono.defer(() -> { + upsertSubscribed.set(true); + return Mono.just(ServerConfig.builder().key("k").value("v").build()); + })); + when(repository.findByKey("k")).thenReturn(Mono.just(ServerConfig.builder().key("k").value("stored").build())); + when(repository.findByKey("missing")).thenReturn(Mono.empty()); + SharedPluginServices shared = shared(mock(PluginEndpointHandler.class), repository); + + shared.setConfig("k", "v"); + + verify(repository).upsert("k", "v"); + assertThat(upsertSubscribed).as("setConfig must wait for (subscribe to) the upsert").isTrue(); + assertThat(shared.getConfig("k")).isEqualTo("stored"); + assertThat(shared.getConfig("missing")).isNull(); + System.out.println("[LowcoderPluginManagerTest] config round trip"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/PluginLoadingTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/PluginLoadingTest.java new file mode 100644 index 0000000000..ff446e9299 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/PluginLoadingTest.java @@ -0,0 +1,262 @@ +package org.lowcoder.api.framework.plugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.lowcoder.api.framework.plugin.PluginTestSupport.OrphanClass; +import org.lowcoder.api.framework.plugin.PluginTestSupport.RecordingPlugin; +import org.lowcoder.api.framework.plugin.PluginTestSupport.SecondPlugin; +import org.lowcoder.plugin.api.LowcoderPlugin; +import org.lowcoder.sdk.config.CommonConfig; +import org.springframework.boot.system.ApplicationHome; + +/** + * Tests of {@link PathBasedPluginLoader} and {@link PluginClassLoader} with real jars built in a {@code @TempDir}. + * {@code PathBasedPluginLoader.cachedPluginJars} is static and keyed by the directory list, so every test uses its + * own temporary directory (a unique key); the cache is deliberately never reset. + * + *

    Pinned under D-6, plan §9 row "PluginClassLoader.loadClass returns null after a NoClassDefFoundError ..., + * against the ClassLoader contract" ({@link #loadClass_classWithAMissingSuperclass_returnsNull_pinsNullContractViolation}). + */ +class PluginLoadingTest { + + @TempDir + Path tempDir; + + private PathBasedPluginLoader loader(String... pluginDirs) { + CommonConfig common = new CommonConfig(); + common.setPluginDirs(List.of(pluginDirs)); + ApplicationHome home = mock(ApplicationHome.class); + when(home.getDir()).thenReturn(tempDir.toFile()); + return new PathBasedPluginLoader(common, home); + } + + // -------------------------------------------------------------- discovery + + /** Catches plugins being missed or foreign files being loaded: discovery is recursive and {@code .jar} only (any case). */ + @Test + void findPluginsJars_isRecursive_andOnlyTakesJarFiles() throws IOException { + Path root = tempDir.resolve("plugins-recursive"); + Path top = PluginTestSupport.jar(root, "a.jar", List.of()); + Path nested = PluginTestSupport.jar(root.resolve("sub/deeper"), "b.JAR", List.of()); + Files.writeString(root.resolve("c.txt"), "text"); + Files.writeString(root.resolve("d.jar.bak"), "backup"); + Files.createDirectories(root.resolve("folder.jar")); + + List found = loader(root.toString()).findPluginsJars(); + + assertThat(found).containsExactlyInAnyOrder(top.toString(), nested.toString()); + System.out.println("[PluginLoadingTest] found " + found); + } + + /** Catches a relative plugin directory being resolved against the working directory instead of the application home. */ + @Test + void findPluginsJars_resolvesARelativeDirectoryAgainstTheApplicationHome() throws IOException { + Path jar = PluginTestSupport.jar(tempDir.resolve("relative-plugins"), "r.jar", List.of()); + + List found = loader("relative-plugins").findPluginsJars(); + + assertThat(found).containsExactly(jar.toAbsolutePath().normalize().toString()); + System.out.println("[PluginLoadingTest] relative dir -> " + found); + } + + /** Catches blank directory entries and unreadable directories breaking discovery. */ + @Test + void findPluginsJars_skipsBlankEntries_andAMissingDirectoryYieldsNothing() throws IOException { + Path root = tempDir.resolve("plugins-blank"); + Path jar = PluginTestSupport.jar(root, "x.jar", List.of()); + + assertThat(loader("", " ", root.toString()).findPluginsJars()).containsExactly(jar.toString()); + assertThat(loader(tempDir.resolve("does-not-exist").toString()).findPluginsJars()).isEmpty(); + System.out.println("[PluginLoadingTest] blank entries skipped, missing directory -> empty"); + } + + /** + * Documents the staleness of the static cache: the jar list of a directory list is computed once per JVM, so a + * jar added afterwards is not found by a later loader with the same directories. + */ + @Test + void findPluginsJars_isCachedPerDirectoryList_soLaterJarsAreNotFound() throws IOException { + Path root = tempDir.resolve("plugins-cached"); + PluginTestSupport.jar(root, "first.jar", List.of()); + assertThat(loader(root.toString()).findPluginsJars()).hasSize(1); + + PluginTestSupport.jar(root, "added-later.jar", List.of()); + + assertThat(loader(root.toString()).findPluginsJars()).as("the cached list is returned").hasSize(1); + System.out.println("[PluginLoadingTest] a jar added after the first scan is not found (static cache)"); + } + + // ------------------------------------------------------------ loadPlugins + + /** Catches plugins not being loaded from a jar, or being loaded by the application class loader. */ + @Test + void loadPlugins_loadsTheServicesOfEveryJar_throughThePluginClassLoader() throws IOException { + Path root = tempDir.resolve("plugins-load"); + PluginTestSupport.jar(root, "one.jar", List.of(RecordingPlugin.class), RecordingPlugin.class); + PluginTestSupport.jar(root, "two.jar", List.of(SecondPlugin.class), RecordingPlugin.class, SecondPlugin.class); + + List plugins = loader(root.toString()).loadPlugins(); + + assertThat(plugins).extracting(LowcoderPlugin::pluginId).containsExactlyInAnyOrder("recording-plugin", "second-plugin"); + assertThat(plugins).allSatisfy(plugin -> { + assertThat(plugin.getClass().getClassLoader()).isInstanceOf(PluginClassLoader.class); + assertThat(plugin.getClass()).isNotEqualTo(RecordingPlugin.class); + }); + System.out.println("[PluginLoadingTest] loaded " + plugins.stream().map(LowcoderPlugin::pluginId).toList()); + } + + /** Catches one broken jar taking the others down: a corrupt jar and a jar without services entry yield nothing. */ + @Test + void loadPlugins_skipsACorruptJar_andAJarWithoutAServicesEntry() throws IOException { + Path root = tempDir.resolve("plugins-mixed"); + PluginTestSupport.jar(root, "good.jar", List.of(RecordingPlugin.class), RecordingPlugin.class); + PluginTestSupport.jar(root, "no-services.jar", List.of(), SecondPlugin.class, RecordingPlugin.class); + Files.writeString(root.resolve("corrupt.jar"), "this is not a zip file"); + + List plugins = loader(root.toString()).loadPlugins(); + + assertThat(plugins).extracting(LowcoderPlugin::pluginId).containsExactly("recording-plugin"); + System.out.println("[PluginLoadingTest] corrupt jar and jar without services skipped, loaded " + plugins.size()); + } + + /** + * A services entry naming a class that is not in the jar makes the ServiceLoader throw a + * ServiceConfigurationError (an Error, not an Exception): it must be caught per jar, so the other jars still load. + */ + @Test + void loadPlugins_skipsAJarWhoseServicesEntryNamesAMissingClass_andLoadsTheOthers() throws IOException { + Path root = tempDir.resolve("plugins-missing-provider"); + PluginTestSupport.jar(root, "good.jar", List.of(RecordingPlugin.class), RecordingPlugin.class); + PluginTestSupport.rawJar(root, "broken.jar", java.util.Map.of( + PluginTestSupport.SERVICES_ENTRY, "org.example.MissingPlugin".getBytes(java.nio.charset.StandardCharsets.UTF_8))); + + List plugins = loader(root.toString()).loadPlugins(); + + assertThat(plugins).extracting(LowcoderPlugin::pluginId).containsExactly("recording-plugin"); + System.out.println("[PluginLoadingTest] jar naming a missing provider skipped, loaded " + plugins.size()); + } + + @Test + void loadPlugins_withoutAnyJar_isEmpty() { + assertThat(loader().loadPlugins()).isEmpty(); + assertThat(loader(tempDir.resolve("empty-plugins-dir").toString()).loadPlugins()).isEmpty(); + System.out.println("[PluginLoadingTest] no jars -> no plugins"); + } + + // -------------------------------------------------------- PluginClassLoader + + private PluginClassLoader classLoaderOf(String jarName, Class... classes) throws IOException { + Path jar = PluginTestSupport.jar(tempDir.resolve("loader-" + jarName), jarName, List.of(RecordingPlugin.class), classes); + return new PluginClassLoader(jarName, jar); + } + + /** Catches the plugin API being loaded twice: its classes must be the application's, so plugins and server agree on the types. */ + @Test + void loadClass_pluginApiClasses_comeFromTheApplicationClassLoader() throws Exception { + PluginClassLoader classLoader = classLoaderOf("api.jar", RecordingPlugin.class); + + assertThat(classLoader.loadClass(LowcoderPlugin.class.getName())).isSameAs(LowcoderPlugin.class); + System.out.println("[PluginLoadingTest] plugin API class shared with the application"); + } + + /** Catches a plugin class being defined by the application loader, or defined again on every load. */ + @Test + void loadClass_jarClasses_areDefinedByThePluginLoader_once() throws Exception { + PluginClassLoader classLoader = classLoaderOf("jar-classes.jar", RecordingPlugin.class); + + Class first = classLoader.loadClass(RecordingPlugin.class.getName()); + Class second = classLoader.loadClass(RecordingPlugin.class.getName()); + + assertThat(first.getClassLoader()).isSameAs(classLoader); + assertThat(first).isNotEqualTo(RecordingPlugin.class); + assertThat(second).isSameAs(first); + System.out.println("[PluginLoadingTest] " + first.getName() + " defined by " + first.getClassLoader().getName()); + } + + /** + * A class that exists nowhere throws {@code ClassNotFoundException}: it does NOT return null (the analysis said it + * did). That holds for an ordinary name and for a name under the plugin API prefix, whose lookup in the + * application loader fails first. + */ + @Test + void loadClass_unknownClass_throwsClassNotFound() throws Exception { + PluginClassLoader classLoader = classLoaderOf("unknown.jar", RecordingPlugin.class); + + assertThatThrownBy(() -> classLoader.loadClass("does.not.Exist")).isInstanceOf(ClassNotFoundException.class); + assertThatThrownBy(() -> classLoader.loadClass("org.lowcoder.plugin.api.DoesNotExist")).isInstanceOf(ClassNotFoundException.class); + System.out.println("[PluginLoadingTest] unknown classes -> ClassNotFoundException"); + } + + /** + * A class under the plugin API prefix that the application does not have is not an error when the plugin jar has + * it: the failed application lookup is logged and the jar is consulted next. + */ + @Test + void loadClass_apiPrefixClassMissingInTheApplication_fallsBackToTheJar() throws Exception { + String internalName = "org/lowcoder/plugin/api/ShadowOnlyInTheJar"; + Path jar = PluginTestSupport.rawJar(tempDir.resolve("loader-shadow"), "shadow.jar", + java.util.Map.of(internalName + ".class", PluginTestSupport.emptyClass(internalName))); + PluginClassLoader classLoader = new PluginClassLoader("shadow.jar", jar); + + Class shadow = classLoader.loadClass(internalName.replace('/', '.')); + + assertThat(shadow.getClassLoader()).isSameAs(classLoader); + System.out.println("[PluginLoadingTest] " + shadow.getName() + " fell back to the jar"); + } + + /** + * Pins plan §9 row "PluginClassLoader.loadClass returns null after a NoClassDefFoundError ..., against the + * ClassLoader contract": a plugin class whose superclass is missing makes the definition fail with a + * NoClassDefFoundError, which is caught and answered with {@code null} instead of an exception. A fix changes this + * test on purpose. + */ + @Test + void loadClass_classWithAMissingSuperclass_returnsNull_pinsNullContractViolation() throws Exception { + PluginClassLoader classLoader = classLoaderOf("orphan.jar", OrphanClass.class); + + assertThat(classLoader.loadClass(OrphanClass.class.getName())).isNull(); + System.out.println("[PluginLoadingTest] class with a missing superclass -> null (today's behaviour)"); + } + + /** Catches resources of the plugin API (and other names) being looked up in the wrong loader. */ + @Test + void getResource_andGetResources_delegateTheApiPrefixToTheApplication_andReadTheRestFromTheJar() throws Exception { + PluginClassLoader classLoader = classLoaderOf("resources.jar", RecordingPlugin.class); + String apiResource = PluginTestSupport.classEntry(LowcoderPlugin.class); + String jarResource = PluginTestSupport.classEntry(RecordingPlugin.class); + + URL api = classLoader.getResource(apiResource); + assertThat(api).isNotNull().isEqualTo(Thread.currentThread().getContextClassLoader().getResource(apiResource)); + assertThat(Collections.list(classLoader.getResources(apiResource))) + .isEqualTo(Collections.list(Thread.currentThread().getContextClassLoader().getResources(apiResource))); + + URL fromJar = classLoader.getResource(jarResource); + assertThat(fromJar).isNotNull(); + assertThat(fromJar.toString()).startsWith("jar:file:").contains("resources.jar"); + assertThat(Collections.list(classLoader.getResources(jarResource))).hasSize(1); + assertThat(classLoader.getResource("org/lowcoder/not/in/the/jar.txt")).isNull(); + assertThat(Collections.list(classLoader.getResources("org/lowcoder/not/in/the/jar.txt"))).isEmpty(); + System.out.println("[PluginLoadingTest] api resource " + api + ", jar resource " + fromJar); + } + + @Test + void getResource_andGetResources_rejectANullName() throws Exception { + PluginClassLoader classLoader = classLoaderOf("null-name.jar", RecordingPlugin.class); + + assertThatThrownBy(() -> classLoader.getResource(null)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> classLoader.getResources(null)).isInstanceOf(NullPointerException.class); + System.out.println("[PluginLoadingTest] null resource names rejected"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/PluginTestSupport.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/PluginTestSupport.java new file mode 100644 index 0000000000..17ef2db96c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/PluginTestSupport.java @@ -0,0 +1,150 @@ +package org.lowcoder.api.framework.plugin; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import org.lowcoder.plugin.api.LowcoderPlugin; +import org.lowcoder.plugin.api.LowcoderServices; +import org.lowcoder.plugin.api.PluginEndpoint; + +/** Plugin fixtures and a jar builder shared by the plugin loading and manager tests (new test support, L2-7). */ +public final class PluginTestSupport { + + public static final String SERVICES_ENTRY = "META-INF/services/" + LowcoderPlugin.class.getName(); + + private PluginTestSupport() { + } + + /** + * A plugin that records how it was loaded: {@link #pluginInfo()} returns a future of + * {@code {thread name, context class loader, env, services}}, completed by {@code load}. Instances created by a + * {@code PluginClassLoader} cannot share static state with the test, so the future is the way out. + */ + public static class RecordingPlugin implements LowcoderPlugin { + private final CompletableFuture loaded = new CompletableFuture<>(); + + @Override + public boolean load(Map env, LowcoderServices services) { + loaded.complete(new Object[]{Thread.currentThread().getName(), Thread.currentThread().getContextClassLoader(), env, services}); + return true; + } + + @Override + public void unload() { + } + + @Override + public String pluginId() { + return "recording-plugin"; + } + + @Override + public Object pluginInfo() { + return loaded; + } + + @Override + public String description() { + return "records its load"; + } + + @Override + public int loadOrder() { + return 0; + } + + @Override + public List endpoints() { + return List.of(); + } + } + + /** A second plugin with another id. */ + public static class SecondPlugin extends RecordingPlugin { + @Override + public String pluginId() { + return "second-plugin"; + } + + @Override + public String description() { + return "the second plugin"; + } + } + + /** A plugin with the same id as {@link RecordingPlugin}. */ + public static class SameIdPlugin extends RecordingPlugin { + @Override + public String description() { + return "same id as the recording plugin"; + } + } + + /** A class whose superclass is missing from every jar built here. */ + public static class MissingBase { + } + + public static class OrphanClass extends MissingBase { + } + + /** Resource path of a class file. */ + public static String classEntry(Class type) { + return type.getName().replace('.', '/') + ".class"; + } + + /** + * Writes a jar with the class files of {@code classes}, and, when {@code serviceClasses} is not empty, a + * {@code META-INF/services} entry naming them. + */ + public static Path jar(Path directory, String fileName, List> serviceClasses, Class... classes) throws IOException { + Files.createDirectories(directory); + Path jar = directory.resolve(fileName); + try (OutputStream out = Files.newOutputStream(jar); JarOutputStream jarOut = new JarOutputStream(out)) { + for (Class type : classes) { + jarOut.putNextEntry(new JarEntry(classEntry(type))); + try (InputStream in = type.getClassLoader().getResourceAsStream(classEntry(type))) { + in.transferTo(jarOut); + } + jarOut.closeEntry(); + } + if (!serviceClasses.isEmpty()) { + jarOut.putNextEntry(new JarEntry(SERVICES_ENTRY)); + String names = String.join("\n", serviceClasses.stream().map(Class::getName).toList()); + jarOut.write(names.getBytes(StandardCharsets.UTF_8)); + jarOut.closeEntry(); + } + } + return jar; + } + + /** Writes a jar with the given raw entries (entry name to bytes). */ + public static Path rawJar(Path directory, String fileName, Map entries) throws IOException { + Files.createDirectories(directory); + Path jar = directory.resolve(fileName); + try (OutputStream out = Files.newOutputStream(jar); JarOutputStream jarOut = new JarOutputStream(out)) { + for (Map.Entry entry : entries.entrySet()) { + jarOut.putNextEntry(new JarEntry(entry.getKey())); + jarOut.write(entry.getValue()); + jarOut.closeEntry(); + } + } + return jar; + } + + /** Bytes of an empty public class with the given internal name (for example {@code a/b/C}). */ + public static byte[] emptyClass(String internalName) { + org.springframework.asm.ClassWriter writer = new org.springframework.asm.ClassWriter(0); + writer.visit(org.springframework.asm.Opcodes.V17, org.springframework.asm.Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null); + writer.visitEnd(); + return writer.toByteArray(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/data/PluginServerRequestTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/data/PluginServerRequestTest.java new file mode 100644 index 0000000000..3f4bfb167c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/data/PluginServerRequestTest.java @@ -0,0 +1,107 @@ +package org.lowcoder.api.framework.plugin.data; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.URI; +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.api.PluginEndpoint; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.reactive.function.server.HandlerStrategies; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** Tests of {@link PluginServerRequest}: conversion of a Spring {@code ServerRequest} for plugins. */ +class PluginServerRequestTest { + + private static final long WAIT_SECONDS = 10; + + /** Catches any field of the request being dropped or mixed up in the conversion. */ + @Test + void fromServerRequest_copiesEveryPartOfTheRequest() throws Exception { + MockServerHttpRequest httpRequest = MockServerHttpRequest.post("/api/plugins/p/items/7?a=1&a=2&b=3") + .header("X-Test", "h1", "h2") + .cookie(new HttpCookie("sid", "s1"), new HttpCookie("sid", "s2"), new HttpCookie("other", "o")) + .body("payload"); + MockServerWebExchange exchange = MockServerWebExchange.from(httpRequest); + exchange.getAttributes().put("attr", "value"); + exchange.getAttributes().put(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE, Map.of("id", "7")); + Principal principal = () -> "alice"; + ServerWebExchange withPrincipal = exchange.mutate().principal(Mono.just(principal)).build(); + + PluginServerRequest request = PluginServerRequest.fromServerRequest( + ServerRequest.create(withPrincipal, HandlerStrategies.withDefaults().messageReaders())); + + assertThat(request.uri()).isEqualTo(URI.create("/api/plugins/p/items/7?a=1&a=2&b=3")); + assertThat(request.method()).isEqualTo(PluginEndpoint.Method.POST); + assertThat(request.body().get(WAIT_SECONDS, TimeUnit.SECONDS)).isEqualTo("payload".getBytes()); + assertThat(request.headers().get("X-Test")).containsExactly("h1", "h2"); + assertThat(request.cookies().get("sid")).extracting(Map.Entry::getKey, Map.Entry::getValue) + .containsExactly(org.assertj.core.groups.Tuple.tuple("sid", "s1"), org.assertj.core.groups.Tuple.tuple("sid", "s2")); + assertThat(request.cookies().get("other")).hasSize(1); + assertThat(request.attributes()).containsEntry("attr", "value"); + assertThat(request.pathVariables()).containsEntry("id", "7"); + assertThat(request.queryParams()).containsEntry("a", List.of("1", "2")).containsEntry("b", List.of("3")); + assertThat(request.principal().get(WAIT_SECONDS, TimeUnit.SECONDS).getName()).isEqualTo("alice"); + System.out.println("[PluginServerRequestTest] uri " + request.uri() + " cookies " + request.cookies() + " query " + request.queryParams()); + } + + /** Catches a null part of the source request failing the conversion; the target maps stay empty. */ + @Test + void fromServerRequest_nullParts_leaveEmptyMaps() throws Exception { + ServerRequest source = mock(ServerRequest.class); + lenient().when(source.uri()).thenReturn(URI.create("/x")); + lenient().when(source.method()).thenReturn(HttpMethod.GET); + lenient().when(source.bodyToMono(byte[].class)).thenReturn(Mono.empty()); + lenient().when(source.headers()).thenReturn(null); + lenient().when(source.cookies()).thenReturn(null); + lenient().when(source.attributes()).thenReturn(null); + lenient().when(source.pathVariables()).thenReturn(null); + lenient().when(source.queryParams()).thenReturn(null); + when(source.principal()).thenReturn(Mono.empty()); + + PluginServerRequest request = PluginServerRequest.fromServerRequest(source); + + assertThat(request.headers()).isEmpty(); + assertThat(request.cookies()).isEmpty(); + assertThat(request.attributes()).isEmpty(); + assertThat(request.pathVariables()).isEmpty(); + assertThat(request.queryParams()).isEmpty(); + assertThat(request.method()).isEqualTo(PluginEndpoint.Method.GET); + System.out.println("[PluginServerRequestTest] null parts -> empty maps"); + } + + /** Catches a verb being mapped to the wrong one in either direction. */ + @Test + void verbMappings_coverTheSixVerbs_andRejectOthers() { + Object[][] pairs = { + {HttpMethod.GET, PluginEndpoint.Method.GET}, {HttpMethod.POST, PluginEndpoint.Method.POST}, + {HttpMethod.PUT, PluginEndpoint.Method.PUT}, {HttpMethod.PATCH, PluginEndpoint.Method.PATCH}, + {HttpMethod.DELETE, PluginEndpoint.Method.DELETE}, {HttpMethod.OPTIONS, PluginEndpoint.Method.OPTIONS}}; + for (Object[] pair : pairs) { + assertThat(PluginServerRequest.fromHttpMetod((HttpMethod) pair[0])).isEqualTo(pair[1]); + assertThat(PluginServerRequest.fromPluginEndpointMethod((PluginEndpoint.Method) pair[1])).isEqualTo(pair[0]); + } + assertThat(PluginServerRequest.fromHttpMetod(HttpMethod.HEAD)).isNull(); + assertThat(PluginServerRequest.fromHttpMetod(HttpMethod.TRACE)).isNull(); + System.out.println("[PluginServerRequestTest] six verbs map both ways, HEAD and TRACE -> null"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/endpoint/PluginEndpointRegistrationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/endpoint/PluginEndpointRegistrationTest.java new file mode 100644 index 0000000000..a67f7635df --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/endpoint/PluginEndpointRegistrationTest.java @@ -0,0 +1,214 @@ +package org.lowcoder.api.framework.plugin.endpoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.framework.plugin.security.PluginAuthorizationManager; +import org.lowcoder.plugin.api.EndpointExtension; +import org.lowcoder.plugin.api.PluginEndpoint; +import org.lowcoder.plugin.api.data.EndpointRequest; +import org.lowcoder.plugin.api.data.EndpointResponse; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.web.reactive.function.server.HandlerStrategies; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; + +import reactor.test.StepVerifier; + +/** + * Tests of the registration side of {@link PluginEndpointHandlerImpl#registerEndpoints}: which methods of a plugin + * endpoint become routes, under which URL, with which bean name, and the router reload afterwards. Running a + * registered endpoint is covered by {@code PluginEndpointHandlerImplTest}. + * + *

    Pinned (ruling a, no section 9 row): {@code checkHandlerMethod} is lax. It asks whether the declared return type + * is a supertype of {@code EndpointResponse} and the parameter a supertype of {@code EndpointRequest}, so + * {@code Object handle(Object)} is registered; a result that is not an {@code EndpointResponse} fails later with a + * {@code ClassCastException} when invoked ({@link #looseSignature_isRegistered_andFailsWithClassCastWhenInvoked}). + */ +class PluginEndpointRegistrationTest { + + private static final String PREFIX = "demo"; + private static final String BASE = PluginEndpointHandler.PLUGINS_BASE_URL + PREFIX; + private static final Duration WAIT = Duration.ofSeconds(10); + + // ---------------------------------------------------------------- fixtures + + public static class MixedEndpoint implements PluginEndpoint { + @EndpointExtension(uri = "/ok", method = PluginEndpoint.Method.GET) + public EndpointResponse ok(EndpointRequest request) { + return mock(EndpointResponse.class); + } + + @EndpointExtension(uri = "/two", method = PluginEndpoint.Method.GET) + public EndpointResponse twoParameters(EndpointRequest request, String extra) { + return null; + } + + @EndpointExtension(uri = "/string-return", method = PluginEndpoint.Method.GET) + public String stringReturn(EndpointRequest request) { + return "x"; + } + + @EndpointExtension(uri = "/string-param", method = PluginEndpoint.Method.GET) + public EndpointResponse stringParameter(String request) { + return null; + } + + public EndpointResponse unannotated(EndpointRequest request) { + return null; + } + } + + public static class VerbEndpoint implements PluginEndpoint { + @EndpointExtension(uri = "items/{id}", method = PluginEndpoint.Method.GET) + public EndpointResponse get(EndpointRequest r) { return null; } + + @EndpointExtension(uri = "items/{id}", method = PluginEndpoint.Method.POST) + public EndpointResponse post(EndpointRequest r) { return null; } + + @EndpointExtension(uri = "items/{id}", method = PluginEndpoint.Method.PUT) + public EndpointResponse put(EndpointRequest r) { return null; } + + @EndpointExtension(uri = "items/{id}", method = PluginEndpoint.Method.PATCH) + public EndpointResponse patch(EndpointRequest r) { return null; } + + @EndpointExtension(uri = "items/{id}", method = PluginEndpoint.Method.DELETE) + public EndpointResponse delete(EndpointRequest r) { return null; } + + @EndpointExtension(uri = "items/{id}", method = PluginEndpoint.Method.OPTIONS) + public EndpointResponse options(EndpointRequest r) { return null; } + } + + public static class SlashEndpoint implements PluginEndpoint { + @EndpointExtension(uri = "/with-slash", method = PluginEndpoint.Method.GET) + public EndpointResponse withSlash(EndpointRequest r) { return null; } + + @EndpointExtension(uri = "no-slash", method = PluginEndpoint.Method.GET) + public EndpointResponse noSlash(EndpointRequest r) { return null; } + } + + public static class LooseEndpoint implements PluginEndpoint { + @EndpointExtension(uri = "/loose", method = PluginEndpoint.Method.GET) + public Object handle(Object request) { + return "not an EndpointResponse"; + } + } + + // ------------------------------------------------------------------- setup + + private GenericApplicationContext context; + private ReloadableRouterFunctionMapping mapping; + private PluginEndpointHandlerImpl handler; + + @BeforeEach + void setUp() { + context = new GenericApplicationContext(); + context.refresh(); + mapping = mock(ReloadableRouterFunctionMapping.class); + context.getDefaultListableBeanFactory().registerSingleton("routerFunctionMapping", mapping); + handler = new PluginEndpointHandlerImpl(context, context.getDefaultListableBeanFactory(), new PluginAuthorizationManager()); + } + + private ServerRequest request(HttpMethod method, String path) { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.method(method, path).build()); + return ServerRequest.create(exchange, HandlerStrategies.withDefaults().messageReaders()); + } + + private long matches(HttpMethod method, String path) { + ServerRequest request = request(method, path); + return handler.registeredEndpoints().stream() + .filter(route -> route.route(request).blockOptional(WAIT).isPresent()) + .count(); + } + + // ------------------------------------------------------------------- tests + + /** Catches methods with the wrong signature, or without the annotation, becoming routes. */ + @Test + void registerEndpoints_registersOnlyAnnotatedMethodsWithTheEndpointSignature() { + handler.registerEndpoints(PREFIX, List.of(new MixedEndpoint())); + + assertThat(handler.registeredEndpoints()).hasSize(1); + assertThat(matches(HttpMethod.GET, BASE + "/ok")).isEqualTo(1); + for (String other : new String[]{"/two", "/string-return", "/string-param"}) { + assertThat(matches(HttpMethod.GET, BASE + other)).as(other).isZero(); + } + String[] beans = context.getBeanNamesForType(RouterFunction.class); + System.out.println("[PluginEndpointRegistrationTest] beans " + Arrays.toString(beans)); + assertThat(beans).hasSize(1); + assertThat(beans[0]).matches("pluginEndpoint_MixedEndpoint_ok_\\d+"); + verify(mapping, times(1)).reloadFunctionMappings(); + } + + /** Catches the router being reloaded for nothing, or routes appearing, when there is nothing to register. */ + @Test + void registerEndpoints_nullOrEmptyList_registersNothingAndDoesNotReload() { + handler.registerEndpoints(PREFIX, null); + handler.registerEndpoints(PREFIX, List.of()); + + assertThat(handler.registeredEndpoints()).isEmpty(); + verify(mapping, never()).reloadFunctionMappings(); + System.out.println("[PluginEndpointRegistrationTest] null and empty list -> no routes, no reload"); + } + + /** Catches a verb being routed to another one, a wrong path matching, or the reload running once per endpoint. */ + @Test + void registerEndpoints_routesEachVerbOnItsOwn_underThePluginPrefix_andReloadsOnce() { + handler.registerEndpoints(PREFIX, List.of(new VerbEndpoint(), new SlashEndpoint())); + + for (HttpMethod method : new HttpMethod[]{HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS}) { + assertThat(matches(method, BASE + "/items/5")).as(method.name()).isEqualTo(1); + } + assertThat(matches(HttpMethod.GET, BASE + "/other/5")).isZero(); + assertThat(matches(HttpMethod.GET, "/api/plugins/another/items/5")).isZero(); + assertThat(matches(HttpMethod.GET, "/items/5")).isZero(); + assertThat(handler.registeredEndpoints()).hasSize(8); + verify(mapping, times(1)).reloadFunctionMappings(); + System.out.println("[PluginEndpointRegistrationTest] 8 routes, one reload"); + } + + /** Catches a missing leading slash of the endpoint uri producing a joined path without separator. */ + @Test + void registerEndpoints_uriWithAndWithoutLeadingSlash_bothResolveUnderThePrefix() { + handler.registerEndpoints(PREFIX, List.of(new SlashEndpoint())); + + assertThat(matches(HttpMethod.GET, BASE + "/with-slash")).isEqualTo(1); + assertThat(matches(HttpMethod.GET, BASE + "/no-slash")).isEqualTo(1); + assertThat(matches(HttpMethod.GET, BASE + "no-slash")).isZero(); + System.out.println("[PluginEndpointRegistrationTest] slash and no-slash uris routed"); + } + + /** Pins ruling (a): the lax signature check registers {@code Object handle(Object)}, which fails when run. */ + @Test + void looseSignature_isRegistered_andFailsWithClassCastWhenInvoked() throws Exception { + LooseEndpoint endpoint = new LooseEndpoint(); + handler.registerEndpoints(PREFIX, List.of(endpoint)); + + assertThat(matches(HttpMethod.GET, BASE + "/loose")).isEqualTo(1); + + java.lang.reflect.Method method = LooseEndpoint.class.getMethod("handle", Object.class); + EndpointExtension meta = method.getAnnotation(EndpointExtension.class); + var authentication = new UsernamePasswordAuthenticationToken("user", "n/a", List.of()); + StepVerifier.create(handler.runPluginEndpointMethod(endpoint, meta, method, request(HttpMethod.GET, BASE + "/loose")) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication))) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(ClassCastException.class); + System.out.println("[PluginEndpointRegistrationTest] loose signature registered; invoking -> " + error); + }).verify(WAIT); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/endpoint/ReloadableRouterFunctionMappingTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/endpoint/ReloadableRouterFunctionMappingTest.java new file mode 100644 index 0000000000..352a6b93d8 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/endpoint/ReloadableRouterFunctionMappingTest.java @@ -0,0 +1,73 @@ +package org.lowcoder.api.framework.plugin.endpoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.reactive.function.server.support.RouterFunctionMapping; +import org.springframework.web.util.pattern.PathPatternParser; + +/** Tests of {@link ReloadableRouterFunctionMapping}: a RouterFunction bean added after start-up becomes routable on reload. */ +class ReloadableRouterFunctionMappingTest { + + private static final Duration WAIT = Duration.ofSeconds(10); + + private static boolean routes(RouterFunctionMapping mapping, String path) { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.method(HttpMethod.GET, path).build()); + return mapping.getHandler(exchange).blockOptional(WAIT).isPresent(); + } + + /** Catches the reload not rescanning the context: a bean registered later stays unrouted without it. */ + @Test + void reloadFunctionMappings_picksUpRouterFunctionsRegisteredAfterStart() throws Exception { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("first", RouterFunction.class, () -> RouterFunctions.route() + .GET("/first", request -> ServerResponse.ok().build()).build()); + PathPatternParser caseInsensitive = new PathPatternParser(); + caseInsensitive.setCaseSensitive(false); + ReloadableRouterFunctionMapping mapping = new ReloadableRouterFunctionMapping() { + @Override + public PathPatternParser getPathPatternParser() { + return caseInsensitive; + } + }; + mapping.setApplicationContext(context); + context.refresh(); + mapping.afterPropertiesSet(); + assertThat(routes(mapping, "/first")).isTrue(); + assertThat(routes(mapping, "/second")).isFalse(); + + context.registerBean("second", RouterFunction.class, () -> RouterFunctions.route() + .GET("/second", request -> ServerResponse.ok().build()).build()); + assertThat(routes(mapping, "/second")).as("not routable before the reload").isFalse(); + + mapping.reloadFunctionMappings(); + + assertThat(routes(mapping, "/second")).isTrue(); + assertThat(routes(mapping, "/first")).isTrue(); + // the mapping's own path pattern parser (here: case insensitive) is applied to the new routes too + assertThat(routes(mapping, "/SECOND")).as("parser of the mapping applied on reload").isTrue(); + System.out.println("[ReloadableRouterFunctionMappingTest] /second routable after reload, /first still routable"); + } + + /** Catches a context without any RouterFunction bean making the reload fail. */ + @Test + void reloadFunctionMappings_withoutRouterFunctionBeans_doesNotFail() { + GenericApplicationContext context = new GenericApplicationContext(); + ReloadableRouterFunctionMapping mapping = new ReloadableRouterFunctionMapping(); + mapping.setApplicationContext(context); + context.refresh(); + + assertThatCode(mapping::reloadFunctionMappings).doesNotThrowAnyException(); + System.out.println("[ReloadableRouterFunctionMappingTest] reload without beans is a no-op"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/security/PluginAuthorizationManagerTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/security/PluginAuthorizationManagerTest.java new file mode 100644 index 0000000000..0da26744d3 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/plugin/security/PluginAuthorizationManagerTest.java @@ -0,0 +1,148 @@ +package org.lowcoder.api.framework.plugin.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.List; + +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.Test; +import org.lowcoder.plugin.api.EndpointExtension; +import org.lowcoder.plugin.api.PluginEndpoint; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Tests of {@link PluginAuthorizationManager}: the SpEL expression of {@code @EndpointExtension.authorize()} decides. + * The invocation carries the extension as its second argument, as {@code PluginEndpointHandlerImpl} builds it. + */ +class PluginAuthorizationManagerTest { + + private final PluginAuthorizationManager manager = new PluginAuthorizationManager(); + + private static EndpointExtension extension(String authorize) { + return new EndpointExtension() { + @Override + public Class annotationType() { + return EndpointExtension.class; + } + + @Override + public String uri() { + return "/x"; + } + + @Override + public PluginEndpoint.Method method() { + return PluginEndpoint.Method.GET; + } + + @Override + public String authorize() { + return authorize; + } + }; + } + + private static MethodInvocation invocation(EndpointExtension extension) { + try { + Method method = Object.class.getMethod("toString"); + return new MethodInvocation() { + @Override + public Method getMethod() { + return method; + } + + @Override + public Object[] getArguments() { + return new Object[]{"someString", extension}; + } + + @Override + public Object proceed() { + return null; + } + + @Override + public Object getThis() { + return "target"; + } + + @Override + public java.lang.reflect.AccessibleObject getStaticPart() { + return method; + } + }; + } catch (NoSuchMethodException e) { + throw new IllegalStateException(e); + } + } + + private static Mono user(String... roles) { + return Mono.just(new UsernamePasswordAuthenticationToken("user", "n/a", + List.of(roles).stream().map(SimpleGrantedAuthority::new).toList())); + } + + private Mono check(String authorize, Mono auth) { + return manager.check(auth, invocation(extension(authorize))); + } + + /** Catches an endpoint without an expression being evaluated (the erroring authentication would fail it). */ + @Test + void check_missingOrBlankExpression_grantsWithoutEvaluating() { + Mono failing = Mono.error(new IllegalStateException("must not be read")); + + for (String authorize : new String[]{null, "", " "}) { + StepVerifier.create(check(authorize, failing)).expectNextMatches(AuthorizationDecision::isGranted).verifyComplete(); + } + StepVerifier.create(manager.check(failing, invocation(null))).expectNextMatches(AuthorizationDecision::isGranted).verifyComplete(); + System.out.println("[PluginAuthorizationManagerTest] null/blank expression and null extension granted"); + } + + /** Catches roles being ignored or inverted. */ + @Test + void check_roleExpressions_decideByTheAuthorities() { + StepVerifier.create(check("isAuthenticated()", user("ROLE_USER"))).expectNextMatches(AuthorizationDecision::isGranted).verifyComplete(); + StepVerifier.create(check("hasRole('USER')", user("ROLE_USER"))).expectNextMatches(AuthorizationDecision::isGranted).verifyComplete(); + StepVerifier.create(check("hasRole('ADMIN')", user("ROLE_USER"))).expectNextMatches(d -> !d.isGranted()).verifyComplete(); + System.out.println("[PluginAuthorizationManagerTest] role expressions decided by authorities"); + } + + /** Catches a Mono-valued expression not being unwrapped (true and false both). */ + @Test + void check_monoBooleanExpression_isUnwrapped() { + StepVerifier.create(check("T(reactor.core.publisher.Mono).just(true)", user())).expectNextMatches(AuthorizationDecision::isGranted).verifyComplete(); + StepVerifier.create(check("T(reactor.core.publisher.Mono).just(false)", user())).expectNextMatches(d -> !d.isGranted()).verifyComplete(); + System.out.println("[PluginAuthorizationManagerTest] Mono unwrapped"); + } + + /** Catches a non-boolean result being treated as a decision. */ + @Test + void check_nonBooleanResults_failWithIllegalState() { + for (String expression : new String[]{"'text'", "T(reactor.core.publisher.Mono).just('text')", "T(reactor.core.publisher.Mono).empty()"}) { + StepVerifier.create(check(expression, user())) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(IllegalStateException.class).hasMessageContaining("must return boolean or Mono") + .hasMessageContaining(expression); + System.out.println("[PluginAuthorizationManagerTest] " + expression + " -> " + error.getMessage()); + }).verify(); + } + } + + /** Catches an evaluation failure not being reported as an illegal argument with the cause. */ + @Test + void check_evaluationFailure_failsWithIllegalArgumentAndCause() { + StepVerifier.create(check("noSuchFunction()", user())) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Failed to evaluate expression 'noSuchFunction()'"); + assertThat(error.getCause()).isNotNull(); + System.out.println("[PluginAuthorizationManagerTest] " + error.getMessage() + " / " + error.getCause().getClass().getSimpleName()); + }).verify(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/service/GlobalContextServiceImplTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/service/GlobalContextServiceImplTest.java new file mode 100644 index 0000000000..97a13222d2 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/framework/service/GlobalContextServiceImplTest.java @@ -0,0 +1,89 @@ +package org.lowcoder.api.framework.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Locale; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.reactive.function.server.HandlerStrategies; +import org.springframework.web.reactive.function.server.ServerRequest; + +/** + * Tests of {@link GlobalContextServiceImpl#getClientLocale} for both request types. + * + *

    Pinned under D-6, plan §9 row "malformed Accept-Language makes the error handler fail (LanguageRange.parse, + * GlobalContextServiceImpl)": a malformed header makes both overloads throw IllegalArgumentException; the effect on + * the client is shown in {@code CustomErrorWebExceptionHandlerTest}. + */ +class GlobalContextServiceImplTest { + + private final GlobalContextServiceImpl service = new GlobalContextServiceImpl(); + + private static MockServerHttpRequest httpRequest(String acceptLanguage) { + MockServerHttpRequest.BaseBuilder builder = MockServerHttpRequest.get("/api/x"); + if (acceptLanguage != null) { + builder.header("Accept-Language", acceptLanguage); + } + return builder.build(); + } + + private static ServerRequest serverRequest(String acceptLanguage) { + return ServerRequest.create(MockServerWebExchange.from(httpRequest(acceptLanguage)), HandlerStrategies.withDefaults().messageReaders()); + } + + private Locale resolve(boolean functional, String acceptLanguage) { + Function resolver = functional + ? header -> service.getClientLocale(serverRequest(header)) + : header -> service.getClientLocale(httpRequest(header)); + return resolver.apply(acceptLanguage); + } + + /** Catches the first range of the header not being used, or a missing header not falling back to English. */ + @ParameterizedTest + @CsvSource({ + "false,'en-US,de;q=0.5',en-US", "true,'en-US,de;q=0.5',en-US", + "false,de,de", "true,de,de", + "false,'zh-CN',zh-CN", "true,'zh-CN',zh-CN", + "false,,en", "true,,en"}) + void firstLanguageRange_orEnglish(boolean functional, String header, String expectedTag) { + Locale locale = resolve(functional, header); + + assertThat(locale).isEqualTo(Locale.forLanguageTag(expectedTag)); + System.out.println("[GlobalContextServiceImplTest] " + (functional ? "ServerRequest" : "ServerHttpRequest") + + " '" + header + "' -> " + locale.toLanguageTag()); + } + + /** Observes the range order of a header whose first entry has the lower weight. */ + @ParameterizedTest + @CsvSource({"false", "true"}) + void headerOrderedByWeight_observed(boolean functional) { + Locale locale = resolve(functional, "de;q=0.4,fr;q=0.9"); + + System.out.println("[GlobalContextServiceImplTest] 'de;q=0.4,fr;q=0.9' -> " + locale.toLanguageTag()); + assertThat(locale).isEqualTo(Locale.forLanguageTag("fr")); + } + + /** Pins plan §9 row "malformed Accept-Language makes the error handler fail": the parse error is not caught. */ + @ParameterizedTest + @CsvSource({"false", "true"}) + void malformedHeader_throwsIllegalArgument_pinsTheSection9Row(boolean functional) { + assertThatThrownBy(() -> resolve(functional, "this is !! not a language range")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> resolve(functional, "")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void wildcardHeader_observed() { + Locale locale = resolve(true, "*"); + + System.out.println("[GlobalContextServiceImplTest] '*' -> '" + locale.toLanguageTag() + "'"); + assertThat(locale).isEqualTo(Locale.forLanguageTag("*")); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderApiServiceImplTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderApiServiceImplTest.java new file mode 100644 index 0000000000..2e78ed7f26 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderApiServiceImplTest.java @@ -0,0 +1,878 @@ +package org.lowcoder.api.home; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.api.application.view.ApplicationInfoView; +import org.lowcoder.api.permission.PermissionHelper; +import org.lowcoder.api.permission.view.PermissionItemView; +import org.lowcoder.api.usermanagement.OrgDevChecker; +import org.lowcoder.domain.folder.model.Folder; +import org.lowcoder.domain.folder.model.FolderElement; +import org.lowcoder.domain.folder.service.FolderElementRelationService; +import org.lowcoder.domain.folder.service.FolderService; +import org.lowcoder.domain.group.service.GroupService; +import org.lowcoder.domain.interaction.UserFolderInteraction; +import org.lowcoder.domain.interaction.UserFolderInteractionService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.sdk.constants.FieldName; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.ArgumentCaptor; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Mockito unit tests of {@link FolderApiServiceImpl}: every collaborator is a mock, so the tests show which checks run + * and what the service hands to {@link FolderService}. The Spring + Mongo twin of the create pins is + * {@code FolderCreateKeepsClientIdPersistenceTest}. + * + *

    Several operations are written as {@code a.then(b(...))}: {@code b(...)} is CALLED while the chain is assembled + * even when {@code a} fails, and only subscribed when {@code a} succeeds. "Nothing was mutated" is therefore asserted + * with a flag set when the mutation Mono is subscribed, never with {@code verify(..., never())}. + * + *

    Pinned under D-6: plan §9 row "FolderApiServiceImpl.create keeps a client-supplied id ... overwrites that folder + * (with the caller's org id)", tests {@code create_withAClientSuppliedId*}, {@code create_withAnotherOrgsFolderId*}, + * {@code create_withIdEqualToParentFolderId*}. A fix (reject or ignore a client id) makes those tests fail on purpose. + */ +class FolderApiServiceImplTest { + + private static final String ORG = "org-caller"; + private static final String OTHER_ORG = "org-foreign"; + private static final String VISITOR = "visitor-1"; + private static final String OTHER_USER = "someone-else"; + private static final Instant CREATED = Instant.parse("2024-01-01T00:00:00Z"); + + private FolderService folderService; + private SessionUserService sessionUserService; + private OrgDevChecker orgDevChecker; + private UserHomeApiService userHomeApiService; + private FolderElementRelationService relationService; + private ResourcePermissionService permissionService; + private PermissionHelper permissionHelper; + private UserService userService; + private OrganizationService organizationService; + private UserFolderInteractionService interactionService; + private FolderApiServiceImpl service; + + @BeforeEach + void setUp() { + folderService = mock(FolderService.class); + sessionUserService = mock(SessionUserService.class); + orgDevChecker = mock(OrgDevChecker.class); + userHomeApiService = mock(UserHomeApiService.class); + relationService = mock(FolderElementRelationService.class); + permissionService = mock(ResourcePermissionService.class); + permissionHelper = mock(PermissionHelper.class); + userService = mock(UserService.class); + organizationService = mock(OrganizationService.class); + interactionService = mock(UserFolderInteractionService.class); + service = new FolderApiServiceImpl(folderService, sessionUserService, orgDevChecker, userHomeApiService, + relationService, permissionService, permissionHelper, mock(GroupService.class), userService, + organizationService, interactionService); + + signedInAs(MemberRole.MEMBER); + when(orgDevChecker.checkCurrentOrgDev()).thenReturn(Mono.empty()); + when(orgDevChecker.isCurrentOrgDev()).thenReturn(Mono.just(false)); + when(folderService.findByOrganizationId(anyString())).thenReturn(Flux.empty()); + when(folderService.create(any(Folder.class))).thenAnswer(invocation -> Mono.just(invocation.getArgument(0))); + when(userService.findById(anyString())).thenAnswer(invocation -> Mono.just(user(invocation.getArgument(0)))); + } + + // ----------------------------------------------------------------- fixtures + + private void signedInAs(MemberRole role) { + OrgMember member = OrgMember.builder().orgId(ORG).userId(VISITOR).role(role).build(); + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(member)); + when(sessionUserService.getVisitorId()).thenReturn(Mono.just(VISITOR)); + } + + private static User user(String id) { + User user = new User(); + user.setId(id); + user.setName("name of " + id); + return user; + } + + private static Folder folder(String id, String org, String parent, String name, String creator) { + Folder folder = new Folder(); + folder.setId(id); + folder.setOrganizationId(org); + folder.setParentFolderId(parent); + folder.setName(name); + folder.setCreatedBy(creator); + folder.setCreatedAt(CREATED); + return folder; + } + + private static Folder request(String id, String parent, String name) { + Folder folder = new Folder(); + folder.setId(id); + folder.setParentFolderId(parent); + folder.setName(name); + return folder; + } + + private static void expectBizError(org.reactivestreams.Publisher mono, BizError error) { + StepVerifier.create(mono) + .expectErrorSatisfies(throwable -> { + assertThat(throwable).isInstanceOf(BizException.class); + assertThat(((BizException) throwable).getError()).isEqualTo(error); + System.out.println("[FolderApiServiceImplTest] " + error + ": " + throwable.getMessage()); + }) + .verify(); + } + + private Folder capturedCreate() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Folder.class); + verify(folderService).create(captor.capture()); + return captor.getValue(); + } + + private static Mono flagged(AtomicBoolean subscribed, T value) { + return Mono.defer(() -> { + subscribed.set(true); + return Mono.justOrEmpty(value); + }); + } + + private static Mono flaggedVoid(AtomicBoolean subscribed) { + return Mono.defer(() -> { + subscribed.set(true); + return Mono.empty(); + }); + } + + // ------------------------------------------------------------------- create + + /** Catches nameless folders being stored: the name check comes before anything else runs. */ + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + void create_blankName_failsBeforeAnyServiceCall(String name) { + expectBizError(service.create(request(null, null, name)), BizError.INVALID_PARAMETER); + + verifyNoInteractions(folderService, orgDevChecker, sessionUserService); + } + + /** Catches the gid being missing, or organization and creator coming from the request instead of the session. */ + @Test + void create_generatesGid_setsOrgAndCreatorFromTheVisitor_andBuildsTheView() { + Folder body = request(null, null, "reports"); + body.setOrganizationId(OTHER_ORG); + body.setCreatedBy(OTHER_USER); + + StepVerifier.create(service.create(body)) + .assertNext(view -> { + assertThat(view.getName()).isEqualTo("reports"); + assertThat(view.getOrgId()).isEqualTo(ORG); + assertThat(view.getCreateBy()).isEqualTo("name of " + VISITOR); + assertThat(view.isVisible()).isTrue(); + assertThat(view.isManageable()).isTrue(); + assertThat(FieldName.isGID(view.getFolderGid())).isTrue(); + System.out.println("[FolderApiServiceImplTest] created " + view); + }) + .verifyComplete(); + + Folder saved = capturedCreate(); + assertThat(saved.getOrganizationId()).isEqualTo(ORG); + assertThat(saved.getCreatedBy()).isEqualTo(VISITOR); + assertThat(FieldName.isGID(saved.getGid())).isTrue(); + assertThat(saved.getId()).isNull(); + } + + /** Catches folders being created by users who are not developers of the organization. */ + @Test + void create_requiresOrgDev() { + when(orgDevChecker.checkCurrentOrgDev()).thenReturn(Mono.error(new BizException(BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED"))); + + expectBizError(service.create(request(null, null, "reports")), BizError.NOT_AUTHORIZED); + + verify(folderService, never()).create(any()); + } + + /** Catches a folder being created under a missing parent or under a parent of another organization. */ + @Test + void create_withParent_requiresAnExistingParentOfTheCallersOrg() { + when(folderService.findById("missing-parent")).thenReturn(Mono.empty()); + when(folderService.findById("foreign-parent")).thenReturn(Mono.just(folder("foreign-parent", OTHER_ORG, null, "p", OTHER_USER))); + when(folderService.findById("good-parent")).thenReturn(Mono.just(folder("good-parent", ORG, null, "p", OTHER_USER))); + + expectBizError(service.create(request(null, "missing-parent", "child")), BizError.FOLDER_NOT_EXIST); + expectBizError(service.create(request(null, "foreign-parent", "child")), BizError.FOLDER_NOT_EXIST); + verify(folderService, never()).create(any()); + + StepVerifier.create(service.create(request(null, "good-parent", "child"))) + .assertNext(view -> assertThat(view.getParentFolderId()).isEqualTo("good-parent")) + .verifyComplete(); + assertThat(capturedCreate().getParentFolderId()).isEqualTo("good-parent"); + } + + /** Catches duplicate sibling names, and names of other parents (or other organizations) counting as conflicts. */ + @Test + void create_nameUniqueness_isPerParentWithinTheCallersOrg() { + when(folderService.findByOrganizationId(ORG)).thenReturn(Flux.just( + folder("f-root", ORG, null, "taken", OTHER_USER), + folder("f-under-p", ORG, "p", "taken-in-p", OTHER_USER))); + when(folderService.findById("p")).thenReturn(Mono.just(folder("p", ORG, null, "p", OTHER_USER))); + + expectBizError(service.create(request(null, null, "taken")), BizError.FOLDER_NAME_CONFLICT); + expectBizError(service.create(request(null, "p", "taken-in-p")), BizError.FOLDER_NAME_CONFLICT); + verify(folderService, never()).create(any()); + + StepVerifier.create(service.create(request(null, "p", "taken"))).expectNextCount(1).verifyComplete(); + verify(folderService, atLeastOnce()).findByOrganizationId(ORG); + verify(folderService, never()).findByOrganizationId(OTHER_ORG); + } + + /** + * Pins plan §9 row "FolderApiServiceImpl.create keeps a client-supplied id ..." for (a), an id of a folder of the + * caller's own org: the entity handed to {@code folderService.create} (repository save, a replace by id) keeps the + * client's id, has no gid, and carries the caller's org and creator. No lookup of that id ever happens. + */ + @Test + void create_withAClientSuppliedId_handsTheIdToTheRepository_withoutLookingItUp_pinsClientIdRow() { + StepVerifier.create(service.create(request("existing-id", null, "new name"))).expectNextCount(1).verifyComplete(); + + Folder saved = capturedCreate(); + assertThat(saved.getId()).isEqualTo("existing-id"); + assertThat(saved.getGid()).as("a client id suppresses the gid generation").isNull(); + assertThat(saved.getOrganizationId()).isEqualTo(ORG); + assertThat(saved.getCreatedBy()).isEqualTo(VISITOR); + verify(folderService, never()).findById(any()); + verify(folderService, never()).exist(any()); + System.out.println("[FolderApiServiceImplTest] (a) saved with the client id " + saved.getId() + ", gid " + saved.getGid()); + } + + /** + * Pins the same row, (a) with the same name and parent: the unique-name check lists the existing folder as a + * sibling and rejects it. Accidental protection, only when name and parent are unchanged. + */ + @Test + void create_withTheIdNameAndParentOfAnExistingFolder_isRejectedByTheNameCheck_pinsClientIdRow() { + when(folderService.findByOrganizationId(ORG)).thenReturn(Flux.just(folder("existing-id", ORG, null, "same", OTHER_USER))); + + expectBizError(service.create(request("existing-id", null, "same")), BizError.FOLDER_NAME_CONFLICT); + + verify(folderService, never()).create(any()); + } + + /** + * Pins the same row for (b), an id of ANOTHER org's folder: only the caller's org is listed for the name check, the + * foreign folder is never looked up, and the entity saved over it is stamped with the caller's org and creator. + */ + @Test + void create_withAnotherOrgsFolderId_overwritesItWithTheCallersOrg_pinsClientIdRow() { + when(folderService.findByOrganizationId(ORG)).thenReturn(Flux.just(folder("mine", ORG, null, "mine", VISITOR))); + + StepVerifier.create(service.create(request("foreign-folder-id", null, "taken over"))).expectNextCount(1).verifyComplete(); + + Folder saved = capturedCreate(); + assertThat(saved.getId()).isEqualTo("foreign-folder-id"); + assertThat(saved.getOrganizationId()).isEqualTo(ORG); + assertThat(saved.getCreatedBy()).isEqualTo(VISITOR); + verify(folderService, never()).findByOrganizationId(OTHER_ORG); + verify(folderService, never()).findById("foreign-folder-id"); + System.out.println("[FolderApiServiceImplTest] (b) foreign id saved with org " + saved.getOrganizationId()); + } + + /** + * Pins the same row for (c), id equal to parentFolderId: the parent check finds the folder (it exists in the + * caller's org) and the saved entity is its own parent. Only the creation of such data is shown here. + */ + @Test + void create_withIdEqualToParentFolderId_storesASelfParentedFolder_pinsClientIdRow() { + when(folderService.findById("self-id")).thenReturn(Mono.just(folder("self-id", ORG, null, "old", VISITOR))); + + StepVerifier.create(service.create(request("self-id", "self-id", "loop"))).expectNextCount(1).verifyComplete(); + + Folder saved = capturedCreate(); + assertThat(saved.getId()).isEqualTo("self-id"); + assertThat(saved.getParentFolderId()).isEqualTo(saved.getId()); + verify(folderService, times(1)).findById("self-id"); + System.out.println("[FolderApiServiceImplTest] (c) saved folder " + saved.getId() + " with parent " + saved.getParentFolderId()); + } + + // ------------------------------------------------------------ existence/org + + @Test + void checkFolderExist_returnsTheFolder_orFailsWithTheId() { + Folder existing = folder("f1", ORG, null, "f1", VISITOR); + when(folderService.findById("f1")).thenReturn(Mono.just(existing)); + when(folderService.findById("f2")).thenReturn(Mono.empty()); + + StepVerifier.create(service.checkFolderExist("f1")).expectNext(existing).verifyComplete(); + StepVerifier.create(service.checkFolderExist("f2")) + .expectErrorSatisfies(throwable -> { + assertThat(((BizException) throwable).getError()).isEqualTo(BizError.FOLDER_NOT_EXIST); + assertThat(((BizException) throwable).getArgs()).containsExactly("f2"); + }) + .verify(); + } + + @Test + void checkFolderCurrentOrg_acceptsOnlyTheSameOrg() { + StepVerifier.create(service.checkFolderCurrentOrg(folder("f1", ORG, null, "n", null), ORG)).verifyComplete(); + expectBizError(service.checkFolderCurrentOrg(folder("f1", OTHER_ORG, null, "n", null), ORG), BizError.FOLDER_NOT_EXIST); + } + + // ------------------------------------------------------------------- delete + + /** Catches the manage check: org admins and super admins skip the creator lookup, others must be the creator. */ + @ParameterizedTest + @ValueSource(strings = {"ADMIN", "SUPER_ADMIN"}) + void delete_adminsMayDeleteWithoutBeingTheCreator(String role) { + signedInAs(MemberRole.valueOf(role)); + when(folderService.findByOrganizationId(ORG)).thenReturn(Flux.just(folder("f1", ORG, null, "f1", OTHER_USER))); + stubDeleteCollaborators(); + + StepVerifier.create(service.delete("f1")).assertNext(deleted -> assertThat(deleted.getId()).isEqualTo("f1")).verifyComplete(); + + verify(folderService, never()).findById(any()); + } + + @Test + void delete_creatorMayDelete_otherMembersMayNot() { + when(folderService.findByOrganizationId(ORG)).thenReturn(Flux.just(folder("f1", ORG, null, "f1", VISITOR), folder("f2", ORG, null, "f2", OTHER_USER))); + when(folderService.findById("f1")).thenReturn(Mono.just(folder("f1", ORG, null, "f1", VISITOR))); + when(folderService.findById("f2")).thenReturn(Mono.just(folder("f2", ORG, null, "f2", OTHER_USER))); + AtomicBoolean deleted = new AtomicBoolean(); + when(folderService.deleteAllById(anyCollection())).thenReturn(flaggedVoid(deleted)); + stubDeleteCollaborators(deleted); + + StepVerifier.create(service.delete("f1")).expectNextCount(1).verifyComplete(); + assertThat(deleted).isTrue(); + + deleted.set(false); + expectBizError(service.delete("f2"), BizError.FOLDER_OPERATE_NO_PERMISSION); + assertThat(deleted).as("nothing deleted for a non-creator").isFalse(); + } + + /** + * Pins today's behaviour (no §9 row): a non-admin deleting a folder that does not exist gets an EMPTY result, not + * a FOLDER_NOT_EXIST error, because the creator lookup is empty; nothing is deleted. + */ + @Test + void delete_unknownFolder_asANonAdmin_completesEmpty_withoutDeleting() { + when(folderService.findById("ghost")).thenReturn(Mono.empty()); + AtomicBoolean deleted = new AtomicBoolean(); + when(folderService.deleteAllById(anyCollection())).thenReturn(flaggedVoid(deleted)); + + StepVerifier.create(service.delete("ghost")).verifyComplete(); + + assertThat(deleted).isFalse(); + System.out.println("[FolderApiServiceImplTest] delete of an unknown folder by a member completes empty"); + } + + @Test + void delete_unknownFolderInTheTree_asAnAdmin_failsWithFolderNotExist() { + signedInAs(MemberRole.ADMIN); + when(folderService.findByOrganizationId(ORG)).thenReturn(Flux.just(folder("f1", ORG, null, "f1", VISITOR))); + AtomicBoolean deleted = new AtomicBoolean(); + when(folderService.deleteAllById(anyCollection())).thenReturn(flaggedVoid(deleted)); + + expectBizError(service.delete("ghost"), BizError.FOLDER_NOT_EXIST); + + assertThat(deleted).isFalse(); + } + + /** Catches sub folders surviving the delete, or the permissions and element relations of the subtree staying behind. */ + @Test + void delete_removesTheWholeSubtree_itsPermissions_andItsRelations() { + signedInAs(MemberRole.ADMIN); + when(folderService.findByOrganizationId(ORG)).thenReturn(Flux.just( + folder("top", ORG, null, "top", VISITOR), + folder("child", ORG, "top", "child", VISITOR), + folder("grandchild", ORG, "child", "grandchild", VISITOR), + folder("unrelated", ORG, null, "unrelated", VISITOR))); + stubDeleteCollaborators(); + + StepVerifier.create(service.delete("top")).assertNext(deleted -> assertThat(deleted.getId()).isEqualTo("top")).verifyComplete(); + + ArgumentCaptor> deleted = ArgumentCaptor.forClass(java.util.Collection.class); + verify(folderService).deleteAllById(deleted.capture()); + assertThat(deleted.getValue()).containsExactlyInAnyOrder("top", "child", "grandchild"); + ArgumentCaptor> relations = ArgumentCaptor.forClass(List.class); + verify(relationService).deleteByFolderIds(relations.capture()); + assertThat(relations.getValue()).containsExactlyInAnyOrder("top", "child", "grandchild"); + verify(permissionService).removeById("perm-1"); + verify(permissionService).removeById("perm-2"); + System.out.println("[FolderApiServiceImplTest] deleted " + deleted.getValue()); + } + + private void stubDeleteCollaborators() { + stubDeleteCollaborators(new AtomicBoolean()); + when(folderService.deleteAllById(anyCollection())).thenReturn(Mono.empty()); + } + + private void stubDeleteCollaborators(AtomicBoolean ignored) { + ResourcePermission p1 = ResourcePermission.builder().id("perm-1").build(); + ResourcePermission p2 = ResourcePermission.builder().id("perm-2").build(); + when(permissionService.getByResourceTypeAndResourceId(eq(ResourceType.FOLDER), anyString())).thenReturn(Mono.just(List.of(p1, p2))); + when(permissionService.removeById(anyString())).thenReturn(Mono.just(true)); + when(relationService.deleteByFolderIds(anyList())).thenReturn(Mono.just(true)); + } + + // ------------------------------------------------------------------- update + + /** Catches update changing more than the six editable fields (id, org, parent, creator), or ignoring the name check. */ + @Test + void update_copiesOnlyTheEditableFields_andAllowsKeepingItsOwnName() { + signedInAs(MemberRole.ADMIN); + Folder existing = folder("f1", ORG, "p", "own name", VISITOR); + when(folderService.findById("f1")).thenReturn(Mono.just(existing)); + when(folderService.findByOrganizationId(ORG)).thenReturn(Flux.just(existing, folder("f2", ORG, "p", "sibling", VISITOR))); + AtomicReference sent = new AtomicReference<>(); + when(folderService.updateById(eq("f1"), any(Folder.class))).thenAnswer(invocation -> { + sent.set(invocation.getArgument(1)); + return Mono.just(true); + }); + Folder body = request("f1", "elsewhere", "own name"); + body.setOrganizationId(OTHER_ORG); + body.setTitle("title"); + body.setType("type"); + body.setCategory("category"); + body.setDescription("description"); + body.setImage("image"); + + StepVerifier.create(service.update(body)).assertNext(view -> assertThat(view.getName()).isEqualTo("own name")).verifyComplete(); + + Folder update = sent.get(); + assertThat(update.getName()).isEqualTo("own name"); + assertThat(update.getTitle()).isEqualTo("title"); + assertThat(update.getType()).isEqualTo("type"); + assertThat(update.getCategory()).isEqualTo("category"); + assertThat(update.getDescription()).isEqualTo("description"); + assertThat(update.getImage()).isEqualTo("image"); + assertThat(update.getId()).isNull(); + assertThat(update.getOrganizationId()).isNull(); + assertThat(update.getParentFolderId()).isNull(); + } + + @Test + void update_toASiblingsName_isRejected_andNotWrittenWithoutPermission() { + signedInAs(MemberRole.ADMIN); + Folder existing = folder("f1", ORG, "p", "own name", VISITOR); + when(folderService.findById("f1")).thenReturn(Mono.just(existing)); + when(folderService.findByOrganizationId(ORG)).thenReturn(Flux.just(existing, folder("f2", ORG, "p", "sibling", VISITOR))); + AtomicBoolean written = new AtomicBoolean(); + when(folderService.updateById(eq("f1"), any(Folder.class))).thenReturn(flagged(written, true)); + + expectBizError(service.update(request("f1", null, "sibling")), BizError.FOLDER_NAME_CONFLICT); + assertThat(written).isFalse(); + + signedInAs(MemberRole.MEMBER); + when(folderService.findById("f1")).thenReturn(Mono.just(folder("f1", ORG, "p", "own name", OTHER_USER))); + expectBizError(service.update(request("f1", null, "fresh")), BizError.FOLDER_OPERATE_NO_PERMISSION); + assertThat(written).as("a member who is not the creator writes nothing").isFalse(); + } + + // ------------------------------------------------------------- move/bundle + + private Mono doMove(boolean bundle, String id, String target) { + return bundle ? service.moveBundle(id, target) : service.move(id, target); + } + + /** Catches the permission check being skipped or using the wrong action, and relations changing before it passes. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void move_checksTheMatchingPermissionFirst_thenReplacesTheRelation(boolean bundle) { + ResourceAction action = bundle ? ResourceAction.MANAGE_BUNDLES : ResourceAction.MANAGE_APPLICATIONS; + AtomicBoolean removed = new AtomicBoolean(); + AtomicBoolean created = new AtomicBoolean(); + when(relationService.deleteByElementId("el")).thenReturn(flagged(removed, true)); + when(relationService.create(anyString(), anyString())).thenReturn(flaggedVoid(created)); + + when(permissionService.checkResourcePermissionWithError(VISITOR, "el", action)) + .thenReturn(Mono.error(new BizException(BizError.NOT_AUTHORIZED, "NO_PERMISSION"))); + expectBizError(doMove(bundle, "el", "target"), BizError.NOT_AUTHORIZED); + assertThat(removed).isFalse(); + assertThat(created).isFalse(); + + when(permissionService.checkResourcePermissionWithError(VISITOR, "el", action)).thenReturn(Mono.empty()); + StepVerifier.create(doMove(bundle, "el", "target")).verifyComplete(); + assertThat(removed).isTrue(); + assertThat(created).isTrue(); + verify(relationService).create("target", "el"); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void move_toTheRoot_onlyRemovesTheOldRelation(boolean bundle) { + when(permissionService.checkResourcePermissionWithError(anyString(), anyString(), any(ResourceAction.class))).thenReturn(Mono.empty()); + when(relationService.deleteByElementId("el")).thenReturn(Mono.just(true)); + + StepVerifier.create(doMove(bundle, "el", " ")).verifyComplete(); + StepVerifier.create(doMove(bundle, "el", null)).verifyComplete(); + + verify(relationService, times(2)).deleteByElementId("el"); + verify(relationService, never()).create(any(), any()); + } + + // ----------------------------------------------------------- last view time + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void upsertLastViewTime_blankFolder_doesNothing(String folderId) { + StepVerifier.create(service.upsertLastViewTime(folderId)).verifyComplete(); + + verifyNoInteractions(interactionService); + } + + @Test + void upsertLastViewTime_recordsNowForTheVisitor() { + AtomicBoolean subscribed = new AtomicBoolean(); + when(interactionService.upsert(eq(VISITOR), eq("f1"), any(Instant.class))).thenReturn(flaggedVoid(subscribed)); + Instant before = Instant.now(); + + StepVerifier.create(service.upsertLastViewTime("f1")).verifyComplete(); + + ArgumentCaptor time = ArgumentCaptor.forClass(Instant.class); + verify(interactionService).upsert(eq(VISITOR), eq("f1"), time.capture()); + assertThat(subscribed).isTrue(); + assertThat(time.getValue()).isBetween(before, Instant.now()); + } + + // -------------------------------------------------------------- getElements + + private static ApplicationInfoView app(String id, String name, long lastViewMillis) { + return ApplicationInfoView.builder().applicationId(id).name(name) + .lastViewTime(lastViewMillis == 0 ? null : Instant.ofEpochMilli(lastViewMillis)).build(); + } + + private void stubListing(List folders, List apps, Map appToFolder, + List interactions) { + when(folderService.findByOrganizationId(ORG)).thenReturn(Flux.fromIterable(folders)); + when(userHomeApiService.getAllAuthorisedApplications4CurrentOrgMember(any(), any(), anyBoolean(), any(), any())) + .thenReturn(Flux.fromIterable(apps)); + List relations = new ArrayList<>(); + appToFolder.forEach((app, folder) -> relations.add(new FolderElement(folder, app))); + when(relationService.getByElementIds(anyList())).thenReturn(Flux.fromIterable(relations)); + when(interactionService.findByUserId(VISITOR)).thenReturn(Flux.fromIterable(interactions)); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(Map.of(OTHER_USER, user(OTHER_USER), VISITOR, user(VISITOR)))); + } + + private static boolean anyBoolean() { + return org.mockito.ArgumentMatchers.anyBoolean(); + } + + private List listing(String folderId, String name) { + List raw = service.getElements(folderId, null, name, null).collectList().block(java.time.Duration.ofSeconds(10)); + @SuppressWarnings("unchecked") List out = (List) raw; + assertThat(out).isNotNull(); + return out; + } + + /** + * Catches the visibility rule (a folder is visible to devs and admins, or when something visible is inside), + * the manageable rule (admin, super admin or creator), and the placement of applications into their folders. + */ + @Test + void getElements_root_marksVisibilityAndManageability_andPlacesApplications() { + stubListing( + List.of(folder("f-full", ORG, null, "Alpha", OTHER_USER), folder("f-empty", ORG, null, "Beta", VISITOR)), + List.of(app("a-root", "zeta", 0), app("a-in", "inside", 0)), + Map.of("a-in", "f-full"), + List.of()); + + List root = listing(null, null); + + assertThat(root).hasSize(3); + FolderInfoView full = (FolderInfoView) root.stream().filter(o -> o instanceof FolderInfoView f && f.getFolderId().equals("f-full")).findFirst().orElseThrow(); + FolderInfoView empty = (FolderInfoView) root.stream().filter(o -> o instanceof FolderInfoView f && f.getFolderId().equals("f-empty")).findFirst().orElseThrow(); + assertThat(full.isVisible()).as("has an application inside").isTrue(); + assertThat(full.isManageable()).as("member, not the creator").isFalse(); + assertThat(full.getSubApplications()).extracting(ApplicationInfoView::getApplicationId).containsExactly("a-in"); + assertThat(empty.isVisible()).as("empty and the visitor is no developer").isFalse(); + assertThat(empty.isManageable()) + .as("the visitor created it, but the rule compares the user id with the creator's NAME (observed defect, pinned)") + .isFalse(); + assertThat(root).anyMatch(o -> o instanceof ApplicationInfoView a && a.getApplicationId().equals("a-root")); + System.out.println("[FolderApiServiceImplTest] root listing " + root.size() + " elements"); + } + + /** + * Pins plan §9 row "getElements' creator rule compares user id with creator name": the creator rule of {@code manageable} compares the visitor's + * user ID with {@code FolderInfoView.createBy}, which holds the creator's display NAME, so a non-admin creator + * manages a folder only when the name happens to equal the id. + */ + @Test + void getElements_creatorRuleComparesTheUserIdWithTheCreatorsName_pinsTheSection9Row() { + User sameNameAsId = new User(); + sameNameAsId.setId(VISITOR); + sameNameAsId.setName(VISITOR); + stubListing(List.of(folder("f-mine", ORG, null, "Mine", VISITOR)), List.of(), Map.of(), List.of()); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(Map.of(VISITOR, sameNameAsId))); + + FolderInfoView view = (FolderInfoView) listing(null, null).get(0); + + assertThat(view.getCreateBy()).isEqualTo(VISITOR); + assertThat(view.isManageable()).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"ADMIN", "SUPER_ADMIN"}) + void getElements_forDevsAndAdmins_everyFolderIsVisible_andAdminsManageEverything(String role) { + signedInAs(MemberRole.valueOf(role)); + when(orgDevChecker.isCurrentOrgDev()).thenReturn(Mono.just(true)); + stubListing(List.of(folder("f-empty", ORG, null, "Beta", OTHER_USER)), List.of(), Map.of(), List.of()); + + List root = listing(null, null); + + FolderInfoView view = (FolderInfoView) root.get(0); + assertThat(view.isVisible()).isTrue(); + assertThat(view.isManageable()).isTrue(); + } + + /** Catches empty sub folders being listed to a visitor who may not see them: only visible children are listed. */ + @Test + void getElements_subFolders_listOnlyTheVisibleOnes() { + stubListing( + List.of(folder("top", ORG, null, "top", OTHER_USER), + folder("with-app", ORG, "top", "with-app", OTHER_USER), + folder("empty-child", ORG, "top", "empty-child", OTHER_USER)), + List.of(app("a-in", "inside", 0)), + Map.of("a-in", "with-app"), + List.of()); + + FolderInfoView top = (FolderInfoView) listing(null, null).stream() + .filter(o -> o instanceof FolderInfoView f && f.getFolderId().equals("top")).findFirst().orElseThrow(); + + assertThat(top.getSubFolders()).extracting(FolderInfoView::getFolderId).containsExactly("with-app"); + assertThat(top.isVisible()).isTrue(); + } + + /** Catches the sort head: the most recently viewed element comes first (the rest of the order is not asserted). */ + @Test + void getElements_mostRecentlyViewedComesFirst() { + stubListing( + List.of(folder("f-old", ORG, null, "old", OTHER_USER), folder("f-new", ORG, null, "new", OTHER_USER)), + List.of(app("a1", "app", 0)), + Map.of(), + List.of(new UserFolderInteraction(VISITOR, "f-old", Instant.ofEpochMilli(1000)), + new UserFolderInteraction(VISITOR, "f-new", Instant.ofEpochMilli(2000)))); + when(orgDevChecker.isCurrentOrgDev()).thenReturn(Mono.just(true)); + + List root = listing(null, null); + + assertThat(((FolderInfoView) root.get(0)).getFolderId()).isEqualTo("f-new"); + } + + @Test + void getElements_ofAFolder_listsItsChildren_andAnUnknownFolderFails() { + stubListing( + List.of(folder("f1", ORG, null, "f1", OTHER_USER), folder("f2", ORG, "f1", "child folder", OTHER_USER)), + List.of(app("a-in", "inside", 0), app("a-root", "outside", 0)), + Map.of("a-in", "f1"), + List.of()); + when(orgDevChecker.isCurrentOrgDev()).thenReturn(Mono.just(true)); + + List children = listing("f1", null); + + assertThat(children).hasSize(2); + assertThat(children).anyMatch(o -> o instanceof FolderInfoView f && f.getFolderId().equals("f2")); + assertThat(children).anyMatch(o -> o instanceof ApplicationInfoView a && a.getApplicationId().equals("a-in")); + + expectBizError(service.getElements("ghost", null, null, null), BizError.FOLDER_NOT_EXIST); + } + + /** Catches the name filter missing a folder's name, type or description (case-insensitive). */ + @Test + void getElements_nameFilter_matchesFolderNameTypeOrDescription() { + Folder byName = folder("f-name", ORG, null, "Quarterly", OTHER_USER); + Folder byType = folder("f-type", ORG, null, "x1", OTHER_USER); + byType.setType("quarter-type"); + Folder byDescription = folder("f-desc", ORG, null, "x2", OTHER_USER); + byDescription.setDescription("about QUARTER"); + Folder none = folder("f-none", ORG, null, "unrelated", OTHER_USER); + stubListing(List.of(byName, byType, byDescription, none), List.of(), Map.of(), List.of()); + + List root = listing(null, "quarter"); + + assertThat(root).extracting(o -> ((FolderInfoView) o).getFolderId()).containsExactlyInAnyOrder("f-name", "f-type", "f-desc"); + } + + // -------------------------------------------------------------- permissions + + @Test + void grantPermission_withoutUsersAndGroups_isANoOp() { + StepVerifier.create(service.grantPermission("f1", Set.of(), null, ResourceRole.VIEWER)).verifyComplete(); + + verifyNoInteractions(sessionUserService, permissionService); + } + + @Test + void grantPermission_requiresManagePermissionAndAnExistingFolder() { + AtomicBoolean inserted = new AtomicBoolean(); + when(permissionService.insertBatchPermission(any(), anyString(), any(), any(), any())).thenReturn(flaggedVoid(inserted)); + when(folderService.findById("f1")).thenReturn(Mono.just(folder("f1", ORG, null, "f1", OTHER_USER))); + + expectBizError(service.grantPermission("f1", Set.of("u2"), Set.of(), ResourceRole.VIEWER), BizError.FOLDER_OPERATE_NO_PERMISSION); + assertThat(inserted).isFalse(); + + signedInAs(MemberRole.ADMIN); + when(folderService.findById("ghost")).thenReturn(Mono.empty()); + expectBizError(service.grantPermission("ghost", Set.of("u2"), Set.of(), ResourceRole.VIEWER), BizError.FOLDER_NOT_EXIST); + assertThat(inserted).isFalse(); + + StepVerifier.create(service.grantPermission("f1", Set.of("u2"), Set.of("g1"), ResourceRole.EDITOR)).verifyComplete(); + assertThat(inserted).isTrue(); + verify(permissionService).insertBatchPermission(ResourceType.FOLDER, "f1", Set.of("u2"), Set.of("g1"), ResourceRole.EDITOR); + } + + /** Catches a permission id of another resource being accepted: it must be rejected before any mutation. */ + @Test + void updateAndRemovePermission_rejectUnknownAndForeignPermissionIds_beforeMutating() { + signedInAs(MemberRole.ADMIN); + AtomicBoolean updated = new AtomicBoolean(); + AtomicBoolean removed = new AtomicBoolean(); + when(permissionService.updateRoleById(anyString(), any())).thenReturn(flagged(updated, true)); + when(permissionService.removeById(anyString())).thenReturn(flagged(removed, true)); + when(permissionService.getById("p-missing")).thenReturn(Mono.empty()); + when(permissionService.getById("p-foreign")).thenReturn(Mono.just(ResourcePermission.builder().id("p-foreign").resourceId("other-folder").build())); + when(permissionService.getById("p-own")).thenReturn(Mono.just(ResourcePermission.builder().id("p-own").resourceId("f1").build())); + + for (boolean remove : new boolean[]{false, true}) { + Mono missing = remove ? service.removePermission("f1", "p-missing") : service.updatePermission("f1", "p-missing", ResourceRole.VIEWER); + StepVerifier.create(missing).expectErrorSatisfies(t -> { + assertThat(((BizException) t).getError()).isEqualTo(BizError.ILLEGAL_FOLDER_PERMISSION_ID); + assertThat(((BizException) t).getMessageKey()).isEqualTo("PERMISSION_NOT_EXIST"); + }).verify(); + Mono foreign = remove ? service.removePermission("f1", "p-foreign") : service.updatePermission("f1", "p-foreign", ResourceRole.VIEWER); + StepVerifier.create(foreign).expectErrorSatisfies(t -> { + assertThat(((BizException) t).getError()).isEqualTo(BizError.ILLEGAL_FOLDER_PERMISSION_ID); + assertThat(((BizException) t).getMessageKey()).isEqualTo("NO_PERMISSION_TO_OPERATE_FOLDER"); + }).verify(); + } + assertThat(updated).isFalse(); + assertThat(removed).isFalse(); + + StepVerifier.create(service.updatePermission("f1", "p-own", ResourceRole.EDITOR)).verifyComplete(); + assertThat(updated).isTrue(); + StepVerifier.create(service.removePermission("f1", "p-own")).verifyComplete(); + assertThat(removed).isTrue(); + verify(permissionService).updateRoleById("p-own", ResourceRole.EDITOR); + } + + @Test + void updateAndRemovePermission_requireManagePermission() { + when(folderService.findById("f1")).thenReturn(Mono.just(folder("f1", ORG, null, "f1", OTHER_USER))); + AtomicBoolean mutated = new AtomicBoolean(); + when(permissionService.getById("p")).thenReturn(Mono.just(ResourcePermission.builder().id("p").resourceId("f1").build())); + when(permissionService.updateRoleById(anyString(), any())).thenReturn(flagged(mutated, true)); + when(permissionService.removeById(anyString())).thenReturn(flagged(mutated, true)); + + expectBizError(service.updatePermission("f1", "p", ResourceRole.VIEWER), BizError.FOLDER_OPERATE_NO_PERMISSION); + expectBizError(service.removePermission("f1", "p"), BizError.FOLDER_OPERATE_NO_PERMISSION); + + assertThat(mutated).isFalse(); + } + + @Test + void getPermissions_assemblesTheViewFromTheFolderItsPermissionsAndTheOrg() { + when(folderService.findById("f1")).thenReturn(Mono.just(folder("f1", ORG, null, "f1", OTHER_USER))); + when(folderService.findById("ghost")).thenReturn(Mono.empty()); + when(permissionService.getByResourceTypeAndResourceId(ResourceType.FOLDER, "f1")).thenReturn(Mono.just(List.of())); + PermissionItemView groupItem = mock(PermissionItemView.class); + PermissionItemView userItem = mock(PermissionItemView.class); + when(permissionHelper.getGroupPermissions(anyList())).thenReturn(Mono.just(List.of(groupItem))); + when(permissionHelper.getUserPermissions(anyList())).thenReturn(Mono.just(List.of(userItem))); + Organization organization = new Organization(); + organization.setName("Acme"); + when(organizationService.getById(ORG)).thenReturn(Mono.just(organization)); + + StepVerifier.create(service.getPermissions("f1")).assertNext(view -> { + assertThat(view.getCreatorId()).isEqualTo(OTHER_USER); + assertThat(view.getOrgName()).isEqualTo("Acme"); + assertThat(view.getGroupPermissions()).containsExactly(groupItem); + assertThat(view.getUserPermissions()).containsExactly(userItem); + }).verifyComplete(); + when(permissionService.getByResourceTypeAndResourceId(ResourceType.FOLDER, "ghost")).thenReturn(Mono.just(List.of())); + StepVerifier.create(service.getPermissions("ghost")).verifyComplete(); + } + + // ------------------------------------------------------------ view building + + @Test + void buildFolderInfoView_mapsTheFolder_andToleratesAMissingCreationTime() { + Folder folder = folder("f1", ORG, "p", "name", OTHER_USER); + folder.setGid("gid-1"); + folder.setDescription("d"); + folder.setCategory("c"); + folder.setType("t"); + folder.setImage("i"); + + StepVerifier.create(service.buildFolderInfoView(folder, true, false)).assertNext(view -> { + assertThat(view.getFolderId()).isEqualTo("f1"); + assertThat(view.getFolderGid()).isEqualTo("gid-1"); + assertThat(view.getParentFolderId()).isEqualTo("p"); + assertThat(view.getOrgId()).isEqualTo(ORG); + assertThat(view.getDescription()).isEqualTo("d"); + assertThat(view.getCategory()).isEqualTo("c"); + assertThat(view.getType()).isEqualTo("t"); + assertThat(view.getImage()).isEqualTo("i"); + assertThat(view.getCreateAt()).isEqualTo(CREATED.toEpochMilli()); + assertThat(view.getCreateBy()).isEqualTo("name of " + OTHER_USER); + assertThat(view.isVisible()).isTrue(); + assertThat(view.isManageable()).isFalse(); + }).verifyComplete(); + + folder.setCreatedAt(null); + StepVerifier.create(service.buildFolderInfoView(folder, false, true)).assertNext(view -> { + assertThat(view.getCreateAt()).isZero(); + assertThat(view.isVisible()).isFalse(); + assertThat(view.isManageable()).isTrue(); + }).verifyComplete(); + } + + /** + * Pins today's behaviour (no §9 row): when the creator user cannot be found the view Mono is EMPTY. After a real + * {@code create} this cannot happen, because the creator is the caller; it is reachable for folders stored with + * another creator id (for example one replaced through the client-id path), which {@code getElements} never builds + * through this method. + */ + @Test + void buildFolderInfoView_withAMissingCreatorUser_completesEmpty() { + when(userService.findById("ghost-user")).thenReturn(Mono.empty()); + + StepVerifier.create(service.buildFolderInfoView(folder("f1", ORG, null, "n", "ghost-user"), true, true)).verifyComplete(); + System.out.println("[FolderApiServiceImplTest] missing creator user -> empty view"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderCreateKeepsClientIdPersistenceTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderCreateKeepsClientIdPersistenceTest.java new file mode 100644 index 0000000000..81d85160c1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderCreateKeepsClientIdPersistenceTest.java @@ -0,0 +1,199 @@ +package org.lowcoder.api.home; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.lowcoder.api.common.InitData; +import org.lowcoder.api.common.mockuser.WithMockUser; +import org.lowcoder.domain.folder.model.Folder; +import org.lowcoder.domain.folder.service.FolderService; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.context.ActiveProfiles; + +import reactor.test.StepVerifier; + +/** + * Real-persistence reproduction (Spring + MongoDB container) of plan §9 row "FolderApiServiceImpl.create keeps a + * client-supplied id ... overwrites that folder (with the caller's org id)": {@code POST} body = the {@code Folder} + * entity, {@code create} never looks at an id it is given, and {@code FolderServiceImpl.create} is + * {@code repository.save}, a replace by {@code _id}. + * + *

    The three cases: (a) the id of a folder of the caller's org, (b) the id of ANOTHER org's folder, (c) id equal to + * {@code parentFolderId}. For (c) only the creation of the self-parented data is shown; what listing and deletion do + * with it is pinned by the domain tests of the folder {@code Tree} (L3-8) and is not repeated here. + * Observed on top of the row: the replaced document has no {@code createdAt} (a replace of a non-new entity does not set + * it), which {@code getElements} would hit as a NullPointerException when building {@code createAt}; that is not exercised here. + * + *

    Pinned under D-6: a fix (rejecting an existing id, or ignoring a client id) makes these tests fail on purpose. + * + *

    Isolation: the profile {@code folderCreateClientId} is this class's own (its own context and database); the seed + * data is loaded by {@code InitData} (not profile-specific) and read only; every folder written here has a generated + * unique id or name and is deleted again in {@code @AfterAll}; seed folders are never touched. + */ +@SpringBootTest +@ActiveProfiles("folderCreateClientId") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FolderCreateKeepsClientIdPersistenceTest { + + private static final String CALLER_ORG = "org01"; + private static final Duration WAIT = Duration.ofSeconds(30); + + @Autowired + private FolderApiService folderApiService; + @Autowired + private FolderService folderService; + @Autowired + private InitData initData; + + private final String run = UUID.randomUUID().toString(); + private final List createdIds = new ArrayList<>(); + + @BeforeAll + void beforeAll() { + initData.init(); + } + + /** + * Deletes the folders this test wrote right after it: a client-id create leaves a folder WITHOUT gid, the unique + * index on gid is not sparse, and a second gid-less folder in the same database fails with a duplicate key (see + * {@link #create_withAClientSuppliedId_whileAGidlessFolderExists_failsWithDuplicateKey_pinsClientIdRow}). + */ + @AfterEach + void cleanUp() { + if (!createdIds.isEmpty()) { + folderService.deleteAllById(createdIds).block(WAIT); + } + System.out.println("[FolderCreateKeepsClientIdPersistenceTest] deleted own folders " + createdIds); + createdIds.clear(); + } + + private Folder body(String id, String parent, String name) { + Folder folder = new Folder(); + folder.setId(id); + folder.setParentFolderId(parent); + folder.setName(name + "-" + run); + return folder; + } + + private FolderInfoView createViaApi(Folder body) { + FolderInfoView view = folderApiService.create(body).block(WAIT); + assertThat(view).isNotNull(); + if (!createdIds.contains(view.getFolderId())) { + createdIds.add(view.getFolderId()); + } + return view; + } + + /** (a) A create carrying the id of an existing folder of the caller's org replaces that folder. */ + @Test + @WithMockUser + void create_withTheIdOfAnExistingFolder_replacesIt_pinsClientIdRow() { + FolderInfoView original = createViaApi(body(null, null, "victim-a")); + Folder before = folderService.findById(original.getFolderId()).block(WAIT); + assertThat(before.getGid()).isNotNull(); + + FolderInfoView replaced = createViaApi(body(original.getFolderId(), null, "overwritten-a")); + + assertThat(replaced.getFolderId()).isEqualTo(original.getFolderId()); + Folder after = folderService.findById(original.getFolderId()).block(WAIT); + System.out.println("[FolderCreateKeepsClientIdPersistenceTest] (a) before " + before.getName() + " gid " + before.getGid() + + " | after " + after.getName() + " gid " + after.getGid() + " createdAt " + after.getCreatedAt()); + assertThat(after.getName()).isEqualTo("overwritten-a-" + run); + assertThat(after.getGid()).as("the replacing entity had no gid").isNull(); + assertThat(after.getOrganizationId()).isEqualTo(CALLER_ORG); + assertThat(folderService.findByOrganizationId(CALLER_ORG).filter(f -> original.getFolderId().equals(f.getId())).count().block(WAIT)) + .as("still one document with that id").isEqualTo(1L); + } + + /** (a) with the same name and parent the unique-name check rejects it (accidental protection) and nothing changes. */ + @Test + @WithMockUser + void create_withTheIdNameAndParentOfAnExistingFolder_isRejected_pinsClientIdRow() { + FolderInfoView original = createViaApi(body(null, null, "victim-same")); + Folder same = body(original.getFolderId(), null, "victim-same"); + + StepVerifier.create(folderApiService.create(same)) + .expectErrorSatisfies(error -> assertThat(((BizException) error).getError()).isEqualTo(BizError.FOLDER_NAME_CONFLICT)) + .verify(WAIT); + + Folder after = folderService.findById(original.getFolderId()).block(WAIT); + assertThat(after.getName()).isEqualTo("victim-same-" + run); + assertThat(after.getGid()).isNotNull(); + } + + /** (b) A create carrying the id of ANOTHER org's folder takes that folder over, stamped with the caller's org. */ + @Test + @WithMockUser + void create_withAnotherOrgsFolderId_takesItOver_pinsClientIdRow() { + String foreignOrg = "org-foreign-" + run; + String foreignId = "foreignfolder" + run.replace("-", ""); // an id with a dash would be looked up as a gid + Folder foreign = new Folder(); + foreign.setId(foreignId); + foreign.setOrganizationId(foreignOrg); + foreign.setCreatedBy("foreign-user"); + foreign.setGid(UUID.randomUUID().toString()); + foreign.setName("foreign-name-" + run); + folderService.create(foreign).block(WAIT); + createdIds.add(foreignId); + assertThat(folderService.findByOrganizationId(foreignOrg).map(Folder::getId).collectList().block(WAIT)).containsExactly(foreignId); + + FolderInfoView result = createViaApi(body(foreignId, null, "taken-over")); + + assertThat(result.getFolderId()).isEqualTo(foreignId); + Folder after = folderService.findById(foreignId).block(WAIT); + System.out.println("[FolderCreateKeepsClientIdPersistenceTest] (b) foreign folder now org " + after.getOrganizationId() + + " creator " + after.getCreatedBy() + " name " + after.getName()); + assertThat(after.getOrganizationId()).isEqualTo(CALLER_ORG); + assertThat(after.getCreatedBy()).isEqualTo("user01"); + assertThat(after.getName()).isEqualTo("taken-over-" + run); + assertThat(folderService.findByOrganizationId(foreignOrg).collectList().block(WAIT)).as("gone from the foreign org").isEmpty(); + } + + /** + * Observed on top of the row, pinned with it: the replacing entity has no gid and the unique index on gid is not + * sparse, so while one gid-less folder exists, any further create with a client id fails with a duplicate key + * error (an unhandled DuplicateKeyException) instead of replacing or being rejected cleanly. + */ + @Test + @WithMockUser + void create_withAClientSuppliedId_whileAGidlessFolderExists_failsWithDuplicateKey_pinsClientIdRow() { + String firstId = createViaApi(body(null, null, "first")).getFolderId(); + String secondId = createViaApi(body(null, null, "second")).getFolderId(); + createViaApi(body(firstId, null, "first-replaced")); + + StepVerifier.create(folderApiService.create(body(secondId, null, "second-replaced"))) + .expectError(DuplicateKeyException.class) + .verify(WAIT); + + Folder second = folderService.findById(secondId).block(WAIT); + System.out.println("[FolderCreateKeepsClientIdPersistenceTest] second client-id create failed with a duplicate key, " + second.getName() + " unchanged"); + assertThat(second.getName()).isEqualTo("second-" + run); + } + + /** (c) id equal to parentFolderId: create stores a folder that is its own parent. Only the stored data is shown. */ + @Test + @WithMockUser + void create_withIdEqualToParentFolderId_storesASelfParentedFolder_pinsClientIdRow() { + FolderInfoView original = createViaApi(body(null, null, "victim-c")); + String id = original.getFolderId(); + + FolderInfoView looped = createViaApi(body(id, id, "loop-c")); + + assertThat(looped.getParentFolderId()).isEqualTo(id); + Folder after = folderService.findById(id).block(WAIT); + System.out.println("[FolderCreateKeepsClientIdPersistenceTest] (c) stored folder " + after.getId() + " parent " + after.getParentFolderId()); + assertThat(after.getParentFolderId()).isEqualTo(after.getId()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderMoveUnknownGidThroughTheStackTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderMoveUnknownGidThroughTheStackTest.java new file mode 100644 index 0000000000..91ff791d30 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderMoveUnknownGidThroughTheStackTest.java @@ -0,0 +1,218 @@ +package org.lowcoder.api.home; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.application.ApplicationApiService; +import org.lowcoder.api.contract.support.ApplicationSamples; +import org.lowcoder.api.contract.support.ContractTestClient; +import org.lowcoder.api.util.BusinessEventPublisher; +import org.lowcoder.api.util.GidService; +import org.lowcoder.domain.folder.model.Folder; +import org.lowcoder.domain.folder.repository.FolderRepository; +import org.lowcoder.domain.folder.service.FolderElementRelationService; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.infra.constant.NewUrl; +import org.lowcoder.sdk.constants.Authentication; +import org.lowcoder.plugin.api.event.LowcoderEvent.EventType; +import org.lowcoder.domain.folder.model.FolderElement; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.lang.reflect.Constructor; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Task L1-13b (lane L5): {@code PUT /folders/move/{id}?targetFolderId=} with a folder gid that no folder has, through the real + * {@link FolderController}, the real {@link GidService} and the real {@link FolderApiServiceImpl}, over the production codecs and + * exception handling of {@link ContractTestClient}; only the repositories and services behind them are mocks. + * + *

    Plan section 9 row "folder move to an unknown gid": {@code GidService.convertFolderIdToObjectId} answers + * {@code Optional.empty()} for an unknown gid ({@code GidService.java:83-88}); the controller turns it into {@code null} + * ({@code FolderController.java:99-102}), which {@code FolderApiServiceImpl.move} reads as the root folder + * ({@code FolderApiServiceImpl.java:192-205}). The application is taken out of its folder, nothing is created, the audit event records + * a move to nothing, and the client gets a success. The pin asserts today's behaviour; the fix (an error with a not-found code) + * makes it fail. The L1-13 converter pin in {@code ApiUtilGidServiceTest} fails with the same fix; that is expected. + * + *

    Limits: the repositories and the permission, session and event services are mocks; what the real queries match and what the + * real events store is not under test. The id sent is a gid because {@code FieldName.isGID} is true for any text with a hyphen. + */ +public class FolderMoveUnknownGidThroughTheStackTest { + + static final String TAG = "[FolderMoveUnknownGidThroughTheStackTest] "; + static final String APPLICATION_ID = "application-object-id"; + static final String FROM_FOLDER_ID = "from-folder-object-id"; + static final String UNKNOWN_FOLDER_GID = "unknown-folder-gid"; + static final String KNOWN_FOLDER_GID = "known-folder-gid"; + static final String KNOWN_FOLDER_OBJECT_ID = "known-folder-object-id"; + /** No hyphen, so {@code FieldName.isGID} is false. */ + static final String PLAIN_OBJECT_ID = "65a1b2c3d4e5f60718293a4b"; + static final String VISITOR_ID = Authentication.ANONYMOUS_USER_ID; + static final String MOVE_URL = NewUrl.FOLDER_URL + "/move/" + APPLICATION_ID; + static final String TARGET_PARAMETER = "targetFolderId"; + static final int SUCCESS_CODE = 1; + static final ObjectMapper JSON = new ObjectMapper(); + + + private ContractTestClient client; + private FolderApiServiceImpl folderApiService; + private FolderElementRelationService relations; + private ResourcePermissionService permissions; + private BusinessEventPublisher events; + private FolderRepository folderRepository; + + @BeforeEach + void wire() { + ContractTestClient.Builder builder = ContractTestClient.builder(); + folderRepository = Mockito.mock(FolderRepository.class); + Mockito.when(folderRepository.findByGid(anyString())).thenReturn(Flux.empty()); + GidService gidService = new GidService(); + ReflectionTestUtils.setField(gidService, "folderRepository", folderRepository); + builder.singleton("gidService", gidService); + + // the real service: a mock for every parameter of its widest constructor, registered with the harness so that the + // controller and the service share them + Constructor widest = Arrays.stream(FolderApiServiceImpl.class.getDeclaredConstructors()) + .max(Comparator.comparingInt(Constructor::getParameterCount)).orElseThrow(); + Object[] collaborators = Arrays.stream(widest.getParameterTypes()).map(builder::mock).toArray(); + try { + folderApiService = (FolderApiServiceImpl) widest.newInstance(collaborators); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("cannot build FolderApiServiceImpl", e); + } + builder.singleton("folderApiService", folderApiService); + + relations = builder.mock(FolderElementRelationService.class); + permissions = builder.mock(ResourcePermissionService.class); + events = builder.mock(BusinessEventPublisher.class); + ApplicationApiService applicationApiService = builder.mock(ApplicationApiService.class); + Mockito.when(permissions.checkResourcePermissionWithError(anyString(), anyString(), any(ResourceAction.class))) + .thenReturn(Mono.empty()); + Mockito.when(relations.getByElementIds(List.of(APPLICATION_ID))) + .thenReturn(Flux.just(new FolderElement(FROM_FOLDER_ID, APPLICATION_ID))); + Mockito.when(relations.deleteByElementId(APPLICATION_ID)).thenReturn(Mono.just(true)); + Mockito.when(relations.create(anyString(), anyString())).thenReturn(Mono.empty()); + Mockito.when(applicationApiService.getEditingApplication(APPLICATION_ID, true)) + .thenReturn(Mono.just(ApplicationSamples.applicationView())); + Mockito.when(events.publishApplicationCommonEvent(any(), any(), any(), any(), any())).thenReturn(Mono.empty()); + + builder.controller(FolderController.class); + // the controller's FolderService is the one mock left that the controller needs and no test stubs + builder.mock(org.lowcoder.domain.folder.service.FolderService.class); + client = builder.build(); + System.out.println(TAG + "wired: real FolderController, GidService and FolderApiServiceImpl; mocks behind them"); + } + + @AfterEach + void close() { + client.close(); + } + + private JsonNode put(String targetFolderId, HttpStatus expectedStatus) { + EntityExchangeResult result = client.web().put() + .uri(uri -> { + uri.path(MOVE_URL); + if (targetFolderId != null) { + uri.queryParam(TARGET_PARAMETER, targetFolderId); + } + return uri.build(); + }) + .exchange().expectStatus().isEqualTo(expectedStatus) + .expectBody().returnResult(); + try { + JsonNode body = JSON.readTree(result.getResponseBody()); + System.out.println(TAG + "PUT " + MOVE_URL + " " + TARGET_PARAMETER + "=" + targetFolderId + " -> " + + result.getStatus() + " " + body); + return body; + } catch (java.io.IOException e) { + throw new IllegalStateException("response is not JSON", e); + } + } + + /** Guards the wiring: the three collaborators the assertions rely on are the objects the test stubs and verifies. */ + @Test + void theConstructorWiringReachedTheCollaboratorsTheTestsRelyOn() { + assertSame(relations, ReflectionTestUtils.getField(folderApiService, "folderElementRelationService")); + assertSame(permissions, ReflectionTestUtils.getField(folderApiService, "resourcePermissionService")); + assertNotNull(ReflectionTestUtils.getField(folderApiService, "sessionUserService")); + assertSame(relations, client.bean(FolderElementRelationService.class)); + assertSame(events, client.bean(BusinessEventPublisher.class)); + assertSame(folderApiService, client.bean(FolderApiService.class)); + assertSame(folderRepository, ReflectionTestUtils.getField(client.bean(GidService.class), "folderRepository")); + System.out.println(TAG + "wiring reached relations, permissions, session service, events, gid service repository"); + } + + /** + * Pins today's behaviour of the plan section 9 row "folder move to an unknown gid": the response is a success, the old relation + * is deleted, no relation is created, and the event records a move from the old folder to {@code null}. A fix that rejects an + * unknown gid makes this test fail. + */ + @Test + void moveToAnUnknownFolderGidLeavesTheApplicationInTheRootFolder_pinsTheSection9Row() { + JsonNode body = put(UNKNOWN_FOLDER_GID, HttpStatus.OK); + + assertEquals(SUCCESS_CODE, body.get("code").asInt()); + verify(folderRepository).findByGid(UNKNOWN_FOLDER_GID); + verify(permissions).checkResourcePermissionWithError(VISITOR_ID, APPLICATION_ID, ResourceAction.MANAGE_APPLICATIONS); + verify(relations).deleteByElementId(APPLICATION_ID); + verify(relations, never()).create(any(), any()); + // the audit event: moved out of the old folder, into nothing, still typed as a move + verify(events).publishApplicationCommonEvent(any(), eq(APPLICATION_ID), eq(FROM_FOLDER_ID), eq(null), + eq(EventType.APPLICATION_MOVE)); + } + + /** Control: a known gid is converted to its object id and the relation is created there. */ + @Test + void moveToAKnownFolderGidCreatesTheRelationInThatFolder() { + Folder folder = new Folder(); + ReflectionTestUtils.setField(folder, "id", KNOWN_FOLDER_OBJECT_ID); + Mockito.when(folderRepository.findByGid(KNOWN_FOLDER_GID)).thenReturn(Flux.just(folder)); + + JsonNode body = put(KNOWN_FOLDER_GID, HttpStatus.OK); + + assertEquals(SUCCESS_CODE, body.get("code").asInt()); + verify(relations).deleteByElementId(APPLICATION_ID); + verify(relations).create(KNOWN_FOLDER_OBJECT_ID, APPLICATION_ID); + verify(events).publishApplicationCommonEvent(any(), eq(APPLICATION_ID), eq(FROM_FOLDER_ID), eq(KNOWN_FOLDER_OBJECT_ID), + eq(EventType.APPLICATION_MOVE)); + } + + /** Control: a target without a hyphen is no gid and is used as sent. */ + @Test + void moveToAnObjectIdUsesItAsSent() { + put(PLAIN_OBJECT_ID, HttpStatus.OK); + + verify(folderRepository, never()).findByGid(any()); + verify(relations).create(PLAIN_OBJECT_ID, APPLICATION_ID); + } + + /** Control: no target at all is the documented way to move to the root; the result is the same as the unknown gid. */ + @Test + void moveWithoutATargetLandsInTheRootFolder() { + JsonNode body = put(null, HttpStatus.OK); + + assertEquals(SUCCESS_CODE, body.get("code").asInt()); + verify(relations).deleteByElementId(APPLICATION_ID); + verify(relations, never()).create(any(), any()); + verify(events).publishApplicationCommonEvent(any(), eq(APPLICATION_ID), eq(FROM_FOLDER_ID), eq(null), + eq(EventType.APPLICATION_MOVE)); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderUnknownGidOperationsProbeTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderUnknownGidOperationsProbeTest.java new file mode 100644 index 0000000000..ff55932047 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/FolderUnknownGidOperationsProbeTest.java @@ -0,0 +1,352 @@ +package org.lowcoder.api.home; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.util.BusinessEventPublisher; +import org.lowcoder.api.util.GidService; +import org.lowcoder.domain.folder.model.Folder; +import org.lowcoder.domain.folder.repository.FolderRepository; +import org.lowcoder.domain.permission.model.ResourceRole; +import reactor.test.publisher.PublisherProbe; +import org.lowcoder.domain.folder.service.FolderElementRelationService; +import org.lowcoder.domain.folder.service.FolderService; +import org.lowcoder.domain.folder.service.FolderServiceImpl; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.infra.constant.NewUrl; +import org.lowcoder.infra.mongo.MongoUpsertHelper; +import org.lowcoder.sdk.constants.Authentication; +import org.lowcoder.sdk.exception.BizError; +import org.mockito.Mockito; +import org.springframework.http.HttpMethod; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.lang.reflect.Constructor; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; + +/** + * Task L1-13b item 3 (lane L5): what the folder endpoints do with a folder gid that no folder has: delete + * ({@code FolderController.java:51-52}), grant ({@code :137-138}), update permission ({@code :115-116}), remove permission + * ({@code :124-125}) and get permissions ({@code :144-145}). The converter answers {@code Optional.empty()} for an unknown gid and each + * of these passes {@code null} on ({@code objectId.orElse(null)}); this class records what {@link FolderApiServiceImpl} does with the + * {@code null}, as an org admin and as an ordinary member. + * + *

    These are observations, not pins of a defect: nothing here is named a plan row. Real: controller, {@code GidService}, + * {@code FolderApiServiceImpl}, {@code FolderServiceImpl} (whose {@code findById(null)} is a parameter error, + * {@code FolderServiceImpl.java:38-41}). Mocks: the repositories, the permission, session and event services. The admin differs + * from the member in {@code checkManagePermission} ({@code FolderApiServiceImpl.java:361-372}): the member goes through + * {@code isCreator(null)}, whose {@code findById(null)} answers the parameter error before any permission lookup runs. + * + *

    Limits: a real Mongo repository is not used, so what Spring Data does with a null id is not exercised; here the null never + * reaches it because {@code FolderServiceImpl.findById} rejects it. The permission and organization services are stubs that answer + * what the test sets. + */ +public class FolderUnknownGidOperationsProbeTest { + + static final String TAG = "[FolderUnknownGidOperationsProbeTest] "; + static final String UNKNOWN_FOLDER_GID = "unknown-folder-gid"; + static final String PERMISSION_ID = "permission-id"; + static final String OTHER_FOLDER_ID = "other-folder-object-id"; + static final String ORG_ID = "org-id"; + static final String FIRST_FOLDER_ID = "65a1b2c3d4e5f60718293a01"; + static final String SECOND_FOLDER_ID = "65a1b2c3d4e5f60718293a02"; + static final String USER_ID = "user-id"; + static final String ROLE_VALUE = "viewer"; + static final String FOLDERS = NewUrl.FOLDER_URL + "/"; + static final ObjectMapper JSON = new ObjectMapper(); + + /** The HTTP status and the {@code code} of the envelope. */ + record Outcome(int status, int code) { + } + + private org.lowcoder.api.contract.support.ContractTestClient client; + private FolderApiServiceImpl folderApiService; + private ResourcePermissionService permissions; + private SessionUserService session; + private OrgMember orgMember; + private FolderRepository folderRepository; + private FolderElementRelationService relations; + private PublisherProbe deleteFoldersProbe; + private PublisherProbe deleteRelationsProbe; + /** {@code updateRoleById} and {@code removeById} are called while the pipeline is assembled, so only a subscription is a write. */ + private PublisherProbe updateRoleProbe; + private PublisherProbe removePermissionProbe; + + @BeforeEach + void wire() { + org.lowcoder.api.contract.support.ContractTestClient.Builder builder = org.lowcoder.api.contract.support.ContractTestClient.builder(); + folderRepository = Mockito.mock(FolderRepository.class); + Mockito.when(folderRepository.findByGid(anyString())).thenReturn(Flux.empty()); + GidService gidService = new GidService(); + ReflectionTestUtils.setField(gidService, "folderRepository", folderRepository); + builder.singleton("gidService", gidService); + FolderService folderService = new FolderServiceImpl(folderRepository, Mockito.mock(MongoUpsertHelper.class)); + builder.singleton("folderService", folderService); + + Constructor widest = Arrays.stream(FolderApiServiceImpl.class.getDeclaredConstructors()) + .max(Comparator.comparingInt(Constructor::getParameterCount)).orElseThrow(); + Object[] collaborators = Arrays.stream(widest.getParameterTypes()) + .map(type -> type == FolderService.class ? folderService : builder.mock(type)).toArray(); + try { + folderApiService = (FolderApiServiceImpl) widest.newInstance(collaborators); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("cannot build FolderApiServiceImpl", e); + } + builder.singleton("folderApiService", folderApiService); + permissions = builder.mock(ResourcePermissionService.class); + session = builder.mock(SessionUserService.class); + builder.mock(BusinessEventPublisher.class); + relations = builder.mock(FolderElementRelationService.class); + orgMember = Mockito.mock(OrgMember.class); + Mockito.when(orgMember.getOrgId()).thenReturn(ORG_ID); + Mockito.when(session.getVisitorOrgMemberCache()).thenReturn(Mono.just(orgMember)); + Mockito.when(permissions.getById(PERMISSION_ID)).thenReturn(Mono.empty()); + updateRoleProbe = PublisherProbe.of(Mono.just(true)); + removePermissionProbe = PublisherProbe.of(Mono.just(true)); + Mockito.when(permissions.updateRoleById(any(), any())).thenReturn(updateRoleProbe.mono()); + Mockito.when(permissions.removeById(any())).thenReturn(removePermissionProbe.mono()); + Mockito.when(permissions.insertBatchPermission(any(), any(), any(), any(), any())).thenReturn(Mono.empty()); + Mockito.when(permissions.getByResourceTypeAndResourceId(any(), any())).thenReturn(Mono.just(List.of())); + + // the organization has two folders, so that a delete that reached them would show + Mockito.when(folderRepository.findByOrganizationId(ORG_ID)).thenReturn(Flux.just(folder(FIRST_FOLDER_ID), folder(SECOND_FOLDER_ID))); + deleteFoldersProbe = PublisherProbe.empty(); + deleteRelationsProbe = PublisherProbe.of(Mono.just(true)); + Mockito.when(folderRepository.deleteAllById(anyCollection())).thenReturn(deleteFoldersProbe.mono()); + Mockito.when(relations.deleteByFolderIds(any())).thenReturn(deleteRelationsProbe.mono()); + + builder.controller(FolderController.class); + builder.mock(org.lowcoder.api.application.ApplicationApiService.class); + client = builder.build(); + System.out.println(TAG + "wired: real controller, GidService, FolderApiServiceImpl and FolderServiceImpl; folder gid " + + UNKNOWN_FOLDER_GID + " is in no repository"); + } + + @AfterEach + void close() { + client.close(); + } + + private static Folder folder(String id) { + Folder folder = new Folder(); + folder.setOrganizationId(ORG_ID); + ReflectionTestUtils.setField(folder, "id", id); + return folder; + } + + private void asRole(boolean admin) { + Mockito.when(orgMember.isAdmin()).thenReturn(admin); + Mockito.when(orgMember.isSuperAdmin()).thenReturn(false); + } + + private void permissionExists() { + ResourcePermission permission = ResourcePermission.builder().id(PERMISSION_ID).resourceId(OTHER_FOLDER_ID).build(); + Mockito.when(permissions.getById(PERMISSION_ID)).thenReturn(Mono.just(permission)); + } + + private Outcome call(String label, HttpMethod method, String path, Object body) { + var spec = client.web().method(method).uri(path); + EntityExchangeResult result = (body == null ? spec.exchange() + : spec.bodyValue(body).exchange()).expectBody().returnResult(); + try { + JsonNode json = JSON.readTree(result.getResponseBody()); + Outcome outcome = new Outcome(result.getStatus().value(), json.get("code").asInt()); + System.out.println(TAG + label + " -> " + outcome + " " + json); + return outcome; + } catch (java.io.IOException e) { + throw new IllegalStateException("response is not JSON", e); + } + } + + private String path(String suffix) { + return FOLDERS + UNKNOWN_FOLDER_GID + suffix; + } + + private String permissionPath() { + return path("/permissions/" + PERMISSION_ID); + } + + private void assertOutcome(Outcome outcome, int status, BizError error) { + assertEquals(new Outcome(status, error.getBizErrorCode()), outcome); + } + + private static final int HTTP_OK = 200; + private static final int HTTP_INTERNAL_SERVER_ERROR = 500; + private static final int SUCCESS_CODE = 1; + + /** Guards the wiring: the stubs the observations depend on are the objects the service and the controller use. */ + @Test + void theWiringReachedTheCollaboratorsTheObservationsRelyOn() { + assertSame(permissions, ReflectionTestUtils.getField(folderApiService, "resourcePermissionService")); + assertSame(relations, ReflectionTestUtils.getField(folderApiService, "folderElementRelationService")); + assertSame(session, ReflectionTestUtils.getField(folderApiService, "sessionUserService")); + assertTrue(ReflectionTestUtils.getField(folderApiService, "folderService") instanceof FolderServiceImpl); + assertSame(folderApiService, client.bean(FolderApiService.class)); + System.out.println(TAG + "wiring reached permissions, relations, session service and the real folder service"); + } + + /** + * Delete, admin: {@code Tree.get(null)} returns the tree itself for a blank id ({@code Tree.java:73-78}), so the lookup at + * {@code FolderApiServiceImpl.java:147} finds a node and does not answer FOLDER_NOT_EXIST. The root has no folder of its own, so + * {@code thenReturn(folderNode.getSelf())} (line 159) is {@code Mono.just(null)}, which throws an NPE while the lambda + * runs; the deletion Monos assembled just before it are never subscribed. The client sees the generic 500. Nothing is deleted. + */ + @Test + void adminDeleteOfAnUnknownGidIsAnInternalServerErrorAndDeletesNothing() { + asRole(true); + + assertOutcome(call("admin delete", HttpMethod.DELETE, path(""), null), HTTP_INTERNAL_SERVER_ERROR, BizError.INTERNAL_SERVER_ERROR); + + deleteFoldersProbe.assertWasNotSubscribed(); + deleteRelationsProbe.assertWasNotSubscribed(); + removePermissionProbe.assertWasNotSubscribed(); + Mockito.verify(folderRepository).findByOrganizationId(ORG_ID); + StepVerifier.create(folderApiService.delete(null)).expectErrorSatisfies(error -> { + System.out.println(TAG + "delete error " + error.getClass().getName() + ": " + error.getMessage() + + (error.getStackTrace().length > 0 ? " at " + error.getStackTrace()[0] : " (no stack trace)")); + assertTrue(error instanceof NullPointerException, "NullPointerException expected, was " + error); + }).verify(); + deleteFoldersProbe.assertWasNotSubscribed(); + } + + /** Delete, member: {@code isCreator(null)} calls {@code findById(null)}, which is a parameter error naming id. */ + @Test + void memberDeleteOfAnUnknownGidIsAParameterError() { + asRole(false); + + assertOutcome(call("member delete", HttpMethod.DELETE, path(""), null), HTTP_INTERNAL_SERVER_ERROR, BizError.INVALID_PARAMETER); + + deleteFoldersProbe.assertWasNotSubscribed(); + } + + /** Grant with users, admin: {@code checkFolderExist(null)} ends in the parameter error; no permission is inserted. */ + @Test + void adminGrantOfAnUnknownGidIsAParameterErrorAndInsertsNothing() { + asRole(true); + + assertOutcome(call("admin grant", HttpMethod.POST, path("/permissions"), grantRequest(Set.of(USER_ID))), + HTTP_INTERNAL_SERVER_ERROR, BizError.INVALID_PARAMETER); + + Mockito.verify(permissions, Mockito.never()).insertBatchPermission(any(), any(), any(), any(), any()); + } + + /** Grant with users, member: the parameter error, from {@code isCreator(null)}. */ + @Test + void memberGrantOfAnUnknownGidIsAParameterError() { + asRole(false); + + assertOutcome(call("member grant", HttpMethod.POST, path("/permissions"), grantRequest(Set.of(USER_ID))), + HTTP_INTERNAL_SERVER_ERROR, BizError.INVALID_PARAMETER); + + Mockito.verify(permissions, Mockito.never()).insertBatchPermission(any(), any(), any(), any(), any()); + } + + /** + * Grant without any user or group, either role: {@code FolderApiServiceImpl.java:380-382} returns before any check, so the + * answer is a success even though the folder does not exist and the member is no creator. Nothing is written. + */ + @Test + void grantWithoutUsersOrGroupsIsASuccessForAdminAndMemberAlike() { + for (boolean admin : new boolean[]{true, false}) { + asRole(admin); + Outcome outcome = call((admin ? "admin" : "member") + " grant none", HttpMethod.POST, path("/permissions"), grantRequest(Set.of())); + assertEquals(new Outcome(HTTP_OK, SUCCESS_CODE), outcome); + } + Mockito.verify(permissions, Mockito.never()).insertBatchPermission(any(), any(), any(), any(), any()); + } + + /** Update and remove permission, admin, permission not found: {@code PERMISSION_NOT_EXIST} (code 6304); the folder is never looked at. */ + @Test + void adminUpdateAndRemoveOfAnAbsentPermissionAreIllegalPermissionIdErrors() { + asRole(true); + + assertOutcome(call("admin update absent", HttpMethod.PUT, permissionPath(), new FolderEndpoints.UpdatePermissionRequest(ROLE_VALUE)), + HTTP_INTERNAL_SERVER_ERROR, BizError.ILLEGAL_FOLDER_PERMISSION_ID); + assertOutcome(call("admin remove absent", HttpMethod.DELETE, permissionPath(), null), + HTTP_INTERNAL_SERVER_ERROR, BizError.ILLEGAL_FOLDER_PERMISSION_ID); + updateRoleProbe.assertWasNotSubscribed(); + removePermissionProbe.assertWasNotSubscribed(); + } + + /** + * Update and remove permission, admin, permission found: {@code checkPermissionResource} evaluates + * {@code folderId.equals(resourcePermission.getResourceId())} with {@code folderId == null} + * ({@code FolderApiServiceImpl.java:409}); the client sees the generic 500 (code 5000). The permission is neither changed nor removed. + * The NPE is checked on the service itself by its type only: a JVM that has run the code hot throws a preallocated NPE without + * message or stack trace (OmitStackTraceInFastThrow), so neither is asserted; the location is shown by the fix mutation + * ({@code Objects.equals}) in evidence/L1-13b.mutations.py, which changes the outcome. + */ + @Test + void adminUpdateAndRemoveOfAFoundPermissionThrowANullPointerExceptionAtTheFolderIdEquals() { + asRole(true); + permissionExists(); + + assertOutcome(call("admin update present", HttpMethod.PUT, permissionPath(), new FolderEndpoints.UpdatePermissionRequest(ROLE_VALUE)), + HTTP_INTERNAL_SERVER_ERROR, BizError.INTERNAL_SERVER_ERROR); + assertOutcome(call("admin remove present", HttpMethod.DELETE, permissionPath(), null), + HTTP_INTERNAL_SERVER_ERROR, BizError.INTERNAL_SERVER_ERROR); + + List> operations = List.of( + folderApiService.updatePermission(null, PERMISSION_ID, ResourceRole.VIEWER), + folderApiService.removePermission(null, PERMISSION_ID)); + for (Mono operation : operations) { + StepVerifier.create(operation).expectErrorSatisfies(error -> { + System.out.println(TAG + "service error " + error.getClass().getName() + ": " + error.getMessage() + + (error.getStackTrace().length > 0 ? " at " + error.getStackTrace()[0] : " (no stack trace)")); + assertTrue(error instanceof NullPointerException, "NullPointerException expected, was " + error); + }).verify(); + } + updateRoleProbe.assertWasNotSubscribed(); + removePermissionProbe.assertWasNotSubscribed(); + } + + /** Update and remove permission, member, either permission state: the parameter error from {@code isCreator(null)}, before the permission lookup. */ + @Test + void memberUpdateAndRemoveAreParameterErrorsWhetherOrNotThePermissionExists() { + asRole(false); + for (boolean exists : new boolean[]{false, true}) { + if (exists) { + permissionExists(); + } + String state = exists ? " present" : " absent"; + assertOutcome(call("member update" + state, HttpMethod.PUT, permissionPath(), new FolderEndpoints.UpdatePermissionRequest(ROLE_VALUE)), + HTTP_INTERNAL_SERVER_ERROR, BizError.INVALID_PARAMETER); + assertOutcome(call("member remove" + state, HttpMethod.DELETE, permissionPath(), null), + HTTP_INTERNAL_SERVER_ERROR, BizError.INVALID_PARAMETER); + } + updateRoleProbe.assertWasNotSubscribed(); + removePermissionProbe.assertWasNotSubscribed(); + } + + /** Get permissions: no manage check; {@code findById(null)} is the parameter error for admin and member alike. */ + @Test + void getPermissionsOfAnUnknownGidIsAParameterErrorForAdminAndMemberAlike() { + for (boolean admin : new boolean[]{true, false}) { + asRole(admin); + assertOutcome(call((admin ? "admin" : "member") + " get", HttpMethod.GET, path("/permissions"), null), + HTTP_INTERNAL_SERVER_ERROR, BizError.INVALID_PARAMETER); + } + } + + private static FolderEndpoints.BatchAddPermissionRequest grantRequest(Set userIds) { + return new FolderEndpoints.BatchAddPermissionRequest(ROLE_VALUE, userIds, Set.of()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/SessionUserServiceImplJwtAndSessionTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/SessionUserServiceImplJwtAndSessionTest.java new file mode 100644 index 0000000000..e9709c6804 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/SessionUserServiceImplJwtAndSessionTest.java @@ -0,0 +1,359 @@ +package org.lowcoder.api.home; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.lowcoder.sdk.constants.GlobalContext.CURRENT_ORG_MEMBER; +import static org.lowcoder.sdk.constants.GlobalContext.VISITOR_TOKEN; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.api.authentication.util.AuthenticationUtils; +import org.lowcoder.api.usermanagement.UserApiService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.user.model.APIKey; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.model.UserState; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveValueOperations; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import io.jsonwebtoken.Claims; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.util.context.Context; + +/** + * Unit tests of {@link SessionUserServiceImpl}: visitor accessors, the org-member cache, session save/extend/remove, + * cookie and JWT (API key) session resolution. The Redis-backed success path of {@code resolveSessionUserFromCookie} + * is pinned separately by {@code SessionCacheContractTest}; this class covers what that one does not. + */ +@ExtendWith(MockitoExtension.class) +class SessionUserServiceImplJwtAndSessionTest { + + private static final String USER_ID = "user-1"; + private static final String ORG_ID = "org-1"; + private static final String TOKEN = "session-token"; + private static final String SOURCE = "GOOGLE"; + private static final String JWT = "jwt-token-value"; + private static final String OTHER_JWT = "another-jwt-value"; + private static final String CLAIM_USER_ID = "userId"; + /** CommonConfig's default cookie max age: 24 hours. */ + private static final Duration SESSION_TTL = Duration.ofHours(24); + private static final String EVENT_SET = "set"; + private static final String EVENT_REMOVE_INVALID = "removeInvalidTokens"; + private static final String EVENT_SAVE_TOKEN = "saveToken"; + private static final String EVENT_DELETE = "delete"; + private static final String EVENT_REMOVE_TOKEN = "removeToken"; + + @Mock private UserService userService; + @Mock private OrgMemberService orgMemberService; + @Mock private UserApiService userApiService; + @Mock private ReactiveRedisTemplate reactiveTemplate; + @Mock private ReactiveValueOperations ops; + @Mock private Claims claims; + + private SessionUserServiceImpl service; + private final List events = new ArrayList<>(); + + @BeforeEach + void setUp() { + service = new SessionUserServiceImpl(); + ReflectionTestUtils.setField(service, "commonConfig", new CommonConfig()); + ReflectionTestUtils.setField(service, "userService", userService); + ReflectionTestUtils.setField(service, "orgMemberService", orgMemberService); + ReflectionTestUtils.setField(service, "userApiService", userApiService); + ReflectionTestUtils.setField(service, "reactiveTemplate", reactiveTemplate); + lenient().when(reactiveTemplate.opsForValue()).thenReturn(ops); + } + + // ---------------------------------------------------------------- helpers + + private static User user(String id) { + return User.builder().id(id).name("name-" + id).build(); + } + + private static OrgMember member() { + return OrgMember.builder().orgId(ORG_ID).userId(USER_ID).role(MemberRole.MEMBER).build(); + } + + private static Context withVisitor(User user) { + return ReactiveSecurityContextHolder.withAuthentication(AuthenticationUtils.toAuthentication(user)); + } + + private static void assertBizError(Throwable throwable, BizError expected) { + assertThat(throwable).isInstanceOf(BizException.class); + assertThat(((BizException) throwable).getError()).isEqualTo(expected); + } + + private static User userWithApiKeys(UserState state, String... tokens) { + User user = user(USER_ID); + user.setState(state); + List keys = new ArrayList<>(); + for (String token : tokens) { + keys.add(new APIKey("key-" + token, "name", "description", token)); + } + user.setApiKeysList(keys); + return user; + } + + // ----------------------------------------------------------- visitor reads + + /** Catches the visitor accessors reading the wrong principal, id or anonymous flag. */ + @Test + void visitorAccessors_readThePrincipalOfTheSecurityContext() { + User visitor = user(USER_ID); + StepVerifier.create(service.getVisitor().contextWrite(withVisitor(visitor))).expectNext(visitor).verifyComplete(); + StepVerifier.create(service.getVisitorId().contextWrite(withVisitor(visitor))).expectNext(USER_ID).verifyComplete(); + StepVerifier.create(service.isAnonymousUser().contextWrite(withVisitor(visitor))).expectNext(false).verifyComplete(); + + User anonymous = user("anonymous"); + anonymous.setIsAnonymous(true); + StepVerifier.create(service.isAnonymousUser().contextWrite(withVisitor(anonymous))).expectNext(true).verifyComplete(); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] visitor, id and anonymous flag read from the security context"); + } + + // ------------------------------------------------------- org member cache + + /** Catches a visitor without an organisation proceeding as if in one. */ + @Test + void getVisitorOrgMemberCache_returnsTheCachedMember_notExistOrEmpty_failUnableToFindValidOrg() { + OrgMember member = member(); + StepVerifier.create(service.getVisitorOrgMemberCache().contextWrite(Context.of(CURRENT_ORG_MEMBER, Mono.just(member)))) + .expectNext(member).verifyComplete(); + + StepVerifier.create(service.getVisitorOrgMemberCache().contextWrite(Context.of(CURRENT_ORG_MEMBER, Mono.just(OrgMember.NOT_EXIST)))) + .expectErrorSatisfies(e -> assertBizError(e, BizError.UNABLE_TO_FIND_VALID_ORG)).verify(); + + StepVerifier.create(service.getVisitorOrgMemberCache().contextWrite(Context.of(CURRENT_ORG_MEMBER, Mono.empty()))) + .expectErrorSatisfies(e -> assertBizError(e, BizError.UNABLE_TO_FIND_VALID_ORG)).verify(); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] cached member returned; NOT_EXIST and empty -> UNABLE_TO_FIND_VALID_ORG"); + } + + /** Catches the silent variant raising an error: it must hand back even {@code NOT_EXIST}. */ + @Test + void getVisitorOrgMemberCacheSilent_returnsEvenNotExist_withoutError() { + StepVerifier.create(service.getVisitorOrgMemberCacheSilent().contextWrite(Context.of(CURRENT_ORG_MEMBER, Mono.just(OrgMember.NOT_EXIST)))) + .expectNext(OrgMember.NOT_EXIST).verifyComplete(); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] silent variant returned NOT_EXIST without error"); + } + + /** Catches the visitor token being read from the wrong context key. */ + @Test + void getVisitorToken_returnsTheVisitorTokenOfTheContext() { + StepVerifier.create(service.getVisitorToken().contextWrite(Context.of(VISITOR_TOKEN, TOKEN))).expectNext(TOKEN).verifyComplete(); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] visitor token " + TOKEN); + } + + /** Catches the org member being looked up for the wrong user, and a missing membership not being an error. */ + @Test + void getVisitorOrgMember_usesTheVisitorsCurrentOrgMember_andFailsWhenThereIsNone() { + OrgMember member = member(); + when(orgMemberService.getCurrentOrgMember(USER_ID)).thenReturn(Mono.just(member)); + StepVerifier.create(service.getVisitorOrgMember().contextWrite(withVisitor(user(USER_ID)))).expectNext(member).verifyComplete(); + + when(orgMemberService.getCurrentOrgMember("lonely")).thenReturn(Mono.empty()); + StepVerifier.create(service.getVisitorOrgMember().contextWrite(withVisitor(user("lonely")))) + .expectErrorSatisfies(e -> assertBizError(e, BizError.UNABLE_TO_FIND_VALID_ORG)).verify(); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] current org member looked up by visitor id; none -> UNABLE_TO_FIND_VALID_ORG"); + } + + // ------------------------------------------------------------ session write + + /** Catches a session without TTL, or a token list not cleaned or saved, or the steps out of order. */ + @Test + void saveUserSession_storesTheTokenWithTtl_thenRemovesInvalidTokens_thenSavesTheToken_inOrder() { + when(ops.set(TOKEN, USER_ID, SESSION_TTL)).thenReturn(Mono.fromSupplier(() -> { + events.add(EVENT_SET); + return true; + })); + when(userApiService.removeInvalidTokens(USER_ID)).thenReturn(Mono.fromRunnable(() -> events.add(EVENT_REMOVE_INVALID))); + when(userApiService.saveToken(USER_ID, SOURCE, TOKEN)).thenReturn(Mono.fromRunnable(() -> events.add(EVENT_SAVE_TOKEN))); + + StepVerifier.create(service.saveUserSession(TOKEN, user(USER_ID), SOURCE)).verifyComplete(); + + assertThat(events).containsExactly(EVENT_SET, EVENT_REMOVE_INVALID, EVENT_SAVE_TOKEN); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] save order " + events); + } + + /** Catches a session being written for a user without id (the token would map to {@code null}). */ + @Test + void saveUserSession_userWithoutId_failsWithNpe_andWritesNothing() { + assertThatThrownBy(() -> service.saveUserSession(TOKEN, User.builder().build(), SOURCE)) + .isInstanceOf(NullPointerException.class); + + verify(ops, never()).set(any(), any(), any(Duration.class)); + verifyNoInteractions(userApiService); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] user without id -> NPE, nothing written"); + } + + /** Catches {@code expire("")}-style calls for a missing token. */ + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void extendValidity_blankToken_isANoOp(String blank) { + StepVerifier.create(service.extendValidity(blank)).verifyComplete(); + + verifyNoInteractions(reactiveTemplate); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] extendValidity('" + blank + "') made no redis call"); + } + + /** Catches the session lifetime not being renewed with the configured max age. */ + @Test + void extendValidity_token_setsTheConfiguredTtl() { + when(reactiveTemplate.expire(TOKEN, SESSION_TTL)).thenReturn(Mono.just(true)); + + StepVerifier.create(service.extendValidity(TOKEN)).verifyComplete(); + + verify(reactiveTemplate).expire(TOKEN, SESSION_TTL); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] expire(" + TOKEN + ", " + SESSION_TTL + ")"); + } + + /** Catches a blank token touching redis. */ + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void removeUserSession_blankToken_isANoOp(String blank) { + StepVerifier.create(service.removeUserSession(blank)).verifyComplete(); + + verifyNoInteractions(reactiveTemplate, userApiService); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] removeUserSession('" + blank + "') made no call"); + } + + /** Catches a logged-out token staying in redis or on the user, or the user id/token being swapped. */ + @Test + void removeUserSession_existingToken_deletesTheKey_thenRemovesTheTokenFromTheUser() { + when(ops.get(TOKEN)).thenReturn(Mono.just(USER_ID)); + when(ops.delete(TOKEN)).thenReturn(Mono.fromSupplier(() -> { + events.add(EVENT_DELETE); + return true; + })); + when(userApiService.removeToken(USER_ID, TOKEN)).thenReturn(Mono.fromRunnable(() -> events.add(EVENT_REMOVE_TOKEN))); + + StepVerifier.create(service.removeUserSession(TOKEN)).verifyComplete(); + + assertThat(events).containsExactly(EVENT_DELETE, EVENT_REMOVE_TOKEN); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] remove order " + events); + } + + /** Catches a removal against a session that does not exist. */ + @Test + void removeUserSession_unknownToken_deletesNothing_andTouchesNoUser() { + when(ops.get(TOKEN)).thenReturn(Mono.empty()); + + StepVerifier.create(service.removeUserSession(TOKEN)).verifyComplete(); + + verify(ops, never()).delete(anyString()); + verifyNoInteractions(userApiService); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] unknown token -> nothing deleted"); + } + + // ------------------------------------------------------------ cookie resolve + + /** Catches a blank cookie reaching redis. */ + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void resolveSessionUserFromCookie_blankToken_isEmpty_withoutRedis(String blank) { + StepVerifier.create(service.resolveSessionUserFromCookie(blank)).verifyComplete(); + + verifyNoInteractions(reactiveTemplate, userService); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] blank cookie '" + blank + "' -> empty, no redis"); + } + + private static Stream storedSessionValues() { + return Stream.of( + // stored value, id that must be looked up, state of the found user (null = none), user exists, resolved + Arguments.of("plain-id", "plain-id", UserState.ACTIVATED, true, true), + Arguments.of("{\"id\":\"legacy-id\",\"name\":\"Legacy\"}", "legacy-id", UserState.ACTIVATED, true, true), + Arguments.of("plain-id", "plain-id", UserState.DELETED, true, false), + Arguments.of("plain-id", "plain-id", null, true, true), + Arguments.of("unknown-id", "unknown-id", null, false, false)); + } + + /** + * A session value is either a plain user id or (legacy) a user JSON whose id is looked up; a DELETED user never + * resolves; an unknown id or a user without state behave as stated. + */ + @ParameterizedTest(name = "stored={0} lookup={1} state={2} exists={3} -> resolved={4}") + @MethodSource("storedSessionValues") + void resolveSessionUserFromCookie_looksUpTheRightId_andFiltersDeletedUsers( + String stored, String lookupId, UserState state, boolean exists, boolean resolved) { + when(ops.get(TOKEN)).thenReturn(Mono.just(stored)); + User found = user(lookupId); + found.setState(state); + when(userService.findById(lookupId)).thenReturn(exists ? Mono.just(found) : Mono.empty()); + + if (resolved) { + StepVerifier.create(service.resolveSessionUserFromCookie(TOKEN)).expectNext(found).verifyComplete(); + } else { + StepVerifier.create(service.resolveSessionUserFromCookie(TOKEN)).verifyComplete(); + } + + verify(userService).findById(lookupId); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] stored=" + stored + " looked up " + lookupId + " resolved=" + resolved); + } + + // --------------------------------------------------------------------- JWT + + /** Catches a valid API key not authenticating its owner. */ + @Test + void resolveSessionUserForJWT_tokenOfAnApiKeyOfTheUser_resolvesTheUser() { + User owner = userWithApiKeys(UserState.ACTIVATED, OTHER_JWT, JWT); + when(claims.get(CLAIM_USER_ID)).thenReturn(USER_ID); + when(userService.findById(USER_ID)).thenReturn(Mono.just(owner)); + + StepVerifier.create(service.resolveSessionUserForJWT(claims, JWT)).expectNext(owner).verifyComplete(); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] token matched one of " + owner.getApiKeysList().size() + " API keys"); + } + + /** Catches a revoked (or foreign) API key still authenticating: a token not among the user's keys is rejected. */ + @ParameterizedTest(name = "stored keys={0}") + @ValueSource(strings = {"", "another-jwt-value"}) + void resolveSessionUserForJWT_tokenNotAmongTheUsersApiKeys_isRejected(String storedKey) { + User owner = storedKey.isEmpty() ? userWithApiKeys(UserState.ACTIVATED) : userWithApiKeys(UserState.ACTIVATED, storedKey); + when(claims.get(CLAIM_USER_ID)).thenReturn(USER_ID); + when(userService.findById(USER_ID)).thenReturn(Mono.just(owner)); + + StepVerifier.create(service.resolveSessionUserForJWT(claims, JWT)).verifyComplete(); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] token not among keys '" + storedKey + "' -> rejected"); + } + + /** Catches a deleted user, or an unknown user, authenticating by API key. */ + @Test + void resolveSessionUserForJWT_deletedOrUnknownUser_isRejected() { + when(claims.get(CLAIM_USER_ID)).thenReturn(USER_ID, "ghost"); + when(userService.findById(USER_ID)).thenReturn(Mono.just(userWithApiKeys(UserState.DELETED, JWT))); + when(userService.findById("ghost")).thenReturn(Mono.empty()); + + StepVerifier.create(service.resolveSessionUserForJWT(claims, JWT)).verifyComplete(); + StepVerifier.create(service.resolveSessionUserForJWT(claims, JWT)).verifyComplete(); + System.out.println("[SessionUserServiceImplJwtAndSessionTest] deleted and unknown users rejected"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/UserHomeApiServiceImplTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/UserHomeApiServiceImplTest.java new file mode 100644 index 0000000000..8590c2d172 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/home/UserHomeApiServiceImplTest.java @@ -0,0 +1,681 @@ +package org.lowcoder.api.home; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.api.application.view.ApplicationInfoView; +import org.lowcoder.api.application.view.MarketplaceApplicationInfoView; +import org.lowcoder.api.bundle.view.BundleInfoView; +import org.lowcoder.api.bundle.view.MarketplaceBundleInfoView; +import org.lowcoder.api.usermanagement.OrgDevChecker; +import org.lowcoder.api.usermanagement.view.UserProfileView; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationStatus; +import org.lowcoder.domain.application.model.ApplicationType; +import org.lowcoder.domain.application.model.ApplicationVersion; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.bundle.model.Bundle; +import org.lowcoder.domain.bundle.model.BundleElement; +import org.lowcoder.domain.bundle.model.BundleStatus; +import org.lowcoder.domain.bundle.service.BundleElementRelationServiceImpl; +import org.lowcoder.domain.bundle.service.BundleService; +import org.lowcoder.domain.folder.model.FolderElement; +import org.lowcoder.domain.folder.service.FolderElementRelationService; +import org.lowcoder.domain.interaction.UserApplicationInteraction; +import org.lowcoder.domain.interaction.UserApplicationInteractionService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.model.UserStatus; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.domain.user.service.UserStatusService; +import org.lowcoder.sdk.config.CommonConfig; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Mockito unit tests of {@link UserHomeApiServiceImpl}: profile, home page, the authorised application and bundle + * listings and the four marketplace / agency listings. Every collaborator is a mock; applications and bundles are real + * model objects whose live DSL comes from a mocked {@link ApplicationRecordService}. + * + *

    Pinned under D-6, plan §9 rows: "marketplace and agency listings fail" (ClassCastException on a non-String DSL + * title, NullPointerException on a missing organization; getAllMarketplaceApplications falls back to blank) and + * "buildUserProfileView never sets hasShownNewUserGuidance or isEnabled". The tests carrying them are named + * {@code *_pinsTheSection9Row}. + */ +class UserHomeApiServiceImplTest { + + private static final String ORG = "org-1"; + private static final String VISITOR = "visitor-1"; + private static final Instant CREATED = Instant.parse("2024-02-03T04:05:06Z"); + private static final Duration WAIT = Duration.ofSeconds(10); + + private SessionUserService sessionUserService; + private OrganizationService organizationService; + private OrgMemberService orgMemberService; + private ApplicationService applicationService; + private ResourcePermissionService permissionService; + private UserService userService; + private UserStatusService userStatusService; + private OrgDevChecker orgDevChecker; + private FolderApiService folderApiService; + private UserApplicationInteractionService interactionService; + private CommonConfig config; + private BundleElementRelationServiceImpl bundleElementRelationService; + private BundleService bundleService; + private ApplicationRecordService recordService; + private FolderElementRelationService folderElementRelationService; + private UserHomeApiServiceImpl service; + + @BeforeEach + void setUp() { + sessionUserService = mock(SessionUserService.class); + organizationService = mock(OrganizationService.class); + orgMemberService = mock(OrgMemberService.class); + applicationService = mock(ApplicationService.class); + permissionService = mock(ResourcePermissionService.class); + userService = mock(UserService.class); + userStatusService = mock(UserStatusService.class); + orgDevChecker = mock(OrgDevChecker.class); + folderApiService = mock(FolderApiService.class); + interactionService = mock(UserApplicationInteractionService.class); + config = new CommonConfig(); + bundleElementRelationService = mock(BundleElementRelationServiceImpl.class); + bundleService = mock(BundleService.class); + recordService = mock(ApplicationRecordService.class); + folderElementRelationService = mock(FolderElementRelationService.class); + service = new UserHomeApiServiceImpl(sessionUserService, organizationService, orgMemberService, applicationService, + permissionService, userService, userStatusService, orgDevChecker, folderApiService, interactionService, config, + bundleElementRelationService, bundleService, recordService, folderElementRelationService); + + OrgMember member = OrgMember.builder().orgId(ORG).userId(VISITOR).role(MemberRole.MEMBER).build(); + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(member)); + when(recordService.getLatestRecordByApplicationId(anyString())).thenReturn(Mono.empty()); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(Map.of())); + when(organizationService.getByIds(anyCollection())).thenReturn(Flux.empty()); + when(bundleElementRelationService.getByElementIds(anyList())).thenReturn(Flux.empty()); + when(folderElementRelationService.getByElementIds(anyList())).thenReturn(Flux.empty()); + when(interactionService.findByUserId(anyString())).thenReturn(Flux.empty()); + when(orgDevChecker.isCurrentOrgDev()).thenReturn(Mono.just(false)); + } + + // ----------------------------------------------------------------- fixtures + + private static ServerWebExchange exchange() { + return MockServerWebExchange.from(MockServerHttpRequest.get("/").header("X-Forwarded-For", "203.0.113.7").build()); + } + + private static User user(String id, String name, String password) { + User user = new User(); + user.setId(id); + user.setName(name); + user.setPassword(password); + user.setCreatedAt(CREATED); + return user; + } + + private static Organization org(String id, String name, Map settings) { + Organization organization = new Organization(); + organization.setId(id); + organization.setName(name); + if (settings != null) { + Organization.OrganizationCommonSettings commonSettings = new Organization.OrganizationCommonSettings(); + commonSettings.putAll(settings); + organization.setCommonSettings(commonSettings); + } + return organization; + } + + private static Application app(String id, String name, ApplicationType type, ApplicationStatus status, String category, String creator, String orgId) { + Map dsl = new HashMap<>(); + if (category != null) { + dsl.put("settings", new HashMap<>(Map.of("category", category))); + } + return Application.builder().id(id).name(name).applicationType(type.getValue()).applicationStatus(status) + .organizationId(orgId).createdBy(creator).createdAt(CREATED).editingApplicationDSL(dsl).build(); + } + + private static Bundle bundle(String id, String name, BundleStatus status, String creator, String orgId) { + return Bundle.builder().id(id).gid("gid-" + id).name(name).bundleStatus(status).organizationId(orgId) + .createdBy(creator).createdAt(CREATED).build(); + } + + private static ResourcePermission permission(ResourceRole role) { + return ResourcePermission.builder().resourceRole(role).build(); + } + + private static List collect(Flux flux) { + List list = flux.collectList().block(WAIT); + assertThat(list).isNotNull(); + return list; + } + + // ------------------------------------------------------------- user profile + + /** Catches the anonymous visitor path reaching into user status or organization data. */ + @Test + void buildUserProfileView_anonymous_returnsOnlyNameAndIp() { + User anonymous = new User(); + anonymous.setName("Anonymous"); + anonymous.setIsAnonymous(true); + + StepVerifier.create(service.buildUserProfileView(anonymous, exchange())).assertNext(view -> { + assertThat(view.isAnonymous()).isTrue(); + assertThat(view.getUsername()).isEqualTo("Anonymous"); + assertThat(view.getIp()).isEqualTo("203.0.113.7"); + assertThat(view.getOrgAndRoles()).isNull(); + assertThat(view.getId()).isNull(); + System.out.println("[UserHomeApiServiceImplTest] anonymous profile ip " + view.getIp()); + }).verifyComplete(); + + verifyNoInteractions(userStatusService, orgMemberService, organizationService); + } + + private void stubProfile(User user, List members, OrgMember current, List orgs, boolean dev) { + when(userStatusService.findByUserId(user.getId())).thenReturn(Mono.just(UserStatus.builder().statusMap(Map.of("flag", true)).build())); + when(orgMemberService.getUserOrgMemberInfo(user.getId())).thenReturn(Mono.just(new OrgMemberService.UserOrgMemberInfo(current, members))); + when(organizationService.getByIds(anyCollection())).thenReturn(Flux.fromIterable(orgs)); + when(orgDevChecker.isCurrentOrgDev()).thenReturn(Mono.just(dev)); + } + + /** Catches profile fields being dropped or mixed up; hasPassword depends on a non-blank password. */ + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "secret"}) + void buildUserProfileView_mapsTheUserAndTheOrganizations(String password) { + User user = user("u1", "Ada", password); + user.setUiLanguage("de"); + user.setAvatar("avatar-file"); + user.setHasSetNickname(true); + OrgMember current = OrgMember.builder().orgId("o1").userId("u1").role(MemberRole.ADMIN).build(); + OrgMember other = OrgMember.builder().orgId("o2").userId("u1").role(MemberRole.MEMBER).build(); + stubProfile(user, List.of(current, other), current, List.of(org("o1", "One", null), org("o2", "Two", null)), true); + + StepVerifier.create(service.buildUserProfileView(user, exchange())).assertNext(view -> { + assertThat(view.getId()).isEqualTo("u1"); + assertThat(view.getUsername()).isEqualTo("Ada"); + assertThat(view.isAnonymous()).isFalse(); + assertThat(view.getUiLanguage()).isEqualTo("de"); + assertThat(view.getAvatar()).isEqualTo("avatar-file"); + assertThat(view.isHasSetNickname()).isTrue(); + assertThat(view.getCurrentOrgId()).isEqualTo("o1"); + assertThat(view.isOrgDev()).isTrue(); + assertThat(view.getCreatedTimeMs()).isEqualTo(CREATED.toEpochMilli()); + assertThat(view.getIp()).isEqualTo("203.0.113.7"); + assertThat(view.getUserStatus()).containsEntry("flag", true); + assertThat(view.isHasPassword()).isEqualTo("secret".equals(password)); + assertThat(view.getOrgAndRoles()).extracting(r -> r.getOrg().getId(), r -> r.getRole()) + .containsExactly(org.assertj.core.groups.Tuple.tuple("o1", "admin"), org.assertj.core.groups.Tuple.tuple("o2", "member")); + System.out.println("[UserHomeApiServiceImplTest] profile of " + view.getUsername() + " hasPassword " + view.isHasPassword()); + }).verifyComplete(); + } + + /** + * Pins plan §9 row "buildUserProfileView never sets hasShownNewUserGuidance or isEnabled": both profile fields are + * never set by the builder call, so they are always false, whatever the user status says. + */ + @Test + void buildUserProfileView_neverFillsHasShownNewUserGuidanceOrIsEnabled_pinsTheSection9Row() { + User user = user("u1", "Ada", "x"); + OrgMember current = OrgMember.builder().orgId("o1").userId("u1").role(MemberRole.MEMBER).build(); + stubProfile(user, List.of(current), current, List.of(org("o1", "One", null)), false); + when(userStatusService.findByUserId("u1")).thenReturn(Mono.just(UserStatus.builder().hasShowNewUserGuidance(true).build())); + + UserProfileView view = service.buildUserProfileView(user, exchange()).block(WAIT); + + assertThat(view.getUserStatus()).containsValue(true); + assertThat(view.isHasShownNewUserGuidance()).isFalse(); + assertThat(view.isEnabled()).isFalse(); + } + + /** + * Catches an organization secret leaving in the profile: the password-reset e-mail template is removed from the + * common settings of every organization, other keys stay. Also catches an organization row that no longer exists + * being listed. Observation: the sanitised settings are set on the instance the organization service returned. + */ + @Test + void buildUserProfileView_sanitisesOrgSettings_andSkipsOrgsWhoseRowIsMissing() { + User user = user("u1", "Ada", "x"); + OrgMember current = OrgMember.builder().orgId("o1").userId("u1").role(MemberRole.MEMBER).build(); + OrgMember gone = OrgMember.builder().orgId("o-gone").userId("u1").role(MemberRole.MEMBER).build(); + Organization withSecret = org("o1", "One", Map.of(Organization.OrganizationCommonSettings.PASSWORD_RESET_EMAIL_TEMPLATE, "secret template", "theme", "dark")); + stubProfile(user, List.of(current, gone), current, List.of(withSecret), false); + + UserProfileView view = service.buildUserProfileView(user, exchange()).block(WAIT); + + assertThat(view.getOrgAndRoles()).hasSize(1); + Organization shown = view.getOrgAndRoles().get(0).getOrg(); + assertThat(shown.getCommonSettings()).containsEntry("theme", "dark") + .doesNotContainKey(Organization.OrganizationCommonSettings.PASSWORD_RESET_EMAIL_TEMPLATE); + assertThat(shown).as("the instance from the service is changed in place").isSameAs(withSecret); + System.out.println("[UserHomeApiServiceImplTest] shown org settings " + shown.getCommonSettings()); + } + + @Test + void markNewUserGuidanceShown_delegatesAndReturnsTheResult() { + when(userStatusService.markNewUserGuidanceShown("u1")).thenReturn(Mono.just(true)); + when(userStatusService.markNewUserGuidanceShown("u2")).thenReturn(Mono.just(false)); + + StepVerifier.create(service.markNewUserGuidanceShown("u1")).expectNext(true).verifyComplete(); + StepVerifier.create(service.markNewUserGuidanceShown("u2")).expectNext(false).verifyComplete(); + } + + // ------------------------------------------------------------ home page + + @Test + void getUserHomePageView_withoutACurrentOrg_returnsOnlyTheUser() { + User visitor = user(VISITOR, "Ada", null); + when(sessionUserService.getVisitor()).thenReturn(Mono.just(visitor)); + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(OrgMember.builder().orgId("").userId(VISITOR).role(MemberRole.MEMBER).build())); + + UserHomepageView view = service.getUserHomePageView(ApplicationType.APPLICATION).block(WAIT); + + assertThat(view.getUser()).isSameAs(visitor); + assertThat(view.getOrganization()).isNull(); + verifyNoInteractions(organizationService, folderApiService); + } + + @Test + void getUserHomePageView_withAnOrg_addsTheOrganization_andListsTheRootElements() { + User visitor = user(VISITOR, "Ada", null); + Organization organization = org(ORG, "Acme", null); + when(sessionUserService.getVisitor()).thenReturn(Mono.just(visitor)); + when(organizationService.getById(ORG)).thenReturn(Mono.just(organization)); + when(folderApiService.getElements(null, ApplicationType.MODULE, null, null)).thenReturn(Flux.empty()); + + UserHomepageView view = service.getUserHomePageView(ApplicationType.MODULE).block(WAIT); + + assertThat(view.getUser()).isSameAs(visitor); + assertThat(view.getOrganization()).isSameAs(organization); + verify(folderApiService).getElements(null, ApplicationType.MODULE, null, null); + } + + /** Observed, pinned (no §9 row): a current org whose row is gone leaves the whole home page Mono empty. */ + @Test + void getUserHomePageView_whenTheOrganizationRowIsMissing_completesEmpty() { + when(sessionUserService.getVisitor()).thenReturn(Mono.just(user(VISITOR, "Ada", null))); + when(organizationService.getById(ORG)).thenReturn(Mono.empty()); + when(folderApiService.getElements(null, null, null, null)).thenReturn(Flux.empty()); + + StepVerifier.create(service.getUserHomePageView(null)).verifyComplete(); + System.out.println("[UserHomeApiServiceImplTest] home page with a missing organization completes empty"); + } + + // ------------------------------------------- authorised applications listing + + private static final Application SALES = app("a-sales", "Sales Dashboard", ApplicationType.APPLICATION, ApplicationStatus.NORMAL, "sales", "u-creator", ORG); + private static final Application HR = app("a-hr", "HR Portal", ApplicationType.APPLICATION, ApplicationStatus.NORMAL, "hr", "u-creator", ORG); + private static final Application MODULE_APP = app("a-module", "Sales Module", ApplicationType.MODULE, ApplicationStatus.NORMAL, "sales", "u-creator", ORG); + private static final Application OLD = app("a-old", "Old sales", ApplicationType.APPLICATION, ApplicationStatus.RECYCLED, "sales", "u-creator", ORG); + private static final Application HIDDEN = app("a-hidden", "Hidden sales", ApplicationType.APPLICATION, ApplicationStatus.NORMAL, "sales", "u-creator", ORG); + + private void stubApplications(Application... apps) { + when(applicationService.findByOrganizationIdWithoutDsl(ORG)).thenReturn(Flux.just(apps)); + when(applicationService.findByOrganizationIdWithDsl(ORG)).thenReturn(Flux.just(apps)); + Map permitted = new HashMap<>(); + for (Application application : apps) { + if (!application.getId().equals("a-hidden")) { + permitted.put(application.getId(), permission(ResourceRole.EDITOR)); + } + } + when(permissionService.getMaxMatchingPermission(eq(VISITOR), anyCollection(), eq(ResourceAction.READ_APPLICATIONS))).thenReturn(Mono.just(permitted)); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(Map.of("u-creator", user("u-creator", "Creator Name", null)))); + } + + static Stream applicationFilters() { + return Stream.of( + Arguments.of(null, null, null, null, List.of("a-sales", "a-hr", "a-module", "a-old")), + Arguments.of(ApplicationType.ALL, null, null, null, List.of("a-sales", "a-hr", "a-module", "a-old")), + Arguments.of(ApplicationType.APPLICATION, null, null, null, List.of("a-sales", "a-hr", "a-old")), + Arguments.of(ApplicationType.MODULE, null, null, null, List.of("a-module")), + Arguments.of(null, ApplicationStatus.NORMAL, null, null, List.of("a-sales", "a-hr", "a-module")), + Arguments.of(null, ApplicationStatus.RECYCLED, null, null, List.of("a-old")), + Arguments.of(null, null, "SALES", null, List.of("a-sales", "a-module", "a-old")), + Arguments.of(null, null, null, "Hr", List.of("a-hr")), + Arguments.of(ApplicationType.APPLICATION, ApplicationStatus.NORMAL, "sales", "sales", List.of("a-sales"))); + } + + /** + * Catches a filter being ignored or inverted (type incl. ALL, status, name and category, case-insensitive) and an + * application without READ permission appearing (the hidden one is never listed). + */ + @ParameterizedTest + @MethodSource("applicationFilters") + void getAllAuthorisedApplications_appliesTheFilters_andHidesApplicationsWithoutPermission(ApplicationType type, ApplicationStatus status, + String name, String category, List expected) { + stubApplications(SALES, HR, MODULE_APP, OLD, HIDDEN); + + List views = collect(service.getAllAuthorisedApplications4CurrentOrgMember(type, status, false, name, category)); + + System.out.println("[UserHomeApiServiceImplTest] filters " + type + "/" + status + "/" + name + "/" + category + " -> " + + views.stream().map(ApplicationInfoView::getApplicationId).toList()); + assertThat(views).extracting(ApplicationInfoView::getApplicationId).containsExactlyInAnyOrderElementsOf(expected); + } + + /** Catches the view fields: role, creator name (blank for an unknown creator), last view time, folder, DSL settings. */ + @Test + void getAllAuthorisedApplications_buildsTheViewFields() { + Application known = Application.builder().id("a1").name("n1").applicationType(ApplicationType.APPLICATION.getValue()) + .applicationStatus(ApplicationStatus.NORMAL).organizationId(ORG).createdBy("u-creator").createdAt(CREATED) + .editingApplicationDSL(new HashMap<>(Map.of("settings", new HashMap<>(Map.of("title", "T", "description", "D", "icon", "I", "category", "C"))))) + .publicToAll(true).build(); + Application unknownCreator = app("a2", "n2", ApplicationType.APPLICATION, ApplicationStatus.NORMAL, null, "u-stranger", ORG); + stubApplications(known, unknownCreator); + when(permissionService.getMaxMatchingPermission(eq(VISITOR), anyCollection(), eq(ResourceAction.READ_APPLICATIONS))) + .thenReturn(Mono.just(Map.of("a1", permission(ResourceRole.OWNER), "a2", permission(ResourceRole.VIEWER)))); + when(interactionService.findByUserId(VISITOR)).thenReturn(Flux.just(new UserApplicationInteraction(VISITOR, "a1", Instant.ofEpochMilli(5000)))); + when(folderElementRelationService.getByElementIds(List.of("a1"))).thenReturn(Flux.just(new FolderElement("f-1", "a1"))); + when(bundleElementRelationService.getByElementIds(List.of("a1"))).thenReturn(Flux.just(new BundleElement("b1", "a1", 7L))); + when(recordService.getLatestRecordByApplicationId("a1")).thenReturn(Mono.just(ApplicationVersion.builder().tag("1.0.0").createdAt(CREATED) + .applicationDSL(Map.of("settings", Map.of("title", "T", "description", "D", "icon", "I", "category", "C"))).build())); + + List views = collect(service.getAllAuthorisedApplications4CurrentOrgMember(null, null, false, null, null)); + + ApplicationInfoView first = views.stream().filter(v -> v.getApplicationId().equals("a1")).findFirst().orElseThrow(); + ApplicationInfoView second = views.stream().filter(v -> v.getApplicationId().equals("a2")).findFirst().orElseThrow(); + assertThat(first.getRole()).isEqualTo(ResourceRole.OWNER.getValue()); + assertThat(first.getCreateBy()).isEqualTo("Creator Name"); + assertThat(first.getLastViewTime()).isEqualTo(5000L); + assertThat(first.getFolderId()).isEqualTo("f-1"); + assertThat(first.getTitle()).isEqualTo("T"); + assertThat(first.getDescription()).isEqualTo("D"); + assertThat(first.getIcon()).isEqualTo("I"); + assertThat(first.getCategory()).isEqualTo("C"); + assertThat(first.isPublished()).isTrue(); + assertThat(first.isPublicToAll()).isTrue(); + assertThat(first.getCreateAt()).isEqualTo(CREATED.toEpochMilli()); + assertThat(second.getRole()).isEqualTo(ResourceRole.VIEWER.getValue()); + assertThat(second.getCreateBy()).as("unknown creator").isEmpty(); + assertThat(second.getLastViewTime()).isZero(); + assertThat(second.getFolderId()).isNull(); + assertThat(second.isPublished()).isFalse(); + } + + /** Catches the DSL-less query being used when the container size is requested, and the reverse. */ + @Test + void getAllAuthorisedApplications_withContainerSize_readsTheApplicationsWithTheirDsl() { + stubApplications(MODULE_APP); + + collect(service.getAllAuthorisedApplications4CurrentOrgMember(null, null, true, null, null)); + verify(applicationService).findByOrganizationIdWithDsl(ORG); + verify(applicationService, never()).findByOrganizationIdWithoutDsl(ORG); + } + + // ---------------------------------------------------- authorised bundles + + private static Object field(Object target, String name) { + return ReflectionTestUtils.getField(target, name); + } + + @Test + void getAllAuthorisedBundles_filtersByStatus_andByPermission() { + Bundle normal = bundle("b-normal", "Normal", BundleStatus.NORMAL, "u-creator", ORG); + Bundle recycled = bundle("b-recycled", "Recycled", BundleStatus.RECYCLED, "u-creator", ORG); + Bundle hidden = bundle("b-hidden", "Hidden", BundleStatus.NORMAL, "u-creator", ORG); + when(bundleService.findByUserId(VISITOR)).thenReturn(Flux.just(normal, recycled, hidden)); + when(permissionService.getMaxMatchingPermission(eq(VISITOR), anyCollection(), eq(ResourceAction.READ_BUNDLES))) + .thenReturn(Mono.just(Map.of("b-normal", permission(ResourceRole.VIEWER), "b-recycled", permission(ResourceRole.VIEWER)))); + + List all = collect(service.getAllAuthorisedBundles4CurrentOrgMember(null)); + List onlyRecycled = collect(service.getAllAuthorisedBundles4CurrentOrgMember(BundleStatus.RECYCLED)); + + assertThat(all).extracting(v -> field(v, "bundleId")).containsExactlyInAnyOrder("b-normal", "b-recycled"); + assertThat(onlyRecycled).extracting(v -> field(v, "bundleId")).containsExactly("b-recycled"); + assertThat(field(all.get(0), "bundleGid")).isEqualTo("gid-" + field(all.get(0), "bundleId")); + assertThat(field(all.get(0), "createBy")).isEqualTo("u-creator"); + assertThat(all.get(0).getCreateTime()).isEqualTo(CREATED.toEpochMilli()); + } + + // ---------------------------------------------------- marketplace listings + + private void signedIn(boolean anonymous, boolean privateMode) { + when(sessionUserService.isAnonymousUser()).thenReturn(Mono.just(anonymous)); + config.getMarketplace().setPrivateMode(privateMode); + } + + private Application marketplaceApp(String id, String name, ApplicationType type, String creator, String orgId, Instant createdAt) { + return Application.builder().id(id).name(name).applicationType(type.getValue()).applicationStatus(ApplicationStatus.NORMAL) + .organizationId(orgId).createdBy(creator).createdAt(createdAt).build(); + } + + private void stubDsl(String appId, Map dsl) { + when(recordService.getLatestRecordByApplicationId(appId)).thenReturn(Mono.just(ApplicationVersion.builder().applicationDSL(dsl).build())); + } + + /** Catches the private-mode rule: anonymous visitors see nothing, and the marketplace is not even queried. */ + @Test + void getAllMarketplaceApplications_privateModeHidesTheListingFromAnonymousVisitors() { + when(applicationService.findAllMarketplaceApps()).thenReturn(Flux.just(marketplaceApp("m1", "n", ApplicationType.APPLICATION, "u1", ORG, CREATED))); + when(organizationService.getByIds(anyCollection())).thenReturn(Flux.just(org(ORG, "Acme", null))); + + signedIn(true, true); + assertThat(collect(service.getAllMarketplaceApplications(null))).isEmpty(); + verify(applicationService, never()).findAllMarketplaceApps(); + + signedIn(false, true); + assertThat(collect(service.getAllMarketplaceApplications(null))).hasSize(1); + + signedIn(true, false); + assertThat(collect(service.getAllMarketplaceApplications(null))).hasSize(1); + } + + /** Catches the type filter, the org and creator fallbacks (blank), and createAt falling back to 0. */ + @Test + void getAllMarketplaceApplications_filtersByType_andFallsBackForMissingOrgCreatorAndDate() { + signedIn(false, true); + Application full = marketplaceApp("m-full", "Full", ApplicationType.APPLICATION, "u1", ORG, CREATED); + Application bare = marketplaceApp("m-bare", "Bare", ApplicationType.APPLICATION, "u-gone", "org-gone", null); + Application module = marketplaceApp("m-module", "Module", ApplicationType.MODULE, "u1", ORG, CREATED); + when(applicationService.findAllMarketplaceApps()).thenReturn(Flux.just(full, bare, module)); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(Map.of("u1", user("u1", "creator@example.com", null)))); + when(organizationService.getByIds(anyCollection())).thenReturn(Flux.just(org(ORG, "Acme", null))); + + List all = collect(service.getAllMarketplaceApplications(null)); + List modules = collect(service.getAllMarketplaceApplications(ApplicationType.MODULE)); + + MarketplaceApplicationInfoView fullView = all.stream().filter(v -> v.getApplicationId().equals("m-full")).findFirst().orElseThrow(); + MarketplaceApplicationInfoView bareView = all.stream().filter(v -> v.getApplicationId().equals("m-bare")).findFirst().orElseThrow(); + assertThat(all).hasSize(3); + assertThat(modules).extracting(MarketplaceApplicationInfoView::getApplicationId).containsExactly("m-module"); + assertThat(fullView.getOrgName()).isEqualTo("Acme"); + assertThat(fullView.getCreatorEmail()).isEqualTo("creator@example.com"); + assertThat(fullView.getCreateAt()).isEqualTo(CREATED.toEpochMilli()); + assertThat(bareView.getOrgName()).as("missing organization falls back").isEmpty(); + assertThat(bareView.getCreatorEmail()).as("missing creator falls back").isEmpty(); + assertThat(bareView.getCreateAt()).isZero(); + System.out.println("[UserHomeApiServiceImplTest] marketplace fallbacks: org '" + bareView.getOrgName() + "', creator '" + bareView.getCreatorEmail() + "'"); + } + + /** Catches the marketplace fields: they come from the published DSL settings, with defaults when it has none. */ + @Test + void getAllMarketplaceApplications_readsTitleCategoryDescriptionAndIconFromThePublishedSettings() { + signedIn(false, true); + Application withSettings = marketplaceApp("m-set", "App name", ApplicationType.APPLICATION, "u1", ORG, CREATED); + Application noDsl = marketplaceApp("m-nodsl", "No dsl", ApplicationType.APPLICATION, "u1", ORG, CREATED); + Application noSettingsKey = marketplaceApp("m-nokey", "No key", ApplicationType.APPLICATION, "u1", ORG, CREATED); + Application settingsNotAMap = marketplaceApp("m-notmap", "Not a map", ApplicationType.APPLICATION, "u1", ORG, CREATED); + Application noTitle = marketplaceApp("m-notitle", "No title", ApplicationType.APPLICATION, "u1", ORG, CREATED); + when(applicationService.findAllMarketplaceApps()).thenReturn(Flux.just(withSettings, noDsl, noSettingsKey, settingsNotAMap, noTitle)); + stubDsl("m-set", Map.of("settings", Map.of("title", "Shown title", "category", "cat", "description", "desc", "icon", "icon.png"))); + stubDsl("m-nokey", Map.of("other", 1)); + stubDsl("m-notmap", Map.of("settings", "just text")); + stubDsl("m-notitle", Map.of("settings", Map.of("category", "cat"))); + when(organizationService.getByIds(anyCollection())).thenReturn(Flux.just(org(ORG, "Acme", null))); + + Map byId = new HashMap<>(); + collect(service.getAllMarketplaceApplications(null)).forEach(v -> byId.put(v.getApplicationId(), v)); + + MarketplaceApplicationInfoView set = byId.get("m-set"); + assertThat(set.getTitle()).isEqualTo("Shown title"); + assertThat(set.getCategory()).isEqualTo("cat"); + assertThat(set.getDescription()).isEqualTo("desc"); + assertThat(set.getImage()).isEqualTo("icon.png"); + for (String id : List.of("m-nodsl", "m-nokey", "m-notmap")) { + assertThat(byId.get(id).getTitle()).as(id + " title defaults to the name").isEqualTo(byId.get(id).getName()); + assertThat(byId.get(id).getCategory()).as(id).isNull(); + assertThat(byId.get(id).getImage()).as(id).isNull(); + } + assertThat(byId.get("m-notitle").getTitle()).isEqualTo("No title"); + assertThat(byId.get("m-notitle").getCategory()).isEqualTo("cat"); + } + + /** + * Pins plan §9 row "marketplace and agency listings fail" (part 1): a published DSL whose {@code settings.title} is not a + * String makes the listing fail with a ClassCastException instead of falling back to the application name. + */ + @Test + void getAllMarketplaceApplications_nonStringDslTitle_failsWithClassCast_pinsTheSection9Row() { + signedIn(false, true); + when(applicationService.findAllMarketplaceApps()).thenReturn(Flux.just(marketplaceApp("m-bad", "Bad", ApplicationType.APPLICATION, "u1", ORG, CREATED))); + stubDsl("m-bad", Map.of("settings", Map.of("title", 42))); + when(organizationService.getByIds(anyCollection())).thenReturn(Flux.just(org(ORG, "Acme", null))); + + StepVerifier.create(service.getAllMarketplaceApplications(null)) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(ClassCastException.class); + System.out.println("[UserHomeApiServiceImplTest] non-String title -> " + error); + }) + .verify(WAIT); + } + + // ------------------------------------------------------- agency / bundles + + @Test + void getAllAgencyProfileApplications_filtersByType_andBuildsTheView() { + Application first = marketplaceApp("g1", "One", ApplicationType.APPLICATION, "u1", ORG, CREATED); + Application module = marketplaceApp("g2", "Two", ApplicationType.MODULE, "u-gone", ORG, CREATED); + when(applicationService.findAllAgencyProfileApps()).thenReturn(Flux.just(first, module)); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(Map.of("u1", user("u1", "creator@example.com", null)))); + when(organizationService.getByIds(anyCollection())).thenReturn(Flux.just(org(ORG, "Acme", null))); + + List all = collect(service.getAllAgencyProfileApplications(null)); + List modules = collect(service.getAllAgencyProfileApplications(ApplicationType.MODULE)); + + assertThat(all).hasSize(2); + assertThat(modules).extracting(MarketplaceApplicationInfoView::getApplicationId).containsExactly("g2"); + MarketplaceApplicationInfoView view = all.stream().filter(v -> v.getApplicationId().equals("g1")).findFirst().orElseThrow(); + assertThat(view.getOrgName()).isEqualTo("Acme"); + assertThat(view.getOrgId()).isEqualTo(ORG); + assertThat(view.getCreatorEmail()).isEqualTo("creator@example.com"); + assertThat(view.getCreateAt()).isEqualTo(CREATED.toEpochMilli()); + assertThat(modules.get(0).getCreatorEmail()).as("unknown creator").isEmpty(); + } + + /** + * Pins plan §9 row "marketplace and agency listings fail" (part 2): an agency application whose organization row is + * missing makes the listing fail with a NullPointerException (getAllMarketplaceApplications falls back to ""). + */ + @Test + void getAllAgencyProfileApplications_missingOrganization_failsWithNullPointer_pinsTheSection9Row() { + when(applicationService.findAllAgencyProfileApps()).thenReturn(Flux.just(marketplaceApp("g1", "One", ApplicationType.APPLICATION, "u1", "org-gone", CREATED))); + + StepVerifier.create(service.getAllAgencyProfileApplications(null)) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(NullPointerException.class); + System.out.println("[UserHomeApiServiceImplTest] agency application, missing org -> " + error.getClass().getSimpleName()); + }) + .verify(WAIT); + } + + private void stubBundleCollaborators(Bundle... bundles) { + when(bundleService.findAllMarketplaceBundles()).thenReturn(Flux.just(bundles)); + when(bundleService.findAllAgencyProfileBundles()).thenReturn(Flux.just(bundles)); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(Map.of("u1", user("u1", "creator@example.com", null)))); + when(organizationService.getByIds(anyCollection())).thenReturn(Flux.just(org(ORG, "Acme", null))); + } + + /** Catches the bundle view fields in both bundle listings, and the creator fallback (blank). */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void bundleListings_buildTheView(boolean agency) { + signedIn(false, true); + stubBundleCollaborators(bundle("b1", "Bundle", BundleStatus.NORMAL, "u1", ORG), bundle("b2", "Other", BundleStatus.RECYCLED, "u-gone", ORG)); + + List views = collect(agency ? service.getAllAgencyProfileBundles() : service.getAllMarketplaceBundles()); + + assertThat(views).hasSize(2); + MarketplaceBundleInfoView first = views.stream().filter(v -> v.getBundleId().equals("b1")).findFirst().orElseThrow(); + MarketplaceBundleInfoView second = views.stream().filter(v -> v.getBundleId().equals("b2")).findFirst().orElseThrow(); + assertThat(first.getBundleGid()).isEqualTo("gid-b1"); + assertThat(first.getName()).isEqualTo("Bundle"); + assertThat(first.getBundleStatus()).isEqualTo(BundleStatus.NORMAL); + assertThat(first.getOrgId()).isEqualTo(ORG); + assertThat(first.getOrgName()).isEqualTo("Acme"); + assertThat(first.getCreatorEmail()).isEqualTo("creator@example.com"); + assertThat(first.getCreateAt()).isEqualTo(CREATED.toEpochMilli()); + assertThat(first.getCreateBy()).isEqualTo("u1"); + assertThat(second.getBundleStatus()).isEqualTo(BundleStatus.RECYCLED); + assertThat(second.getCreatorEmail()).isEmpty(); + System.out.println("[UserHomeApiServiceImplTest] " + (agency ? "agency" : "marketplace") + " bundles " + views.size()); + } + + /** Catches the private-mode rule for bundles (the agency listing has none: it only needs a session org member). */ + @Test + void getAllMarketplaceBundles_privateModeHidesTheListingFromAnonymousVisitors() { + stubBundleCollaborators(bundle("b1", "Bundle", BundleStatus.NORMAL, "u1", ORG)); + + signedIn(true, true); + assertThat(collect(service.getAllMarketplaceBundles())).isEmpty(); + verify(bundleService, never()).findAllMarketplaceBundles(); + + signedIn(false, true); + assertThat(collect(service.getAllMarketplaceBundles())).hasSize(1); + signedIn(true, false); + assertThat(collect(service.getAllMarketplaceBundles())).hasSize(1); + } + + /** Pins plan §9 row "marketplace and agency listings fail" (part 2) for both bundle listings: a missing organization row fails with a NullPointerException. */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void bundleListings_missingOrganization_failWithNullPointer_pinsTheSection9Row(boolean agency) { + signedIn(false, true); + when(bundleService.findAllMarketplaceBundles()).thenReturn(Flux.just(bundle("b1", "Bundle", BundleStatus.NORMAL, "u1", "org-gone"))); + when(bundleService.findAllAgencyProfileBundles()).thenReturn(Flux.just(bundle("b1", "Bundle", BundleStatus.NORMAL, "u1", "org-gone"))); + + StepVerifier.create(agency ? service.getAllAgencyProfileBundles() : service.getAllMarketplaceBundles()) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(NullPointerException.class); + System.out.println("[UserHomeApiServiceImplTest] " + (agency ? "agency" : "marketplace") + " bundle, missing org -> " + error.getClass().getSimpleName()); + }) + .verify(WAIT); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/infra/ServerConfigRepositoryTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/infra/ServerConfigRepositoryTest.java index c88eb0432c..062c07bcb4 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/infra/ServerConfigRepositoryTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/infra/ServerConfigRepositoryTest.java @@ -2,27 +2,40 @@ import com.google.common.collect.ImmutableList; import lombok.extern.slf4j.Slf4j; -import org.junit.Ignore; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.Test; import org.lowcoder.infra.config.model.ServerConfig; import org.lowcoder.infra.config.repository.ServerConfigRepository; import org.lowcoder.sdk.config.dynamic.Conf; import org.lowcoder.sdk.config.dynamic.ConfigInstance; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringRunner; +import java.time.Duration; import java.util.List; import java.util.Map; - -import static org.junit.Assert.assertEquals; - -@RunWith(SpringRunner.class) +import java.util.Objects; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A config saved to the repository reaches {@link ConfigInstance} through the reloading cache of + * {@code AutoReloadConfigFactory}, which reloads every {@link #RELOAD_INTERVAL}. Catches a reload that stops, or that + * drops or misreads a key (an integer, a list, a JSON object). + * + *

    The values are polled until they change, up to {@link #RELOAD_TIMEOUT}: the test used to sleep exactly one reload + * interval and then assert, a race it lost in a full build on 2026-10-04 (key3 still held its previous value). + */ @SpringBootTest @Slf4j public class ServerConfigRepositoryTest { + /** {@code AutoReloadConfigFactory}'s interval. */ + private static final Duration RELOAD_INTERVAL = Duration.ofSeconds(3); + /** Five reload intervals: enough for a reload to run after the write even on a loaded machine. */ + private static final Duration RELOAD_TIMEOUT = RELOAD_INTERVAL.multipliedBy(5); + private static final Duration POLL_INTERVAL = Duration.ofMillis(100); + @Autowired ServerConfigRepository configRepository; @@ -43,22 +56,31 @@ public void test() throws InterruptedException { configRepository.save(getNewConfig("key1", "123")).block(); configRepository.save(getNewConfig("key2", List.of(1, 2))).block(); configRepository.save(getNewConfig("key3", Map.of("x", 22, "y", 33))).block(); - Thread.sleep(3000); - assertEquals(123, test1.get().intValue()); - assertEquals(ImmutableList.of(1, 2), test2.get()); - assertEquals(new SomeClass(22, 33), test3.get()); + awaitValue("key1", 123, () -> test1.get().intValue()); + awaitValue("key2", ImmutableList.of(1, 2), test2::get); + awaitValue("key3", new SomeClass(22, 33), test3::get); configRepository.upsert("key1", "12345").block(); configRepository.upsert("key2", List.of(1, 2, 3)).block(); configRepository.upsert("key3", Map.of("x", 33, "y", 44)).block(); - Thread.sleep(3000); - - assertEquals(12345, test1.get().intValue()); - assertEquals(ImmutableList.of(1, 2, 3), test2.get()); - assertEquals(new SomeClass(33, 44), test3.get()); + awaitValue("key1", 12345, () -> test1.get().intValue()); + awaitValue("key2", ImmutableList.of(1, 2, 3), test2::get); + awaitValue("key3", new SomeClass(33, 44), test3::get); + } + /** Polls {@code actual} until it equals {@code expected} or {@link #RELOAD_TIMEOUT} passes, then asserts. */ + private static void awaitValue(String key, T expected, Supplier actual) throws InterruptedException { + long start = System.nanoTime(); + long deadline = start + RELOAD_TIMEOUT.toNanos(); + while (!Objects.equals(expected, actual.get()) && System.nanoTime() < deadline) { + Thread.sleep(POLL_INTERVAL.toMillis()); + } + T last = actual.get(); + System.out.println("[ServerConfigRepositoryTest] " + key + " = " + last + " after " + + Duration.ofNanos(System.nanoTime() - start).toMillis() + " ms (expected " + expected + ")"); + assertEquals(expected, last, key + " reloaded within " + RELOAD_TIMEOUT); } private ServerConfig getNewConfig(String key, Object value) { diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/material/MaterialApiServiceImplTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/material/MaterialApiServiceImplTest.java new file mode 100644 index 0000000000..5b71289d79 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/material/MaterialApiServiceImplTest.java @@ -0,0 +1,457 @@ +package org.lowcoder.api.material; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.lowcoder.api.contract.support.ContractTestClient; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.api.usermanagement.OrgDevChecker; +import org.lowcoder.domain.material.model.MaterialMeta; +import org.lowcoder.domain.material.model.MaterialType; +import org.lowcoder.domain.material.repository.MaterialMateRepository; +import org.lowcoder.domain.material.service.meta.MaterialMetaService; +import org.lowcoder.domain.material.service.storage.MaterialStorageService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.infra.constant.NewUrl; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.dynamic.Conf; +import org.lowcoder.sdk.config.dynamic.ConfigCenter; +import org.lowcoder.sdk.config.dynamic.ConfigInstance; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.ArgumentCaptor; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Mockito unit tests of {@link MaterialApiServiceImpl}. Every call that changes something is recorded in {@link #ops} + * when its Mono is subscribed (several operations are written as {@code a.then(b(...))}, where {@code b(...)} is + * called at assembly time), so "nothing was deleted or saved" and the order of the steps are asserted on what was + * really executed. + * + *

    Pinned under D-6, plan §9 row "material upload deletes the old file before the new one is stored; a storage + * failure loses both (MaterialApiServiceImpl:84-91)": + * {@link #upload_whenTheStorageSaveFails_theOldRowAndFileAreAlreadyGone_pinsTheSection9Row}; and plan §9 row "material + * quota counts the file being replaced": {@link #upload_quotaCountsTheFileBeingReplaced_pinsTheSection9Row}. Pinned as + * behaviour (no row): a malformed base64 content is a synchronous IllegalArgumentException instead of a coded error + * ({@link #upload_malformedBase64_throwsIllegalArgumentSynchronously_andTheClientGetsAGeneric500}). + */ +class MaterialApiServiceImplTest { + + private static final String ORG = "org-1"; + private static final String OTHER_ORG = "org-2"; + private static final String SINGLE_LIMIT_KEY = "material.single-size-limit"; + private static final String TOTAL_LIMIT_KEY = "material.total-size-limit"; + private static final long ONE_MB = 1024L * 1024L; + private static final Duration WAIT = Duration.ofSeconds(10); + + private final List ops = new ArrayList<>(); + private final Map limitOverrides = new java.util.HashMap<>(); + + private MaterialMetaService metaService; + private MaterialMateRepository repository; + private MaterialStorageService storage; + private SessionUserService sessionUserService; + private OrgDevChecker orgDevChecker; + private ConfigInstance threshold; + private CommonConfig commonConfig; + private MaterialApiServiceImpl service; + + @BeforeEach + void setUp() { + metaService = mock(MaterialMetaService.class); + repository = mock(MaterialMateRepository.class); + storage = mock(MaterialStorageService.class); + sessionUserService = mock(SessionUserService.class); + orgDevChecker = mock(OrgDevChecker.class); + ConfigCenter configCenter = mock(ConfigCenter.class); + threshold = mock(ConfigInstance.class); + commonConfig = new CommonConfig(); + ReflectionTestUtils.setField(commonConfig, "cloud", true); + when(configCenter.threshold()).thenReturn(threshold); + stubLimitConfig(); + + service = new MaterialApiServiceImpl(); + ReflectionTestUtils.setField(service, "materialMetaService", metaService); + ReflectionTestUtils.setField(service, "materialStorageService", storage); + ReflectionTestUtils.setField(service, "sessionUserService", sessionUserService); + ReflectionTestUtils.setField(service, "orgDevChecker", orgDevChecker); + ReflectionTestUtils.setField(service, "configCenter", configCenter); + ReflectionTestUtils.setField(service, "materialMateRepository", repository); + ReflectionTestUtils.setField(service, "commonConfig", commonConfig); + service.init(); + + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(member(ORG))); + when(orgDevChecker.checkCurrentOrgDev()).thenReturn(Mono.empty()); + when(metaService.totalSize(anyString())).thenReturn(Mono.just(0L)); + when(repository.findByOrgIdAndType(anyString(), any())).thenReturn(Flux.empty()); + when(repository.findByOrgIdAndFilenameAndType(anyString(), anyString(), any())).thenReturn(Flux.empty()); + when(repository.deleteById(anyString())).thenAnswer(invocation -> recorded("repo.delete:" + invocation.getArgument(0))); + when(repository.save(any(MaterialMeta.class))).thenAnswer(invocation -> Mono.defer(() -> { + MaterialMeta meta = invocation.getArgument(0); + meta.setId("new-id"); + ops.add("repo.save:" + meta.getType() + ":" + meta.getFilename()); + return Mono.just(meta); + })); + when(storage.save(any(MaterialMeta.class), any(byte[].class))).thenAnswer(invocation -> Mono.defer(() -> { + ops.add("storage.save:" + ((MaterialMeta) invocation.getArgument(0)).getId()); + return Mono.just(true); + })); + when(storage.delete(any(MaterialMeta.class))).thenAnswer(invocation -> recorded("storage.delete:" + ((MaterialMeta) invocation.getArgument(0)).getId())); + when(metaService.deleteById(anyString())).thenAnswer(invocation -> recorded("meta.delete:" + invocation.getArgument(0))); + } + + // ----------------------------------------------------------------- fixtures + + private Mono recorded(String op) { + return Mono.defer(() -> { + ops.add(op); + return Mono.empty(); + }); + } + + @SuppressWarnings("unchecked") + private void stubLimitConfig() { + when(threshold.ofJson(anyString(), eq(Long.class), anyLong())).thenAnswer(invocation -> { + Conf conf = mock(Conf.class); + String key = invocation.getArgument(0); + long defaultValue = invocation.getArgument(2); + when(conf.get()).thenReturn(limitOverrides.getOrDefault(key, defaultValue)); + return conf; + }); + } + + private static OrgMember member(String orgId) { + return OrgMember.builder().orgId(orgId).userId("user-1").role(MemberRole.MEMBER).build(); + } + + private static MaterialMeta meta(String id, String orgId, String filename, long size, MaterialType type) { + MaterialMeta meta = MaterialMeta.builder().orgId(orgId).filename(filename).size(size).type(type).build(); + meta.setId(id); + return meta; + } + + private static String base64(byte[] content) { + return Base64.getEncoder().encodeToString(content); + } + + private static byte[] bytes(int length) { + return new byte[length]; + } + + private static void expectBizError(Mono mono, BizError error, String messageKey) { + StepVerifier.create(mono).expectErrorSatisfies(throwable -> { + assertThat(throwable).isInstanceOf(BizException.class); + assertThat(((BizException) throwable).getError()).isEqualTo(error); + assertThat(((BizException) throwable).getMessageKey()).isEqualTo(messageKey); + System.out.println("[MaterialApiServiceImplTest] " + error + " " + messageKey + ": " + throwable.getMessage()); + }).verify(WAIT); + } + + private Mono upload(byte[] content, MaterialType type) { + return service.upload("file.png", base64(content), type); + } + + // ------------------------------------------------------------------- upload + + /** Catches the wrong org, filename, size or type being stored, or the file's bytes not being the decoded content. */ + @Test + void upload_storesTheDecodedFile_forTheSessionOrg() { + byte[] content = "hello material".getBytes(StandardCharsets.UTF_8); + + StepVerifier.create(service.upload("hello.txt", base64(content), MaterialType.COMMON)).assertNext(saved -> { + assertThat(saved.getId()).isEqualTo("new-id"); + assertThat(saved.getOrgId()).isEqualTo(ORG); + assertThat(saved.getFilename()).isEqualTo("hello.txt"); + assertThat(saved.getSize()).isEqualTo(content.length); + assertThat(saved.getType()).isEqualTo(MaterialType.COMMON); + }).verifyComplete(); + + ArgumentCaptor stored = ArgumentCaptor.forClass(byte[].class); + verify(storage).save(any(MaterialMeta.class), stored.capture()); + assertThat(stored.getValue()).isEqualTo(content); + assertThat(ops).containsExactly("repo.save:COMMON:hello.txt", "storage.save:new-id"); + System.out.println("[MaterialApiServiceImplTest] upload steps " + ops); + } + + /** + * Pins today's behaviour (no row): content that is not base64 makes {@code upload} throw an + * IllegalArgumentException synchronously, before any Mono exists, instead of a coded error. The controller does not + * convert it: through the request stack the client receives the generic HTTP 500 of the controller advice (code 5000, + * "Oops! Service is busy"), not a raw exception. + */ + @Test + void upload_malformedBase64_throwsIllegalArgumentSynchronously_andTheClientGetsAGeneric500() { + assertThatThrownBy(() -> service.upload("x.png", "%%% not base64 %%%", MaterialType.COMMON)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.upload("x.png", "QUJD$REVG", MaterialType.COMMON)) + .as("an illegal character inside otherwise valid base64 is not skipped (the MIME decoder would skip it)") + .isInstanceOf(IllegalArgumentException.class); + assertThat(ops).isEmpty(); + + try (ContractTestClient client = ContractTestClient.builder() + .singleton("materialApiService", service) + .controllerWithMockedDependencies(MaterialController.class) + .visitor("user-1", member(ORG)) + .build()) { + client.web().post().uri(NewUrl.MATERIAL_URL).contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("filename", "x.png", "content", "%%% not base64 %%%", "type", "COMMON")) + .exchange() + .expectStatus().isEqualTo(500) + .expectBody().jsonPath("$.code").isEqualTo(BizError.INTERNAL_SERVER_ERROR.getBizErrorCode()) + .jsonPath("$.message").value(message -> System.out.println("[MaterialApiServiceImplTest] client receives: 500 / " + message)); + } + } + + /** Catches a non-developer uploading: the dev check fails the upload before anything is deleted or saved. */ + @Test + void upload_requiresOrgDev_andChangesNothingOtherwise() { + when(orgDevChecker.checkCurrentOrgDev()).thenReturn(Mono.error(new BizException(BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED"))); + when(repository.findByOrgIdAndType(anyString(), any())).thenReturn(Flux.just(meta("old", ORG, "logo.png", 5, MaterialType.LOGO))); + + expectBizError(upload(bytes(3), MaterialType.LOGO), BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED"); + + assertThat(ops).isEmpty(); + } + + /** Catches an old LOGO or FAVICON staying behind, and the wrong org or type being replaced. */ + @ParameterizedTest + @EnumSource(value = MaterialType.class, names = {"LOGO", "FAVICON"}) + void upload_logoAndFavicon_replaceTheOrgsOldOneOfThatType(MaterialType type) { + when(repository.findByOrgIdAndType(ORG, type)).thenReturn(Flux.just(meta("old-" + type, ORG, "old.png", 5, type))); + + StepVerifier.create(upload(bytes(3), type)).expectNextCount(1).verifyComplete(); + + assertThat(ops).containsExactly("repo.delete:old-" + type, "storage.delete:old-" + type, "repo.save:" + type + ":file.png", "storage.save:new-id"); + verify(repository, never()).findByOrgIdAndFilenameAndType(anyString(), anyString(), any()); + System.out.println("[MaterialApiServiceImplTest] " + type + " replacement steps " + ops); + } + + @ParameterizedTest + @EnumSource(value = MaterialType.class, names = {"LOGO", "FAVICON"}) + void upload_logoAndFavicon_withoutAnOldOne_justSaves(MaterialType type) { + StepVerifier.create(upload(bytes(3), type)).expectNextCount(1).verifyComplete(); + + assertThat(ops).containsExactly("repo.save:" + type + ":file.png", "storage.save:new-id"); + } + + /** Catches a COMMON file replacing a file of another name, org or type: only org + filename + type. */ + @Test + void upload_common_replacesTheSameNameFileOfTheOrg() { + when(repository.findByOrgIdAndFilenameAndType(ORG, "file.png", MaterialType.COMMON)) + .thenReturn(Flux.just(meta("old-common", ORG, "file.png", 5, MaterialType.COMMON))); + + StepVerifier.create(upload(bytes(3), MaterialType.COMMON)).expectNextCount(1).verifyComplete(); + + assertThat(ops).containsExactly("repo.delete:old-common", "storage.delete:old-common", "repo.save:COMMON:file.png", "storage.save:new-id"); + verify(repository, never()).findByOrgIdAndType(anyString(), any()); + } + + // ------------------------------------------------------------- size limits + + /** Catches the single-size boundary: exactly the limit passes, one byte more fails before anything is changed. */ + @Test + void upload_singleSizeLimit_boundary_andConfiguredKey() { + limitOverrides.put(SINGLE_LIMIT_KEY, 10L); + + StepVerifier.create(upload(bytes(10), MaterialType.COMMON)).expectNextCount(1).verifyComplete(); + ops.clear(); + + expectBizError(upload(bytes(11), MaterialType.COMMON), BizError.INVALID_MATERIAL_REQUEST, "EXCEEDS_FILE_SIZE_LIMIT"); + assertThat(ops).isEmpty(); + } + + @Test + void upload_singleSizeLimit_defaultsToTwentyMegabytes() { + StepVerifier.create(upload(bytes(1), MaterialType.COMMON)).expectNextCount(1).verifyComplete(); + + ArgumentCaptor defaultValue = ArgumentCaptor.forClass(Long.class); + verify(threshold).ofJson(eq(SINGLE_LIMIT_KEY), eq(Long.class), defaultValue.capture()); + assertThat(defaultValue.getValue()).isEqualTo(20 * ONE_MB); + } + + /** Catches the org total boundary: used + new == limit passes, one byte more fails with the org limit error. */ + @Test + void upload_totalSizeLimit_boundary_andConfiguredKey() { + limitOverrides.put(TOTAL_LIMIT_KEY, 100L); + when(metaService.totalSize(ORG)).thenReturn(Mono.just(90L)); + + StepVerifier.create(upload(bytes(10), MaterialType.COMMON)).expectNextCount(1).verifyComplete(); + ops.clear(); + + expectBizError(upload(bytes(11), MaterialType.COMMON), BizError.INVALID_MATERIAL_REQUEST, "EXCEEDS_ORG_SIZE_LIMIT"); + assertThat(ops).isEmpty(); + } + + @Test + void upload_totalSizeLimit_defaultsToTwoGigabytes() { + StepVerifier.create(upload(bytes(1), MaterialType.COMMON)).expectNextCount(1).verifyComplete(); + + ArgumentCaptor defaultValue = ArgumentCaptor.forClass(Long.class); + verify(threshold).ofJson(eq(TOTAL_LIMIT_KEY), eq(Long.class), defaultValue.capture()); + assertThat(defaultValue.getValue()).isEqualTo(2 * 1024 * ONE_MB); + } + + /** Catches self-hosted installations being limited: neither limit is read and the total is not even asked. */ + @Test + void upload_selfHost_ignoresBothLimits() { + ReflectionTestUtils.setField(commonConfig, "cloud", false); + limitOverrides.put(SINGLE_LIMIT_KEY, 1L); + limitOverrides.put(TOTAL_LIMIT_KEY, 1L); + when(metaService.totalSize(ORG)).thenReturn(Mono.just(Long.MAX_VALUE / 2)); + + StepVerifier.create(upload(bytes(100), MaterialType.COMMON)).expectNextCount(1).verifyComplete(); + + verify(metaService, never()).totalSize(anyString()); + verify(threshold, never()).ofJson(anyString(), eq(Long.class), anyLong()); + } + + /** + * Pins plan §9 row "material quota counts the file being replaced" ({@code checkTotalSize}, MaterialApiServiceImpl:147-160): + * the org total includes the file that is being replaced, so a user near the quota cannot replace a LOGO with one + * of the same size: it is refused within one file size of the limit although the replacement does not grow the total. + */ + @Test + void upload_quotaCountsTheFileBeingReplaced_pinsTheSection9Row() { + limitOverrides.put(TOTAL_LIMIT_KEY, 100L); + when(metaService.totalSize(ORG)).thenReturn(Mono.just(96L)); + when(repository.findByOrgIdAndType(ORG, MaterialType.LOGO)).thenReturn(Flux.just(meta("old-logo", ORG, "logo.png", 5, MaterialType.LOGO))); + + expectBizError(upload(bytes(5), MaterialType.LOGO), BizError.INVALID_MATERIAL_REQUEST, "EXCEEDS_ORG_SIZE_LIMIT"); + + assertThat(ops).as("the old logo stays").isEmpty(); + System.out.println("[MaterialApiServiceImplTest] same-size logo replacement refused at 96/100 with a 5 byte old logo"); + } + + // ------------------------------------------------------- storage failure + + /** + * Pins plan §9 row "material upload deletes the old file before the new one is stored; a storage failure loses + * both (MaterialApiServiceImpl:84-91)": the old row and its file are deleted first, the new row is saved, and when + * storing the new file fails the org is left with no old material and a new row without a file. + */ + @Test + void upload_whenTheStorageSaveFails_theOldRowAndFileAreAlreadyGone_pinsTheSection9Row() { + when(repository.findByOrgIdAndType(ORG, MaterialType.LOGO)).thenReturn(Flux.just(meta("old-logo", ORG, "logo.png", 5, MaterialType.LOGO))); + when(storage.save(any(MaterialMeta.class), any(byte[].class))).thenAnswer(invocation -> Mono.defer(() -> { + ops.add("storage.save:failed"); + return Mono.error(new IllegalStateException("storage backend down")); + })); + + StepVerifier.create(upload(bytes(3), MaterialType.LOGO)).expectError(IllegalStateException.class).verify(WAIT); + + assertThat(ops).containsExactly("repo.delete:old-logo", "storage.delete:old-logo", "repo.save:LOGO:file.png", "storage.save:failed"); + System.out.println("[MaterialApiServiceImplTest] failed storage save: " + ops); + } + + // ----------------------------------------------------------------- download + + private static Flux content() { + return Flux.just(new DefaultDataBufferFactory().wrap("bytes".getBytes(StandardCharsets.UTF_8))); + } + + /** Catches an org check on LOGO and FAVICON: they are served to any caller (they appear on public pages). */ + @ParameterizedTest + @EnumSource(value = MaterialType.class, names = {"LOGO", "FAVICON"}) + void download_logoAndFavicon_needNoOrgMatch(MaterialType type) { + MaterialMeta foreign = meta("m1", OTHER_ORG, "logo.png", 5, type); + when(storage.download(foreign)).thenAnswer(invocation -> content()); + + StepVerifier.create(service.download(foreign)).expectNextCount(1).verifyComplete(); + } + + /** Catches another org's material being readable: the caller's org must match, and no byte is read otherwise. */ + @Test + void download_otherTypes_requireTheCallersOrg() { + MaterialMeta own = meta("m1", ORG, "a.png", 5, MaterialType.COMMON); + MaterialMeta foreign = meta("m2", OTHER_ORG, "b.png", 5, MaterialType.COMMON); + AtomicBoolean foreignRead = new AtomicBoolean(); + when(storage.download(own)).thenAnswer(invocation -> content()); + when(storage.download(foreign)).thenAnswer(invocation -> content().doOnSubscribe(subscription -> foreignRead.set(true))); + + StepVerifier.create(service.download(own)).expectNextCount(1).verifyComplete(); + StepVerifier.create(service.download(foreign)).expectErrorSatisfies(error -> { + assertThat(((BizException) error).getError()).isEqualTo(BizError.INVALID_MATERIAL_REQUEST); + assertThat(((BizException) error).getMessageKey()).isEqualTo("FILE_ORG_NOT_MATCH"); + }).verify(WAIT); + + assertThat(foreignRead).as("the foreign file is never read").isFalse(); + } + + // --------------------------------------------------------------------- list + + @Test + void list_mapsIdAndFilename_ofTheCallersOrg() { + when(metaService.getByOrgId(ORG)).thenReturn(Flux.just(meta("m1", ORG, "a.png", 5, MaterialType.COMMON), meta("m2", ORG, "b.png", 6, MaterialType.LOGO))); + + List views = service.list().block(WAIT); + + assertThat(views).extracting(MaterialEndpoints.MaterialView::getId).containsExactly("m1", "m2"); + assertThat(views).extracting(MaterialEndpoints.MaterialView::getFilename).containsExactly("a.png", "b.png"); + verify(metaService, never()).getByOrgId(OTHER_ORG); + } + + @Test + void list_ofAnOrgWithoutMaterial_isEmpty() { + when(metaService.getByOrgId(ORG)).thenReturn(Flux.empty()); + + assertThat(service.list().block(WAIT)).isEmpty(); + } + + // ------------------------------------------------------------------- delete + + @Test + void delete_unknownId_failsWithInvalidParameter10095_andDeletesNothing() { + when(metaService.findById("ghost")).thenReturn(Mono.empty()); + + expectBizError(service.delete("ghost"), BizError.INVALID_PARAMETER, "10095"); + + assertThat(ops).isEmpty(); + } + + /** Catches another org's material being deleted, a non-developer deleting, and the order row then file. */ + @Test + void delete_checksTheOrgBeforeDev_deletesRowThenFile_andNothingOnAFailure() { + when(metaService.findById("own")).thenReturn(Mono.just(meta("own", ORG, "a.png", 5, MaterialType.COMMON))); + when(metaService.findById("foreign")).thenReturn(Mono.just(meta("foreign", OTHER_ORG, "b.png", 5, MaterialType.LOGO))); + + expectBizError(service.delete("foreign"), BizError.INVALID_MATERIAL_REQUEST, "FILE_ORG_NOT_MATCH"); + assertThat(ops).isEmpty(); + + when(orgDevChecker.checkCurrentOrgDev()).thenReturn(Mono.error(new BizException(BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED"))); + expectBizError(service.delete("own"), BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED"); + assertThat(ops).isEmpty(); + + when(orgDevChecker.checkCurrentOrgDev()).thenReturn(Mono.empty()); + StepVerifier.create(service.delete("own")).verifyComplete(); + assertThat(ops).containsExactly("meta.delete:own", "storage.delete:own"); + System.out.println("[MaterialApiServiceImplTest] delete steps " + ops); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/ApplicationQueryApiServiceImplTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/ApplicationQueryApiServiceImplTest.java new file mode 100644 index 0000000000..d13582f380 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/ApplicationQueryApiServiceImplTest.java @@ -0,0 +1,644 @@ +package org.lowcoder.api.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.lowcoder.domain.permission.model.ResourceAction.READ_APPLICATIONS; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.api.query.view.QueryExecutionRequest; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceCreationSource; +import org.lowcoder.domain.datasource.service.DatasourceService; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.query.model.ApplicationQuery; +import org.lowcoder.domain.query.model.BaseQuery; +import org.lowcoder.domain.query.model.LibraryQueryRecord; +import org.lowcoder.domain.query.service.LibraryQueryRecordService; +import org.lowcoder.domain.query.service.LibraryQueryService; +import org.lowcoder.domain.query.service.QueryExecutionService; +import org.lowcoder.domain.user.model.Connection; +import org.lowcoder.domain.user.model.ConnectionAuthToken; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.models.JsDatasourceConnectionConfig; +import org.lowcoder.sdk.models.Param; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.OAuthInheritAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; +import org.lowcoder.sdk.query.QueryVisitorContext; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpCookie; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@link ApplicationQueryApiServiceImpl} with every collaborator mocked: argument validation, the read-permission gate + * (edit mode, view mode, forged module path), the tenant check between datasource and app, the base query source + * (own, live library record, recorded library record), what reaches {@code QueryExecutionService.executeQuery}, and + * the "OAuth inherited from login" headers. + * + *

    Pinned production defect (owner decision D-6: fixes are deferred, a fix changes this test on purpose): the plan + * section 9 row "UserSessionPersistenceFilter: connection.getAuthId().equals(...) NPE for a connection with a null + * authId" (this site added by the coordinator). See + * {@link #oauthInherit_connectionWithNullAuthIdBeforeTheMatch_isANullPointerException_pinsSection9Row}. + */ +@ExtendWith(MockitoExtension.class) +class ApplicationQueryApiServiceImplTest { + + private static final String LOG_PREFIX = "[ApplicationQueryApiServiceImplTest] "; + + private static final String APP_ID = "app-1"; + private static final String ROOT_APP_ID = "root-app"; + private static final String QUERY_ID = "query-1"; + private static final String DATASOURCE_ID = "ds-1"; + private static final String APP_ORG = "org-app"; + private static final String OTHER_ORG = "org-other"; + private static final String VISITOR_ID = "visitor-1"; + private static final String TIMEOUT = "5000"; + private static final int PORT = 18080; + private static final String PLAIN_TYPE = "mysql"; + private static final String OAUTH_TYPE_NAME = RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN.name(); + + @Mock + private SessionUserService sessionUserService; + @Mock + private LibraryQueryService libraryQueryService; + @Mock + private LibraryQueryRecordService libraryQueryRecordService; + @Mock + private ApplicationService applicationService; + @Mock + private ResourcePermissionService resourcePermissionService; + @Mock + private DatasourceService datasourceService; + @Mock + private QueryExecutionService queryExecutionService; + @Mock + private CommonConfig commonConfig; + @Mock + private ApplicationRecordService applicationRecordService; + @Mock + private ServerWebExchange exchange; + @Mock + private ServerHttpRequest httpRequest; + @Mock + private Application app; + + private ApplicationQueryApiServiceImpl service; + + private final List events = new ArrayList<>(); + private final MultiValueMap cookies = new LinkedMultiValueMap<>(); + private final Set disallowedHosts = Set.of("169.254.169.254"); + private final QueryExecutionResult result = QueryExecutionResult.success("rows"); + private final User visitor = new User(); + private Datasource datasource; + private ApplicationQuery appQuery; + + @BeforeEach + void setUp() { + service = new ApplicationQueryApiServiceImpl(sessionUserService, libraryQueryService, libraryQueryRecordService, + applicationService, resourcePermissionService, datasourceService, queryExecutionService, commonConfig, + applicationRecordService); + ReflectionTestUtils.setField(service, "port", PORT); + + visitor.setId(VISITOR_ID); + datasource = new Datasource(); + datasource.setId(DATASOURCE_ID); + datasource.setOrganizationId(APP_ORG); + datasource.setType(PLAIN_TYPE); + datasource.setCreationSource(DatasourceCreationSource.USER_CREATED.getValue()); + appQuery = new ApplicationQuery("q-id", "q-gid", "q-name", DATASOURCE_ID, Map.of("sql", "select 1"), "manual", TIMEOUT, "mysql"); + + lenient().when(sessionUserService.getVisitor()).thenReturn(Mono.just(visitor)); + lenient().when(applicationService.findById(APP_ID)).thenReturn(Mono.just(app)); + lenient().when(app.getOrganizationId()).thenReturn(APP_ORG); + lenient().when(app.getQueryByViewModeAndQueryId(anyBoolean(), eq(QUERY_ID), eq(applicationRecordService))) + .thenAnswer(invocation -> Mono.just(appQuery)); + lenient().when(resourcePermissionService.checkResourcePermissionWithError(anyString(), anyString(), eq(READ_APPLICATIONS))) + .thenAnswer(invocation -> loggedPermission(invocation.getArgument(1))); + lenient().when(datasourceService.getById(DATASOURCE_ID)).thenAnswer(invocation -> Mono.defer(() -> { + events.add("datasource"); + return Mono.just(datasource); + })); + lenient().when(queryExecutionService.executeQuery(any(), any(), any(), any(), any())).thenAnswer(invocation -> Mono.defer(() -> { + events.add("execute"); + return Mono.just(result); + })); + lenient().when(commonConfig.getDisallowedHosts()).thenReturn(disallowedHosts); + lenient().when(exchange.getRequest()).thenReturn(httpRequest); + lenient().when(httpRequest.getCookies()).thenReturn(cookies); + } + + // ------------------------------------------------------------------ fixtures + + private static void say(String format, Object... args) { + System.out.println(LOG_PREFIX + String.format(format, args)); + } + + private Mono loggedPermission(String resourceId) { + return Mono.defer(() -> { + events.add("permission:" + resourceId); + return Mono.empty(); + }); + } + + private static void assertBizError(Throwable error, BizError expected, String messageKey) { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(expected); + assertThat(((BizException) error).getMessageKey()).isEqualTo(messageKey); + } + + private static QueryExecutionRequest request(String appId, String queryId, boolean viewMode, String[] path) { + QueryExecutionRequest request = new QueryExecutionRequest(); + request.setApplicationId(appId); + request.setQueryId(queryId); + request.setViewMode(viewMode); + request.setPath(path); + return request; + } + + private static QueryExecutionRequest validRequest() { + return request(APP_ID, QUERY_ID, false, null); + } + + private static Application moduleWithId(String id) { + Application module = org.mockito.Mockito.mock(Application.class); + lenient().when(module.getId()).thenReturn(id); + return module; + } + + private QueryVisitorContext capturedContext() { + ArgumentCaptor captor = ArgumentCaptor.forClass(QueryVisitorContext.class); + verify(queryExecutionService).executeQuery(any(), any(), any(), any(), captor.capture()); + return captor.getValue(); + } + + // ------------------------------------------------------------------ argument validation + + /** + * Catches executing a query without a query id: INVALID_PARAMETER with key INVALID_QUERY_ID, and the id is checked + * before the app id (both blank gives INVALID_QUERY_ID), with no lookup and no session read. + */ + @ParameterizedTest(name = "query id [{0}] and app id [{1}]") + @MethodSource("blankQueryIdRows") + void execute_blankQueryId_isInvalidQueryId(String queryId, String appId) { + StepVerifier.create(service.executeApplicationQuery(exchange, request(appId, queryId, false, null))) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PARAMETER, "INVALID_QUERY_ID")) + .verify(); + verifyNoInteractions(applicationService, sessionUserService, resourcePermissionService, queryExecutionService); + say("blank query id [%s] / app id [%s] -> INVALID_QUERY_ID", queryId, appId); + } + + static Stream blankQueryIdRows() { + return Stream.of(Arguments.of(null, APP_ID), Arguments.of("", APP_ID), Arguments.of(" ", APP_ID), + Arguments.of("", ""), Arguments.of(null, null)); + } + + /** Catches executing a query without an app id: INVALID_PARAMETER with key INVALID_APP_ID and nothing looked up. */ + @ParameterizedTest(name = "app id [{0}]") + @NullAndEmptySource + @ValueSource(strings = {" "}) + void execute_blankAppId_isInvalidAppId(String appId) { + StepVerifier.create(service.executeApplicationQuery(exchange, request(appId, QUERY_ID, false, null))) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PARAMETER, "INVALID_APP_ID")) + .verify(); + verifyNoInteractions(applicationService, sessionUserService, resourcePermissionService, queryExecutionService); + say("blank app id [%s] -> INVALID_APP_ID", appId); + } + + // ------------------------------------------------------------------ the read-permission gate + + static Stream permissionRows() { + return Stream.of( + Arguments.of("edit mode, no path", false, null, APP_ID), + Arguments.of("edit mode ignores the path", false, new String[] {ROOT_APP_ID}, APP_ID), + Arguments.of("view mode, null path", true, null, APP_ID), + Arguments.of("view mode, empty path", true, new String[0], APP_ID), + Arguments.of("view mode, blank root", true, new String[] {" "}, APP_ID), + Arguments.of("view mode, root app in the path", true, new String[] {ROOT_APP_ID}, ROOT_APP_ID), + Arguments.of("view mode, only the first path entry is the root", true, new String[] {ROOT_APP_ID, "nested-module"}, ROOT_APP_ID)); + } + + /** + * Catches reading the wrong app's permission: edit mode checks READ_APPLICATIONS on the app id and ignores the path; + * view mode checks it on the root app of the path (when it is not blank and the app is one of its dependent + * modules) and on the app id otherwise. The permission check runs before the datasource is fetched or executed. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("permissionRows") + void execute_checksReadPermissionOnTheRightApp_beforeAnythingElse(String label, boolean viewMode, String[] path, String checkedId) { + List modules = List.of(moduleWithId(ROOT_APP_ID), moduleWithId(APP_ID)); + lenient().when(applicationService.getAllDependentModulesFromApplicationId(ROOT_APP_ID, true)).thenReturn(Mono.just(modules)); + + StepVerifier.create(service.executeApplicationQuery(exchange, request(APP_ID, QUERY_ID, viewMode, path))) + .expectNext(result) + .verifyComplete(); + + assertThat(events).containsExactly("permission:" + checkedId, "datasource", "execute"); + if (!viewMode) { + verify(applicationService, never()).getAllDependentModulesFromApplicationId(anyString(), anyBoolean()); + } + say("%s -> permission checked on %s, events %s", label, checkedId, events); + } + + /** + * Catches a query executed after a failed permission check: the error of the check is the result, and neither the + * datasource is fetched nor the query executed. + */ + @ParameterizedTest(name = "view mode {0}") + @ValueSource(booleans = {false, true}) + void execute_deniedReadPermission_stopsBeforeTheDatasourceAndTheExecution(boolean viewMode) { + BizException denied = new BizException(BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED"); + when(resourcePermissionService.checkResourcePermissionWithError(VISITOR_ID, APP_ID, READ_APPLICATIONS)) + .thenReturn(Mono.defer(() -> { + events.add("permission:" + APP_ID); + return Mono.error(denied); + })); + + StepVerifier.create(service.executeApplicationQuery(exchange, request(APP_ID, QUERY_ID, viewMode, null))) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(denied)) + .verify(); + + assertThat(events).containsExactly("permission:" + APP_ID); + verify(queryExecutionService, never()).executeQuery(any(), any(), any(), any(), any()); + say("denied read permission (view mode %s) -> %s", viewMode, events); + } + + /** + * Regression test of correct behaviour (not a defect pin): a view-mode request whose path names a root app of which + * the requested app is not a dependent module (a forged path) gives INVALID_PARAMETER; the permission is not + * checked on the forged root and nothing is executed. When the app is among the modules, the permission is checked + * on the root. + */ + @Test + void execute_forgedPath_appNotAmongTheRootsModules_isRejected() { + List modules = List.of(moduleWithId(ROOT_APP_ID), moduleWithId("some-other-module")); + when(applicationService.getAllDependentModulesFromApplicationId(ROOT_APP_ID, true)).thenReturn(Mono.just(modules)); + + StepVerifier.create(service.executeApplicationQuery(exchange, request(APP_ID, QUERY_ID, true, new String[] {ROOT_APP_ID}))) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_PARAMETER, "INVALID_PARAMETER")) + .verify(); + + assertThat(events).isEmpty(); + verify(resourcePermissionService, never()).checkResourcePermissionWithError(anyString(), anyString(), any()); + say("forged path -> INVALID_PARAMETER, no permission check, no execution"); + } + + // ------------------------------------------------------------------ the datasource / app tenant check + + private static final int USER_CREATED = DatasourceCreationSource.USER_CREATED.getValue(); + private static final int CLONED = DatasourceCreationSource.CLONE_FROM_TEMPLATE.getValue(); + @SuppressWarnings("deprecation") + private static final int LEGACY = DatasourceCreationSource.LEGACY_WORKSPACE_PREDEFINED.getValue(); + private static final int STATIC = DatasourceCreationSource.SYSTEM_STATIC.getValue(); + private static final String REST_TYPE = org.lowcoder.domain.plugin.DatasourceMetaInfoConstants.REST_API; + + static Stream orgMatchRows() { + return Stream.of( + Arguments.of("user-created REST, other org", USER_CREATED, REST_TYPE, OTHER_ORG, false), + Arguments.of("user-created plugin, other org", USER_CREATED, PLAIN_TYPE, OTHER_ORG, false), + Arguments.of("cloned plugin, other org", CLONED, PLAIN_TYPE, OTHER_ORG, false), + Arguments.of("user-created plugin, no org", USER_CREATED, PLAIN_TYPE, null, false), + Arguments.of("user-created plugin, same org", USER_CREATED, PLAIN_TYPE, APP_ORG, true), + Arguments.of("system static, other org (check skipped)", STATIC, PLAIN_TYPE, OTHER_ORG, true), + Arguments.of("legacy quick REST, other org (check skipped)", LEGACY, REST_TYPE, OTHER_ORG, true), + Arguments.of("legacy lowcoder api, other org (check skipped)", LEGACY, PLAIN_TYPE, OTHER_ORG, true)); + } + + /** + * Catches running a query against another tenant's datasource: a datasource of another organization than the app + * gives DATASOURCE_AND_APP_ORG_NOT_MATCH and the query is not executed; the check is skipped only for system-static, + * legacy quick REST and legacy lowcoder API datasources, and passes for the same organization. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("orgMatchRows") + void execute_datasourceOrganizationMustMatchTheApp_exceptForTheSystemAndLegacyDatasources(String label, int creationSource, + String type, String datasourceOrg, boolean runs) { + datasource.setCreationSource(creationSource); + datasource.setType(type); + datasource.setOrganizationId(datasourceOrg); + + Mono execution = service.executeApplicationQuery(exchange, validRequest()); + + if (runs) { + StepVerifier.create(execution).expectNext(result).verifyComplete(); + assertThat(events).contains("execute"); + } else { + StepVerifier.create(execution) + .expectErrorSatisfies(error -> assertBizError(error, BizError.DATASOURCE_AND_APP_ORG_NOT_MATCH, + "DATASOURCE_AND_APP_ORG_NOT_MATCH")) + .verify(); + assertThat(events).doesNotContain("execute"); + } + say("%s -> runs=%s, events %s", label, runs, events); + } + + // ------------------------------------------------------------------ the base query source + + private static ApplicationQuery libraryQuery(String libraryQueryId, String recordId) { + Map comp = new HashMap<>(); + comp.put("libraryQueryId", libraryQueryId); + comp.put("libraryQueryRecordId", recordId); + return new ApplicationQuery("q-id", "q-gid", "q-name", null, comp, "manual", TIMEOUT, "libraryQuery"); + } + + private static BaseQuery baseQuery(Map config) { + return BaseQuery.builder().datasourceId(DATASOURCE_ID).queryConfig(config).compType("mysql").build(); + } + + private Map executedQueryConfig() { + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(queryExecutionService).executeQuery(any(), captor.capture(), any(), any(), any()); + return captor.getValue(); + } + + /** Catches executing a library query's reference instead of its configuration: the app query's own base query is used. */ + @Test + void execute_ownQuery_usesItsOwnBaseQuery() { + StepVerifier.create(service.executeApplicationQuery(exchange, validRequest())).expectNext(result).verifyComplete(); + + assertThat(executedQueryConfig()).isEqualTo(Map.of("sql", "select 1")); + verifyNoInteractions(libraryQueryService, libraryQueryRecordService); + say("own query: its own config executed, no library lookup"); + } + + /** + * Catches the wrong library version: record id "latest" runs the live base query of the library query and never reads + * a record; any other record id runs the recorded query of that record and never reads the live one. The datasource + * is the library base query's, not the (empty) one of the app query. + */ + @ParameterizedTest(name = "record id {0}") + @ValueSource(strings = {"latest", "record-7"}) + void execute_libraryQuery_usesTheLiveOrTheRecordedVersion(String recordId) { + boolean live = "latest".equals(recordId); + appQuery = libraryQuery("lib-1", recordId); + Map liveConfig = Map.of("sql", "live"); + Map recordedConfig = Map.of("sql", "recorded"); + LibraryQueryRecord record = org.mockito.Mockito.mock(LibraryQueryRecord.class); + lenient().when(record.getQuery()).thenReturn(baseQuery(recordedConfig)); + lenient().when(libraryQueryService.getLiveBaseQueryByLibraryQueryId("lib-1")).thenReturn(Mono.just(baseQuery(liveConfig))); + lenient().when(libraryQueryRecordService.getById("record-7")).thenReturn(Mono.just(record)); + + StepVerifier.create(service.executeApplicationQuery(exchange, validRequest())).expectNext(result).verifyComplete(); + + assertThat(executedQueryConfig()).isSameAs(live ? liveConfig : recordedConfig); + if (live) { + verify(libraryQueryRecordService, never()).getById(anyString()); + } else { + verify(libraryQueryService, never()).getLiveBaseQueryByLibraryQueryId(anyString()); + } + verify(datasourceService).getById(DATASOURCE_ID); + say("library query, record id %s -> %s version executed", recordId, live ? "live" : "recorded"); + } + + /** Catches a query run without its datasource: DATASOURCE_NOT_FOUND carrying the datasource id, nothing executed. */ + @Test + void execute_missingDatasource_isDatasourceNotFound() { + when(datasourceService.getById(DATASOURCE_ID)).thenReturn(Mono.empty()); + + StepVerifier.create(service.executeApplicationQuery(exchange, validRequest())) + .expectErrorSatisfies(error -> { + assertBizError(error, BizError.DATASOURCE_NOT_FOUND, "DATASOURCE_NOT_FOUND"); + assertThat(((BizException) error).getArgs()).containsExactly(DATASOURCE_ID); + }) + .verify(); + verify(queryExecutionService, never()).executeQuery(any(), any(), any(), any(), any()); + say("missing datasource -> DATASOURCE_NOT_FOUND"); + } + + // ------------------------------------------------------------------ what reaches executeQuery + + /** + * Catches a wrong hand-over to the execution: the datasource, the query config, the cleaned request parameters (blank + * keys dropped, keys trimmed, a later duplicate wins, a null value kept), the app query's timeout and a visitor + * context with the visitor id, the app's organization, the injected port, the request cookies and the disallowed + * hosts, and no inherited auth headers for a plain datasource. The view mode reaches the app's query lookup. + */ + @ParameterizedTest(name = "view mode {0}") + @ValueSource(booleans = {false, true}) + void execute_handsTheRightArgumentsToTheExecution(boolean viewMode) { + cookies.add("SESSION", new HttpCookie("SESSION", "abc")); + // a system datasource of another org passes the tenant check, so the context's org id must come from the app + datasource.setCreationSource(STATIC); + datasource.setOrganizationId(OTHER_ORG); + QueryExecutionRequest request = request(APP_ID, QUERY_ID, viewMode, null); + request.setParams(List.of(Param.of(" a ", 1), Param.of("", 2), Param.of(" ", 3), Param.of("b", null), Param.of("a", 4))); + + StepVerifier.create(service.executeApplicationQuery(exchange, request)).expectNext(result).verifyComplete(); + + ArgumentCaptor executedDatasource = ArgumentCaptor.forClass(Datasource.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> params = ArgumentCaptor.forClass(Map.class); + ArgumentCaptor timeout = ArgumentCaptor.forClass(String.class); + ArgumentCaptor context = ArgumentCaptor.forClass(QueryVisitorContext.class); + verify(queryExecutionService).executeQuery(executedDatasource.capture(), eq(appQuery.getBaseQuery().getQueryConfig()), + params.capture(), timeout.capture(), context.capture()); + verify(app).getQueryByViewModeAndQueryId(viewMode, QUERY_ID, applicationRecordService); + + assertThat(executedDatasource.getValue()).isSameAs(datasource); + assertThat(params.getValue()).containsOnlyKeys("a", "b").containsEntry("a", 4).containsEntry("b", null); + assertThat(timeout.getValue()).isEqualTo(TIMEOUT); + QueryVisitorContext visitorContext = context.getValue(); + assertThat(visitorContext.getVisitorId()).isEqualTo(VISITOR_ID); + assertThat(visitorContext.getApplicationOrgId()).isEqualTo(APP_ORG); + assertThat(visitorContext.getSystemPort()).isEqualTo(PORT); + assertThat(visitorContext.getCookies()).isSameAs(cookies); + assertThat(visitorContext.getDisallowedHosts()).isSameAs(disallowedHosts); + assertThat(visitorContext.getAuthTokenMono().block()).isNull(); + say("hand-over verified for view mode %s: params %s", viewMode, params.getValue()); + } + + /** Catches a swallowed execution failure: the error of {@code executeQuery} is the result. */ + @Test + void execute_failingExecution_propagatesTheError() { + IllegalStateException failure = new IllegalStateException("plugin failed"); + when(queryExecutionService.executeQuery(any(), anyMap(), anyMap(), any(), any())).thenReturn(Mono.error(failure)); + + StepVerifier.create(service.executeApplicationQuery(exchange, validRequest())) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(failure)) + .verify(); + say("failing execution -> the plugin error is the result"); + } + + // ------------------------------------------------------------------ OAuth inherited from login + + private enum Kind { + REST, GRAPHQL, JS + } + + private static DatasourceConnectionConfig oauthConfig(Kind kind, String authId) { + return switch (kind) { + case REST -> RestApiDatasourceConfig.builder().authConfig(oauthAuth(authId)).build(); + case GRAPHQL -> GraphQLDatasourceConfig.builder().authConfig(oauthAuth(authId)).build(); + case JS -> { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + HashMap authConfig = new HashMap<>(); + authConfig.put("type", OAUTH_TYPE_NAME); + if (authId != null) { + authConfig.put("authId", authId); + } + config.put("authConfig", authConfig); + yield config; + } + }; + } + + private static OAuthInheritAuthConfig oauthAuth(String authId) { + return OAuthInheritAuthConfig.builder().authId(authId).type(RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN).build(); + } + + private static Connection connection(String authId, String accessToken) { + return Connection.builder().authId(authId).source("src-" + authId).rawId("raw-" + authId) + .authConnectionAuthToken(accessToken == null ? null : ConnectionAuthToken.builder().accessToken(accessToken).build()) + .build(); + } + + private void visitorWithConnections(String activeAuthId, Connection... connections) { + visitor.setActiveAuthId(activeAuthId); + visitor.setConnections(new LinkedHashSet<>(List.of(connections))); + } + + private static Property expectedHeader(Kind kind, String token) { + return kind == Kind.JS ? new Property("OAUTH_ACCESS_TOKEN", token, "header") + : new Property("Authorization", "Bearer " + token, "header"); + } + + /** Runs the query and returns the auth properties handed to the executor (null when none). */ + private List inheritedProperties() { + StepVerifier.create(service.executeApplicationQuery(exchange, validRequest())).expectNext(result).verifyComplete(); + return capturedContext().getAuthTokenMono().block(); + } + + static Stream oauthRows() { + List rows = new ArrayList<>(); + for (Kind kind : Kind.values()) { + rows.add(Arguments.of(kind, "configured authId picks that connection's token, not the other's", "auth-A", "auth-B", "tA")); + rows.add(Arguments.of(kind, "empty authId falls back to the active auth id", "", "auth-B", "tB")); + rows.add(Arguments.of(kind, "null authId gives no headers even if a connection is active", null, "auth-A", null)); + rows.add(Arguments.of(kind, "unknown authId gives no headers", "auth-X", "auth-A", null)); + rows.add(Arguments.of(kind, "empty authId and no active auth id gives no headers", "", null, null)); + } + return rows.stream(); + } + + /** + * Catches the token of the wrong connection, or a leaked/absent token: for a datasource that inherits OAuth from + * login (REST, GraphQL, JS), the connection whose auth id matches the configured one (the active auth id when it is + * empty, nothing when it is null) supplies the header: {@code Authorization: Bearer } for REST and GraphQL, + * {@code OAUTH_ACCESS_TOKEN} for JS; no match gives no properties. + */ + @ParameterizedTest(name = "{0}: {1}") + @MethodSource("oauthRows") + void oauthInherit_picksTheRightConnectionsToken(Kind kind, String label, String configuredAuthId, String activeAuthId, String expectedToken) { + datasource.setDetailConfig(oauthConfig(kind, configuredAuthId)); + visitorWithConnections(activeAuthId, connection("auth-A", "tA"), connection("auth-B", "tB")); + + List properties = inheritedProperties(); + + if (expectedToken == null) { + assertThat(properties).isNull(); + } else { + assertThat(properties).containsExactly(expectedHeader(kind, expectedToken)); + } + say("%s %s -> %s", kind, label, properties); + } + + /** Catches a header built from a connection that holds no token: the matching connection without a token gives no properties. */ + @ParameterizedTest + @EnumSource(Kind.class) + void oauthInherit_matchingConnectionWithoutToken_givesNoHeaders(Kind kind) { + datasource.setDetailConfig(oauthConfig(kind, "auth-A")); + visitorWithConnections("auth-A", connection("auth-A", null), connection("auth-B", "tB")); + + assertThat(inheritedProperties()).isNull(); + say("%s matching connection without token -> no headers", kind); + } + + static Stream notInheritingRows() { + List rows = new ArrayList<>(); + rows.add(Arguments.of("REST without auth config", RestApiDatasourceConfig.builder().build())); + rows.add(Arguments.of("GraphQL without auth config", GraphQLDatasourceConfig.builder().build())); + rows.add(Arguments.of("JS without auth config", new JsDatasourceConnectionConfig())); + rows.add(Arguments.of("no detail config", null)); + return rows.stream(); + } + + /** Catches headers attached to a datasource that does not inherit OAuth from login: none are handed to the executor. */ + @ParameterizedTest(name = "{0}") + @MethodSource("notInheritingRows") + void oauthInherit_datasourceNotInheritingFromLogin_getsNoHeaders(String label, DatasourceConnectionConfig config) { + datasource.setDetailConfig(config); + visitorWithConnections("auth-A", connection("auth-A", "tA")); + + assertThat(inheritedProperties()).isNull(); + say("%s -> no headers", label); + } + + /** + * Pins the plan section 9 row "UserSessionPersistenceFilter: connection.getAuthId().equals(...) NPE for a connection + * with a null authId" (same defect family, this call site added by the coordinator): when a visitor has a connection + * with a null auth id (legacy data) that comes before the matching one, every inherit-from-login query of that user + * fails with a NullPointerException. When the matching connection comes first the stream short-circuits and the query + * runs, so the failure depends on the connection order. A fix (null-safe comparison) changes this test on purpose. + */ + @ParameterizedTest + @EnumSource(Kind.class) + void oauthInherit_connectionWithNullAuthIdBeforeTheMatch_isANullPointerException_pinsSection9Row(Kind kind) { + datasource.setDetailConfig(oauthConfig(kind, "auth-A")); + visitorWithConnections("auth-A", connection(null, "t0"), connection("auth-A", "tA")); + + StepVerifier.create(service.executeApplicationQuery(exchange, validRequest())) + .expectError(NullPointerException.class) + .verify(); + verify(queryExecutionService, never()).executeQuery(any(), any(), any(), any(), any()); + + visitorWithConnections("auth-A", connection("auth-A", "tA"), connection(null, "t0")); + events.clear(); + assertThat(inheritedProperties()).containsExactly(expectedHeader(kind, "tA")); + say("%s: null-authId connection first -> NullPointerException (section 9 row pinned); matching first -> works", kind); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/LibraryQueryApiServiceImplTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/LibraryQueryApiServiceImplTest.java new file mode 100644 index 0000000000..cdbd6837dd --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/LibraryQueryApiServiceImplTest.java @@ -0,0 +1,1045 @@ +package org.lowcoder.api.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.domain.permission.model.ResourceAction.USE_DATASOURCES; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.api.query.view.LibraryQueryAggregateView; +import org.lowcoder.api.query.view.LibraryQueryPublishRequest; +import org.lowcoder.api.query.view.LibraryQueryRequestFromJs; +import org.lowcoder.api.query.view.LibraryQueryView; +import org.lowcoder.api.query.view.QueryExecutionRequest; +import org.lowcoder.api.query.view.UpsertLibraryQueryRequest; +import org.lowcoder.api.usermanagement.OrgDevChecker; +import org.lowcoder.api.util.BusinessEventPublisher; +import org.lowcoder.domain.authentication.AuthenticationService; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.service.DatasourceService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.query.model.BaseQuery; +import org.lowcoder.domain.query.model.LibraryQuery; +import org.lowcoder.domain.query.model.LibraryQueryRecord; +import org.lowcoder.domain.query.service.LibraryQueryRecordService; +import org.lowcoder.domain.query.service.LibraryQueryService; +import org.lowcoder.domain.query.service.QueryExecutionService; +import org.lowcoder.domain.user.model.Connection; +import org.lowcoder.domain.user.model.ConnectionAuthToken; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.DatasourceConnectionConfig; +import org.lowcoder.sdk.models.JsDatasourceConnectionConfig; +import org.lowcoder.sdk.models.Param; +import org.lowcoder.sdk.models.Property; +import org.lowcoder.sdk.models.QueryExecutionResult; +import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig; +import org.lowcoder.sdk.plugin.restapi.auth.OAuthInheritAuthConfig; +import org.lowcoder.sdk.plugin.restapi.auth.RestApiAuthType; +import org.lowcoder.sdk.query.QueryVisitorContext; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpCookie; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@link LibraryQueryApiServiceImpl} with every collaborator mocked: the datasource-based visibility of library + * queries, the management and view permission checks, the mutations, and the two execution flows (editing version by + * id, and from JS by name), plus {@link LibraryQueryRequestFromJs#paramMap()}. + * + *

    Pinned production defect (owner decision D-6: fixes are deferred, a fix changes these tests on purpose): the plan + * section 9 row (tenant isolation) "LibraryQueryApiServiceImpl does not compare the library query's org with the + * visitor's on read or run", at three sites: + *

      + *
    • {@link #get_libraryQueryOfAnotherOrganization_isReturnedWithItsDsl_pinsSection9Row}
    • + *
    • {@link #executeLibraryQuery_libraryQueryOfAnotherOrganization_runsOnThatOrganizationsDatasource_pinsSection9Row}
    • + *
    • {@link #executeFromJs_libraryQueryOfAnotherOrganization_runsOnThatOrganizationsDatasource_pinsSection9Row}
    • + *
    + * Also pinned under the existing section 9 row "connection.getAuthId().equals(...) NPE for a connection with a null + * authId": {@link #oauthInherit_connectionWithNullAuthIdBeforeTheMatch_isANullPointerException_pinsSection9Row}. + */ +@ExtendWith(MockitoExtension.class) +class LibraryQueryApiServiceImplTest { + + private static final String LOG_PREFIX = "[LibraryQueryApiServiceImplTest] "; + + private static final String ORG = "org-1"; + private static final String OTHER_ORG = "org-other"; + private static final String VISITOR_ID = "visitor-1"; + private static final String MEMBER_USER_ID = "member-user"; + private static final String CREATOR_ID = "creator-1"; + private static final String CREATOR_NAME = "Creator"; + private static final String LQ_ID = "lq-1"; + private static final String DS_ID = "ds-1"; + private static final String DS_PERMITTED = "ds-permitted"; + private static final String DS_DENIED = "ds-denied"; + private static final String DS_FOREIGN = "ds-foreign"; + private static final int PORT = 18081; + private static final Instant CREATED_AT = Instant.ofEpochMilli(1_700_000_000_000L); + private static final String OAUTH_TYPE_NAME = RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN.name(); + + @Mock + private LibraryQueryService libraryQueryService; + @Mock + private LibraryQueryRecordService libraryQueryRecordService; + @Mock + private UserService userService; + @Mock + private OrgDevChecker orgDevChecker; + @Mock + private SessionUserService sessionUserService; + @Mock + private QueryExecutionService queryExecutionService; + @Mock + private DatasourceService datasourceService; + @Mock + private BusinessEventPublisher businessEventPublisher; + @Mock + private ResourcePermissionService resourcePermissionService; + @Mock + private CommonConfig commonConfig; + @Mock + private AuthenticationService authenticationService; + @Mock + private ServerWebExchange exchange; + @Mock + private ServerHttpRequest httpRequest; + + private LibraryQueryApiServiceImpl service; + + private final List events = new ArrayList<>(); + private final MultiValueMap cookies = new LinkedMultiValueMap<>(); + private final java.util.Set disallowedHosts = java.util.Set.of("169.254.169.254"); + private final QueryExecutionResult result = QueryExecutionResult.success("rows"); + private final User visitor = new User(); + private final User creator = new User(); + private Datasource datasource; + + @BeforeEach + void setUp() { + service = new LibraryQueryApiServiceImpl(libraryQueryService, libraryQueryRecordService, userService, orgDevChecker, + sessionUserService, queryExecutionService, datasourceService, businessEventPublisher, resourcePermissionService, + commonConfig, authenticationService); + ReflectionTestUtils.setField(service, "port", PORT); + + visitor.setId(VISITOR_ID); + creator.setId(CREATOR_ID); + creator.setName(CREATOR_NAME); + datasource = new Datasource(); + datasource.setId(DS_ID); + datasource.setOrganizationId(ORG); + + lenient().when(orgDevChecker.checkCurrentOrgDev()).thenAnswer(invocation -> loggedVoid("dev")); + lenient().when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(member(ORG))); + lenient().when(sessionUserService.getVisitorId()).thenReturn(Mono.just(VISITOR_ID)); + lenient().when(sessionUserService.getVisitor()).thenReturn(Mono.just(visitor)); + lenient().when(commonConfig.getDisallowedHosts()).thenReturn(disallowedHosts); + lenient().when(exchange.getRequest()).thenReturn(httpRequest); + lenient().when(httpRequest.getCookies()).thenReturn(cookies); + lenient().when(datasourceService.getById(DS_ID)).thenAnswer(invocation -> logged("datasource", datasource)); + lenient().when(queryExecutionService.executeQuery(any(), any(), any(), any(), any())) + .thenAnswer(invocation -> logged("execute", result)); + lenient().when(userService.findById(CREATOR_ID)).thenReturn(Mono.just(creator)); + } + + // ------------------------------------------------------------------ fixtures + + private static void say(String format, Object... args) { + System.out.println(LOG_PREFIX + String.format(format, args)); + } + + private static OrgMember member(String orgId) { + return new OrgMember(orgId, MEMBER_USER_ID, MemberRole.MEMBER, "normal", 0L); + } + + private Mono loggedVoid(String event) { + return Mono.defer(() -> { + events.add(event); + return Mono.empty(); + }); + } + + private Mono logged(String event, T value) { + return Mono.defer(() -> { + events.add(event); + return Mono.justOrEmpty(value); + }); + } + + private static void assertBizError(Throwable error, BizError expected, String messageKey) { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(expected); + assertThat(((BizException) error).getMessageKey()).isEqualTo(messageKey); + } + + private static Map dsl(String datasourceId, Map comp, String timeout) { + Map query = new HashMap<>(); + if (datasourceId != null) { + query.put("datasourceId", datasourceId); + } + query.put("comp", comp); + query.put("compType", "mysql"); + if (timeout != null) { + query.put("timeout", timeout); + } + return Map.of("query", query); + } + + private static LibraryQuery libraryQuery(String id, String orgId, String name, String datasourceId, String createdBy) { + LibraryQuery query = LibraryQuery.builder().organizationId(orgId).name(name) + .libraryQueryDSL(dsl(datasourceId, Map.of("sql", name), null)).build(); + query.setId(id); + query.setGid("gid-" + id); + query.setCreatedBy(createdBy); + query.setCreatedAt(CREATED_AT); + return query; + } + + private static BaseQuery baseQuery(Map config, String timeout) { + return BaseQuery.builder().datasourceId(DS_ID).queryConfig(config).compType("mysql").timeoutStr(timeout).build(); + } + + private static LibraryQueryRecord record(String id, String libraryQueryId, String tag) { + LibraryQueryRecord record = LibraryQueryRecord.builder().libraryQueryId(libraryQueryId).tag(tag).commitMessage("msg-" + tag) + .libraryQueryDSL(dsl(DS_ID, Map.of("sql", "recorded"), null)).build(); + record.setId(id); + record.setCreatedAt(CREATED_AT); + record.setCreatedBy(CREATOR_ID); + return record; + } + + private QueryVisitorContext capturedContext() { + ArgumentCaptor captor = ArgumentCaptor.forClass(QueryVisitorContext.class); + verify(queryExecutionService).executeQuery(any(), any(), any(), any(), captor.capture()); + return captor.getValue(); + } + + // ------------------------------------------------------------------ catalogue visibility (list and drop-down) + + private final LibraryQuery alpha = libraryQuery("lq-alpha", ORG, "alpha-perm", DS_PERMITTED, CREATOR_ID); + private final LibraryQuery beta = libraryQuery("lq-beta", ORG, "beta-denied", DS_DENIED, CREATOR_ID); + private final LibraryQuery gamma = libraryQuery("lq-gamma", ORG, "gamma-foreign", DS_FOREIGN, CREATOR_ID); + private final LibraryQuery delta = libraryQuery("lq-delta", ORG, "delta-nods", null, CREATOR_ID); + private final LibraryQuery epsilon = libraryQuery("lq-epsilon", ORG, "epsilon-blank", "", CREATOR_ID); + private final LibraryQuery zeta = libraryQuery("lq-zeta", ORG, "zeta-ghost", DS_PERMITTED, "ghost"); + + /** Datasource ids of the catalogue in order, without the blank ones: what the permission filters are asked about. */ + private final List catalogueDatasourceIds = List.of(DS_PERMITTED, DS_DENIED, DS_FOREIGN, DS_PERMITTED); + + private void stubCatalogue() { + when(libraryQueryService.getByOrganizationId(ORG)).thenReturn(Flux.just(alpha, beta, gamma, delta, epsilon, zeta)); + when(resourcePermissionService.filterResourceWithPermission(VISITOR_ID, catalogueDatasourceIds, USE_DATASOURCES)) + .thenReturn(Flux.just(DS_PERMITTED)); + when(datasourceService.retainNoneExistAndNonCurrentOrgDatasourceIds(catalogueDatasourceIds, ORG)) + .thenReturn(Flux.just(DS_FOREIGN)); + lenient().when(userService.getByIds(any())).thenReturn(Mono.just(Map.of(CREATOR_ID, creator))); + } + + static Stream nameFilterRows() { + return Stream.of( + Arguments.of("", List.of("alpha-perm", "gamma-foreign")), + Arguments.of("PERM", List.of("alpha-perm")), + Arguments.of("FOREIGN", List.of("gamma-foreign")), + Arguments.of("denied", List.of()), + Arguments.of("nods", List.of()), + Arguments.of("zzz", List.of())); + } + + /** + * Catches showing a library query whose datasource the visitor may not use: the visitor's own organization is + * listed; a query is kept when its datasource is USE_DATASOURCES-permitted or does not exist / is not of the + * current organization, hidden when not permitted or without a datasource id; the name filter ignores case; the + * views carry the creator's name, and a query whose creator no longer resolves is dropped from this list. + */ + @ParameterizedTest(name = "name filter [{0}]") + @MethodSource("nameFilterRows") + void listLibraryQueries_showsOnlyQueriesWithAUsableDatasource_matchingTheName(String name, List expectedNames) { + stubCatalogue(); + + StepVerifier.create(service.listLibraryQueries(name)) + .assertNext(views -> { + assertThat(views).extracting(LibraryQueryView::name).containsExactlyElementsOf(expectedNames); + assertThat(views).allSatisfy(view -> assertThat(view.creatorName()).isEqualTo(CREATOR_NAME)); + assertThat(views).allSatisfy(view -> assertThat(view.organizationId()).isEqualTo(ORG)); + }) + .verifyComplete(); + say("list [%s] -> %s", name, expectedNames); + } + + /** Catches listing without the developer check: a failing check is the result and no library query is read. */ + @Test + void listLibraryQueries_deniedDevCheck_readsNothing() { + BizException denied = new BizException(BizError.NEED_DEV_TO_CREATE_RESOURCE, "NEED_DEV_TO_CREATE_RESOURCE"); + when(orgDevChecker.checkCurrentOrgDev()).thenReturn(Mono.error(denied)); + + StepVerifier.create(service.listLibraryQueries("")).expectErrorSatisfies(error -> assertThat(error).isSameAs(denied)).verify(); + + verifyNoInteractions(libraryQueryService, resourcePermissionService); + say("list: dev check denied -> nothing read"); + } + + /** + * Catches the drop-down mixing records between queries or leaking hidden ones: the same visibility and name filter + * as the list, no developer check, a query without records gives a view with a null record list, a query with + * records the record meta views, and a query whose creator does not resolve stays (creator name null). + */ + @ParameterizedTest(name = "name filter [{0}]") + @ValueSource(strings = {"", "FOREIGN", "ghost"}) + void dropDownList_sameVisibility_attachesRecordsPerQuery_andKeepsQueriesWithoutCreator(String name) { + stubCatalogue(); + List visible = Stream.of(alpha, gamma, zeta) + .filter(query -> query.getName().toLowerCase().contains(name.toLowerCase())).toList(); + when(libraryQueryRecordService.getByLibraryQueryIdIn(visible.stream().map(LibraryQuery::getId).toList())) + .thenReturn(Mono.just(Map.of("lq-alpha", List.of(record("rec-1", "lq-alpha", "v1"))))); + + StepVerifier.create(service.dropDownList(name)) + .assertNext(views -> { + assertThat(views).extracting(view -> view.libraryQueryMetaView().name()) + .containsExactlyElementsOf(visible.stream().map(LibraryQuery::getName).toList()); + for (LibraryQueryAggregateView view : views) { + String viewName = view.libraryQueryMetaView().name(); + assertThat(view.libraryQueryMetaView().creatorName()).isEqualTo(viewName.contains("ghost") ? null : CREATOR_NAME); + if (viewName.equals("alpha-perm")) { + assertThat(view.recordMetaViewList()).hasSize(1); + assertThat(view.recordMetaViewList().get(0).tag()).isEqualTo("v1"); + assertThat(view.recordMetaViewList().get(0).libraryQueryId()).isEqualTo("lq-alpha"); + } else { + assertThat(view.recordMetaViewList()).isNull(); + } + } + }) + .verifyComplete(); + verify(orgDevChecker, never()).checkCurrentOrgDev(); + say("dropDown [%s] -> %s", name, visible.stream().map(LibraryQuery::getName).toList()); + } + + // ------------------------------------------------------------------ get + + /** Catches a wrong view of a library query: it carries the stored fields and the creator's name. */ + @Test + void get_returnsTheViewWithTheCreatorsName() { + LibraryQuery stored = libraryQuery(LQ_ID, ORG, "stored", DS_ID, CREATOR_ID); + when(libraryQueryService.getById(LQ_ID)).thenReturn(Mono.just(stored)); + + StepVerifier.create(service.get(LQ_ID)) + .assertNext(view -> { + assertThat(view.id()).isEqualTo(LQ_ID); + assertThat(view.gid()).isEqualTo("gid-" + LQ_ID); + assertThat(view.name()).isEqualTo("stored"); + assertThat(view.organizationId()).isEqualTo(ORG); + assertThat(view.creatorName()).isEqualTo(CREATOR_NAME); + assertThat(view.createTime()).isEqualTo(CREATED_AT.toEpochMilli()); + }) + .verifyComplete(); + say("get: view built"); + } + + /** Pins today's behaviour: an unknown query propagates the service error, a creator that does not resolve gives an empty result. */ + @Test + void get_unknownQueryIsAnError_unresolvedCreatorGivesEmpty() { + BizException notFound = new BizException(BizError.LIBRARY_QUERY_NOT_FOUND, "LIBRARY_QUERY_NOT_FOUND"); + when(libraryQueryService.getById("missing")).thenReturn(Mono.error(notFound)); + StepVerifier.create(service.get("missing")).expectErrorSatisfies(error -> assertThat(error).isSameAs(notFound)).verify(); + + when(libraryQueryService.getById("orphan")).thenReturn(Mono.just(libraryQuery("orphan", ORG, "orphan", DS_ID, "ghost"))); + when(userService.findById("ghost")).thenReturn(Mono.empty()); + StepVerifier.create(service.get("orphan")).verifyComplete(); + say("get: unknown query -> error, unresolved creator -> empty"); + } + + /** + * Pins the plan section 9 row (tenant isolation) "LibraryQueryApiServiceImpl does not compare the library query's org + * with the visitor's on read or run": a library query of another organization is returned to the visitor with its + * DSL (datasource id and query text), and neither the developer check nor the visitor's organization is consulted. + * {@code checkLibraryQueryViewPermission} exists but only the record service uses it. A fix changes this test on + * purpose. + */ + @Test + void get_libraryQueryOfAnotherOrganization_isReturnedWithItsDsl_pinsSection9Row() { + LibraryQuery foreign = libraryQuery("lq-foreign", OTHER_ORG, "secret-query", DS_FOREIGN, CREATOR_ID); + when(libraryQueryService.getById("lq-foreign")).thenReturn(Mono.just(foreign)); + + StepVerifier.create(service.get("lq-foreign")) + .assertNext(view -> { + assertThat(view.organizationId()).isEqualTo(OTHER_ORG); + assertThat(view.libraryQueryDSL()).isSameAs(foreign.getLibraryQueryDSL()); + }) + .verifyComplete(); + + verifyNoInteractions(orgDevChecker, sessionUserService); + say("get: another org's library query returned, no org check (section 9 row pinned)"); + } + + // ------------------------------------------------------------------ management permission and mutations + + private enum Operation { + CREATE, UPDATE, DELETE, PUBLISH + } + + private enum Gate { + DEV_DENIED, ORG_MISMATCH, ALLOWED + } + + private LibraryQuery stored; + private BizException devDenied; + + private void stubMutations(String storedOrg) { + stored = libraryQuery(LQ_ID, storedOrg, "stored", DS_ID, CREATOR_ID); + lenient().when(libraryQueryService.getById(LQ_ID)).thenReturn(Mono.just(stored)); + lenient().when(libraryQueryService.insert(any(LibraryQuery.class))).thenAnswer(invocation -> logged("insert", stored)); + lenient().when(libraryQueryService.update(eq(LQ_ID), any(LibraryQuery.class))).thenAnswer(invocation -> logged("update", true)); + lenient().when(libraryQueryService.delete(LQ_ID)).thenAnswer(invocation -> Mono.defer(() -> { + events.add("delete"); + return Mono.empty(); + })); + lenient().when(libraryQueryRecordService.deleteAllLibraryQueryTagByLibraryQueryId(LQ_ID)) + .thenAnswer(invocation -> logged("deleteRecords", 1L)); + lenient().when(libraryQueryRecordService.insert(any(LibraryQueryRecord.class))) + .thenAnswer(invocation -> logged("insertRecord", record("rec-new", LQ_ID, "tag"))); + } + + private Mono invoke(Operation operation) { + UpsertLibraryQueryRequest update = new UpsertLibraryQueryRequest(); + update.setName("renamed"); + update.setLibraryQueryDSL(dsl(DS_ID, Map.of("sql", "new"), null)); + return switch (operation) { + case CREATE -> service.create(stored); + case UPDATE -> service.update(LQ_ID, update); + case DELETE -> service.delete(LQ_ID); + case PUBLISH -> service.publish(LQ_ID, new LibraryQueryPublishRequest("first release", "v1")); + }; + } + + private static List expectedMutationEvents(Operation operation) { + return switch (operation) { + case CREATE -> List.of("dev", "insert"); + case UPDATE -> List.of("dev", "update"); + case DELETE -> List.of("dev", "delete", "deleteRecords"); + case PUBLISH -> List.of("dev", "insertRecord"); + }; + } + + static Stream gateRows() { + List rows = new ArrayList<>(); + for (Operation operation : Operation.values()) { + for (Gate gate : Gate.values()) { + rows.add(Arguments.of(operation, gate)); + } + } + return rows.stream(); + } + + /** + * Catches editing another organization's library query: create, update, delete and publish run the developer check + * first, then compare the visitor's organization with the one of the entity (the new entity for create, the stored + * query for the others); a failing check or a mismatch (LIBRARY_QUERY_AND_ORG_NOT_MATCH) stops everything and + * nothing is inserted, updated or deleted (counted subscriptions); a match performs exactly the mutation. + */ + @ParameterizedTest(name = "{0}, {1}") + @MethodSource("gateRows") + void mutation_requiresADeveloperOfTheQuerysOrganization_beforeAnyChange(Operation operation, Gate gate) { + stubMutations(gate == Gate.ORG_MISMATCH ? OTHER_ORG : ORG); + if (gate == Gate.DEV_DENIED) { + devDenied = new BizException(BizError.NEED_DEV_TO_CREATE_RESOURCE, "NEED_DEV_TO_CREATE_RESOURCE"); + when(orgDevChecker.checkCurrentOrgDev()).thenAnswer(invocation -> Mono.defer(() -> { + events.add("dev"); + return Mono.error(devDenied); + })); + } + + switch (gate) { + case DEV_DENIED -> { + StepVerifier.create(invoke(operation)).expectErrorSatisfies(error -> assertThat(error).isSameAs(devDenied)).verify(); + assertThat(events).containsExactly("dev"); + } + case ORG_MISMATCH -> { + StepVerifier.create(invoke(operation)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.LIBRARY_QUERY_AND_ORG_NOT_MATCH, "LIBRARY_QUERY_AND_ORG_NOT_MATCH")) + .verify(); + assertThat(events).containsExactly("dev"); + } + default -> { + StepVerifier.create(invoke(operation)).expectNextCount(operation == Operation.DELETE ? 0 : 1).verifyComplete(); + assertThat(events).isEqualTo(expectedMutationEvents(operation)); + } + } + say("%s, %s -> events %s", operation, gate, events); + } + + /** + * Catches wrong mutation payloads: create returns the view with the creator's name; update hands only the new name + * and DSL (no organization id) to the service and returns its result; delete removes the query and then its records, + * both by id, and ends empty; publish builds the record from tag, commit message, the library query's id and its + * stored DSL and returns the record meta view with the creator's name. + */ + @Test + void mutations_handTheRightPayloads() { + stubMutations(ORG); + + StepVerifier.create(service.create(stored)).assertNext(view -> { + assertThat(view.id()).isEqualTo(LQ_ID); + assertThat(view.creatorName()).isEqualTo(CREATOR_NAME); + }).verifyComplete(); + + UpsertLibraryQueryRequest request = new UpsertLibraryQueryRequest(); + request.setName("renamed"); + request.setLibraryQueryDSL(dsl(DS_ID, Map.of("sql", "new"), null)); + StepVerifier.create(service.update(LQ_ID, request)).expectNext(true).verifyComplete(); + ArgumentCaptor updated = ArgumentCaptor.forClass(LibraryQuery.class); + verify(libraryQueryService).update(eq(LQ_ID), updated.capture()); + assertThat(updated.getValue().getName()).isEqualTo("renamed"); + assertThat(updated.getValue().getLibraryQueryDSL()).isSameAs(request.getLibraryQueryDSL()); + assertThat(updated.getValue().getOrganizationId()).isNull(); + + events.clear(); + StepVerifier.create(service.delete(LQ_ID)).verifyComplete(); + assertThat(events).containsExactly("dev", "delete", "deleteRecords"); + + StepVerifier.create(service.publish(LQ_ID, new LibraryQueryPublishRequest("first release", "v1"))) + .assertNext(view -> { + assertThat(view.id()).isEqualTo("rec-new"); + assertThat(view.creatorName()).isEqualTo(CREATOR_NAME); + }) + .verifyComplete(); + ArgumentCaptor inserted = ArgumentCaptor.forClass(LibraryQueryRecord.class); + verify(libraryQueryRecordService).insert(inserted.capture()); + assertThat(inserted.getValue().getTag()).isEqualTo("v1"); + assertThat(inserted.getValue().getCommitMessage()).isEqualTo("first release"); + assertThat(inserted.getValue().getLibraryQueryId()).isEqualTo(LQ_ID); + assertThat(inserted.getValue().getLibraryQueryDSL()).isSameAs(stored.getLibraryQueryDSL()); + say("mutation payloads verified"); + } + + // ------------------------------------------------------------------ view permission + + /** + * Catches reading another organization's library query through the record flow: the view check compares the + * visitor's organization with the stored query's (no developer check), a mismatch is + * LIBRARY_QUERY_AND_ORG_NOT_MATCH and an unknown query propagates the service error. + */ + @Test + void checkLibraryQueryViewPermission_sameOrgPasses_otherOrgIsRefused_unknownQueryPropagates() { + when(libraryQueryService.getById(LQ_ID)).thenReturn(Mono.just(libraryQuery(LQ_ID, ORG, "mine", DS_ID, CREATOR_ID))); + StepVerifier.create(service.checkLibraryQueryViewPermission(LQ_ID)).verifyComplete(); + + when(libraryQueryService.getById("lq-foreign")).thenReturn(Mono.just(libraryQuery("lq-foreign", OTHER_ORG, "theirs", DS_ID, CREATOR_ID))); + StepVerifier.create(service.checkLibraryQueryViewPermission("lq-foreign")) + .expectErrorSatisfies(error -> assertBizError(error, BizError.LIBRARY_QUERY_AND_ORG_NOT_MATCH, "LIBRARY_QUERY_AND_ORG_NOT_MATCH")) + .verify(); + + BizException notFound = new BizException(BizError.LIBRARY_QUERY_NOT_FOUND, "LIBRARY_QUERY_NOT_FOUND"); + when(libraryQueryService.getById("missing")).thenReturn(Mono.error(notFound)); + StepVerifier.create(service.checkLibraryQueryViewPermission("missing")).expectErrorSatisfies(error -> assertThat(error).isSameAs(notFound)).verify(); + + verify(orgDevChecker, never()).checkCurrentOrgDev(); + say("view permission: same org ok, other org refused, unknown propagates"); + } + + // ------------------------------------------------------------------ executeLibraryQuery (editing version, by id) + + private QueryExecutionRequest editingRequest(String libraryQueryId) { + QueryExecutionRequest request = new QueryExecutionRequest(); + request.setLibraryQueryId(libraryQueryId); + return request; + } + + /** + * Catches executing without the developer check or against the wrong query: the check runs first (denied: nothing is + * fetched or executed), the editing base query of the request's library query id is run on the datasource named by + * that base query, and a missing datasource is DATASOURCE_NOT_FOUND with the datasource id. + */ + @Test + void executeLibraryQuery_checksDevFirst_runsTheEditingQuery_missingDatasourceIsNotFound() { + Map config = Map.of("sql", "editing"); + when(libraryQueryService.getEditingBaseQueryByLibraryQueryId(LQ_ID)).thenReturn(Mono.just(baseQuery(config, "7s"))); + + StepVerifier.create(service.executeLibraryQuery(exchange, editingRequest(LQ_ID))).expectNext(result).verifyComplete(); + assertThat(events).containsExactly("dev", "datasource", "execute"); + + events.clear(); + BizException denied = new BizException(BizError.NEED_DEV_TO_CREATE_RESOURCE, "NEED_DEV_TO_CREATE_RESOURCE"); + when(orgDevChecker.checkCurrentOrgDev()).thenAnswer(invocation -> Mono.defer(() -> { + events.add("dev"); + return Mono.error(denied); + })); + StepVerifier.create(service.executeLibraryQuery(exchange, editingRequest(LQ_ID))) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(denied)).verify(); + assertThat(events).containsExactly("dev"); + + events.clear(); + when(orgDevChecker.checkCurrentOrgDev()).thenAnswer(invocation -> loggedVoid("dev")); + when(datasourceService.getById(DS_ID)).thenReturn(Mono.empty()); + StepVerifier.create(service.executeLibraryQuery(exchange, editingRequest(LQ_ID))) + .expectErrorSatisfies(error -> { + assertBizError(error, BizError.DATASOURCE_NOT_FOUND, "DATASOURCE_NOT_FOUND"); + assertThat(((BizException) error).getArgs()).containsExactly(DS_ID); + }) + .verify(); + assertThat(events).containsExactly("dev"); + say("executeLibraryQuery: dev first, editing query run, missing datasource"); + } + + /** + * Catches a wrong hand-over to the execution: datasource, query config, the cleaned request parameters, the base + * query's timeout and a context with the organization member's user and org id (not the visitor's), the injected + * port, the cookies and the disallowed hosts; a plain datasource gets no inherited headers. A failing executor + * propagates its error here (unlike the from-JS flow). + */ + @Test + void executeLibraryQuery_handsTheRightArgumentsToTheExecution_andPropagatesFailures() { + cookies.add("SESSION", new HttpCookie("SESSION", "abc")); + Map config = Map.of("sql", "editing"); + when(libraryQueryService.getEditingBaseQueryByLibraryQueryId(LQ_ID)).thenReturn(Mono.just(baseQuery(config, "7s"))); + QueryExecutionRequest request = editingRequest(LQ_ID); + request.setParams(List.of(Param.of(" a ", 1), Param.of("", 2), Param.of("b", null), Param.of("a", 4))); + + StepVerifier.create(service.executeLibraryQuery(exchange, request)).expectNext(result).verifyComplete(); + + ArgumentCaptor executed = ArgumentCaptor.forClass(Datasource.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> params = ArgumentCaptor.forClass(Map.class); + ArgumentCaptor context = ArgumentCaptor.forClass(QueryVisitorContext.class); + verify(queryExecutionService).executeQuery(executed.capture(), eq(config), params.capture(), eq("7s"), context.capture()); + assertThat(executed.getValue()).isSameAs(datasource); + assertThat(params.getValue()).containsOnlyKeys("a", "b").containsEntry("a", 4).containsEntry("b", null); + assertThat(context.getValue().getVisitorId()).isEqualTo(MEMBER_USER_ID); + assertThat(context.getValue().getApplicationOrgId()).isEqualTo(ORG); + assertThat(context.getValue().getSystemPort()).isEqualTo(PORT); + assertThat(context.getValue().getCookies()).isSameAs(cookies); + assertThat(context.getValue().getDisallowedHosts()).isSameAs(disallowedHosts); + assertThat(context.getValue().getAuthTokenMono().block()).isNull(); + + IllegalStateException failure = new IllegalStateException("plugin failed"); + when(queryExecutionService.executeQuery(any(), anyMap(), anyMap(), any(), any())).thenReturn(Mono.error(failure)); + StepVerifier.create(service.executeLibraryQuery(exchange, editingRequest(LQ_ID))) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(failure)).verify(); + say("executeLibraryQuery: hand-over verified, failure propagates"); + } + + /** + * Pins the plan section 9 row (tenant isolation) "LibraryQueryApiServiceImpl does not compare the library query's org + * with the visitor's on read or run": a developer of one organization runs, by id, the library query of another + * organization on that organization's datasource; the executor receives it, and the stored query's organization is + * never read ({@code libraryQueryService.getById}, which the permission checks use, is not called). A fix changes this + * test on purpose. + */ + @Test + void executeLibraryQuery_libraryQueryOfAnotherOrganization_runsOnThatOrganizationsDatasource_pinsSection9Row() { + datasource.setOrganizationId(OTHER_ORG); + when(libraryQueryService.getEditingBaseQueryByLibraryQueryId("lq-foreign")) + .thenReturn(Mono.just(baseQuery(Map.of("sql", "select * from their_secrets"), null))); + + StepVerifier.create(service.executeLibraryQuery(exchange, editingRequest("lq-foreign"))).expectNext(result).verifyComplete(); + + ArgumentCaptor executed = ArgumentCaptor.forClass(Datasource.class); + verify(queryExecutionService).executeQuery(executed.capture(), any(), any(), any(), any()); + assertThat(executed.getValue().getOrganizationId()).isEqualTo(OTHER_ORG); + verify(libraryQueryService, never()).getById(anyString()); + say("executeLibraryQuery: another org's query ran on org %s datasource (section 9 row pinned)", OTHER_ORG); + } + + // ------------------------------------------------------------------ OAuth inherited from login (editing flow) + + private enum Kind { + REST, GRAPHQL, JS + } + + private static OAuthInheritAuthConfig oauthAuth(String authId) { + return OAuthInheritAuthConfig.builder().authId(authId).type(RestApiAuthType.OAUTH2_INHERIT_FROM_LOGIN).build(); + } + + private static DatasourceConnectionConfig oauthConfig(Kind kind, String authId) { + return switch (kind) { + case REST -> RestApiDatasourceConfig.builder().authConfig(oauthAuth(authId)).build(); + case GRAPHQL -> GraphQLDatasourceConfig.builder().authConfig(oauthAuth(authId)).build(); + case JS -> { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + HashMap authConfig = new HashMap<>(); + authConfig.put("type", OAUTH_TYPE_NAME); + if (authId != null) { + authConfig.put("authId", authId); + } + config.put("authConfig", authConfig); + yield config; + } + }; + } + + private static Connection connection(String authId, String accessToken) { + return Connection.builder().authId(authId).source("src-" + authId).rawId("raw-" + authId) + .authConnectionAuthToken(accessToken == null ? null : ConnectionAuthToken.builder().accessToken(accessToken).build()) + .build(); + } + + private void visitorWithConnections(Connection... connections) { + visitor.setConnections(new LinkedHashSet<>(List.of(connections))); + } + + private static Property expectedHeader(Kind kind, String token) { + return kind == Kind.JS ? new Property("OAUTH_ACCESS_TOKEN", token, "header") + : new Property("Authorization", "Bearer " + token, "header"); + } + + private List inheritedPropertiesOfTheEditingFlow() { + when(libraryQueryService.getEditingBaseQueryByLibraryQueryId(LQ_ID)).thenReturn(Mono.just(baseQuery(Map.of("sql", "x"), null))); + StepVerifier.create(service.executeLibraryQuery(exchange, editingRequest(LQ_ID))).expectNext(result).verifyComplete(); + return capturedContext().getAuthTokenMono().block(); + } + + static Stream oauthRows() { + List rows = new ArrayList<>(); + for (Kind kind : Kind.values()) { + rows.add(Arguments.of(kind, "configured authId picks that connection's token, not the other's", "auth-A", "tA")); + rows.add(Arguments.of(kind, "null authId gives no headers", null, null)); + rows.add(Arguments.of(kind, "empty authId matches no connection (no fallback in this flow)", "", null)); + rows.add(Arguments.of(kind, "unknown authId gives no headers", "auth-X", null)); + } + return rows.stream(); + } + + /** + * Catches the token of the wrong connection or a leaked one: for a datasource inheriting OAuth from login (REST, + * GraphQL, JS) the connection whose auth id equals the configured one supplies {@code Authorization: Bearer } + * (REST, GraphQL) or {@code OAUTH_ACCESS_TOKEN} (JS); a null, empty or unknown auth id gives no properties. + */ + @ParameterizedTest(name = "{0}: {1}") + @MethodSource("oauthRows") + void oauthInherit_editingFlow_picksTheRightConnectionsToken(Kind kind, String label, String configuredAuthId, String expectedToken) { + datasource.setDetailConfig(oauthConfig(kind, configuredAuthId)); + visitorWithConnections(connection("auth-A", "tA"), connection("auth-B", "tB")); + + List properties = inheritedPropertiesOfTheEditingFlow(); + + if (expectedToken == null) { + assertThat(properties).isNull(); + } else { + assertThat(properties).containsExactly(expectedHeader(kind, expectedToken)); + } + say("%s %s -> %s", kind, label, properties); + } + + /** + * Catches the null auth id reaching the connection list: a datasource configured with a null auth id gives no + * properties even when the visitor has a connection whose own auth id is null (the null guard comes first, so the + * {@code getAuthId().equals(...)} of that connection is never evaluated). + */ + @ParameterizedTest + @EnumSource(Kind.class) + void oauthInherit_editingFlow_nullConfiguredAuthId_doesNotTouchTheConnections(Kind kind) { + datasource.setDetailConfig(oauthConfig(kind, null)); + visitorWithConnections(connection(null, "t0")); + + assertThat(inheritedPropertiesOfTheEditingFlow()).isNull(); + say("%s null configured authId with a null-authId connection -> no headers, no NPE", kind); + } + + /** Catches a header from a connection without token: the matching connection holding no token gives no properties. */ + @ParameterizedTest + @EnumSource(Kind.class) + void oauthInherit_editingFlow_matchingConnectionWithoutToken_givesNoHeaders(Kind kind) { + datasource.setDetailConfig(oauthConfig(kind, "auth-A")); + visitorWithConnections(connection("auth-A", null), connection("auth-B", "tB")); + + assertThat(inheritedPropertiesOfTheEditingFlow()).isNull(); + say("%s matching connection without token -> no headers", kind); + } + + static Stream notInheritingRows() { + return Stream.of( + Arguments.of("REST without auth config", RestApiDatasourceConfig.builder().build()), + Arguments.of("GraphQL without auth config", GraphQLDatasourceConfig.builder().build()), + Arguments.of("JS without auth config", new JsDatasourceConnectionConfig()), + Arguments.of("no detail config", null)); + } + + /** Catches headers attached to a datasource that does not inherit OAuth from login. */ + @ParameterizedTest(name = "{0}") + @MethodSource("notInheritingRows") + void oauthInherit_editingFlow_datasourceNotInheriting_getsNoHeaders(String label, DatasourceConnectionConfig config) { + datasource.setDetailConfig(config); + visitorWithConnections(connection("auth-A", "tA")); + + assertThat(inheritedPropertiesOfTheEditingFlow()).isNull(); + say("%s -> no headers", label); + } + + /** + * Pins the plan section 9 row "connection.getAuthId().equals(...) NPE for a connection with a null authId" + * ({@code getParamsAndHeadersInheritFromLogin} uses the same {@code connection.getAuthId().equals(...)} as the + * application flow): a connection with a null auth id placed before the matching one gives a NullPointerException; + * with the matching connection first the stream short-circuits and the query runs. A fix changes this test on purpose. + */ + @ParameterizedTest + @EnumSource(Kind.class) + void oauthInherit_connectionWithNullAuthIdBeforeTheMatch_isANullPointerException_pinsSection9Row(Kind kind) { + datasource.setDetailConfig(oauthConfig(kind, "auth-A")); + when(libraryQueryService.getEditingBaseQueryByLibraryQueryId(LQ_ID)).thenReturn(Mono.just(baseQuery(Map.of("sql", "x"), null))); + visitorWithConnections(connection(null, "t0"), connection("auth-A", "tA")); + + StepVerifier.create(service.executeLibraryQuery(exchange, editingRequest(LQ_ID))).expectError(NullPointerException.class).verify(); + verify(queryExecutionService, never()).executeQuery(any(), any(), any(), any(), any()); + + visitorWithConnections(connection("auth-A", "tA"), connection(null, "t0")); + assertThat(inheritedPropertiesOfTheEditingFlow()).containsExactly(expectedHeader(kind, "tA")); + say("%s: null-authId connection first -> NullPointerException (section 9 row pinned); matching first -> works", kind); + } + + // ------------------------------------------------------------------ executeLibraryQueryFromJs (by name) + + private LibraryQueryRequestFromJs jsRequest(String recordId) { + LibraryQueryRequestFromJs request = new LibraryQueryRequestFromJs(); + request.setLibraryQueryName("name-1"); + request.setLibraryQueryRecordId(recordId); + return request; + } + + static Stream recordIdRows() { + return Stream.of( + Arguments.of(null, "editing"), + Arguments.of("", "editing"), + Arguments.of(" ", "editing"), + Arguments.of("editing", "editing"), + Arguments.of("latest", "live"), + Arguments.of("rec-9", "recorded")); + } + + /** + * Catches running the wrong version of a library query from JS: the query is found by name; a blank or "editing" + * record id runs the stored query's own base query, "latest" the live one and any other id the recorded one, and + * the other sources are not asked. + */ + @ParameterizedTest(name = "record id [{0}] -> {1}") + @MethodSource("recordIdRows") + void executeFromJs_picksTheVersionByRecordId(String recordId, String version) { + LibraryQuery named = libraryQuery(LQ_ID, ORG, "editing", DS_ID, CREATOR_ID); + when(libraryQueryService.getByName("name-1")).thenReturn(Mono.just(named)); + Map liveConfig = Map.of("sql", "live"); + Map recordedConfig = Map.of("sql", "recorded"); + LibraryQueryRecord recorded = mock(LibraryQueryRecord.class); + lenient().when(recorded.getQuery()).thenReturn(baseQuery(recordedConfig, null)); + lenient().when(libraryQueryService.getById(LQ_ID)).thenReturn(Mono.just(named)); + lenient().when(libraryQueryService.getLiveBaseQueryByLibraryQueryId(LQ_ID)).thenReturn(Mono.just(baseQuery(liveConfig, null))); + lenient().when(libraryQueryRecordService.getById("rec-9")).thenReturn(Mono.just(recorded)); + + StepVerifier.create(service.executeLibraryQueryFromJs(exchange, jsRequest(recordId))).expectNext(result).verifyComplete(); + + ArgumentCaptor> config = mapCaptor(); + verify(queryExecutionService).executeQuery(any(), config.capture(), any(), any(), any()); + assertThat(config.getValue()).isEqualTo(switch (version) { + case "live" -> liveConfig; + case "recorded" -> recordedConfig; + default -> Map.of("sql", "editing"); + }); + if (!version.equals("editing")) { + verify(libraryQueryService, never()).getById(anyString()); + } + if (!version.equals("live")) { + verify(libraryQueryService, never()).getLiveBaseQueryByLibraryQueryId(anyString()); + } + if (!version.equals("recorded")) { + verify(libraryQueryRecordService, never()).getById(anyString()); + } + say("fromJs record id [%s] -> %s version", recordId, version); + } + + @SuppressWarnings("unchecked") + private static ArgumentCaptor> mapCaptor() { + return ArgumentCaptor.forClass(Map.class); + } + + private void stubJsQuery(String timeout) { + LibraryQuery named = libraryQuery(LQ_ID, ORG, "editing", DS_ID, CREATOR_ID); + lenient().when(libraryQueryService.getByName("name-1")).thenReturn(Mono.just(named)); + lenient().when(libraryQueryService.getLiveBaseQueryByLibraryQueryId(LQ_ID)) + .thenReturn(Mono.just(baseQuery(Map.of("sql", "live"), timeout))); + } + + /** Catches a wrong timeout: the base query's timeout is used, "5s" only when it is null, empty or blank. */ + @ParameterizedTest(name = "base query timeout [{0}]") + @NullAndEmptySource + @ValueSource(strings = {" ", "9s"}) + void executeFromJs_usesTheQuerysTimeout_orFiveSeconds(String timeout) { + stubJsQuery(timeout); + + StepVerifier.create(service.executeLibraryQueryFromJs(exchange, jsRequest("latest"))).expectNext(result).verifyComplete(); + + verify(queryExecutionService).executeQuery(any(), any(), any(), eq("9s".equals(timeout) ? "9s" : "5s"), any()); + say("fromJs timeout [%s]", timeout); + } + + /** Catches an unknown library query name or datasource swallowed: the service error / DATASOURCE_NOT_FOUND is the result. */ + @Test + void executeFromJs_unknownNameAndMissingDatasource_areErrors() { + BizException notFound = new BizException(BizError.LIBRARY_QUERY_NOT_FOUND, "LIBRARY_QUERY_NOT_FOUND"); + when(libraryQueryService.getByName("name-1")).thenReturn(Mono.error(notFound)); + StepVerifier.create(service.executeLibraryQueryFromJs(exchange, jsRequest("latest"))) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(notFound)).verify(); + + stubJsQuery(null); + when(datasourceService.getById(DS_ID)).thenReturn(Mono.empty()); + StepVerifier.create(service.executeLibraryQueryFromJs(exchange, jsRequest("latest"))) + .expectErrorSatisfies(error -> { + assertBizError(error, BizError.DATASOURCE_NOT_FOUND, "DATASOURCE_NOT_FOUND"); + assertThat(((BizException) error).getArgs()).containsExactly(DS_ID); + }) + .verify(); + verify(queryExecutionService, never()).executeQuery(any(), any(), any(), any(), any()); + say("fromJs: unknown name -> service error, missing datasource -> DATASOURCE_NOT_FOUND"); + } + + /** + * Catches an executor failure escaping to the caller: unlike the editing flow, the from-JS flow turns it into a + * {@code QueryExecutionResult} with code QUERY_EXECUTION_ERROR, that message key and the failure's message as argument. + */ + @Test + void executeFromJs_executorFailure_isReturnedAsAnErrorResult() { + stubJsQuery(null); + when(queryExecutionService.executeQuery(any(), anyMap(), anyMap(), any(), any())) + .thenReturn(Mono.error(new IllegalStateException("boom"))); + + StepVerifier.create(service.executeLibraryQueryFromJs(exchange, jsRequest("latest"))) + .assertNext(error -> { + assertThat(error.getQueryCode()).isEqualTo("QUERY_EXECUTION_ERROR"); + assertThat(error.getMessageKey()).isEqualTo("QUERY_EXECUTION_ERROR"); + assertThat(error.getMessageArgs()).containsExactly("boom"); + }) + .verifyComplete(); + say("fromJs: executor failure -> error result"); + } + + /** + * Catches the wrong identity in the context: a visitor with an organization member gets that member's user id and org + * id, port, cookies and disallowed hosts; a visitor whose organization member lookup fails is mapped to + * {@code OrgMember.NOT_EXIST} (empty user and org ids) and still runs, with no inherited headers (pinned as behaviour). + */ + @ParameterizedTest(name = "member lookup fails: {0}") + @ValueSource(booleans = {false, true}) + void executeFromJs_contextCarriesTheMembersIds_orEmptyIdsWhenTheLookupFails(boolean lookupFails) { + stubJsQuery(null); + cookies.add("SESSION", new HttpCookie("SESSION", "abc")); + if (lookupFails) { + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.error(new IllegalStateException("no member"))); + } + LibraryQueryRequestFromJs request = jsRequest("latest"); + request.setParams(List.of(Param.of(" a ", 1), Param.of("", 2), Param.of("a", 4))); + + StepVerifier.create(service.executeLibraryQueryFromJs(exchange, request)).expectNext(result).verifyComplete(); + + ArgumentCaptor> params = mapCaptor(); + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(QueryVisitorContext.class); + verify(queryExecutionService).executeQuery(any(), any(), params.capture(), any(), contextCaptor.capture()); + assertThat(params.getValue()).isEqualTo(Map.of("a", 4)); + QueryVisitorContext context = contextCaptor.getValue(); + assertThat(context.getVisitorId()).isEqualTo(lookupFails ? "" : MEMBER_USER_ID); + assertThat(context.getApplicationOrgId()).isEqualTo(lookupFails ? "" : ORG); + assertThat(context.getSystemPort()).isEqualTo(PORT); + assertThat(context.getCookies()).isSameAs(cookies); + assertThat(context.getDisallowedHosts()).isSameAs(disallowedHosts); + assertThat(context.getAuthTokenMono().block()).isNull(); + say("fromJs context for lookupFails=%s: user [%s] org [%s]", lookupFails, context.getVisitorId(), context.getApplicationOrgId()); + } + + /** + * Pins today's behaviour (a dead branch of the feature, not ruled a defect): the from-JS flow asks for inherited + * headers with a null auth id, so a datasource that inherits OAuth from login never gets its headers here, even + * for a visitor with a matching connection and token. + */ + @ParameterizedTest + @EnumSource(Kind.class) + void executeFromJs_neverAddsInheritedOAuthHeaders_evenForAnInheritingDatasource(Kind kind) { + stubJsQuery(null); + datasource.setDetailConfig(oauthConfig(kind, "auth-A")); + visitorWithConnections(connection("auth-A", "tA")); + + StepVerifier.create(service.executeLibraryQueryFromJs(exchange, jsRequest("latest"))).expectNext(result).verifyComplete(); + + assertThat(capturedContext().getAuthTokenMono().block()).isNull(); + say("%s: from-JS flow adds no inherited headers", kind); + } + + /** + * Pins the plan section 9 row (tenant isolation) "LibraryQueryApiServiceImpl does not compare the library query's org + * with the visitor's on read or run": a visitor runs, by name, a library query of another organization on that + * organization's datasource. The name is looked up with {@code libraryQueryService.getByName} (not scoped to an + * organization), no organization is compared, and the executor receives the other organization's datasource. + * Reachability: {@code POST /api/query/execute-from-node} (also under {@code /api/v1}) is not in the permit-all list of + * {@code SecurityConfig} (only {@code POST .../query/execute} is), so it falls under {@code /api/**} which requires a + * signed-in user; any signed-in user suffices, there is no organization check. A fix changes this test on purpose. + */ + @Test + void executeFromJs_libraryQueryOfAnotherOrganization_runsOnThatOrganizationsDatasource_pinsSection9Row() { + datasource.setOrganizationId(OTHER_ORG); + LibraryQuery foreign = libraryQuery(LQ_ID, OTHER_ORG, "name-1", DS_ID, CREATOR_ID); + when(libraryQueryService.getByName("name-1")).thenReturn(Mono.just(foreign)); + when(libraryQueryService.getLiveBaseQueryByLibraryQueryId(LQ_ID)) + .thenReturn(Mono.just(baseQuery(Map.of("sql", "select * from their_secrets"), null))); + + StepVerifier.create(service.executeLibraryQueryFromJs(exchange, jsRequest("latest"))).expectNext(result).verifyComplete(); + + ArgumentCaptor executed = ArgumentCaptor.forClass(Datasource.class); + verify(queryExecutionService).executeQuery(executed.capture(), any(), any(), any(), any()); + assertThat(executed.getValue().getOrganizationId()).isEqualTo(OTHER_ORG); + assertThat(capturedContext().getApplicationOrgId()).isEqualTo(ORG); + say("fromJs: another org's query ran on org %s datasource (section 9 row pinned)", OTHER_ORG); + } + + // ------------------------------------------------------------------ LibraryQueryRequestFromJs.paramMap + + /** + * Catches wrong parameters from JS: blank keys are dropped, keys are trimmed, the last duplicate wins, a null value + * is kept, and no parameters give an empty map. + */ + @Test + void requestFromJs_paramMap_cleansTheParameters() { + LibraryQueryRequestFromJs request = new LibraryQueryRequestFromJs(); + assertThat(request.paramMap()).isEmpty(); + + request.setParams(List.of(Param.of(" a ", 1), Param.of("", 2), Param.of(" ", 3), Param.of("b", null), Param.of("a", 4))); + + assertThat(request.paramMap()).containsOnlyKeys("a", "b").containsEntry("a", 4).containsEntry("b", null); + say("paramMap: %s", request.paramMap()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/LibraryQueryApiServiceIntegrationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/LibraryQueryApiServiceIntegrationTest.java index f4886ca5b1..bae04bfa36 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/LibraryQueryApiServiceIntegrationTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/LibraryQueryApiServiceIntegrationTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.lowcoder.api.common.InitData; import org.lowcoder.api.common.mockuser.WithMockUser; @@ -26,7 +25,6 @@ @SuppressWarnings("SameParameterValue") @SpringBootTest @ActiveProfiles("test") -@Disabled("Enable after all plugins are loaded in test mode") //@RunWith(SpringRunner.class) public class LibraryQueryApiServiceIntegrationTest { @@ -47,7 +45,8 @@ public void beforeEach() { public void testListLibraryQueries() { Mono> listMono = datasourceApiService.create(DatasourceApiServiceIntegrationTest.buildMysqlDatasource("mysql06")) .flatMap(datasource -> libraryQueryApiService.create(buildLibraryQuery("query01", datasource.getId()))) - .then(libraryQueryApiService.listLibraryQueries(null)); + // "" is what LibraryQueryController passes when the request names no filter + .then(libraryQueryApiService.listLibraryQueries("")); StepVerifier.create(listMono) .assertNext(libraryQueryViews -> { diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/LibraryQueryRecordApiServiceImplTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/LibraryQueryRecordApiServiceImplTest.java new file mode 100644 index 0000000000..dc86d74ada --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/LibraryQueryRecordApiServiceImplTest.java @@ -0,0 +1,335 @@ +package org.lowcoder.api.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.api.query.view.LibraryQueryRecordMetaView; +import org.lowcoder.api.usermanagement.OrgDevChecker; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.query.model.LibraryQuery; +import org.lowcoder.domain.query.model.LibraryQueryCombineId; +import org.lowcoder.domain.query.model.LibraryQueryRecord; +import org.lowcoder.domain.query.service.LibraryQueryRecordService; +import org.lowcoder.domain.query.service.LibraryQueryService; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Tests of {@link LibraryQueryRecordApiServiceImpl} over mocked services. The sibling {@code LibraryQueryApiServiceImpl} + * (its two permission checks are tested in LibraryQueryApiServiceImplTest) is mocked; the checks of this class itself + * (record view and record management) run for real. + * + *

    {@code LibraryQueryRecordMetaView} is already covered by the L1-7 publish test and LibraryQueryMetaViewTest; it is only + * asserted here through {@code getByLibraryQueryId}'s output. + * + *

    Event-log stubs (Mono.defer) show WHEN a service call executes, because the DSL read and the deletion are assembled + * eagerly but must run only after the permission checks. + */ +class LibraryQueryRecordApiServiceImplTest { + + private static final String ORG_ID = "org-1"; + private static final String OTHER_ORG_ID = "org-2"; + private static final String USER_ID = "user-1"; + private static final String LIBRARY_QUERY_ID = "lq-1"; + private static final String OTHER_QUERY_ID = "lq-2"; + private static final String RECORD_ID = "record-1"; + private static final String LATEST = "latest"; + private static final Instant CREATED_AT = Instant.ofEpochMilli(1_700_000_000_000L); + private static final Map LIVE_DSL = Map.of("which", "live"); + private static final Map RECORDED_DSL = Map.of("which", "recorded"); + + private LibraryQueryService libraryQueryService; + private LibraryQueryRecordService libraryQueryRecordService; + private LibraryQueryApiServiceImpl libraryQueryApiService; + private SessionUserService sessionUserService; + private OrgDevChecker orgDevChecker; + private UserService userService; + private LibraryQueryRecordApiServiceImpl service; + + private final List events = new ArrayList<>(); + + @BeforeEach + void setUp() { + libraryQueryService = mock(LibraryQueryService.class); + libraryQueryRecordService = mock(LibraryQueryRecordService.class); + libraryQueryApiService = mock(LibraryQueryApiServiceImpl.class); + sessionUserService = mock(SessionUserService.class); + orgDevChecker = mock(OrgDevChecker.class); + userService = mock(UserService.class); + service = new LibraryQueryRecordApiServiceImpl(libraryQueryService, libraryQueryRecordService, libraryQueryApiService, + sessionUserService, orgDevChecker, userService); + lenient().when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(member(ORG_ID))); + lenient().when(orgDevChecker.checkCurrentOrgDev()).thenReturn(logged("dev check")); + } + + private static OrgMember member(String orgId) { + return new OrgMember(orgId, USER_ID, MemberRole.MEMBER, "normal", 0L); + } + + private static LibraryQuery libraryQuery(String id, String orgId) { + LibraryQuery query = LibraryQuery.builder().organizationId(orgId).libraryQueryDSL(LIVE_DSL).build(); + query.setId(id); + return query; + } + + private static LibraryQueryRecord record(String id, String libraryQueryId, String creatorId) { + Map query = new HashMap<>(); + query.put("compType", "mysql"); + Map dsl = new HashMap<>(RECORDED_DSL); + dsl.put("query", query); + LibraryQueryRecord record = LibraryQueryRecord.builder().libraryQueryId(libraryQueryId).tag("tag-" + id) + .commitMessage("msg-" + id).libraryQueryDSL(dsl).build(); + record.setId(id); + record.setCreatedAt(CREATED_AT); + record.setCreatedBy(creatorId); + return record; + } + + private static User user(String id, String name) { + User user = new User(); + user.setId(id); + user.setName(name); + return user; + } + + private Mono logged(String event) { + return Mono.defer(() -> { + events.add(event); + return Mono.empty(); + }); + } + + private Mono logged(String event, T value) { + return Mono.defer(() -> { + events.add(event); + return Mono.just(value); + }); + } + + private static void assertBizError(Throwable error, BizError expected, String messageKey) { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(expected); + assertThat(((BizException) error).getMessageKey()).isEqualTo(messageKey); + } + + private static void say(String format, Object... args) { + System.out.println("[LibraryQueryRecordApiServiceImplTest] " + String.format(format, args)); + } + + private void stubFirstCheckPasses() { + when(libraryQueryApiService.checkLibraryQueryViewPermission(LIBRARY_QUERY_ID)).thenReturn(logged("view check " + LIBRARY_QUERY_ID)); + } + + // ------------------------------------------------------------------ getRecordDSLFromLibraryQueryCombineId + + /** Record id "latest": the live DSL of the combine's query; the record service is not asked at all. */ + @Test + void getRecordDsl_latest_readsTheLiveDsl() { + stubFirstCheckPasses(); + when(libraryQueryService.getById(LIBRARY_QUERY_ID)).thenReturn(logged("get query " + LIBRARY_QUERY_ID, libraryQuery(LIBRARY_QUERY_ID, ORG_ID))); + when(libraryQueryService.getLiveDSLByLibraryQueryId(LIBRARY_QUERY_ID)).thenReturn(logged("read live dsl", LIVE_DSL)); + + StepVerifier.create(service.getRecordDSLFromLibraryQueryCombineId(new LibraryQueryCombineId(LIBRARY_QUERY_ID, LATEST))) + .expectNext(LIVE_DSL).verifyComplete(); + + assertThat(events).containsExactly("view check " + LIBRARY_QUERY_ID, "get query " + LIBRARY_QUERY_ID, "read live dsl"); + verify(libraryQueryRecordService, never()).getById(anyString()); + say("latest: %s", events); + } + + /** Another record id: the record's DSL; the live DSL is never asked and the second check follows the RECORD's own query. */ + @Test + void getRecordDsl_recordId_readsTheRecordedDsl() { + stubFirstCheckPasses(); + LibraryQueryRecord record = record(RECORD_ID, OTHER_QUERY_ID, USER_ID); + when(libraryQueryRecordService.getById(RECORD_ID)).thenReturn(logged("get record", record)); + when(libraryQueryService.getById(OTHER_QUERY_ID)).thenReturn(logged("get query " + OTHER_QUERY_ID, libraryQuery(OTHER_QUERY_ID, ORG_ID))); + + StepVerifier.create(service.getRecordDSLFromLibraryQueryCombineId(new LibraryQueryCombineId(LIBRARY_QUERY_ID, RECORD_ID))) + .expectNext(record.getLibraryQueryDSL()).verifyComplete(); + + verify(libraryQueryService, never()).getLiveDSLByLibraryQueryId(anyString()); + verify(libraryQueryService, never()).getById(LIBRARY_QUERY_ID); + assertThat(events).startsWith("view check " + LIBRARY_QUERY_ID, "get record", "get query " + OTHER_QUERY_ID); + assertThat(events.get(events.size() - 1)).as("the DSL is read after the checks").isEqualTo("get record"); + } + + /** + * Tenant isolation of query versions: (a) the first check refuses another org's query; (b) the live second check refuses + * a query of another org; (c) an own-org query id combined with a record of ANOTHER org's query is refused. In every case + * the DSL is never read (it is a lazy read that runs only after the checks). + */ + @Test + void getRecordDsl_deniesAnotherOrgsQueries_beforeAnyRead() { + // (a) first check fails + when(libraryQueryApiService.checkLibraryQueryViewPermission(LIBRARY_QUERY_ID)) + .thenReturn(Mono.defer(() -> org.lowcoder.sdk.util.ExceptionUtils.ofError(BizError.LIBRARY_QUERY_AND_ORG_NOT_MATCH, "LIBRARY_QUERY_AND_ORG_NOT_MATCH"))); + when(libraryQueryService.getLiveDSLByLibraryQueryId(LIBRARY_QUERY_ID)).thenReturn(logged("read live dsl", LIVE_DSL)); + StepVerifier.create(service.getRecordDSLFromLibraryQueryCombineId(new LibraryQueryCombineId(LIBRARY_QUERY_ID, LATEST))) + .expectErrorSatisfies(e -> assertBizError(e, BizError.LIBRARY_QUERY_AND_ORG_NOT_MATCH, "LIBRARY_QUERY_AND_ORG_NOT_MATCH")).verify(); + assertThat(events).as("(a) nothing read").isEmpty(); + verify(libraryQueryService, never()).getById(anyString()); + + // (b) first check passes, the live query belongs to another org + stubFirstCheckPasses(); + when(libraryQueryService.getById(LIBRARY_QUERY_ID)).thenReturn(logged("get query", libraryQuery(LIBRARY_QUERY_ID, OTHER_ORG_ID))); + StepVerifier.create(service.getRecordDSLFromLibraryQueryCombineId(new LibraryQueryCombineId(LIBRARY_QUERY_ID, LATEST))) + .expectErrorSatisfies(e -> assertBizError(e, BizError.LIBRARY_QUERY_AND_ORG_NOT_MATCH, "LIBRARY_QUERY_AND_ORG_NOT_MATCH")).verify(); + assertThat(events).as("(b) the live DSL is not read").doesNotContain("read live dsl"); + + // (c) own-org query id, record of another org's query + events.clear(); + LibraryQueryRecord foreign = record(RECORD_ID, OTHER_QUERY_ID, USER_ID); + when(libraryQueryRecordService.getById(RECORD_ID)).thenReturn(logged("get record", foreign)); + when(libraryQueryService.getById(OTHER_QUERY_ID)).thenReturn(logged("get foreign query", libraryQuery(OTHER_QUERY_ID, OTHER_ORG_ID))); + StepVerifier.create(service.getRecordDSLFromLibraryQueryCombineId(new LibraryQueryCombineId(LIBRARY_QUERY_ID, RECORD_ID))) + .expectErrorSatisfies(e -> assertBizError(e, BizError.LIBRARY_QUERY_AND_ORG_NOT_MATCH, "LIBRARY_QUERY_AND_ORG_NOT_MATCH")).verify(); + assertThat(events).as("(c) record and query looked up once each by the check, no second record read for the DSL") + .containsExactly("view check " + LIBRARY_QUERY_ID, "get record", "get foreign query"); + } + + /** + * An unknown record id ends in LIBRARY_QUERY_NOT_FOUND (the record service errors, nothing is emitted). Behaviour: the + * "editing" and blank record ids that LibraryQueryCombineId.isUsingEditingRecord names take the same record path and end + * in NOT_FOUND here, because this class never branches on isUsingEditingRecord (same ruling as L2's twin test). + */ + @ParameterizedTest(name = "record id [{0}]") + @ValueSource(strings = {"unknown-record", "editing", ""}) + void getRecordDsl_unknownEditingOrBlankRecordId_endsInNotFound(String recordId) { + stubFirstCheckPasses(); + when(libraryQueryRecordService.getById(recordId)).thenReturn(Mono.defer(() -> org.lowcoder.sdk.util.ExceptionUtils + .ofError(BizError.LIBRARY_QUERY_NOT_FOUND, "LIBRARY_QUERY_NOT_FOUND"))); + + StepVerifier.create(service.getRecordDSLFromLibraryQueryCombineId(new LibraryQueryCombineId(LIBRARY_QUERY_ID, recordId))) + .expectErrorSatisfies(e -> assertBizError(e, BizError.LIBRARY_QUERY_NOT_FOUND, "LIBRARY_QUERY_NOT_FOUND")).verify(); + verify(libraryQueryService, never()).getLiveDSLByLibraryQueryId(anyString()); + } + + // ------------------------------------------------------------------ delete + + /** + * Order of the management check before a deletion: org developer check, the visitor's org, the record, the record's + * library query, and only then deleteById, exactly once and with the record id. + */ + @Test + void delete_checksDevOrgAndRecord_thenDeletes() { + LibraryQueryRecord record = record(RECORD_ID, OTHER_QUERY_ID, USER_ID); + when(libraryQueryRecordService.getById(RECORD_ID)).thenReturn(logged("get record", record)); + when(libraryQueryService.getById(OTHER_QUERY_ID)).thenReturn(logged("get query", libraryQuery(OTHER_QUERY_ID, ORG_ID))); + when(libraryQueryRecordService.deleteById(RECORD_ID)).thenReturn(logged("delete record")); + + StepVerifier.create(service.delete(RECORD_ID)).verifyComplete(); + + assertThat(events).containsExactly("dev check", "get record", "get query", "delete record"); + say("delete: %s", events); + } + + /** No deletion subscribes when the dev check fails, the org differs, or the record is unknown. */ + @Test + void delete_neverDeletes_whenAnyCheckFails() { + when(libraryQueryRecordService.deleteById(anyString())).thenReturn(logged("delete record")); + + // developer check fails + when(orgDevChecker.checkCurrentOrgDev()).thenReturn(Mono.defer(() -> org.lowcoder.sdk.util.ExceptionUtils + .ofError(BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED"))); + when(libraryQueryRecordService.getById(RECORD_ID)).thenReturn(logged("get record", record(RECORD_ID, OTHER_QUERY_ID, USER_ID))); + StepVerifier.create(service.delete(RECORD_ID)) + .expectErrorSatisfies(e -> assertBizError(e, BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED")).verify(); + assertThat(events).doesNotContain("delete record"); + + // org of the record's query differs + when(orgDevChecker.checkCurrentOrgDev()).thenReturn(logged("dev check")); + when(libraryQueryService.getById(OTHER_QUERY_ID)).thenReturn(logged("get query", libraryQuery(OTHER_QUERY_ID, OTHER_ORG_ID))); + StepVerifier.create(service.delete(RECORD_ID)) + .expectErrorSatisfies(e -> assertBizError(e, BizError.LIBRARY_QUERY_AND_ORG_NOT_MATCH, "LIBRARY_QUERY_AND_ORG_NOT_MATCH")).verify(); + assertThat(events).doesNotContain("delete record"); + + // unknown record + when(libraryQueryRecordService.getById("missing")).thenReturn(Mono.defer(() -> org.lowcoder.sdk.util.ExceptionUtils + .ofError(BizError.LIBRARY_QUERY_NOT_FOUND, "LIBRARY_QUERY_NOT_FOUND"))); + StepVerifier.create(service.delete("missing")) + .expectErrorSatisfies(e -> assertBizError(e, BizError.LIBRARY_QUERY_NOT_FOUND, "LIBRARY_QUERY_NOT_FOUND")).verify(); + assertThat(events).as("no deletion ever subscribed").doesNotContain("delete record"); + } + + // ------------------------------------------------------------------ getByLibraryQueryId + + /** The management check runs first (a failing check means the records are never read); views keep the service's order. */ + @Test + void getByLibraryQueryId_checksManagementFirst_thenBuildsViewsWithCreatorNames() { + when(libraryQueryApiService.checkLibraryQueryManagementPermission(LIBRARY_QUERY_ID)).thenReturn(logged("management check")); + LibraryQueryRecord newer = record("r-new", LIBRARY_QUERY_ID, "creator-a"); + LibraryQueryRecord older = record("r-old", LIBRARY_QUERY_ID, "creator-b"); + when(libraryQueryRecordService.getByLibraryQueryId(LIBRARY_QUERY_ID)).thenReturn(logged("read records", List.of(newer, older))); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(Map.of( + "creator-a", user("creator-a", "Alice"), "creator-b", user("creator-b", "Bob")))); + + List views = service.getByLibraryQueryId(LIBRARY_QUERY_ID).block(); + + assertThat(events).containsExactly("management check", "read records"); + assertThat(views).extracting(LibraryQueryRecordMetaView::id).containsExactly("r-new", "r-old"); + assertThat(views).extracting(LibraryQueryRecordMetaView::creatorName).containsExactly("Alice", "Bob"); + assertThat(views).extracting(LibraryQueryRecordMetaView::tag).containsExactly("tag-r-new", "tag-r-old"); + assertThat(views).extracting(LibraryQueryRecordMetaView::libraryQueryId).containsOnly(LIBRARY_QUERY_ID); + org.mockito.ArgumentCaptor> ids = org.mockito.ArgumentCaptor.forClass(java.util.Collection.class); + verify(userService).getByIds(ids.capture()); + assertThat(Set.copyOf(ids.getValue())).containsExactlyInAnyOrder("creator-a", "creator-b"); + } + + @Test + void getByLibraryQueryId_refusedManagementCheck_readsNoRecords() { + when(libraryQueryApiService.checkLibraryQueryManagementPermission(LIBRARY_QUERY_ID)).thenReturn(Mono.defer(() -> org.lowcoder.sdk.util.ExceptionUtils + .ofError(BizError.LIBRARY_QUERY_AND_ORG_NOT_MATCH, "LIBRARY_QUERY_AND_ORG_NOT_MATCH"))); + when(libraryQueryRecordService.getByLibraryQueryId(LIBRARY_QUERY_ID)).thenReturn(logged("read records", List.of())); + + StepVerifier.create(service.getByLibraryQueryId(LIBRARY_QUERY_ID)) + .expectErrorSatisfies(e -> assertBizError(e, BizError.LIBRARY_QUERY_AND_ORG_NOT_MATCH, "LIBRARY_QUERY_AND_ORG_NOT_MATCH")).verify(); + + assertThat(events).isEmpty(); + verify(userService, never()).getByIds(anyCollection()); + } + + /** + * Behaviour (same ruling as L2): a record whose creator is not in the user lookup is DROPPED from the version list (multiBuild + * omits it), so a deleted user's published versions vanish from the list while the data and the DSL read by record id stay + * available. No records gives an empty list. + */ + @Test + void getByLibraryQueryId_dropsRecordsWithAnUnknownCreator_andHandlesNoRecords() { + when(libraryQueryApiService.checkLibraryQueryManagementPermission(LIBRARY_QUERY_ID)).thenReturn(Mono.empty()); + when(libraryQueryRecordService.getByLibraryQueryId(LIBRARY_QUERY_ID)).thenReturn(Mono.just(List.of( + record("r-known", LIBRARY_QUERY_ID, "creator-a"), record("r-gone", LIBRARY_QUERY_ID, "creator-deleted")))); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(Map.of("creator-a", user("creator-a", "Alice")))); + + assertThat(service.getByLibraryQueryId(LIBRARY_QUERY_ID).block()).extracting(LibraryQueryRecordMetaView::id).containsExactly("r-known"); + + when(libraryQueryRecordService.getByLibraryQueryId(LIBRARY_QUERY_ID)).thenReturn(Mono.just(List.of())); + when(userService.getByIds(any())).thenReturn(Mono.just(Map.of())); + assertThat(service.getByLibraryQueryId(LIBRARY_QUERY_ID).block()).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/PagedEndpointPageNumZeroThroughTheStackTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/PagedEndpointPageNumZeroThroughTheStackTest.java new file mode 100644 index 0000000000..6cd1949a6f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/PagedEndpointPageNumZeroThroughTheStackTest.java @@ -0,0 +1,148 @@ +package org.lowcoder.api.query; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.contract.support.ContractTestClient; +import org.lowcoder.api.contract.support.LibraryQuerySamples; +import org.lowcoder.api.query.view.LibraryQueryView; +import org.lowcoder.infra.constant.NewUrl; +import org.lowcoder.sdk.exception.BizError; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Task L1-13b (lane L5): a paged endpoint with {@code pageNum} 0 or negative, through the real {@link LibraryQueryController} + * ({@code GET /library-queries/listByOrg}) over the production codecs and exception handling of {@link ContractTestClient}. + * + *

    Plan section 9 row "pageNum <= 0 gives a raw 500": {@code Pagination.fluxToPageResponseView} computes + * {@code skip((pageNum - 1) * pageSize)} ({@code Pagination.java:67}); a negative count makes Reactor throw an + * {@code IllegalArgumentException} while the pipeline is assembled, which {@code GlobalExceptionHandler.catchException} answers as HTTP + * 500 with code {@code INTERNAL_SERVER_ERROR} (5000). The seven callers listed in log-L5.md (L1-13) share it; this class pins one of + * them. {@code Pagination.check()} would throw a {@code BizException} with {@code INVALID_PARAMETER} (HTTP 500, code 5002, + * {@code BizError.java:15}) and the message key ILLEGAL_PAGE_NUMBER ({@code Pagination.java:41-46}); calling it first changes the code and + * makes this pin fail. A page size of 0 is the documented way to take every item and stays a control. + * + *

    Limits: one endpoint stands for the seven; the service behind it is a mock. A page number whose skip count overflows is not tried. + */ +public class PagedEndpointPageNumZeroThroughTheStackTest { + + static final String TAG = "[PagedEndpointPageNumZeroThroughTheStackTest] "; + static final String LIST_URL = NewUrl.LIBRARY_QUERY_URL + "/listByOrg"; + static final String PAGE_NUM = "pageNum"; + static final String PAGE_SIZE = "pageSize"; + static final String NAME_FILTER = "name"; + static final String NAME_VALUE = "library-query-name-filter"; + static final int ITEM_COUNT = 3; + static final int DEFAULT_PAGE_SIZE = 100; + static final int ZERO_PAGE = 0; + static final int NEGATIVE_PAGE = -3; + static final int SUCCESS_CODE = 1; + static final ObjectMapper JSON = new ObjectMapper(); + + private ContractTestClient client; + private LibraryQueryApiService libraryQueryApiService; + + @BeforeEach + void wire() { + ContractTestClient.Builder builder = ContractTestClient.builder(); + libraryQueryApiService = builder.mock(LibraryQueryApiService.class); + List views = java.util.Collections.nCopies(ITEM_COUNT, LibraryQuerySamples.libraryQueryView()); + Mockito.when(libraryQueryApiService.listLibraryQueries(Mockito.anyString())).thenReturn(Mono.just(views)); + client = builder.controllerWithMockedDependencies(LibraryQueryController.class).build(); + assertSame(libraryQueryApiService, client.bean(LibraryQueryApiService.class)); + System.out.println(TAG + "wired: real LibraryQueryController, mocked LibraryQueryApiService returning " + ITEM_COUNT + " views"); + } + + @AfterEach + void close() { + client.close(); + } + + private JsonNode get(Integer pageNum, Integer pageSize, HttpStatus expectedStatus) { + EntityExchangeResult result = client.web().get() + .uri(uri -> { + uri.path(LIST_URL); + if (pageNum != null) { + uri.queryParam(PAGE_NUM, pageNum); + } + if (pageSize != null) { + uri.queryParam(PAGE_SIZE, pageSize); + } + return uri.build(); + }) + .exchange().expectStatus().isEqualTo(expectedStatus) + .expectBody().returnResult(); + try { + JsonNode body = JSON.readTree(result.getResponseBody()); + System.out.println(TAG + "GET " + LIST_URL + " " + PAGE_NUM + "=" + pageNum + " " + PAGE_SIZE + "=" + pageSize + + " -> " + result.getStatus() + " " + body); + return body; + } catch (java.io.IOException e) { + throw new IllegalStateException("response is not JSON", e); + } + } + + /** Pins today's behaviour of the plan section 9 row: page 0 is a raw internal server error, not a parameter error. */ + @Test + void pageNumZeroGivesAnInternalServerErrorThroughTheStack_pinsTheSection9Row() { + JsonNode body = get(ZERO_PAGE, DEFAULT_PAGE_SIZE, HttpStatus.INTERNAL_SERVER_ERROR); + + assertEquals(BizError.INTERNAL_SERVER_ERROR.getBizErrorCode(), body.get("code").asInt()); + assertTrue(body.get("data") == null || body.get("data").isNull()); + } + + /** Same pin for a negative page number. */ + @Test + void aNegativePageNumGivesTheSameInternalServerError_pinsTheSection9Row() { + JsonNode body = get(NEGATIVE_PAGE, DEFAULT_PAGE_SIZE, HttpStatus.INTERNAL_SERVER_ERROR); + + assertEquals(BizError.INTERNAL_SERVER_ERROR.getBizErrorCode(), body.get("code").asInt()); + } + + /** Control: the name filter is passed on to the service as sent. */ + @Test + void theNameFilterReachesTheService() { + client.web().get().uri(uri -> uri.path(LIST_URL).queryParam(NAME_FILTER, NAME_VALUE).build()) + .exchange().expectStatus().isOk(); + + Mockito.verify(libraryQueryApiService).listLibraryQueries(NAME_VALUE); + } + + /** Control: page 1 is the first page. */ + @Test + void pageNumOneGivesTheFirstPage() { + JsonNode body = get(1, DEFAULT_PAGE_SIZE, HttpStatus.OK); + + assertEquals(SUCCESS_CODE, body.get("code").asInt()); + assertEquals(ITEM_COUNT, body.get("data").size()); + assertEquals(ITEM_COUNT, body.get("total").asInt()); + } + + /** Control: page size 0 takes every item, and page 0 is the only value that fails. */ + @Test + void pageSizeZeroTakesEveryItem() { + JsonNode body = get(1, 0, HttpStatus.OK); + + assertEquals(ITEM_COUNT, body.get("data").size()); + } + + /** Control: no parameter at all uses page 1 of size 100. */ + @Test + void noParametersUseTheDefaults() { + JsonNode body = get(null, null, HttpStatus.OK); + + assertEquals(ITEM_COUNT, body.get("data").size()); + assertEquals(DEFAULT_PAGE_SIZE, body.get("pageSize").asInt()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/view/LibraryQueryMetaViewTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/view/LibraryQueryMetaViewTest.java index e25b775c3f..0a19d05022 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/view/LibraryQueryMetaViewTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/query/view/LibraryQueryMetaViewTest.java @@ -17,8 +17,8 @@ * instead of NPEing. This is the crash that 500'd {@code GET /api/library-queries/dropDownList} for a * cross-environment-deployed library query whose {@code createdBy} has no matching {@code User} on the * target -- one such row would take down the whole list. The integration test - * {@link org.lowcoder.api.query.LibraryQueryApiServiceIntegrationTest} is {@code @Disabled}, so these guard - * the leaf factories directly. + * {@link org.lowcoder.api.query.LibraryQueryApiServiceIntegrationTest} lists queries whose creator exists, so + * these guard the leaf factories directly for the unresolved case. */ class LibraryQueryMetaViewTest { diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/service/FolderApiServiceTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/service/FolderApiServiceTest.java index 38bd7b044c..7adbca3e51 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/service/FolderApiServiceTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/service/FolderApiServiceTest.java @@ -9,11 +9,14 @@ import org.lowcoder.api.common.InitData; import org.lowcoder.api.common.mockuser.WithMockUser; import org.lowcoder.api.home.FolderApiService; +import org.lowcoder.api.home.FolderInfoView; import org.lowcoder.api.permission.view.PermissionItemView; import org.lowcoder.domain.folder.model.Folder; import org.lowcoder.domain.folder.service.FolderService; import org.lowcoder.domain.permission.model.ResourceRole; import org.lowcoder.sdk.constants.FieldName; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -118,20 +121,86 @@ public void update() { @Test @WithMockUser public void updateByGid() { - String id = "019053a3-f968-7a57-91fd-36f50d43713c"; + // gid of folder04; update() renames folder02, so the two tests must not share a folder + String id = "01905d61-5c7e-788a-b650-82c983f04968"; Folder newFolder = new Folder(); newFolder.setId(id); - newFolder.setName("test_update"); + newFolder.setName("test_update_by_gid"); StepVerifier.create(folderApiService.update(newFolder)) .assertNext(Assertions::assertNotNull) .verifyComplete(); StepVerifier.create(folderService.findById(id)) - .assertNext(folder -> Assertions.assertEquals("test_update", folder.getName())) + .assertNext(folder -> Assertions.assertEquals("test_update_by_gid", folder.getName())) + .verifyComplete(); + } + + @Test + @WithMockUser + public void updateWithUnchangedNameSucceeds() { + String folderId = createFolder("unchanged_name_folder", null).getFolderId(); + + Folder newFolder = new Folder(); + newFolder.setId(folderId); + newFolder.setName("unchanged_name_folder"); + newFolder.setTitle("new title"); + StepVerifier.create(folderApiService.update(newFolder)) + .assertNext(view -> Assertions.assertEquals("unchanged_name_folder", view.getName())) + .verifyComplete(); + + StepVerifier.create(folderService.findById(folderId)) + .assertNext(folder -> Assertions.assertEquals("new title", folder.getTitle())) + .verifyComplete(); + } + + @Test + @WithMockUser + public void updateRejectsNameOfSiblingInSameParent() { + String parentId = createFolder("sibling_conflict_parent", null).getFolderId(); + createFolder("sibling_a", parentId); + String siblingBId = createFolder("sibling_b", parentId).getFolderId(); + + Folder newFolder = new Folder(); + newFolder.setId(siblingBId); + newFolder.setName("sibling_a"); + StepVerifier.create(folderApiService.update(newFolder)) + .expectErrorMatches(throwable -> throwable instanceof BizException bizException + && bizException.getError() == BizError.FOLDER_NAME_CONFLICT) + .verify(); + + StepVerifier.create(folderService.findById(siblingBId)) + .assertNext(folder -> Assertions.assertEquals("sibling_b", folder.getName())) .verifyComplete(); } + @Test + @WithMockUser + public void updateAllowsNameUsedOnlyInAnotherParent() { + createFolder("name_used_at_root", null); + String parentId = createFolder("other_parent", null).getFolderId(); + String childId = createFolder("child_to_rename", parentId).getFolderId(); + + Folder newFolder = new Folder(); + newFolder.setId(childId); + newFolder.setName("name_used_at_root"); + StepVerifier.create(folderApiService.update(newFolder)) + .assertNext(view -> { + Assertions.assertEquals("name_used_at_root", view.getName()); + Assertions.assertEquals(parentId, view.getParentFolderId()); + }) + .verifyComplete(); + } + + private FolderInfoView createFolder(String name, String parentFolderId) { + Folder folder = new Folder(); + folder.setName(name); + folder.setParentFolderId(parentFolderId); + FolderInfoView created = folderApiService.create(folder).block(); + assertNotNull(created); + return created; + } + @Test @WithMockUser public void move() { diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/service/impl/ApplicationHistorySnapshotServiceTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/service/impl/ApplicationHistorySnapshotServiceTest.java index e3a427cad6..d2f4365a18 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/service/impl/ApplicationHistorySnapshotServiceTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/service/impl/ApplicationHistorySnapshotServiceTest.java @@ -2,21 +2,18 @@ import com.google.common.collect.ImmutableMap; import lombok.extern.slf4j.Slf4j; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.Test; import org.lowcoder.domain.application.model.ApplicationHistorySnapshot; import org.lowcoder.domain.application.service.ApplicationHistorySnapshotService; import org.lowcoder.sdk.models.HasIdAndAuditing; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.PageRequest; -import org.springframework.test.context.junit4.SpringRunner; import reactor.test.StepVerifier; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; @SuppressWarnings({"ReactiveStreamsNullableInLambdaInTransform"}) -@RunWith(SpringRunner.class) @SpringBootTest @Slf4j public class ApplicationHistorySnapshotServiceTest { diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/GroupApiServiceImplTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/GroupApiServiceImplTest.java new file mode 100644 index 0000000000..b5f16b4126 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/GroupApiServiceImplTest.java @@ -0,0 +1,1167 @@ +package org.lowcoder.api.usermanagement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.api.bizthreshold.AbstractBizThresholdChecker; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.api.usermanagement.view.CreateGroupRequest; +import org.lowcoder.api.usermanagement.view.GroupMemberAggregateView; +import org.lowcoder.api.usermanagement.view.GroupMemberView; +import org.lowcoder.api.usermanagement.view.GroupView; +import org.lowcoder.api.usermanagement.view.OrgMemberListView; +import org.lowcoder.api.usermanagement.view.UpdateGroupRequest; +import org.lowcoder.api.usermanagement.view.UpdateRoleRequest; +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.group.model.GroupMember; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.group.service.GroupService; +import org.lowcoder.domain.group.util.SystemGroups; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.model.UserState; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit test of {@link GroupApiServiceImpl} with every collaborator mocked: the visitor/role resolution and read + * permission of the roster, the manage-permission gate of every mutation, the member updates and the group listing. + * + *

    "No mutation" is asserted by subscription counters on the mocked service Monos (a Mono that is built but never + * subscribed does not mutate), not by {@code verify(never())} on the call: {@code updateRoleForMember} and + * {@code deleteGroup} build the service Mono eagerly, then chain it with {@code then(..)}. + * + *

    Pinned production defects (owner decision D-6: fixes are deferred, a fix changes these tests on purpose): + *

      + *
    • A1 {@code subList}: {@code getGroupMembers} slices with an unguarded {@code subList} + * (see {@link #getGroupMembers_pageBeyondLastPage_failsWithIllegalArgumentException} and + * {@link #getGroupMembers_pageZero_failsWithIndexOutOfBounds}); the search variant guards it.
    • + *
    • A1 {@code getPotentialGroupMembers} has no role or same-org check + * (see {@link #getPotentialGroupMembers_hasNoPermissionOrSameOrgCheck_pinsDefect}).
    • + *
    + */ +@ExtendWith(MockitoExtension.class) +class GroupApiServiceImplTest { + + private static final String LOG_PREFIX = "[GroupApiServiceImplTest] "; + + private static final String ORG_ID = "org-1"; + private static final String OTHER_ORG_ID = "org-2"; + private static final String GROUP_ID = "group-1"; + private static final String VISITOR_ID = "visitor-1"; + private static final String TARGET_USER_ID = "target-user"; + private static final String GROUP_NAME = "Group One"; + private static final String DYNAMIC_RULE = "{\"rule\":true}"; + private static final String ORG_MEMBER_STATE = "CURRENT"; + private static final long CREATED_AT_MILLIS = 5_000L; + + private static final String ROLE_MEMBER = MemberRole.MEMBER.getValue(); + private static final String ROLE_ADMIN = MemberRole.ADMIN.getValue(); + private static final String ROLE_SUPER_ADMIN = MemberRole.SUPER_ADMIN.getValue(); + + private static final String STATS_ADMIN_COUNT = "adminUserCount"; + private static final String STATS_USER_COUNT = "userCount"; + private static final String STATS_USERS = "users"; + + private static final String ALL_USERS_SEARCH_REGEX = ".*"; + + @Mock + private SessionUserService sessionUserService; + @Mock + private GroupMemberService groupMemberService; + @Mock + private UserService userService; + @Mock + private GroupService groupService; + @Mock + private AbstractBizThresholdChecker bizThresholdChecker; + @Mock + private OrgMemberService orgMemberService; + + private GroupApiServiceImpl service; + + /** Subscriptions of the mocked mutating Monos; a rejected mutation must leave it at 0. */ + private final AtomicInteger mutations = new AtomicInteger(); + /** Subscription order of the mocked quota check and mutation, for the developer-quota ordering test. */ + private final List events = new ArrayList<>(); + + @BeforeEach + void setUp() { + service = new GroupApiServiceImpl(sessionUserService, groupMemberService, userService, groupService, + bizThresholdChecker, orgMemberService); + } + + // ------------------------------------------------------------------ fixtures + + private static void say(String format, Object... args) { + System.out.println(LOG_PREFIX + String.format(format, args)); + } + + private static OrgMember orgMember(MemberRole role) { + return new OrgMember(ORG_ID, VISITOR_ID, role, ORG_MEMBER_STATE, 0L); + } + + private static GroupMember groupMember(String userId, MemberRole role, long joinTime) { + return new GroupMember(GROUP_ID, userId, role, ORG_ID, joinTime); + } + + private static User user(String id, String name) { + User user = new User(); + user.setId(id); + user.setName(name); + return user; + } + + private static Group group(String id, String orgId, String type, Boolean allUsersGroup) { + return Group.builder() + .id(id) + .gid("gid-" + id) + .name(GROUP_NAME) + .organizationId(orgId) + .type(type) + .allUsersGroup(allUsersGroup) + .createdAt(Instant.ofEpochMilli(CREATED_AT_MILLIS)) + .build(); + } + + private static Group normalGroup() { + return group(GROUP_ID, ORG_ID, null, null); + } + + private Mono counting(T value) { + return Mono.defer(() -> { + mutations.incrementAndGet(); + return Mono.just(value); + }); + } + + private Mono countingVoid() { + return Mono.defer(() -> { + mutations.incrementAndGet(); + return Mono.empty(); + }); + } + + /** + * Stubs what {@code getGroupAndOrgMemberInfo} reads. A null {@code groupMember} is "visitor not in the group", + * a null {@code orgMember} is "no visitor org member", a null {@code group} is "unknown group". Lenient: which of + * the lookups run depends on the branch under test. + */ + private void stubVisitor(OrgMember orgMember, GroupMember groupMember, Group group) { + lenient().when(sessionUserService.getVisitorId()).thenReturn(Mono.just(VISITOR_ID)); + lenient().when(groupMemberService.getGroupMember(GROUP_ID, VISITOR_ID)) + .thenReturn(groupMember == null ? Mono.empty() : Mono.just(groupMember)); + lenient().when(sessionUserService.getVisitorOrgMemberCache()) + .thenReturn(orgMember == null ? Mono.empty() : Mono.just(orgMember)); + lenient().when(groupService.getById(GROUP_ID)).thenReturn(group == null ? Mono.empty() : Mono.just(group)); + } + + private static GroupMember visitorGroupMember(MemberRole role) { + return role == null ? null : groupMember(VISITOR_ID, role, 1L); + } + + private static void assertBizError(Throwable error, BizError expected) { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(expected); + } + + private static List userIds(GroupMemberAggregateView view) { + return view.getMembers().stream().map(GroupMemberView::getUserId).toList(); + } + + /** Roster of {@code size} members u1..uN, joined at i*100, with users named user1..userN. */ + private static List roster(int size) { + return IntStream.rangeClosed(1, size) + .mapToObj(i -> groupMember("u" + i, MemberRole.MEMBER, i * 100L)) + .toList(); + } + + private static Map usersOf(int size) { + return IntStream.rangeClosed(1, size).boxed() + .collect(java.util.stream.Collectors.toMap(i -> "u" + i, i -> user("u" + i, "user" + i))); + } + + /** The two roster readers share the permission and paging code; each test that can runs against both. */ + enum Variant { + PLAIN { + @Override + Mono call(GroupApiServiceImpl service, int page, int count) { + return service.getGroupMembers(GROUP_ID, page, count); + } + }, + SEARCH { + @Override + Mono call(GroupApiServiceImpl service, int page, int count) { + return service.getGroupMembersForSearch(GROUP_ID, null, null, null, null, page, count); + } + }; + + abstract Mono call(GroupApiServiceImpl service, int page, int count); + } + + private void stubRoster(Variant variant, List members) { + if (variant == Variant.PLAIN) { + when(groupMemberService.getGroupMembers(GROUP_ID)).thenReturn(Mono.just(members)); + } else { + when(groupMemberService.getGroupMembersByIdAndRole(GROUP_ID, null)).thenReturn(Mono.just(members)); + } + } + + private void verifyRosterNotRead(Variant variant) { + if (variant == Variant.PLAIN) { + verify(groupMemberService, never()).getGroupMembers(GROUP_ID); + } else { + verify(groupMemberService, never()).getGroupMembersByIdAndRole(any(), any()); + } + } + + // ------------------------------------------------------------------ roster: visitor role and read permission + + static Stream visitorRoles() { + List args = new ArrayList<>(); + for (Variant variant : Variant.values()) { + args.add(Arguments.of(variant, MemberRole.SUPER_ADMIN, null, ROLE_SUPER_ADMIN)); + args.add(Arguments.of(variant, MemberRole.MEMBER, MemberRole.SUPER_ADMIN, ROLE_SUPER_ADMIN)); + args.add(Arguments.of(variant, MemberRole.ADMIN, MemberRole.SUPER_ADMIN, ROLE_SUPER_ADMIN)); + args.add(Arguments.of(variant, MemberRole.ADMIN, null, ROLE_ADMIN)); + args.add(Arguments.of(variant, MemberRole.ADMIN, MemberRole.MEMBER, ROLE_ADMIN)); + args.add(Arguments.of(variant, MemberRole.MEMBER, MemberRole.ADMIN, ROLE_ADMIN)); + args.add(Arguments.of(variant, MemberRole.MEMBER, MemberRole.MEMBER, ROLE_MEMBER)); + } + return args.stream(); + } + + /** + * Catches a wrong visitor role in the roster response (SUPER_ADMIN above ADMIN above MEMBER, taken from either the + * org membership or the group membership) and a read permission that excludes org admins or group members. + */ + @ParameterizedTest(name = "{0}: org {1}, group {2} -> {3}") + @MethodSource("visitorRoles") + void roster_resolvesVisitorRole(Variant variant, MemberRole orgRole, MemberRole groupRole, String expectedRole) { + stubVisitor(orgMember(orgRole), visitorGroupMember(groupRole), normalGroup()); + stubRoster(variant, roster(1)); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(usersOf(1))); + + StepVerifier.create(variant.call(service, 1, 10)) + .assertNext(view -> assertThat(view.getVisitorRole()).isEqualTo(expectedRole)) + .verifyComplete(); + say("%s: org=%s group=%s -> visitorRole=%s", variant, orgRole, groupRole, expectedRole); + } + + /** + * Catches a roster read open to a user who is neither a member of the group nor an admin: NOT_AUTHORIZED and the + * roster is never read. + */ + @ParameterizedTest + @EnumSource(Variant.class) + void roster_outsiderWithoutGroupMembership_isNotAuthorized(Variant variant) { + stubVisitor(orgMember(MemberRole.MEMBER), null, normalGroup()); + + StepVerifier.create(variant.call(service, 1, 10)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.NOT_AUTHORIZED)) + .verify(); + verifyRosterNotRead(variant); + say("%s: org member without group membership -> NOT_AUTHORIZED, roster not read", variant); + } + + static Stream invalidGroupScenarios() { + List args = new ArrayList<>(); + for (Variant variant : Variant.values()) { + args.add(Arguments.of(variant, "group of another organization", MemberRole.SUPER_ADMIN, + group(GROUP_ID, OTHER_ORG_ID, null, null))); + args.add(Arguments.of(variant, "unknown group", MemberRole.ADMIN, null)); + args.add(Arguments.of(variant, "no visitor org member", null, normalGroup())); + } + return args.stream(); + } + + /** + * Catches the cross-tenant read: a group whose organization differs from the visitor's org member must be + * INVALID_GROUP_ID even for a super admin, as must an unknown group and a missing visitor org member. + */ + @ParameterizedTest(name = "{0}: {1}") + @MethodSource("invalidGroupScenarios") + void roster_groupNotInVisitorOrganization_isInvalidGroupId(Variant variant, String label, MemberRole orgRole, + Group group) { + stubVisitor(orgRole == null ? null : orgMember(orgRole), visitorGroupMember(MemberRole.ADMIN), group); + + StepVerifier.create(variant.call(service, 1, 10)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_GROUP_ID)) + .verify(); + verifyRosterNotRead(variant); + say("%s: %s -> INVALID_GROUP_ID, roster not read", variant, label); + } + + @ParameterizedTest + @EnumSource(Variant.class) + void roster_emptyRoster_returnsEmptyListWithTotalZeroAndNoUserLookup(Variant variant) { + stubVisitor(orgMember(MemberRole.ADMIN), null, normalGroup()); + stubRoster(variant, List.of()); + + StepVerifier.create(variant.call(service, 1, 10)) + .assertNext(view -> { + assertThat(view.getMembers()).isEmpty(); + assertThat(view.getTotal()).isZero(); + assertThat(view.getVisitorRole()).isEqualTo(ROLE_ADMIN); + }) + .verifyComplete(); + verifyNoInteractions(userService); + say("%s: empty roster -> no members, total 0, users not looked up", variant); + } + + /** + * Catches members whose user record is gone leaking into the page as null views, and a total that still counts them. + */ + @ParameterizedTest + @EnumSource(Variant.class) + void roster_membersWithoutUserRecord_areDroppedFromListAndTotal(Variant variant) { + stubVisitor(orgMember(MemberRole.ADMIN), null, normalGroup()); + stubRoster(variant, roster(3)); + Map twoOfThree = Map.of("u1", user("u1", "user1"), "u3", user("u3", "user3")); + ArgumentCaptor> requestedIds = ArgumentCaptor.forClass(Collection.class); + when(userService.getByIds(requestedIds.capture())).thenReturn(Mono.just(twoOfThree)); + + StepVerifier.create(variant.call(service, 1, 10)) + .assertNext(view -> { + assertThat(userIds(view)).containsExactly("u1", "u3"); + assertThat(view.getTotal()).isEqualTo(2); + }) + .verifyComplete(); + assertThat(requestedIds.getValue()).containsExactlyInAnyOrder("u1", "u2", "u3"); + say("%s: u2 has no user record -> dropped, total 2; user lookup asked for %s", variant, requestedIds.getValue()); + } + + static Stream pages() { + List args = new ArrayList<>(); + for (Variant variant : Variant.values()) { + args.add(Arguments.of(variant, 5, 1, 2, List.of("u1", "u2"))); + args.add(Arguments.of(variant, 5, 2, 2, List.of("u3", "u4"))); + args.add(Arguments.of(variant, 5, 3, 2, List.of("u5"))); + args.add(Arguments.of(variant, 5, 1, 10, List.of("u1", "u2", "u3", "u4", "u5"))); + args.add(Arguments.of(variant, 5, 1, 0, List.of("u1", "u2", "u3", "u4", "u5"))); + // the page right after an exactly full last page: from == to, an empty page and no error + args.add(Arguments.of(variant, 4, 3, 2, List.of())); + } + return args.stream(); + } + + /** + * Catches an off-by-one in the page slice, a total that is the page size instead of the roster size, and the + * {@code count == 0 means all} rule; pageNum and pageSize are echoed. + */ + @ParameterizedTest(name = "{0}: {1} members, page {2}, count {3} -> {4}") + @MethodSource("pages") + void roster_pagination_returnsRequestedSliceAndFullTotal(Variant variant, int size, int page, int count, + List expectedIds) { + stubVisitor(orgMember(MemberRole.ADMIN), null, normalGroup()); + stubRoster(variant, roster(size)); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(usersOf(size))); + + StepVerifier.create(variant.call(service, page, count)) + .assertNext(view -> { + assertThat(userIds(view)).isEqualTo(expectedIds); + assertThat(view.getTotal()).isEqualTo(size); + assertThat(view.getPageNum()).isEqualTo(page); + assertThat(view.getPageSize()).isEqualTo(count); + }) + .verifyComplete(); + say("%s: %d members, page %d count %d -> %s, total %d", variant, size, page, count, expectedIds, size); + } + + /** + * Pins defect A1 (plan section 9): {@code getGroupMembers} slices with {@code list.subList((page - 1) * count, + * min(page * count, total))} unguarded. Three members, page 3 of size 2 gives subList(4, 3) and the call fails + * with an IllegalArgumentException instead of returning an empty page (the search variant guards it, see + * {@link #roster_pagination_returnsRequestedSliceAndFullTotal}). A fix changes this test on purpose. + */ + @ParameterizedTest + @ValueSource(ints = {3, 4}) + void getGroupMembers_pageBeyondLastPage_failsWithIllegalArgumentException(int page) { + stubVisitor(orgMember(MemberRole.ADMIN), null, normalGroup()); + stubRoster(Variant.PLAIN, roster(3)); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(usersOf(3))); + + StepVerifier.create(service.getGroupMembers(GROUP_ID, page, 2)) + .expectErrorSatisfies(error -> assertThat(error).isExactlyInstanceOf(IllegalArgumentException.class)) + .verify(); + say("getGroupMembers: 3 members, page %d size 2 -> IllegalArgumentException (defect A1 pinned)", page); + } + + /** + * Pins defect A1 (plan section 9), same unguarded {@code subList}: page 0 gives a negative fromIndex and the call + * fails with an IndexOutOfBoundsException. A fix changes this test on purpose. + */ + @Test + void getGroupMembers_pageZero_failsWithIndexOutOfBounds() { + stubVisitor(orgMember(MemberRole.ADMIN), null, normalGroup()); + stubRoster(Variant.PLAIN, roster(3)); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(usersOf(3))); + + StepVerifier.create(service.getGroupMembers(GROUP_ID, 0, 2)) + .expectErrorSatisfies(error -> assertThat(error).isInstanceOf(IndexOutOfBoundsException.class)) + .verify(); + say("getGroupMembers: page 0 size 2 -> IndexOutOfBoundsException (defect A1 pinned)"); + } + + // ------------------------------------------------------------------ roster: search, role filter, sort + + private List sortRoster() { + return List.of( + groupMember("u1", MemberRole.MEMBER, 300), + groupMember("u2", MemberRole.ADMIN, 100), + groupMember("u3", MemberRole.SUPER_ADMIN, 200), + groupMember("u4", MemberRole.MEMBER, 400)); + } + + private Map sortUsers() { + return Map.of("u1", user("u1", "bravo"), "u2", user("u2", "Alpha"), + "u3", user("u3", "Delta"), "u4", user("u4", "charlie")); + } + + private GroupMemberAggregateView search(String search, String sort, String order, int page, int size, + List roster, Map users) { + stubVisitor(orgMember(MemberRole.ADMIN), null, normalGroup()); + when(groupMemberService.getGroupMembersByIdAndRole(GROUP_ID, null)).thenReturn(Mono.just(roster)); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(users)); + return service.getGroupMembersForSearch(GROUP_ID, search, null, sort, order, page, size).block(); + } + + static Stream sorts() { + return Stream.of( + Arguments.of("userName", "asc", List.of("u2", "u1", "u4", "u3"), null), + Arguments.of("userName", "desc", List.of("u3", "u4", "u1", "u2"), null), + Arguments.of("USERNAME", "DESC", List.of("u3", "u4", "u1", "u2"), null), + Arguments.of("userName", null, List.of("u2", "u1", "u4", "u3"), null), + Arguments.of("userName", "sideways", List.of("u2", "u1", "u4", "u3"), null), + Arguments.of("role", "asc", List.of("u2", "u1", "u4", "u3"), null), + Arguments.of("role", "desc", List.of("u3", "u1", "u4", "u2"), null), + Arguments.of("joinTime", "asc", List.of("u2", "u3", "u1", "u4"), null), + Arguments.of("joinTime", "desc", List.of("u4", "u1", "u3", "u2"), null), + Arguments.of("unknownKey", "desc", List.of("u1", "u2", "u3", "u4"), null), + Arguments.of("", "desc", List.of("u1", "u2", "u3", "u4"), null), + Arguments.of(null, "desc", List.of("u1", "u2", "u3", "u4"), null), + // desc is reversed() over nullsLast: the member without a user name (u4) comes FIRST (pinned + // behaviour, the usual database convention), then Delta, bravo, Alpha + Arguments.of("userName", "desc", List.of("u4", "u3", "u1", "u2"), "u4")); + } + + /** + * Catches a wrong comparator per sort key, a case-sensitive userName compare (names bravo/Alpha/Delta/charlie sort + * differently case-sensitively), a descending flag that is ignored or applied to the wrong key, and an unknown or + * blank sort key that reorders the roster instead of leaving it as stored. The last case pins that a descending userName + * sort puts a member without a name first ({@code reversed()} over {@code nullsLast}). + */ + @ParameterizedTest(name = "sort {0} {1} -> {2} (nameless: {3})") + @MethodSource("sorts") + void search_sortsByKeyAndOrder(String sort, String order, List expectedIds, String nameLessUserId) { + Map users = new java.util.HashMap<>(sortUsers()); + if (nameLessUserId != null) { + users.put(nameLessUserId, user(nameLessUserId, null)); + } + GroupMemberAggregateView view = search(null, sort, order, 1, 10, sortRoster(), users); + + assertThat(userIds(view)).isEqualTo(expectedIds); + assertThat(view.getTotal()).isEqualTo(4); + say("search: sort=%s order=%s nameless=%s -> %s", sort, order, nameLessUserId, expectedIds); + } + + /** + * Catches nulls not sorted last on an ascending userName sort (a member whose user has no name). + */ + @Test + void search_sortByUserNameAscending_putsMembersWithoutNameLast() { + Map users = Map.of("u1", user("u1", null), "u2", user("u2", "Alpha"), + "u3", user("u3", "bravo"), "u4", user("u4", "charlie")); + + GroupMemberAggregateView view = search(null, "userName", "asc", 1, 10, sortRoster(), users); + + assertThat(userIds(view)).containsExactly("u2", "u3", "u4", "u1"); + say("search: ascending userName sort puts the nameless member last: %s", userIds(view)); + } + + static Stream searchTerms() { + return Stream.of( + Arguments.of("ali", List.of("u1", "u3")), + Arguments.of("ALI", List.of("u1", "u3")), + Arguments.of("bob", List.of("u2")), + Arguments.of("nobody", List.of()), + Arguments.of(null, List.of("u1", "u2", "u3", "u4")), + Arguments.of("", List.of("u1", "u2", "u3", "u4")), + Arguments.of(" ", List.of("u1", "u2", "u3", "u4"))); + } + + /** + * Catches a case-sensitive match, a search that ignores the user name, a blank term that filters everything, a + * member without a name matching a real term, and a total that is not the filtered size. + */ + @ParameterizedTest(name = "search [{0}] -> {1}") + @MethodSource("searchTerms") + void search_filtersByUserNameIgnoringCase(String term, List expectedIds) { + Map users = Map.of("u1", user("u1", "Alice"), "u2", user("u2", "bob"), + "u3", user("u3", "ALIcia"), "u4", user("u4", null)); + + GroupMemberAggregateView view = search(term, null, null, 1, 10, roster(4), users); + + assertThat(userIds(view)).isEqualTo(expectedIds); + assertThat(view.getTotal()).isEqualTo(expectedIds.size()); + say("search: term [%s] -> %s, total %d", term, expectedIds, expectedIds.size()); + } + + /** + * Catches paging applied before the filter: page 2 of size 1 over the two matches is the second match, and a + * page beyond the end of the filtered list is empty (the guard the plain variant lacks) with the total kept. + */ + @Test + void search_pagesTheFilteredList_andPageBeyondEndIsEmpty() { + Map users = Map.of("u1", user("u1", "Alice"), "u2", user("u2", "bob"), + "u3", user("u3", "ALIcia"), "u4", user("u4", "dora")); + + GroupMemberAggregateView second = search("ali", null, null, 2, 1, roster(4), users); + assertThat(userIds(second)).containsExactly("u3"); + assertThat(second.getTotal()).isEqualTo(2); + + GroupMemberAggregateView beyond = search("ali", null, null, 5, 1, roster(4), users); + assertThat(beyond.getMembers()).isEmpty(); + assertThat(beyond.getTotal()).isEqualTo(2); + say("search: filtered page 2 -> %s; page 5 -> empty, total %d", userIds(second), beyond.getTotal()); + } + + /** + * Catches the role filter not reaching the service: the role string is forwarded to + * {@code getGroupMembersByIdAndRole} unchanged (the stub only matches "admin"). + */ + @Test + void search_forwardsRoleFilterToTheMemberService() { + stubVisitor(orgMember(MemberRole.MEMBER), visitorGroupMember(MemberRole.MEMBER), normalGroup()); + when(groupMemberService.getGroupMembersByIdAndRole(GROUP_ID, ROLE_ADMIN)) + .thenReturn(Mono.just(List.of(groupMember("u1", MemberRole.ADMIN, 1L)))); + when(userService.getByIds(anyCollection())).thenReturn(Mono.just(usersOf(1))); + + StepVerifier.create(service.getGroupMembersForSearch(GROUP_ID, null, ROLE_ADMIN, null, null, 1, 10)) + .assertNext(view -> { + assertThat(userIds(view)).containsExactly("u1"); + assertThat(view.getVisitorRole()).isEqualTo(ROLE_MEMBER); + }) + .verifyComplete(); + say("search: role filter %s forwarded to getGroupMembersByIdAndRole", ROLE_ADMIN); + } + + // ------------------------------------------------------------------ mutations: manage permission + + enum Mutation { + ADD_MEMBER, UPDATE_ROLE, DELETE_GROUP, UPDATE_GROUP, REMOVE_USER + } + + private void stubMutation(Mutation mutation) { + switch (mutation) { + case ADD_MEMBER -> lenient().when(groupMemberService.addMember(ORG_ID, GROUP_ID, TARGET_USER_ID, MemberRole.MEMBER)) + .thenReturn(counting(true)); + case UPDATE_ROLE -> lenient().when(groupMemberService.updateMemberRole(GROUP_ID, TARGET_USER_ID, MemberRole.ADMIN)) + .thenReturn(counting(true)); + case DELETE_GROUP -> lenient().when(groupService.delete(GROUP_ID)).thenReturn(countingVoid()); + case UPDATE_GROUP -> lenient().when(groupService.updateGroup(any(Group.class))).thenReturn(counting(true)); + case REMOVE_USER -> lenient().when(groupMemberService.removeMember(GROUP_ID, TARGET_USER_ID)) + .thenReturn(counting(true)); + } + } + + private Mono invoke(Mutation mutation) { + UpdateRoleRequest roleRequest = new UpdateRoleRequest(); + roleRequest.setUserId(TARGET_USER_ID); + roleRequest.setRole(ROLE_ADMIN); + UpdateGroupRequest updateRequest = new UpdateGroupRequest(); + updateRequest.setGroupName("renamed"); + return switch (mutation) { + case ADD_MEMBER -> service.addGroupMember(GROUP_ID, TARGET_USER_ID, ROLE_MEMBER); + case UPDATE_ROLE -> service.updateRoleForMember(GROUP_ID, roleRequest); + case DELETE_GROUP -> service.deleteGroup(GROUP_ID); + case UPDATE_GROUP -> service.update(GROUP_ID, updateRequest); + case REMOVE_USER -> service.removeUser(GROUP_ID, TARGET_USER_ID); + }; + } + + static Stream mutationsWithoutManagePermission() { + List args = new ArrayList<>(); + for (Mutation mutation : Mutation.values()) { + args.add(Arguments.of(mutation, "org member outside the group", null)); + args.add(Arguments.of(mutation, "plain group member", MemberRole.MEMBER)); + } + return args.stream(); + } + + /** + * Catches mutate-before-authorise: a visitor who is neither a group admin nor an org admin gets NOT_AUTHORIZED and + * the mocked mutation Mono is never subscribed, for every mutating call of the service. + */ + @ParameterizedTest(name = "{0} as {1}") + @MethodSource("mutationsWithoutManagePermission") + void mutation_withoutManagePermission_isNotAuthorizedAndMutatesNothing(Mutation mutation, String label, + MemberRole groupRole) { + stubVisitor(orgMember(MemberRole.MEMBER), visitorGroupMember(groupRole), normalGroup()); + stubMutation(mutation); + + StepVerifier.create(invoke(mutation)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.NOT_AUTHORIZED)) + .verify(); + assertThat(mutations).hasValue(0); + say("%s as %s -> NOT_AUTHORIZED, mutation subscriptions: %d", mutation, label, mutations.get()); + } + + static Stream mutationsWithManagePermission() { + List args = new ArrayList<>(); + for (Mutation mutation : Mutation.values()) { + args.add(Arguments.of(mutation, MemberRole.ADMIN, null)); + args.add(Arguments.of(mutation, MemberRole.SUPER_ADMIN, null)); + args.add(Arguments.of(mutation, MemberRole.MEMBER, MemberRole.ADMIN)); + args.add(Arguments.of(mutation, MemberRole.MEMBER, MemberRole.SUPER_ADMIN)); + } + return args.stream(); + } + + /** + * Catches a manage permission that drops one of its four sources (org admin, org super admin, group admin, group + * super admin): the mutation runs exactly once and returns true. + */ + @ParameterizedTest(name = "{0}: org {1}, group {2}") + @MethodSource("mutationsWithManagePermission") + void mutation_withManagePermission_runsOnceAndReturnsTrue(Mutation mutation, MemberRole orgRole, + MemberRole groupRole) { + stubVisitor(orgMember(orgRole), visitorGroupMember(groupRole), normalGroup()); + stubMutation(mutation); + + StepVerifier.create(invoke(mutation)).expectNext(true).verifyComplete(); + assertThat(mutations).hasValue(1); + say("%s: org %s, group %s -> true after one mutation", mutation, orgRole, groupRole); + } + + // ------------------------------------------------------------------ addGroupMember: developer quota + + /** + * Catches the developer quota bypass: for a dev group {@code checkMaxDeveloperCount(orgId, groupId, newUserId)} + * runs and completes before {@code addMember} is subscribed. + */ + @Test + void addGroupMember_devGroup_checksDeveloperQuotaBeforeAddingTheMember() { + stubVisitor(orgMember(MemberRole.ADMIN), null, group(GROUP_ID, ORG_ID, SystemGroups.DEV, null)); + when(bizThresholdChecker.checkMaxDeveloperCount(ORG_ID, GROUP_ID, TARGET_USER_ID)) + .thenReturn(Mono.defer(() -> { + events.add("quota"); + return Mono.empty(); + })); + when(groupMemberService.addMember(ORG_ID, GROUP_ID, TARGET_USER_ID, MemberRole.ADMIN)) + .thenReturn(Mono.defer(() -> { + events.add("addMember"); + return Mono.just(true); + })); + + StepVerifier.create(service.addGroupMember(GROUP_ID, TARGET_USER_ID, ROLE_ADMIN)).expectNext(true).verifyComplete(); + + assertThat(events).containsExactly("quota", "addMember"); + say("addGroupMember dev group: subscription order %s", events); + } + + /** + * Catches a quota error that does not stop the add: EXCEED_MAX_DEVELOPER_COUNT propagates and {@code addMember} + * is never subscribed. + */ + @Test + void addGroupMember_devGroupOverQuota_failsAndAddsNobody() { + stubVisitor(orgMember(MemberRole.ADMIN), null, group(GROUP_ID, ORG_ID, SystemGroups.DEV, null)); + when(bizThresholdChecker.checkMaxDeveloperCount(ORG_ID, GROUP_ID, TARGET_USER_ID)) + .thenReturn(Mono.error(new BizException(BizError.EXCEED_MAX_DEVELOPER_COUNT, "EXCEED_MAX_DEVELOPER_COUNT"))); + when(groupMemberService.addMember(ORG_ID, GROUP_ID, TARGET_USER_ID, MemberRole.MEMBER)).thenReturn(counting(true)); + + StepVerifier.create(service.addGroupMember(GROUP_ID, TARGET_USER_ID, ROLE_MEMBER)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.EXCEED_MAX_DEVELOPER_COUNT)) + .verify(); + assertThat(mutations).hasValue(0); + say("addGroupMember dev group over quota -> EXCEED_MAX_DEVELOPER_COUNT, member not added"); + } + + /** + * Catches the quota check running for a normal group (it would reject adds to ordinary groups) and the role name + * mapping: a known name maps to its role, an unknown name to MEMBER. + */ + @ParameterizedTest + @MethodSource("roleNames") + void addGroupMember_normalGroup_skipsDeveloperQuotaAndMapsRoleName(String roleName, MemberRole expectedRole) { + stubVisitor(orgMember(MemberRole.ADMIN), null, normalGroup()); + when(groupMemberService.addMember(ORG_ID, GROUP_ID, TARGET_USER_ID, expectedRole)).thenReturn(Mono.just(true)); + + StepVerifier.create(service.addGroupMember(GROUP_ID, TARGET_USER_ID, roleName)).expectNext(true).verifyComplete(); + + verifyNoInteractions(bizThresholdChecker); + say("addGroupMember normal group: role name [%s] -> %s, quota check not run", roleName, expectedRole); + } + + static Stream roleNames() { + return Stream.of( + Arguments.of(ROLE_ADMIN, MemberRole.ADMIN), + Arguments.of(ROLE_MEMBER, MemberRole.MEMBER), + Arguments.of("no-such-role", MemberRole.MEMBER)); + } + + // ------------------------------------------------------------------ leaveGroup, deleteGroup, create, update, removeUser + + static Stream leaveCases() { + return Stream.of( + Arguments.of("visitor is the only admin", List.of(VISITOR_ID), false), + Arguments.of("the only admin is someone else", List.of("other-admin"), true), + Arguments.of("visitor and another admin", List.of(VISITOR_ID, "other-admin"), true), + Arguments.of("group without admins", List.of(), true)); + } + + /** + * Catches a group left without any admin (the last admin may not leave) and the opposite, a leave blocked when + * the visitor is not the sole admin. + */ + @ParameterizedTest(name = "{0}: leaves = {2}") + @MethodSource("leaveCases") + void leaveGroup_onlyTheLastAdminIsBlocked(String label, List adminIds, boolean leaves) { + when(sessionUserService.getVisitorId()).thenReturn(Mono.just(VISITOR_ID)); + when(groupMemberService.getAllGroupAdmin(GROUP_ID)).thenReturn(Mono.just( + adminIds.stream().map(id -> groupMember(id, MemberRole.ADMIN, 1L)).toList())); + lenient().when(groupMemberService.removeMember(GROUP_ID, VISITOR_ID)).thenReturn(counting(true)); + + if (leaves) { + StepVerifier.create(service.leaveGroup(GROUP_ID)).expectNext(true).verifyComplete(); + assertThat(mutations).hasValue(1); + } else { + StepVerifier.create(service.leaveGroup(GROUP_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.CANNOT_LEAVE_GROUP)) + .verify(); + assertThat(mutations).hasValue(0); + } + say("leaveGroup: %s -> leaves=%s", label, leaves); + } + + static Stream systemGroups() { + return Stream.of( + Arguments.of(SystemGroups.DEV, null), + Arguments.of(SystemGroups.ALL_USER, null), + Arguments.of(null, Boolean.TRUE)); + } + + /** + * Catches a system group (dev group, all-users group by type or by flag) being deletable: CANNOT_DELETE_SYSTEM_GROUP + * and the delete Mono is never subscribed. + */ + @ParameterizedTest(name = "type {0}, allUsersGroup {1}") + @MethodSource("systemGroups") + void deleteGroup_systemGroup_cannotBeDeleted(String type, Boolean allUsersGroup) { + stubVisitor(orgMember(MemberRole.ADMIN), null, group(GROUP_ID, ORG_ID, type, allUsersGroup)); + when(groupService.delete(GROUP_ID)).thenReturn(countingVoid()); + + StepVerifier.create(service.deleteGroup(GROUP_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.CANNOT_DELETE_SYSTEM_GROUP)) + .verify(); + assertThat(mutations).hasValue(0); + say("deleteGroup: system group (type=%s, allUsersGroup=%s) -> CANNOT_DELETE_SYSTEM_GROUP", type, allUsersGroup); + } + + @ParameterizedTest + @EnumSource(value = MemberRole.class, names = {"ADMIN", "SUPER_ADMIN"}) + void create_admin_checksGroupQuotaThenCreatesTheGroupInTheVisitorsOrganization(MemberRole role) { + OrgMember admin = orgMember(role); + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(admin)); + when(bizThresholdChecker.checkMaxGroupCount(admin)).thenReturn(Mono.defer(() -> { + events.add("quota"); + return Mono.empty(); + })); + ArgumentCaptor created = ArgumentCaptor.forClass(Group.class); + Group stored = normalGroup(); + when(groupService.create(created.capture(), eq(VISITOR_ID), eq(ORG_ID))).thenReturn(Mono.defer(() -> { + events.add("create"); + return Mono.just(stored); + })); + CreateGroupRequest request = new CreateGroupRequest(); + request.setName(GROUP_NAME); + request.setDynamicRule(DYNAMIC_RULE); + + StepVerifier.create(service.create(request)).expectNext(stored).verifyComplete(); + + Group group = created.getValue(); + assertThat(events).containsExactly("quota", "create"); + assertThat(group.getOrganizationId()).isEqualTo(ORG_ID); + assertThat(group.getName(java.util.Locale.ENGLISH)).isEqualTo(GROUP_NAME); + assertThat(group.getDynamicRule()).isEqualTo(DYNAMIC_RULE); + assertThat(UUID.fromString(group.getGid())).isNotNull(); + say("create as %s: %s, group org=%s name=%s gid=%s", role, events, group.getOrganizationId(), + group.getName(java.util.Locale.ENGLISH), group.getGid()); + } + + /** + * Catches a group created by a plain member: NOT_AUTHORIZED, the quota is not even consulted and nothing is + * created. + */ + @Test + void create_plainMember_isNotAuthorizedAndCreatesNothing() { + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + CreateGroupRequest request = new CreateGroupRequest(); + request.setName(GROUP_NAME); + + StepVerifier.create(service.create(request)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.NOT_AUTHORIZED)) + .verify(); + verifyNoInteractions(bizThresholdChecker, groupService); + say("create as plain member -> NOT_AUTHORIZED, quota and group service untouched"); + } + + /** + * Catches a group created although the quota check failed (the check must gate the create): EXCEED_MAX_GROUP_COUNT + * propagates and the create Mono is never subscribed. + */ + @Test + void create_overGroupQuota_failsBeforeCreating() { + OrgMember admin = orgMember(MemberRole.ADMIN); + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(admin)); + when(bizThresholdChecker.checkMaxGroupCount(admin)) + .thenReturn(Mono.error(new BizException(BizError.EXCEED_MAX_GROUP_COUNT, "EXCEED_MAX_GROUP_COUNT"))); + lenient().when(groupService.create(any(Group.class), eq(VISITOR_ID), eq(ORG_ID))).thenReturn(counting(normalGroup())); + CreateGroupRequest request = new CreateGroupRequest(); + request.setName(GROUP_NAME); + + StepVerifier.create(service.create(request)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.EXCEED_MAX_GROUP_COUNT)) + .verify(); + assertThat(mutations).hasValue(0); + say("create over quota -> EXCEED_MAX_GROUP_COUNT, group not created"); + } + + /** + * Catches the update writing the wrong group or fields: the Group handed to {@code updateGroup} carries the path + * group id, the new name and the new dynamic rule. + */ + @Test + void update_handsTheGroupIdNameAndRuleToTheGroupService() { + stubVisitor(orgMember(MemberRole.ADMIN), null, normalGroup()); + ArgumentCaptor updated = ArgumentCaptor.forClass(Group.class); + when(groupService.updateGroup(updated.capture())).thenReturn(Mono.just(true)); + UpdateGroupRequest request = new UpdateGroupRequest(); + request.setGroupName("renamed"); + request.setDynamicRule(DYNAMIC_RULE); + + StepVerifier.create(service.update(GROUP_ID, request)).expectNext(true).verifyComplete(); + + Group group = updated.getValue(); + assertThat(group.getId()).isEqualTo(GROUP_ID); + assertThat(group.getName(java.util.Locale.ENGLISH)).isEqualTo("renamed"); + assertThat(group.getDynamicRule()).isEqualTo(DYNAMIC_RULE); + say("update: group %s renamed to %s with rule %s", group.getId(), group.getName(java.util.Locale.ENGLISH), + group.getDynamicRule()); + } + + /** + * Catches an admin removing their own membership through {@code removeUser}: CANNOT_REMOVE_MYSELF and nothing + * is removed. + */ + @Test + void removeUser_removingYourself_isRejected() { + stubVisitor(orgMember(MemberRole.ADMIN), null, normalGroup()); + lenient().when(groupMemberService.removeMember(GROUP_ID, VISITOR_ID)).thenReturn(counting(true)); + + StepVerifier.create(service.removeUser(GROUP_ID, VISITOR_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.CANNOT_REMOVE_MYSELF)) + .verify(); + assertThat(mutations).hasValue(0); + say("removeUser: visitor removing themselves -> CANNOT_REMOVE_MYSELF"); + } + + // ------------------------------------------------------------------ getGroups + + @Test + void getGroups_anonymousVisitor_getsAnEmptyListWithoutOrgLookup() { + when(sessionUserService.isAnonymousUser()).thenReturn(Mono.just(true)); + + StepVerifier.create(service.getGroups()).expectNext(List.of()).verifyComplete(); + + verify(sessionUserService, never()).getVisitorOrgMemberCache(); + verifyNoInteractions(groupService); + say("getGroups: anonymous -> empty list, no org member or group lookup"); + } + + private static OrgMember orgAdmin(String userId) { + return new OrgMember(ORG_ID, userId, MemberRole.ADMIN, ORG_MEMBER_STATE, 0L); + } + + /** + * Catches a wrong listing for an org admin or super admin: every group of the org, system groups first + * (sorted), visitor role of the org membership, SUPER_ADMIN group members excluded from the counts, the admin count + * of an ordinary group being the org admins inside it, and the all-users group counting all org admins instead. + */ + @ParameterizedTest + @EnumSource(value = MemberRole.class, names = {"ADMIN", "SUPER_ADMIN"}) + void getGroups_orgAdmin_seesAllGroupsWithCounts(MemberRole visitorRole) { + when(sessionUserService.isAnonymousUser()).thenReturn(Mono.just(false)); + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(orgMember(visitorRole))); + Group all = group("g-all", ORG_ID, SystemGroups.ALL_USER, null); + Group ordinary = group("g-ordinary", ORG_ID, null, null); + when(groupService.getByOrgId(ORG_ID)).thenReturn(Flux.just(ordinary, all)); + when(orgMemberService.getAllOrgAdmins(ORG_ID)) + .thenReturn(Mono.just(List.of(orgAdmin("admin-in-group"), orgAdmin("admin-elsewhere")))); + when(groupMemberService.getGroupMembers("g-ordinary")).thenReturn(Mono.just(List.of( + new GroupMember("g-ordinary", "admin-in-group", MemberRole.MEMBER, ORG_ID, 1L), + new GroupMember("g-ordinary", "plain", MemberRole.MEMBER, ORG_ID, 2L), + new GroupMember("g-ordinary", "super", MemberRole.SUPER_ADMIN, ORG_ID, 3L)))); + when(groupMemberService.getGroupMembers("g-all")).thenReturn(Mono.just(List.of( + new GroupMember("g-all", "admin-in-group", MemberRole.MEMBER, ORG_ID, 1L), + new GroupMember("g-all", "plain", MemberRole.MEMBER, ORG_ID, 2L), + new GroupMember("g-all", "super", MemberRole.SUPER_ADMIN, ORG_ID, 3L)))); + + StepVerifier.create(service.getGroups()) + .assertNext(views -> { + assertThat(views).extracting(GroupView::getGroupId).containsExactly("g-all", "g-ordinary"); + assertThat(views).extracting(GroupView::getVisitorRole) + .containsOnly(visitorRole.getValue()); + GroupView allView = views.get(0); + assertThat(allView.isAllUsersGroup()).isTrue(); + assertThat(allView.getStats()).containsEntry(STATS_ADMIN_COUNT, 2).containsEntry(STATS_USER_COUNT, 2) + .containsEntry(STATS_USERS, List.of("admin-in-group", "plain")); + GroupView ordinaryView = views.get(1); + assertThat(ordinaryView.isAllUsersGroup()).isFalse(); + assertThat(ordinaryView.getStats()).containsEntry(STATS_ADMIN_COUNT, 1) + .containsEntry(STATS_USER_COUNT, 2) + .containsEntry(STATS_USERS, List.of("admin-in-group", "plain")); + }) + .verifyComplete(); + say("getGroups as org %s: all-users group admin count 2 (all org admins), ordinary group 1, both user count 2", + visitorRole); + } + + /** + * Catches a plain member seeing groups they do not belong to: only the groups of their own memberships are loaded + * (never the whole org), each carries the visitor's role in that group, and the all-users group counts all org + * admins while an ordinary group counts the org admins among the listed members. + */ + @Test + void getGroups_plainMember_seesOnlyOwnGroupsWithOwnRole() { + when(sessionUserService.isAnonymousUser()).thenReturn(Mono.just(false)); + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + when(orgMemberService.getAllOrgAdmins(ORG_ID)) + .thenReturn(Mono.just(List.of(orgAdmin("admin-1"), orgAdmin("admin-2")))); + when(groupMemberService.getUserGroupMembersInOrg(ORG_ID, VISITOR_ID)).thenReturn(Mono.just(List.of( + new GroupMember("g-ordinary", VISITOR_ID, MemberRole.ADMIN, ORG_ID, 1L), + new GroupMember("g-all", VISITOR_ID, MemberRole.MEMBER, ORG_ID, 2L)))); + ArgumentCaptor> requestedGroups = ArgumentCaptor.forClass(Collection.class); + when(groupService.getByIds(requestedGroups.capture())).thenReturn(Flux.just( + group("g-ordinary", ORG_ID, null, null), group("g-all", ORG_ID, SystemGroups.ALL_USER, null))); + + StepVerifier.create(service.getGroups()) + .assertNext(views -> { + assertThat(views).extracting(GroupView::getGroupId).containsExactly("g-all", "g-ordinary"); + assertThat(views).extracting(GroupView::getVisitorRole).containsExactly(ROLE_MEMBER, ROLE_ADMIN); + assertThat(views.get(0).getStats()).containsEntry(STATS_ADMIN_COUNT, 2); + assertThat(views.get(1).getStats()).containsEntry(STATS_ADMIN_COUNT, 0); + }) + .verifyComplete(); + assertThat(requestedGroups.getValue()).containsExactlyInAnyOrder("g-ordinary", "g-all"); + verify(groupService, never()).getByOrgId(any()); + say("getGroups as plain member: groups loaded by id %s only, roles member/admin", requestedGroups.getValue()); + } + + /** + * Catches a super admin membership of the visitor leaking into the member listing of a plain org member: the + * group is listed with the visitor's role, but the visitor's SUPER_ADMIN row is not among the listed users. + */ + @Test + void getGroups_plainMemberWhoIsSuperAdminOfAGroup_isNotListedAmongItsUsers() { + when(sessionUserService.isAnonymousUser()).thenReturn(Mono.just(false)); + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + when(orgMemberService.getAllOrgAdmins(ORG_ID)).thenReturn(Mono.just(List.of())); + when(groupMemberService.getUserGroupMembersInOrg(ORG_ID, VISITOR_ID)).thenReturn(Mono.just(List.of( + new GroupMember("g-ordinary", VISITOR_ID, MemberRole.SUPER_ADMIN, ORG_ID, 1L)))); + when(groupService.getByIds(anyCollection())).thenReturn(Flux.just(group("g-ordinary", ORG_ID, null, null))); + + StepVerifier.create(service.getGroups()) + .assertNext(views -> { + assertThat(views).extracting(GroupView::getVisitorRole).containsExactly(ROLE_SUPER_ADMIN); + assertThat(views.get(0).getStats()).containsEntry(STATS_USERS, List.of()); + }) + .verifyComplete(); + say("getGroups as plain member with a SUPER_ADMIN group role: role listed, user list excludes the super admin row"); + } + + /** + * Pins the plan section 9 defect row "GroupApiServiceImpl.getGroups for a plain member counts and lists only the + * visitor's own membership rows": for the same group of three members an org admin gets userCount 3 and all three + * users, while a plain member gets userCount 1 and users = [visitor] (the member path filters the visitor's own + * rows from {@code getUserGroupMembersInOrg}, which returns only that user's rows). A fix changes this test on + * purpose. + */ + @Test + void getGroups_plainMemberSeesOnlyOwnRowsWhereAdminSeesAllMembers_pinsSection9Defect() { + when(sessionUserService.isAnonymousUser()).thenReturn(Mono.just(false)); + when(orgMemberService.getAllOrgAdmins(ORG_ID)).thenReturn(Mono.just(List.of())); + Group ordinary = group("g-ordinary", ORG_ID, null, null); + GroupMember visitorRow = new GroupMember("g-ordinary", VISITOR_ID, MemberRole.MEMBER, ORG_ID, 1L); + when(groupMemberService.getGroupMembers("g-ordinary")).thenReturn(Mono.just(List.of(visitorRow, + new GroupMember("g-ordinary", "other-1", MemberRole.MEMBER, ORG_ID, 2L), + new GroupMember("g-ordinary", "other-2", MemberRole.MEMBER, ORG_ID, 3L)))); + when(groupService.getByOrgId(ORG_ID)).thenReturn(Flux.just(ordinary)); + when(groupMemberService.getUserGroupMembersInOrg(ORG_ID, VISITOR_ID)).thenReturn(Mono.just(List.of(visitorRow))); + when(groupService.getByIds(anyCollection())).thenReturn(Flux.just(ordinary)); + + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(orgMember(MemberRole.ADMIN))); + StepVerifier.create(service.getGroups()) + .assertNext(views -> assertThat(views.get(0).getStats()) + .containsEntry(STATS_USER_COUNT, 3) + .containsEntry(STATS_USERS, List.of(VISITOR_ID, "other-1", "other-2"))) + .verifyComplete(); + + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(orgMember(MemberRole.MEMBER))); + StepVerifier.create(service.getGroups()) + .assertNext(views -> assertThat(views.get(0).getStats()) + .containsEntry(STATS_USER_COUNT, 1) + .containsEntry(STATS_USERS, List.of(VISITOR_ID))) + .verifyComplete(); + say("getGroups: same 3-member group, org admin sees 3 users, plain member sees only itself (section 9 defect pinned)"); + } + + // ------------------------------------------------------------------ getPotentialGroupMembers + + private void stubPotentialMembers(String orgId, List orgUserIds, List groupUserIds) { + when(groupService.getById(GROUP_ID)).thenReturn(Mono.just(group(GROUP_ID, orgId, null, null))); + when(orgMemberService.getOrganizationMembers(orgId)).thenReturn(Flux.fromIterable(orgUserIds.stream() + .map(id -> new OrgMember(orgId, id, MemberRole.MEMBER, ORG_MEMBER_STATE, 0L)).toList())); + when(groupMemberService.getGroupMembers(GROUP_ID)).thenReturn(Mono.just( + groupUserIds.stream().map(id -> groupMember(id, MemberRole.MEMBER, 1L)).toList())); + } + + static Stream potentialSearchTerms() { + return Stream.of( + Arguments.of("al.ex", ".*\\Qal.ex\\E.*"), + Arguments.of(null, ALL_USERS_SEARCH_REGEX), + Arguments.of("", ALL_USERS_SEARCH_REGEX), + Arguments.of(" ", ALL_USERS_SEARCH_REGEX)); + } + + /** + * Catches existing group members offered as candidates, an unquoted search term (regex injection: the dot of + * "al.ex" must not be a wildcard), a wrong state or enabled flag, a wrong page offset, and a total that is the + * page size instead of the count query. + */ + @ParameterizedTest(name = "search [{0}] -> regex {1}") + @MethodSource("potentialSearchTerms") + void getPotentialGroupMembers_excludesGroupMembersAndQuotesTheSearchTerm(String searchName, String expectedRegex) { + stubPotentialMembers(ORG_ID, List.of("m1", "m2", "m3"), List.of("m2")); + ArgumentCaptor> candidateIds = ArgumentCaptor.forClass(Collection.class); + ArgumentCaptor pageable = ArgumentCaptor.forClass(Pageable.class); + ArgumentCaptor regex = ArgumentCaptor.forClass(String.class); + String activated = String.valueOf(UserState.ACTIVATED); + when(userService.findUsersByIdsAndSearchNameForPagination(candidateIds.capture(), eq(activated), eq(true), + regex.capture(), pageable.capture())).thenReturn(Flux.just(user("m1", "Alex"), user("m3", "Alexa"))); + when(userService.countUsersByIdsAndSearchName(anyCollection(), eq(activated), eq(true), any())) + .thenReturn(Mono.just(7L)); + + StepVerifier.create(service.getPotentialGroupMembers(GROUP_ID, searchName, 3, 2)) + .assertNext(view -> { + assertThat(view.getMembers()).extracting(OrgMemberListView.OrgMemberView::getUserId) + .containsExactly("m1", "m3"); + assertThat(view.getMembers()).extracting(OrgMemberListView.OrgMemberView::getName) + .containsExactly("Alex", "Alexa"); + assertThat(view.getTotal()).isEqualTo(7); + assertThat(view.getPageNum()).isEqualTo(3); + assertThat(view.getPageSize()).isEqualTo(2); + }) + .verifyComplete(); + assertThat(candidateIds.getValue()).containsExactlyInAnyOrder("m1", "m3"); + assertThat(regex.getValue()).isEqualTo(expectedRegex); + assertThat(pageable.getValue()).isEqualTo(PageRequest.of(2, 2)); + say("getPotentialGroupMembers: candidates %s, regex %s, page %s, total 7", candidateIds.getValue(), regex.getValue(), + pageable.getValue()); + } + + /** + * Catches a user query for an org whose members are all in the group already: total 0, no members, and the user + * service is not consulted. + */ + @Test + void getPotentialGroupMembers_everyOrgMemberAlreadyInGroup_returnsEmptyWithoutUserQuery() { + stubPotentialMembers(ORG_ID, List.of("m1", "m2"), List.of("m1", "m2", "outsider")); + + StepVerifier.create(service.getPotentialGroupMembers(GROUP_ID, "x", 1, 10)) + .assertNext(view -> { + assertThat(view.getMembers()).isEmpty(); + assertThat(view.getTotal()).isZero(); + assertThat(view.getPageNum()).isEqualTo(1); + assertThat(view.getPageSize()).isEqualTo(10); + }) + .verifyComplete(); + verifyNoInteractions(userService); + say("getPotentialGroupMembers: no candidates -> total 0, user service untouched"); + } + + /** + * Pins today's behaviour for an unknown group: an empty Mono (no item, no error), not INVALID_GROUP_ID as the + * roster readers answer. + */ + @Test + void getPotentialGroupMembers_unknownGroup_completesEmpty() { + when(groupService.getById(GROUP_ID)).thenReturn(Mono.empty()); + + StepVerifier.create(service.getPotentialGroupMembers(GROUP_ID, null, 1, 10)).verifyComplete(); + verifyNoInteractions(orgMemberService, userService); + say("getPotentialGroupMembers: unknown group -> empty Mono, no error"); + } + + /** + * Pins defect A1 (plan section 9): {@code getPotentialGroupMembers} has no permission and no same-org check. The + * group belongs to another organization, no visitor is resolved at all (the session service is never touched), + * and the org's users are listed anyway. A fix (authorisation, INVALID_GROUP_ID for a foreign group) changes this + * test on purpose. + */ + @Test + void getPotentialGroupMembers_hasNoPermissionOrSameOrgCheck_pinsDefect() { + stubPotentialMembers(OTHER_ORG_ID, List.of("foreign-1"), List.of()); + String activated = String.valueOf(UserState.ACTIVATED); + when(userService.findUsersByIdsAndSearchNameForPagination(anyCollection(), eq(activated), eq(true), + eq(ALL_USERS_SEARCH_REGEX), any(Pageable.class))).thenReturn(Flux.just(user("foreign-1", "Foreign"))); + when(userService.countUsersByIdsAndSearchName(anyCollection(), eq(activated), eq(true), any())) + .thenReturn(Mono.just(1L)); + + StepVerifier.create(service.getPotentialGroupMembers(GROUP_ID, null, 1, 10)) + .assertNext(view -> assertThat(view.getMembers()) + .extracting(OrgMemberListView.OrgMemberView::getUserId).containsExactly("foreign-1")) + .verifyComplete(); + verifyNoInteractions(sessionUserService); + say("getPotentialGroupMembers: group of org %s listed with no visitor or org check (defect A1 pinned)", + OTHER_ORG_ID); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/InvitationAndUserApiServiceTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/InvitationAndUserApiServiceTest.java new file mode 100644 index 0000000000..998fe7579d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/InvitationAndUserApiServiceTest.java @@ -0,0 +1,495 @@ +package org.lowcoder.api.usermanagement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.api.bizthreshold.AbstractBizThresholdChecker; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.api.usermanagement.view.InvitationVO; +import org.lowcoder.domain.invitation.model.Invitation; +import org.lowcoder.domain.invitation.service.InvitationService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.domain.user.model.Connection; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.model.UserDetail; +import org.lowcoder.domain.user.repository.UserRepository; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@link InvitationApiServiceImpl} (joining by invitation, the invitation view, invitation creation), + * {@link InvitationVO} and {@link UserApiServiceImpl} (the admin gate of user detail and password reset, and the + * removal of stale tokens), with every collaborator mocked. The two one-line delegates of {@code UserApiServiceImpl} + * ({@code lostPassword}, {@code resetLostPassword}) are not tested: that would test a mock. + * + *

    Pinned production defects (owner decision D-6: fixes are deferred, a fix changes these tests on purpose): + *

      + *
    • plan section 9 row "no role check on invitation creation": see + * {@link #create_visitorWhoIsNotAMemberOfTheOrganization_getsAnInvitation_pinsSection9Row}.
    • + *
    • plan section 9 row "InvitationApiServiceImpl (:93) raises INVITER_NOT_FOUND with the message key + * INVITED_ORG_DELETED": see {@link #getInvitationView_inviterMissing_isInviterNotFoundWithTheOrgDeletedKey_pinsSection9Row}.
    • + *
    + */ +@ExtendWith(MockitoExtension.class) +class InvitationAndUserApiServiceTest { + + private static final String LOG_PREFIX = "[InvitationAndUserApiServiceTest] "; + + private static final String INVITATION_ID = "invitation-1"; + private static final String ORG_ID = "org-1"; + private static final String VISITOR_ID = "visitor-1"; + private static final String CREATOR_ID = "creator-1"; + private static final String TARGET_ID = "target-1"; + private static final String ORG_NAME = "Org One"; + + @Mock + private InvitationService invitationService; + @Mock + private OrgApiService orgApiService; + @Mock + private UserService userService; + @Mock + private SessionUserService sessionUserService; + @Mock + private OrganizationService organizationService; + @Mock + private OrgMemberService orgMemberService; + @Mock + private AbstractBizThresholdChecker bizThresholdChecker; + @Mock + private UserRepository userRepository; + + private InvitationApiServiceImpl invitationApiService; + private UserApiServiceImpl userApiService; + + private final List events = new ArrayList<>(); + + @BeforeEach + void setUp() { + invitationApiService = new InvitationApiServiceImpl(invitationService, orgApiService, userService, sessionUserService, + organizationService, orgMemberService, bizThresholdChecker); + userApiService = new UserApiServiceImpl(sessionUserService, orgMemberService, userService, userRepository); + } + + // ------------------------------------------------------------------ fixtures + + private static void say(String format, Object... args) { + System.out.println(LOG_PREFIX + String.format(format, args)); + } + + private static void assertBizError(Throwable error, BizError expected, String messageKey) { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(expected); + assertThat(((BizException) error).getMessageKey()).isEqualTo(messageKey); + } + + private static Invitation invitation() { + Invitation invitation = Invitation.builder().createUserId(CREATOR_ID).invitedOrganizationId(ORG_ID).build(); + invitation.setId(INVITATION_ID); + return invitation; + } + + private static Organization organization() { + Organization organization = new Organization(); + organization.setId(ORG_ID); + organization.setName(ORG_NAME); + return organization; + } + + private static User user(String id, String name) { + User user = new User(); + user.setId(id); + user.setName(name); + return user; + } + + private Mono logged(String event, T value) { + return Mono.defer(() -> { + events.add(event); + return Mono.justOrEmpty(value); + }); + } + + private Mono loggedVoid(String event, RuntimeException failure) { + return Mono.defer(() -> { + events.add(event); + return failure == null ? Mono.empty() : Mono.error(failure); + }); + } + + // ------------------------------------------------------------------ inviteUser + + /** + * Catches a join with a code that does not exist: INVALID_INVITATION_CODE with the code as argument, and no + * organization or membership lookup, no quota check and no switch. + */ + @Test + void inviteUser_unknownInvitation_isInvalidInvitationCode() { + when(sessionUserService.getVisitorId()).thenReturn(Mono.just(VISITOR_ID)); + when(invitationService.getById(INVITATION_ID)).thenReturn(Mono.empty()); + + StepVerifier.create(invitationApiService.inviteUser(INVITATION_ID)) + .expectErrorSatisfies(error -> { + assertBizError(error, BizError.INVALID_INVITATION_CODE, "INVALID_INVITATION_CODE"); + assertThat(((BizException) error).getArgs()).containsExactly(INVITATION_ID); + }) + .verify(); + verifyNoInteractions(organizationService, orgMemberService, orgApiService, bizThresholdChecker); + say("inviteUser: unknown invitation -> INVALID_INVITATION_CODE, nothing else touched"); + } + + private void stubJoin() { + lenient().when(sessionUserService.getVisitorId()).thenReturn(Mono.just(VISITOR_ID)); + lenient().when(invitationService.getById(INVITATION_ID)).thenReturn(Mono.just(invitation())); + lenient().when(organizationService.getById(ORG_ID)).thenReturn(Mono.just(organization())); + lenient().when(orgApiService.switchCurrentOrganizationTo(ORG_ID)).thenReturn(logged("switch", true)); + } + + private void stubMembership(boolean member) { + lenient().when(orgMemberService.getOrgMember(ORG_ID, VISITOR_ID)).thenReturn(Mono.defer(() -> { + events.add("lookup"); + return member ? Mono.just(new OrgMember(ORG_ID, VISITOR_ID, MemberRole.MEMBER, "normal", 0L)) : Mono.empty(); + })); + } + + /** + * Catches a join into a deleted organization: INVITED_ORG_DELETED, the membership lookup is never subscribed, no + * member is added and the current org is not switched. + */ + @Test + void inviteUser_deletedOrganization_isInvitedOrgDeleted() { + stubJoin(); + stubMembership(false); + when(organizationService.getById(ORG_ID)).thenReturn(Mono.empty()); + lenient().when(invitationService.inviteToOrg(VISITOR_ID, ORG_ID)).thenReturn(logged("invite", true)); + + StepVerifier.create(invitationApiService.inviteUser(INVITATION_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVITED_ORG_DELETED, "INVITED_ORG_DELETED")) + .verify(); + assertThat(events).isEmpty(); + say("inviteUser: deleted organization -> INVITED_ORG_DELETED, nothing subscribed"); + } + + /** + * Catches a second membership (or a second quota count) for a visitor who is already in the organization: + * ALREADY_IN_ORGANIZATION, no quota check, no {@code inviteToOrg}, and the switch is never subscribed. + */ + @Test + void inviteUser_alreadyAMember_isRejectedWithoutASecondMembership() { + stubJoin(); + stubMembership(true); + lenient().when(bizThresholdChecker.checkMaxOrgCount(VISITOR_ID)).thenReturn(loggedVoid("orgCount", null)); + lenient().when(bizThresholdChecker.checkMaxOrgMemberCount(ORG_ID)).thenReturn(loggedVoid("memberCount", null)); + lenient().when(invitationService.inviteToOrg(VISITOR_ID, ORG_ID)).thenReturn(logged("invite", true)); + + StepVerifier.create(invitationApiService.inviteUser(INVITATION_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.ALREADY_IN_ORGANIZATION, "ALREADY_IN_ORGANIZATION")) + .verify(); + assertThat(events).containsExactly("lookup"); + say("inviteUser: already a member -> ALREADY_IN_ORGANIZATION, events %s", events); + } + + private enum Join { + OK, ORG_COUNT_FAILS, MEMBER_COUNT_FAILS, INVITE_RETURNS_FALSE + } + + /** + * Catches the quota bypass on joining: {@code checkMaxOrgCount(visitor)}, then {@code checkMaxOrgMemberCount(org)}, + * then {@code inviteToOrg(visitor, org)}, then the visitor's current org is switched; a failing check stops + * everything after it. The last row pins today's behaviour (not a defect): the flow does not check + * {@code inviteToOrg}'s result, so after a false join the switch is still subscribed and its result returned. + */ + @ParameterizedTest + @EnumSource(Join.class) + void inviteUser_newMember_runsTheQuotaChecksInOrderThenJoinsThenSwitches(Join scenario) { + stubJoin(); + stubMembership(false); + BizException orgCountFailure = new BizException(BizError.EXCEED_MAX_USER_ORG_COUNT, "EXCEED_MAX_USER_ORG_COUNT"); + BizException memberCountFailure = new BizException(BizError.EXCEED_MAX_ORG_MEMBER_COUNT, "EXCEED_MAX_ORG_MEMBER_COUNT"); + when(bizThresholdChecker.checkMaxOrgCount(VISITOR_ID)) + .thenReturn(loggedVoid("orgCount", scenario == Join.ORG_COUNT_FAILS ? orgCountFailure : null)); + when(bizThresholdChecker.checkMaxOrgMemberCount(ORG_ID)) + .thenReturn(loggedVoid("memberCount", scenario == Join.MEMBER_COUNT_FAILS ? memberCountFailure : null)); + lenient().when(invitationService.inviteToOrg(VISITOR_ID, ORG_ID)) + .thenReturn(logged("invite", scenario != Join.INVITE_RETURNS_FALSE)); + + Mono result = invitationApiService.inviteUser(INVITATION_ID); + + switch (scenario) { + case ORG_COUNT_FAILS -> { + StepVerifier.create(result).expectErrorSatisfies(error -> assertThat(error).isSameAs(orgCountFailure)).verify(); + assertThat(events).containsExactly("lookup", "orgCount"); + } + case MEMBER_COUNT_FAILS -> { + StepVerifier.create(result).expectErrorSatisfies(error -> assertThat(error).isSameAs(memberCountFailure)).verify(); + assertThat(events).containsExactly("lookup", "orgCount", "memberCount"); + } + default -> { + StepVerifier.create(result).expectNext(true).verifyComplete(); + assertThat(events).containsExactly("lookup", "orgCount", "memberCount", "invite", "switch"); + } + } + say("inviteUser %s -> %s", scenario, events); + } + + // ------------------------------------------------------------------ getInvitationView + + private void stubViewParts(boolean inviterExists, boolean orgExists) { + lenient().when(userService.findById(CREATOR_ID)).thenReturn(inviterExists ? Mono.just(user(CREATOR_ID, "Creator")) : Mono.empty()); + lenient().when(organizationService.getById(ORG_ID)).thenReturn(orgExists ? Mono.just(organization()) : Mono.empty()); + } + + /** + * Catches an invitation view for a code that does not exist (INVALID_INVITATION_CODE with the code as argument) + * and for an invitation whose organization was deleted (INVITED_ORG_DELETED). + */ + @Test + void getInvitationView_unknownInvitationOrDeletedOrganization_areRefused() { + when(invitationService.getById("unknown")).thenReturn(Mono.empty()); + StepVerifier.create(invitationApiService.getInvitationView("unknown")) + .expectErrorSatisfies(error -> { + assertBizError(error, BizError.INVALID_INVITATION_CODE, "INVALID_INVITATION_CODE"); + assertThat(((BizException) error).getArgs()).containsExactly("unknown"); + }) + .verify(); + + when(invitationService.getById(INVITATION_ID)).thenReturn(Mono.just(invitation())); + stubViewParts(true, false); + StepVerifier.create(invitationApiService.getInvitationView(INVITATION_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVITED_ORG_DELETED, "INVITED_ORG_DELETED")) + .verify(); + say("getInvitationView: unknown code -> INVALID_INVITATION_CODE, deleted org -> INVITED_ORG_DELETED"); + } + + /** + * Pins the plan section 9 row "InvitationApiServiceImpl (:93) raises INVITER_NOT_FOUND with the message key + * INVITED_ORG_DELETED": an invitation whose creator no longer exists gives the error code INVITER_NOT_FOUND but the + * message key of the deleted organization, so the user is told the wrong thing. A fix changes this test on purpose. + */ + @Test + void getInvitationView_inviterMissing_isInviterNotFoundWithTheOrgDeletedKey_pinsSection9Row() { + when(invitationService.getById(INVITATION_ID)).thenReturn(Mono.just(invitation())); + stubViewParts(false, true); + + StepVerifier.create(invitationApiService.getInvitationView(INVITATION_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVITER_NOT_FOUND, "INVITED_ORG_DELETED")) + .verify(); + say("getInvitationView: inviter missing -> INVITER_NOT_FOUND with key INVITED_ORG_DELETED (section 9 row pinned)"); + } + + /** + * Catches a wrong invitation view ({@code InvitationVO.from}): the invite code is the invitation id, the creator + * name is the inviter's, and the organization name and id are the invited organization's. + */ + @Test + void getInvitationView_buildsTheViewFromTheInvitationInviterAndOrganization() { + when(invitationService.getById(INVITATION_ID)).thenReturn(Mono.just(invitation())); + stubViewParts(true, true); + + StepVerifier.create(invitationApiService.getInvitationView(INVITATION_ID)) + .assertNext(this::assertViewOfTheInvitation) + .verifyComplete(); + say("getInvitationView: view built from invitation, inviter and organization"); + } + + private void assertViewOfTheInvitation(InvitationVO view) { + assertThat(view.getInviteCode()).isEqualTo(INVITATION_ID); + assertThat(view.getCreateUserName()).isEqualTo("Creator"); + assertThat(view.getInvitedOrganizationName()).isEqualTo(ORG_NAME); + assertThat(view.getInvitedOrganizationId()).isEqualTo(ORG_ID); + } + + // ------------------------------------------------------------------ create + + /** Catches an invitation created for an organization that does not exist: INVALID_ORG_ID, nothing saved. */ + @Test + void create_unknownOrganization_isInvalidOrgId() { + when(sessionUserService.getVisitor()).thenReturn(Mono.just(user(CREATOR_ID, "Creator"))); + when(organizationService.getById(ORG_ID)).thenReturn(Mono.empty()); + + StepVerifier.create(invitationApiService.create(ORG_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_ORG_ID, "INVALID_ORG_ID")) + .verify(); + verifyNoInteractions(invitationService); + say("create: unknown organization -> INVALID_ORG_ID, nothing saved"); + } + + /** + * Catches an invitation attributed to the wrong user or organization: the saved invitation carries the visitor's id + * as creator and the organization id, and the returned view shows the visitor's name and the organization. + */ + @Test + void create_savesTheInvitationForTheVisitorAndTheOrganization() { + when(sessionUserService.getVisitor()).thenReturn(Mono.just(user(CREATOR_ID, "Creator"))); + when(organizationService.getById(ORG_ID)).thenReturn(Mono.just(organization())); + ArgumentCaptor saved = ArgumentCaptor.forClass(Invitation.class); + when(invitationService.create(saved.capture())).thenReturn(Mono.just(invitation())); + + StepVerifier.create(invitationApiService.create(ORG_ID)).assertNext(this::assertViewOfTheInvitation).verifyComplete(); + + assertThat(saved.getValue().getCreateUserId()).isEqualTo(CREATOR_ID); + assertThat(saved.getValue().getInvitedOrganizationId()).isEqualTo(ORG_ID); + say("create: invitation saved for creator %s and org %s", CREATOR_ID, ORG_ID); + } + + /** + * Pins the plan section 9 row "no role check on invitation creation": a signed-in visitor who is not a member of the + * organization (no membership, role or org-admin lookup is made at all) gets an invitation for it, and anyone with + * that code joins as a member through {@code inviteUser}. A fix changes this test on purpose. + */ + @Test + void create_visitorWhoIsNotAMemberOfTheOrganization_getsAnInvitation_pinsSection9Row() { + when(sessionUserService.getVisitor()).thenReturn(Mono.just(user(VISITOR_ID, "Outsider"))); + when(organizationService.getById(ORG_ID)).thenReturn(Mono.just(organization())); + when(invitationService.create(any(Invitation.class))).thenReturn(Mono.just(invitation())); + + StepVerifier.create(invitationApiService.create(ORG_ID)) + .assertNext(view -> assertThat(view.getInvitedOrganizationId()).isEqualTo(ORG_ID)) + .verifyComplete(); + + verifyNoInteractions(orgMemberService, orgApiService, bizThresholdChecker); + say("create: a visitor with no membership in %s got an invitation (section 9 row pinned)", ORG_ID); + } + + // ------------------------------------------------------------------ UserApiServiceImpl: the admin gate + + private enum UserOperation { + GET_DETAIL, RESET_PASSWORD + } + + static Stream gateRows() { + List rows = new ArrayList<>(); + for (UserOperation operation : UserOperation.values()) { + rows.add(Arguments.of(operation, MemberRole.MEMBER, true, false)); + rows.add(Arguments.of(operation, MemberRole.MEMBER, false, false)); + rows.add(Arguments.of(operation, MemberRole.ADMIN, false, false)); + rows.add(Arguments.of(operation, MemberRole.ADMIN, true, true)); + rows.add(Arguments.of(operation, MemberRole.SUPER_ADMIN, true, true)); + rows.add(Arguments.of(operation, MemberRole.SUPER_ADMIN, false, false)); + } + return rows.stream(); + } + + /** + * Regression test of correct behaviour (not a defect pin): a user's detail and password reset are reserved to an + * ADMIN or SUPER_ADMIN of the visitor's current organization and only for a user who belongs to that same + * organization; a member, or an admin asking about a user of a foreign organization, gets UNSUPPORTED_OPERATION + * BAD_REQUEST and no user service Mono is subscribed. The membership lookup is made with the visitor's own org id. + */ + @ParameterizedTest(name = "{0} as {1}, target in the org {2} -> allowed {3}") + @MethodSource("gateRows") + void userOperation_requiresAnAdminOfTheVisitorsOrgAndATargetInThatOrg(UserOperation operation, MemberRole role, + boolean targetInOrg, boolean allowed) { + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(new OrgMember(ORG_ID, VISITOR_ID, role, "current", 0L))); + lenient().when(orgMemberService.getOrgMember(ORG_ID, TARGET_ID)).thenReturn( + targetInOrg ? Mono.just(new OrgMember(ORG_ID, TARGET_ID, MemberRole.MEMBER, "normal", 0L)) : Mono.empty()); + User target = user(TARGET_ID, "Target"); + UserDetail detail = UserDetail.builder().build(); + lenient().when(userService.findById(TARGET_ID)).thenReturn(logged("findById", target)); + lenient().when(userService.buildUserDetail(target, false)).thenReturn(logged("detail", detail)); + lenient().when(userService.resetPassword(TARGET_ID)).thenReturn(logged("reset", "new-password")); + + Mono result = operation == UserOperation.GET_DETAIL ? userApiService.getUserDetailById(TARGET_ID) + : userApiService.resetPassword(TARGET_ID); + + if (allowed) { + Object expected = operation == UserOperation.GET_DETAIL ? detail : "new-password"; + StepVerifier.create(result.map(value -> (Object) value)).expectNext(expected).verifyComplete(); + assertThat(events).isEqualTo(operation == UserOperation.GET_DETAIL ? List.of("findById", "detail") : List.of("reset")); + } else { + StepVerifier.create(result) + .expectErrorSatisfies(error -> assertBizError(error, BizError.UNSUPPORTED_OPERATION, "BAD_REQUEST")) + .verify(); + assertThat(events).isEmpty(); + } + say("%s as %s, target in org=%s -> allowed=%s, events %s", operation, role, targetInOrg, allowed, events); + } + + /** Pins today's behaviour: an admin asking for the detail of an unknown user of the org gets an empty result. */ + @Test + void getUserDetailById_unknownUserOfTheOrg_completesEmpty() { + when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(new OrgMember(ORG_ID, VISITOR_ID, MemberRole.ADMIN, "current", 0L))); + when(orgMemberService.getOrgMember(ORG_ID, TARGET_ID)) + .thenReturn(Mono.just(new OrgMember(ORG_ID, TARGET_ID, MemberRole.MEMBER, "normal", 0L))); + when(userService.findById(TARGET_ID)).thenReturn(Mono.empty()); + + StepVerifier.create(userApiService.getUserDetailById(TARGET_ID)).verifyComplete(); + + verify(userService, never()).buildUserDetail(any(), org.mockito.ArgumentMatchers.anyBoolean()); + say("getUserDetailById: unknown user -> empty, no detail built"); + } + + // ------------------------------------------------------------------ UserApiServiceImpl: removeInvalidTokens + + private static Connection connection(String source, Set tokens) { + return Connection.builder().source(source).rawId("raw-" + source).tokens(new HashSet<>(tokens)).build(); + } + + /** + * Catches stale tokens kept alive in MongoDB forever and valid ones dropped: the tokens of every connection are + * checked against Redis ({@code tokenExist}), those that do not exist are removed from every connection, valid ones + * stay, and the user is saved once (also when nothing had to be removed, today's behaviour). + */ + @ParameterizedTest(name = "stale tokens: {0}") + @ValueSource(booleans = {true, false}) + void removeInvalidTokens_removesOnlyTheTokensMissingFromRedis_andSavesTheUser(boolean someStale) { + User user = user("user-1", "User"); + Connection first = connection("GITHUB", Set.of("t1", "t2")); + Connection second = connection("GOOGLE", Set.of("t3")); + user.getConnections().add(first); + user.getConnections().add(second); + when(userRepository.findById("user-1")).thenReturn(Mono.just(user)); + when(sessionUserService.tokenExist("t1")).thenReturn(Mono.just(true)); + when(sessionUserService.tokenExist("t2")).thenReturn(Mono.just(!someStale)); + when(sessionUserService.tokenExist("t3")).thenReturn(Mono.just(!someStale)); + when(userRepository.save(user)).thenReturn(logged("save", user)); + + StepVerifier.create(userApiService.removeInvalidTokens("user-1")).verifyComplete(); + + assertThat(first.getTokens()).containsExactlyInAnyOrderElementsOf(someStale ? Set.of("t1") : Set.of("t1", "t2")); + assertThat(second.getTokens()).containsExactlyInAnyOrderElementsOf(someStale ? Set.of() : Set.of("t3")); + assertThat(events).containsExactly("save"); + say("removeInvalidTokens stale=%s: tokens left %s / %s", someStale, first.getTokens(), second.getTokens()); + } + + /** Catches a save for a user that does not exist: nothing is saved. */ + @Test + void removeInvalidTokens_unknownUser_savesNothing() { + when(userRepository.findById("ghost")).thenReturn(Mono.empty()); + + StepVerifier.create(userApiService.removeInvalidTokens("ghost")).verifyComplete(); + + verify(userRepository, never()).save(any()); + say("removeInvalidTokens: unknown user -> nothing saved"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/OrgApiServiceImplTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/OrgApiServiceImplTest.java new file mode 100644 index 0000000000..82d7bb418d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/OrgApiServiceImplTest.java @@ -0,0 +1,1018 @@ +package org.lowcoder.api.usermanagement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.api.authentication.dto.OrganizationDomainCheckResult; +import org.lowcoder.api.usermanagement.view.OrgMemberListView; +import org.lowcoder.api.usermanagement.view.OrgMemberListView.OrgMemberView; +import org.lowcoder.api.usermanagement.view.UpdateOrgRequest; +import org.lowcoder.api.usermanagement.view.UpdateRoleRequest; +import org.lowcoder.api.bizthreshold.AbstractBizThresholdChecker; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.domain.authentication.AuthenticationService; +import org.lowcoder.domain.authentication.FindAuthConfig; +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.group.service.GroupService; +import org.lowcoder.domain.organization.event.OrgMemberLeftEvent; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.model.OrgMemberState; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.model.Organization.OrganizationCommonSettings; +import org.lowcoder.domain.organization.model.OrganizationDomain; +import org.lowcoder.domain.organization.model.OrganizationState; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.domain.user.model.Connection; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.infra.serverlog.ServerLogService; +import org.lowcoder.sdk.auth.AbstractAuthConfig; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.CommonConfig.Workspace; +import org.lowcoder.sdk.constants.GlobalContext; +import org.lowcoder.sdk.constants.WorkspaceMode; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationContext; +import org.springframework.http.codec.multipart.Part; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@link OrgApiServiceImpl} with every collaborator mocked: the roster, the admin gate of every organization + * mutation, role changes, switching the current organization, leaving and removing members, removing and creating + * organizations, and the domain check. "Not mutated" is asserted by subscription counters on the mocked service Monos: + * {@code updateRoleForMember} and {@code removeUserFromOrg} assemble {@code .then(orgMemberService...)} eagerly, so + * {@code verify(never())} on the call itself would not show a mutation. + * + *

    Pinned production defects (owner decision D-6: fixes are deferred, a fix changes these tests on purpose): + *

      + *
    • plan section 9 subList row, second site {@code OrgApiServiceImpl.getOrgMemberListView} :115: a page past the end + * and page 0 (see {@link #getOrganizationMembers_pageBeyondTheEnd_failsWithIllegalArgumentException_pinsSection9SubListRow}).
    • + *
    • plan section 9 row "updateRoleForMember ... an org ADMIN can make any member, themselves included, super_admin ... + * skips the quota" (privilege escalation), including the demotion of a SUPER_ADMIN by an ADMIN (see + * {@link #updateRoleForMember_adminMakesAnyoneSuperAdmin_skippingTheQuota_pinsSection9Row} and + * {@link #updateRoleForMember_adminDemotesASuperAdmin_pinsSection9Row}).
    • + *
    • plan section 9 row "removeUserFromOrg has no last-admin or super-admin guard" (see + * {@link #removeUserFromOrg_adminRemovesTheOnlyOtherAdminOrASuperAdmin_pinsSection9Row}).
    • + *
    + */ +@ExtendWith(MockitoExtension.class) +class OrgApiServiceImplTest { + + private static final String LOG_PREFIX = "[OrgApiServiceImplTest] "; + + private static final String ORG_ID = "org-1"; + private static final String OTHER_ORG_ID = "org-2"; + private static final String VISITOR_ID = "visitor-1"; + private static final String TARGET_ID = "target-1"; + private static final String SUPER_ADMIN_ID = "super-1"; + private static final String DEV_GROUP_ID = "dev-group"; + private static final String DEFAULT_DOMAIN = "default.example"; + private static final String ORG_DOMAIN = "org.example"; + private static final String COOKIE_NAME = "LOWCODER_CE_SELFHOST_TOKEN"; + private static final String GITHUB = "GITHUB"; + private static final String GOOGLE = "GOOGLE"; + + @Mock + private SessionUserService sessionUserService; + @Mock + private OrgMemberService orgMemberService; + @Mock + private UserService userService; + @Mock + private OrganizationService organizationService; + @Mock + private AbstractBizThresholdChecker bizThresholdChecker; + @Mock + private ApplicationContext applicationContext; + @Mock + private CommonConfig commonConfig; + @Mock + private GroupService groupService; + @Mock + private AuthenticationService authenticationService; + @Mock + private ServerLogService serverLogService; + @Mock + private GroupMemberService groupMemberService; + + @InjectMocks + private OrgApiServiceImpl service; + + private final AtomicInteger mutations = new AtomicInteger(); + private final List events = new ArrayList<>(); + + // ------------------------------------------------------------------ fixtures + + private static void say(String format, Object... args) { + System.out.println(LOG_PREFIX + String.format(format, args)); + } + + private static void assertBizError(Throwable error, BizError expected, String messageKey) { + assertThat(error).isInstanceOf(BizException.class); + assertThat(((BizException) error).getError()).isEqualTo(expected); + assertThat(((BizException) error).getMessageKey()).isEqualTo(messageKey); + } + + private static OrgMember orgMember(String orgId, String userId, MemberRole role, OrgMemberState state) { + return new OrgMember(orgId, userId, role, state.getValue(), 100L); + } + + private static User user(String id, String name) { + User user = new User(); + user.setId(id); + user.setName(name); + return user; + } + + private Mono counting(T value) { + return Mono.defer(() -> { + mutations.incrementAndGet(); + return Mono.just(value); + }); + } + + private Mono countingVoid() { + return Mono.defer(() -> { + mutations.incrementAndGet(); + return Mono.empty(); + }); + } + + private Mono logged(String event, T value) { + return Mono.defer(() -> { + events.add(event); + return Mono.just(value); + }); + } + + private Mono loggedVoid(String event, RuntimeException failure) { + return Mono.defer(() -> { + events.add(event); + return failure == null ? Mono.empty() : Mono.error(failure); + }); + } + + private void stubVisitor() { + lenient().when(sessionUserService.getVisitorId()).thenReturn(Mono.just(VISITOR_ID)); + } + + /** The visitor's role in the org; null means the visitor is not a member. */ + private void stubVisitorRole(String orgId, MemberRole role) { + stubVisitor(); + lenient().when(orgMemberService.getOrgMember(orgId, VISITOR_ID)) + .thenReturn(role == null ? Mono.empty() : Mono.just(orgMember(orgId, VISITOR_ID, role, OrgMemberState.NORMAL))); + } + + private Workspace stubWorkspace(WorkspaceMode mode, String enterpriseOrgId) { + Workspace workspace = new Workspace(); + workspace.setMode(mode); + workspace.setEnterpriseOrgId(enterpriseOrgId); + lenient().when(commonConfig.getWorkspace()).thenReturn(workspace); + return workspace; + } + + // ------------------------------------------------------------------ getOrganizationMembers + + private static Connection connection(String source, Set orgIds, Map rawUserInfo) { + return Connection.builder().source(source).rawId("raw-" + source).orgIds(new HashSet<>(orgIds)).rawUserInfo(rawUserInfo).build(); + } + + private void stubRoster() { + stubVisitorRole(ORG_ID, MemberRole.MEMBER); + lenient().when(sessionUserService.getVisitorOrgMemberCache()) + .thenReturn(Mono.just(orgMember(ORG_ID, VISITOR_ID, MemberRole.ADMIN, OrgMemberState.CURRENT))); + lenient().when(orgMemberService.getOrganizationMembers(ORG_ID)).thenReturn(Flux.just( + orgMember(ORG_ID, "u1", MemberRole.ADMIN, OrgMemberState.NORMAL), + orgMember(ORG_ID, "u2", MemberRole.MEMBER, OrgMemberState.NORMAL), + orgMember(ORG_ID, "u3", MemberRole.MEMBER, OrgMemberState.NORMAL), + orgMember(ORG_ID, "u4", MemberRole.MEMBER, OrgMemberState.NORMAL))); + User one = user("u1", "One"); + one.getConnections().add(connection(GITHUB, Set.of(ORG_ID), Map.of("login", "one"))); + one.getConnections().add(connection(GITHUB, Set.of(ORG_ID), Map.of("login", "one"))); + User two = user("u2", "Two"); + two.getConnections().add(connection(GITHUB, Set.of(ORG_ID), null)); + two.getConnections().add(connection(GOOGLE, Set.of(OTHER_ORG_ID), Map.of("g", 1))); + // u3 has no user record + lenient().when(userService.getByIds(anyCollection())).thenReturn(Mono.just(Map.of("u1", one, "u2", two, "u4", user("u4", "Four")))); + } + + static Stream rawUserInfoRows() { + return Stream.of( + Arguments.of(true, Map.of(GITHUB, Map.of())), + Arguments.of(false, Map.of(GITHUB, Map.of(), GOOGLE, Map.of("g", 1)))); + } + + /** + * Catches an organization roster served to a non-member (NOT_AUTHORIZED, and the member list is never subscribed), + * and the wrong view: members without a user record are dropped and not counted, the visitor role is the cached + * org member's, raw user infos are keyed by connection source with the first duplicate kept, and a connection + * whose {@code rawUserInfo} is null yields an empty map ({@code Connection.getRawUserInfo()} never returns null, so + * the listing does not fail). In cloud mode only connections that contain the org are shown, otherwise all. + */ + @ParameterizedTest(name = "cloud={0}") + @MethodSource("rawUserInfoRows") + void getOrganizationMembers_buildsTheViewForAMember(boolean cloud, Map> expectedTwoInfos) { + stubRoster(); + when(commonConfig.isCloud()).thenReturn(cloud); + + StepVerifier.create(service.getOrganizationMembers(ORG_ID, 1, 10)) + .assertNext(view -> { + assertThat(view.getMembers()).extracting(OrgMemberView::getUserId).containsExactly("u1", "u2", "u4"); + assertThat(view.getTotal()).isEqualTo(3); + assertThat(view.getVisitorRole()).isEqualTo(MemberRole.ADMIN.getValue()); + assertThat(view.getMembers()).extracting(OrgMemberView::getRole) + .containsExactly(MemberRole.ADMIN.getValue(), MemberRole.MEMBER.getValue(), MemberRole.MEMBER.getValue()); + assertThat(view.getMembers().get(0).getName()).isEqualTo("One"); + assertThat(view.getMembers().get(0).getJoinTime()).isEqualTo(100L); + assertThat(view.getMembers().get(0).getRawUserInfos()).isEqualTo(Map.of(GITHUB, Map.of("login", "one"))); + assertThat(view.getMembers().get(1).getRawUserInfos()).isEqualTo(expectedTwoInfos); + assertThat(view.getMembers().get(2).getRawUserInfos()).isEmpty(); + }) + .verifyComplete(); + say("getOrganizationMembers cloud=%s: u3 dropped, total 3, raw infos of u2 %s", cloud, expectedTwoInfos); + } + + /** + * Catches the roster read before the membership check: a visitor who is not in the org gets NOT_AUTHORIZED and the + * org member list is not subscribed. + */ + @Test + void getOrganizationMembers_visitorNotInTheOrganization_isNotAuthorizedAndReadsNoRoster() { + stubVisitorRole(ORG_ID, null); + lenient().when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.empty()); + when(orgMemberService.getOrganizationMembers(ORG_ID)).thenReturn(Flux.defer(() -> { + mutations.incrementAndGet(); + return Flux.empty(); + })); + + StepVerifier.create(service.getOrganizationMembers(ORG_ID, 1, 10)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED")) + .verify(); + assertThat(mutations).hasValue(0); + say("getOrganizationMembers: non-member -> NOT_AUTHORIZED, roster not read"); + } + + static Stream pages() { + return Stream.of( + Arguments.of(1, 2, List.of("u1", "u2")), + Arguments.of(2, 2, List.of("u4")), + Arguments.of(1, 0, List.of("u1", "u2", "u4")), + Arguments.of(1, 10, List.of("u1", "u2", "u4")), + // the page right after an exactly full last page: from == to, empty and no error + Arguments.of(2, 3, List.of())); + } + + /** + * Catches an off-by-one in the page slice, a total that is the page size, and {@code count == 0} not meaning all. + */ + @ParameterizedTest(name = "page {0} count {1} -> {2}") + @MethodSource("pages") + void getOrganizationMembers_pagination(int page, int count, List expectedIds) { + stubRoster(); + lenient().when(commonConfig.isCloud()).thenReturn(false); + + StepVerifier.create(service.getOrganizationMembers(ORG_ID, page, count)) + .assertNext(view -> { + assertThat(view.getMembers()).extracting(OrgMemberView::getUserId).isEqualTo(expectedIds); + assertThat(view.getTotal()).isEqualTo(3); + assertThat(view.getPageNum()).isEqualTo(page); + assertThat(view.getPageSize()).isEqualTo(count); + }) + .verifyComplete(); + say("getOrganizationMembers page %d count %d -> %s", page, count, expectedIds); + } + + /** + * Pins the plan section 9 subList row at its second site, {@code getOrgMemberListView} :115: the slice is the + * unguarded {@code list.subList((page - 1) * count, min(page * count, total))}; three resolvable members, page 3 + * of size 2 gives subList(4, 3) and the call fails with an IllegalArgumentException. A fix changes this test on + * purpose. + */ + @ParameterizedTest + @ValueSource(ints = {3, 4}) + void getOrganizationMembers_pageBeyondTheEnd_failsWithIllegalArgumentException_pinsSection9SubListRow(int page) { + stubRoster(); + lenient().when(commonConfig.isCloud()).thenReturn(false); + + StepVerifier.create(service.getOrganizationMembers(ORG_ID, page, 2)) + .expectErrorSatisfies(error -> assertThat(error).isExactlyInstanceOf(IllegalArgumentException.class)) + .verify(); + say("getOrganizationMembers: page %d size 2 over 3 members -> IllegalArgumentException (section 9 subList row pinned)", page); + } + + /** + * Pins the same plan section 9 subList row: page 0 gives a negative fromIndex and the call fails with an + * IndexOutOfBoundsException. A fix changes this test on purpose. + */ + @Test + void getOrganizationMembers_pageZero_failsWithIndexOutOfBounds_pinsSection9SubListRow() { + stubRoster(); + lenient().when(commonConfig.isCloud()).thenReturn(false); + + StepVerifier.create(service.getOrganizationMembers(ORG_ID, 0, 2)) + .expectErrorSatisfies(error -> assertThat(error).isInstanceOf(IndexOutOfBoundsException.class)) + .verify(); + say("getOrganizationMembers: page 0 -> IndexOutOfBoundsException (section 9 subList row pinned)"); + } + + // ------------------------------------------------------------------ the admin gate + + static Stream adminRoles() { + return Stream.of( + Arguments.of((MemberRole) null, false), + Arguments.of(MemberRole.MEMBER, false), + Arguments.of(MemberRole.ADMIN, true), + Arguments.of(MemberRole.SUPER_ADMIN, true)); + } + + /** + * Catches a MEMBER or an outsider passing {@code checkVisitorAdminRole}: only ADMIN and SUPER_ADMIN pass (and get + * their org member back), everything else is NOT_AUTHORIZED. + */ + @ParameterizedTest(name = "role {0} -> admitted {1}") + @MethodSource("adminRoles") + void checkVisitorAdminRole_onlyAdminsPass(MemberRole role, boolean admitted) { + stubVisitorRole(ORG_ID, role); + + if (admitted) { + StepVerifier.create(service.checkVisitorAdminRole(ORG_ID)) + .assertNext(member -> assertThat(member.getRole()).isEqualTo(role)).verifyComplete(); + } else { + StepVerifier.create(service.checkVisitorAdminRole(ORG_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED")).verify(); + } + say("checkVisitorAdminRole: role %s -> admitted=%s", role, admitted); + } + + enum AdminOperation { + UPDATE_ROLE(1), DELETE_LOGO(1), UPLOAD_LOGO(2), REMOVE_USER(2), REMOVE_ORG(1), UPDATE(1), UPDATE_SETTINGS(1), API_USAGE(1); + + final int subscriptionsWhenAllowed; + + AdminOperation(int subscriptionsWhenAllowed) { + this.subscriptionsWhenAllowed = subscriptionsWhenAllowed; + } + } + + private Mono invoke(AdminOperation operation, Part part) { + UpdateRoleRequest roleRequest = new UpdateRoleRequest(); + roleRequest.setUserId(TARGET_ID); + roleRequest.setRole(MemberRole.MEMBER.getValue()); + UpdateOrgRequest updateRequest = new UpdateOrgRequest(); + updateRequest.setOrgName("renamed"); + return switch (operation) { + case UPDATE_ROLE -> service.updateRoleForMember(ORG_ID, roleRequest); + case DELETE_LOGO -> service.deleteLogo(ORG_ID); + case UPLOAD_LOGO -> service.uploadLogo(ORG_ID, counting(part)); + case REMOVE_USER -> service.removeUserFromOrg(ORG_ID, TARGET_ID); + case REMOVE_ORG -> service.removeOrg(ORG_ID); + case UPDATE -> service.update(ORG_ID, updateRequest); + case UPDATE_SETTINGS -> service.updateOrgCommonSettings(ORG_ID, "k", "v"); + case API_USAGE -> service.getApiUsageCount(ORG_ID, true); + }; + } + + private Object resultOf(AdminOperation operation) { + return operation == AdminOperation.API_USAGE ? (Object) 5L : (Object) Boolean.TRUE; + } + + static Stream adminOperations() { + List args = new ArrayList<>(); + for (AdminOperation operation : AdminOperation.values()) { + args.add(Arguments.of(operation, (MemberRole) null, false)); + args.add(Arguments.of(operation, MemberRole.MEMBER, false)); + args.add(Arguments.of(operation, MemberRole.ADMIN, true)); + } + return args.stream(); + } + + /** + * Catches mutate-before-authorise on the organization: a visitor who is not an admin of the org gets NOT_AUTHORIZED + * and none of the mocked service Monos (updateMemberRole, deleteLogo, uploadLogo and its file, removeMember and + * the user marking, delete, update, updateCommonSettings, getApiUsageCount) is subscribed; an admin reaches them + * with the right arguments. + */ + @ParameterizedTest(name = "{0} as {1}") + @MethodSource("adminOperations") + void adminOperation_requiresAnAdminOfTheOrganization(AdminOperation operation, MemberRole role, boolean allowed) { + stubVisitorRole(ORG_ID, role); + stubWorkspace(WorkspaceMode.SAAS, null); + Part part = mock(Part.class); + lenient().when(orgMemberService.updateMemberRole(ORG_ID, TARGET_ID, MemberRole.MEMBER)).thenReturn(counting(true)); + lenient().when(organizationService.deleteLogo(ORG_ID)).thenReturn(counting(true)); + lenient().when(organizationService.uploadLogo(ORG_ID, part)).thenReturn(counting(true)); + lenient().when(orgMemberService.removeMember(ORG_ID, TARGET_ID)).thenReturn(counting(true)); + lenient().when(userService.markUserDeletedAndInvalidConnectionsAtEnterpriseMode(TARGET_ID)).thenReturn(counting(true)); + lenient().when(organizationService.delete(ORG_ID)).thenReturn(counting(true)); + lenient().when(organizationService.update(eq(ORG_ID), any(Organization.class))).thenReturn(counting(true)); + lenient().when(organizationService.updateCommonSettings(ORG_ID, "k", "v")).thenReturn(counting(true)); + lenient().when(serverLogService.getApiUsageCount(ORG_ID, true)).thenReturn(counting(5L)); + + @SuppressWarnings("unchecked") + Mono result = (Mono) invoke(operation, part); + + if (allowed) { + StepVerifier.create(result).expectNext(resultOf(operation)).verifyComplete(); + assertThat(mutations).hasValue(operation.subscriptionsWhenAllowed); + } else { + StepVerifier.create(result) + .expectErrorSatisfies(error -> assertBizError(error, BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED")).verify(); + assertThat(mutations).hasValue(0); + } + say("%s as %s -> allowed=%s, service subscriptions %d", operation, role, allowed, mutations.get()); + } + + // ------------------------------------------------------------------ updateRoleForMember + + private UpdateRoleRequest roleRequest(String userId, String role) { + UpdateRoleRequest request = new UpdateRoleRequest(); + request.setUserId(userId); + request.setRole(role); + return request; + } + + /** + * Catches the developer quota bypass: for the role "admin" the dev group is looked up and + * {@code checkMaxDeveloperCount(orgId, devGroupId, userId)} completes before {@code updateMemberRole}; a quota + * error stops the update; an org without a dev group goes straight to the update. + */ + @Test + void updateRoleForMember_roleAdmin_checksTheDeveloperQuotaBeforeUpdating() { + stubVisitorRole(ORG_ID, MemberRole.ADMIN); + when(groupService.getDevGroup(ORG_ID)).thenReturn(Mono.just(Group.builder().id(DEV_GROUP_ID).build())); + when(bizThresholdChecker.checkMaxDeveloperCount(ORG_ID, DEV_GROUP_ID, TARGET_ID)).thenReturn(loggedVoid("quota", null)); + when(orgMemberService.updateMemberRole(ORG_ID, TARGET_ID, MemberRole.ADMIN)).thenReturn(logged("update", true)); + + StepVerifier.create(service.updateRoleForMember(ORG_ID, roleRequest(TARGET_ID, "admin"))).expectNext(true).verifyComplete(); + + assertThat(events).containsExactly("quota", "update"); + say("updateRoleForMember role admin: %s", events); + } + + @Test + void updateRoleForMember_roleAdmin_quotaExceeded_updatesNothing() { + stubVisitorRole(ORG_ID, MemberRole.ADMIN); + when(groupService.getDevGroup(ORG_ID)).thenReturn(Mono.just(Group.builder().id(DEV_GROUP_ID).build())); + when(bizThresholdChecker.checkMaxDeveloperCount(ORG_ID, DEV_GROUP_ID, TARGET_ID)) + .thenReturn(loggedVoid("quota", new BizException(BizError.EXCEED_MAX_DEVELOPER_COUNT, "EXCEED_MAX_DEVELOPER_COUNT"))); + lenient().when(orgMemberService.updateMemberRole(ORG_ID, TARGET_ID, MemberRole.ADMIN)).thenReturn(logged("update", true)); + + StepVerifier.create(service.updateRoleForMember(ORG_ID, roleRequest(TARGET_ID, "admin"))) + .expectErrorSatisfies(error -> assertBizError(error, BizError.EXCEED_MAX_DEVELOPER_COUNT, "EXCEED_MAX_DEVELOPER_COUNT")) + .verify(); + assertThat(events).containsExactly("quota"); + say("updateRoleForMember role admin over quota -> EXCEED_MAX_DEVELOPER_COUNT, role not updated"); + } + + @Test + void updateRoleForMember_roleAdmin_orgWithoutDevGroup_updatesWithoutQuotaCall() { + stubVisitorRole(ORG_ID, MemberRole.ADMIN); + when(groupService.getDevGroup(ORG_ID)).thenReturn(Mono.empty()); + when(orgMemberService.updateMemberRole(ORG_ID, TARGET_ID, MemberRole.ADMIN)).thenReturn(logged("update", true)); + + StepVerifier.create(service.updateRoleForMember(ORG_ID, roleRequest(TARGET_ID, "admin"))).expectNext(true).verifyComplete(); + + assertThat(events).containsExactly("update"); + verifyNoInteractions(bizThresholdChecker); + say("updateRoleForMember role admin, no dev group: %s", events); + } + + /** + * Catches a quota check for roles that are not "admin" and a wrong role mapping: "member" and an unknown role name + * (mapped to MEMBER by {@code MemberRole.fromValue}) never look up the dev group. + */ + @ParameterizedTest + @ValueSource(strings = {"member", "no-such-role"}) + void updateRoleForMember_otherRoleNames_updateAsMemberWithoutAQuotaCheck(String roleName) { + stubVisitorRole(ORG_ID, MemberRole.ADMIN); + when(orgMemberService.updateMemberRole(ORG_ID, TARGET_ID, MemberRole.MEMBER)).thenReturn(logged("update", true)); + + StepVerifier.create(service.updateRoleForMember(ORG_ID, roleRequest(TARGET_ID, roleName))).expectNext(true).verifyComplete(); + + assertThat(events).containsExactly("update"); + verifyNoInteractions(groupService, bizThresholdChecker); + say("updateRoleForMember role [%s] -> MEMBER, no quota check", roleName); + } + + /** + * Pins the plan section 9 row "updateRoleForMember ... an org ADMIN can make any member, themselves included, + * super_admin ... skips the quota" (privilege escalation): an ADMIN visitor writes the role "super_admin" for + * another member and for themselves, the update is subscribed, and neither the dev group nor the developer quota + * is consulted. A fix changes this test on purpose. + */ + @ParameterizedTest(name = "target {0}") + @ValueSource(strings = {TARGET_ID, VISITOR_ID}) + void updateRoleForMember_adminMakesAnyoneSuperAdmin_skippingTheQuota_pinsSection9Row(String targetId) { + stubVisitorRole(ORG_ID, MemberRole.ADMIN); + when(orgMemberService.updateMemberRole(ORG_ID, targetId, MemberRole.SUPER_ADMIN)).thenReturn(logged("update", true)); + + StepVerifier.create(service.updateRoleForMember(ORG_ID, roleRequest(targetId, "super_admin"))).expectNext(true).verifyComplete(); + + assertThat(events).containsExactly("update"); + verifyNoInteractions(groupService, bizThresholdChecker); + say("updateRoleForMember: ADMIN made %s super_admin, no quota (section 9 row pinned)", targetId); + } + + /** + * Pins the same plan section 9 row (the gate looks at the visitor's role only, never at the target's): an ADMIN + * demotes a SUPER_ADMIN to MEMBER and the update is subscribed, with the target's membership never read. A fix + * changes this test on purpose. + */ + @Test + void updateRoleForMember_adminDemotesASuperAdmin_pinsSection9Row() { + stubVisitorRole(ORG_ID, MemberRole.ADMIN); + when(orgMemberService.updateMemberRole(ORG_ID, SUPER_ADMIN_ID, MemberRole.MEMBER)).thenReturn(logged("update", true)); + + StepVerifier.create(service.updateRoleForMember(ORG_ID, roleRequest(SUPER_ADMIN_ID, "member"))).expectNext(true).verifyComplete(); + + assertThat(events).containsExactly("update"); + verify(orgMemberService, never()).getOrgMember(ORG_ID, SUPER_ADMIN_ID); + say("updateRoleForMember: ADMIN demoted the super admin, target role never read (section 9 row pinned)"); + } + + // ------------------------------------------------------------------ switchCurrentOrganizationTo + + private enum Switch { + UNKNOWN_ORG, NO_ACTIVE_ORGS, ALREADY_CURRENT, SWITCH, REMOVAL_FAILS, NO_PREVIOUS_CURRENT + } + + /** + * Catches a user switched into an org they are not an active member of (INVALID_ORG_ID, nothing marked; also when + * the user has no active orgs at all), a needless write for the already current org, a switch that skips the + * removal of the old mark or ignores its failure (SWITCH_CURRENT_ORG_ERROR and no new mark), and a switch without + * previous current org that removes a mark. + */ + @ParameterizedTest + @EnumSource(Switch.class) + void switchCurrentOrganizationTo_followsTheCurrentOrgMarks(Switch scenario) { + OrgMember current = orgMember(ORG_ID, VISITOR_ID, MemberRole.MEMBER, OrgMemberState.CURRENT); + OrgMember other = orgMember(OTHER_ORG_ID, VISITOR_ID, MemberRole.MEMBER, OrgMemberState.NORMAL); + List active = switch (scenario) { + case NO_ACTIVE_ORGS -> List.of(); + case NO_PREVIOUS_CURRENT -> List.of(orgMember(ORG_ID, VISITOR_ID, MemberRole.MEMBER, OrgMemberState.NORMAL), other); + default -> List.of(current, other); + }; + String next = switch (scenario) { + case UNKNOWN_ORG, NO_ACTIVE_ORGS -> "not-mine"; + case ALREADY_CURRENT -> ORG_ID; + default -> OTHER_ORG_ID; + }; + lenient().when(orgMemberService.getAllActiveOrgs(VISITOR_ID)).thenReturn(Flux.fromIterable(active)); + lenient().when(orgMemberService.removeCurrentOrgMark(ORG_ID, VISITOR_ID)) + .thenReturn(logged("remove", scenario != Switch.REMOVAL_FAILS)); + lenient().when(orgMemberService.markAsUserCurrentOrgId(next, VISITOR_ID)).thenReturn(logged("mark", true)); + + Mono result = service.switchCurrentOrganizationTo(VISITOR_ID, next); + + switch (scenario) { + case UNKNOWN_ORG, NO_ACTIVE_ORGS -> { + StepVerifier.create(result) + .expectErrorSatisfies(error -> assertBizError(error, BizError.INVALID_ORG_ID, "INVALID_ORG_ID")).verify(); + assertThat(events).isEmpty(); + } + case ALREADY_CURRENT -> { + StepVerifier.create(result).expectNext(true).verifyComplete(); + assertThat(events).isEmpty(); + } + case SWITCH -> { + StepVerifier.create(result).expectNext(true).verifyComplete(); + assertThat(events).containsExactly("remove", "mark"); + } + case REMOVAL_FAILS -> { + StepVerifier.create(result) + .expectErrorSatisfies(error -> assertBizError(error, BizError.SWITCH_CURRENT_ORG_ERROR, "SWITCH_CURRENT_ORG_ERROR")) + .verify(); + assertThat(events).containsExactly("remove"); + } + case NO_PREVIOUS_CURRENT -> { + StepVerifier.create(result).expectNext(true).verifyComplete(); + assertThat(events).containsExactly("mark"); + } + } + say("switchCurrentOrganizationTo %s -> %s", scenario, events); + } + + /** Catches the one-argument overload using the wrong user: it switches the visitor's current org. */ + @Test + void switchCurrentOrganizationTo_oneArgument_switchesTheVisitor() { + stubVisitor(); + when(orgMemberService.getAllActiveOrgs(VISITOR_ID)) + .thenReturn(Flux.just(orgMember(ORG_ID, VISITOR_ID, MemberRole.MEMBER, OrgMemberState.NORMAL))); + when(orgMemberService.markAsUserCurrentOrgId(ORG_ID, VISITOR_ID)).thenReturn(Mono.just(true)); + + StepVerifier.create(service.switchCurrentOrganizationTo(ORG_ID)).expectNext(true).verifyComplete(); + say("switchCurrentOrganizationTo(org): the visitor's current org marked"); + } + + // ------------------------------------------------------------------ leaveOrganization, removeUserFromOrg + + private static OrgMember admin(String userId) { + return orgMember(ORG_ID, userId, MemberRole.ADMIN, OrgMemberState.NORMAL); + } + + static Stream leaveCases() { + return Stream.of( + Arguments.of("visitor is the only admin", List.of(VISITOR_ID), false), + Arguments.of("the only admin is someone else", List.of("other-admin"), true), + Arguments.of("visitor and another admin", List.of(VISITOR_ID, "other-admin"), true)); + } + + /** + * Catches an organization left without any admin (the last admin may not leave) and a leave blocked for a visitor + * who is not the sole admin. + */ + @ParameterizedTest(name = "{0}: leaves {2}") + @MethodSource("leaveCases") + void leaveOrganization_theOnlyAdminCannotLeave(String label, List adminIds, boolean leaves) { + stubVisitor(); + when(orgMemberService.getAllOrgAdmins(ORG_ID)).thenReturn(Mono.just(adminIds.stream().map(OrgApiServiceImplTest::admin).toList())); + lenient().when(orgMemberService.removeMember(ORG_ID, VISITOR_ID)).thenReturn(counting(true)); + + if (leaves) { + StepVerifier.create(service.leaveOrganization(ORG_ID)).expectNext(true).verifyComplete(); + assertThat(mutations).hasValue(1); + } else { + StepVerifier.create(service.leaveOrganization(ORG_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.LAST_ADMIN_CANNOT_LEAVE_ORG, "LAST_ADMIN_CANNOT_LEAVE_ORG")) + .verify(); + assertThat(mutations).hasValue(0); + } + say("leaveOrganization: %s -> leaves=%s", label, leaves); + } + + /** + * Catches a spurious leave event: {@code OrgMemberLeftEvent(orgId, visitorId)} is published only when the removal + * returned true, and the result mirrors the removal. + */ + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void leaveOrganization_publishesTheLeaveEventOnlyWhenTheMemberWasRemoved(boolean removed) { + stubVisitor(); + when(orgMemberService.getAllOrgAdmins(ORG_ID)).thenReturn(Mono.just(List.of(admin("other-admin")))); + when(orgMemberService.removeMember(ORG_ID, VISITOR_ID)).thenReturn(Mono.just(removed)); + + StepVerifier.create(service.leaveOrganization(ORG_ID)).expectNext(removed).verifyComplete(); + + assertEventPublished(removed, ORG_ID, VISITOR_ID); + say("leaveOrganization: removal=%s -> event published=%s", removed, removed); + } + + private void assertEventPublished(boolean published, String orgId, String userId) { + if (published) { + ArgumentCaptor event = ArgumentCaptor.forClass(Object.class); + verify(applicationContext).publishEvent(event.capture()); + assertThat(event.getValue()).isInstanceOfSatisfying(OrgMemberLeftEvent.class, left -> { + assertThat(left.getOrgId()).isEqualTo(orgId); + assertThat(left.getUserId()).isEqualTo(userId); + }); + } else { + verifyNoInteractions(applicationContext); + } + } + + /** + * Catches a leave event for a removal that did not happen, and the enterprise-mode user marking being skipped: + * the event is published only when the removal returned true; {@code markUserDeletedAndInvalidConnectionsAtEnterpriseMode} + * runs afterwards in both cases (today's behaviour; whether it acts is decided inside that service). + */ + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void removeUserFromOrg_publishesTheLeaveEventOnlyWhenRemoved_andAlwaysMarksTheUser(boolean removed) { + stubVisitorRole(ORG_ID, MemberRole.ADMIN); + when(orgMemberService.removeMember(ORG_ID, TARGET_ID)).thenReturn(Mono.just(removed)); + when(userService.markUserDeletedAndInvalidConnectionsAtEnterpriseMode(TARGET_ID)).thenReturn(logged("mark", true)); + + StepVerifier.create(service.removeUserFromOrg(ORG_ID, TARGET_ID)).expectNext(removed).verifyComplete(); + + assertEventPublished(removed, ORG_ID, TARGET_ID); + assertThat(events).containsExactly("mark"); + say("removeUserFromOrg: removal=%s -> event %s, user marked", removed, removed); + } + + /** + * Pins the plan section 9 row "removeUserFromOrg has no last-admin or super-admin guard": an ADMIN visitor removes + * the only other admin and a SUPER_ADMIN, the removal is subscribed each time, and no admin or membership lookup + * is made for the target (compare {@code leaveOrganization}, which refuses the last admin). A fix changes this + * test on purpose. + */ + @ParameterizedTest(name = "target {0}") + @ValueSource(strings = {"only-other-admin", SUPER_ADMIN_ID}) + void removeUserFromOrg_adminRemovesTheOnlyOtherAdminOrASuperAdmin_pinsSection9Row(String targetId) { + stubVisitorRole(ORG_ID, MemberRole.ADMIN); + when(orgMemberService.removeMember(ORG_ID, targetId)).thenReturn(counting(true)); + when(userService.markUserDeletedAndInvalidConnectionsAtEnterpriseMode(targetId)).thenReturn(Mono.just(true)); + + StepVerifier.create(service.removeUserFromOrg(ORG_ID, targetId)).expectNext(true).verifyComplete(); + + assertThat(mutations).hasValue(1); + verify(orgMemberService, never()).getAllOrgAdmins(any()); + verify(orgMemberService, never()).getOrgMember(ORG_ID, targetId); + say("removeUserFromOrg: ADMIN removed %s without any guard (section 9 row pinned)", targetId); + } + + // ------------------------------------------------------------------ removeOrg, create, update + + static Stream removeOrgRows() { + return Stream.of( + Arguments.of(WorkspaceMode.ENTERPRISE, "enterprise-org", "enterprise-org", false), + Arguments.of(WorkspaceMode.ENTERPRISE, "enterprise-org", "other-org", true), + Arguments.of(WorkspaceMode.ENTERPRISE, null, "other-org", true), + Arguments.of(WorkspaceMode.SAAS, "enterprise-org", "enterprise-org", true), + Arguments.of(WorkspaceMode.SAAS, null, "other-org", true)); + } + + /** + * Catches the enterprise workspace being deletable (the only workspace of an enterprise install): in ENTERPRISE mode + * the enterprise org is UNSUPPORTED_OPERATION BAD_REQUEST and {@code organizationService.delete} is never + * subscribed; any other org, and any org in SAAS mode, is deleted and the result returned. The plan section 9 + * row "deleting an org leaves its apps and datasources" is not reachable here: {@code organizationService.delete} + * is mocked, and its pin belongs to {@code OrganizationServiceImpl.delete} (domain code, lane L3). + */ + @ParameterizedTest(name = "{0} enterpriseOrg={1} removing {2} -> allowed {3}") + @MethodSource("removeOrgRows") + void removeOrg_theEnterpriseOrganizationCannotBeRemoved(WorkspaceMode mode, String enterpriseOrgId, String orgId, boolean allowed) { + stubVisitorRole(orgId, MemberRole.ADMIN); + stubWorkspace(mode, enterpriseOrgId); + lenient().when(organizationService.delete(orgId)).thenReturn(counting(true)); + + if (allowed) { + StepVerifier.create(service.removeOrg(orgId)).expectNext(true).verifyComplete(); + assertThat(mutations).hasValue(1); + } else { + StepVerifier.create(service.removeOrg(orgId)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.UNSUPPORTED_OPERATION, "BAD_REQUEST")).verify(); + assertThat(mutations).hasValue(0); + } + say("removeOrg %s enterpriseOrg=%s removing %s -> allowed=%s", mode, enterpriseOrgId, orgId, allowed); + } + + private Organization created() { + Organization organization = new Organization(); + organization.setId(ORG_ID); + organization.setName("Created"); + return organization; + } + + /** + * Catches an org created past the quota or in enterprise mode: {@code checkMaxOrgCount(visitor)} runs first, then the + * SaaS-mode check (ENTERPRISE gives UNSUPPORTED_OPERATION BAD_REQUEST), then + * {@code organizationService.create(org, visitor, false)}; a failure stops the creation. The result is wrapped as + * an {@code OrgView}. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("createRows") + void create_checksTheQuotaThenTheWorkspaceModeBeforeCreating(String label, WorkspaceMode mode, boolean quotaFails, + List expectedEvents) { + stubVisitor(); + stubWorkspace(mode, null); + Organization requested = new Organization(); + when(bizThresholdChecker.checkMaxOrgCount(VISITOR_ID)).thenReturn(loggedVoid("quota", + quotaFails ? new BizException(BizError.EXCEED_MAX_USER_ORG_COUNT, "EXCEED_MAX_USER_ORG_COUNT") : null)); + lenient().when(organizationService.create(requested, VISITOR_ID, false)).thenReturn(logged("create", created())); + + Mono result = service.create(requested); + + if (expectedEvents.contains("create")) { + StepVerifier.create(result).assertNext(view -> { + assertThat(((org.lowcoder.api.usermanagement.view.OrgView) view).getOrgId()).isEqualTo(ORG_ID); + assertThat(((org.lowcoder.api.usermanagement.view.OrgView) view).getOrgName()).isEqualTo("Created"); + }).verifyComplete(); + } else if (quotaFails) { + StepVerifier.create(result).expectErrorSatisfies(error -> + assertBizError(error, BizError.EXCEED_MAX_USER_ORG_COUNT, "EXCEED_MAX_USER_ORG_COUNT")).verify(); + } else { + StepVerifier.create(result).expectErrorSatisfies(error -> + assertBizError(error, BizError.UNSUPPORTED_OPERATION, "BAD_REQUEST")).verify(); + } + assertThat(events).isEqualTo(expectedEvents); + say("create: %s -> %s", label, events); + } + + static Stream createRows() { + return Stream.of( + Arguments.of("SaaS, quota ok", WorkspaceMode.SAAS, false, List.of("quota", "create")), + Arguments.of("enterprise mode", WorkspaceMode.ENTERPRISE, false, List.of("quota")), + Arguments.of("quota exceeded", WorkspaceMode.SAAS, true, List.of("quota"))); + } + + /** + * Catches a wrong field mapping of the organization update: the Organization handed to + * {@code organizationService.update(orgId, ..)} carries the request's name, contact name, email, phone and state. + */ + @Test + void update_buildsTheUpdatedOrganizationFromTheRequest() { + stubVisitorRole(ORG_ID, MemberRole.ADMIN); + ArgumentCaptor updated = ArgumentCaptor.forClass(Organization.class); + when(organizationService.update(eq(ORG_ID), updated.capture())).thenReturn(Mono.just(true)); + UpdateOrgRequest request = new UpdateOrgRequest(); + request.setOrgName("New Name"); + request.setContactName("Ann"); + request.setContactEmail("ann@example.com"); + request.setContactPhoneNumber("+421 900"); + request.setState(OrganizationState.ACTIVE); + + StepVerifier.create(service.update(ORG_ID, request)).expectNext(true).verifyComplete(); + + Organization organization = updated.getValue(); + assertThat(organization.getName()).isEqualTo("New Name"); + assertThat(organization.getContactName()).isEqualTo("Ann"); + assertThat(organization.getContactEmail()).isEqualTo("ann@example.com"); + assertThat(organization.getContactPhoneNumber()).isEqualTo("+421 900"); + assertThat(organization.getState()).isEqualTo(OrganizationState.ACTIVE); + say("update: organization fields mapped from the request"); + } + + // ------------------------------------------------------------------ domain check + + private enum Domain { + NOT_CLOUD, ANONYMOUS, NO_REFERER, SKIP_CHECK, SAME_IGNORING_CASE, ORG_WITHOUT_DOMAIN_USES_DEFAULT, ORG_WITHOUT_DOMAIN_DIFFERENT_REFERER, + MISMATCH_NO_ORG_FOR_DOMAIN, MISMATCH_MEMBER_OF_DOMAIN_ORG, MISMATCH_NOT_MEMBER_OF_DOMAIN_ORG + } + + /** + * Catches a wrong redirect or bind for a visitor on another workspace's domain: success when not cloud, for an + * anonymous visitor, without a referer domain, for "skipCheck", for an equal domain (ignoring case) and for an org + * without domain on the default domain (an org without domain on another domain is redirected to the default domain); on a mismatch: redirect to the org's domain when no org owns the request + * domain or the user is a member of it, bind when the user is not a member of the org that owns the domain. + */ + @ParameterizedTest + @EnumSource(Domain.class) + void checkOrganizationDomain_decidesBetweenSuccessRedirectAndBind(Domain scenario) { + lenient().when(commonConfig.isCloud()).thenReturn(scenario != Domain.NOT_CLOUD); + User visitor = user(VISITOR_ID, "Vi"); + if (scenario == Domain.ANONYMOUS) { + visitor.setIsAnonymous(true); + } + lenient().when(sessionUserService.getVisitor()).thenReturn(Mono.just(visitor)); + lenient().when(sessionUserService.getVisitorOrgMemberCache()) + .thenReturn(Mono.just(orgMember(ORG_ID, VISITOR_ID, MemberRole.MEMBER, OrgMemberState.CURRENT))); + Organization org = new Organization(); + org.setId(ORG_ID); + if (scenario != Domain.ORG_WITHOUT_DOMAIN_USES_DEFAULT && scenario != Domain.ORG_WITHOUT_DOMAIN_DIFFERENT_REFERER) { + OrganizationDomain domain = new OrganizationDomain(); + domain.setDomain(scenario == Domain.SKIP_CHECK ? "skipCheck" : ORG_DOMAIN); + org.setOrganizationDomain(domain); + } + lenient().when(organizationService.getById(ORG_ID)).thenReturn(Mono.just(org)); + CommonConfig.Domain defaultDomain = new CommonConfig.Domain(); + defaultDomain.setDefaultValue(DEFAULT_DOMAIN); + lenient().when(commonConfig.getDomain()).thenReturn(defaultDomain); + Organization domainOrg = new Organization(); + domainOrg.setId("domain-org"); + lenient().when(organizationService.getByDomain()) + .thenReturn(scenario == Domain.MISMATCH_NO_ORG_FOR_DOMAIN || scenario == Domain.ORG_WITHOUT_DOMAIN_DIFFERENT_REFERER ? Mono.empty() : Mono.just(domainOrg)); + lenient().when(orgMemberService.getOrgMember("domain-org", VISITOR_ID)).thenReturn( + scenario == Domain.MISMATCH_MEMBER_OF_DOMAIN_ORG + ? Mono.just(orgMember("domain-org", VISITOR_ID, MemberRole.MEMBER, OrgMemberState.NORMAL)) : Mono.empty()); + String referer = switch (scenario) { + case SAME_IGNORING_CASE -> ORG_DOMAIN.toUpperCase(); + case ORG_WITHOUT_DOMAIN_USES_DEFAULT -> DEFAULT_DOMAIN; + default -> "other.example"; + }; + + Mono result = service.checkOrganizationDomain(); + if (scenario != Domain.NO_REFERER) { + result = result.contextWrite(context -> context.put(GlobalContext.DOMAIN, referer)); + } + + switch (scenario) { + case ORG_WITHOUT_DOMAIN_DIFFERENT_REFERER -> StepVerifier.create(result) + .assertNext(check -> { + assertThat(check.needRedirect()).isTrue(); + assertThat(check.redirectDomain()).isEqualTo(DEFAULT_DOMAIN); + }).verifyComplete(); + case MISMATCH_NO_ORG_FOR_DOMAIN, MISMATCH_MEMBER_OF_DOMAIN_ORG -> StepVerifier.create(result) + .assertNext(check -> { + assertThat(check.needRedirect()).isTrue(); + assertThat(check.redirectDomain()).isEqualTo(ORG_DOMAIN); + assertThat(check.needBind()).isFalse(); + }).verifyComplete(); + case MISMATCH_NOT_MEMBER_OF_DOMAIN_ORG -> StepVerifier.create(result) + .assertNext(check -> { + assertThat(check.needBind()).isTrue(); + assertThat(check.needRedirect()).isFalse(); + }).verifyComplete(); + default -> StepVerifier.create(result) + .assertNext(check -> { + assertThat(check.needBind()).isFalse(); + assertThat(check.needRedirect()).isFalse(); + }).verifyComplete(); + } + if (scenario == Domain.NOT_CLOUD) { + verifyNoInteractions(sessionUserService); + } + say("checkOrganizationDomain %s referer [%s]", scenario, referer); + } + + // ------------------------------------------------------------------ settings, add and switch, configs + + /** + * Catches the org's common settings readable by a non-member (NOT_AUTHORIZED, the organization not even loaded) + * and the wrong source: a member gets the organization's own common settings. + */ + @Test + void getOrgCommonSettings_membersGetTheOrgsSettings_outsidersAreRefused() { + stubVisitorRole(ORG_ID, null); + StepVerifier.create(service.getOrgCommonSettings(ORG_ID)) + .expectErrorSatisfies(error -> assertBizError(error, BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED")).verify(); + verify(organizationService, never()).getById(any()); + + stubVisitorRole(ORG_ID, MemberRole.MEMBER); + Organization org = new Organization(); + OrganizationCommonSettings settings = new OrganizationCommonSettings(); + settings.put("k", "v"); + org.setCommonSettings(settings); + when(organizationService.getById(ORG_ID)).thenReturn(Mono.just(org)); + StepVerifier.create(service.getOrgCommonSettings(ORG_ID)).assertNext(found -> assertThat(found).containsEntry("k", "v")).verifyComplete(); + say("getOrgCommonSettings: outsider refused, member gets the org's settings"); + } + + /** + * Catches the switch running before the member was added, or after a failed add: {@code tryAddOrgMember(orgId, + * userId, MEMBER)} first, then the visitor's current org is switched to that org. + */ + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void tryAddUserToOrgAndSwitchOrg_addsTheMemberThenSwitches(boolean addFails) { + stubVisitor(); + BizException failure = new BizException(BizError.NOT_AUTHORIZED, "NOT_AUTHORIZED"); + when(orgMemberService.tryAddOrgMember(ORG_ID, TARGET_ID, MemberRole.MEMBER)) + .thenReturn(Mono.defer(() -> { + events.add("add"); + return addFails ? Mono.error(failure) : Mono.just(true); + })); + lenient().when(orgMemberService.getAllActiveOrgs(VISITOR_ID)) + .thenReturn(Flux.just(orgMember(ORG_ID, VISITOR_ID, MemberRole.MEMBER, OrgMemberState.NORMAL))); + lenient().when(orgMemberService.markAsUserCurrentOrgId(ORG_ID, VISITOR_ID)).thenReturn(logged("mark", true)); + + if (addFails) { + StepVerifier.create(service.tryAddUserToOrgAndSwitchOrg(ORG_ID, TARGET_ID)).expectErrorSatisfies(error -> assertThat(error).isSameAs(failure)).verify(); + assertThat(events).containsExactly("add"); + } else { + StepVerifier.create(service.tryAddUserToOrgAndSwitchOrg(ORG_ID, TARGET_ID)).expectNext(true).verifyComplete(); + assertThat(events).containsExactly("add", "mark"); + } + say("tryAddUserToOrgAndSwitchOrg addFails=%s -> %s", addFails, events); + } + + /** + * Catches a wrong config view: the enabled auth configs of the organization, the cloud flag, the workspace mode, + * the cookie name, and {@code selfDomain} true only when an organization owns the request's domain. + */ + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void getOrganizationConfigs_buildsTheConfigView(boolean hasSelfDomain) { + AbstractAuthConfig authConfig = mock(AbstractAuthConfig.class); + when(authenticationService.findAllAuthConfigs(ORG_ID, true)).thenReturn(Flux.just(new FindAuthConfig(authConfig, null))); + when(organizationService.getByDomain()).thenReturn(hasSelfDomain ? Mono.just(new Organization()) : Mono.empty()); + when(commonConfig.isCloud()).thenReturn(true); + stubWorkspace(WorkspaceMode.ENTERPRISE, null); + when(commonConfig.getCookieName()).thenReturn(COOKIE_NAME); + + StepVerifier.create(service.getOrganizationConfigs(ORG_ID)) + .assertNext(view -> { + assertThat(view.getAuthConfigs()).containsExactly(authConfig); + assertThat(view.isCloudHosting()).isTrue(); + assertThat(view.getWorkspaceMode()).isEqualTo(WorkspaceMode.ENTERPRISE); + assertThat(view.isSelfDomain()).isEqualTo(hasSelfDomain); + assertThat(view.getCookieName()).isEqualTo(COOKIE_NAME); + }) + .verifyComplete(); + say("getOrganizationConfigs selfDomain=%s", hasSelfDomain); + } +} diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/organization/service/OrgMemberServiceTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/OrgMemberServiceTest.java similarity index 50% rename from server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/organization/service/OrgMemberServiceTest.java rename to server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/OrgMemberServiceTest.java index 04130e1488..f93fdccf33 100644 --- a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/organization/service/OrgMemberServiceTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/OrgMemberServiceTest.java @@ -1,4 +1,6 @@ -package org.lowcoder.domain.organization.service; +package org.lowcoder.api.usermanagement; + +import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.HashSet; import java.util.List; @@ -7,42 +9,51 @@ import java.util.stream.IntStream; import org.apache.commons.lang3.RandomUtils; -import org.junit.Assert; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.Test; import org.lowcoder.domain.organization.model.MemberRole; import org.lowcoder.domain.organization.model.OrgMember; import org.lowcoder.domain.organization.service.OrgMemberService; import org.lowcoder.sdk.util.IDUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.ActiveProfiles; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +/** + * {@link OrgMemberService#addMember} for each user, followed by {@link OrgMemberService#getOrganizationMembers(String)} against the + * MongoDB test container: every added user comes back exactly once. Moved here from lowcoder-domain, where it never ran + * (a JUnit 4 test in a module without the vintage engine, and a {@code @SpringBootTest} without an application class). + */ @SpringBootTest -@RunWith(SpringRunner.class) +@ActiveProfiles("test") public class OrgMemberServiceTest { + private static final int MIN_MEMBERS = 100; + private static final int MAX_MEMBERS = 500; + @Autowired private OrgMemberService orgMemberService; @Test public void testGetAllOrganizationMembers() { - int totalCount = RandomUtils.nextInt(100, 500); + int totalCount = RandomUtils.nextInt(MIN_MEMBERS, MAX_MEMBERS); String orgId = IDUtils.generate(); Set userIds = IntStream.rangeClosed(1, totalCount).mapToObj(i -> IDUtils.generate()).collect(Collectors.toSet()); - Mono> listMono = orgMemberService.bulkAddMember(orgId, userIds, MemberRole.MEMBER) + Mono> listMono = Flux.fromIterable(userIds) + .flatMap(userId -> orgMemberService.addMember(orgId, userId, MemberRole.MEMBER)) .thenMany(orgMemberService.getOrganizationMembers(orgId)) .map(OrgMember::getUserId) .collectList(); StepVerifier.create(listMono) .assertNext(list -> { - Assert.assertEquals(totalCount, list.size()); - Assert.assertEquals(userIds, new HashSet<>(list)); + System.out.println("[org-members] org " + orgId + ": added " + userIds.size() + ", listed " + list.size()); + assertEquals(totalCount, list.size()); + assertEquals(userIds, new HashSet<>(list)); }) .verifyComplete(); } -} \ No newline at end of file +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/ApiUtilGidServiceTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/ApiUtilGidServiceTest.java new file mode 100644 index 0000000000..e3ef2d7e12 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/ApiUtilGidServiceTest.java @@ -0,0 +1,215 @@ +package org.lowcoder.api.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.repository.ApplicationRepository; +import org.lowcoder.domain.bundle.model.Bundle; +import org.lowcoder.domain.bundle.repository.BundleRepository; +import org.lowcoder.domain.datasource.model.DatasourceDO; +import org.lowcoder.domain.datasource.repository.DatasourceDORepository; +import org.lowcoder.domain.folder.model.Folder; +import org.lowcoder.domain.folder.repository.FolderRepository; +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.group.repository.GroupRepository; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.repository.OrganizationRepository; +import org.lowcoder.domain.query.model.LibraryQuery; +import org.lowcoder.domain.query.repository.LibraryQueryRepository; +import org.lowcoder.sdk.models.HasIdAndAuditing; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Unit I2 (task L1-13, lane L5): {@code GidService}, the converters from the id a client sends (an object id, a slug or a gid) to + * the stored object id. A gid is any text with a hyphen ({@code FieldName.isGID}); applications and organizations try the slug + * first. Mockito: the seven repositories are mocks, the entities are real objects. + * + *

    Not a re-pin: the permission-bypass row of plan section 9 ({@code DatasourceRepository.retainNoneExistAndNonCurrentOrgDatasourceIds}, + * pinned by 1f8d88dce and 1b81200d8) starts from gids that come through {@code convertDatasourceIdToObjectId}; this class asserts only the + * converter's own contract and never touches that repository or its callers. + * + *

    Limits: the repositories are mocks; what the real queries match is not under test. + */ +@ExtendWith(MockitoExtension.class) +public class ApiUtilGidServiceTest { + + static final String TAG = "[ApiUtilGidServiceTest] "; + static final String OBJECT_ID = "65a1b2c3d4e5f60718293a4b"; + static final String GID = "gid-1234-abcd"; + static final String REAL_ID = "real-object-id"; + static final String OTHER_ID = "other-object-id"; + static final String SLUG = "my-slug"; + + @Mock + private ApplicationRepository applicationRepository; + @Mock + private DatasourceDORepository datasourceDORepository; + @Mock + private OrganizationRepository organizationRepository; + @Mock + private GroupRepository groupRepository; + @Mock + private LibraryQueryRepository libraryQueryRepository; + @Mock + private FolderRepository folderRepository; + @Mock + private BundleRepository bundleRepository; + @InjectMocks + private GidService service; + + private static T withId(T entity, String id) { + ReflectionTestUtils.setField(entity, "id", id); + return entity; + } + + private static Application application(String id) { + return withId(new Application(), id); + } + + private static Organization organization(String id) { + return withId(new Organization(), id); + } + + /** Every repository of the service is the mock (a field mismatch would leave one null and fail here). */ + @Test + public void injectionReachedEveryField() { + assertSame(applicationRepository, ReflectionTestUtils.getField(service, "applicationRepository")); + assertSame(datasourceDORepository, ReflectionTestUtils.getField(service, "datasourceDORepository")); + assertSame(organizationRepository, ReflectionTestUtils.getField(service, "organizationRepository")); + assertSame(groupRepository, ReflectionTestUtils.getField(service, "groupRepository")); + assertSame(libraryQueryRepository, ReflectionTestUtils.getField(service, "libraryQueryRepository")); + assertSame(folderRepository, ReflectionTestUtils.getField(service, "folderRepository")); + assertSame(bundleRepository, ReflectionTestUtils.getField(service, "bundleRepository")); + assertNotNull(service); + } + + // ---- datasource, group, library query, bundle: same shape + + @Test + public void aNonGidIdIsReturnedUnchangedWithoutAnyLookup() { + StepVerifier.create(service.convertDatasourceIdToObjectId(OBJECT_ID)).expectNext(OBJECT_ID).verifyComplete(); + StepVerifier.create(service.convertGroupIdToObjectId(OBJECT_ID)).expectNext(OBJECT_ID).verifyComplete(); + StepVerifier.create(service.convertLibraryQueryIdToObjectId(OBJECT_ID)).expectNext(OBJECT_ID).verifyComplete(); + StepVerifier.create(service.convertBundleIdToObjectId(OBJECT_ID)).expectNext(OBJECT_ID).verifyComplete(); + StepVerifier.create(service.convertDatasourceIdToObjectId("")).expectNext("").verifyComplete(); + verifyNoInteractions(datasourceDORepository, groupRepository, libraryQueryRepository, bundleRepository); + } + + @Test + public void aGidIsLookedUpAndTheFirstMatchsRealIdIsReturned() { + when(datasourceDORepository.findByGid(GID)).thenReturn(Flux.just(withId(new DatasourceDO(), REAL_ID), withId(new DatasourceDO(), OTHER_ID))); + when(groupRepository.findByGid(GID)).thenReturn(Flux.just(withId(new Group(), REAL_ID))); + when(libraryQueryRepository.findByGid(GID)).thenReturn(Flux.just(withId(new LibraryQuery(), REAL_ID))); + when(bundleRepository.findByGid(GID)).thenReturn(Flux.just(withId(new Bundle(), REAL_ID))); + StepVerifier.create(service.convertDatasourceIdToObjectId(GID)).expectNext(REAL_ID).verifyComplete(); + StepVerifier.create(service.convertGroupIdToObjectId(GID)).expectNext(REAL_ID).verifyComplete(); + StepVerifier.create(service.convertLibraryQueryIdToObjectId(GID)).expectNext(REAL_ID).verifyComplete(); + StepVerifier.create(service.convertBundleIdToObjectId(GID)).expectNext(REAL_ID).verifyComplete(); + } + + /** + * Observation (the callers are named in the Turn 2 report): an unknown gid, and a match without an id, give an empty Mono, not an + * error and not the gid itself. + */ + @Test + public void anUnknownGidOrAMatchWithoutAnIdGivesAnEmptyMono() { + when(datasourceDORepository.findByGid(GID)).thenReturn(Flux.empty()); + when(groupRepository.findByGid(GID)).thenReturn(Flux.empty()); + when(libraryQueryRepository.findByGid(GID)).thenReturn(Flux.just(new LibraryQuery())); + when(bundleRepository.findByGid(GID)).thenReturn(Flux.empty()); + StepVerifier.create(service.convertDatasourceIdToObjectId(GID)).verifyComplete(); + StepVerifier.create(service.convertGroupIdToObjectId(GID)).verifyComplete(); + StepVerifier.create(service.convertLibraryQueryIdToObjectId(GID)).verifyComplete(); + StepVerifier.create(service.convertBundleIdToObjectId(GID)).verifyComplete(); + } + + // ---- application and organization: slug first + + + @Test + public void applicationSlugBeatsGidAndNonGidIdsFallBackToThemselves() { + when(applicationRepository.findBySlug(SLUG)).thenReturn(Flux.just(application(REAL_ID))); + StepVerifier.create(service.convertApplicationIdToObjectId(SLUG)).expectNext(REAL_ID).verifyComplete(); + verify(applicationRepository, never()).findByGid(SLUG); + + when(applicationRepository.findBySlug(GID)).thenReturn(Flux.empty()); + when(applicationRepository.findByGid(GID)).thenReturn(Flux.just(application(OTHER_ID))); + StepVerifier.create(service.convertApplicationIdToObjectId(GID)).expectNext(OTHER_ID).verifyComplete(); + + when(applicationRepository.findBySlug(OBJECT_ID)).thenReturn(Flux.empty()); + StepVerifier.create(service.convertApplicationIdToObjectId(OBJECT_ID)).expectNext(OBJECT_ID).verifyComplete(); + verify(applicationRepository, never()).findByGid(OBJECT_ID); + + String unknown = "unknown-gid-1"; + when(applicationRepository.findBySlug(unknown)).thenReturn(Flux.empty()); + when(applicationRepository.findByGid(unknown)).thenReturn(Flux.empty()); + StepVerifier.create(service.convertApplicationIdToObjectId(unknown)).verifyComplete(); + } + + @Test + public void organizationSlugBeatsGidAndNonGidIdsFallBackToThemselves() { + when(organizationRepository.findBySlug(SLUG)).thenReturn(Flux.just(organization(REAL_ID))); + StepVerifier.create(service.convertOrganizationIdToObjectId(SLUG)).expectNext(REAL_ID).verifyComplete(); + verify(organizationRepository, never()).findByGid(SLUG); + + when(organizationRepository.findBySlug(GID)).thenReturn(Flux.empty()); + when(organizationRepository.findByGid(GID)).thenReturn(Flux.just(organization(OTHER_ID))); + StepVerifier.create(service.convertOrganizationIdToObjectId(GID)).expectNext(OTHER_ID).verifyComplete(); + + when(organizationRepository.findBySlug(OBJECT_ID)).thenReturn(Flux.empty()); + StepVerifier.create(service.convertOrganizationIdToObjectId(OBJECT_ID)).expectNext(OBJECT_ID).verifyComplete(); + verify(organizationRepository, never()).findByGid(OBJECT_ID); + + String unknown = "unknown-gid-2"; + when(organizationRepository.findBySlug(unknown)).thenReturn(Flux.empty()); + when(organizationRepository.findByGid(unknown)).thenReturn(Flux.empty()); + StepVerifier.create(service.convertOrganizationIdToObjectId(unknown)).verifyComplete(); + } + + /** A slug match whose entity has no id is treated as no match, so the lookup goes on to the gid (and then to the id itself). */ + @Test + public void aSlugMatchWithoutAnIdFallsThroughToTheGidLookup() { + when(applicationRepository.findBySlug(GID)).thenReturn(Flux.just(new Application())); + when(applicationRepository.findByGid(GID)).thenReturn(Flux.just(application(REAL_ID))); + StepVerifier.create(service.convertApplicationIdToObjectId(GID)).expectNext(REAL_ID).verifyComplete(); + } + + // ---- folder: the one converter that returns an Optional + + @Test + public void folderConverterReturnsAnOptionalInEveryCase() { + StepVerifier.create(service.convertFolderIdToObjectId(OBJECT_ID)).expectNext(Optional.of(OBJECT_ID)).verifyComplete(); + StepVerifier.create(service.convertFolderIdToObjectId(null)).expectNext(Optional.empty()).verifyComplete(); + when(folderRepository.findByGid(GID)).thenReturn(Flux.just(withId(new Folder(), REAL_ID))); + StepVerifier.create(service.convertFolderIdToObjectId(GID)).expectNext(Optional.of(REAL_ID)).verifyComplete(); + String unknown = "unknown-gid-3"; + when(folderRepository.findByGid(unknown)).thenReturn(Flux.empty()); + StepVerifier.create(service.convertFolderIdToObjectId(unknown)).expectNext(Optional.empty()).verifyComplete(); + when(folderRepository.findByGid("no-id-gid")).thenReturn(Flux.just(new Folder())); + StepVerifier.create(service.convertFolderIdToObjectId("no-id-gid")).expectNext(Optional.empty()).verifyComplete(); + } + + @Test + public void anObjectIdWithoutAHyphenIsNeverTreatedAsAGid() { + for (String id : List.of(OBJECT_ID, "abc", "with space", "under_score")) { + StepVerifier.create(service.convertBundleIdToObjectId(id)).expectNext(id).verifyComplete(); + } + verifyNoInteractions(bundleRepository); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/ApiUtilPaginationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/ApiUtilPaginationTest.java new file mode 100644 index 0000000000..69221410ba --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/ApiUtilPaginationTest.java @@ -0,0 +1,164 @@ +package org.lowcoder.api.util; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.framework.view.PageResponseView; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.springframework.data.domain.PageRequest; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit I2 (task L1-13, lane L5): {@code Pagination}: the clamping of page and size, the page check, the zero-based page request, + * and {@code fluxToPageResponseView}, which slices an in-memory flux for the endpoints that page after loading everything. + * + *

    Limits: no Spring; the flux is a plain Reactor source. + */ +public class ApiUtilPaginationTest { + + static final String TAG = "[ApiUtilPaginationTest] "; + static final Duration TIMEOUT = Duration.ofSeconds(10); + static final int MIN_SIZE = 5; + static final int DEFAULT_MAX_SIZE = 100; + static final String ILLEGAL_PAGE_KEY = "ILLEGAL_PAGE_NUMBER"; + + @Test + public void sizeIsClampedBetweenTheMinimumAndTheMaximum() { + assertEquals(MIN_SIZE, Pagination.of(1, 0).size()); + assertEquals(MIN_SIZE, Pagination.of(1, -7).size()); + assertEquals(MIN_SIZE, Pagination.of(1, 4).size()); + assertEquals(5, Pagination.of(1, 5).size()); + assertEquals(37, Pagination.of(1, 37).size()); + assertEquals(DEFAULT_MAX_SIZE, Pagination.of(1, 100).size()); + assertEquals(DEFAULT_MAX_SIZE, Pagination.of(1, 5000).size()); + assertEquals(20, Pagination.of(1, 5000).withMaxSize(20).size()); + assertEquals(1000, Pagination.of(1, 5000).withMaxSize(1000).size()); + assertEquals(20, Pagination.of(1, 20).withMaxSize(1000).size()); + } + + @Test + public void pageHasAMinimumOfOneAndAMaximumOnlyWhenOneIsSet() { + assertEquals(1, Pagination.of(0, 10).page()); + assertEquals(1, Pagination.of(-3, 10).page()); + assertEquals(7, Pagination.of(7, 10).page()); + assertEquals(100000, Pagination.of(100000, 10).page(), "no maximum by default"); + assertEquals(3, Pagination.of(9, 10).withMaxPage(3).page()); + assertEquals(2, Pagination.of(2, 10).withMaxPage(3).page()); + assertEquals(9, Pagination.of(9, 10).withMaxPage(0).page(), "a maximum of zero means none"); + assertEquals(9, Pagination.of(9, 10).withMaxPage(-1).page()); + assertEquals(1, Pagination.of(-4, 10).withMaxPage(3).page()); + } + + @Test + public void checkRejectsANonPositivePageOrOneAboveTheMaximumAndReturnsTheSameObjectOtherwise() { + Pagination valid = Pagination.of(2, 10); + assertSame(valid, valid.check()); + assertSame(valid.withMaxPage(5), valid.withMaxPage(5).check().withMaxPage(5)); + for (int bad : new int[] {0, -1, -100}) { + BizException thrown = assertThrows(BizException.class, () -> Pagination.of(bad, 10).check(), "page " + bad); + assertEquals(BizError.INVALID_PARAMETER, thrown.getError()); + assertEquals(ILLEGAL_PAGE_KEY, thrown.getMessageKey()); + assertEquals(bad, thrown.getArgs()[0]); + } + BizException above = assertThrows(BizException.class, () -> Pagination.of(6, 10).withMaxPage(5).check()); + assertEquals(ILLEGAL_PAGE_KEY, above.getMessageKey()); + assertEquals(6, above.getArgs()[0]); + assertEquals(1, Pagination.of(1, 10).withMaxPage(0).check().page(), "a maximum of zero means no maximum, so no page is above it"); + assertEquals(4000, Pagination.of(4000, 10).withMaxPage(-1).check().page()); + assertSame(Pagination.class, Pagination.of(5, 10).withMaxPage(5).check().getClass()); + assertEquals(5, Pagination.of(5, 10).withMaxPage(5).check().page(), "the maximum page itself is valid"); + } + + @Test + public void pageRequestIsZeroBasedAndUsesTheClampedValues() { + assertEquals(PageRequest.of(2, 20), Pagination.of(3, 20).toPageRequest()); + assertEquals(PageRequest.of(0, MIN_SIZE), Pagination.of(0, 1).toPageRequest()); + assertEquals(PageRequest.of(0, DEFAULT_MAX_SIZE), Pagination.of(1, 999).toPageRequest()); + assertEquals(PageRequest.of(1, 20), Pagination.of(9, 999).withMaxPage(2).withMaxSize(20).toPageRequest()); + } + + @Test + public void ofReturnsIndependentObjects() { + Pagination first = Pagination.of(1, 10); + Pagination second = Pagination.of(1, 10); + assertNotSame(first, second); + first.withMaxSize(5); + assertEquals(10, second.size()); + } + + // ---- fluxToPageResponseView + + private static PageResponseView view(Integer pageNum, Integer pageSize, Flux flux) { + PageResponseView view = Pagination.fluxToPageResponseView(pageNum, pageSize, flux).block(TIMEOUT); + System.out.println(TAG + "page " + pageNum + " size " + pageSize + " -> " + view.getData() + " pageNum=" + view.getPageNum() + " pageSize=" + view.getPageSize() + " total=" + view.getTotal()); + return view; + } + + @Test + public void aPageIsASliceOfTheFlux() { + Flux numbers = Flux.range(1, 25); + PageResponseView first = view(1, 10, numbers); + assertEquals(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), first.getData()); + assertEquals(1, first.getPageNum()); + assertEquals(10, first.getPageSize()); + assertEquals(25, first.getTotal()); + assertEquals(List.of(11, 12, 13, 14, 15, 16, 17, 18, 19, 20), view(2, 10, numbers).getData()); + assertEquals(List.of(21, 22, 23, 24, 25), view(3, 10, numbers).getData(), "the last page is short"); + assertEquals(0, PageResponseView.success(List.of(), 1, 1, 0).getTotal()); + } + + @Test + public void aPagePastTheEndIsEmptyWithTheRightTotal() { + PageResponseView past = view(9, 10, Flux.range(1, 25)); + assertEquals(List.of(), past.getData()); + assertEquals(25, past.getTotal()); + assertEquals(9, past.getPageNum()); + PageResponseView empty = view(1, 10, Flux.empty()); + assertEquals(List.of(), empty.getData()); + assertEquals(0, empty.getTotal()); + } + + /** Observation: a page size of 0 or less takes no limit: every item from the skipped start, with the size echoed as given. */ + @Test + public void aPageSizeOfZeroOrLessReturnsEverything() { + PageResponseView zero = view(1, 0, Flux.range(1, 7)); + assertEquals(7, zero.getData().size()); + assertEquals(0, zero.getPageSize()); + assertEquals(7, zero.getTotal()); + PageResponseView negative = view(1, -3, Flux.range(1, 7)); + assertEquals(7, negative.getData().size()); + assertEquals(-3, negative.getPageSize()); + assertEquals(7, view(4, 0, Flux.range(1, 7)).getData().size(), "with size 0 the skip is 0 whatever the page"); + } + + /** + * Observation: {@code fluxToPageResponseView} does not check its arguments. A page number of 0 or less makes the skip count negative, + * and {@code Flux.skip} refuses that while the pipeline is being assembled, so the call throws an + * {@code IllegalArgumentException} to its caller (not a failed Mono, and not the {@code BizException} that {@code check()} gives). + */ + @Test + public void aPageNumberOfZeroOrLessThrowsAnIllegalArgumentExceptionFromSkip() { + for (int bad : new int[] {0, -1, -5}) { + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> Pagination.fluxToPageResponseView(bad, 10, Flux.range(1, 5)), "page " + bad); + System.out.println(TAG + "page " + bad + " -> " + thrown.getMessage()); + } + } + + /** Observation (reported): the flux is subscribed twice, once to count it and once to take the slice. */ + @Test + public void theFluxIsSubscribedTwice() { + AtomicInteger subscriptions = new AtomicInteger(); + Flux counting = Flux.range(1, 12).doOnSubscribe(s -> subscriptions.incrementAndGet()); + StepVerifier.create(Pagination.fluxToPageResponseView(1, 5, counting)).expectNextCount(1).verifyComplete(); + assertEquals(2, subscriptions.get()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/ApiUtilViewBuilderTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/ApiUtilViewBuilderTest.java new file mode 100644 index 0000000000..bb02f971fe --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/ApiUtilViewBuilderTest.java @@ -0,0 +1,144 @@ +package org.lowcoder.api.util; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit I2 (task L1-13, lane L5): {@code ViewBuilder.multiBuild}: items are combined with the objects their ids point to, in the + * items' order, and an item whose object is missing is silently dropped. Pinned here at the {@code ViewBuilder} level as behaviour; + * the same consequence at the service level (a record whose creator is not in the user map disappears from the list) is lane + * L2's test of {@code ApplicationRecordApiServiceImpl} and is not repeated. + * + *

    Limits: plain Java values stand in for the items; no Spring, no repositories. + */ +public class ApiUtilViewBuilderTest { + + static final String TAG = "[ApiUtilViewBuilderTest] "; + static final Duration TIMEOUT = Duration.ofSeconds(10); + + /** An item that points at an object id. */ + record Member(String name, String userId) { + } + + /** The object an id points to. */ + record User(String id, String display) { + } + + private static final List MEMBERS = List.of(new Member("m1", "u2"), new Member("m2", "u1"), new Member("m3", "u3")); + private static final Map USERS = Map.of("u1", new User("u1", "Ann"), "u2", new User("u2", "Bob"), "u3", new User("u3", "Cid")); + + private static List build(List members, Map users, AtomicReference> askedFor) { + List result = ViewBuilder.multiBuild(members, Member::userId, + ids -> { + askedFor.set(ids); + return Mono.just(users); + }, + (member, user) -> member.name() + ":" + user.display()).block(TIMEOUT); + System.out.println(TAG + members + " -> " + result); + return result; + } + + @Test + public void resultKeepsTheItemsOrderAndMergesEachItemWithItsObject() { + AtomicReference> asked = new AtomicReference<>(); + assertEquals(List.of("m1:Bob", "m2:Ann", "m3:Cid"), build(MEMBERS, USERS, asked)); + assertEquals(List.of("u2", "u1", "u3"), new ArrayList<>(asked.get()), "the supplier is asked for the ids in the items' order"); + } + + @Test + public void duplicateIdsAreAskedForAsTheyAreAndEveryItemIsMerged() { + AtomicReference> asked = new AtomicReference<>(); + List members = List.of(new Member("a", "u1"), new Member("b", "u1")); + assertEquals(List.of("a:Ann", "b:Ann"), build(members, USERS, asked)); + assertEquals(List.of("u1", "u1"), new ArrayList<>(asked.get()), "duplicates are kept in the request"); + } + + @Test + public void anEmptyInputGivesAnEmptyList() { + AtomicReference> asked = new AtomicReference<>(); + assertEquals(List.of(), build(List.of(), USERS, asked)); + assertEquals(List.of(), new ArrayList<>(asked.get()), "the supplier is still called, with no ids"); + } + + /** + * Behaviour, pinned at this level: an item whose object is not in the supplied map is silently dropped from the result, with no + * error, no placeholder and no count of what was lost. The rest keep their order. + */ + @Test + public void anItemWhoseObjectIsMissingIsSilentlyDropped() { + Map withoutU1 = new HashMap<>(USERS); + withoutU1.remove("u1"); + AtomicReference> asked = new AtomicReference<>(); + assertEquals(List.of("m1:Bob", "m3:Cid"), build(MEMBERS, withoutU1, asked)); + assertEquals(3, asked.get().size(), "all three ids were asked for"); + assertEquals(List.of(), build(MEMBERS, Map.of(), asked), "with no object at all the result is empty, not an error"); + } + + @Test + public void aMergerThatReturnsNullDropsTheItemToo() { + List result = ViewBuilder.multiBuild(MEMBERS, Member::userId, ids -> Mono.just(USERS), + (member, user) -> "m2".equals(member.name()) ? null : member.name()).block(TIMEOUT); + assertEquals(List.of("m1", "m3"), result); + } + + @Test + public void anItemWithANullIdIsLookedUpAsANullKey() { + Map users = new HashMap<>(USERS); + users.put(null, new User("none", "Nobody")); + List members = new ArrayList<>(MEMBERS); + members.add(new Member("m4", null)); + AtomicReference> asked = new AtomicReference<>(); + assertEquals(List.of("m1:Bob", "m2:Ann", "m3:Cid", "m4:Nobody"), build(members, users, asked)); + assertEquals(null, new ArrayList<>(asked.get()).get(3)); + assertEquals(List.of("m1:Bob", "m2:Ann", "m3:Cid"), build(members, new HashMap<>(USERS), asked), "with no entry for the null key the item is dropped"); + assertThrows(NullPointerException.class, () -> build(members, USERS, asked), "an immutable Map.of refuses a null key lookup"); + } + + // ---- the overload with a Flux supplier + + @Test + public void theFluxOverloadBuildsTheMapByTheObjectsOwnIdAndKeepsTheItemsOrder() { + AtomicReference> asked = new AtomicReference<>(); + List result = ViewBuilder.multiBuild(MEMBERS, Member::userId, + ids -> { + asked.set(ids); + return Flux.fromIterable(List.of(USERS.get("u3"), USERS.get("u1"), USERS.get("u2"))); + }, + User::id, + (member, user) -> member.name() + ":" + user.display()).block(TIMEOUT); + System.out.println(TAG + "flux overload -> " + result); + assertEquals(List.of("m1:Bob", "m2:Ann", "m3:Cid"), result); + assertEquals(List.of("u2", "u1", "u3"), new ArrayList<>(asked.get())); + } + + @Test + public void theFluxOverloadDropsItemsWithoutAnObjectAndTheLaterObjectWinsOnADuplicateId() { + List dropped = ViewBuilder.multiBuild(MEMBERS, Member::userId, + ids -> Flux.just(USERS.get("u1")), User::id, (member, user) -> member.name() + ":" + user.display()).block(TIMEOUT); + assertEquals(List.of("m2:Ann"), dropped); + + Map unused = new LinkedHashMap<>(); + assertTrue(unused.isEmpty()); + List duplicate = ViewBuilder.multiBuild(List.of(new Member("m1", "u1")), Member::userId, + ids -> Flux.just(new User("u1", "First"), new User("u1", "Second")), User::id, + (member, user) -> member.name() + ":" + user.display()).block(TIMEOUT); + assertEquals(List.of("m1:Second"), duplicate, "the later object replaces the earlier one"); + List none = ViewBuilder.multiBuild(MEMBERS, Member::userId, ids -> Flux.empty(), User::id, + (member, user) -> member.name()).block(TIMEOUT); + assertEquals(List.of(), none); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/BusinessEventPublisherTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/BusinessEventPublisherTest.java new file mode 100644 index 0000000000..fb0f9b441b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/util/BusinessEventPublisherTest.java @@ -0,0 +1,721 @@ +package org.lowcoder.api.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.lowcoder.api.application.view.ApplicationInfoView; +import org.lowcoder.api.application.view.ApplicationPermissionView; +import org.lowcoder.api.application.view.ApplicationPublishRequest; +import org.lowcoder.api.application.view.ApplicationView; +import org.lowcoder.api.bundle.view.BundleInfoView; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.api.permission.view.CommonPermissionView; +import org.lowcoder.api.permission.view.PermissionItemView; +import org.lowcoder.api.usermanagement.view.AddMemberRequest; +import org.lowcoder.api.usermanagement.view.UpdateRoleRequest; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.service.ApplicationRecordServiceImpl; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.service.DatasourceService; +import org.lowcoder.domain.folder.model.Folder; +import org.lowcoder.domain.folder.service.FolderService; +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.group.model.GroupMember; +import org.lowcoder.domain.group.service.GroupService; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.infra.event.AbstractEvent; +import org.lowcoder.infra.event.ApplicationCommonEvent; +import org.lowcoder.infra.event.DatasourceResourcePermissionEvent; +import org.lowcoder.infra.event.FolderCommonEvent; +import org.lowcoder.infra.event.LibraryQueryEvent; +import org.lowcoder.infra.event.LibraryQueryPublishEvent; +import org.lowcoder.infra.event.QueryExecutionEvent; +import org.lowcoder.infra.event.datasource.DatasourceEvent; +import org.lowcoder.infra.event.datasource.DatasourcePermissionEvent; +import org.lowcoder.infra.event.group.GroupCreateEvent; +import org.lowcoder.infra.event.group.GroupDeleteEvent; +import org.lowcoder.infra.event.group.GroupUpdateEvent; +import org.lowcoder.infra.event.groupmember.GroupMemberAddEvent; +import org.lowcoder.infra.event.groupmember.GroupMemberLeaveEvent; +import org.lowcoder.infra.event.groupmember.GroupMemberRemoveEvent; +import org.lowcoder.infra.event.groupmember.GroupMemberRoleUpdateEvent; +import org.lowcoder.infra.event.user.UserLoginEvent; +import org.lowcoder.infra.event.user.UserLogoutEvent; +import org.lowcoder.plugin.api.event.LowcoderEvent.EventType; +import org.lowcoder.sdk.constants.Authentication; +import org.lowcoder.sdk.constants.GlobalContext; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * {@link BusinessEventPublisher} (audit events) with every collaborator mocked; the published events are captured with an + * {@link ArgumentCaptor} on {@code ApplicationEventPublisher.publishEvent} and read field by field. Every publish method + * calls {@code event.populateDetails(contextView)}, which needs {@code GlobalContext.HEADERS} in the Reactor context, so + * every call here runs with a context holding the headers and a client locale (the one test that omits it says so). + * The infra class {@code AbstractEvent} is covered through these builders and in the last test. + * + *

    Effects pinned as behaviour (none ruled a defect), each named in its test: + *

      + *
    • an audit event is lost, not an error, when its application cannot be loaded for the details, including the + * recycle and restore calls of {@code ApplicationController} (:62, :71) when the application is addressed by a slug: + * {@link #applicationCommon_idForm_applicationNotFoundByTheSecondLookup_publishesNothing}
    • + *
    • the application common events fall back to an {@code OrgMember} with null ids, giving an audit event with null orgId + * and userId: {@link #applicationCommon_visitorOrgMemberLookupFails_publishesAnEventWithNullOrgAndUser}
    • + *
    • a failing visitor lookup gives no audit event and no error: {@link #failingVisitorLookup_isSwallowed_noEventNoError}
    • + *
    + */ +@ExtendWith(MockitoExtension.class) +class BusinessEventPublisherTest { + + private static final String LOG_PREFIX = "[BusinessEventPublisherTest] "; + + private static final String ORG = "org-1"; + private static final String USER = "user-1"; + private static final String TOKEN = "token-abc"; + private static final String APP_ID = "app-1"; + private static final String BUNDLE_ID = "bundle-1"; + private static final String FOLDER_TO = "f-to"; + private static final String FOLDER_FROM = "f-from"; + private static final String GROUP_ID = "group-1"; + private static final String MEMBER_ID = "member-1"; + private static final String DS_ID = "ds-1"; + private static final Locale LOCALE = Locale.GERMAN; + private static final String GROUP_NAME = "name-" + LOCALE.toLanguageTag(); + private static final Map HEADERS = Map.of("x-test", "header-value"); + private static final String ENVIRONMENT_ID = "env-test"; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + @Mock + private SessionUserService sessionUserService; + @Mock + private GroupService groupService; + @Mock + private UserService userService; + @Mock + private FolderService folderService; + @Mock + private ApplicationService applicationService; + @Mock + private DatasourceService datasourceService; + @Mock + private ResourcePermissionService resourcePermissionService; + @Mock + private ApplicationRecordServiceImpl applicationRecordServiceImpl; + + private BusinessEventPublisher publisher; + + private final Application app = mock(Application.class); + private final Group group = mock(Group.class); + private final Folder folderTo = new Folder(); + private final Folder folderFrom = new Folder(); + private final User member = new User(); + private final Datasource datasource = new Datasource(); + private final GroupMember groupMember = new GroupMember(GROUP_ID, MEMBER_ID, MemberRole.ADMIN, ORG, 0L); + private final ResourcePermission oldResourcePermission = ResourcePermission.builder().resourceId(DS_ID).build(); + private final ResourcePermission newResourcePermission = ResourcePermission.builder().resourceId(DS_ID).build(); + private final CommonPermissionView oldPermissionView = mock(CommonPermissionView.class); + private final CommonPermissionView newPermissionView = mock(CommonPermissionView.class); + private final ApplicationPermissionView sharingView = ApplicationPermissionView.builder().build(); + private final List oldPermissions = List.of(mock(PermissionItemView.class)); + private final List newPermissions = List.of(mock(PermissionItemView.class)); + + @BeforeEach + void setUp() { + publisher = new BusinessEventPublisher(applicationEventPublisher, sessionUserService, groupService, userService, + folderService, applicationService, datasourceService, resourcePermissionService, applicationRecordServiceImpl); + AbstractEvent.setEnvironmentID(ENVIRONMENT_ID); + + lenient().when(sessionUserService.getVisitorToken()).thenReturn(Mono.just(TOKEN)); + stubVisitor(USER, false); + + lenient().when(app.getId()).thenReturn(APP_ID); + lenient().when(app.getGid()).thenReturn("gid-app-1"); + lenient().when(app.getName()).thenReturn("App"); + lenient().when(app.getCategory(any())).thenReturn(Mono.just("cat")); + lenient().when(app.getDescription(any())).thenReturn(Mono.just("desc")); + lenient().when(app.getTitle(any())).thenReturn(Mono.just("title")); + lenient().when(applicationService.findById(APP_ID)).thenReturn(Mono.just(app)); + lenient().when(applicationService.findByIdWithoutDsl(APP_ID)).thenReturn(Mono.just(app)); + lenient().when(applicationService.findByIdWithoutDsl(BUNDLE_ID)).thenReturn(Mono.just(app)); + + folderTo.setId(FOLDER_TO); + folderTo.setName("Folder To"); + folderFrom.setId(FOLDER_FROM); + folderFrom.setName("Folder From"); + lenient().when(folderService.findById(FOLDER_TO)).thenReturn(Mono.just(folderTo)); + lenient().when(folderService.findById(FOLDER_FROM)).thenReturn(Mono.just(folderFrom)); + + lenient().when(group.getId()).thenReturn(GROUP_ID); + lenient().when(group.getName(any(Locale.class))).thenAnswer(invocation -> "name-" + ((Locale) invocation.getArgument(0)).toLanguageTag()); + lenient().when(groupService.getById(GROUP_ID)).thenReturn(Mono.just(group)); + member.setId(MEMBER_ID); + member.setName("Member Name"); + lenient().when(userService.findById(MEMBER_ID)).thenReturn(Mono.just(member)); + + datasource.setId(DS_ID); + datasource.setName("Datasource"); + datasource.setType("mysql"); + lenient().when(datasourceService.getById(DS_ID)).thenReturn(Mono.just(datasource)); + + lenient().when(oldPermissionView.getPermissions()).thenReturn(oldPermissions); + lenient().when(newPermissionView.getPermissions()).thenReturn(newPermissions); + } + + @AfterEach + void tearDown() { + AbstractEvent.setEnvironmentID(null); + } + + // ------------------------------------------------------------------ fixtures + + private static void say(String format, Object... args) { + System.out.println(LOG_PREFIX + String.format(format, args)); + } + + private void stubVisitor(String userId, boolean sessionAnonymous) { + OrgMember orgMember = new OrgMember(ORG, userId, MemberRole.MEMBER, "normal", 0L); + lenient().when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.just(orgMember)); + lenient().when(sessionUserService.getVisitorOrgMember()).thenReturn(Mono.just(orgMember)); + lenient().when(sessionUserService.isAnonymousUser()).thenReturn(Mono.just(sessionAnonymous)); + } + + private static String sha512(String text) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-512").digest(text.getBytes(StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private static Mono withContext(Mono mono) { + return mono.contextWrite(context -> context.put(GlobalContext.HEADERS, HEADERS).put(GlobalContext.CLIENT_LOCALE, LOCALE)); + } + + private List publishedEvents(int expected) { + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); + verify(applicationEventPublisher, times(expected)).publishEvent(captor.capture()); + return captor.getAllValues(); + } + + private AbstractEvent singleEvent() { + List events = publishedEvents(1); + assertThat(events.get(0)).isInstanceOf(AbstractEvent.class); + return (AbstractEvent) events.get(0); + } + + private static Object field(Object event, String name) { + return ReflectionTestUtils.getField(event, name); + } + + private static Map fields(Object... keyValues) { + Map map = new HashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + map.put((String) keyValues[i], keyValues[i + 1]); + } + return map; + } + + private static void assertFields(Object event, Map expected) { + expected.forEach((name, value) -> assertThat(field(event, name)).as("field " + name).isEqualTo(value)); + } + + private ApplicationView applicationView(String name, String category, String description, String title, String folderId, String folderIdFrom) { + return ApplicationView.builder().applicationInfoView(ApplicationInfoView.builder() + .orgId("author-org").applicationId(APP_ID).applicationGid("gid-app-1").name(name).createBy("author-1") + .category(category).description(description).title(title).folderId(folderId).folderIdFrom(folderIdFrom).build()).build(); + } + + private ApplicationView originalView() { + return applicationView("Old App", "old-cat", "old-desc", "old-title", null, null); + } + + private BundleInfoView bundleView() { + return BundleInfoView.builder().bundleId(BUNDLE_ID).bundleGid("gid-bundle-1").name("Bundle").folderId(FOLDER_TO).folderIdFrom(FOLDER_FROM).build(); + } + + // ------------------------------------------------------------------ the methods under test + + private enum Method { + FOLDER(FolderCommonEvent.class, false, false), + APP_VIEW_FORM(ApplicationCommonEvent.class, true, false), + APP_ID_FORM(ApplicationCommonEvent.class, true, false), + APP_PERMISSION(ApplicationCommonEvent.class, true, false), + APP_SHARING(ApplicationCommonEvent.class, true, true), + APP_PUBLISH(ApplicationCommonEvent.class, true, true), + APP_VERSION(ApplicationCommonEvent.class, true, true), + BUNDLE_VIEW(ApplicationCommonEvent.class, true, true), + BUNDLE_ID(ApplicationCommonEvent.class, true, true), + LOGIN(UserLoginEvent.class, false, false), + LOGOUT(UserLogoutEvent.class, false, false), + GROUP_CREATE(GroupCreateEvent.class, false, false), + GROUP_UPDATE(GroupUpdateEvent.class, false, false), + GROUP_DELETE(GroupDeleteEvent.class, false, false), + MEMBER_ADD(GroupMemberAddEvent.class, false, false), + MEMBER_ROLE(GroupMemberRoleUpdateEvent.class, false, false), + MEMBER_LEAVE(GroupMemberLeaveEvent.class, false, false), + MEMBER_REMOVE(GroupMemberRemoveEvent.class, false, false), + DS_OBJECT(DatasourceEvent.class, false, false), + DS_ID_FORM(DatasourceEvent.class, false, false), + DS_PERMISSION(DatasourcePermissionEvent.class, false, false), + DS_RESOURCE_PERMISSION(DatasourceResourcePermissionEvent.class, false, false), + LQ_PUBLISH(LibraryQueryPublishEvent.class, false, false), + LQ(LibraryQueryEvent.class, false, false); + + final Class eventType; + /** anonymity comes from {@code SessionUserService.isAnonymousUser()} instead of the org member's user id */ + final boolean sessionAnonymity; + /** an anonymous visitor produces no event at all */ + final boolean skipsAnonymous; + + Method(Class eventType, boolean sessionAnonymity, boolean skipsAnonymous) { + this.eventType = eventType; + this.sessionAnonymity = sessionAnonymity; + this.skipsAnonymous = skipsAnonymous; + } + } + + private Mono call(Method method, boolean publish) { + return switch (method) { + case FOLDER -> publisher.publishFolderCommonEvent("folder-1", "Folder", "From", EventType.FOLDER_UPDATE); + case APP_VIEW_FORM -> publisher.publishApplicationCommonEvent(originalView(), + applicationView("App", "cat-view", "desc-view", "title-view", FOLDER_TO, FOLDER_FROM), EventType.APPLICATION_UPDATE); + case APP_ID_FORM -> publisher.publishApplicationCommonEvent(originalView(), APP_ID, FOLDER_FROM, FOLDER_TO, EventType.APPLICATION_MOVE); + case APP_PERMISSION -> publisher.publishApplicationPermissionEvent(APP_ID, Set.of("u1"), Set.of("g1"), "perm-1", "admin"); + case APP_SHARING -> publisher.publishApplicationSharingEvent(APP_ID, "public", sharingView); + case APP_PUBLISH -> publisher.publishApplicationPublishEvent(APP_ID, new ApplicationPublishRequest("release notes", "v2")); + case APP_VERSION -> publisher.publishApplicationVersionChangeEvent(APP_ID, "v3"); + case BUNDLE_VIEW -> publisher.publishBundleCommonEvent(bundleView(), EventType.BUNDLE_UPDATE); + case BUNDLE_ID -> publisher.publishBundleCommonEvent(BUNDLE_ID, FOLDER_FROM, FOLDER_TO, EventType.BUNDLE_UPDATE); + case LOGIN -> publisher.publishUserLoginEvent("GITHUB"); + case LOGOUT -> publisher.publishUserLogoutEvent(); + case GROUP_CREATE -> publisher.publishGroupCreateEvent(group); + case GROUP_UPDATE -> publisher.publishGroupUpdateEvent(publish, group, "New Group Name"); + case GROUP_DELETE -> publisher.publishGroupDeleteEvent(publish, group); + case MEMBER_ADD -> publisher.publishGroupMemberAddEvent(publish, GROUP_ID, addMemberRequest()); + case MEMBER_ROLE -> publisher.publishGroupMemberRoleUpdateEvent(publish, GROUP_ID, groupMember, updateRoleRequest()); + case MEMBER_LEAVE -> publisher.publishGroupMemberLeaveEvent(publish, groupMember); + case MEMBER_REMOVE -> publisher.publishGroupMemberRemoveEvent(publish, groupMember); + case DS_OBJECT -> publisher.publishDatasourceEvent(datasource, EventType.DATA_SOURCE_UPDATE, "Old Name"); + case DS_ID_FORM -> publisher.publishDatasourceEvent(DS_ID, EventType.DATA_SOURCE_DELETE, "Old Name"); + case DS_PERMISSION -> publisher.publishDatasourcePermissionEvent(DS_ID, List.of("u1"), List.of("g1"), "admin", + EventType.DATA_SOURCE_PERMISSION_UPDATE, oldPermissionView, newPermissionView); + case DS_RESOURCE_PERMISSION -> publisher.publishDatasourceResourcePermissionEvent(EventType.DATA_SOURCE_PERMISSION_GRANT, + oldResourcePermission, newResourcePermission); + case LQ_PUBLISH -> publisher.publishLibraryQueryPublishEvent("lq-1", "v1", "v2", EventType.LIBRARY_QUERY_PUBLISH); + case LQ -> publisher.publishLibraryQueryEvent("lq-1", "Query Name", EventType.LIBRARY_QUERY_UPDATE, "Old Query Name"); + }; + } + + private AddMemberRequest addMemberRequest() { + AddMemberRequest request = new AddMemberRequest(); + request.setUserId(MEMBER_ID); + request.setRole("member"); + return request; + } + + private UpdateRoleRequest updateRoleRequest() { + UpdateRoleRequest request = new UpdateRoleRequest(); + request.setUserId(MEMBER_ID); + request.setRole("super_admin"); + return request; + } + + private void run(Method method) { + StepVerifier.create(withContext(call(method, true))).verifyComplete(); + } + + /** The method-specific fields of the event published by {@link #call}, besides the identity fields. */ + private Map payload(Method method) { + return switch (method) { + case FOLDER -> fields("id", "folder-1", "name", "Folder", "fromName", "From", "type", EventType.FOLDER_UPDATE); + case APP_VIEW_FORM -> fields("applicationId", APP_ID, "applicationGid", "gid-app-1", "applicationName", "App", + "applicationAuthor", "author-1", "applicationAuthorOrgId", "author-org", + "applicationCategory", "cat", "applicationDescription", "desc", "applicationTitle", "title", + "oldApplicationName", "Old App", "oldApplicationCategory", "old-cat", "oldApplicationDescription", "old-desc", + "oldApplicationTitle", "old-title", "type", EventType.APPLICATION_UPDATE, + "folderId", FOLDER_TO, "folderName", "Folder To", "oldFolderId", FOLDER_FROM, "oldFolderName", "Folder From"); + case APP_ID_FORM -> fields("applicationId", APP_ID, "applicationName", "App", "applicationCategory", "cat", + "oldApplicationName", "Old App", "type", EventType.APPLICATION_MOVE, + "folderId", FOLDER_TO, "folderName", "Folder To", "oldFolderId", FOLDER_FROM, "oldFolderName", "Folder From"); + case APP_PERMISSION -> fields("applicationId", APP_ID, "applicationGid", "gid-app-1", "applicationName", "App", + "applicationCategory", "cat", "applicationDescription", "desc", "type", EventType.APPLICATION_PERMISSION_CHANGE, + "permissionId", "perm-1", "role", "admin", "userIds", Set.of("u1"), "groupIds", Set.of("g1")); + case APP_SHARING -> fields("applicationId", APP_ID, "applicationGid", "gid-app-1", "applicationName", "App", "applicationCategory", "cat", + "applicationDescription", "desc", "type", EventType.APPLICATION_SHARING_CHANGE, "shareType", "public", + "sharingDetails", sharingView); + case APP_PUBLISH -> fields("applicationId", APP_ID, "applicationGid", "gid-app-1", "applicationName", "App", "applicationCategory", "cat", + "applicationDescription", "desc", "type", EventType.APPLICATION_PUBLISH, "commitMessage", "release notes", "tag", "v2"); + case APP_VERSION -> fields("applicationId", APP_ID, "applicationGid", "gid-app-1", "applicationName", "App", "applicationCategory", "cat", + "applicationDescription", "desc", "type", EventType.APPLICATION_VERSION_CHANGE, "tag", "v3"); + case BUNDLE_VIEW, BUNDLE_ID -> fields("applicationId", BUNDLE_ID, "applicationGid", method == Method.BUNDLE_VIEW ? "gid-bundle-1" : null, + "applicationName", method == Method.BUNDLE_VIEW ? "Bundle" : "App", + "type", EventType.BUNDLE_UPDATE, "folderId", FOLDER_TO, "folderName", "Folder To", + "oldFolderId", FOLDER_FROM, "oldFolderName", "Folder From"); + case LOGIN -> fields("source", "GITHUB"); + case LOGOUT -> fields(); + case GROUP_CREATE, GROUP_DELETE -> fields("groupId", GROUP_ID, "groupName", GROUP_NAME); + case GROUP_UPDATE -> fields("groupId", GROUP_ID, "groupName", "New Group Name", "oldGroupName", GROUP_NAME); + case MEMBER_ADD -> fields("groupId", GROUP_ID, "groupName", GROUP_NAME, "memberId", MEMBER_ID, + "memberName", "Member Name", "memberRole", "member"); + case MEMBER_ROLE -> fields("groupId", GROUP_ID, "groupName", GROUP_NAME, "memberId", MEMBER_ID, + "memberName", "Member Name", "memberRole", "super_admin", "oldMemberRole", "admin"); + case MEMBER_LEAVE, MEMBER_REMOVE -> fields("groupId", GROUP_ID, "groupName", GROUP_NAME, "memberId", MEMBER_ID, + "memberName", "Member Name", "memberRole", "admin"); + case DS_OBJECT -> fields("datasourceId", DS_ID, "name", "Datasource", "type", "mysql", "oldName", "Old Name", + "eventType", EventType.DATA_SOURCE_UPDATE); + case DS_ID_FORM -> fields("datasourceId", DS_ID, "name", "Datasource", "type", "mysql", "oldName", "Old Name", + "eventType", EventType.DATA_SOURCE_DELETE); + case DS_PERMISSION -> fields("datasourceId", DS_ID, "name", "Datasource", "type", "mysql", "userIds", List.of("u1"), + "groupIds", List.of("g1"), "role", "admin", "oldPermissions", oldPermissions, "newPermissions", newPermissions, + "eventType", EventType.DATA_SOURCE_PERMISSION_UPDATE); + case DS_RESOURCE_PERMISSION -> fields("name", "Datasource", "type", "mysql", "oldPermission", oldResourcePermission, + "newPermission", newResourcePermission, "eventType", EventType.DATA_SOURCE_PERMISSION_GRANT); + case LQ_PUBLISH -> fields("id", "lq-1", "oldVersion", "v1", "newVersion", "v2", "eventType", EventType.LIBRARY_QUERY_PUBLISH); + case LQ -> fields("id", "lq-1", "name", "Query Name", "oldName", "Old Query Name", "eventType", EventType.LIBRARY_QUERY_UPDATE); + }; + } + + // ------------------------------------------------------------------ identity fields + + /** + * Catches a missing or mis-attributed audit event: for every publish method exactly one event of the expected class is + * published with the visitor's org and user ids, {@code isAnonymous} false, the SHA-512 (hex, 128 characters, not the + * token itself) of the visitor token as session hash, the context headers as event headers, and populated details. + */ + @ParameterizedTest + @EnumSource(Method.class) + void everyPublishMethod_publishesOneEventWithTheVisitorsIdentity(Method method) { + run(method); + + AbstractEvent event = singleEvent(); + assertThat(event).isInstanceOf(method.eventType); + assertThat(event.getOrgId()).isEqualTo(ORG); + assertThat(event.getUserId()).isEqualTo(USER); + assertThat(event.getIsAnonymous()).isFalse(); + assertThat(event.getSessionHash()).isEqualTo(sha512(TOKEN)).hasSize(128).isNotEqualTo(TOKEN); + assertThat(event.getEventHeaders()).isEqualTo(HEADERS); + assertThat(event.getDetails()).isNotNull(); + say("%s -> %s published for %s/%s", method, event.getClass().getSimpleName(), event.getOrgId(), event.getUserId()); + } + + /** + * Catches a wrong anonymous flag: application and bundle events take it from {@code isAnonymousUser()}, the others + * from the org member's user id ({@code Authentication.isAnonymousUser}); an anonymous visitor produces no event for + * sharing, publish, version change and the two bundle forms, and an event with {@code isAnonymous} true otherwise. + */ + @ParameterizedTest + @EnumSource(Method.class) + void anonymousVisitor_isFlagged_orSkipped(Method method) { + if (method.sessionAnonymity) { + stubVisitor(USER, true); + } else { + stubVisitor(Authentication.ANONYMOUS_USER_ID, false); + } + + run(method); + + if (method.skipsAnonymous) { + verifyNoInteractions(applicationEventPublisher); + } else { + AbstractEvent event = singleEvent(); + assertThat(event.getIsAnonymous()).isTrue(); + assertThat(event.getUserId()).isEqualTo(method.sessionAnonymity ? USER : Authentication.ANONYMOUS_USER_ID); + } + say("%s anonymous -> %s", method, method.skipsAnonymous ? "no event" : "event flagged anonymous"); + } + + /** Catches wrong or missing payload fields: each method's event carries the fields its audit entry is about. */ + @ParameterizedTest + @EnumSource(Method.class) + void everyPublishMethod_carriesTheRightPayload(Method method) { + run(method); + + assertFields(singleEvent(), payload(method)); + say("%s payload verified: %s", method, payload(method).keySet()); + } + + // ------------------------------------------------------------------ application common event details + + /** + * Catches wrong "old" values: they come from the original view and are null when there is no original view; the new + * values come from the new view / the live DSL. + */ + @Test + void applicationCommon_oldValuesComeFromTheOriginalView_nullWithoutOne() { + ApplicationView view = applicationView("App", "cat-view", "desc-view", "title-view", null, null); + StepVerifier.create(withContext(publisher.publishApplicationCommonEvent(null, view, EventType.APPLICATION_CREATE))).verifyComplete(); + + AbstractEvent event = singleEvent(); + assertFields(event, fields("oldApplicationName", null, "oldApplicationCategory", null, "oldApplicationDescription", null, + "oldApplicationTitle", null, "applicationName", "App", "type", EventType.APPLICATION_CREATE)); + say("application common without original view: old values null"); + } + + static Stream folderRows() { + return Stream.of( + Arguments.of("resolved folders", FOLDER_TO, FOLDER_FROM, FOLDER_TO, "Folder To", FOLDER_FROM, "Folder From"), + Arguments.of("null folder ids", null, null, null, null, null, null), + Arguments.of("blank folder ids", " ", "", null, null, null, null), + Arguments.of("unknown folders", "f-missing", "f-missing-2", null, null, null, null)); + } + + /** + * Catches wrong folder fields: folders are resolved through {@code FolderService} only for non-blank ids (a blank or + * null id makes no lookup), a folder that cannot be found (the real service errors with FOLDER_NOT_FOUND) gives null id and name instead of an error, and the "from" + * folder fills the old folder fields. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("folderRows") + void applicationCommon_resolvesFoldersOnlyForNonBlankIds_andToleratesUnknownOnes(String label, String folderId, String folderIdFrom, + String expectedId, String expectedName, String expectedOldId, String expectedOldName) { + lenient().when(folderService.findById("f-missing")).thenReturn(Mono.error(new IllegalStateException("not found"))); + lenient().when(folderService.findById("f-missing-2")).thenReturn(Mono.error(new BizException(BizError.NO_RESOURCE_FOUND, "FOLDER_NOT_FOUND", "f-missing-2"))); + ApplicationView view = applicationView("App", null, null, null, folderId, folderIdFrom); + + StepVerifier.create(withContext(publisher.publishApplicationCommonEvent(null, view, EventType.APPLICATION_MOVE))).verifyComplete(); + + assertFields(singleEvent(), fields("folderId", expectedId, "folderName", expectedName, "oldFolderId", expectedOldId, + "oldFolderName", expectedOldName)); + if (folderId == null || folderId.isBlank()) { + verify(folderService, never()).findById(any()); + } + say("%s -> folder %s / old folder %s", label, expectedId, expectedOldId); + } + + /** + * Catches wrong folder fields of the bundle events (view form): folders are resolved only for non-blank ids (no lookup + * for a null or blank id), a folder that cannot be found gives null id and name, not an error. + */ + @ParameterizedTest(name = "{0}") + @MethodSource("folderRows") + void bundleCommon_resolvesFoldersOnlyForNonBlankIds_andToleratesUnknownOnes(String label, String folderId, String folderIdFrom, + String expectedId, String expectedName, String expectedOldId, String expectedOldName) { + lenient().when(folderService.findById("f-missing")).thenReturn(Mono.error(new BizException(BizError.NO_RESOURCE_FOUND, "FOLDER_NOT_FOUND", "f-missing"))); + lenient().when(folderService.findById("f-missing-2")).thenReturn(Mono.error(new BizException(BizError.NO_RESOURCE_FOUND, "FOLDER_NOT_FOUND", "f-missing-2"))); + BundleInfoView view = BundleInfoView.builder().bundleId(BUNDLE_ID).name("Bundle").folderId(folderId).folderIdFrom(folderIdFrom).build(); + + StepVerifier.create(withContext(publisher.publishBundleCommonEvent(view, EventType.BUNDLE_UPDATE))).verifyComplete(); + + assertFields(singleEvent(), fields("folderId", expectedId, "folderName", expectedName, "oldFolderId", expectedOldId, + "oldFolderName", expectedOldName)); + if (folderId == null || folderId.isBlank()) { + verify(folderService, never()).findById(any()); + } + say("bundle %s -> folder %s / old folder %s", label, expectedId, expectedOldId); + } + + /** Catches a datasource permission change audited without old or new permission views: both permission lists are then null. */ + @Test + void datasourcePermission_withoutPermissionViews_hasNullPermissions() { + StepVerifier.create(withContext(publisher.publishDatasourcePermissionEvent(DS_ID, List.of("u1"), List.of("g1"), "admin", + EventType.DATA_SOURCE_PERMISSION_GRANT, null, null))).verifyComplete(); + + assertFields(singleEvent(), fields("oldPermissions", null, "newPermissions", null, "eventType", EventType.DATA_SOURCE_PERMISSION_GRANT)); + say("datasource permission without views -> null permissions"); + } + + // ------------------------------------------------------------------ guards and swallowed failures + + /** Catches an audit event for a change that was not made: with {@code publish == false} there is no event and no service call. */ + @ParameterizedTest + @EnumSource(value = Method.class, names = {"GROUP_UPDATE", "GROUP_DELETE", "MEMBER_ADD", "MEMBER_ROLE", "MEMBER_LEAVE", "MEMBER_REMOVE"}) + void guardedMethods_withPublishFalse_doNothing(Method method) { + StepVerifier.create(withContext(call(method, false))).verifyComplete(); + + verifyNoInteractions(applicationEventPublisher, sessionUserService, groupService, userService); + say("%s with publish=false -> nothing", method); + } + + private enum Failure { + TOKEN, ORG_MEMBER + } + + static Stream failureRows() { + List rows = new ArrayList<>(); + for (Method method : Method.values()) { + for (Failure failure : Failure.values()) { + boolean appCommonFallback = failure == Failure.ORG_MEMBER && (method == Method.APP_VIEW_FORM || method == Method.APP_ID_FORM); + if (!appCommonFallback) { + rows.add(Arguments.of(method, failure)); + } + } + } + return rows.stream(); + } + + /** + * Pins today's behaviour: an audit failure never aborts the business call. When the visitor token or the organization + * member cannot be read, every publish method completes empty (no error) and publishes no event, so that audit event + * is lost. (The application common events with a failing org member are the next test.) + */ + @ParameterizedTest(name = "{0}, failing {1}") + @MethodSource("failureRows") + void failingVisitorLookup_isSwallowed_noEventNoError(Method method, Failure failure) { + IllegalStateException failureCause = new IllegalStateException("lookup failed"); + if (failure == Failure.TOKEN) { + lenient().when(sessionUserService.getVisitorToken()).thenReturn(Mono.error(failureCause)); + } else { + lenient().when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.error(failureCause)); + lenient().when(sessionUserService.getVisitorOrgMember()).thenReturn(Mono.error(failureCause)); + } + + StepVerifier.create(withContext(call(method, true))).verifyComplete(); + + verifyNoInteractions(applicationEventPublisher); + say("%s with failing %s -> completes empty, no event", method, failure); + } + + /** + * Pins today's behaviour: the application common events (view and id form) replace a failing organization member + * lookup by {@code new OrgMember(null, null, null, null, 0)}, so an audit event IS published, with a null orgId and a + * null userId (the audit entry cannot say who did it or in which organization). + */ + @ParameterizedTest + @EnumSource(value = Method.class, names = {"APP_VIEW_FORM", "APP_ID_FORM"}) + void applicationCommon_visitorOrgMemberLookupFails_publishesAnEventWithNullOrgAndUser(Method method) { + lenient().when(sessionUserService.getVisitorOrgMemberCache()).thenReturn(Mono.error(new IllegalStateException("no member"))); + + run(method); + + AbstractEvent event = singleEvent(); + assertThat(event.getOrgId()).isNull(); + assertThat(event.getUserId()).isNull(); + assertThat(event.getSessionHash()).isEqualTo(sha512(TOKEN)); + say("%s with failing org member -> event with null org and user", method); + } + + /** + * Pins today's behaviour: the application common events are lost, with no error, when the application cannot be + * loaded for the details. (1) id form: an unknown id gives an empty result. (2) both forms: the second lookup + * {@code applicationService.findById(applicationInfoView.getApplicationId())} failing or empty drops the event. The + * id form passes the raw id of the caller on to that second lookup; {@code ApplicationController.recycle} (:62) and + * {@code restore} (:71) pass the path variable as is, and {@code findById} resolves only an object id or a GID + * ({@code CustomApplicationRepositoryImpl.findByIdWithDsl}, :28-29), so for an application addressed by its slug + * (which {@code findByIdWithoutDsl} and {@code GidService.convertApplicationIdToObjectId} do accept) the recycle and + * restore audit events are lost. The delete flow (:87) passes the view built after the soft delete, whose id resolves, + * so it is not affected. Not verified against a real database here: the second lookup is stubbed to fail. + */ + @Test + void applicationCommon_idForm_applicationNotFoundByTheSecondLookup_publishesNothing() { + lenient().when(applicationService.findByIdWithoutDsl("app-slug")).thenReturn(Mono.just(app)); + lenient().when(applicationService.findById("app-slug")) + .thenReturn(Mono.error(new BizException(BizError.NO_RESOURCE_FOUND, "CANT_FIND_APPLICATION", "app-slug"))); + lenient().when(applicationService.findByIdWithoutDsl("app-unknown")).thenReturn(Mono.empty()); + lenient().when(applicationService.findById("app-empty")).thenReturn(Mono.empty()); + + StepVerifier.create(withContext(publisher.publishApplicationCommonEvent(originalView(), "app-slug", null, null, EventType.APPLICATION_RECYCLED))).verifyComplete(); + StepVerifier.create(withContext(publisher.publishApplicationCommonEvent(originalView(), "app-unknown", null, null, EventType.APPLICATION_RECYCLED))).verifyComplete(); + StepVerifier.create(withContext(publisher.publishApplicationCommonEvent(originalView(), + ApplicationView.builder().applicationInfoView(ApplicationInfoView.builder().applicationId("app-empty").build()).build(), + EventType.APPLICATION_DELETE))).verifyComplete(); + + verifyNoInteractions(applicationEventPublisher); + say("application common: unresolvable application -> no event, no error"); + } + + /** + * Pins today's behaviour (explains why every test here writes the headers into the context): without + * {@code GlobalContext.HEADERS} in the Reactor context {@code populateDetails} throws, the failure is swallowed and no + * event is published. In production the web filter always puts the headers (L4-7 evidence in plan section 9), so this + * is only reachable here. + */ + @Test + void contextWithoutHeaders_populateDetailsFails_eventSwallowed() { + StepVerifier.create(publisher.publishUserLogoutEvent()).verifyComplete(); + + verifyNoInteractions(applicationEventPublisher); + say("no headers in the context -> no event"); + } + + /** Catches a failing datasource lookup by id breaking the caller: an unknown datasource gives an empty result and no event. */ + @Test + void datasourceEvent_idForm_unknownDatasource_publishesNothing() { + lenient().when(datasourceService.getById("ds-missing")).thenReturn(Mono.error(new IllegalStateException("not found"))); + + StepVerifier.create(withContext(publisher.publishDatasourceEvent("ds-missing", EventType.DATA_SOURCE_DELETE, "Old"))).verifyComplete(); + + verifyNoInteractions(applicationEventPublisher); + say("datasource event for an unknown datasource -> no event, no error"); + } + + // ------------------------------------------------------------------ the synchronous query execution event + + /** Catches a modified or dropped query execution event: it is handed to the publisher unchanged. */ + @Test + void publishQueryExecutionEvent_handsTheEventOverUnchanged() { + QueryExecutionEvent event = QueryExecutionEvent.builder().build(); + + publisher.publishQueryExecutionEvent(event); + + assertThat(publishedEvents(1).get(0)).isSameAs(event); + say("query execution event handed over"); + } + + // ------------------------------------------------------------------ AbstractEvent (lowcoder-infra), covered through the builders + + /** + * Covers {@code AbstractEvent}: the builder's {@code detail(name, value)} stores the value and the environment id; + * {@code populateDetails} adds {@code environmentId} and the headers and copies the declared fields of the concrete event + * into {@code details}, skipping a null field annotated {@code @JsonInclude(NON_NULL)} and keeping a null field without + * it, and takes the event headers from the context. ({@code details()} is only called after {@code detail(...)} or + * {@code populateDetails}: it fails on an event without details, which no production path reaches, plan section 9.) + */ + @Test + void abstractEvent_detailsAndPopulateDetails() { + FolderCommonEvent withDetail = FolderCommonEvent.builder().detail("custom", "value").build(); + assertThat(withDetail.getDetails()).containsEntry("custom", "value").containsEntry("environmentId", ENVIRONMENT_ID); + assertThat(withDetail.details()).containsEntry("custom", "value").containsEntry("environmentId", ENVIRONMENT_ID); + + run(Method.APP_PUBLISH); + AbstractEvent populated = singleEvent(); + assertThat(populated.getDetails()).containsEntry("tag", "v2").containsEntry("commitMessage", "release notes") + .containsEntry("applicationName", "App"); + assertThat(populated.getDetails()).doesNotContainKeys("permissionId", "role", "userIds", "groupIds", "shareType", "sharingDetails", "folderId"); + assertThat(populated.getDetails()).containsKey("oldApplicationName"); + assertThat(populated.getDetails().get("oldApplicationName")).isNull(); + assertThat(populated.getDetails()).containsEntry("environmentId", ENVIRONMENT_ID).containsEntry("headers", HEADERS); + assertThat(populated.details()).containsEntry("environmentId", ENVIRONMENT_ID); + assertThat(populated.getEventHeaders()).isEqualTo(HEADERS); + say("AbstractEvent details: %s", populated.getDetails().keySet()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/datasource/DatasourceRepositoryMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/datasource/DatasourceRepositoryMongoTest.java new file mode 100644 index 0000000000..044ec266f0 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/datasource/DatasourceRepositoryMongoTest.java @@ -0,0 +1,261 @@ +package org.lowcoder.domain.datasource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceCreationSource; +import org.lowcoder.domain.datasource.model.DatasourceDO; +import org.lowcoder.domain.datasource.model.DatasourceStatus; +import org.lowcoder.domain.datasource.repository.DatasourceRepository; +import org.lowcoder.domain.plugin.client.DatasourcePluginClient; +import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService; +import org.lowcoder.sdk.models.JsDatasourceConnectionConfig; +import org.lowcoder.sdk.util.IDUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import reactor.core.publisher.Mono; + +/** + * DatasourceRepository against the MongoDB test container (unit U14, task L3-11c). The plugin connectors are not available + * in this module's surefire context, so the class has a Spring context of its own (extra property, hence its own database) + * with a mocked DatasourceMetaInfoService (every type is a "JS" datasource whose config is the stored map) and a mocked + * DatasourcePluginClient (no plugin definition). Every test works under ids and organisations it generates. Not repeated: + * DatasourcePersistenceContractTest (the stored shape of save), the L1-3 row on DatasourceApiServiceImpl.getPluginDynamicConfig, + * the L3-3 row on Datasource.mergeWith. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +@TestPropertySource(properties = "l3_11c.context=datasource-repository") +class DatasourceRepositoryMongoTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + private static final String JS_TYPE = "someJsPlugin"; + private static final String BROKEN_TYPE = "brokenPlugin"; + + @MockBean + private DatasourceMetaInfoService datasourceMetaInfoService; + @MockBean + private DatasourcePluginClient datasourcePluginClient; + @Autowired + private DatasourceRepository repository; + @Autowired + private ReactiveMongoTemplate mongo; + + @BeforeEach + void everyTypeIsAJsDatasourceWithoutADefinition() { + when(datasourceMetaInfoService.isJsDatasourcePlugin(anyString())).thenReturn(true); + when(datasourceMetaInfoService.isJsDatasourcePlugin(BROKEN_TYPE)).thenThrow(new IllegalStateException("no connector")); + when(datasourcePluginClient.getDatasourcePluginDefinition(anyString())).thenReturn(Mono.empty()); + } + + private static String org() { + return "org-" + IDUtils.generate(); + } + + private Datasource save(String orgId, String type, DatasourceCreationSource source) { + JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig(); + config.put("host", "db.example"); + Datasource datasource = Datasource.builder().organizationId(orgId).name("ds-" + IDUtils.generate()).type(type) + .gid(UUID.randomUUID().toString()).creationSource(source.getValue()).datasourceStatus(DatasourceStatus.NORMAL) + .detailConfig(config).build(); + return repository.save(datasource).block(TIMEOUT); + } + + private Datasource save(String orgId) { + return save(orgId, JS_TYPE, DatasourceCreationSource.USER_CREATED); + } + + private static List ids(List datasources) { + return datasources.stream().map(Datasource::getId).toList(); + } + + /** Catches: the gid lookup broken, a lost field in the DO-to-domain conversion, an unknown key not completing empty. */ + @Test + void saveThenFindByIdAndByGidRoundTripsTheFields() { + String orgId = org(); + Datasource saved = save(orgId); + + Datasource byId = repository.findById(saved.getId()).block(TIMEOUT); + Datasource byGid = repository.findById(saved.getGid()).block(TIMEOUT); + + System.out.println("[DatasourceRepositoryMongoTest] saved " + saved.getId() + " gid " + saved.getGid()); + for (Datasource found : List.of(byId, byGid)) { + assertThat(found.getId()).isEqualTo(saved.getId()); + assertThat(found.getGid()).isEqualTo(saved.getGid()); + assertThat(found.getName()).isEqualTo(saved.getName()); + assertThat(found.getType()).isEqualTo(JS_TYPE); + assertThat(found.getOrganizationId()).isEqualTo(orgId); + assertThat(found.getCreationSource()).isEqualTo(DatasourceCreationSource.USER_CREATED.getValue()); + assertThat(found.getDatasourceStatus()).isEqualTo(DatasourceStatus.NORMAL); + assertThat(found.getDetailConfig()).isNotNull(); + } + assertThat(repository.findById(IDUtils.generate()).blockOptional(TIMEOUT)).isEmpty(); + assertThat(repository.findById(UUID.randomUUID().toString()).blockOptional(TIMEOUT)).isEmpty(); + } + + /** Catches: findByIds querying the wrong key type for a homogeneous list; an empty list failing. */ + @Test + void findByIdsAnswersHomogeneousIdOrGidListsAndNothingForAnEmptyList() { + String orgId = org(); + Datasource a = save(orgId); + Datasource b = save(orgId); + save(orgId); + + assertThat(ids(repository.findByIds(List.of(a.getId(), b.getId())).collectList().block(TIMEOUT))) + .containsExactlyInAnyOrder(a.getId(), b.getId()); + assertThat(ids(repository.findByIds(List.of(a.getGid(), b.getGid())).collectList().block(TIMEOUT))) + .containsExactlyInAnyOrder(a.getId(), b.getId()); + assertThat(repository.findByIds(List.of()).collectList().block(TIMEOUT)).isEmpty(); + } + + /** + * Pins plan section 9 row "DatasourceRepository.findByIds picks id or gid from one element and drops the other kind + * (:65-72)": the key type of the whole list is decided by one element (findAny, the first of a list), so a mixed list + * only returns the datasources of that element's kind. Reach: MetaController.getDatasourceMetas (MetaController:50) passes + * the client's datasource id list through DatasourceServiceImpl.getByIds (:133) unchanged. A fix (query both kinds, as + * findAllById does) changes this test on purpose. + */ + @Test + void findByIdsOfAMixedListDropsTheOtherKind_pinsTheSection9Row() { + String orgId = org(); + Datasource a = save(orgId); + Datasource b = save(orgId); + + List idFirst = ids(repository.findByIds(List.of(a.getId(), b.getGid())).collectList().block(TIMEOUT)); + List gidFirst = ids(repository.findByIds(List.of(b.getGid(), a.getId())).collectList().block(TIMEOUT)); + + System.out.println("[DatasourceRepositoryMongoTest] PINNED mixed list: id first -> " + idFirst.size() + ", gid first -> " + gidFirst.size()); + assertThat(idFirst).containsExactly(a.getId()); + assertThat(gidFirst).containsExactly(b.getId()); + } + + /** Catches: findAllById dropping one key kind (it is the variant that must handle mixed lists). */ + @Test + void findAllByIdAnswersMixedIdAndGidListsInFull() { + String orgId = org(); + Datasource a = save(orgId); + Datasource b = save(orgId); + Datasource c = save(orgId); + + assertThat(ids(repository.findAllById(List.of(a.getId(), b.getGid(), c.getId())).collectList().block(TIMEOUT))) + .containsExactlyInAnyOrder(a.getId(), b.getId(), c.getId()); + assertThat(ids(repository.findAllById(List.of(a.getId(), c.getId())).collectList().block(TIMEOUT))) + .containsExactlyInAnyOrder(a.getId(), c.getId()); + assertThat(ids(repository.findAllById(List.of(b.getGid())).collectList().block(TIMEOUT))).containsExactly(b.getId()); + assertThat(repository.findAllById(List.of()).collectList().block(TIMEOUT)).isEmpty(); + } + + /** Catches: a cross-org leak or a wrong creation-source / type match in the organisation queries. */ + @Test + void organisationQueriesAreScopedToTheOrgTypeAndCreationSource() { + String orgId = org(); + String otherOrg = org(); + Datasource own1 = save(orgId); + Datasource own2 = save(orgId); + save(otherOrg); + Datasource predefined = save(orgId, "legacyType", DatasourceCreationSource.LEGACY_WORKSPACE_PREDEFINED); + save(otherOrg, "legacyType", DatasourceCreationSource.LEGACY_WORKSPACE_PREDEFINED); + save(orgId, "legacyType", DatasourceCreationSource.USER_CREATED); + + assertThat(ids(repository.findAllByOrganizationId(orgId).collectList().block(TIMEOUT))) + .contains(own1.getId(), own2.getId(), predefined.getId()).hasSize(4); + assertThat(repository.countByOrganizationId(orgId).block(TIMEOUT)).isEqualTo(4L); + assertThat(repository.countByOrganizationId(org()).block(TIMEOUT)).isZero(); + assertThat(repository.findWorkspacePredefinedDatasourceByOrgIdAndType(orgId, "legacyType").block(TIMEOUT).getId()) + .isEqualTo(predefined.getId()); + assertThat(repository.findWorkspacePredefinedDatasourceByOrgIdAndType(orgId, JS_TYPE).blockOptional(TIMEOUT)).isEmpty(); + System.out.println("[DatasourceRepositoryMongoTest] org queries scoped to " + orgId); + } + + /** Catches: the delete marker wiping other fields, or not being stored. */ + @Test + void markDatasourceAsDeletedOnlyChangesTheStatus() { + Datasource saved = save(org()); + + assertThat(repository.markDatasourceAsDeleted(saved.getId()).block(TIMEOUT)).isTrue(); + + DatasourceDO raw = mongo.findById(saved.getId(), DatasourceDO.class).block(TIMEOUT); + assertThat(raw.getDatasourceStatus()).isEqualTo(DatasourceStatus.DELETED); + assertThat(raw.getName()).isEqualTo(saved.getName()); + assertThat(raw.getOrganizationId()).isEqualTo(saved.getOrganizationId()); + assertThat(raw.getDetailConfig()).isNotEmpty(); + } + + /** Catches: other orgs' ids reported as usable, own org's ids reported as missing, an empty input failing. */ + @Test + void retainReturnsTheIdsThatAreMissingOrBelongToAnotherOrg() { + String orgId = org(); + Datasource own = save(orgId); + Datasource foreign = save(org()); + String missing = IDUtils.generate(); + + List retained = repository.retainNoneExistAndNonCurrentOrgDatasourceIds( + List.of(own.getId(), foreign.getId(), missing), orgId).collectList().block(TIMEOUT); + + System.out.println("[DatasourceRepositoryMongoTest] retained " + retained.size() + " of 3 ids"); + assertThat(retained).containsExactlyInAnyOrder(foreign.getId(), missing); + assertThat(repository.retainNoneExistAndNonCurrentOrgDatasourceIds(List.of(own.getId()), orgId).collectList().block(TIMEOUT)).isEmpty(); + assertThat(repository.retainNoneExistAndNonCurrentOrgDatasourceIds(List.of(), orgId).collectList().block(TIMEOUT)).isEmpty(); + } + + /** + * Pins plan section 9 row "retainNoneExistAndNonCurrentOrgDatasourceIds reports existing current-org datasources as + * missing when given gids (:118-137)": for gids the datasources are found by gid but removed from the result by their real + * id, so every gid stays in the result, whatever exists in the current org. Effect on the callers: they treat the result + * as "needs no permission": ApplicationApiServiceImpl:757-768 passes the application edit check when + * hasPermission-union-retained covers all ids (so gid-referenced datasources never need the USE_DATASOURCES permission), + * and LibraryQueryApiServiceImpl:104-106 lists library queries whose datasource is in that set. A fix (remove by the + * key kind that was queried) changes this test on purpose. + */ + @Test + void retainOfGidsReportsExistingCurrentOrgDatasourcesAsMissing_pinsTheSection9Row() { + String orgId = org(); + Datasource own = save(orgId); + Datasource foreign = save(org()); + + List byId = repository.retainNoneExistAndNonCurrentOrgDatasourceIds(List.of(own.getId()), orgId).collectList().block(TIMEOUT); + List byGid = repository.retainNoneExistAndNonCurrentOrgDatasourceIds(List.of(own.getGid(), foreign.getGid()), orgId) + .collectList().block(TIMEOUT); + + System.out.println("[DatasourceRepositoryMongoTest] PINNED by id -> " + byId + ", by gid -> " + byGid.size() + " of 2"); + assertThat(byId).as("the same datasource asked by its id is correctly not reported").isEmpty(); + assertThat(byGid).as("asked by gid, the existing current-org datasource is reported as missing").containsExactlyInAnyOrder(own.getGid(), foreign.getGid()); + } + + /** + * Behaviour (candidate D3): a config that cannot be resolved is swallowed (convertToDomainObjectAndDecrypt ends with + * onErrorResume), so the datasource is returned WITHOUT a config and without an error; Datasource.mergeWith then hits the + * L3-3 NullPointerException row. + */ + @Test + void aDatasourceWhoseConfigCannotBeResolvedComesBackWithoutAConfig() { + String orgId = org(); + Datasource broken = save(orgId); + // the type is switched in the stored document afterwards, because saving a datasource of that type already needs the connector + mongo.updateFirst(Query.query(Criteria.where("_id").is(broken.getId())), Update.update("type", BROKEN_TYPE), DatasourceDO.class) + .block(TIMEOUT); + + assertThat(broken.getId()).isNotBlank(); + Datasource found = repository.findById(broken.getId()).block(TIMEOUT); + System.out.println("[DatasourceRepositoryMongoTest] unresolvable config: datasource returned, config=" + found.getDetailConfig()); + assertThat(found.getName()).isEqualTo(broken.getName()); + assertThat(found.getDetailConfig()).isNull(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/datasource/TokenBasedConnectionRepositoryMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/datasource/TokenBasedConnectionRepositoryMongoTest.java new file mode 100644 index 0000000000..d9b0fcd50a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/datasource/TokenBasedConnectionRepositoryMongoTest.java @@ -0,0 +1,172 @@ +package org.lowcoder.domain.datasource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.datasource.model.TokenBasedConnection; +import org.lowcoder.domain.datasource.model.TokenBasedConnectionDO; +import org.lowcoder.domain.datasource.repository.TokenBasedConnectionRepository; +import org.lowcoder.domain.encryption.EncryptionService; +import org.lowcoder.domain.plugin.client.DatasourcePluginClient; +import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService; +import org.lowcoder.sdk.models.TokenBasedConnectionDetail; +import org.lowcoder.sdk.plugin.common.DatasourceConnector; +import org.lowcoder.sdk.util.IDUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +/** + * TokenBasedConnectionRepository against the MongoDB test container (unit U14, task L3-11c). The connector that turns the + * stored map back into a token detail is a mock (plugin jars are not on the surefire classpath); the detail class is a fake + * with one secret field, which makes the encryption at rest observable in the raw document. Shares the Spring context of + * DatasourceRepositoryMongoTest (same extra property and mock beans). + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +@TestPropertySource(properties = "l3_11c.context=datasource-repository") +class TokenBasedConnectionRepositoryMongoTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + private static final String TYPE = "tokenPlugin"; + private static final String ACCESS_TOKEN = "accessToken"; + + /** Fake detail: one secret field, reversible encryption through the functions it is handed. */ + static class FakeTokenDetail implements TokenBasedConnectionDetail { + String accessToken; + boolean stale; + + FakeTokenDetail(String accessToken, boolean stale) { + this.accessToken = accessToken; + this.stale = stale; + } + + @Override + public boolean isStale() { + return stale; + } + + @Override + public Map toMap() { + Map map = new HashMap<>(); + map.put(ACCESS_TOKEN, accessToken); + map.put("stale", stale); + return map; + } + + @Override + public void doEncrypt(Function encryptFunc) { + accessToken = encryptFunc.apply(accessToken); + } + + @Override + public void doDecrypt(Function decryptFunc) { + accessToken = decryptFunc.apply(accessToken); + } + } + + @MockBean + private DatasourceMetaInfoService datasourceMetaInfoService; + @MockBean + private DatasourcePluginClient datasourcePluginClient; + @Autowired + private TokenBasedConnectionRepository repository; + @Autowired + private EncryptionService encryptionService; + @Autowired + private ReactiveMongoTemplate mongo; + + @SuppressWarnings("unchecked") + @BeforeEach + void theConnectorBuildsAFakeDetailFromTheStoredMap() { + DatasourceConnector connector = org.mockito.Mockito.mock(DatasourceConnector.class); + when(connector.resolveTokenDetail(anyMap())).thenAnswer(invocation -> { + Map map = invocation.getArgument(0); + return new FakeTokenDetail((String) map.get(ACCESS_TOKEN), Boolean.TRUE.equals(map.get("stale"))); + }); + when(datasourceMetaInfoService.getDatasourceConnector(anyString())).thenReturn((DatasourceConnector) connector); + } + + private static TokenBasedConnection connection(String datasourceId, String token, boolean stale) { + TokenBasedConnection connection = new TokenBasedConnection(); + connection.setDatasourceId(datasourceId); + connection.setTokenDetail(new FakeTokenDetail(token, stale)); + return connection; + } + + private long countRaw(String datasourceId) { + return mongo.count(Query.query(Criteria.where("datasourceId").is(datasourceId)), TokenBasedConnectionDO.class).block(TIMEOUT); + } + + private TokenBasedConnectionDO raw(String datasourceId) { + return mongo.findOne(Query.query(Criteria.where("datasourceId").is(datasourceId)), TokenBasedConnectionDO.class).block(TIMEOUT); + } + + /** Catches: the token stored in clear, the encryption not being undone on the caller's object, a lost field on the way back. */ + @Test + void saveStoresTheTokenEncryptedAndFindDecryptsItAgain() { + String datasourceId = IDUtils.generate(); + TokenBasedConnection connection = connection(datasourceId, "secret-token-1", true); + + repository.saveConnection(connection, datasourceId).block(TIMEOUT); + + String stored = (String) raw(datasourceId).getTokenDetail().get(ACCESS_TOKEN); + System.out.println("[TokenBasedConnectionRepositoryMongoTest] stored token is " + stored.length() + " chars, not the clear text"); + assertThat(stored).isNotEqualTo("secret-token-1"); + assertThat(encryptionService.decryptString(stored)).isEqualTo("secret-token-1"); + assertThat(((FakeTokenDetail) connection.getTokenDetail()).accessToken) + .as("the caller's object is decrypted again after the save").isEqualTo("secret-token-1"); + + TokenBasedConnection found = repository.findByDatasourceId(datasourceId, TYPE).block(TIMEOUT); + assertThat(found.getDatasourceId()).isEqualTo(datasourceId); + assertThat(found.getId()).isNotBlank(); + assertThat(found.isStale()).isTrue(); + assertThat(((FakeTokenDetail) found.getTokenDetail()).accessToken).isEqualTo("secret-token-1"); + } + + /** Catches: a second save inserting a second document, or replacing the id / creation data. */ + @Test + void savingAgainForTheSameDatasourceReplacesTheTokenInTheSameDocument() { + String datasourceId = IDUtils.generate(); + repository.saveConnection(connection(datasourceId, "first", false), datasourceId).block(TIMEOUT); + TokenBasedConnectionDO firstRaw = raw(datasourceId); + + repository.saveConnection(connection(datasourceId, "second", false), datasourceId).block(TIMEOUT); + + assertThat(countRaw(datasourceId)).isEqualTo(1L); + TokenBasedConnectionDO secondRaw = raw(datasourceId); + assertThat(secondRaw.getId()).isEqualTo(firstRaw.getId()); + assertThat(encryptionService.decryptString((String) secondRaw.getTokenDetail().get(ACCESS_TOKEN))).isEqualTo("second"); + TokenBasedConnection found = repository.findByDatasourceId(datasourceId, TYPE).block(TIMEOUT); + assertThat(((FakeTokenDetail) found.getTokenDetail()).accessToken).isEqualTo("second"); + assertThat(found.isStale()).isFalse(); + } + + /** Catches: a cross-datasource mix-up in the lookup, a missing connection failing instead of completing empty. */ + @Test + void connectionsOfDifferentDatasourcesStayApartAndAnUnknownOneIsEmpty() { + String a = IDUtils.generate(); + String b = IDUtils.generate(); + repository.saveConnection(connection(a, "token-a", false), a).block(TIMEOUT); + repository.saveConnection(connection(b, "token-b", true), b).block(TIMEOUT); + + assertThat(((FakeTokenDetail) repository.findByDatasourceId(a, TYPE).block(TIMEOUT).getTokenDetail()).accessToken).isEqualTo("token-a"); + assertThat(((FakeTokenDetail) repository.findByDatasourceId(b, TYPE).block(TIMEOUT).getTokenDetail()).accessToken).isEqualTo("token-b"); + assertThat(repository.findByDatasourceId(IDUtils.generate(), TYPE).blockOptional(TIMEOUT)).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/material/MaterialMetaServiceImplMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/material/MaterialMetaServiceImplMongoTest.java new file mode 100644 index 0000000000..99834bc708 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/material/MaterialMetaServiceImplMongoTest.java @@ -0,0 +1,86 @@ +package org.lowcoder.domain.material; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.material.model.MaterialMeta; +import org.lowcoder.domain.material.model.MaterialType; +import org.lowcoder.domain.material.service.meta.MaterialMetaService; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.util.IDUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * MaterialMetaServiceImpl against the MongoDB test container (unit U14, task L3-11d). Org ids are generated per test. + * create is used as setup. existsByOrgIdAndFilename had no caller in main code and is deleted on coverage-gate (the owner's unreferenced-code deletions), so it is not tested. + * MaterialApiServiceImpl (L2-10) is not repeated. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +class MaterialMetaServiceImplMongoTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + @Autowired + private MaterialMetaService materialMetaService; + + private MaterialMeta create(String orgId, String filename, long size, MaterialType type) { + return materialMetaService.create(MaterialMeta.builder().orgId(orgId).filename(filename).size(size).type(type).build()).block(TIMEOUT); + } + + /** Catches: a lost field, findById(null) not failing with INVALID_PARAMETER, an unknown id not failing with NO_RESOURCE_FOUND. */ + @Test + void createThenFindByIdRoundTripsTheFieldsAndBadIdsFail() { + String orgId = IDUtils.generate(); + MaterialMeta created = create(orgId, "logo.png", 1234L, MaterialType.LOGO); + + MaterialMeta read = materialMetaService.findById(created.getId()).block(TIMEOUT); + + System.out.println("[MaterialMetaServiceImplMongoTest] created " + created.getId()); + assertThat(read.getOrgId()).isEqualTo(orgId); + assertThat(read.getFilename()).isEqualTo("logo.png"); + assertThat(read.getSize()).isEqualTo(1234L); + assertThat(read.getType()).isEqualTo(MaterialType.LOGO); + BizException nullId = assertThrows(BizException.class, () -> materialMetaService.findById(null).block(TIMEOUT)); + assertThat(nullId.getError()).isEqualTo(BizError.INVALID_PARAMETER); + BizException unknown = assertThrows(BizException.class, () -> materialMetaService.findById(IDUtils.generate()).block(TIMEOUT)); + assertThat(unknown.getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + } + + /** Catches: the sum taken over other orgs' rows, a missing org not answering 0. */ + @Test + void totalSizeSumsOnlyTheOrgsMaterialsAndIsZeroWithoutAny() { + String orgId = IDUtils.generate(); + create(orgId, "a.png", 100L, MaterialType.COMMON); + create(orgId, "b.png", 250L, MaterialType.LOGO); + create(IDUtils.generate(), "other.png", 9999L, MaterialType.COMMON); + + assertThat(materialMetaService.totalSize(orgId).block(TIMEOUT)).isEqualTo(350L); + assertThat(materialMetaService.totalSize(IDUtils.generate()).block(TIMEOUT)).isZero(); + } + + /** Catches: a cross-org leak in the listing, deleteById removing more than one row. */ + @Test + void getByOrgIdIsScopedToTheOrgAndDeleteByIdRemovesOnlyThatRow() { + String orgId = IDUtils.generate(); + MaterialMeta a = create(orgId, "a.png", 1L, MaterialType.COMMON); + MaterialMeta b = create(orgId, "b.png", 2L, MaterialType.COMMON); + create(IDUtils.generate(), "c.png", 3L, MaterialType.COMMON); + + assertThat(materialMetaService.getByOrgId(orgId).collectList().block(TIMEOUT)) + .extracting(MaterialMeta::getId).containsExactlyInAnyOrder(a.getId(), b.getId()); + + materialMetaService.deleteById(a.getId()).block(TIMEOUT); + + assertThat(materialMetaService.getByOrgId(orgId).collectList().block(TIMEOUT)) + .extracting(MaterialMeta::getId).containsExactly(b.getId()); + assertThat(materialMetaService.getByOrgId(IDUtils.generate()).collectList().block(TIMEOUT)).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/GroupMemberServiceImplMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/GroupMemberServiceImplMongoTest.java new file mode 100644 index 0000000000..2a9f4e2cae --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/GroupMemberServiceImplMongoTest.java @@ -0,0 +1,140 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.infra.birelation.BiRelationBizType.GROUP_MEMBER; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.group.model.GroupMember; +import org.lowcoder.domain.organization.model.MemberRole; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * GroupMemberServiceImpl against the MongoDB test container (unit U14, task L3-11a), shared {@code test} context, ids + * generated per test. Not repeated: deleteGroupMembers (the L4-8 section 9 row on BiRelationServiceImpl.removeAllBiRelations) + * and the bulk methods without a production caller (owner decision). + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +class GroupMemberServiceImplMongoTest extends OrganizationMongoTestBase { + + private static final String ADMIN = "admin"; + + private static Group group(String id) { + return Group.builder().id(id).build(); + } + + /** Catches: wrong rows from the group queries, a lost org id, an admin list that includes other roles. */ + @Test + void groupMembersAreReadBackWithTheirRoleAndOrgAndFilteredByRole() { + String org = newId(); + String groupId = newId(); + String otherGroup = newId(); + String member = newId(); + String admin = newId(); + String stranger = newId(); + assertThat(groupMemberService.addMember(org, groupId, member, MemberRole.MEMBER).block(TIMEOUT)).isTrue(); + groupMemberService.addMember(org, groupId, admin, MemberRole.ADMIN).block(TIMEOUT); + groupMemberService.addMember(org, otherGroup, stranger, MemberRole.ADMIN).block(TIMEOUT); + + List members = groupMemberService.getGroupMembers(groupId).block(TIMEOUT); + System.out.println("[GroupMemberServiceImplMongoTest] members of the group: " + members.stream().map(GroupMember::getUserId).toList()); + assertThat(members).extracting(GroupMember::getUserId).containsExactlyInAnyOrder(member, admin); + assertThat(members).extracting(GroupMember::getOrgId).containsOnly(org); + assertThat(groupMemberService.getGroupMember(groupId, admin).block(TIMEOUT).getRole()).isEqualTo(MemberRole.ADMIN); + assertThat(groupMemberService.getGroupMember(groupId, stranger).blockOptional(TIMEOUT)).isEmpty(); + + assertThat(groupMemberService.isMember(group(groupId), member).block(TIMEOUT)).isTrue(); + assertThat(groupMemberService.isMember(group(groupId), stranger).block(TIMEOUT)).isFalse(); + assertThat(groupMemberService.getGroupMembersByIdAndRole(groupId, ADMIN).block(TIMEOUT)) + .extracting(GroupMember::getUserId).containsExactly(admin); + assertThat(groupMemberService.getAllGroupAdmin(groupId).block(TIMEOUT)) + .extracting(GroupMember::getUserId).containsExactly(admin); + assertThat(groupMemberService.getGroupMembersByIdAndRole(groupId, MemberRole.SUPER_ADMIN.getValue()).block(TIMEOUT)).isEmpty(); + } + + /** + * Catches: groups of another organisation leaking into a user's group list (permission checks are built on these + * ids): the organisation of a membership is stored in extParam1 and is the only thing separating the orgs. + */ + @Test + void aUsersGroupsAreScopedToTheAskedOrg() { + String orgA = newId(); + String orgB = newId(); + String groupA = newId(); + String groupB = newId(); + String user = newId(); + groupMemberService.addMember(orgA, groupA, user, MemberRole.MEMBER).block(TIMEOUT); + groupMemberService.addMember(orgB, groupB, user, MemberRole.ADMIN).block(TIMEOUT); + + assertThat(groupMemberService.getUserGroupIdsInOrg(orgA, user).block(TIMEOUT)).containsExactly(groupA); + assertThat(groupMemberService.getNonDynamicUserGroupIdsInOrg(orgB, user).block(TIMEOUT)).containsExactly(groupB); + List inA = groupMemberService.getUserGroupMembersInOrg(orgA, user).block(TIMEOUT); + System.out.println("[GroupMemberServiceImplMongoTest] memberships of the user in org A: " + inA.size()); + assertThat(inA).extracting(GroupMember::getGroupId).containsExactly(groupA); + assertThat(inA.get(0).getRole()).isEqualTo(MemberRole.MEMBER); + assertThat(groupMemberService.getUserGroupIdsInOrg(newId(), user).block(TIMEOUT)).isEmpty(); + assertThat(groupMemberService.getUserGroupMembersInOrg(newId(), user).block(TIMEOUT)).isEmpty(); + } + + /** Catches: the role change leaking to the user's other groups or to other members of the same group. */ + @Test + void updateMemberRoleChangesOnlyThatGroupMembershipOfThatUser() { + String org = newId(); + String groupA = newId(); + String groupB = newId(); + String user = newId(); + String bystander = newId(); + groupMemberService.addMember(org, groupA, user, MemberRole.MEMBER).block(TIMEOUT); + groupMemberService.addMember(org, groupB, user, MemberRole.MEMBER).block(TIMEOUT); + groupMemberService.addMember(org, groupA, bystander, MemberRole.MEMBER).block(TIMEOUT); + + assertThat(groupMemberService.updateMemberRole(groupA, user, MemberRole.ADMIN).block(TIMEOUT)).isTrue(); + + assertThat(singleRow(GROUP_MEMBER, groupA, user).getRelation()).isEqualTo(ADMIN); + assertThat(singleRow(GROUP_MEMBER, groupB, user).getRelation()).isEqualTo(MemberRole.MEMBER.getValue()); + assertThat(singleRow(GROUP_MEMBER, groupA, bystander).getRelation()).isEqualTo(MemberRole.MEMBER.getValue()); + } + + /** + * Pins plan section 9 row "updateMemberRole answers true for a member that does not exist (hasElement on the update + * result; OrgMemberServiceImpl:142-143, GroupMemberServiceImpl:47-48)". Caller GroupApiServiceImpl.updateRoleForMember + * (:243-251) returns that value to the client. A fix (map the update's boolean) changes this test on purpose. + */ + @Test + void updateMemberRoleAnswersTrueForAMemberWhoIsNotInTheGroup_pinsTheSection9Row() { + String groupId = newId(); + String stranger = newId(); + + Boolean answer = groupMemberService.updateMemberRole(groupId, stranger, MemberRole.ADMIN).block(TIMEOUT); + + System.out.println("[GroupMemberServiceImplMongoTest] PINNED updateMemberRole of a non-member answers " + answer); + assertThat(rows(GROUP_MEMBER, groupId, stranger)).isEmpty(); + assertThat(answer).isTrue(); + } + + /** Catches: removing the user from every group (or every user from the group) instead of one membership. */ + @Test + void removeMemberRemovesOnlyThatMembershipAndAbsentMembersAnswerFalse() { + String org = newId(); + String groupA = newId(); + String groupB = newId(); + String user = newId(); + String other = newId(); + groupMemberService.addMember(org, groupA, user, MemberRole.MEMBER).block(TIMEOUT); + groupMemberService.addMember(org, groupB, user, MemberRole.MEMBER).block(TIMEOUT); + groupMemberService.addMember(org, groupA, other, MemberRole.MEMBER).block(TIMEOUT); + + assertThat(groupMemberService.removeMember(groupA, user).block(TIMEOUT)).isTrue(); + + assertThat(rows(GROUP_MEMBER, groupA, user)).isEmpty(); + assertThat(rows(GROUP_MEMBER, groupB, user)).hasSize(1); + assertThat(rows(GROUP_MEMBER, groupA, other)).hasSize(1); + assertThat(groupMemberService.removeMember(groupA, user).block(TIMEOUT)).isFalse(); + System.out.println("[GroupMemberServiceImplMongoTest] removeMember kept the other group and the other member"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/GroupServiceImplDeleteEventMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/GroupServiceImplDeleteEventMongoTest.java new file mode 100644 index 0000000000..600af58101 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/GroupServiceImplDeleteEventMongoTest.java @@ -0,0 +1,63 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.group.event.GroupDeletedEvent; +import org.lowcoder.domain.group.model.Group; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import java.util.Locale; + +/** + * GroupServiceImpl.delete (unit U14, task L3-11a): the group is removed and exactly one GroupDeletedEvent carrying its id + * is published. A collecting listener makes this class's Spring context, and therefore its database, its own. What the + * real listener does with the group's members on that event is the L4-8 section 9 row on + * BiRelationServiceImpl.removeAllBiRelations and is not asserted here. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +@TestPropertySource(properties = "l3_11a.context=group-delete-event") +@Import(GroupServiceImplDeleteEventMongoTest.EventCollector.class) +class GroupServiceImplDeleteEventMongoTest extends OrganizationMongoTestBase { + + static final List DELETED_GROUP_IDS = new CopyOnWriteArrayList<>(); + + @TestConfiguration + static class EventCollector { + @EventListener + void onGroupDeleted(GroupDeletedEvent event) { + DELETED_GROUP_IDS.add(event.getGroupId()); + } + + @Bean + String l3_11aMarker() { + return "collector"; + } + } + + /** Catches: a deleted group that stays stored, an event that is not published, or one for the wrong id. */ + @Test + void deleteRemovesTheGroupAndPublishesOneEventWithItsId() { + String org = newId(); + Group keep = mongo.save(Group.builder().organizationId(org).name("keep").gid(newId()).build()).block(TIMEOUT); + Group doomed = mongo.save(Group.builder().organizationId(org).name("doomed").gid(newId()).build()).block(TIMEOUT); + + groupService.delete(doomed.getId()).block(TIMEOUT); + + System.out.println("[GroupServiceImplDeleteEventMongoTest] events: " + DELETED_GROUP_IDS); + assertThat(groupService.getById(doomed.getId()).blockOptional(TIMEOUT)).isEmpty(); + assertThat(groupService.getById(keep.getId()).block(TIMEOUT).getName(Locale.ENGLISH)).isEqualTo("keep"); + assertThat(DELETED_GROUP_IDS).containsOnlyOnce(doomed.getId()).doesNotContain(keep.getId()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/GroupServiceImplMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/GroupServiceImplMongoTest.java new file mode 100644 index 0000000000..d8366b9d51 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/GroupServiceImplMongoTest.java @@ -0,0 +1,131 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.infra.birelation.BiRelationBizType.GROUP_MEMBER; + +import java.util.Locale; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.group.model.GroupMember; +import org.lowcoder.domain.group.util.SystemGroups; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.infra.birelation.BiRelation; +import org.lowcoder.sdk.constants.GlobalContext; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * GroupServiceImpl against the MongoDB test container (unit U14, task L3-11a), shared {@code test} context, ids + * generated per test. The delete event is in GroupServiceImplDeleteEventMongoTest (it needs its own context). + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +class GroupServiceImplMongoTest extends OrganizationMongoTestBase { + + /** The name field as stored: for a system group getName(locale) re-localises, so the document is read raw. */ + private String storedName(Group group) { + org.bson.Document stored = mongo.findById(group.getId(), org.bson.Document.class, mongo.getCollectionName(Group.class)).block(TIMEOUT); + return stored.getString("name"); + } + + private Group saveGroup(String orgId, String name) { + Group group = Group.builder().organizationId(orgId).name(name).gid(newId()).build(); + return mongo.save(group).block(TIMEOUT); + } + + /** Catches: a created group without its creator as ADMIN member, or a returned group that was not stored. */ + @Test + void createStoresTheGroupAndMakesTheCreatorItsAdmin() { + String org = newId(); + String creator = newId(); + Group draft = Group.builder().organizationId(org).name("team").gid(newId()).build(); + + Group created = groupService.create(draft, creator, org).block(TIMEOUT); + + System.out.println("[GroupServiceImplMongoTest] created group " + created.getId()); + assertThat(created.getId()).isNotBlank(); + assertThat(groupService.getById(created.getId()).block(TIMEOUT).getName(Locale.ENGLISH)).isEqualTo("team"); + BiRelation row = singleRow(GROUP_MEMBER, created.getId(), creator); + assertThat(row.getRelation()).isEqualTo(MemberRole.ADMIN.getValue()); + GroupMember member = groupMemberService.getGroupMember(created.getId(), creator).block(TIMEOUT); + assertThat(member.getOrgId()).isEqualTo(org); + assertThat(rowsOfSource(GROUP_MEMBER, created.getId())).hasSize(1); + } + + /** Catches: groups of another organisation in the lookups or counts, and an id lookup returning too much. */ + @Test + void lookupsAndCountsAreScopedToTheOrgAndTheIds() { + String orgA = newId(); + String orgB = newId(); + Group a1 = saveGroup(orgA, "a1"); + Group a2 = saveGroup(orgA, "a2"); + Group b1 = saveGroup(orgB, "b1"); + + assertThat(groupService.getByOrgId(orgA).collectList().block(TIMEOUT)).extracting(Group::getId) + .containsExactlyInAnyOrder(a1.getId(), a2.getId()); + assertThat(groupService.getOrgGroupCount(orgA).block(TIMEOUT)).isEqualTo(2L); + assertThat(groupService.getOrgGroupCount(orgB).block(TIMEOUT)).isEqualTo(1L); + assertThat(groupService.getOrgGroupCount(newId()).block(TIMEOUT)).isZero(); + assertThat(groupService.getByIds(Set.of(a1.getId(), b1.getId())).collectList().block(TIMEOUT)).extracting(g -> g.getName(Locale.ENGLISH)) + .containsExactlyInAnyOrder("a1", "b1"); + assertThat(groupService.getById(newId()).blockOptional(TIMEOUT)).isEmpty(); + System.out.println("[GroupServiceImplMongoTest] org A has " + groupService.getOrgGroupCount(orgA).block(TIMEOUT) + " groups"); + } + + /** Catches: swapped system group types or flags, an unlocalised name, and lookups of another org's system groups. */ + @Test + void systemGroupsCarryTheirTypeFlagLocalisedNameAndAreFoundPerOrg() { + String org = newId(); + String bareOrg = newId(); + + Group allUsers = groupService.createAllUserGroup(org).block(TIMEOUT); + Group dev = groupService.createDevGroup(org).block(TIMEOUT); + + System.out.println("[GroupServiceImplMongoTest] system groups: " + storedName(allUsers) + " / " + storedName(dev)); + assertThat(allUsers.isAllUsersGroup()).isTrue(); + assertThat(allUsers.getType()).isEqualTo(SystemGroups.ALL_USER); + assertThat(allUsers.getName(Locale.ENGLISH)).isEqualTo("All Users"); + assertThat(allUsers.getGid()).isNotBlank(); + assertThat(storedName(allUsers)).isEqualTo("All Users"); + assertThat(storedName(dev)).isEqualTo("Developers"); + assertThat(dev.isAllUsersGroup()).isFalse(); + assertThat(dev.isDevGroup()).isTrue(); + assertThat(dev.getName(Locale.ENGLISH)).isEqualTo("Developers"); + + assertThat(groupService.getAllUsersGroup(org).block(TIMEOUT).getId()).isEqualTo(allUsers.getId()); + assertThat(groupService.getDevGroup(org).block(TIMEOUT).getId()).isEqualTo(dev.getId()); + assertThat(groupService.getAllUsersGroup(bareOrg).blockOptional(TIMEOUT)).isEmpty(); + assertThat(groupService.getDevGroup(bareOrg).blockOptional(TIMEOUT)).isEmpty(); + } + + /** Catches: the system group name ignoring the locale of the request context (the stored name is what clients see). */ + @Test + void systemGroupNameFollowsTheLocaleOfTheReactorContext() { + Group chinese = groupService.createAllUserGroup(newId()) + .contextWrite(context -> context.put(GlobalContext.CLIENT_LOCALE, Locale.CHINESE)).block(TIMEOUT); + Group english = groupService.createDevGroup(newId()).block(TIMEOUT); + + System.out.println("[GroupServiceImplMongoTest] stored names: " + storedName(chinese) + " / " + storedName(english)); + assertThat(storedName(chinese)).isEqualTo("所有用户"); + assertThat(storedName(english)).isEqualTo("Developers"); + } + + /** Catches: a partial update overwriting the unset fields with null. */ + @Test + void updateGroupChangesOnlyTheGivenFields() { + String org = newId(); + Group group = saveGroup(org, "keep-me"); + + Boolean updated = groupService.updateGroup(Group.builder().id(group.getId()).dynamicRule("role=dev").build()).block(TIMEOUT); + + Group stored = groupService.getById(group.getId()).block(TIMEOUT); + System.out.println("[GroupServiceImplMongoTest] updated=" + updated + " name=" + stored.getName(Locale.ENGLISH) + " rule=" + stored.getDynamicRule()); + assertThat(updated).isTrue(); + assertThat(stored.getDynamicRule()).isEqualTo("role=dev"); + assertThat(stored.getName(Locale.ENGLISH)).isEqualTo("keep-me"); + assertThat(stored.getOrganizationId()).isEqualTo(org); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrgMemberServiceImplEnterpriseEnrolmentPinMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrgMemberServiceImplEnterpriseEnrolmentPinMongoTest.java new file mode 100644 index 0000000000..27ba78216c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrgMemberServiceImplEnterpriseEnrolmentPinMongoTest.java @@ -0,0 +1,73 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.infra.birelation.BiRelationBizType.ORG_MEMBER; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.constants.WorkspaceMode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +/** + * Pins the plan section 9 row "enterprise mode: first getAllActiveOrgs of a user who is not yet in the enterprise org + * returns an empty list" (task L3-11a follow-up). In its own Spring context (and database) because it changes the global + * workspace mode; the CommonConfig singleton is restored after every test. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +@TestPropertySource(properties = "l3_11a.context=enterprise-enrolment-pin") +class OrgMemberServiceImplEnterpriseEnrolmentPinMongoTest extends OrganizationMongoTestBase { + + @Autowired + private CommonConfig commonConfig; + + private WorkspaceMode originalMode; + private String originalEnterpriseOrgId; + + @BeforeEach + void rememberTheConfiguration() { + originalMode = commonConfig.getWorkspace().getMode(); + originalEnterpriseOrgId = commonConfig.getWorkspace().getEnterpriseOrgId(); + } + + @AfterEach + void restoreTheConfiguration() { + commonConfig.getWorkspace().setMode(originalMode); + commonConfig.getWorkspace().setEnterpriseOrgId(originalEnterpriseOrgId); + } + + /** + * Pins the row: getAllActiveOrgs (OrgMemberServiceImpl:60-97) enrols a non-member in the configured enterprise org + * (:82) but filters the memberships it read BEFORE that write (the cached orgMemberFlux), so the first call answers an + * empty list and only the next call returns the org. Small reach: login enrols the user first through + * tryAddUserToOrgAndSwitchOrg (AuthenticationApiServiceImpl:304), so this is hit only by an existing user who was + * never added. A fix (re-read the memberships after the write) changes this test on purpose. + */ + @Test + void theFirstCallForANonMemberReturnsNothingAlthoughTheEnrolmentIsWritten_pinsTheSection9Row() { + String enterpriseOrg = createActiveOrg(); + String otherOrg = createActiveOrg(); + String user = newId(); + orgMemberService.addMember(otherOrg, user, MemberRole.ADMIN).block(TIMEOUT); + commonConfig.getWorkspace().setMode(WorkspaceMode.ENTERPRISE); + commonConfig.getWorkspace().setEnterpriseOrgId(enterpriseOrg); + + List first = orgMemberService.getAllActiveOrgs(user).map(OrgMember::getOrgId).collectList().block(TIMEOUT); + List second = orgMemberService.getAllActiveOrgs(user).map(OrgMember::getOrgId).collectList().block(TIMEOUT); + + System.out.println("[OrgMemberServiceImplEnterpriseEnrolmentPinMongoTest] PINNED first call " + first + ", second call " + second); + assertThat(singleRow(ORG_MEMBER, enterpriseOrg, user).getRelation()).isEqualTo(MemberRole.MEMBER.getValue()); + assertThat(first).isEmpty(); + assertThat(second).containsExactly(enterpriseOrg); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrgMemberServiceImplIsolatedMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrgMemberServiceImplIsolatedMongoTest.java new file mode 100644 index 0000000000..1ce9630314 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrgMemberServiceImplIsolatedMongoTest.java @@ -0,0 +1,221 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.infra.birelation.BiRelationBizType.GROUP_MEMBER; +import static org.lowcoder.infra.birelation.BiRelationBizType.ORG_MEMBER; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.authentication.AuthenticationService; +import org.lowcoder.domain.authentication.FindAuthConfig; +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.group.model.GroupMember; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.model.OrganizationState; +import org.lowcoder.infra.birelation.BiRelation; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.constants.AuthSourceConstants; +import org.lowcoder.sdk.constants.WorkspaceMode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +/** + * OrgMemberServiceImpl behaviour that depends on state of the WHOLE database or on global configuration (unit U14, task + * L3-11a): the global admin count, the "add to every active org" job and enterprise mode. The extra property gives this + * class a Spring context, and therefore a MongoDB database, of its own (TestContainersInitializer), so the collections + * can be emptied before every test without touching any other class; the CommonConfig singleton is restored after every + * test. Email registration is switched off by configuration for the registration tests. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +@TestPropertySource(properties = {"l3_11a.context=org-member-isolated", "auth.email.enable-register=false"}) +class OrgMemberServiceImplIsolatedMongoTest extends OrganizationMongoTestBase { + + @Autowired + private CommonConfig commonConfig; + @Autowired + private AuthenticationService authenticationService; + + private WorkspaceMode originalMode; + private String originalEnterpriseOrgId; + + @BeforeEach + void emptyTheCollectionsAndRememberTheConfiguration() { + mongo.remove(new Query(), BiRelation.class).block(TIMEOUT); + mongo.remove(new Query(), Organization.class).block(TIMEOUT); + mongo.remove(new Query(), Group.class).block(TIMEOUT); + originalMode = commonConfig.getWorkspace().getMode(); + originalEnterpriseOrgId = commonConfig.getWorkspace().getEnterpriseOrgId(); + } + + @AfterEach + void restoreTheConfiguration() { + commonConfig.getWorkspace().setMode(originalMode); + commonConfig.getWorkspace().setEnterpriseOrgId(originalEnterpriseOrgId); + } + + private boolean emailRegistrationEnabled() { + List configs = authenticationService.findAllAuthConfigs(null, false).collectList().block(TIMEOUT); + return configs.stream() + .filter(config -> AuthSourceConstants.EMAIL.equals(config.authConfig().getSource())) + .findFirst().orElseThrow() + .authConfig().isEnableRegister(); + } + + private List activeOrgIdsOf(String userId) { + return orgMemberService.getAllActiveOrgs(userId).map(OrgMember::getOrgId).collectList().block(TIMEOUT); + } + + // ------------------------------------------------------------------ doesAtleastOneAdminExist + + /** Catches: the admin check answering true without any admin, or ignoring an ADMIN row. */ + @Test + void anAdminExistsOnlyAfterAnAdminRowIsStored() { + assertThat(orgMemberService.doesAtleastOneAdminExist().block(TIMEOUT)).isFalse(); + + orgMemberService.addMember(createActiveOrg(), newId(), MemberRole.MEMBER).block(TIMEOUT); + assertThat(orgMemberService.doesAtleastOneAdminExist().block(TIMEOUT)).isFalse(); + + orgMemberService.addMember(createActiveOrg(), newId(), MemberRole.ADMIN).block(TIMEOUT); + assertThat(orgMemberService.doesAtleastOneAdminExist().block(TIMEOUT)).isTrue(); + System.out.println("[OrgMemberServiceImplIsolatedMongoTest] admin exists after an ADMIN row"); + } + + /** + * Pins plan section 9 row "registration forced open when the only admins are SUPER_ADMIN: doesAtleastOneAdminExist + * counts relation `admin` only (OrgMemberServiceImpl:116), and AuthenticationServiceImpl:55-62 then sets + * enableRegister TRUE despite configuration". Reachable on a fresh deployment: AddSuperAdminUserImpl:42-50 creates the + * configured super admin through createDefault(user, true), whose first org membership is SUPER_ADMIN + * (OrganizationServiceImpl:165-167), while a normal first sign-up gets ADMIN (AuthenticationApiServiceImpl:159, + * :283-285). A fix (count ADMIN or SUPER_ADMIN) changes this test on purpose. + */ + @Test + void onlySuperAdminsCountAsNoAdminAndOpenRegistration_pinsTheSection9Row() { + orgMemberService.addMember(createActiveOrg(), newId(), MemberRole.SUPER_ADMIN).block(TIMEOUT); + + boolean adminExists = orgMemberService.doesAtleastOneAdminExist().block(TIMEOUT); + boolean registrationEnabled = emailRegistrationEnabled(); + System.out.println("[OrgMemberServiceImplIsolatedMongoTest] PINNED only SUPER_ADMIN rows: adminExists=" + adminExists + + " email registration enabled=" + registrationEnabled + " (configured: disabled)"); + assertThat(adminExists).isFalse(); + assertThat(registrationEnabled).isTrue(); + + orgMemberService.addMember(createActiveOrg(), newId(), MemberRole.ADMIN).block(TIMEOUT); + assertThat(emailRegistrationEnabled()).as("with an ADMIN row the configuration (disabled) applies").isFalse(); + } + + // ------------------------------------------------------------------ addToAllOrgAsAdminIfNot + + /** + * Catches: the job joining the wrong set of orgs (deleted ones, orgs the user is already in) or overwriting an existing + * membership. Behaviour pinned (candidate C3, no row): the all-users group membership gets the ORG role, SUPER_ADMIN, + * while addMember always uses MEMBER. GroupApiServiceImpl treats a SUPER_ADMIN group member as a manager + * (:62, :121, :208) and hides it from member lists (:291, :313), so this looks intended. + */ + @Test + void addToAllOrgAsAdminIfNotMakesTheUserSuperAdminOfEveryActiveOrgTheyAreNotIn() { + String[] already = createActiveOrgWithAllUsersGroup(); + String[] fresh1 = createActiveOrgWithAllUsersGroup(); + String[] fresh2 = createActiveOrgWithAllUsersGroup(); + String deleted = createOrg(OrganizationState.DELETED); + String user = newId(); + orgMemberService.addMember(already[0], user, MemberRole.MEMBER).block(TIMEOUT); + + orgMemberService.addToAllOrgAsAdminIfNot(user).block(TIMEOUT); + + assertThat(singleRow(ORG_MEMBER, already[0], user).getRelation()).isEqualTo(MemberRole.MEMBER.getValue()); + assertThat(singleRow(ORG_MEMBER, fresh1[0], user).getRelation()).isEqualTo(MemberRole.SUPER_ADMIN.getValue()); + assertThat(singleRow(ORG_MEMBER, fresh2[0], user).getRelation()).isEqualTo(MemberRole.SUPER_ADMIN.getValue()); + assertThat(rows(ORG_MEMBER, deleted, user)).isEmpty(); + + GroupMember inFresh = groupMemberService.getGroupMember(fresh1[1], user).block(TIMEOUT); + System.out.println("[OrgMemberServiceImplIsolatedMongoTest] all-users group role of the job: " + inFresh.getRole()); + assertThat(inFresh.getRole()).isEqualTo(MemberRole.SUPER_ADMIN); + assertThat(inFresh.getOrgId()).isEqualTo(fresh1[0]); + assertThat(rows(GROUP_MEMBER, already[1], user)).hasSize(1); + assertThat(groupMemberService.getGroupMember(already[1], user).block(TIMEOUT).getRole()).isEqualTo(MemberRole.MEMBER); + + orgMemberService.addToAllOrgAsAdminIfNot(user).block(TIMEOUT); + assertThat(rows(ORG_MEMBER, fresh1[0], user)).as("a second run adds nothing").hasSize(1); + } + + // ------------------------------------------------------------------ enterprise mode + + private void enterprise(String enterpriseOrgId) { + commonConfig.getWorkspace().setMode(WorkspaceMode.ENTERPRISE); + commonConfig.getWorkspace().setEnterpriseOrgId(enterpriseOrgId); + } + + /** Catches: SaaS mode filtering the user's orgs, or enterprise mode leaking the other orgs of a member. */ + @Test + void saasModeListsEveryActiveOrgAndEnterpriseModeOnlyTheEnterpriseOrg() { + String enterpriseOrg = createActiveOrg(); + String otherOrg = createActiveOrg(); + String user = newId(); + orgMemberService.addMember(enterpriseOrg, user, MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(otherOrg, user, MemberRole.MEMBER).block(TIMEOUT); + + commonConfig.getWorkspace().setMode(WorkspaceMode.SAAS); + assertThat(activeOrgIdsOf(user)).containsExactlyInAnyOrder(enterpriseOrg, otherOrg); + + enterprise(enterpriseOrg); + assertThat(activeOrgIdsOf(user)).containsExactly(enterpriseOrg); + assertThat(rows(ORG_MEMBER, enterpriseOrg, user)).as("an existing membership is not enrolled again").hasSize(1); + System.out.println("[OrgMemberServiceImplIsolatedMongoTest] enterprise mode shows only " + enterpriseOrg); + } + + /** + * Catches: a user of an enterprise deployment not being enrolled in the enterprise org on first use. The result of the + * FIRST call is deliberately not asserted: today it is empty (the memberships were read before the enrolment and the + * new one is not in that snapshot), which is a defect candidate reported to the coordinator, not pinned. The + * enrolment itself and the result of the second call are asserted. + */ + @Test + void enterpriseModeEnrolsANonMemberAsMemberInTheEnterpriseOrg() { + String[] enterprise = createActiveOrgWithAllUsersGroup(); + String otherOrg = createActiveOrg(); + String user = newId(); + orgMemberService.addMember(otherOrg, user, MemberRole.ADMIN).block(TIMEOUT); + enterprise(enterprise[0]); + + List firstCall = activeOrgIdsOf(user); + System.out.println("[OrgMemberServiceImplIsolatedMongoTest] first call after enrolment returned " + firstCall); + + BiRelation row = singleRow(ORG_MEMBER, enterprise[0], user); + assertThat(row.getRelation()).isEqualTo(MemberRole.MEMBER.getValue()); + assertThat(rows(GROUP_MEMBER, enterprise[1], user)).hasSize(1); + assertThat(singleRow(ORG_MEMBER, otherOrg, user).getRelation()).isEqualTo(MemberRole.ADMIN.getValue()); + assertThat(activeOrgIdsOf(user)).containsExactly(enterprise[0]); + } + + /** Catches: an unconfigured enterprise org showing several orgs, or hiding the only one. */ + @Test + void enterpriseModeWithoutAConfiguredOrgShowsOnlyTheFirstActiveOrg() { + String orgA = createActiveOrg(); + String orgB = createActiveOrg(); + String orgC = createActiveOrg(); + String many = newId(); + for (String org : List.of(orgA, orgB, orgC)) { + orgMemberService.addMember(org, many, MemberRole.MEMBER).block(TIMEOUT); + } + String single = newId(); + orgMemberService.addMember(orgB, single, MemberRole.MEMBER).block(TIMEOUT); + + for (String unconfigured : new String[] {null, "", " "}) { + enterprise(unconfigured); + List shown = activeOrgIdsOf(many); + System.out.println("[OrgMemberServiceImplIsolatedMongoTest] enterprise org [" + unconfigured + "] shows " + shown.size()); + assertThat(shown).hasSize(1).isSubsetOf(orgA, orgB, orgC); + assertThat(activeOrgIdsOf(single)).containsExactly(orgB); + } + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrgMemberServiceImplMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrgMemberServiceImplMongoTest.java new file mode 100644 index 0000000000..165558d771 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrgMemberServiceImplMongoTest.java @@ -0,0 +1,291 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.infra.birelation.BiRelationBizType.GROUP_MEMBER; +import static org.lowcoder.infra.birelation.BiRelationBizType.ORG_MEMBER; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.group.model.GroupMember; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.OrgMember; +import org.lowcoder.domain.organization.model.OrganizationState; +import org.lowcoder.domain.organization.service.OrgMemberService.UserOrgMemberInfo; +import org.lowcoder.infra.birelation.BiRelation; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * OrgMemberServiceImpl against the MongoDB test container (unit U14, task L3-11a), shared {@code test} context: every + * test works only under ids it generates. Not repeated here: adding members + getOrganizationMembers (OrgMemberServiceTest; it adds with bulkAddMember on this branch and with addMember on coverage-gate), + * deleteOrgMembers (the L4-8 section 9 row on BiRelationServiceImpl.removeAllBiRelations), the L1 role rules of + * OrgApiServiceImpl, and the bulk methods that have no production caller (owner decision). + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +class OrgMemberServiceImplMongoTest extends OrganizationMongoTestBase { + + private static final String NORMAL = "normal"; + private static final String CURRENT = "current"; + + /** Catches: the member row written with a wrong role or state, or the user not joining the org's all-users group. */ + @Test + void addMemberWritesTheRowAndJoinsTheAllUsersGroupAsMember() { + String[] org = createActiveOrgWithAllUsersGroup(); + String[] otherOrg = createActiveOrgWithAllUsersGroup(); + String user = newId(); + + assertThat(orgMemberService.addMember(org[0], user, MemberRole.ADMIN).block(TIMEOUT)).isTrue(); + + BiRelation row = singleRow(ORG_MEMBER, org[0], user); + System.out.println("[OrgMemberServiceImplMongoTest] member row relation=" + row.getRelation() + " state=" + row.getState()); + assertThat(row.getRelation()).isEqualTo(MemberRole.ADMIN.getValue()); + assertThat(row.getState()).isEqualTo(NORMAL); + OrgMember member = orgMemberService.getOrgMember(org[0], user).block(TIMEOUT); + assertThat(member.getRole()).isEqualTo(MemberRole.ADMIN); + + GroupMember groupMember = groupMemberService.getGroupMember(org[1], user).block(TIMEOUT); + assertThat(groupMember.getRole()).isEqualTo(MemberRole.MEMBER); + assertThat(groupMember.getOrgId()).isEqualTo(org[0]); + assertThat(rowsOfSource(GROUP_MEMBER, otherOrg[1])).isEmpty(); + assertThat(rows(ORG_MEMBER, otherOrg[0], user)).isEmpty(); + } + + /** Catches: an org without an all-users group failing the add (the member row must still be written). */ + @Test + void addMemberToAnOrgWithoutAnAllUsersGroupStillWritesTheMemberRow() { + String org = createActiveOrg(); + String user = newId(); + + assertThat(orgMemberService.addMember(org, user, MemberRole.MEMBER).block(TIMEOUT)).isTrue(); + assertThat(rows(ORG_MEMBER, org, user)).hasSize(1); + assertThat(rowsOfSource(GROUP_MEMBER, org)).isEmpty(); + } + + /** Catches: tryAddOrgMember overwriting or duplicating an existing membership. */ + @Test + void tryAddOrgMemberOnlyAddsNewMembersAndNeverChangesAnExistingRole() { + String org = createActiveOrg(); + String user = newId(); + + assertThat(orgMemberService.tryAddOrgMember(org, user, MemberRole.MEMBER).block(TIMEOUT)).isTrue(); + assertThat(orgMemberService.tryAddOrgMember(org, user, MemberRole.ADMIN).block(TIMEOUT)).isFalse(); + + List stored = rows(ORG_MEMBER, org, user); + System.out.println("[OrgMemberServiceImplMongoTest] after two tryAdd calls: " + stored.size() + " row, role " + stored.get(0).getRelation()); + assertThat(stored).hasSize(1); + assertThat(stored.get(0).getRelation()).isEqualTo(MemberRole.MEMBER.getValue()); + } + + /** Catches: the role change leaking to the user's other organisations or to other users of the same org. */ + @Test + void updateMemberRoleChangesOnlyThatMembershipOfThatUser() { + String orgA = createActiveOrg(); + String orgB = createActiveOrg(); + String user = newId(); + String bystander = newId(); + orgMemberService.addMember(orgA, user, MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(orgB, user, MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(orgA, bystander, MemberRole.MEMBER).block(TIMEOUT); + + assertThat(orgMemberService.updateMemberRole(orgA, user, MemberRole.ADMIN).block(TIMEOUT)).isTrue(); + + assertThat(singleRow(ORG_MEMBER, orgA, user).getRelation()).isEqualTo(MemberRole.ADMIN.getValue()); + assertThat(singleRow(ORG_MEMBER, orgB, user).getRelation()).isEqualTo(MemberRole.MEMBER.getValue()); + assertThat(singleRow(ORG_MEMBER, orgA, bystander).getRelation()).isEqualTo(MemberRole.MEMBER.getValue()); + } + + /** + * Pins plan section 9 row "updateMemberRole answers true for a member that does not exist (hasElement on the update + * result; OrgMemberServiceImpl:142-143, GroupMemberServiceImpl:47-48)": {@code Mono.hasElement()} is true + * whenever the update helper emits any value, false included. Caller OrgApiServiceImpl.updateRoleForMember + * (:159-165) returns that value to the client. A fix (map the update's boolean) changes this test on purpose. + */ + @Test + void updateMemberRoleAnswersTrueForAMemberWhoDoesNotExist_pinsTheSection9Row() { + String org = createActiveOrg(); + String stranger = newId(); + + Boolean answer = orgMemberService.updateMemberRole(org, stranger, MemberRole.ADMIN).block(TIMEOUT); + + System.out.println("[OrgMemberServiceImplMongoTest] PINNED updateMemberRole of a non-member answers " + answer); + assertThat(rows(ORG_MEMBER, org, stranger)).isEmpty(); + assertThat(answer).isTrue(); + } + + /** Catches: removing the user from every organisation instead of one (the analysis' defect). */ + @Test + void removeMemberRemovesOnlyThatOrgAndAbsentMembersAnswerFalse() { + String orgA = createActiveOrg(); + String orgB = createActiveOrg(); + String user = newId(); + String other = newId(); + orgMemberService.addMember(orgA, user, MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(orgB, user, MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(orgA, other, MemberRole.MEMBER).block(TIMEOUT); + + assertThat(orgMemberService.removeMember(orgA, user).block(TIMEOUT)).isTrue(); + + assertThat(rows(ORG_MEMBER, orgA, user)).isEmpty(); + assertThat(rows(ORG_MEMBER, orgB, user)).hasSize(1); + assertThat(rows(ORG_MEMBER, orgA, other)).hasSize(1); + assertThat(orgMemberService.removeMember(orgA, user).block(TIMEOUT)).isFalse(); + System.out.println("[OrgMemberServiceImplMongoTest] removeMember kept the other org and the other user"); + } + + /** Catches: an off-by-one in the page number (pages are 1-based), overlapping or short pages. */ + @Test + void membersArePagedOneBasedWithoutOverlap() { + String org = createActiveOrg(); + Set users = new HashSet<>(); + for (int i = 0; i < 5; i++) { + String user = newId(); + users.add(user); + orgMemberService.addMember(org, user, MemberRole.MEMBER).block(TIMEOUT); + } + + List page1 = orgMemberService.getOrganizationMembers(org, 1, 2).map(OrgMember::getUserId).collectList().block(TIMEOUT); + List page2 = orgMemberService.getOrganizationMembers(org, 2, 2).map(OrgMember::getUserId).collectList().block(TIMEOUT); + List page3 = orgMemberService.getOrganizationMembers(org, 3, 2).map(OrgMember::getUserId).collectList().block(TIMEOUT); + List page4 = orgMemberService.getOrganizationMembers(org, 4, 2).map(OrgMember::getUserId).collectList().block(TIMEOUT); + + System.out.println("[OrgMemberServiceImplMongoTest] page sizes " + page1.size() + "," + page2.size() + "," + page3.size() + "," + page4.size()); + assertThat(page1).hasSize(2); + assertThat(page2).hasSize(2); + assertThat(page3).hasSize(1); + assertThat(page4).isEmpty(); + Set union = new HashSet<>(page1); + union.addAll(page2); + union.addAll(page3); + assertThat(union).isEqualTo(users); + } + + /** Behaviour (candidate C6): pages are 1-based, so page 0 throws synchronously from PageRequest.of(-1, n). */ + @Test + void pageZeroIsRejectedSynchronously() { + String org = createActiveOrg(); + assertThrows(IllegalArgumentException.class, () -> orgMemberService.getOrganizationMembers(org, 0, 2)); + } + + /** Catches: counting other orgs' rows, or counting super admins and plain members as admins. */ + @Test + void countAndAdminListAreScopedToTheOrgAndToTheAdminRole() { + String org = createActiveOrg(); + String otherOrg = createActiveOrg(); + String admin = newId(); + orgMemberService.addMember(org, newId(), MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(org, newId(), MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(org, admin, MemberRole.ADMIN).block(TIMEOUT); + orgMemberService.addMember(org, newId(), MemberRole.SUPER_ADMIN).block(TIMEOUT); + orgMemberService.addMember(otherOrg, newId(), MemberRole.ADMIN).block(TIMEOUT); + + assertThat(orgMemberService.getOrgMemberCount(org).block(TIMEOUT)).isEqualTo(4L); + List admins = orgMemberService.getAllOrgAdmins(org).block(TIMEOUT); + System.out.println("[OrgMemberServiceImplMongoTest] admins of the org: " + admins.stream().map(OrgMember::getUserId).toList()); + assertThat(admins).extracting(OrgMember::getUserId).containsExactly(admin); + } + + /** Catches: roles of other orgs or other users leaking into the role map, or a missing org key. */ + @Test + void orgMemberRolesAreKeyedByTheAskedOrgsForThatUserOnly() { + String orgA = createActiveOrg(); + String orgB = createActiveOrg(); + String orgC = createActiveOrg(); + String user = newId(); + orgMemberService.addMember(orgA, user, MemberRole.ADMIN).block(TIMEOUT); + orgMemberService.addMember(orgB, user, MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(orgA, newId(), MemberRole.SUPER_ADMIN).block(TIMEOUT); + + Map roles = orgMemberService.getOrgMemberRoles(List.of(orgA, orgB, orgC), user).block(TIMEOUT); + System.out.println("[OrgMemberServiceImplMongoTest] roles " + roles.keySet()); + assertThat(roles).containsOnlyKeys(orgA, orgB); + assertThat(roles.get(orgA).getRole()).isEqualTo(MemberRole.ADMIN); + assertThat(roles.get(orgB).getRole()).isEqualTo(MemberRole.MEMBER); + assertThat(orgMemberService.getOrgMemberRoles(List.of(orgB), user).block(TIMEOUT)).containsOnlyKeys(orgB); + } + + /** Catches: deleted organisations, or memberships of organisations that no longer exist, being listed as active. */ + @Test + void activeOrgsExcludeDeletedOrgsAndMembershipsWithoutAnOrganisation() { + String active = createActiveOrg(); + String deleted = createOrg(OrganizationState.DELETED); + String ghost = newId(); + String user = newId(); + orgMemberService.addMember(active, user, MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(deleted, user, MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(ghost, user, MemberRole.MEMBER).block(TIMEOUT); + + List orgIds = orgMemberService.getAllActiveOrgs(user).map(OrgMember::getOrgId).collectList().block(TIMEOUT); + + System.out.println("[OrgMemberServiceImplMongoTest] active orgs " + orgIds + " of 3 memberships"); + assertThat(orgIds).containsExactly(active); + assertThat(orgMemberService.countAllActiveOrgs(user).block(TIMEOUT)).isEqualTo(1L); + assertThat(orgMemberService.countAllActiveOrgs(newId()).block(TIMEOUT)).isZero(); + } + + /** + * Catches: the current org not being persisted (the first active org must be marked CURRENT when none is), the wrong + * org returned, and the explicit mark / unmark writing wrong state values. + */ + @Test + void currentOrgIsMarkedWhenNoneIsAndCanBeSwitched() { + String orgA = createActiveOrg(); + String orgB = createActiveOrg(); + String user = newId(); + orgMemberService.addMember(orgA, user, MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(orgB, user, MemberRole.MEMBER).block(TIMEOUT); + + OrgMember first = orgMemberService.getCurrentOrgMember(user).block(TIMEOUT); + String marked = first.getOrgId(); + System.out.println("[OrgMemberServiceImplMongoTest] first current org " + marked); + assertThat(marked).isIn(orgA, orgB); + assertThat(singleRow(ORG_MEMBER, marked, user).getState()).isEqualTo(CURRENT); + String unmarked = marked.equals(orgA) ? orgB : orgA; + assertThat(singleRow(ORG_MEMBER, unmarked, user).getState()).isEqualTo(NORMAL); + + UserOrgMemberInfo info = orgMemberService.getUserOrgMemberInfo(user).block(TIMEOUT); + assertThat(info.currentOrgMember().getOrgId()).isEqualTo(marked); + assertThat(info.orgMembers()).extracting(OrgMember::getOrgId).containsExactlyInAnyOrder(orgA, orgB); + + assertThat(orgMemberService.removeCurrentOrgMark(marked, user).block(TIMEOUT)).isTrue(); + assertThat(orgMemberService.markAsUserCurrentOrgId(unmarked, user).block(TIMEOUT)).isTrue(); + assertThat(orgMemberService.getCurrentOrgMember(user).block(TIMEOUT).getOrgId()).isEqualTo(unmarked); + assertThat(singleRow(ORG_MEMBER, marked, user).getState()).isEqualTo(NORMAL); + } + + /** Catches: getUserOrgMemberInfo not persisting the first org as current when none is marked (it has its own path). */ + @Test + void orgMemberInfoMarksTheFirstOrgAsCurrentWhenNoneIsMarked() { + String orgA = createActiveOrg(); + String orgB = createActiveOrg(); + String user = newId(); + orgMemberService.addMember(orgA, user, MemberRole.MEMBER).block(TIMEOUT); + orgMemberService.addMember(orgB, user, MemberRole.MEMBER).block(TIMEOUT); + + UserOrgMemberInfo info = orgMemberService.getUserOrgMemberInfo(user).block(TIMEOUT); + + String marked = info.currentOrgMember().getOrgId(); + System.out.println("[OrgMemberServiceImplMongoTest] info marked " + marked + " as current of " + info.orgMembers().size()); + assertThat(singleRow(ORG_MEMBER, marked, user).getState()).isEqualTo(CURRENT); + assertThat(singleRow(ORG_MEMBER, marked.equals(orgA) ? orgB : orgA, user).getState()).isEqualTo(NORMAL); + assertThat(info.orgMembers()).hasSize(2); + } + + /** Catches: a user without any active organisation getting a current org or an error instead of nothing. */ + @Test + void aUserWithoutActiveOrgsHasNoCurrentOrgAndNoOrgInfo() { + String user = newId(); + assertThat(orgMemberService.getCurrentOrgMember(user).blockOptional(TIMEOUT)).isEmpty(); + assertThat(orgMemberService.getUserOrgMemberInfo(user).blockOptional(TIMEOUT)).isEmpty(); + + String deleted = createOrg(OrganizationState.DELETED); + orgMemberService.addMember(deleted, user, MemberRole.MEMBER).block(TIMEOUT); + assertThat(orgMemberService.getCurrentOrgMember(user).blockOptional(TIMEOUT)).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationMongoTestBase.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationMongoTestBase.java new file mode 100644 index 0000000000..d1a9603114 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationMongoTestBase.java @@ -0,0 +1,77 @@ +package org.lowcoder.domain.organization; + +import java.time.Duration; +import java.util.List; + +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.group.service.GroupService; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.model.OrganizationState; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.infra.birelation.BiRelation; +import org.lowcoder.infra.birelation.BiRelationBizType; +import org.lowcoder.sdk.util.IDUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; + +/** + * Helpers of the organisation-membership tests (task L3-11a): every test generates its own organisation, user and group + * ids, creates the organisation documents it needs directly (no seed data, no side effects of OrganizationService.create) + * and reads the stored BiRelation rows straight from MongoDB, independently of the services under test. + * The concrete classes carry the {@code @SpringBootTest} and context annotations. + */ +abstract class OrganizationMongoTestBase { + + protected static final Duration TIMEOUT = Duration.ofSeconds(30); + + @Autowired + protected ReactiveMongoTemplate mongo; + @Autowired + protected OrgMemberService orgMemberService; + @Autowired + protected GroupService groupService; + @Autowired + protected GroupMemberService groupMemberService; + + protected static String newId() { + return IDUtils.generate(); + } + + /** An organisation document in the given state, without groups or members. */ + protected String createOrg(OrganizationState state) { + Organization org = Organization.builder().name("org-" + newId()).gid(newId()).state(state).build(); + return mongo.save(org).block(TIMEOUT).getId(); + } + + protected String createActiveOrg() { + return createOrg(OrganizationState.ACTIVE); + } + + /** An active organisation that also has its all-users group; returns {orgId, allUsersGroupId}. */ + protected String[] createActiveOrgWithAllUsersGroup() { + String orgId = createActiveOrg(); + Group group = groupService.createAllUserGroup(orgId).block(TIMEOUT); + return new String[] {orgId, group.getId()}; + } + + protected List rows(BiRelationBizType type, String sourceId, String targetId) { + Query query = Query.query(Criteria.where("bizType").is(type).and("sourceId").is(sourceId).and("targetId").is(targetId)); + return mongo.find(query, BiRelation.class).collectList().block(TIMEOUT); + } + + protected List rowsOfSource(BiRelationBizType type, String sourceId) { + Query query = Query.query(Criteria.where("bizType").is(type).and("sourceId").is(sourceId)); + return mongo.find(query, BiRelation.class).collectList().block(TIMEOUT); + } + + protected BiRelation singleRow(BiRelationBizType type, String sourceId, String targetId) { + List found = rows(type, sourceId, targetId); + if (found.size() != 1) { + throw new AssertionError("expected one " + type + " row for " + sourceId + "/" + targetId + " but found " + found.size()); + } + return found.get(0); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplCreationMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplCreationMongoTest.java new file mode 100644 index 0000000000..f5fb23004b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplCreationMongoTest.java @@ -0,0 +1,240 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.domain.organization.model.OrganizationState.ACTIVE; +import static org.lowcoder.domain.organization.model.OrganizationState.DELETED; +import static org.lowcoder.infra.birelation.BiRelationBizType.GROUP_MEMBER; +import static org.lowcoder.infra.birelation.BiRelationBizType.ORG_MEMBER; + +import java.util.Locale; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.model.Organization.OrganizationCommonSettings; +import org.lowcoder.domain.organization.model.OrganizationDomain; +import org.lowcoder.domain.organization.model.OrganizationState; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.infra.birelation.BiRelation; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.constants.GlobalContext; +import org.lowcoder.sdk.constants.WorkspaceMode; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +/** + * Organisation creation, default organisations, enterprise-mode lookup and domain lookup of OrganizationServiceImpl (unit + * U14, task L3-11b). They depend on state of the WHOLE database (the first active org, the single super-admin user, the + * org count) and on the global workspace mode, so the class has a Spring context and database of its own (extra property); + * its collections are emptied before every test and the CommonConfig singleton is restored after every test. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +@TestPropertySource(properties = "l3_11b.context=org-creation") +class OrganizationServiceImplCreationMongoTest extends OrganizationMongoTestBase { + + private static final String ENGLISH_SUFFIX = "'s workspace"; + private static final String CHINESE_SUFFIX = "的工作区"; + + @Autowired + private OrganizationService organizationService; + @Autowired + private CommonConfig commonConfig; + + private WorkspaceMode originalMode; + private String originalEnterpriseOrgId; + + @BeforeEach + void emptyTheCollectionsAndRememberTheConfiguration() { + mongo.remove(new Query(), BiRelation.class).block(TIMEOUT); + mongo.remove(new Query(), Organization.class).block(TIMEOUT); + mongo.remove(new Query(), Group.class).block(TIMEOUT); + mongo.remove(new Query(), User.class).block(TIMEOUT); + originalMode = commonConfig.getWorkspace().getMode(); + originalEnterpriseOrgId = commonConfig.getWorkspace().getEnterpriseOrgId(); + commonConfig.getWorkspace().setMode(WorkspaceMode.SAAS); + } + + @AfterEach + void restoreTheConfiguration() { + commonConfig.getWorkspace().setMode(originalMode); + commonConfig.getWorkspace().setEnterpriseOrgId(originalEnterpriseOrgId); + } + + private Organization newOrg(String name) { + return Organization.builder().name(name).gid(UUID.randomUUID().toString()).build(); + } + + private Organization stored(String orgId) { + return mongo.findById(orgId, Organization.class).block(TIMEOUT); + } + + private User user(String name, boolean superAdmin) { + return mongo.save(User.builder().name(name).superAdmin(superAdmin).build()).block(TIMEOUT); + } + + private Organization saveOrg(OrganizationState state, String name) { + return mongo.save(Organization.builder().name(name).gid(UUID.randomUUID().toString()).state(state).build()).block(TIMEOUT); + } + + private long orgCount() { + return mongo.count(new Query(), Organization.class).block(TIMEOUT); + } + + // ------------------------------------------------------------------ create + + /** Catches: an org created from a null or an already stored organisation (an id means "not new"). */ + @Test + void createRejectsANullOrganisationAndOneThatAlreadyHasAnId() { + BizException nullOrg = assertThrows(BizException.class, () -> organizationService.create(null, newId(), false).block(TIMEOUT)); + assertThat(nullOrg.getError()).isEqualTo(BizError.INVALID_PARAMETER); + + Organization withId = newOrg("has-id"); + withId.setId(newId()); + BizException hasId = assertThrows(BizException.class, () -> organizationService.create(withId, newId(), false).block(TIMEOUT)); + assertThat(hasId.getError()).isEqualTo(BizError.INVALID_PARAMETER); + assertThat(orgCount()).isZero(); + } + + /** Catches: a new org that is not ACTIVE, lacks its reset template or system groups, or whose creator has the wrong role. */ + @Test + void createStoresAnActiveOrgWithTheDefaultTemplateGroupsAndAnAdminCreator() { + String creator = newId(); + + Organization created = organizationService.create(newOrg("fresh"), creator, false).block(TIMEOUT); + + Organization stored = stored(created.getId()); + System.out.println("[OrganizationServiceImplCreationMongoTest] created " + stored.getId() + " state " + stored.getState()); + assertThat(stored.getState()).isEqualTo(ACTIVE); + assertThat(stored.getCommonSettings()).containsEntry(OrganizationCommonSettings.PASSWORD_RESET_EMAIL_TEMPLATE, + OrganizationService.PASSWORD_RESET_EMAIL_TEMPLATE_DEFAULT); + Group allUsers = groupService.getAllUsersGroup(created.getId()).block(TIMEOUT); + assertThat(allUsers).isNotNull(); + assertThat(groupService.getDevGroup(created.getId()).block(TIMEOUT)).isNotNull(); + assertThat(singleRow(ORG_MEMBER, created.getId(), creator).getRelation()).isEqualTo(MemberRole.ADMIN.getValue()); + assertThat(rows(GROUP_MEMBER, allUsers.getId(), creator)).as("the creator joins the all-users group").hasSize(1); + } + + /** Catches: the super-admin flag of the creator not giving SUPER_ADMIN, or the configured super admin not joining. */ + @Test + void aSuperAdminCreatorIsSuperAdminAndAConfiguredSuperAdminJoinsEveryNewOrg() { + User root = user("root", true); + String creator = newId(); + + Organization byCreator = organizationService.create(newOrg("by-creator"), creator, false).block(TIMEOUT); + assertThat(singleRow(ORG_MEMBER, byCreator.getId(), creator).getRelation()).isEqualTo(MemberRole.ADMIN.getValue()); + assertThat(singleRow(ORG_MEMBER, byCreator.getId(), root.getId()).getRelation()).isEqualTo(MemberRole.SUPER_ADMIN.getValue()); + + Organization byRoot = organizationService.create(newOrg("by-root"), root.getId(), true).block(TIMEOUT); + assertThat(rows(ORG_MEMBER, byRoot.getId(), root.getId())).as("the super admin is not added twice").hasSize(1); + assertThat(rows(ORG_MEMBER, byRoot.getId(), root.getId()).get(0).getRelation()).isEqualTo(MemberRole.SUPER_ADMIN.getValue()); + + String other = newId(); + Organization flagged = organizationService.create(newOrg("flagged"), other, true).block(TIMEOUT); + assertThat(singleRow(ORG_MEMBER, flagged.getId(), other).getRelation()).isEqualTo(MemberRole.SUPER_ADMIN.getValue()); + System.out.println("[OrganizationServiceImplCreationMongoTest] super admin handling checked"); + } + + // ------------------------------------------------------------------ createDefault + + /** Catches: a wrong default org name or locale, a missing auto-generated flag, a wrong creator role. */ + @Test + void createDefaultInSaasModeCreatesALocalisedAutoGeneratedOrgWithAnAdminCreator() { + User alice = user("Alice", false); + User bob = user("Bob", false); + + Organization english = organizationService.createDefault(alice, false).block(TIMEOUT); + Organization chinese = organizationService.createDefault(bob, false) + .contextWrite(context -> context.put(GlobalContext.CLIENT_LOCALE, Locale.CHINESE)).block(TIMEOUT); + + System.out.println("[OrganizationServiceImplCreationMongoTest] default orgs: " + english.getName() + " / " + chinese.getName()); + assertThat(english.getName()).isEqualTo("Alice" + ENGLISH_SUFFIX); + assertThat(chinese.getName()).isEqualTo("Bob" + CHINESE_SUFFIX); + assertThat(stored(english.getId()).getIsAutoGeneratedOrganization()).isTrue(); + assertThat(singleRow(ORG_MEMBER, english.getId(), alice.getId()).getRelation()).isEqualTo(MemberRole.ADMIN.getValue()); + } + + /** + * Catches: enterprise mode creating one org per user. Behaviour (candidate L5): when the user joins the enterprise + * org the Mono completes EMPTY, so callers cannot use the result as "the org of the user". + */ + @Test + void createDefaultInEnterpriseModeJoinsTheEnterpriseOrgAndCompletesEmptyOrCreatesWhenThereIsNone() { + commonConfig.getWorkspace().setMode(WorkspaceMode.ENTERPRISE); + User carol = user("Carol", false); + + Organization first = organizationService.createDefault(carol, false).block(TIMEOUT); + assertThat(first).as("no enterprise org yet: a normal org is created").isNotNull(); + assertThat(orgCount()).isEqualTo(1L); + + User dave = user("Dave", false); + assertThat(organizationService.createDefault(dave, false).blockOptional(TIMEOUT)).as("joined: completes empty").isEmpty(); + + assertThat(orgCount()).as("no organisation created for the joining user").isEqualTo(1L); + assertThat(singleRow(ORG_MEMBER, first.getId(), dave.getId()).getRelation()).isEqualTo(MemberRole.MEMBER.getValue()); + } + + // ------------------------------------------------------------------ enterprise org lookup + + /** Catches: a wrong org returned or a wrong fallback in enterprise mode, and an enterprise org lookup in SaaS mode. */ + @Test + void enterpriseOrgLookupFollowsTheConfigurationWithFallbacks() { + commonConfig.getWorkspace().setMode(WorkspaceMode.ENTERPRISE); + assertThat(organizationService.getOrganizationInEnterpriseMode().blockOptional(TIMEOUT)).as("no active org").isEmpty(); + + Organization configured = saveOrg(ACTIVE, "configured"); + Organization other = saveOrg(ACTIVE, "other"); + commonConfig.getWorkspace().setMode(WorkspaceMode.SAAS); + assertThat(organizationService.getOrganizationInEnterpriseMode().blockOptional(TIMEOUT)).as("SaaS mode, active orgs exist").isEmpty(); + commonConfig.getWorkspace().setMode(WorkspaceMode.ENTERPRISE); + commonConfig.getWorkspace().setEnterpriseOrgId(configured.getId()); + assertThat(organizationService.getOrganizationInEnterpriseMode().block(TIMEOUT).getId()).isEqualTo(configured.getId()); + + for (String fallbackId : new String[] {newId(), "", " "}) { + commonConfig.getWorkspace().setEnterpriseOrgId(fallbackId); + String found = organizationService.getOrganizationInEnterpriseMode().block(TIMEOUT).getId(); + System.out.println("[OrganizationServiceImplCreationMongoTest] enterprise org id [" + fallbackId + "] resolves to the first active org"); + assertThat(found).isIn(configured.getId(), other.getId()); + } + + Organization deleted = saveOrg(DELETED, "deleted"); + commonConfig.getWorkspace().setEnterpriseOrgId(deleted.getId()); + BizException error = assertThrows(BizException.class, () -> organizationService.getOrganizationInEnterpriseMode().block(TIMEOUT)); + assertThat(error.getError()).isEqualTo(BizError.ORG_DELETED_FOR_ENTERPRISE_MODE); + } + + // ------------------------------------------------------------------ domain lookup + + /** Catches: the domain lookup ignoring the domain of the request context, or returning a deleted org. */ + @Test + void getByDomainUsesTheDomainOfTheRequestContextAndOnlyActiveOrgs() { + String domain = "acme" + newId() + ".example"; + OrganizationDomain organizationDomain = new OrganizationDomain(); + organizationDomain.setDomain(domain); + Organization acme = mongo.save(Organization.builder().name("acme").gid(UUID.randomUUID().toString()).state(ACTIVE) + .organizationDomain(organizationDomain).build()).block(TIMEOUT); + String goneDomain = "gone" + newId() + ".example"; + OrganizationDomain goneOrganizationDomain = new OrganizationDomain(); + goneOrganizationDomain.setDomain(goneDomain); + mongo.save(Organization.builder().name("gone").gid(UUID.randomUUID().toString()).state(DELETED) + .organizationDomain(goneOrganizationDomain).build()).block(TIMEOUT); + + assertThat(organizationService.getByDomain().contextWrite(context -> context.put(GlobalContext.DOMAIN, domain)).block(TIMEOUT).getId()) + .isEqualTo(acme.getId()); + assertThat(organizationService.getByDomain().contextWrite(context -> context.put(GlobalContext.DOMAIN, goneDomain)).blockOptional(TIMEOUT)).isEmpty(); + assertThat(organizationService.getByDomain().contextWrite(context -> context.put(GlobalContext.DOMAIN, "unknown.example")).blockOptional(TIMEOUT)).isEmpty(); + assertThat(organizationService.getByDomain().blockOptional(TIMEOUT)).as("no domain in the context").isEmpty(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplDeleteMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplDeleteMongoTest.java new file mode 100644 index 0000000000..5f1c3dc4ba --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplDeleteMongoTest.java @@ -0,0 +1,131 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.domain.organization.model.OrganizationState.ACTIVE; +import static org.lowcoder.domain.organization.model.OrganizationState.DELETED; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationStatus; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceStatus; +import org.lowcoder.domain.organization.event.OrgDeletedEvent; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +/** + * OrganizationServiceImpl.delete (unit U14, task L3-11b): the soft delete, the event, and the section 9 row "deleting an + * org leaves its apps and datasources". The collecting listener and the extra property give this class a Spring context + * (and database) of its own. The real OrgAndGroupEventListener runs too, asynchronously: the test waits for it by polling + * the org's groups (bounded, 20 s, 100 ms interval). What it does with the members is the L4-8 section 9 row on + * BiRelationServiceImpl.removeAllBiRelations and is not asserted here. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +@TestPropertySource(properties = "l3_11b.context=org-delete") +@Import(OrganizationServiceImplDeleteMongoTest.OrgEventCollector.class) +class OrganizationServiceImplDeleteMongoTest extends OrganizationMongoTestBase { + + private static final Duration POLL_LIMIT = Duration.ofSeconds(20); + private static final long POLL_INTERVAL_MILLIS = 100; + static final List DELETED_ORG_IDS = new CopyOnWriteArrayList<>(); + + @TestConfiguration + static class OrgEventCollector { + @EventListener + void onOrgDeleted(OrgDeletedEvent event) { + DELETED_ORG_IDS.add(event.getOrgId()); + } + } + + @Autowired + private OrganizationService organizationService; + + private String savedOrgId() { + return mongo.save(Organization.builder().name("doomed").gid(UUID.randomUUID().toString()).state(ACTIVE).build()).block(TIMEOUT).getId(); + } + + private Organization stored(String orgId) { + return mongo.findById(orgId, Organization.class).block(TIMEOUT); + } + + /** Polls (bounded) until the asynchronous org-deletion handler has removed the org's groups. */ + private void awaitGroupsRemoved(String orgId) throws InterruptedException { + long deadline = System.nanoTime() + POLL_LIMIT.toNanos(); + while (System.nanoTime() < deadline) { + if (groupService.getOrgGroupCount(orgId).block(TIMEOUT) == 0L) { + return; + } + Thread.sleep(POLL_INTERVAL_MILLIS); + } + throw new AssertionError("the org deletion handler did not remove the groups of " + orgId + " within " + POLL_LIMIT); + } + + /** Catches: a hard delete or no state change, a missing event, an event for a failed update. */ + @Test + void deleteMarksTheOrgDeletedAndPublishesOneEventAndNothingForUnknownOrAlreadyDeletedOrgs() throws Exception { + String orgId = savedOrgId(); + + assertThat(organizationService.delete(orgId).block(TIMEOUT)).isTrue(); + awaitGroupsRemoved(orgId); + + System.out.println("[OrganizationServiceImplDeleteMongoTest] events: " + DELETED_ORG_IDS); + assertThat(stored(orgId).getState()).isEqualTo(DELETED); + assertThrows(RuntimeException.class, () -> organizationService.getById(orgId).block(TIMEOUT)); + assertThat(organizationService.getByIdWithDeleted(orgId).block(TIMEOUT).getId()).isEqualTo(orgId); + assertThat(DELETED_ORG_IDS).containsOnlyOnce(orgId); + + // Deleting an already deleted org is deliberately not asserted: today the update still reports a change (the + // auditing fields move), so the answer and a second event are an observation (candidate L9), printed only. + Boolean again = organizationService.delete(orgId).block(TIMEOUT); + System.out.println("[OrganizationServiceImplDeleteMongoTest] second delete answered " + again + ", events now " + DELETED_ORG_IDS); + String unknown = newId(); + assertThat(organizationService.delete(unknown).block(TIMEOUT)).as("an unknown org changes nothing").isFalse(); + assertThat(DELETED_ORG_IDS).doesNotContain(unknown); + } + + /** + * Pins plan section 9 row "deleting an org leaves its apps and datasources": OrgAndGroupEventListener.onOrgDeleted + * (:58-70) ends with deleteOrgApplications() and deleteOrgDatasources() (:64-65), both of which return Mono.empty() + * (:98-104), so after the org is deleted (and the listener has finished: the groups are gone) its applications and + * datasources are still stored, still carrying the deleted org's id. A fix (delete them) changes this test on purpose. + */ + @Test + void deletingAnOrgLeavesItsApplicationsAndDatasources_pinsTheSection9Row() throws Exception { + String orgId = savedOrgId(); + groupService.createAllUserGroup(orgId).block(TIMEOUT); + groupService.createDevGroup(orgId).block(TIMEOUT); + Application application = mongo.save(Application.builder().organizationId(orgId).name("app-of-the-doomed-org") + .gid(UUID.randomUUID().toString()).applicationType(1).applicationStatus(ApplicationStatus.NORMAL).build()).block(TIMEOUT); + Datasource datasource = mongo.save(Datasource.builder().organizationId(orgId).name("datasource-of-the-doomed-org") + .type("postgres").creationSource(0).build()).block(TIMEOUT); + + assertThat(organizationService.delete(orgId).block(TIMEOUT)).isTrue(); + awaitGroupsRemoved(orgId); + + Application appAfter = mongo.findById(application.getId(), Application.class).block(TIMEOUT); + Datasource datasourceAfter = mongo.findById(datasource.getId(), Datasource.class).block(TIMEOUT); + System.out.println("[OrganizationServiceImplDeleteMongoTest] PINNED after the org deletion: application present=" + + (appAfter != null) + " datasource present=" + (datasourceAfter != null)); + assertThat(stored(orgId).getState()).isEqualTo(DELETED); + assertThat(appAfter).isNotNull(); + assertThat(appAfter.getOrganizationId()).isEqualTo(orgId); + assertThat(datasourceAfter).isNotNull(); + assertThat(datasourceAfter.getOrganizationId()).isEqualTo(orgId); + assertThat(datasourceAfter.getDatasourceStatus()).as("not even soft-deleted").isEqualTo(DatasourceStatus.NORMAL); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplDeleteTwicePinMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplDeleteTwicePinMongoTest.java new file mode 100644 index 0000000000..3c81cd3da1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplDeleteTwicePinMongoTest.java @@ -0,0 +1,67 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.lowcoder.domain.organization.model.OrganizationState.ACTIVE; +import static org.lowcoder.domain.organization.model.OrganizationState.DELETED; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.organization.event.OrgDeletedEvent; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +/** + * Pin of the section 9 row found by L3-11b on OrganizationServiceImpl.delete (follow-up). Own Spring context and database + * (extra property), with a collecting OrgDeletedEvent listener. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +@TestPropertySource(properties = "l3_11b.context=org-delete-twice") +@Import(OrganizationServiceImplDeleteTwicePinMongoTest.OrgEventCollector.class) +class OrganizationServiceImplDeleteTwicePinMongoTest extends OrganizationMongoTestBase { + + static final List DELETED_ORG_IDS = new CopyOnWriteArrayList<>(); + + @TestConfiguration + static class OrgEventCollector { + @EventListener + void onOrgDeleted(OrgDeletedEvent event) { + DELETED_ORG_IDS.add(event.getOrgId()); + } + } + + @Autowired + private OrganizationService organizationService; + + /** + * Pins plan section 9 row "deleting a deleted org answers true and publishes a second OrgDeletedEvent": delete + * (OrganizationServiceImpl:264-275) updates the state without checking it, and the update reports a change because the + * auditing fields move, so a second delete answers true and publishes the event again (the cascades run twice). A fix + * (guard on state ACTIVE) changes this test on purpose. + */ + @Test + void deletingADeletedOrgAnswersTrueAndPublishesASecondEvent_pinsTheSection9Row() { + String orgId = mongo.save(Organization.builder().name("twice").gid(UUID.randomUUID().toString()).state(ACTIVE).build()).block(TIMEOUT).getId(); + + Boolean first = organizationService.delete(orgId).block(TIMEOUT); + Boolean second = organizationService.delete(orgId).block(TIMEOUT); + + long events = DELETED_ORG_IDS.stream().filter(orgId::equals).count(); + System.out.println("[OrganizationServiceImplDeleteTwicePinMongoTest] PINNED first=" + first + " second=" + second + " events=" + events); + assertThat(mongo.findById(orgId, Organization.class).block(TIMEOUT).getState()).isEqualTo(DELETED); + assertThat(first).isTrue(); + assertThat(second).isTrue(); + assertThat(events).isEqualTo(2L); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplLogoAndSlugPinMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplLogoAndSlugPinMongoTest.java new file mode 100644 index 0000000000..dc163a296e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplLogoAndSlugPinMongoTest.java @@ -0,0 +1,115 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.domain.organization.model.OrganizationState.ACTIVE; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.asset.service.AssetRepository; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.Part; +import org.springframework.test.context.ActiveProfiles; + +import reactor.core.publisher.Flux; + +/** + * Pins of two section 9 rows found by L3-11b on OrganizationServiceImpl (follow-up): the dangling logo reference and the + * hyphenated slug. Shared {@code test} context, ids generated per test. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +class OrganizationServiceImplLogoAndSlugPinMongoTest extends OrganizationMongoTestBase { + + private static final byte[] LOGO_BYTES = "logo-bytes".getBytes(StandardCharsets.UTF_8); + + @Autowired + private OrganizationService organizationService; + @Autowired + private AssetRepository assetRepository; + + private Organization saveOrg() { + return mongo.save(Organization.builder().name("pin-" + newId()).gid(UUID.randomUUID().toString()).slug("s" + newId()).state(ACTIVE).build()).block(TIMEOUT); + } + + private Organization stored(String orgId) { + return mongo.findById(orgId, Organization.class).block(TIMEOUT); + } + + private Part pngPart() { + Part part = Mockito.mock(Part.class); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.IMAGE_PNG); + Mockito.when(part.headers()).thenReturn(headers); + Mockito.when(part.content()).thenAnswer(invocation -> Flux.just(new DefaultDataBufferFactory().wrap(LOGO_BYTES))); + return part; + } + + /** + * Pins plan section 9 row "deleteLogo keeps a dangling logoAssetId (null not written by convertToUpdate)": deleteLogo + * (OrganizationServiceImpl:251-256) clears the logo with updateById on an Organization whose logoAssetId is null, and + * MongoUpsertHelper.convertToUpdate (:139-147) writes only non-null fields, so the asset is deleted but the org keeps + * its logoAssetId (for the real id and, a fortiori, for a gid or slug). A second deleteLogo then fails naming the + * deleted asset. A fix (unset the field) changes this test on purpose. + */ + @Test + void deleteLogoKeepsADanglingLogoAssetId_pinsTheSection9Row() { + Organization org = saveOrg(); + organizationService.uploadLogo(org.getId(), pngPart()).block(TIMEOUT); + String assetId = stored(org.getId()).getLogoAssetId(); + + organizationService.deleteLogo(org.getId()).block(TIMEOUT); + + System.out.println("[OrganizationServiceImplLogoAndSlugPinMongoTest] PINNED asset " + assetId + " present=" + + assetRepository.findById(assetId).blockOptional(TIMEOUT).isPresent() + ", reference " + stored(org.getId()).getLogoAssetId()); + assertThat(assetRepository.findById(assetId).blockOptional(TIMEOUT)).as("the asset is deleted").isEmpty(); + assertThat(stored(org.getId()).getLogoAssetId()).as("the reference stays").isEqualTo(assetId); + BizException again = assertThrows(BizException.class, () -> { + try { + organizationService.deleteLogo(org.getId()).block(TIMEOUT); + } catch (RuntimeException e) { + throw e instanceof BizException ? e : (BizException) e.getCause(); + } + }); + assertThat(again.getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + assertThat(again.getArgs()).containsExactly(assetId); + } + + /** + * Pins plan section 9 row "a hyphenated slug is accepted but unreachable (isGID)": SlugUtils.validate accepts a hyphen, + * but getById (OrganizationServiceImpl:175-179) decides GID-or-not by "contains a hyphen" (FieldName.guessFieldNameFromId), + * so the org cannot be found by that slug. A fix (reject hyphens, or look the slug up before the GID guess) changes this + * test on purpose. + */ + @Test + void aHyphenatedSlugIsAcceptedButUnreachable_pinsTheSection9Row() { + Organization org = saveOrg(); + String slug = "my-" + newId(); + + assertThat(organizationService.updateSlug(org.getId(), slug).block(TIMEOUT).getSlug()).isEqualTo(slug); + + assertThat(stored(org.getId()).getSlug()).isEqualTo(slug); + BizException error = assertThrows(BizException.class, () -> { + try { + organizationService.getById(slug).block(TIMEOUT); + } catch (RuntimeException e) { + throw e instanceof BizException ? e : (BizException) e.getCause(); + } + }); + System.out.println("[OrganizationServiceImplLogoAndSlugPinMongoTest] PINNED slug " + slug + " not resolvable: " + error.getError()); + assertThat(error.getError()).isEqualTo(BizError.UNABLE_TO_FIND_VALID_ORG); + assertThat(organizationService.getById(org.getId()).block(TIMEOUT).getId()).as("the id still works").isEqualTo(org.getId()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplMongoTest.java new file mode 100644 index 0000000000..5175fcae39 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/OrganizationServiceImplMongoTest.java @@ -0,0 +1,355 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.lowcoder.domain.organization.model.OrganizationState.ACTIVE; +import static org.lowcoder.domain.organization.model.OrganizationState.DELETED; +import static org.lowcoder.infra.birelation.BiRelationBizType.ORG_MEMBER; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.asset.model.Asset; +import org.lowcoder.domain.asset.service.AssetRepository; +import org.lowcoder.domain.organization.model.MemberRole; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.model.OrganizationState; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.Part; +import org.springframework.test.context.ActiveProfiles; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * OrganizationServiceImpl against the MongoDB test container (unit U14, task L3-11b), shared {@code test} context, ids + * generated per test. Not repeated: the happy paths of updateSlug, getAllActive, getByIds, getOrgCommonSettings and + * updateCommonSettings (OrganizationServiceTest), the BiRelation cascade of org deletion (L4-8 section 9 row), and the + * asset rows of L3-10 (the logo tests use the real AssetService only for what OrganizationServiceImpl decides). + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +class OrganizationServiceImplMongoTest extends OrganizationMongoTestBase { + + private static final String INVALID_ORG_ID = "INVALID_ORG_ID"; + private static final byte[] LOGO_BYTES = "logo-bytes".getBytes(StandardCharsets.UTF_8); + + @Autowired + private OrganizationService organizationService; + @Autowired + private AssetRepository assetRepository; + + /** An organisation with a GID-shaped gid (contains a hyphen) and a hyphen-free slug, so every lookup key is usable. */ + private Organization saveOrg(OrganizationState state, String name) { + Organization org = Organization.builder().name(name).gid(UUID.randomUUID().toString()).slug("s" + newId()).state(state).build(); + return mongo.save(org).block(TIMEOUT); + } + + private Organization stored(String orgId) { + return mongo.findById(orgId, Organization.class).block(TIMEOUT); + } + + private static void assertNoValidOrg(Throwable error) { + assertThat(error).isInstanceOf(BizException.class); + BizException biz = (BizException) error; + System.out.println("[OrganizationServiceImplMongoTest] error " + biz.getError() + " / " + biz.getMessageKey()); + assertThat(biz.getError()).isEqualTo(BizError.UNABLE_TO_FIND_VALID_ORG); + assertThat(biz.getMessageKey()).isEqualTo(INVALID_ORG_ID); + } + + private Part pngPart() { + Part part = Mockito.mock(Part.class); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.IMAGE_PNG); + Mockito.when(part.headers()).thenReturn(headers); + Mockito.when(part.content()).thenAnswer(invocation -> Flux.just(new DefaultDataBufferFactory().wrap(LOGO_BYTES))); + return part; + } + + private Part svgPart() { + Part part = Mockito.mock(Part.class); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("image/svg+xml")); + Mockito.when(part.headers()).thenReturn(headers); + Mockito.when(part.content()).thenReturn(Flux.just(new DefaultDataBufferFactory().wrap(LOGO_BYTES))); + return part; + } + + // ------------------------------------------------------------------ lookups + + /** Catches: a deleted org served as live, or a lookup key (id, gid, slug) not resolving. */ + @Test + void getByIdResolvesActiveOrgsByIdGidAndSlugAndNeverDeletedOnes() { + Organization active = saveOrg(ACTIVE, "active"); + Organization deleted = saveOrg(DELETED, "deleted"); + + for (String key : new String[] {active.getId(), active.getGid(), active.getSlug()}) { + assertThat(organizationService.getById(key).block(TIMEOUT).getId()).as("lookup by " + key).isEqualTo(active.getId()); + } + for (String key : new String[] {deleted.getId(), deleted.getGid(), deleted.getSlug(), newId(), UUID.randomUUID().toString()}) { + assertThrows(Exception.class, () -> organizationService.getById(key).block(TIMEOUT)); + } + try { + organizationService.getById(deleted.getId()).block(TIMEOUT); + } catch (Exception e) { + assertNoValidOrg(e); + } + } + + /** Catches: the with-deleted lookup filtering by state, or not resolving a key type; unknown keys must fail. */ + @Test + void getByIdWithDeletedFindsDeletedOrgsByIdGidAndSlug() { + Organization deleted = saveOrg(DELETED, "deleted"); + + for (String key : new String[] {deleted.getId(), deleted.getGid(), deleted.getSlug()}) { + assertThat(organizationService.getByIdWithDeleted(key).block(TIMEOUT).getId()).as("lookup by " + key).isEqualTo(deleted.getId()); + } + for (String key : new String[] {newId(), UUID.randomUUID().toString()}) { + assertThrows(Exception.class, () -> organizationService.getByIdWithDeleted(key).block(TIMEOUT)); + } + } + + /** Catches: the settings of a deleted or unknown org being readable. */ + @Test + void commonSettingsOfADeletedOrUnknownOrgAreNotReadable() { + Organization deleted = saveOrg(DELETED, "deleted"); + for (String key : new String[] {deleted.getId(), deleted.getGid(), newId()}) { + assertThrows(Exception.class, () -> organizationService.getOrgCommonSettings(key).block(TIMEOUT)); + } + Organization active = saveOrg(ACTIVE, "active"); + assertThat(organizationService.getOrgCommonSettings(active.getGid()).block(TIMEOUT)).isNotNull(); + } + + /** Catches: the wrong error for an invalid or duplicate slug, a duplicate slug being applied, an unknown org erroring. */ + @Test + void updateSlugRejectsInvalidAndDuplicateSlugsAndIgnoresUnknownOrgs() { + Organization first = saveOrg(ACTIVE, "first"); + Organization second = saveOrg(ACTIVE, "second"); + String newSlug = "n" + newId(); + + assertThat(slugError(first.getId(), "bad slug!")).isEqualTo(BizError.SLUG_INVALID); + assertThat(slugError(first.getId(), second.getSlug())).isEqualTo(BizError.SLUG_DUPLICATE_ENTRY); + assertThat(stored(first.getId()).getSlug()).as("a rejected slug is not applied").isEqualTo(first.getSlug()); + + assertThat(organizationService.updateSlug(first.getId(), newSlug).block(TIMEOUT).getSlug()).isEqualTo(newSlug); + assertThat(stored(first.getId()).getSlug()).isEqualTo(newSlug); + assertThat(organizationService.updateSlug(newId(), "u" + newId()).blockOptional(TIMEOUT)).as("unknown org completes empty").isEmpty(); + System.out.println("[OrganizationServiceImplMongoTest] slug errors checked; unknown org completes empty"); + } + + private BizError slugError(String orgId, String slug) { + try { + organizationService.updateSlug(orgId, slug).block(TIMEOUT); + } catch (RuntimeException e) { + Throwable cause = e instanceof BizException ? e : e.getCause(); + return ((BizException) cause).getError(); + } + throw new AssertionError("expected an error for slug " + slug); + } + + /** + * Behaviour (candidate L6, reported, not pinned): SlugUtils accepts a hyphen in a slug, but getById treats any key + * containing a hyphen as a GID, so an org whose slug has a hyphen cannot be found by its slug afterwards. + */ + @Test + void aSlugWithAHyphenIsAcceptedButNotResolvableBySlug() { + Organization org = saveOrg(ACTIVE, "hyphen"); + String slug = "my-" + newId(); + + Organization updated = organizationService.updateSlug(org.getId(), slug).block(TIMEOUT); + + assertThat(updated.getSlug()).isEqualTo(slug); + Throwable error = assertThrows(RuntimeException.class, () -> organizationService.getById(slug).block(TIMEOUT)); + System.out.println("[OrganizationServiceImplMongoTest] slug " + slug + " accepted; getById by that slug fails: " + error.getMessage()); + } + + /** Catches: a lost update time of a setting, or other keys being touched. */ + @Test + void updateCommonSettingsStoresTheValueAndItsUpdateTime() { + Organization org = saveOrg(ACTIVE, "settings"); + long before = System.currentTimeMillis(); + + assertThat(organizationService.updateCommonSettings(org.getId(), "theme", "dark").block(TIMEOUT)).isTrue(); + assertThat(organizationService.updateCommonSettings(org.getId(), "lang", "de").block(TIMEOUT)).isTrue(); + + Map settings = stored(org.getId()).getCommonSettings(); + System.out.println("[OrganizationServiceImplMongoTest] settings " + settings); + assertThat(settings).containsEntry("theme", "dark").containsEntry("lang", "de"); + assertThat(((Number) settings.get("theme_updateTime")).longValue()).isBetween(before, System.currentTimeMillis()); + assertThat(settings).containsKey("lang_updateTime"); + } + + /** Catches: a partial update overwriting the unset fields with null. */ + @Test + void updateChangesOnlyTheGivenFields() { + Organization org = saveOrg(ACTIVE, "keep-name"); + + assertThat(organizationService.update(org.getId(), Organization.builder().contactName("Ada").build()).block(TIMEOUT)).isTrue(); + + Organization after = stored(org.getId()); + assertThat(after.getContactName()).isEqualTo("Ada"); + assertThat(after.getName()).isEqualTo("keep-name"); + assertThat(after.getState()).isEqualTo(ACTIVE); + } + + /** Catches: a company or source mix-up, or deleted orgs matching. */ + @Test + void bySourceAndCompanyIdMatchesOnlyActiveOrgsOfThatSourceAndCompany() { + String company = "c" + newId(); + Organization match = mongo.save(Organization.builder().name("m").gid(UUID.randomUUID().toString()).state(ACTIVE) + .source("wecom").thirdPartyCompanyId(company).build()).block(TIMEOUT); + mongo.save(Organization.builder().name("d").gid(UUID.randomUUID().toString()).state(DELETED) + .source("lark").thirdPartyCompanyId(company).build()).block(TIMEOUT); + + assertThat(organizationService.getBySourceAndTpCompanyId("wecom", company).block(TIMEOUT).getId()).isEqualTo(match.getId()); + assertThat(organizationService.getBySourceAndTpCompanyId("lark", company).blockOptional(TIMEOUT)).isEmpty(); + assertThat(organizationService.getBySourceAndTpCompanyId("wecom", "other" + company).blockOptional(TIMEOUT)).isEmpty(); + } + + // ------------------------------------------------------------------ user org lists + + /** Catches: other users' or deleted orgs listed, a case-sensitive or missing name filter, a count that disagrees. */ + @Test + void userOrgsAreTheActiveOrgsOfTheMembershipsFilteredByNameAndCounted() { + String user = newId(); + String stranger = newId(); + String token = "Zeta" + newId(); + Organization alpha = saveOrg(ACTIVE, token + " Alpha"); + Organization beta = saveOrg(ACTIVE, token + " Beta"); + Organization gone = saveOrg(DELETED, token + " Gone"); + Organization foreign = saveOrg(ACTIVE, token + " Foreign"); + Organization unrelated = saveOrg(ACTIVE, "Unrelated" + newId()); + for (Organization org : List.of(alpha, beta, gone, unrelated)) { + orgMemberService.addMember(org.getId(), user, MemberRole.MEMBER).block(TIMEOUT); + } + orgMemberService.addMember(foreign.getId(), stranger, MemberRole.MEMBER).block(TIMEOUT); + PageRequest all = PageRequest.of(0, 10); + + List byName = organizationService.findUserOrgs(user, token.toLowerCase(), all).map(Organization::getId).collectList().block(TIMEOUT); + System.out.println("[OrganizationServiceImplMongoTest] orgs of the user matching the name: " + byName.size()); + assertThat(byName).containsExactlyInAnyOrder(alpha.getId(), beta.getId()); + assertThat(organizationService.countUserOrgs(user, token.toLowerCase()).block(TIMEOUT)).isEqualTo(2L); + assertThat(organizationService.findUserOrgs(user, "", all).collectList().block(TIMEOUT)).hasSize(3); + assertThat(organizationService.countUserOrgs(user, "").block(TIMEOUT)).isEqualTo(3L); + assertThat(organizationService.countUserOrgs(user, null).block(TIMEOUT)).as("a null name means no filter").isEqualTo(3L); + assertThat(organizationService.findUserOrgs(user, token, PageRequest.of(0, 1)).collectList().block(TIMEOUT)).hasSize(1); + assertThat(organizationService.findUserOrgs(user, token, PageRequest.of(1, 1)).collectList().block(TIMEOUT)).hasSize(1); + assertThat(organizationService.findUserOrgs(user, token, PageRequest.of(2, 1)).collectList().block(TIMEOUT)).isEmpty(); + assertThat(organizationService.findUserOrgs(newId(), "", all).collectList().block(TIMEOUT)).isEmpty(); + assertThat(organizationService.countUserOrgs(newId(), "").block(TIMEOUT)).isZero(); + } + + /** + * Candidate L3 (service level only: UserController.getUserOrgs maps a null name to "" before calling, :145): what + * findUserOrgs does with a null name, recorded as observed (printed, not asserted beyond "does not hang"). + */ + @Test + void findUserOrgsWithANullNameIsRecorded() { + String user = newId(); + Organization org = saveOrg(ACTIVE, "nullname" + newId()); + orgMemberService.addMember(org.getId(), user, MemberRole.MEMBER).block(TIMEOUT); + String outcome; + try { + outcome = "returned " + organizationService.findUserOrgs(user, null, PageRequest.of(0, 10)).collectList().block(TIMEOUT).size() + " orgs"; + } catch (RuntimeException e) { + outcome = "failed with " + e.getClass().getSimpleName(); + } + System.out.println("[OrganizationServiceImplMongoTest] findUserOrgs with a null name " + outcome); + } + + // ------------------------------------------------------------------ logo + + /** Catches: an uploaded logo not stored or not referenced; a rejected type changing anything. */ + @Test + void uploadLogoStoresTheAssetAndReferencesItAndRejectsOtherTypes() { + Organization org = saveOrg(ACTIVE, "logo"); + + assertThat(organizationService.uploadLogo(org.getId(), pngPart()).block(TIMEOUT)).isTrue(); + + String assetId = stored(org.getId()).getLogoAssetId(); + System.out.println("[OrganizationServiceImplMongoTest] logo asset " + assetId); + assertThat(assetId).isNotBlank(); + Asset asset = assetRepository.findById(assetId).block(TIMEOUT); + assertThat(asset.getData()).isEqualTo(LOGO_BYTES); + assertThat(asset.getContentType()).isEqualTo(MediaType.IMAGE_PNG_VALUE); + + assertThrows(RuntimeException.class, () -> organizationService.uploadLogo(org.getId(), svgPart()).block(TIMEOUT)); + assertThat(stored(org.getId()).getLogoAssetId()).as("a rejected upload leaves the logo alone").isEqualTo(assetId); + } + + /** + * Pins plan section 9 row "uploadLogo never removes the previous logo asset (prevAssetId read from a new empty + * Organization, OrganizationServiceImpl:221-223)": the previous asset id is read from an Organization created one line + * earlier, so it is always null; two uploads leave two assets stored and the first one orphaned. A fix (read the id from + * the stored org) changes this test on purpose. + */ + @Test + void aSecondLogoUploadLeavesTheFirstAssetStored_pinsTheSection9Row() { + Organization org = saveOrg(ACTIVE, "logo-twice"); + + organizationService.uploadLogo(org.getId(), pngPart()).block(TIMEOUT); + String first = stored(org.getId()).getLogoAssetId(); + organizationService.uploadLogo(org.getId(), pngPart()).block(TIMEOUT); + String second = stored(org.getId()).getLogoAssetId(); + + System.out.println("[OrganizationServiceImplMongoTest] PINNED first asset " + first + " still stored after the second " + second); + assertThat(second).isNotEqualTo(first); + assertThat(assetRepository.findById(first).blockOptional(TIMEOUT)).as("the first logo asset is orphaned, not removed").isPresent(); + assertThat(assetRepository.findById(second).blockOptional(TIMEOUT)).isPresent(); + } + + /** Catches: deleteLogo leaving the asset stored; the error cases mapping to the wrong key. */ + @Test + void deleteLogoRemovesTheAssetAndTheReferenceAndFailsWithoutALogo() { + Organization org = saveOrg(ACTIVE, "logo-delete"); + organizationService.uploadLogo(org.getId(), pngPart()).block(TIMEOUT); + String assetId = stored(org.getId()).getLogoAssetId(); + + assertThat(organizationService.deleteLogo(org.getId()).block(TIMEOUT)).isTrue(); + + assertThat(assetRepository.findById(assetId).blockOptional(TIMEOUT)).isEmpty(); + // The org's logoAssetId after the delete is deliberately NOT asserted: today it is still set (candidate L7, reported). + System.out.println("[OrganizationServiceImplMongoTest] after deleteLogo by id: asset present=" + + assetRepository.findById(assetId).blockOptional(TIMEOUT).isPresent() + " reference=" + stored(org.getId()).getLogoAssetId()); + + Organization withoutLogo = saveOrg(ACTIVE, "never-had-a-logo"); + BizException noLogo = assertThrows(BizException.class, () -> blockOrUnwrap(organizationService.deleteLogo(withoutLogo.getId()))); + assertThat(noLogo.getError()).isEqualTo(BizError.NO_RESOURCE_FOUND); + assertThat(noLogo.getMessageKey()).isEqualTo("ASSET_NOT_FOUND"); + assertThat(noLogo.getArgs()).containsExactly(""); + + Organization dangling = saveOrg(ACTIVE, "dangling"); + String missingAsset = newId(); + organizationService.update(dangling.getId(), Organization.builder().logoAssetId(missingAsset).build()).block(TIMEOUT); + BizException missing = assertThrows(BizException.class, () -> blockOrUnwrap(organizationService.deleteLogo(dangling.getId()))); + assertThat(missing.getMessageKey()).isEqualTo("ASSET_NOT_FOUND"); + assertThat(missing.getArgs()).containsExactly(missingAsset); + } + + private static T blockOrUnwrap(Mono mono) { + try { + return mono.block(TIMEOUT); + } catch (RuntimeException e) { + if (e instanceof BizException) { + throw e; + } + if (e.getCause() instanceof BizException biz) { + throw biz; + } + throw e; + } + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/UserStatusServiceImplMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/UserStatusServiceImplMongoTest.java new file mode 100644 index 0000000000..9e7fee873b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/UserStatusServiceImplMongoTest.java @@ -0,0 +1,87 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.user.constant.UserStatusType; +import org.lowcoder.domain.user.model.UserStatus; +import org.lowcoder.domain.user.service.UserStatusService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.test.context.ActiveProfiles; + +/** + * UserStatusServiceImpl against the MongoDB test container (unit U14, task L3-11b), shared {@code test} context, user + * ids generated per test. The returned booleans of the upserts are printed, not asserted (see the observation on + * MongoUpsertHelper.upsert reported with this task). + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +class UserStatusServiceImplMongoTest extends OrganizationMongoTestBase { + + @Autowired + private UserStatusService userStatusService; + + private List documentsOf(String userId) { + return mongo.find(Query.query(Criteria.where("_id").is(userId)), UserStatus.class).collectList().block(TIMEOUT); + } + + /** Catches: an unknown user failing or getting a status without his id; the defaults must be "not shown, not banned". */ + @Test + void anUnknownUserGetsADefaultStatusCarryingTheId() { + String user = newId(); + + UserStatus status = userStatusService.findByUserId(user).block(TIMEOUT); + + System.out.println("[UserStatusServiceImplMongoTest] default status of " + user + ": " + status.getStatusMap()); + assertThat(status.getId()).isEqualTo(user); + assertThat(status.hasShowNewUserGuidance()).isFalse(); + assertThat(status.isBanned()).isFalse(); + assertThat(status.getStatusMap()).containsOnly(Map.entry(UserStatusType.HAS_SHOW_NEW_USER_GUIDANCE.getValue(), false)); + assertThat(documentsOf(user)).as("reading a status does not create a document").isEmpty(); + } + + /** Catches: the guidance flag not stored, duplicate documents on a second call, or a stored ban being lost. */ + @Test + void markingTheGuidanceShownIsStoredOnceAndKeepsAnExistingBan() { + String fresh = newId(); + String banned = newId(); + mongo.save(UserStatus.builder().id(banned).banned(true).build()).block(TIMEOUT); + + Boolean first = userStatusService.markNewUserGuidanceShown(fresh).block(TIMEOUT); + Boolean second = userStatusService.markNewUserGuidanceShown(fresh).block(TIMEOUT); + Boolean onBanned = userStatusService.markNewUserGuidanceShown(banned).block(TIMEOUT); + + System.out.println("[UserStatusServiceImplMongoTest] returned: first=" + first + " second=" + second + " onBanned=" + onBanned); + assertThat(documentsOf(fresh)).hasSize(1); + assertThat(userStatusService.findByUserId(fresh).block(TIMEOUT).hasShowNewUserGuidance()).isTrue(); + UserStatus keptBan = userStatusService.findByUserId(banned).block(TIMEOUT); + assertThat(keptBan.hasShowNewUserGuidance()).isTrue(); + assertThat(keptBan.isBanned()).as("marking the guidance must not lift a ban").isTrue(); + assertThat(documentsOf(banned)).hasSize(1); + } + + /** Catches: a status written under the wrong key or overwriting the other keys of the map. */ + @Test + void markWritesOneKeyOfTheStatusMapAndKeepsTheOthers() { + String user = newId(); + + userStatusService.mark(user, UserStatusType.NON_DEV_POP_UP_FOR_OLD_USERS, true).block(TIMEOUT); + userStatusService.mark(user, UserStatusType.HAS_SHOW_NEW_USER_GUIDANCE, "seen").block(TIMEOUT); + userStatusService.mark(user, UserStatusType.NON_DEV_POP_UP_FOR_OLD_USERS, false).block(TIMEOUT); + + UserStatus status = userStatusService.findByUserId(user).block(TIMEOUT); + Map map = status.getStatusMap(); + System.out.println("[UserStatusServiceImplMongoTest] stored status map " + map); + assertThat(documentsOf(user)).hasSize(1); + assertThat(map).containsEntry(UserStatusType.NON_DEV_POP_UP_FOR_OLD_USERS.getValue(), false) + .containsEntry(UserStatusType.HAS_SHOW_NEW_USER_GUIDANCE.getValue(), "seen"); + assertThat(map).hasSize(2); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/UserStatusServiceImplUpsertPinMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/UserStatusServiceImplUpsertPinMongoTest.java new file mode 100644 index 0000000000..af776aa853 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/organization/UserStatusServiceImplUpsertPinMongoTest.java @@ -0,0 +1,49 @@ +package org.lowcoder.domain.organization; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.user.model.UserStatus; +import org.lowcoder.domain.user.service.UserStatusService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.test.context.ActiveProfiles; + +/** + * Pin of the section 9 row found by L3-11b on MongoUpsertHelper.upsert (follow-up). Shared {@code test} context, user id + * generated per test. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +class UserStatusServiceImplUpsertPinMongoTest extends OrganizationMongoTestBase { + + @Autowired + private UserStatusService userStatusService; + + /** + * Pins plan section 9 row "MongoUpsertHelper.upsert answers false for an insert; markNewUserGuidanceShown returns false + * to the client": upsert (MongoUpsertHelper:136) answers {@code getModifiedCount() > 0}, which is 0 for an insert and for + * an update that changes nothing, so the first call and a repeat answer false although the status is stored; + * UserController.newUserGuidanceShown (:176-180) returns that boolean. A fix (also count an upserted id) changes this test + * on purpose. + */ + @Test + void markNewUserGuidanceShownAnswersFalseForTheInsert_pinsTheSection9Row() { + String user = newId(); + + Boolean first = userStatusService.markNewUserGuidanceShown(user).block(TIMEOUT); + Boolean repeat = userStatusService.markNewUserGuidanceShown(user).block(TIMEOUT); + + List stored = mongo.find(Query.query(Criteria.where("_id").is(user)), UserStatus.class).collectList().block(TIMEOUT); + System.out.println("[UserStatusServiceImplUpsertPinMongoTest] PINNED first=" + first + " repeat=" + repeat + " stored=" + stored.size()); + assertThat(stored).hasSize(1); + assertThat(stored.get(0).hasShowNewUserGuidance()).isTrue(); + assertThat(first).isFalse(); + assertThat(repeat).isFalse(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/permission/ResourcePermissionRepositoryImplDuplicateKeyMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/permission/ResourcePermissionRepositoryImplDuplicateKeyMongoTest.java new file mode 100644 index 0000000000..18ba68f919 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/permission/ResourcePermissionRepositoryImplDuplicateKeyMongoTest.java @@ -0,0 +1,67 @@ +package org.lowcoder.domain.permission; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.permission.model.ResourceHolder; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.domain.permission.service.impl.ResourcePermissionRepositoryImpl; +import org.lowcoder.infra.birelation.BiRelation; +import org.lowcoder.sdk.util.IDUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +/** + * addPermission when the unique BiRelation index rejects the insert (unit U14, task L3-11c, candidate D6). The test profile does + * not run the Mongock changelog, so this class (own Spring context, hence its own database) creates the index the production + * changelog defines (DatabaseChangelog: bizType, sourceId, targetId, unique, "biztype_sourceid_targetid_uniq") in that database + * only. Behaviour, not a defect row: addPermission maps every error to false (onErrorResume), so a caller cannot tell "already + * granted" from a database failure, and the existing permission, including its role, is left unchanged. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +@TestPropertySource(properties = "l3_11c.context=resource-permission-duplicate-key") +class ResourcePermissionRepositoryImplDuplicateKeyMongoTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + private static final String INDEX_NAME = "biztype_sourceid_targetid_uniq"; + + @Autowired + private ResourcePermissionRepositoryImpl repository; + @Autowired + private ReactiveMongoTemplate mongo; + + @BeforeEach + void theUniqueIndexOfTheProductionChangelogExistsInThisDatabase() { + mongo.indexOps(BiRelation.class).ensureIndex(new Index() + .on("bizType", Sort.Direction.ASC).on("sourceId", Sort.Direction.ASC).on("targetId", Sort.Direction.ASC) + .unique().named(INDEX_NAME)).block(TIMEOUT); + } + + /** Catches: the error no longer being mapped to false (a fix would change this on purpose), or the first grant being altered. */ + @Test + void grantingTheSameHolderTwiceAnswersFalseAndKeepsTheFirstRole() { + String resourceId = IDUtils.generate(); + String userId = IDUtils.generate(); + + boolean first = repository.addPermission(ResourceType.APPLICATION, resourceId, ResourceHolder.USER, userId, ResourceRole.VIEWER).block(TIMEOUT); + boolean second = repository.addPermission(ResourceType.APPLICATION, resourceId, ResourceHolder.USER, userId, ResourceRole.OWNER).block(TIMEOUT); + + System.out.println("[ResourcePermissionRepositoryImplDuplicateKeyMongoTest] first=" + first + " duplicate=" + second); + assertThat(first).isTrue(); + assertThat(second).as("the duplicate key error is swallowed and reported as false").isFalse(); + assertThat(repository.getByResourceTypeAndResourceId(ResourceType.APPLICATION, resourceId).block(TIMEOUT)) + .extracting(ResourcePermission::getResourceRole).containsExactly(ResourceRole.VIEWER); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/permission/ResourcePermissionRepositoryImplMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/permission/ResourcePermissionRepositoryImplMongoTest.java new file mode 100644 index 0000000000..733f5e11d6 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/permission/ResourcePermissionRepositoryImplMongoTest.java @@ -0,0 +1,164 @@ +package org.lowcoder.domain.permission; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.domain.permission.model.ResourceHolder; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceType; +import org.lowcoder.domain.permission.service.impl.ResourcePermissionRepositoryImpl; +import org.lowcoder.sdk.util.IDUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * ResourcePermissionRepositoryImpl against the MongoDB test container (unit U14, task L3-11c). Every test uses resource, + * user and group ids it generates. Not repeated: the removeBiRelationById row (L4-8), the privilege-escalation rows (L1). + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +class ResourcePermissionRepositoryImplMongoTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + @Autowired + private ResourcePermissionRepositoryImpl repository; + + private static String id() { + return IDUtils.generate(); + } + + /** Catches: a lost field in the BiRelation mapping, addPermission reporting failure, the lookup key mixed up. */ + @Test + void addPermissionThenReadItBackByResourceAndByHolder() { + String resourceId = id(); + String userId = id(); + + assertThat(repository.addPermission(ResourceType.APPLICATION, resourceId, ResourceHolder.USER, userId, ResourceRole.EDITOR) + .block(TIMEOUT)).isTrue(); + + List found = repository.getByResourceTypeAndResourceId(ResourceType.APPLICATION, resourceId) + .block(TIMEOUT); + System.out.println("[ResourcePermissionRepositoryImplMongoTest] permissions on " + resourceId + ": " + found.size()); + assertThat(found).hasSize(1); + ResourcePermission permission = found.get(0); + assertThat(permission.getResourceType()).isEqualTo(ResourceType.APPLICATION); + assertThat(permission.getResourceId()).isEqualTo(resourceId); + assertThat(permission.getResourceHolder()).isEqualTo(ResourceHolder.USER); + assertThat(permission.getResourceHolderId()).isEqualTo(userId); + assertThat(permission.getResourceRole()).isEqualTo(ResourceRole.EDITOR); + assertThat(permission.getId()).isNotBlank(); + + ResourcePermission byTarget = repository.getByResourceTypeAndResourceIdAndTargetId( + ResourceType.APPLICATION, resourceId, ResourceHolder.USER, userId).block(TIMEOUT); + assertThat(byTarget.getId()).isEqualTo(permission.getId()); + assertThat(repository.getById(permission.getId()).block(TIMEOUT).getResourceRole()).isEqualTo(ResourceRole.EDITOR); + assertThat(repository.getByResourceTypeAndResourceIdAndTargetId(ResourceType.APPLICATION, resourceId, ResourceHolder.GROUP, userId) + .blockOptional(TIMEOUT)).as("holder kind is part of the key").isEmpty(); + assertThat(repository.getByResourceTypeAndResourceIdAndTargetId(ResourceType.DATASOURCE, resourceId, ResourceHolder.USER, userId) + .blockOptional(TIMEOUT)).as("resource type is part of the key").isEmpty(); + assertThat(repository.getById(id()).blockOptional(TIMEOUT)).isEmpty(); + } + + /** Catches: the multimap keyed wrongly, ids of one resource leaking into another, unknown ids missing from or invented in the map. */ + @Test + void getByResourceTypeAndResourceIdsGroupsThePermissionsPerResource() { + String r1 = id(); + String r2 = id(); + String unknown = id(); + String u1 = id(); + String u2 = id(); + repository.addPermission(ResourceType.DATASOURCE, r1, ResourceHolder.USER, u1, ResourceRole.OWNER).block(TIMEOUT); + repository.addPermission(ResourceType.DATASOURCE, r1, ResourceHolder.USER, u2, ResourceRole.VIEWER).block(TIMEOUT); + repository.addPermission(ResourceType.DATASOURCE, r2, ResourceHolder.USER, u1, ResourceRole.EDITOR).block(TIMEOUT); + repository.addPermission(ResourceType.APPLICATION, r1, ResourceHolder.USER, u2, ResourceRole.OWNER).block(TIMEOUT); + + Map> map = repository + .getByResourceTypeAndResourceIds(ResourceType.DATASOURCE, List.of(r1, r2, unknown)).block(TIMEOUT); + + System.out.println("[ResourcePermissionRepositoryImplMongoTest] grouped keys " + map.keySet()); + assertThat(map.keySet()).containsExactlyInAnyOrder(r1, r2); + assertThat(map.get(r1)).extracting(ResourcePermission::getResourceHolderId).containsExactlyInAnyOrder(u1, u2); + assertThat(map.get(r1)).extracting(ResourcePermission::getResourceRole) + .containsExactlyInAnyOrder(ResourceRole.OWNER, ResourceRole.VIEWER); + assertThat(map.get(r2)).extracting(ResourcePermission::getResourceRole).containsExactly(ResourceRole.EDITOR); + assertThat(repository.getByResourceTypeAndResourceIds(ResourceType.DATASOURCE, List.of(unknown)).block(TIMEOUT)).isEmpty(); + } + + /** Catches: insertBatchPermission dropping entries or writing a wrong holder kind / role. */ + @Test + void insertBatchPermissionStoresEveryEntryAndAnEmptyBatchStoresNothing() { + String resourceId = id(); + String user = id(); + String group = id(); + + Multimap holders = ArrayListMultimap.create(); + holders.put(ResourceHolder.USER, user); + holders.put(ResourceHolder.GROUP, group); + + repository.insertBatchPermission(ResourceType.APPLICATION, resourceId, holders, ResourceRole.VIEWER).block(TIMEOUT); + + List found = repository.getByResourceTypeAndResourceId(ResourceType.APPLICATION, resourceId).block(TIMEOUT); + assertThat(found).hasSize(2); + assertThat(found).extracting(ResourcePermission::getResourceHolder, ResourcePermission::getResourceHolderId, ResourcePermission::getResourceRole) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple(ResourceHolder.USER, user, ResourceRole.VIEWER), + org.assertj.core.groups.Tuple.tuple(ResourceHolder.GROUP, group, ResourceRole.VIEWER)); + String emptyBatchResource = id(); + repository.insertBatchPermission(ResourceType.APPLICATION, emptyBatchResource, ArrayListMultimap.create(), ResourceRole.VIEWER).block(TIMEOUT); + assertThat(repository.getByResourceTypeAndResourceId(ResourceType.APPLICATION, emptyBatchResource).block(TIMEOUT)).isEmpty(); + } + + /** Catches: the role update touching other rows or other fields, a missing id reported as updated. */ + @Test + void updatePermissionRoleByIdChangesOnlyThatRole() { + String resourceId = id(); + String u1 = id(); + String u2 = id(); + repository.addPermission(ResourceType.APPLICATION, resourceId, ResourceHolder.USER, u1, ResourceRole.VIEWER).block(TIMEOUT); + repository.addPermission(ResourceType.APPLICATION, resourceId, ResourceHolder.USER, u2, ResourceRole.VIEWER).block(TIMEOUT); + ResourcePermission first = repository.getByResourceTypeAndResourceIdAndTargetId( + ResourceType.APPLICATION, resourceId, ResourceHolder.USER, u1).block(TIMEOUT); + + assertThat(repository.updatePermissionRoleById(first.getId(), ResourceRole.OWNER).block(TIMEOUT)).isTrue(); + + ResourcePermission updated = repository.getById(first.getId()).block(TIMEOUT); + assertThat(updated.getResourceRole()).isEqualTo(ResourceRole.OWNER); + assertThat(updated.getResourceHolderId()).isEqualTo(u1); + assertThat(updated.getResourceId()).isEqualTo(resourceId); + ResourcePermission other = repository.getByResourceTypeAndResourceIdAndTargetId( + ResourceType.APPLICATION, resourceId, ResourceHolder.USER, u2).block(TIMEOUT); + assertThat(other.getResourceRole()).isEqualTo(ResourceRole.VIEWER); + assertThat(repository.updatePermissionRoleById(id(), ResourceRole.OWNER).block(TIMEOUT)).isFalse(); + } + + /** Catches: the removal by id or by (resource, holder) taking neighbouring permissions with it, or removing nothing. */ + @Test + void removePermissionByIdAndByResourceAndHolderRemoveOnlyTheirRow() { + String resourceId = id(); + String u1 = id(); + String u2 = id(); + String u3 = id(); + for (String user : List.of(u1, u2, u3)) { + repository.addPermission(ResourceType.APPLICATION, resourceId, ResourceHolder.USER, user, ResourceRole.VIEWER).block(TIMEOUT); + } + ResourcePermission p1 = repository.getByResourceTypeAndResourceIdAndTargetId( + ResourceType.APPLICATION, resourceId, ResourceHolder.USER, u1).block(TIMEOUT); + + assertThat(repository.removePermissionById(p1.getId()).block(TIMEOUT)).isTrue(); + assertThat(repository.removePermissionBy(ResourceType.APPLICATION, resourceId, ResourceHolder.USER, u2).block(TIMEOUT)).isTrue(); + + List left = repository.getByResourceTypeAndResourceId(ResourceType.APPLICATION, resourceId).block(TIMEOUT); + System.out.println("[ResourcePermissionRepositoryImplMongoTest] left after two removals: " + left.size()); + assertThat(left).extracting(ResourcePermission::getResourceHolderId).containsExactly(u3); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryMongoTestBase.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryMongoTestBase.java new file mode 100644 index 0000000000..6ee236133d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryMongoTestBase.java @@ -0,0 +1,69 @@ +package org.lowcoder.domain.query; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import org.lowcoder.domain.query.model.LibraryQuery; +import org.lowcoder.domain.query.model.LibraryQueryRecord; +import org.lowcoder.domain.query.repository.LibraryQueryRecordRepository; +import org.lowcoder.domain.query.service.LibraryQueryRecordService; +import org.lowcoder.domain.query.service.LibraryQueryService; +import org.lowcoder.sdk.util.IDUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import org.lowcoder.api.ServerApplication; + +/** + * Shared helpers of the library query tests on the MongoDB test container (task L3-11d). Every test works under ids, org ids + * and names it generates, so the shared database needs no cleanup. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("test") +abstract class LibraryQueryMongoTestBase { + + protected static final Duration TIMEOUT = Duration.ofSeconds(30); + protected static final String COMP_TYPE = "js"; + + @Autowired + protected LibraryQueryService libraryQueryService; + @Autowired + protected LibraryQueryRecordService libraryQueryRecordService; + @Autowired + protected LibraryQueryRecordRepository libraryQueryRecordRepository; + + protected static String newId() { + return IDUtils.generate(); + } + + /** A DSL whose "query" is read as a BaseQuery (LibraryQuery.java:33): the compType identifies which DSL was returned. */ + protected static Map dsl(String compType) { + Map query = new HashMap<>(); + query.put("compType", compType); + query.put("datasourceId", "ds-" + newId()); + Map dsl = new HashMap<>(); + dsl.put("query", query); + return dsl; + } + + protected LibraryQuery insertQuery(String orgId, String name, String compType) { + LibraryQuery query = LibraryQuery.builder().organizationId(orgId).name(name).libraryQueryDSL(dsl(compType)).build(); + return libraryQueryService.insert(query).block(TIMEOUT); + } + + protected LibraryQuery insertQuery(String orgId) { + return insertQuery(orgId, "lq-" + newId(), COMP_TYPE); + } + + /** A record with an explicit id and creation time (an id makes it not-new, so auditing keeps the given createdAt). */ + protected LibraryQueryRecord insertRecord(String libraryQueryId, String tag, String compType, Instant createdAt) { + LibraryQueryRecord record = LibraryQueryRecord.builder().libraryQueryId(libraryQueryId).tag(tag) + .commitMessage("commit " + tag).libraryQueryDSL(dsl(compType)).build(); + record.setId(newId()); + record.setCreatedAt(createdAt); + return libraryQueryRecordService.insert(record).block(TIMEOUT); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryRecordServiceImplMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryRecordServiceImplMongoTest.java new file mode 100644 index 0000000000..b1ae555e22 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryRecordServiceImplMongoTest.java @@ -0,0 +1,83 @@ +package org.lowcoder.domain.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Instant; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.query.model.LibraryQueryRecord; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; + +/** + * LibraryQueryRecordServiceImpl against the MongoDB test container (unit U14, task L3-11d). getByLibraryQueryIdIn is not + * tested: no caller in main code (owner's list of unreferenced code). + */ +class LibraryQueryRecordServiceImplMongoTest extends LibraryQueryMongoTestBase { + + private static final Instant T0 = Instant.parse("2026-01-01T00:00:00Z"); + + /** Catches: a lost field on insert, an unknown id not failing with LIBRARY_QUERY_NOT_FOUND. */ + @Test + void insertThenGetByIdRoundTripsTheFieldsAndAnUnknownIdFails() { + String queryId = newId(); + LibraryQueryRecord saved = insertRecord(queryId, "v1", "js", T0); + + LibraryQueryRecord read = libraryQueryRecordService.getById(saved.getId()).block(TIMEOUT); + + System.out.println("[LibraryQueryRecordServiceImplMongoTest] record " + read.getId() + " tag " + read.getTag()); + assertThat(read.getLibraryQueryId()).isEqualTo(queryId); + assertThat(read.getTag()).isEqualTo("v1"); + assertThat(read.getCommitMessage()).isEqualTo("commit v1"); + assertThat(read.getCreateTime()).isEqualTo(T0.toEpochMilli()); + assertThat(read.getQuery().getCompType()).isEqualTo("js"); + BizException failure = assertThrows(BizException.class, () -> libraryQueryRecordService.getById(newId()).block(TIMEOUT)); + assertThat(failure.getError()).isEqualTo(BizError.LIBRARY_QUERY_NOT_FOUND); + } + + /** Catches: the order not newest first, other queries' records mixed in, an unknown query not answering an empty list. */ + @Test + void getByLibraryQueryIdAnswersThePublishedVersionsNewestFirstForThatQueryOnly() { + String queryId = newId(); + insertRecord(queryId, "v2", "js", T0.plusSeconds(10)); + insertRecord(queryId, "v1", "js", T0); + insertRecord(queryId, "v3", "js", T0.plusSeconds(20)); + insertRecord(newId(), "other", "js", T0.plusSeconds(30)); + + assertThat(libraryQueryRecordService.getByLibraryQueryId(queryId).block(TIMEOUT)) + .extracting(LibraryQueryRecord::getTag).containsExactly("v3", "v2", "v1"); + assertThat(libraryQueryRecordService.getByLibraryQueryId(newId()).block(TIMEOUT)).isEmpty(); + } + + /** Catches: the oldest or another query's record answered as the latest, a query without records not completing empty. */ + @Test + void theLatestRecordIsTheNewestOfThatQueryAndEmptyWhenThereIsNone() { + String queryId = newId(); + insertRecord(queryId, "v1", "js", T0); + insertRecord(queryId, "v2", "js", T0.plusSeconds(10)); + insertRecord(newId(), "other", "js", T0.plusSeconds(99)); + + assertThat(libraryQueryRecordService.getLatestRecordByLibraryQueryId(queryId).block(TIMEOUT).getTag()).isEqualTo("v2"); + assertThat(libraryQueryRecordService.getLatestRecordByLibraryQueryId(newId()).blockOptional(TIMEOUT)).isEmpty(); + } + + /** Catches: the by-query delete reporting a wrong count or touching other queries, deleteById removing more than one record. */ + @Test + void deleteAllByLibraryQueryIdAnswersTheCountAndDeleteByIdRemovesOneRecord() { + String queryId = newId(); + String otherQueryId = newId(); + LibraryQueryRecord first = insertRecord(queryId, "v1", "js", T0); + insertRecord(queryId, "v2", "js", T0.plusSeconds(1)); + insertRecord(otherQueryId, "o1", "js", T0); + insertRecord(otherQueryId, "o2", "js", T0.plusSeconds(1)); + + libraryQueryRecordService.deleteById(first.getId()).block(TIMEOUT); + assertThat(libraryQueryRecordService.getByLibraryQueryId(queryId).block(TIMEOUT)).extracting(LibraryQueryRecord::getTag).containsExactly("v2"); + + assertThat(libraryQueryRecordService.deleteAllLibraryQueryTagByLibraryQueryId(queryId).block(TIMEOUT)).isEqualTo(1L); + assertThat(libraryQueryRecordService.getByLibraryQueryId(queryId).block(TIMEOUT)).isEmpty(); + assertThat(libraryQueryRecordService.getByLibraryQueryId(otherQueryId).block(TIMEOUT)).hasSize(2); + assertThat(libraryQueryRecordService.deleteAllLibraryQueryTagByLibraryQueryId(newId()).block(TIMEOUT)).isZero(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryServiceImplGetByIdsPinMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryServiceImplGetByIdsPinMongoTest.java new file mode 100644 index 0000000000..093d79790f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryServiceImplGetByIdsPinMongoTest.java @@ -0,0 +1,32 @@ +package org.lowcoder.domain.query; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.query.model.LibraryQuery; + +/** + * Pins plan section 9 row "LibraryQueryServiceImpl.getByIds picks id or gid from one element and drops the other kind" + * (task L3-11d, same shape as the L3-11c DatasourceRepository.findByIds row). The key kind of the whole list is decided by + * its first element (LibraryQueryServiceImpl.getByIds, findFirst), so a mixed list only returns the queries of that element's + * kind. Reach: MetaController.java:53 passes the client's libraryQueryIds list unchanged. A fix (query both kinds) changes + * this test on purpose. + */ +class LibraryQueryServiceImplGetByIdsPinMongoTest extends LibraryQueryMongoTestBase { + + @Test + void getByIdsOfAMixedListDropsTheOtherKind_pinsTheSection9Row() { + String orgId = newId(); + LibraryQuery a = insertQuery(orgId); + LibraryQuery b = insertQuery(orgId); + + List idFirst = libraryQueryService.getByIds(List.of(a.getId(), b.getGid())).collectList().block(TIMEOUT); + List gidFirst = libraryQueryService.getByIds(List.of(b.getGid(), a.getId())).collectList().block(TIMEOUT); + + System.out.println("[LibraryQueryServiceImplGetByIdsPinMongoTest] PINNED id first -> " + idFirst.size() + ", gid first -> " + gidFirst.size()); + assertThat(idFirst).extracting(LibraryQuery::getId).containsExactly(a.getId()); + assertThat(gidFirst).extracting(LibraryQuery::getId).containsExactly(b.getId()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryServiceImplGetByNamePinMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryServiceImplGetByNamePinMongoTest.java new file mode 100644 index 0000000000..cd71a9815a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryServiceImplGetByNamePinMongoTest.java @@ -0,0 +1,34 @@ +package org.lowcoder.domain.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.query.model.LibraryQuery; +import org.springframework.dao.IncorrectResultSizeDataAccessException; + +/** + * Pins plan section 9 row "LibraryQueryServiceImpl.getByName is a single-result finder across all orgs: once two orgs have a + * library query of the same name, by-name execution fails for both (IncorrectResultSizeDataAccessException; caller + * LibraryQueryApiServiceImpl:277)" (task L3-11d). Related to, but separate from, the section 9 tenant-isolation row (L1-7): + * that row is about the lookup answering another org's query; this one is a functional failure, the lookup answers nothing + * for anyone. The lookup is the Mono finder LibraryQueryRepository.findByName, with no org parameter. A fix (scope by org) + * changes this test on purpose. + */ +class LibraryQueryServiceImplGetByNamePinMongoTest extends LibraryQueryMongoTestBase { + + @Test + void getByNameFailsForBothOrgsOnceTwoOrgsUseTheSameName_pinsTheSection9Row() { + String name = "lq-same-" + newId(); + LibraryQuery inFirstOrg = insertQuery(newId(), name, COMP_TYPE); + assertThat(libraryQueryService.getByName(name).block(TIMEOUT).getId()).as("unique so far").isEqualTo(inFirstOrg.getId()); + + insertQuery(newId(), name, COMP_TYPE); + + IncorrectResultSizeDataAccessException failure = assertThrows(IncorrectResultSizeDataAccessException.class, + () -> libraryQueryService.getByName(name).block(TIMEOUT)); + System.out.println("[LibraryQueryServiceImplGetByNamePinMongoTest] PINNED second org's same name: " + failure.getMessage()); + assertThat(libraryQueryService.getByOrganizationId(inFirstOrg.getOrganizationId()).collectList().block(TIMEOUT)) + .as("the first org's own query is still there").hasSize(1); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryServiceImplMongoTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryServiceImplMongoTest.java new file mode 100644 index 0000000000..5831582d17 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/domain/query/LibraryQueryServiceImplMongoTest.java @@ -0,0 +1,170 @@ +package org.lowcoder.domain.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.lowcoder.domain.query.model.BaseQuery; +import org.lowcoder.domain.query.model.LibraryQuery; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; + +/** + * LibraryQueryServiceImpl against the MongoDB test container (unit U14, task L3-11d). Not repeated: the L1-7 tests of + * LibraryQueryApiServiceImpl (Mockito) and the section 9 tenant-isolation row. The two defect pins have classes of their own + * (LibraryQueryServiceImplGetByIdsPinMongoTest, LibraryQueryServiceImplGetByNamePinMongoTest). + */ +class LibraryQueryServiceImplMongoTest extends LibraryQueryMongoTestBase { + + private static BizException failure(Runnable call) { + return assertThrows(BizException.class, call::run); + } + + /** Catches: no gid generated, a given gid replaced, a lost field. */ + @Test + void insertGeneratesAGidWhenNoneIsGivenKeepsAGivenOneAndRoundTripsTheFields() { + String orgId = newId(); + LibraryQuery generated = insertQuery(orgId, "lq-" + newId(), COMP_TYPE); + String explicitGid = "fixed-" + newId(); + LibraryQuery explicit = libraryQueryService.insert(LibraryQuery.builder().organizationId(orgId).name("lq-" + newId()) + .gid(explicitGid).libraryQueryDSL(dsl(COMP_TYPE)).build()).block(TIMEOUT); + + System.out.println("[LibraryQueryServiceImplMongoTest] generated gid " + generated.getGid()); + assertThat(generated.getId()).isNotBlank(); + assertThat(generated.getGid()).contains("-").hasSize(36); + assertThat(explicit.getGid()).isEqualTo(explicitGid); + LibraryQuery read = libraryQueryService.getById(generated.getId()).block(TIMEOUT); + assertThat(read.getOrganizationId()).isEqualTo(orgId); + assertThat(read.getName()).isEqualTo(generated.getName()); + assertThat(read.getGid()).isEqualTo(generated.getGid()); + assertThat(read.getQuery().getCompType()).isEqualTo(COMP_TYPE); + assertThat(read.getCreatedAt()).isNotNull(); + } + + /** Catches: the gid branch missing, an unknown key not failing with LIBRARY_QUERY_NOT_FOUND. */ + @Test + void getByIdAnswersByIdAndByGidAndFailsForAnUnknownKey() { + LibraryQuery saved = insertQuery(newId()); + + assertThat(libraryQueryService.getById(saved.getId()).block(TIMEOUT).getId()).isEqualTo(saved.getId()); + assertThat(libraryQueryService.getById(saved.getGid()).block(TIMEOUT).getId()).isEqualTo(saved.getId()); + assertThat(failure(() -> libraryQueryService.getById(newId()).block(TIMEOUT)).getError()).isEqualTo(BizError.LIBRARY_QUERY_NOT_FOUND); + assertThat(failure(() -> libraryQueryService.getById("no-such-gid").block(TIMEOUT)).getError()).isEqualTo(BizError.LIBRARY_QUERY_NOT_FOUND); + } + + /** Catches: getByIds querying the wrong key type for a homogeneous list, an empty list failing. */ + @Test + void getByIdsAnswersHomogeneousIdOrGidListsAndNothingForAnEmptyList() { + String orgId = newId(); + LibraryQuery a = insertQuery(orgId); + LibraryQuery b = insertQuery(orgId); + insertQuery(orgId); + + assertThat(libraryQueryService.getByIds(List.of(a.getId(), b.getId())).collectList().block(TIMEOUT)) + .extracting(LibraryQuery::getId).containsExactlyInAnyOrder(a.getId(), b.getId()); + assertThat(libraryQueryService.getByIds(List.of(a.getGid(), b.getGid())).collectList().block(TIMEOUT)) + .extracting(LibraryQuery::getId).containsExactlyInAnyOrder(a.getId(), b.getId()); + assertThat(libraryQueryService.getByIds(List.of()).collectList().block(TIMEOUT)).isEmpty(); + } + + /** Catches: a cross-org leak in the organisation listing. */ + @Test + void getByOrganizationIdIsScopedToTheOrg() { + String orgId = newId(); + LibraryQuery a = insertQuery(orgId); + LibraryQuery b = insertQuery(orgId); + insertQuery(newId()); + + assertThat(libraryQueryService.getByOrganizationId(orgId).collectList().block(TIMEOUT)) + .extracting(LibraryQuery::getId).containsExactlyInAnyOrder(a.getId(), b.getId()); + assertThat(libraryQueryService.getByOrganizationId(newId()).collectList().block(TIMEOUT)).isEmpty(); + } + + /** + * Catches: the name lookup failing for a unique name, an unknown name not failing with LIBRARY_QUERY_NOT_FOUND. The lookup + * has no org parameter (section 9 tenant-isolation row, L1-7): the query of another org is answered too. Two orgs with the + * same name is the next class's pin. + */ + @Test + void getByNameAnswersTheQueryWithThatNameWhicheverOrgOwnsItAndFailsForAnUnknownName() { + String name = "lq-" + newId(); + LibraryQuery saved = insertQuery(newId(), name, COMP_TYPE); + + assertThat(libraryQueryService.getByName(name).block(TIMEOUT).getId()).isEqualTo(saved.getId()); + assertThat(failure(() -> libraryQueryService.getByName("lq-" + newId()).block(TIMEOUT)).getError()) + .isEqualTo(BizError.LIBRARY_QUERY_NOT_FOUND); + } + + /** Catches: update overwriting fields that were not given, an unknown id answered true. */ + @Test + void updateChangesOnlyTheGivenFieldsAndAnUnknownIdAnswersFalse() { + LibraryQuery saved = insertQuery(newId()); + String newName = "renamed-" + newId(); + + boolean updated = libraryQueryService.update(saved.getId(), LibraryQuery.builder().name(newName).build()).block(TIMEOUT); + + assertThat(updated).isTrue(); + LibraryQuery read = libraryQueryService.getById(saved.getId()).block(TIMEOUT); + assertThat(read.getName()).isEqualTo(newName); + assertThat(read.getOrganizationId()).isEqualTo(saved.getOrganizationId()); + assertThat(read.getGid()).isEqualTo(saved.getGid()); + assertThat(read.getQuery().getCompType()).isEqualTo(COMP_TYPE); + assertThat(libraryQueryService.update(newId(), LibraryQuery.builder().name("x").build()).block(TIMEOUT)).isFalse(); + } + + /** Catches: delete by id or by gid removing the neighbours, or nothing. Observed: the query's records stay (the API layer removes them, LibraryQueryApiServiceImpl:144-145). */ + @Test + void deleteByIdAndByGidRemoveOnlyThatQueryAndLeaveItsRecords() { + String orgId = newId(); + LibraryQuery byId = insertQuery(orgId); + LibraryQuery byGid = insertQuery(orgId); + LibraryQuery kept = insertQuery(orgId); + insertRecord(byId.getId(), "v1", COMP_TYPE, Instant.now()); + + libraryQueryService.delete(byId.getId()).block(TIMEOUT); + libraryQueryService.delete(byGid.getGid()).block(TIMEOUT); + + assertThat(libraryQueryService.getByOrganizationId(orgId).collectList().block(TIMEOUT)) + .extracting(LibraryQuery::getId).containsExactly(kept.getId()); + assertThat(libraryQueryRecordService.getByLibraryQueryId(byId.getId()).block(TIMEOUT)).hasSize(1); + } + + /** Catches: the editing query not read from the library query, a missing query not failing. */ + @Test + void theEditingBaseQueryIsTheLibraryQueriesOwnAndFailsForAnUnknownId() { + LibraryQuery saved = insertQuery(newId(), "lq-" + newId(), "editing"); + + BaseQuery editing = libraryQueryService.getEditingBaseQueryByLibraryQueryId(saved.getId()).block(TIMEOUT); + + assertThat(editing.getCompType()).isEqualTo("editing"); + assertThat(failure(() -> libraryQueryService.getEditingBaseQueryByLibraryQueryId(newId()).block(TIMEOUT)).getError()) + .isEqualTo(BizError.LIBRARY_QUERY_NOT_FOUND); + } + + /** Catches: live reading the editing version when records exist, not falling back when none, the newest record not chosen. */ + @Test + void theLiveQueryAndDslAreTheLatestRecordsAndFallBackToTheEditingOneWithoutRecords() { + LibraryQuery withRecords = insertQuery(newId(), "lq-" + newId(), "editing"); + LibraryQuery withoutRecords = insertQuery(newId(), "lq-" + newId(), "editing"); + Instant now = Instant.parse("2026-01-01T00:00:00Z"); + insertRecord(withRecords.getId(), "v1", "old", now); + insertRecord(withRecords.getId(), "v3", "newest", now.plusSeconds(20)); + insertRecord(withRecords.getId(), "v2", "middle", now.plusSeconds(10)); + + assertThat(libraryQueryService.getLiveBaseQueryByLibraryQueryId(withRecords.getId()).block(TIMEOUT).getCompType()).isEqualTo("newest"); + assertThat(libraryQueryService.getEditingBaseQueryByLibraryQueryId(withRecords.getId()).block(TIMEOUT).getCompType()) + .as("the editing query is not the published one").isEqualTo("editing"); + assertThat(libraryQueryService.getLiveBaseQueryByLibraryQueryId(withoutRecords.getId()).block(TIMEOUT).getCompType()).isEqualTo("editing"); + assertThat(libraryQueryService.getLiveDSLByLibraryQueryId(withRecords.getId()).block(TIMEOUT)) + .extracting(dsl -> ((java.util.Map) dsl.get("query")).get("compType")).isEqualTo("newest"); + assertThat(libraryQueryService.getLiveDSLByLibraryQueryId(withoutRecords.getId()).block(TIMEOUT)) + .extracting(dsl -> ((java.util.Map) dsl.get("query")).get("compType")).isEqualTo("editing"); + assertThat(failure(() -> libraryQueryService.getLiveBaseQueryByLibraryQueryId(newId()).block(TIMEOUT)).getError()) + .isEqualTo(BizError.LIBRARY_QUERY_NOT_FOUND); + assertThat(failure(() -> libraryQueryService.getLiveDSLByLibraryQueryId(newId()).block(TIMEOUT)).getError()) + .isEqualTo(BizError.LIBRARY_QUERY_NOT_FOUND); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/infra/InfraMongoTestConfiguration.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/infra/InfraMongoTestConfiguration.java new file mode 100644 index 0000000000..e7a6f80355 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/infra/InfraMongoTestConfiguration.java @@ -0,0 +1,65 @@ +package org.lowcoder.infra; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.lowcoder.infra.birelation.BiRelationServiceImpl; +import org.lowcoder.infra.mongo.MongoUpsertHelper; +import org.lowcoder.sdk.event.BeforeSaveEvent; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.mongo.MongoReactiveAutoConfiguration; +import org.springframework.boot.autoconfigure.data.mongo.MongoReactiveDataAutoConfiguration; +import org.springframework.boot.autoconfigure.data.mongo.MongoReactiveRepositoriesAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.event.EventListener; +import org.springframework.data.mongodb.config.EnableReactiveMongoAuditing; +import org.springframework.data.domain.ReactiveAuditorAware; +import reactor.core.publisher.Mono; + +/** + * The narrow application context of the lowcoder-infra MongoDB tests: the reactive MongoDB auto-configuration, the + * repositories of {@code org.lowcoder.infra.birelation}, auditing, and the two beans under test. Used with the profile + * {@value #PROFILE}, so the context is built once for these tests and, through {@code TestContainersInitializer}, gets + * a database of its own on the shared container. + * + *

    Deliberately carries no stereotype annotation ({@code @Configuration}, {@code @TestConfiguration}): the server's + * {@code ComponentScanConfiguration} scans all of {@code org.lowcoder} without a type filter and would add every such + * class to the full-application tests. A class passed in {@code @SpringBootTest(classes=...)} is processed as a lite + * configuration (its {@code @Import}, {@code @Enable...} and {@code @Bean} methods), which is all this needs. + */ +@ImportAutoConfiguration({MongoReactiveAutoConfiguration.class, MongoReactiveDataAutoConfiguration.class, + MongoReactiveRepositoriesAutoConfiguration.class}) +@org.springframework.data.mongodb.repository.config.EnableReactiveMongoRepositories(basePackages = "org.lowcoder.infra.birelation") +@EnableReactiveMongoAuditing +@Import({MongoUpsertHelper.class, BiRelationServiceImpl.class}) +public class InfraMongoTestConfiguration { + + public static final String PROFILE = "infraMongo"; + public static final String AUDITOR = "auditor-1"; + + @Bean + ReactiveAuditorAware auditorProvider() { + return () -> Mono.just(AUDITOR); + } + + @Bean + BeforeSaveRecorder beforeSaveRecorder() { + return new BeforeSaveRecorder(); + } + + /** Collects the {@link BeforeSaveEvent}s that {@link MongoUpsertHelper#update} publishes. */ + public static class BeforeSaveRecorder { + + private final List> events = new CopyOnWriteArrayList<>(); + + @EventListener + void on(BeforeSaveEvent event) { + events.add(event); + } + + public List> events() { + return events; + } + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/infra/birelation/BiRelationServiceImplTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/infra/birelation/BiRelationServiceImplTest.java new file mode 100644 index 0000000000..2a24cffd13 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/infra/birelation/BiRelationServiceImplTest.java @@ -0,0 +1,259 @@ +package org.lowcoder.infra.birelation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.lowcoder.infra.InfraMongoTestConfiguration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +/** + * {@link BiRelationServiceImpl} against a real MongoDB (the context and database of {@link InfraMongoTestConfiguration}). + * Every test writes only rows whose source, target and relation values carry a random run prefix, and counts only by + * those values. + */ +@SpringBootTest(classes = InfraMongoTestConfiguration.class) +@ActiveProfiles(InfraMongoTestConfiguration.PROFILE) +class BiRelationServiceImplTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(20); + private static final BiRelationBizType BIZ_TYPE = BiRelationBizType.RESOURCE; + private static final BiRelationBizType OTHER_BIZ_TYPE = BiRelationBizType.GROUP_MEMBER; + + @Autowired + private BiRelationService service; + + private final String run = UUID.randomUUID().toString(); + + private String id(String name) { + return run + "-" + name; + } + + private BiRelation add(BiRelationBizType bizType, String source, String target, String relation) { + return service.addBiRelation(bizType, id(source), id(target), id(relation), "state").block(TIMEOUT); + } + + private static Set targets(List relations) { + return relations.stream().map(BiRelation::getTargetId).collect(Collectors.toSet()); + } + + @Test + void addBiRelationOverloadsStoreTheExtraParametersAndNullBecomesEmpty() { + BiRelation five = service.addBiRelation(BIZ_TYPE, id("s"), id("t5"), id("r"), "state").block(TIMEOUT); + BiRelation six = service.addBiRelation(BIZ_TYPE, id("s"), id("t6"), id("r"), "state", "e1").block(TIMEOUT); + BiRelation seven = service.addBiRelation(BIZ_TYPE, id("s"), id("t7"), id("r"), "state", "e1", "e2").block(TIMEOUT); + BiRelation eight = service.addBiRelation(BIZ_TYPE, id("s"), id("t8"), id("r"), "state", "e1", "e2", "e3").block(TIMEOUT); + + assertThat(List.of(five, six, seven, eight)).extracting(BiRelation::getExtParam1, BiRelation::getExtParam2, BiRelation::getExtParam3) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("", "", ""), + org.assertj.core.groups.Tuple.tuple("e1", "", ""), + org.assertj.core.groups.Tuple.tuple("e1", "e2", ""), + org.assertj.core.groups.Tuple.tuple("e1", "e2", "e3")); + BiRelation stored = service.getById(eight.getId()).block(TIMEOUT); + assertThat(stored.getBizType()).isEqualTo(BIZ_TYPE); + assertThat(stored.getSourceId()).isEqualTo(id("s")); + assertThat(stored.getTargetId()).isEqualTo(id("t8")); + assertThat(stored.getRelation()).isEqualTo(id("r")); + assertThat(stored.getState()).isEqualTo("state"); + assertThat(stored.getCreateTime()).isPositive(); + } + + @Test + void addBiRelationOfAModelAndBatchAddStoreTheGivenRows() { + BiRelation model = BiRelation.builder().bizType(BIZ_TYPE).sourceId(id("s")).targetId(id("t1")).relation(id("r")).state("state").build(); + BiRelation second = BiRelation.builder().bizType(BIZ_TYPE).sourceId(id("s")).targetId(id("t2")).relation(id("r")).state("state").build(); + BiRelation third = BiRelation.builder().bizType(BIZ_TYPE).sourceId(id("s")).targetId(id("t3")).relation(id("r")).state("state").build(); + + BiRelation saved = service.addBiRelation(model).block(TIMEOUT); + List batch = service.batchAddBiRelation(List.of(second, third)).block(TIMEOUT); + + assertThat(saved.getId()).isNotNull(); + assertThat(batch).hasSize(2).allSatisfy(row -> assertThat(row.getId()).isNotNull()); + assertThat(service.countBySourceId(BIZ_TYPE, id("s")).block(TIMEOUT)).isEqualTo(3); + } + + @Test + void removeBiRelationByIdDeletesTheRow() { + BiRelation saved = add(BIZ_TYPE, "s", "t", "r"); + + assertThat(service.removeBiRelationById(saved.getId()).block(TIMEOUT)).isTrue(); + assertThat(service.getById(saved.getId()).blockOptional(TIMEOUT)).isEmpty(); + } + + @Test + void upsertRejectsABlankSourceOrTargetOrANullBizTypeBeforeTouchingTheDatabase() { + BiRelation noSource = BiRelation.builder().bizType(BIZ_TYPE).sourceId(" ").targetId(id("t")).build(); + BiRelation noTarget = BiRelation.builder().bizType(BIZ_TYPE).sourceId(id("s")).targetId(null).build(); + BiRelation noBizType = BiRelation.builder().sourceId(id("s")).targetId(id("t")).build(); + + assertThatThrownBy(() -> service.upsert(noSource)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.upsert(noTarget)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.upsert(noBizType)).isInstanceOf(IllegalArgumentException.class); + assertThat(service.countBySourceId(BIZ_TYPE, id("s")).block(TIMEOUT)).isZero(); + } + + @Test + void upsertKeepsOneRowPerBizTypeSourceAndTarget() { + BiRelation first = BiRelation.builder().bizType(BIZ_TYPE).sourceId(id("s")).targetId(id("t")).relation(id("r1")).state("state").build(); + BiRelation same = BiRelation.builder().bizType(BIZ_TYPE).sourceId(id("s")).targetId(id("t")).relation(id("r1")).state("state").build(); + BiRelation changed = BiRelation.builder().bizType(BIZ_TYPE).sourceId(id("s")).targetId(id("t")).relation(id("r2")).state("state").build(); + BiRelation otherType = BiRelation.builder().bizType(OTHER_BIZ_TYPE).sourceId(id("s")).targetId(id("t")).relation(id("r3")).state("state").build(); + + Boolean inserted = service.upsert(first).block(TIMEOUT); + Boolean unchanged = service.upsert(same).block(TIMEOUT); + Boolean modified = service.upsert(changed).block(TIMEOUT); + service.upsert(otherType).block(TIMEOUT); + + assertThat(List.of(inserted, unchanged, modified)).containsExactly(false, false, true); + assertThat(service.getBySourceId(BIZ_TYPE, id("s")).collectList().block(TIMEOUT)).singleElement() + .satisfies(row -> assertThat(row.getRelation()).isEqualTo(id("r2"))); + assertThat(service.getBySourceId(OTHER_BIZ_TYPE, id("s")).collectList().block(TIMEOUT)).hasSize(1); + } + + @Test + void theFindersFilterByBizTypeSourceAndTarget() { + add(BIZ_TYPE, "s1", "t1", "r1"); + add(BIZ_TYPE, "s1", "t2", "r2"); + add(BIZ_TYPE, "s2", "t1", "r1"); + add(OTHER_BIZ_TYPE, "s1", "t3", "r1"); + + assertThat(targets(service.getBySourceId(BIZ_TYPE, id("s1")).collectList().block(TIMEOUT))).containsExactlyInAnyOrder(id("t1"), id("t2")); + assertThat(service.getBySourceId(BIZ_TYPE, id("s1"), PageRequest.of(0, 1)).collectList().block(TIMEOUT)).hasSize(1); + assertThat(service.getBySourceId(BIZ_TYPE, id("s1"), PageRequest.of(1, 1)).collectList().block(TIMEOUT)).hasSize(1); + assertThat(service.getBySourceId(BIZ_TYPE, id("s1"), PageRequest.of(2, 1)).collectList().block(TIMEOUT)).isEmpty(); + assertThat(service.getBySourceIds(BIZ_TYPE, List.of(id("s1"), id("s2"))).collectList().block(TIMEOUT)).hasSize(3); + assertThat(service.getByTargetId(BIZ_TYPE, id("t1")).collectList().block(TIMEOUT)).hasSize(2); + assertThat(service.getByTargetIds(BIZ_TYPE, List.of(id("t1"), id("t2"))).collectList().block(TIMEOUT)).hasSize(3); + assertThat(service.getByTargetIdAndSourceIds(BIZ_TYPE, id("t1"), List.of(id("s1"))).collectList().block(TIMEOUT)).singleElement() + .satisfies(row -> assertThat(row.getSourceId()).isEqualTo(id("s1"))); + assertThat(service.getBiRelation(BIZ_TYPE, id("s1"), id("t2")).block(TIMEOUT).getRelation()).isEqualTo(id("r2")); + assertThat(service.getBiRelation(BIZ_TYPE, id("s1"), id("t3")).blockOptional(TIMEOUT)).isEmpty(); + } + + @Test + void theCountsFilterByBizTypeAndTheCountedValue() { + add(BIZ_TYPE, "s1", "t1", "r1"); + add(BIZ_TYPE, "s1", "t2", "r2"); + add(BIZ_TYPE, "s2", "t1", "r1"); + add(OTHER_BIZ_TYPE, "s1", "t3", "r1"); + + assertThat(service.countByRelation(BIZ_TYPE, id("r1")).block(TIMEOUT)).isEqualTo(2); + assertThat(service.countByRelation(OTHER_BIZ_TYPE, id("r1")).block(TIMEOUT)).isEqualTo(1); + assertThat(service.countBySourceId(BIZ_TYPE, id("s1")).block(TIMEOUT)).isEqualTo(2); + assertThat(service.countByTargetId(BIZ_TYPE, id("t1")).block(TIMEOUT)).isEqualTo(2); + assertThat(service.countByTargetId(BIZ_TYPE, id("none")).block(TIMEOUT)).isZero(); + } + + @Test + void getBySourceIdAndRelationReturnsTheRowsOfTheRelationAndAllRowsForANullOrBlankRelation() { + add(BIZ_TYPE, "s", "t1", "r1"); + add(BIZ_TYPE, "s", "t2", "r2"); + add(BIZ_TYPE, "s", "t3", "r1"); + + List withRelation = service.getBySourceIdAndRelation(BIZ_TYPE, id("s"), id("r1")).collectList().block(TIMEOUT); + List nullRelation = service.getBySourceIdAndRelation(BIZ_TYPE, id("s"), null).collectList().block(TIMEOUT); + List blankRelation = service.getBySourceIdAndRelation(BIZ_TYPE, id("s"), " ").collectList().block(TIMEOUT); + List noMatch = service.getBySourceIdAndRelation(BIZ_TYPE, id("s"), id("none")).collectList().block(TIMEOUT); + + System.out.println("[BiRelationServiceImplTest] by relation " + targets(withRelation) + ", null " + targets(nullRelation) + + ", blank " + targets(blankRelation) + ", no match " + targets(noMatch)); + assertThat(targets(withRelation)).containsExactlyInAnyOrder(id("t1"), id("t3")); + assertThat(targets(nullRelation)).containsExactlyInAnyOrder(id("t1"), id("t2"), id("t3")); + assertThat(targets(blankRelation)).containsExactlyInAnyOrder(id("t1"), id("t2"), id("t3")); + assertThat(noMatch).isEmpty(); + } + + @Test + void updateRelationAndUpdateStateReportWhetherARowMatched() { + BiRelation saved = add(BIZ_TYPE, "s", "t", "r"); + + assertThat(service.updateRelation(BIZ_TYPE, id("s"), id("t"), id("r-new")).block(TIMEOUT)).isTrue(); + assertThat(service.updateState(BIZ_TYPE, id("s"), id("t"), "state-new").block(TIMEOUT)).isTrue(); + assertThat(service.updateRelation(BIZ_TYPE, id("s"), id("none"), id("x")).block(TIMEOUT)).isFalse(); + assertThat(service.updateState(BIZ_TYPE, id("s"), id("none"), "x").block(TIMEOUT)).isFalse(); + + BiRelation stored = service.getById(saved.getId()).block(TIMEOUT); + assertThat(stored.getRelation()).isEqualTo(id("r-new")); + assertThat(stored.getState()).isEqualTo("state-new"); + } + + @Test + void removeBiRelationDeletesOneRowAndReportsWhetherOneExisted() { + add(BIZ_TYPE, "s", "t1", "r"); + add(BIZ_TYPE, "s", "t2", "r"); + + assertThat(service.removeBiRelation(BIZ_TYPE, id("s"), id("t1")).block(TIMEOUT)).isTrue(); + assertThat(service.removeBiRelation(BIZ_TYPE, id("s"), id("t1")).block(TIMEOUT)).isFalse(); + assertThat(service.countBySourceId(BIZ_TYPE, id("s")).block(TIMEOUT)).isEqualTo(1); + } + + @Test + void theBulkRemovesSelectByBizTypeAndTheGivenKeys() { + add(BIZ_TYPE, "s1", "t1", "r"); + add(BIZ_TYPE, "s1", "t2", "r"); + add(BIZ_TYPE, "s2", "t1", "r"); + add(BIZ_TYPE, "s3", "t1", "r"); + add(BIZ_TYPE, "s4", "t4", "r"); + add(OTHER_BIZ_TYPE, "s1", "t1", "r"); + + assertThat(service.removeAllBiRelationsBySourceIdAndTargetId(BIZ_TYPE, id("s1"), id("t1")).block(TIMEOUT)).isTrue(); + assertThat(service.removeAllBiRelationsByTargetId(BIZ_TYPE, id("t1")).block(TIMEOUT)).isTrue(); + assertThat(service.removeAllBiRelations(BIZ_TYPE, List.of(id("s1"), id("s4"))).block(TIMEOUT)).isTrue(); + assertThat(service.removeAllBiRelations(BIZ_TYPE, List.of(id("s1"), id("s4"))).block(TIMEOUT)).isFalse(); + + assertThat(service.countBySourceId(BIZ_TYPE, id("s1")).block(TIMEOUT)).isZero(); + assertThat(service.countBySourceId(BIZ_TYPE, id("s2")).block(TIMEOUT)).isZero(); + assertThat(service.countBySourceId(BIZ_TYPE, id("s3")).block(TIMEOUT)).isZero(); + assertThat(service.countBySourceId(BIZ_TYPE, id("s4")).block(TIMEOUT)).isZero(); + assertThat(service.countBySourceId(OTHER_BIZ_TYPE, id("s1")).block(TIMEOUT)).as("another bizType is not touched").isEqualTo(1); + } + + /** + * DEFECT pinned (plan section 9 row, D-6, fix deferred): {@code removeAllBiRelations(bizType, sourceId)} builds + * {@code where(RELATION).is("super_admin").not()} (BiRelationServiceImpl.java:135), and Spring turns that into the + * plain criterion {@code {"relation": "super_admin"}}: the negation is lost. The call therefore deletes only the + * super_admin row of the source and keeps every other member. It runs when an organization or a group is deleted + * (deleteOrgMembers, deleteGroupMembers, through OrgAndGroupEventListener.java:62 and :110), so a deleted org or + * group keeps all its members except the super admin. The obvious fix is {@code where(RELATION).ne("super_admin")} + * (delete everything except super_admin, as the code reads), which turns this test red. + */ + @Test + void removeAllBiRelationsOfASourceDeletesOnlyTheSuperAdminRow() { + service.addBiRelation(BIZ_TYPE, id("s"), id("t1"), "super_admin", "state").block(TIMEOUT); + service.addBiRelation(BIZ_TYPE, id("s"), id("t2"), "member", "state").block(TIMEOUT); + service.addBiRelation(BIZ_TYPE, id("s"), id("t3"), "viewer", "state").block(TIMEOUT); + service.addBiRelation(OTHER_BIZ_TYPE, id("s"), id("t4"), "super_admin", "state").block(TIMEOUT); + + Boolean removed = service.removeAllBiRelations(BIZ_TYPE, id("s")).block(TIMEOUT); + + List left = service.getBySourceId(BIZ_TYPE, id("s")).collectList().block(TIMEOUT); + System.out.println("[BiRelationServiceImplTest] removeAllBiRelations(source) answered " + removed + ", left " + targets(left)); + assertThat(removed).isTrue(); + assertThat(targets(left)).containsExactlyInAnyOrder(id("t2"), id("t3")); + assertThat(service.countBySourceId(OTHER_BIZ_TYPE, id("s")).block(TIMEOUT)).as("another bizType is not touched").isEqualTo(1); + } + + /** + * DEFECT pinned (plan section 9 row, D-6, fix deferred): {@code removeBiRelationById} answers true for an id that + * no row has, because it maps the completion of {@code deleteById} (which is empty for a missing id) to true + * (BiRelationServiceImpl.java:231-233); false is only produced by an error. Caller: + * ResourcePermissionRepositoryImpl.java:79 (removePermissionById). The obvious fix is to answer from whether a row + * existed (for example {@code existsById} before the delete), which turns this test red. + */ + @Test + void removeBiRelationByIdAnswersTrueForAnIdNoRowHas() { + Boolean answer = service.removeBiRelationById(new org.bson.types.ObjectId().toHexString()).block(TIMEOUT); + + assertThat(answer).isTrue(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/infra/mongo/MongoUpsertHelperTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/infra/mongo/MongoUpsertHelperTest.java new file mode 100644 index 0000000000..5b3e2146e4 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/infra/mongo/MongoUpsertHelperTest.java @@ -0,0 +1,246 @@ +package org.lowcoder.infra.mongo; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import org.lowcoder.infra.InfraMongoTestConfiguration; +import org.lowcoder.infra.InfraMongoTestConfiguration.BeforeSaveRecorder; +import org.lowcoder.infra.birelation.BiRelation; +import org.lowcoder.infra.birelation.BiRelationBizType; +import org.lowcoder.infra.birelation.BiRelationRepository; +import org.lowcoder.infra.mongo.MongoUpsertHelper.PartialResourceWithId; +import org.lowcoder.sdk.constants.GlobalContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.test.context.ActiveProfiles; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +/** + * {@link MongoUpsertHelper} against a real MongoDB (the shared container, a database of this context's own). Every + * test writes only rows whose source id carries a random run prefix, so nothing depends on other rows. + * + *

    The "modified" flag of {@code update} is deterministic here in both directions: true for a row that exists and + * false for a query that matches no row. (Which of the two the server's other tests happen to hit in a given build is + * what made the helper's branch counter vary between builds, docs/COVERAGE_GATE_PLAN.md 2.3.) {@code update} always + * stamps a new {@code updatedAt}, so a matched-but-unchanged result is only reachable through {@code updatePurely}, + * {@code upsert} and {@code bulkUpdate}, which are tested for it. + */ +@SpringBootTest(classes = InfraMongoTestConfiguration.class) +@ActiveProfiles(InfraMongoTestConfiguration.PROFILE) +class MongoUpsertHelperTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(20); + private static final BiRelationBizType BIZ_TYPE = BiRelationBizType.RESOURCE; + private static final String SOURCE_ID = "sourceId"; + + @Autowired + private MongoUpsertHelper helper; + @Autowired + private BiRelationRepository repository; + @Autowired + private ReactiveMongoTemplate template; + @Autowired + private BeforeSaveRecorder recorder; + + private final String run = UUID.randomUUID().toString(); + + private String source(String name) { + return run + "-" + name; + } + + private BiRelation relation(String source, String relation) { + return BiRelation.builder().bizType(BIZ_TYPE).sourceId(source).targetId("target").relation(relation).state("state") + .extParam1("").extParam2("").extParam3("").build(); + } + + private BiRelation save(String source, String relation) { + return repository.save(relation(source, relation)).block(TIMEOUT); + } + + private BiRelation reload(String id) { + return repository.findById(id).block(TIMEOUT); + } + + private long count(String source) { + return template.count(new Query(Criteria.where(SOURCE_ID).is(source)), BiRelation.class).block(TIMEOUT); + } + + private static Query bySource(String source) { + return new Query(Criteria.where(SOURCE_ID).is(source)); + } + + private static BiRelation partial(String relation) { + return BiRelation.builder().relation(relation).build(); + } + + @Test + void theContextHasADatabaseOfItsOwn() { + String database = template.getMongoDatabase().map(d -> d.getName()).block(TIMEOUT); + + System.out.println("[MongoUpsertHelperTest] database " + database); + assertThat(database).matches("lowcoder_test_\\d+"); + } + + @Test + void updateReturnsTrueAndStampsTheAuditFieldsWhenARowMatches() { + BiRelation saved = save(source("a"), "old"); + int eventsBefore = recorder.events().size(); + BiRelation partial = partial("new"); + + Boolean modified = helper.update(partial, bySource(source("a"))).block(TIMEOUT); + + BiRelation reloaded = reload(saved.getId()); + assertThat(modified).isTrue(); + assertThat(reloaded.getRelation()).isEqualTo("new"); + assertThat(reloaded.getState()).as("fields the partial does not carry are kept").isEqualTo("state"); + assertThat(partial.getUpdatedAt()).as("the helper stamps the partial before writing it").isNotNull(); + assertThat(reloaded.getUpdatedAt()).isEqualTo(partial.getUpdatedAt().truncatedTo(java.time.temporal.ChronoUnit.MILLIS)); + assertThat(reloaded.getModifiedBy()).isEqualTo(GlobalContext.SYSTEM_USER_ID); + assertThat(recorder.events().subList(eventsBefore, recorder.events().size())) + .hasSize(1).allSatisfy(event -> assertThat(event.source()).isSameAs(partial)); + } + + @Test + void updateStampsTheVisitorFromTheReactorContext() { + BiRelation saved = save(source("a"), "old"); + + Boolean modified = helper.update(partial("new"), bySource(source("a"))) + .contextWrite(Context.of(GlobalContext.VISITOR_ID, "visitor-1")).block(TIMEOUT); + + assertThat(modified).isTrue(); + assertThat(reload(saved.getId()).getModifiedBy()).isEqualTo("visitor-1"); + } + + @Test + void updateReturnsFalseAndChangesNothingWhenNoRowMatches() { + BiRelation saved = save(source("a"), "old"); + + Boolean modified = helper.update(partial("new"), bySource(source("missing"))).block(TIMEOUT); + + assertThat(modified).isFalse(); + assertThat(reload(saved.getId()).getRelation()).isEqualTo("old"); + } + + @Test + void updateByKeyAndValueFollowsTheSameTrueAndFalseRule() { + BiRelation saved = save(source("a"), "old"); + + assertThat(helper.update(partial("new"), SOURCE_ID, source("a")).block(TIMEOUT)).isTrue(); + assertThat(helper.update(partial("newer"), SOURCE_ID, source("missing")).block(TIMEOUT)).isFalse(); + assertThat(reload(saved.getId()).getRelation()).isEqualTo("new"); + } + + @Test + void updateByIdMatchesAnObjectIdAndTreatsAnIdWithADashAsAGidThatNoRowHas() { + BiRelation saved = save(source("a"), "old"); + + assertThat(helper.updateById(partial("new"), saved.getId()).block(TIMEOUT)).isTrue(); + assertThat(helper.updateById(partial("other"), "no-such-gid").block(TIMEOUT)).isFalse(); + assertThat(reload(saved.getId()).getRelation()).isEqualTo("new"); + } + + @Test + void updatePurelyWritesOnlyThePartialAndReportsWhetherARowChanged() { + BiRelation saved = save(source("a"), "old"); + BiRelation before = reload(saved.getId()); + + Boolean first = helper.updatePurely(partial("new"), saved.getId()).block(TIMEOUT); + Boolean sameAgain = helper.updatePurely(partial("new"), saved.getId()).block(TIMEOUT); + Boolean noMatch = helper.updatePurely(partial("new"), bySource(source("missing"))).block(TIMEOUT); + Boolean byQuery = helper.updatePurely(partial("newest"), bySource(source("a"))).block(TIMEOUT); + + BiRelation after = reload(saved.getId()); + System.out.println("[MongoUpsertHelperTest] updatePurely " + first + ", " + sameAgain + ", " + noMatch + ", " + byQuery); + assertThat(List.of(first, sameAgain, noMatch, byQuery)).containsExactly(true, false, false, true); + assertThat(after.getRelation()).isEqualTo("newest"); + assertThat(after.getUpdatedAt()).as("no audit field is stamped").isEqualTo(before.getUpdatedAt()); + } + + @Test + void removeReportsWhetherARowWasDeleted() { + save(source("a"), "old"); + + assertThat(helper.remove(bySource(source("a")), BiRelation.class).block(TIMEOUT)).isTrue(); + assertThat(helper.remove(bySource(source("a")), BiRelation.class).block(TIMEOUT)).isFalse(); + assertThat(count(source("a"))).isZero(); + } + + @Test + void upsertWithAuditingParamsKeepsTheIdAndCreatedFieldsOfAnExistingRow() { + BiRelation existing = reload(save(source("a"), "old").getId()); + + BiRelation byKey = helper.upsertWithAuditingParams(relation(source("a"), "by-key"), SOURCE_ID, source("a")).block(TIMEOUT); + BiRelation byCriteria = helper.upsertWithAuditingParams(relation(source("a"), "by-criteria"), Criteria.where(SOURCE_ID).is(source("a"))) + .block(TIMEOUT); + + assertThat(byKey.getId()).isEqualTo(existing.getId()); + assertThat(byCriteria.getId()).isEqualTo(existing.getId()); + BiRelation reloaded = reload(existing.getId()); + assertThat(reloaded.getRelation()).isEqualTo("by-criteria"); + assertThat(reloaded.getCreatedAt()).isEqualTo(existing.getCreatedAt()); + assertThat(reloaded.getCreatedBy()).isEqualTo(existing.getCreatedBy()).isEqualTo(InfraMongoTestConfiguration.AUDITOR); + assertThat(count(source("a"))).isEqualTo(1); + } + + @Test + void upsertWithAuditingParamsInsertsWhenNoRowMatches() { + BiRelation byKey = helper.upsertWithAuditingParams(relation(source("a"), "new"), SOURCE_ID, source("a")).block(TIMEOUT); + BiRelation byCriteria = helper.upsertWithAuditingParams(relation(source("b"), "new"), Criteria.where(SOURCE_ID).is(source("b"))) + .block(TIMEOUT); + + assertThat(byKey.getId()).isNotNull(); + assertThat(byCriteria.getId()).isNotNull(); + assertThat(count(source("a"))).isEqualTo(1); + assertThat(count(source("b"))).isEqualTo(1); + } + + @Test + void upsertOfAResourceInsertsThenReportsModifiedOnlyWhenARowChanged() { + Boolean inserted = helper.upsert(relation(source("a"), "v1"), SOURCE_ID, source("a")).block(TIMEOUT); + Boolean unchanged = helper.upsert(relation(source("a"), "v1"), SOURCE_ID, source("a")).block(TIMEOUT); + Boolean changed = helper.upsert(relation(source("a"), "v2"), Criteria.where(SOURCE_ID).is(source("a"))).block(TIMEOUT); + + System.out.println("[MongoUpsertHelperTest] upsert resource " + inserted + ", " + unchanged + ", " + changed); + assertThat(List.of(inserted, unchanged, changed)).containsExactly(false, false, true); + assertThat(count(source("a"))).isEqualTo(1); + } + + @Test + void upsertOfAnUpdateInsertsThenReportsModifiedOnlyWhenARowChanged() { + Boolean inserted = helper.upsert(new Update().set("relation", "v1"), SOURCE_ID, source("a"), BiRelation.class).block(TIMEOUT); + Boolean unchanged = helper.upsert(new Update().set("relation", "v1"), Criteria.where(SOURCE_ID).is(source("a")), BiRelation.class) + .block(TIMEOUT); + Boolean changed = helper.upsert(new Update().set("relation", "v2"), SOURCE_ID, source("a"), BiRelation.class).block(TIMEOUT); + + System.out.println("[MongoUpsertHelperTest] upsert update " + inserted + ", " + unchanged + ", " + changed); + assertThat(List.of(inserted, unchanged, changed)).containsExactly(false, false, true); + assertThat(count(source("a"))).isEqualTo(1); + } + + @Test + void bulkUpdateReportsWhetherAnyRowChangedAndAnEmptyCollectionGivesAnEmptyMono() { + BiRelation first = save(source("a"), "old"); + BiRelation second = save(source("b"), "old"); + + Boolean changed = helper.bulkUpdate(List.of(new PartialResourceWithId<>(partial("new"), first.getId()), + new PartialResourceWithId<>(partial("new"), second.getId()))).block(TIMEOUT); + Boolean unchanged = helper.bulkUpdate(List.of(new PartialResourceWithId<>(partial("new"), first.getId()))).block(TIMEOUT); + Boolean unknown = helper.bulkUpdate(List.of(new PartialResourceWithId<>(partial("new"), new ObjectId().toHexString()))).block(TIMEOUT); + + assertThat(List.of(changed, unchanged, unknown)).containsExactly(true, false, false); + assertThat(reload(first.getId()).getRelation()).isEqualTo("new"); + assertThat(reload(second.getId()).getRelation()).isEqualTo("new"); + assertThat(helper.bulkUpdate(List.of()).blockOptional(TIMEOUT)).isEmpty(); + assertThat(helper.bulkUpdate(null).blockOptional(TIMEOUT)).isEmpty(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/eventlistener/OrgAndGroupEventListenerTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/eventlistener/OrgAndGroupEventListenerTest.java new file mode 100644 index 0000000000..5f774a364e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/eventlistener/OrgAndGroupEventListenerTest.java @@ -0,0 +1,497 @@ +package org.lowcoder.runner.eventlistener; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.service.DatasourceService; +import org.lowcoder.domain.group.event.GroupDeletedEvent; +import org.lowcoder.domain.group.model.Group; +import org.lowcoder.domain.group.service.GroupMemberService; +import org.lowcoder.domain.group.service.GroupService; +import org.lowcoder.domain.organization.event.OrgDeletedEvent; +import org.lowcoder.domain.organization.event.OrgMemberLeftEvent; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.springframework.test.util.ReflectionTestUtils; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; + +/** + * Tests of {@link OrgAndGroupEventListener} over mocked services. The handlers run on the listener's own scheduler, so the + * tests wait on latches counted down inside the stubs (bounded waits, never a fixed sleep as an assertion) and record the + * executing thread and an event log. A handler that ends in an error is subscribed without an error consumer, so the end of + * such a chain is observed through Reactor's error-dropped hook. + * + *

    Related pins, referenced and not repeated: L3-11b ({@code OrganizationServiceImplDeleteMongoTest}) drives the listener + * through a real org delete against MongoDB and pins the section 9 row that deleting an org leaves its applications and + * datasources (the two cleanup steps are empty stubs, listener :98-104, called at :64-65); L4-8 + * ({@code BiRelationServiceImplTest}) pins the BiRelation cascade through {@code deleteOrgMembers} and + * {@code deleteGroupMembers}. This class covers what those runs do not branch through: the order of the steps, the retries, + * the error handling, the thread, the group-deleted path and the user-leaves-org handler. + * + *

    The listener's pool uses non-daemon threads (idle timeout 60 s of a cached pool); the surefire fork exits normally. + */ +class OrgAndGroupEventListenerTest { + + private static final String ORG_ID = "org-1"; + private static final String USER_ID = "user-1"; + private static final String GROUP_1 = "group-1"; + private static final String GROUP_2 = "group-2"; + private static final String APP_1 = "app-1"; + private static final String APP_2 = "app-2"; + private static final String DATASOURCE_1 = "ds-1"; + private static final String SCHEDULER_THREAD = "org-event-async-executor"; + private static final long WAIT_SECONDS = 10; + private static final int RETRIES = 3; + + private OrgAndGroupEventListener listener; + private OrgMemberService orgMemberService; + private GroupService groupService; + private GroupMemberService groupMemberService; + private ApplicationService applicationService; + private DatasourceService datasourceService; + private ResourcePermissionService resourcePermissionService; + + private final List events = new CopyOnWriteArrayList<>(); + private final List dropped = new CopyOnWriteArrayList<>(); + private final CountDownLatch droppedLatch = new CountDownLatch(1); + /** The failure the test waits for: only a dropped error carrying it ends the wait (a stale error of another test does not). */ + private volatile Throwable expectedFailure; + + @BeforeEach + void setUp() { + listener = new OrgAndGroupEventListener(); + orgMemberService = mock(OrgMemberService.class); + groupService = mock(GroupService.class); + groupMemberService = mock(GroupMemberService.class); + applicationService = mock(ApplicationService.class); + datasourceService = mock(DatasourceService.class); + resourcePermissionService = mock(ResourcePermissionService.class); + ReflectionTestUtils.setField(listener, "orgMemberService", orgMemberService); + ReflectionTestUtils.setField(listener, "groupService", groupService); + ReflectionTestUtils.setField(listener, "groupMemberService", groupMemberService); + ReflectionTestUtils.setField(listener, "applicationService", applicationService); + ReflectionTestUtils.setField(listener, "datasourceService", datasourceService); + ReflectionTestUtils.setField(listener, "resourcePermissionService", resourcePermissionService); + Hooks.onErrorDropped(error -> { + dropped.add(error); + Throwable expected = expectedFailure; + if (expected != null && (error == expected || error.getCause() == expected)) { + droppedLatch.countDown(); + } + }); + } + + @AfterEach + void tearDown() { + Hooks.resetOnErrorDropped(); + } + + private static Group group(String id) { + return Group.builder().id(id).build(); + } + + private static Application application(String id) { + return Application.builder().id(id).build(); + } + + private static Datasource datasource(String id) { + return Datasource.builder().id(id).build(); + } + + private static void await(CountDownLatch latch, String what) throws InterruptedException { + assertThat(latch.await(WAIT_SECONDS, TimeUnit.SECONDS)).as("waited for: " + what).isTrue(); + } + + /** True when the dropped error is the given failure, directly or as the cause of Reactor's "error callback not implemented". */ + private boolean droppedFailure(Throwable failure) { + return dropped.stream().anyMatch(error -> error == failure || error.getCause() == failure); + } + + private static void say(String format, Object... args) { + System.out.println("[OrgAndGroupEventListenerTest] " + String.format(format, args)); + } + + private static OrgDeletedEvent orgDeleted() { + OrgDeletedEvent event = new OrgDeletedEvent(); + event.setOrgId(ORG_ID); + return event; + } + + private static GroupDeletedEvent groupDeleted() { + GroupDeletedEvent event = new GroupDeletedEvent(); + event.setGroupId(GROUP_1); + return event; + } + + // ------------------------------------------------------------------ onOrgDeleted + + /** + * Cleanup order: the org's members are deleted first, then the groups are listed, then each group is deleted, in the order + * the listing returns them. Applications and datasources are never touched (the section 9 row "deleting an org leaves its + * apps and datasources", pinned for real by L3-11b and not repeated here). + */ + @Test + void onOrgDeleted_runsMembersThenGroupDeletions_inThatOrder() throws InterruptedException { + CountDownLatch done = new CountDownLatch(1); + when(orgMemberService.deleteOrgMembers(ORG_ID)).thenReturn(Mono.defer(() -> { + events.add("delete members"); + return Mono.just(true); + })); + when(groupService.getByOrgId(ORG_ID)).thenReturn(Flux.defer(() -> { + events.add("list groups"); + return Flux.just(group(GROUP_1), group(GROUP_2)); + })); + when(groupService.delete(GROUP_1)).thenReturn(Mono.defer(() -> { + events.add("delete " + GROUP_1); + return Mono.empty(); + })); + when(groupService.delete(GROUP_2)).thenReturn(Mono.defer(() -> { + events.add("delete " + GROUP_2); + done.countDown(); + return Mono.empty(); + })); + + listener.onOrgDeleted(orgDeleted()); + await(done, "the last group deletion"); + + assertThat(events).containsExactly("delete members", "list groups", "delete " + GROUP_1, "delete " + GROUP_2); + verifyNoInteractions(applicationService, datasourceService, resourcePermissionService); + say("org delete order: %s", events); + } + + @Test + void onOrgDeleted_deletesOnlyTheListedGroups_andAnOrgWithoutGroupsStillCompletes() throws InterruptedException { + CountDownLatch listed = new CountDownLatch(1); + when(orgMemberService.deleteOrgMembers(ORG_ID)).thenReturn(Mono.just(true)); + when(groupService.getByOrgId(ORG_ID)).thenReturn(Flux.defer(() -> Flux.empty().doOnComplete(listed::countDown))); + + listener.onOrgDeleted(orgDeleted()); + await(listed, "the group listing of the empty org"); + + verify(groupService, never()).delete(anyString()); + verify(orgMemberService).deleteOrgMembers(ORG_ID); + } + + /** + * {@code .retry(3)} restarts the WHOLE chain, so a failing group step re-runs {@code deleteOrgMembers}: a listing that fails + * twice and then succeeds completes after three subscriptions of the members deletion. The operations are deletes by org id + * (idempotent in these mocks; not verified against the real repositories). + */ + @Test + void onOrgDeleted_retriesTheWholeChain_thenSucceeds() throws InterruptedException { + AtomicInteger memberDeletions = new AtomicInteger(); + AtomicInteger listings = new AtomicInteger(); + CountDownLatch done = new CountDownLatch(1); + when(orgMemberService.deleteOrgMembers(ORG_ID)).thenReturn(Mono.defer(() -> { + memberDeletions.incrementAndGet(); + return Mono.just(true); + })); + when(groupService.getByOrgId(ORG_ID)).thenReturn(Flux.defer(() -> { + if (listings.incrementAndGet() <= 2) { + return Flux.error(new IllegalStateException("transient")); + } + return Flux.just(group(GROUP_1)); + })); + when(groupService.delete(GROUP_1)).thenReturn(Mono.defer(() -> { + done.countDown(); + return Mono.empty(); + })); + + listener.onOrgDeleted(orgDeleted()); + await(done, "the group deletion after two failed attempts"); + + assertThat(listings.get()).isEqualTo(3); + assertThat(memberDeletions.get()).as("the members deletion runs again with every retry of the chain").isEqualTo(3); + } + + /** A listing that always fails is attempted 1 + 3 times, the group deletions never run, and the caller sees no exception. */ + @Test + void onOrgDeleted_givesUpAfterThreeRetries_withoutRaisingToTheCaller() throws InterruptedException { + IllegalStateException failure = new IllegalStateException("permanent"); + expectedFailure = failure; + AtomicInteger listings = new AtomicInteger(); + when(orgMemberService.deleteOrgMembers(ORG_ID)).thenReturn(Mono.just(true)); + when(groupService.getByOrgId(ORG_ID)).thenReturn(Flux.defer(() -> { + listings.incrementAndGet(); + return Flux.error(failure); + })); + + listener.onOrgDeleted(orgDeleted()); + await(droppedLatch, "the end of the failing chain"); + + assertThat(droppedFailure(failure)).isTrue(); + assertThat(listings.get()).isEqualTo(1 + RETRIES); + verify(groupService, never()).delete(anyString()); + } + + /** The work runs on the listener's own scheduler: the handler returns while the first step is still blocked. */ + @Test + void onOrgDeleted_runsOnTheEventScheduler_notOnTheCaller() throws InterruptedException { + AtomicReference thread = new AtomicReference<>(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + when(orgMemberService.deleteOrgMembers(ORG_ID)).thenReturn(Mono.defer(() -> { + thread.set(Thread.currentThread().getName()); + entered.countDown(); + try { + release.await(WAIT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return Mono.just(true); + })); + when(groupService.getByOrgId(ORG_ID)).thenReturn(Flux.defer(() -> Flux.empty().doOnComplete(finished::countDown))); + + listener.onOrgDeleted(orgDeleted()); + + // reaching this line means the handler returned although the members deletion has not finished + await(entered, "the members deletion to start"); + assertThat(finished.getCount()).as("the chain is still blocked in its first step").isEqualTo(1); + release.countDown(); + await(finished, "the chain to finish"); + assertThat(thread.get()).isEqualTo(SCHEDULER_THREAD); + assertThat(Thread.currentThread().getName()).isNotEqualTo(SCHEDULER_THREAD); + } + + // ------------------------------------------------------------------ onGroupDeleted + + @Test + void onGroupDeleted_deletesTheGroupMembers_onTheScheduler() throws InterruptedException { + AtomicReference thread = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + when(groupMemberService.deleteGroupMembers(GROUP_1)).thenReturn(Mono.defer(() -> { + thread.set(Thread.currentThread().getName()); + done.countDown(); + return Mono.just(true); + })); + + listener.onGroupDeleted(groupDeleted()); + await(done, "the group member deletion"); + + assertThat(thread.get()).isEqualTo(SCHEDULER_THREAD); + verify(groupMemberService).deleteGroupMembers(GROUP_1); + verifyNoInteractions(groupService, orgMemberService); + } + + /** + * Behaviour (candidate (a), no row): unlike {@code onOrgDeleted} (retry 3) and {@code onUserLeaveOrg} (retry 3 per part), + * {@code onGroupDeleted} has NO retry. A transient failure of {@code deleteGroupMembers} is attempted once, logged by the + * listener ("fail to handle group deletion"; the log line itself is not asserted) and not raised to the caller, and the + * deleted group's member rows stay behind (orphaned group-member rows). + */ + @Test + void onGroupDeleted_failureIsNotRetried_andNotRaisedToTheCaller() throws InterruptedException { + IllegalStateException failure = new IllegalStateException("transient"); + expectedFailure = failure; + AtomicInteger attempts = new AtomicInteger(); + when(groupMemberService.deleteGroupMembers(GROUP_1)).thenReturn(Mono.defer(() -> { + attempts.incrementAndGet(); + return Mono.error(failure); + })); + + listener.onGroupDeleted(groupDeleted()); + await(droppedLatch, "the end of the failing chain"); + + assertThat(droppedFailure(failure)).isTrue(); + assertThat(attempts.get()).as("one attempt, no retry").isEqualTo(1); + } + + // ------------------------------------------------------------------ onUserLeaveOrg + + private void stubOrgContents(List groups, List apps, List datasources) { + when(groupService.getByOrgId(ORG_ID)).thenReturn(Flux.fromIterable(groups.stream().map(OrgAndGroupEventListenerTest::group).toList())); + when(applicationService.findByOrganizationIdWithoutDsl(ORG_ID)) + .thenReturn(Flux.fromIterable(apps.stream().map(OrgAndGroupEventListenerTest::application).toList())); + when(datasourceService.getByOrgId(ORG_ID)) + .thenReturn(Flux.fromIterable(datasources.stream().map(OrgAndGroupEventListenerTest::datasource).toList())); + } + + /** + * The user is removed from every group of the org and loses the permissions on every application and datasource of the + * org, for that user and org only. A call with any other argument finds an unstubbed mock (null) and breaks the chain, so a + * swapped id is noticed as a missing removal. + */ + @Test + void onUserLeaveOrg_removesTheUserFromEveryGroup_andEveryApplicationAndDatasourcePermission() throws InterruptedException { + CountDownLatch removals = new CountDownLatch(5); + List threads = new CopyOnWriteArrayList<>(); + stubOrgContents(List.of(GROUP_1, GROUP_2), List.of(APP_1, APP_2), List.of(DATASOURCE_1)); + for (String groupId : List.of(GROUP_1, GROUP_2)) { + when(groupMemberService.removeMember(groupId, USER_ID)).thenReturn(Mono.defer(() -> { + events.add("group " + groupId); + threads.add(Thread.currentThread().getName()); + removals.countDown(); + return Mono.just(true); + })); + } + for (String appId : List.of(APP_1, APP_2)) { + when(resourcePermissionService.removeUserApplicationPermission(appId, USER_ID)).thenReturn(Mono.defer(() -> { + events.add("application " + appId); + threads.add(Thread.currentThread().getName()); + removals.countDown(); + return Mono.just(true); + })); + } + when(resourcePermissionService.removeUserDatasourcePermission(DATASOURCE_1, USER_ID)).thenReturn(Mono.defer(() -> { + events.add("datasource " + DATASOURCE_1); + threads.add(Thread.currentThread().getName()); + removals.countDown(); + return Mono.just(true); + })); + + listener.onUserLeaveOrg(new OrgMemberLeftEvent(ORG_ID, USER_ID)); + await(removals, "the five removals"); + + assertThat(Set.copyOf(events)).containsExactlyInAnyOrder("group " + GROUP_1, "group " + GROUP_2, + "application " + APP_1, "application " + APP_2, "datasource " + DATASOURCE_1); + assertThat(events).hasSize(5); + assertThat(threads).as("every part runs on the listener's scheduler, none on the caller").hasSize(5) + .allSatisfy(name -> assertThat(name).isEqualTo(SCHEDULER_THREAD)); + verify(applicationService).findByOrganizationIdWithoutDsl(ORG_ID); + } + + @Test + void onUserLeaveOrg_anOrgWithoutGroupsApplicationsOrDatasources_callsNoRemoval() throws InterruptedException { + CountDownLatch listed = new CountDownLatch(3); + when(groupService.getByOrgId(ORG_ID)).thenReturn(Flux.defer(() -> Flux.empty().doOnComplete(listed::countDown))); + when(applicationService.findByOrganizationIdWithoutDsl(ORG_ID)) + .thenReturn(Flux.defer(() -> Flux.empty().doOnComplete(listed::countDown))); + when(datasourceService.getByOrgId(ORG_ID)).thenReturn(Flux.defer(() -> Flux.empty().doOnComplete(listed::countDown))); + + listener.onUserLeaveOrg(new OrgMemberLeftEvent(ORG_ID, USER_ID)); + await(listed, "the three listings"); + + verifyNoInteractions(groupMemberService, resourcePermissionService); + assertThat(dropped).isEmpty(); + } + + /** Each part retries on its own: a group-member removal that fails twice then succeeds, and the other parts still run. */ + @Test + void onUserLeaveOrg_eachPartRetries_aTransientFailureStillCleansEverything() throws InterruptedException { + AtomicInteger groupAttempts = new AtomicInteger(); + AtomicInteger appAttempts = new AtomicInteger(); + AtomicInteger datasourceAttempts = new AtomicInteger(); + CountDownLatch done = new CountDownLatch(3); + stubOrgContents(List.of(GROUP_1), List.of(APP_1), List.of(DATASOURCE_1)); + when(groupMemberService.removeMember(GROUP_1, USER_ID)).thenReturn(Mono.defer(() -> { + if (groupAttempts.incrementAndGet() <= 2) { + return Mono.error(new IllegalStateException("transient group")); + } + done.countDown(); + return Mono.just(true); + })); + when(resourcePermissionService.removeUserApplicationPermission(APP_1, USER_ID)).thenReturn(Mono.defer(() -> { + if (appAttempts.incrementAndGet() <= 2) { + return Mono.error(new IllegalStateException("transient application")); + } + done.countDown(); + return Mono.just(true); + })); + when(resourcePermissionService.removeUserDatasourcePermission(DATASOURCE_1, USER_ID)).thenReturn(Mono.defer(() -> { + if (datasourceAttempts.incrementAndGet() <= 2) { + return Mono.error(new IllegalStateException("transient datasource")); + } + done.countDown(); + return Mono.just(true); + })); + + listener.onUserLeaveOrg(new OrgMemberLeftEvent(ORG_ID, USER_ID)); + await(done, "all three parts to succeed after two failures each"); + + assertThat(groupAttempts.get()).isEqualTo(3); + assertThat(appAttempts.get()).isEqualTo(3); + assertThat(datasourceAttempts.get()).isEqualTo(3); + assertThat(dropped).isEmpty(); + } + + /** A part that always fails is attempted 1 + 3 times, for each of the three parts on its own. */ + @ParameterizedTest(name = "always failing part: {0}") + @ValueSource(strings = {"group", "application", "datasource"}) + void onUserLeaveOrg_aPartThatAlwaysFails_isAttemptedFourTimes(String part) throws InterruptedException { + IllegalStateException failure = new IllegalStateException("permanent " + part); + expectedFailure = failure; + AtomicInteger attempts = new AtomicInteger(); + stubOrgContents(List.of(GROUP_1), List.of(APP_1), List.of(DATASOURCE_1)); + Mono failing = Mono.defer(() -> { + attempts.incrementAndGet(); + return Mono.error(failure); + }); + when(groupMemberService.removeMember(GROUP_1, USER_ID)).thenReturn("group".equals(part) ? failing : Mono.just(true)); + when(resourcePermissionService.removeUserApplicationPermission(APP_1, USER_ID)) + .thenReturn("application".equals(part) ? failing : Mono.just(true)); + when(resourcePermissionService.removeUserDatasourcePermission(DATASOURCE_1, USER_ID)) + .thenReturn("datasource".equals(part) ? failing : Mono.just(true)); + + listener.onUserLeaveOrg(new OrgMemberLeftEvent(ORG_ID, USER_ID)); + await(droppedLatch, "the end of the failing handler"); + + assertThat(droppedFailure(failure)).isTrue(); + assertThat(attempts.get()).isEqualTo(1 + RETRIES); + } + + /** + * Pins the section 9 row "onUserLeaveOrg runs its three cleanups through Mono.zip with no error consumer: one part failing + * for good cancels the others, and the error is not logged; a user who left an org can keep application or datasource + * permissions". The group-member removal fails for good (after its retries) while the application-permission removal is in + * flight (a sink the test completes only after the zip has failed): the zip cancels the application part, which therefore + * never finishes, and the error is only dropped through Reactor's hook (the listener logs nothing for this handler, unlike + * onOrgDeleted and onGroupDeleted). A fix (running the parts independently, e.g. zipDelayError or Mono.when with delayError, + * with an error log) changes this test on purpose. What the unit test cannot show: whether these services fail in + * production, or the real timing of the three parts; the group part waits for the application part to be in flight so the + * cancellation is deterministic. + */ + @Test + void onUserLeaveOrg_aPersistentFailureCancelsTheOtherParts_pinsTheSection9Row() throws InterruptedException { + IllegalStateException failure = new IllegalStateException("permanent group failure"); + expectedFailure = failure; + CountDownLatch applicationInFlight = new CountDownLatch(1); + CountDownLatch applicationCancelled = new CountDownLatch(1); + AtomicInteger applicationCompleted = new AtomicInteger(); + stubOrgContents(List.of(GROUP_1), List.of(APP_1), List.of()); + when(groupMemberService.removeMember(GROUP_1, USER_ID)).thenReturn(Mono.defer(() -> { + try { + applicationInFlight.await(WAIT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return Mono.error(failure); + })); + when(resourcePermissionService.removeUserApplicationPermission(APP_1, USER_ID)).thenReturn(Mono.create(sink -> { + sink.onCancel(applicationCancelled::countDown); + applicationInFlight.countDown(); + }).doOnNext(ignored -> applicationCompleted.incrementAndGet())); + + listener.onUserLeaveOrg(new OrgMemberLeftEvent(ORG_ID, USER_ID)); + + await(applicationCancelled, "the application part to be cancelled by the failing group part"); + await(droppedLatch, "the end of the failing handler"); + assertThat(droppedFailure(failure)).as("the error ends in Reactor's dropped-error hook, not in a listener log").isTrue(); + assertThat(applicationCompleted.get()).as("the application permission removal never finished").isZero(); + say("PINNED: group part failed for good -> application part cancelled, user keeps application permissions"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/DatabaseChangelogMigrationsTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/DatabaseChangelogMigrationsTest.java new file mode 100644 index 0000000000..78b4b85769 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/DatabaseChangelogMigrationsTest.java @@ -0,0 +1,459 @@ +package org.lowcoder.runner.migrations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.runner.task.ArchiveSnapshotTask; +import org.lowcoder.sdk.config.CommonConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.test.context.ActiveProfiles; + +import com.github.cloudyrock.mongock.ChangeSet; +import com.github.cloudyrock.mongock.driver.mongodb.springdata.v4.decorator.impl.MongockTemplate; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; + +import lombok.extern.slf4j.Slf4j; + +/** + * Drives the data changesets of {@link DatabaseChangelog} against the MongoDB test container. + * + *

    Profile and isolation. The profile is {@code databaseChangelog}, not {@code test}: the class is + * {@code @Profile("!test")} (DatabaseChangelog:68), so under {@code test} Mongock skips it, while under this profile + * Mongock runs the whole changelog at startup. {@code TestContainersInitializer} gives this context its own database + * ({@code lowcoder_test_}); {@code mongockChangeLog}, {@code mongockLock} and every collection used here live in that + * database, so no other context is reachable. A failing changeset at startup would stop the context from loading, and + * that is a finding, not something to work around. + * + *

    Each test seeds legacy-shaped documents into the collections the changeset reads and calls the changeset directly + * with a {@link MongockTemplate} over the context's {@link MongoTemplate}. The collections are emptied before each test. + * Changeset 032 ({@code normalizeEmailBackfill}) is covered by {@code EmailNormalizationBackfillTest} and + * {@code EmailBackfillEndToEndTest} and is only checked here as part of the startup record. + * + *

    Re-runs. Mongock records each changeset and runs it once, so a second run of a changeset happens only through a + * manual re-run; the tests that run a changeset twice describe what such a re-run does, not something the application + * does by itself. + * + *

    The container is {@code mongo:4.0.28}, so {@code addTimeSeriesSnapshotHistory} takes its {@code < 5} ({@code $out}) + * branch; its {@code >= 5} branch is not reachable here and stays uncovered. {@link ArchiveSnapshotTask} runs once at + * context start (initialDelay 0) against the same collections; the test waits for the scheduler thread to go idle before it + * seeds anything. The task's own {@code < 5} pipeline is L2-11b's. + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("databaseChangelog") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +public class DatabaseChangelogMigrationsTest { + + private static final String TAG = "[DatabaseChangelogMigrationsTest] "; + + private static final String CHANGE_LOG_COLLECTION = "mongockChangeLog"; + private static final String CHANGE_ID_FIELD = "changeId"; + private static final String MONGOCK_SYSTEM_CHANGE_PREFIX = "system-change-"; + private static final String STATE_FIELD = "state"; + private static final String STATE_EXECUTED = "EXECUTED"; + + /** Number of {@code @ChangeSet} methods on DatabaseChangelog (order 001-032, with gaps); update when one is added. */ + private static final int DECLARED_CHANGE_SETS = 29; + + private static final String FOLDER = "folder"; + private static final String USER = "user"; + private static final String APPLICATION = "application"; + private static final String APPLICATION_VERSION = "applicationVersion"; + private static final String APPLICATION_RECORD = "applicationRecord"; + private static final String SNAPSHOT = "applicationHistorySnapshot"; + private static final String SNAPSHOT_TS = "applicationHistorySnapshotTS"; + + private static final String EMAIL = "EMAIL"; + private static final String GOOGLE = "GOOGLE"; + private static final String FIRST_VERSION_TAG = "1.0.0"; + private static final int KEEP_DAYS_TOLERANCE_DAYS = 1; + private static final int MONGO_TIME_SERIES_MAJOR = 5; + private static final String SCHEDULER_THREAD = "scheduling-1"; + private static final Duration ARCHIVE_WAIT = Duration.ofSeconds(60); + + @Autowired + private MongoTemplate mongoTemplate; + @Autowired + private CommonConfig commonConfig; + + private MongoDatabase db; + private MongockTemplate mongock; + private final DatabaseChangelog changelog = new DatabaseChangelog(); + + /** + * The scheduler of the application runs the archive task on its thread {@value #SCHEDULER_THREAD} (the task is + * scheduled with initialDelay 0 and fires when the context is refreshed, before the Mongock runner and so before + * the test instance exists). Wait, bounded, until that thread exists and is no longer runnable, i.e. the start-up + * run has finished. Console capture of the task's completion message was tried and does not work: the message is + * not in the captured output. + */ + @BeforeAll + public void awaitArchiveTaskOfContextStart() throws InterruptedException { + long deadline = System.nanoTime() + ARCHIVE_WAIT.toNanos(); + while (System.nanoTime() < deadline) { + Thread scheduler = Thread.getAllStackTraces().keySet().stream() + .filter(t -> SCHEDULER_THREAD.equals(t.getName())).findFirst().orElse(null); + if (scheduler != null && scheduler.getState() != Thread.State.RUNNABLE) { + return; + } + TimeUnit.MILLISECONDS.sleep(50); + } + throw new AssertionError("the scheduler thread did not become idle within " + ARCHIVE_WAIT); + } + + @BeforeEach + public void emptyTheCollections() { + db = mongoTemplate.getDb(); + mongock = new MongockTemplate(mongoTemplate); + db.getCollection(APPLICATION_RECORD).drop(); + for (String name : List.of(FOLDER, USER, APPLICATION, APPLICATION_VERSION, SNAPSHOT, SNAPSHOT_TS)) { + db.getCollection(name).deleteMany(new Document()); + } + // 029 built the unique (applicationId, tag) index at startup; a test that dropped the collection gets it back. + changelog.addTagIndexToRecord(mongock, commonConfig); + } + + // ---------------------------------------------------------------- start-up run + + @Test + public void startup_recordsEveryChangeSetAsExecuted() { + Set declared = new TreeSet<>(); + for (Method method : DatabaseChangelog.class.getDeclaredMethods()) { + ChangeSet changeSet = method.getAnnotation(ChangeSet.class); + if (changeSet != null) { + declared.add(changeSet.id()); + } + } + // Mongock also records its own system change (ids "system-change-00001" and "..._before"); only ours are compared. + List recorded = db.getCollection(CHANGE_LOG_COLLECTION).find().into(new ArrayList<>()).stream() + .filter(d -> !d.getString(CHANGE_ID_FIELD).startsWith(MONGOCK_SYSTEM_CHANGE_PREFIX)) + .collect(Collectors.toList()); + Set recordedIds = recorded.stream().map(d -> d.getString(CHANGE_ID_FIELD)) + .collect(Collectors.toCollection(TreeSet::new)); + log.info("{}declared change sets: {}, recorded: {}", TAG, declared.size(), recordedIds.size()); + + assertThat(declared).as("change sets declared on DatabaseChangelog").hasSize(DECLARED_CHANGE_SETS); + assertThat(recordedIds).as("change ids recorded in " + CHANGE_LOG_COLLECTION + " of this context's database") + .isEqualTo(declared); + assertThat(recorded).extracting(d -> d.getString(STATE_FIELD)).containsOnly(STATE_EXECUTED); + assertThat(recordedIds).contains("init-indexes", "normalize-email-backfill"); + } + + // ---------------------------------------------------------------- 024 fill-create-at + + @Test + public void fillCreateAt_setsCreatedAtOnlyWhereMissing_andIsIdempotent() { + Date original = new Date(1_600_000_000_000L); + Instant before = Instant.now(); + ObjectId missing = new ObjectId(); + ObjectId present = new ObjectId(); + db.getCollection(FOLDER).insertMany(List.of( + new Document("_id", missing).append("gid", uniqueGid()).append("name", "no createdAt"), + new Document("_id", present).append("gid", uniqueGid()).append("name", "has createdAt") + .append("createdAt", original))); + + changelog.fillCreateAt(mongock); + + Document filled = byId(FOLDER, missing); + Document kept = byId(FOLDER, present); + log.info("{}fillCreateAt: filled={} kept={}", TAG, filled.get("createdAt"), kept.get("createdAt")); + assertThat(filled.getDate("createdAt").toInstant()).isBetween(before.minusSeconds(1), Instant.now().plusSeconds(1)); + assertThat(kept.getDate("createdAt")).as("an existing createdAt is not touched").isEqualTo(original); + + Date firstFill = filled.getDate("createdAt"); + changelog.fillCreateAt(mongock); + assertThat(byId(FOLDER, missing).getDate("createdAt")).as("second run changes nothing").isEqualTo(firstFill); + assertThat(byId(FOLDER, present).getDate("createdAt")).isEqualTo(original); + assertThat(db.getCollection(FOLDER).countDocuments()).isEqualTo(2); + } + + // ---------------------------------------------------------------- 027 populate-email-in-user-connections + + @Test + public void populateEmailInUserConnections_copiesNameIntoEmailConnectionsThatLackEmail() { + ObjectId lacking = new ObjectId(); + ObjectId alreadySet = new ObjectId(); + ObjectId mixed = new ObjectId(); + db.getCollection(USER).insertMany(List.of( + new Document("_id", lacking).append("connections", List.of( + connection(EMAIL, "lacking@example.com", null, false), + connection(GOOGLE, "google name", null, false))), + new Document("_id", alreadySet).append("connections", List.of( + connection(EMAIL, "name@example.com", "kept@example.com", true))), + // EMAIL connection with an email next to a GOOGLE one without: selected by the query, changed by nothing + new Document("_id", mixed).append("connections", List.of( + connection(EMAIL, "mixed@example.com", "mixed-email@example.com", true), + connection(GOOGLE, "mixed google", null, false))))); + + changelog.populateEmailInUserConnections(mongock, commonConfig); + + List lackingConnections = connectionsOf(lacking); + log.info("{}populateEmail: lacking={}", TAG, lackingConnections); + assertThat(lackingConnections.get(0).getString("email")).isEqualTo("lacking@example.com"); + assertThat(lackingConnections.get(1).containsKey("email")).as("a non-EMAIL connection is left alone").isFalse(); + assertThat(connectionsOf(alreadySet).get(0).getString("email")).isEqualTo("kept@example.com"); + assertThat(connectionsOf(mixed).get(0).getString("email")).isEqualTo("mixed-email@example.com"); + assertThat(connectionsOf(mixed).get(1).containsKey("email")).isFalse(); + } + + /** + * An EMAIL connection whose {@code email} is stored as an explicit null matches the changeset's query but is skipped + * by its loop ({@code !connection.containsKey("email")}, DatabaseChangelog:421), so it stays null. Pinned as observed; + * the changeset runs once per database (Mongock), so this is a property of a manual re-run on such data. + */ + @Test + public void populateEmailInUserConnections_anExplicitNullEmailIsNotFilled() { + ObjectId id = new ObjectId(); + db.getCollection(USER).insertOne(new Document("_id", id).append("connections", + List.of(connection(EMAIL, "null-email@example.com", null, true)))); + + changelog.populateEmailInUserConnections(mongock, commonConfig); + + Document connection = connectionsOf(id).get(0); + log.info("{}populateEmail explicit null: {}", TAG, connection); + assertThat(connection.containsKey("email")).isTrue(); + assertThat(connection.get("email")).isNull(); + } + + // ---------------------------------------------------------------- 028 published-to-record, 029 + + @Test + public void publishedToRecord_createsOneVersionRecordPerPublishedApplication() { + ObjectId published = new ObjectId(); + ObjectId unpublished = new ObjectId(); + Instant before = Instant.now(); + db.getCollection(APPLICATION).insertMany(List.of( + new Document("_id", published).append("gid", uniqueGid()).append("createdBy", "creator-1") + .append("publishedApplicationDSL", new Document("comp", new Document("name", "table1")).append("version", 3)), + new Document("_id", unpublished).append("gid", uniqueGid()).append("createdBy", "creator-2") + .append("editingApplicationDSL", new Document("comp", "only editing")))); + + changelog.publishedToRecord(mongock, commonConfig); + + List versions = db.getCollection(APPLICATION_VERSION).find().into(new ArrayList<>()); + log.info("{}publishedToRecord: {}", TAG, versions); + assertThat(versions).hasSize(1); + Document record = versions.get(0); + assertThat(record.getString("applicationId")).isEqualTo(published.toHexString()); + assertThat(record.getString("tag")).isEqualTo(FIRST_VERSION_TAG); + assertThat(record.getString("commitMessage")).isEmpty(); + assertThat(record.getString("createdBy")).isEqualTo("creator-1"); + assertThat(record.getString("modifiedBy")).isEqualTo("creator-1"); + Document dsl = record.get("applicationDSL", Document.class); + assertThat(dsl.get("comp", Document.class).getString("name")).isEqualTo("table1"); + assertThat(dsl.getInteger("version")).isEqualTo(3); + assertThat(record.getDate("createdAt").toInstant()).isBetween(before.minusSeconds(1), Instant.now().plusSeconds(1)); + assertThat(record.getDate("updatedAt")).isNotNull(); + } + + /** + * A second run inserts the same (applicationId, tag) again and the unique index from changeset 029 rejects it, so the + * changeset is not idempotent. Mongock runs it once per database; only a manual re-run gets here. + */ + @Test + public void publishedToRecord_runTwice_failsOnTheUniqueApplicationIdTagIndex() { + ObjectId id = new ObjectId(); + db.getCollection(APPLICATION).insertOne(new Document("_id", id).append("gid", uniqueGid()).append("createdBy", "c") + .append("publishedApplicationDSL", new Document("a", 1))); + changelog.publishedToRecord(mongock, commonConfig); + + assertThatThrownBy(() -> changelog.publishedToRecord(mongock, commonConfig)) + .as("second run").isInstanceOf(DuplicateKeyException.class); + assertThat(db.getCollection(APPLICATION_VERSION).countDocuments()).isEqualTo(1); + } + + // ---------------------------------------------------------------- 030 rename + + @Test + public void renameApplicationRecordCollection_renamesWhenPresent() { + db.getCollection(APPLICATION_VERSION).drop(); + db.getCollection(APPLICATION_RECORD).insertOne(new Document("applicationId", "app-1").append("tag", "x")); + + changelog.renameApplicationRecordCollection(mongock, db); + + assertThat(collectionNames()).contains(APPLICATION_VERSION).doesNotContain(APPLICATION_RECORD); + List moved = db.getCollection(APPLICATION_VERSION).find().into(new ArrayList<>()); + assertThat(moved).extracting(d -> d.getString("applicationId")).containsExactly("app-1"); + } + + @Test + public void renameApplicationRecordCollection_skipsWhenTheOldCollectionIsAbsent() { + db.getCollection(APPLICATION_VERSION).insertOne(new Document("applicationId", "stays")); + + changelog.renameApplicationRecordCollection(mongock, db); + + assertThat(collectionNames()).doesNotContain(APPLICATION_RECORD).contains(APPLICATION_VERSION); + assertThat(db.getCollection(APPLICATION_VERSION).countDocuments()).isEqualTo(1); + } + + // ---------------------------------------------------------------- 031 delete-old-super-admin + + @Test + public void deleteOldSuperAdmin_keepsOnlyTheNewestSuperAdmin() { + ObjectId oldest = new ObjectId(); + ObjectId middle = new ObjectId(); + ObjectId newest = new ObjectId(); + ObjectId ordinary = new ObjectId(); + db.getCollection(USER).insertMany(List.of( + admin(oldest, 1_000_000L), admin(newest, 3_000_000L), admin(middle, 2_000_000L), + new Document("_id", ordinary).append("createdAt", new Date(500L)).append("connections", List.of(connection(EMAIL, "ordinary", "o@example.com", true))))); + + changelog.deleteOldSuperAdmin(mongock, db); + + List remaining = db.getCollection(USER).find().into(new ArrayList<>()).stream() + .map(d -> d.getObjectId("_id")).collect(Collectors.toList()); + log.info("{}deleteOldSuperAdmin remaining: {}", TAG, remaining); + assertThat(remaining).containsExactlyInAnyOrder(newest, ordinary); + } + + @Test + public void deleteOldSuperAdmin_aSingleSuperAdminStays() { + ObjectId only = new ObjectId(); + db.getCollection(USER).insertOne(admin(only, 1_000L)); + + changelog.deleteOldSuperAdmin(mongock, db); + + assertThat(db.getCollection(USER).countDocuments()).isEqualTo(1); + assertThat(byId(USER, only)).isNotNull(); + } + + // ---------------------------------------------------------------- 026 time-series snapshot history (MongoDB < 5) + + @Test + public void addTimeSeriesSnapshotHistory_onMongoBelow5_movesOnlySnapshotsOlderThanTheKeepDuration() { + assertThat(mongoMajorVersion()).as("the container's MongoDB major version (the < 5 branch is the one that runs)") + .isLessThan(MONGO_TIME_SERIES_MAJOR); + Instant oldEnough = Instant.now().minus(commonConfig.getQuery().getAppSnapshotKeepDuration() + KEEP_DAYS_TOLERANCE_DAYS, java.time.temporal.ChronoUnit.DAYS); + Instant recent = Instant.now().minus(1, java.time.temporal.ChronoUnit.DAYS); + ObjectId oldOne = new ObjectId(); + ObjectId oldTwo = new ObjectId(); + ObjectId kept = new ObjectId(); + db.getCollection(SNAPSHOT).insertMany(List.of( + snapshot(oldOne, "app-old-1", oldEnough), snapshot(oldTwo, "app-old-2", oldEnough.minusSeconds(60)), + snapshot(kept, "app-recent", recent))); + + changelog.addTimeSeriesSnapshotHistory(mongock, commonConfig); + + List archived = db.getCollection(SNAPSHOT_TS).find().into(new ArrayList<>()); + List remaining = db.getCollection(SNAPSHOT).find().into(new ArrayList<>()); + log.info("{}archived={} remaining={}", TAG, archived, remaining); + assertThat(archived).extracting(d -> d.getString("applicationId")).containsExactlyInAnyOrder("app-old-1", "app-old-2"); + assertThat(archived).extracting(d -> d.getObjectId("id")).containsExactlyInAnyOrder(oldOne, oldTwo); + assertThat(archived.get(0).get("dsl", Document.class).getString("marker")).isEqualTo("dsl-of-" + archived.get(0).getString("applicationId")); + assertThat(remaining).extracting(d -> d.getObjectId("_id")).containsExactly(kept); + } + + /** + * On MongoDB below 5 the changeset moves the old snapshots with {@code $out}, which replaces the target collection: + * what the time-series collection held before the run is gone afterwards, and a run with nothing to move empties it. + * Pinned as observed (a manual re-run only: Mongock runs the changeset once per database). + */ + @Test + public void addTimeSeriesSnapshotHistory_onMongoBelow5_outReplacesWhatTheTargetHeld() { + Instant oldEnough = Instant.now().minus(commonConfig.getQuery().getAppSnapshotKeepDuration() + KEEP_DAYS_TOLERANCE_DAYS, java.time.temporal.ChronoUnit.DAYS); + db.getCollection(SNAPSHOT_TS).insertOne(snapshot(new ObjectId(), "archived-earlier", oldEnough)); + db.getCollection(SNAPSHOT).insertOne(snapshot(new ObjectId(), "moved-now", oldEnough)); + + changelog.addTimeSeriesSnapshotHistory(mongock, commonConfig); + + List afterFirst = applicationIds(SNAPSHOT_TS); + log.info("{}after first run: {}", TAG, afterFirst); + assertThat(afterFirst).as("the earlier archive row is replaced").containsExactly("moved-now"); + + changelog.addTimeSeriesSnapshotHistory(mongock, commonConfig); + + assertThat(applicationIds(SNAPSHOT_TS)).as("a run with nothing to move empties the archive").isEmpty(); + } + + // ---------------------------------------------------------------- helpers of the changelog + + @Test + public void dropIndexIfExists_dropsAKnownIndex_andIgnoresAnUnknownOne() { + String field = "probe" + UUID.randomUUID().toString().replace("-", ""); + DatabaseChangelog.ensureIndexes(mongock, org.lowcoder.domain.folder.model.Folder.class, DatabaseChangelog.makeIndex(field)); + MongoCollection folders = db.getCollection(FOLDER); + assertThat(indexNames(folders)).contains(field); + + DatabaseChangelog.dropIndexIfExists(mongock, org.lowcoder.domain.folder.model.Folder.class, field); + assertThat(indexNames(folders)).doesNotContain(field); + + DatabaseChangelog.dropIndexIfExists(mongock, org.lowcoder.domain.folder.model.Folder.class, field); + assertThat(indexNames(folders)).as("unknown index: the exception is swallowed").doesNotContain(field); + } + + // ---------------------------------------------------------------- utilities + + private static String uniqueGid() { + return UUID.randomUUID().toString(); + } + + private Document byId(String collection, ObjectId id) { + return db.getCollection(collection).find(new Document("_id", id)).first(); + } + + private List connectionsOf(ObjectId userId) { + return byId(USER, userId).getList("connections", Document.class); + } + + private static Document connection(String authId, String name, String email, boolean withEmailKey) { + Document connection = new Document("authId", authId).append("name", name) + .append("source", authId).append("rawId", UUID.randomUUID().toString()); + if (withEmailKey) { + connection.append("email", email); + } + return connection; + } + + private static Document admin(ObjectId id, long createdAtMillis) { + return new Document("_id", id).append("superAdmin", true).append("createdAt", new Date(createdAtMillis)) + .append("connections", List.of(connection(EMAIL, "admin-" + id, "admin@example.com", true))); + } + + private static Document snapshot(ObjectId id, String applicationId, Instant createdAt) { + return new Document("_id", id).append("applicationId", applicationId) + .append("dsl", new Document("marker", "dsl-of-" + applicationId)) + .append("context", new Document("ctx", 1)).append("createdAt", Date.from(createdAt)) + .append("createdBy", "u").append("modifiedBy", "u").append("updatedAt", Date.from(createdAt)); + } + + private List applicationIds(String collection) { + return db.getCollection(collection).find().into(new ArrayList<>()).stream() + .map(d -> d.getString("applicationId")).collect(Collectors.toList()); + } + + private List collectionNames() { + return db.listCollectionNames().into(new ArrayList<>()); + } + + private static List indexNames(MongoCollection collection) { + return collection.listIndexes().into(new ArrayList<>()).stream().map(d -> d.getString("name")).collect(Collectors.toList()); + } + + private int mongoMajorVersion() { + Document buildInfo = db.runCommand(new Document("buildInfo", 1)); + return Integer.parseInt(buildInfo.getString("version").split("\\.")[0]); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/EmailNormalizationBackfillTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/EmailNormalizationBackfillTest.java index abc80818e6..bfcf8c2f8f 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/EmailNormalizationBackfillTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/EmailNormalizationBackfillTest.java @@ -30,7 +30,7 @@ import lombok.extern.slf4j.Slf4j; /** - * Drives {@link EmailNormalizationBackfill} directly against the embedded mongod. + * Drives {@link EmailNormalizationBackfill} directly against the MongoDB test container. * *

    The changeset itself is never driven through Mongock here. Under {@code @ActiveProfiles("test")}, * {@code DatabaseChangelog} is {@code @Profile("!test")} and Mongock skips it — which also means the real @@ -39,7 +39,7 @@ * duplicate-key path testable in isolation, which it would not be against the shared {@code user} * collection. * - *

    Throwaway collections per test, because one embedded mongod is shared across every {@code test}-profile + *

    Throwaway collections per test, because one database is shared across every {@code test}-profile * class and the real {@code user} collection holds other suites' fixtures. * *

    {@code classes = ServerApplication.class} is required: {@code @SpringBootTest} only searches upwards diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobAddPtmFieldsTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobAddPtmFieldsTest.java new file mode 100644 index 0000000000..369fbb95fc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobAddPtmFieldsTest.java @@ -0,0 +1,95 @@ +package org.lowcoder.runner.migrations.job; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.service.ApplicationService; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit K3 (task L2-12, lane L5): {@code AddPtmFieldsJobImpl}, the job behind changeset 021 ({@code add-ptm-fields-to-applications}): + * every application is read, the three flags {@code publicToAll}, {@code publicToMarketplace} and {@code agencyProfile} that are + * missing or false are set to {@code false}, and every application is written back with its id. Mockito only. + * + *

    Limits: the flags are read from the objects' fields by reflection (the getters map a null to false, which would hide the + * difference this job makes). + */ +@ExtendWith(MockitoExtension.class) +public class MigrationJobAddPtmFieldsTest { + + static final String TAG = "[MigrationJobAddPtmFieldsTest] "; + + @Mock + private ApplicationService applicationService; + @InjectMocks + private AddPtmFieldsJobImpl job; + + private static Application application(String id, Boolean publicToAll, Boolean publicToMarketplace, Boolean agencyProfile) { + Application application = new Application(); + ReflectionTestUtils.setField(application, "id", id); + application.setPublicToAll(publicToAll); + application.setPublicToMarketplace(publicToMarketplace); + application.setAgencyProfile(agencyProfile); + return application; + } + + private static Object flag(Application application, String name) { + return ReflectionTestUtils.getField(application, name); + } + + @Test + public void injectionReachedTheService() { + assertSame(applicationService, ReflectionTestUtils.getField(job, "applicationService")); + } + + @Test + public void missingFlagsBecomeFalseAndTrueFlagsStayTrueAndEveryApplicationIsWrittenBackOnce() { + Application missing = application("a1", null, null, null); + Application allTrue = application("a2", true, true, true); + Application mixed = application("a3", true, null, false); + when(applicationService.findAll()).thenReturn(Flux.just(missing, allTrue, mixed)); + when(applicationService.updateById(anyString(), any(Application.class))).thenReturn(Mono.just(true)); + + job.migrateApplicationsToInitPtmFields(); + + for (String name : List.of("publicToAll", "publicToMarketplace", "agencyProfile")) { + System.out.println(TAG + name + ": " + flag(missing, name) + ", " + flag(allTrue, name) + ", " + flag(mixed, name)); + assertEquals(Boolean.FALSE, flag(missing, name), "a missing " + name + " is initialised to false (not left null)"); + assertEquals(Boolean.TRUE, flag(allTrue, name), "a true " + name + " stays true"); + } + assertEquals(Boolean.TRUE, flag(mixed, "publicToAll")); + assertEquals(Boolean.FALSE, flag(mixed, "publicToMarketplace")); + assertEquals(Boolean.FALSE, flag(mixed, "agencyProfile")); + ArgumentCaptor ids = ArgumentCaptor.forClass(String.class); + ArgumentCaptor written = ArgumentCaptor.forClass(Application.class); + verify(applicationService, times(3)).updateById(ids.capture(), written.capture()); + assertEquals(List.of("a1", "a2", "a3"), ids.getAllValues()); + assertSame(missing, written.getAllValues().get(0)); + assertSame(allTrue, written.getAllValues().get(1)); + assertSame(mixed, written.getAllValues().get(2)); + } + + @Test + public void noApplicationMeansNoUpdate() { + when(applicationService.findAll()).thenReturn(Flux.empty()); + job.migrateApplicationsToInitPtmFields(); + verify(applicationService, never()).updateById(anyString(), any(Application.class)); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobAddSuperAdminTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobAddSuperAdminTest.java new file mode 100644 index 0000000000..28a6d21829 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobAddSuperAdminTest.java @@ -0,0 +1,230 @@ +package org.lowcoder.runner.migrations.job; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.lowcoder.api.authentication.service.AuthenticationApiServiceImpl; +import org.lowcoder.domain.authentication.context.FormAuthRequestContext; +import org.lowcoder.domain.organization.service.OrgMemberService; +import org.lowcoder.domain.user.model.AuthUser; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.sdk.config.CommonConfig; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit K3 (task L2-12, lane L5): {@code AddSuperAdminUserImpl}, the job behind changeset 020 ({@code add-super-admin-user}, + * {@code DatabaseChangelog.java:216-218}): it builds the super admin's login from the server config (a default name, a configured + * or generated password), creates or updates the user, registers a new one, sets the password, marks the user as super admin and + * adds it to every organization as admin. Mockito only. + * + *

    Limits: the services are mocks that answer at once unless a test says otherwise; nothing is persisted. + */ +@ExtendWith(MockitoExtension.class) +public class MigrationJobAddSuperAdminTest { + + static final String TAG = "[MigrationJobAddSuperAdminTest] "; + static final String USER_ID = "super-1"; + static final String DEFAULT_NAME = "admin@lowcoder.org"; + static final String CONFIGURED_NAME = "root@corp.example.com"; + static final String CONFIGURED_PASSWORD = "Configured-Pass-1"; + + @Mock + private AuthenticationApiServiceImpl authenticationApiService; + @Mock + private CommonConfig commonConfig; + @Mock + private UserService userService; + @Mock + private OrgMemberService orgMemberService; + @InjectMocks + private AddSuperAdminUserImpl job; + + private CommonConfig.SuperAdmin superAdmin(String name, String password) { + CommonConfig.SuperAdmin superAdmin = new CommonConfig.SuperAdmin(); + superAdmin.setUserName(name); + superAdmin.setPassword(password); + when(commonConfig.getSuperAdmin()).thenReturn(superAdmin); + return superAdmin; + } + + private static User user(boolean isNew) { + User user = User.builder().isNewUser(isNew).build(); + ReflectionTestUtils.setField(user, "id", USER_ID); + return user; + } + + /** All collaborators answer at once and complete. */ + private void everythingSucceeds(boolean isNew) { + when(authenticationApiService.updateOrCreateUser(any(AuthUser.class), anyBoolean(), anyBoolean())).thenReturn(Mono.just(user(isNew))); + if (isNew) { + when(authenticationApiService.onUserRegister(any(User.class), anyBoolean())).thenReturn(Mono.empty()); + } + when(userService.setPassword(anyString(), anyString())).thenReturn(Mono.just(true)); + when(userService.markAsSuperAdmin(anyString())).thenReturn(Mono.just(true)); + when(orgMemberService.addToAllOrgAsAdminIfNot(anyString())).thenReturn(Mono.empty()); + } + + private AuthUser capturedAuthUser() { + ArgumentCaptor captor = ArgumentCaptor.forClass(AuthUser.class); + verify(authenticationApiService).updateOrCreateUser(captor.capture(), eq(false), eq(true)); + return captor.getValue(); + } + + /** Every collaborator of the job is the mock (a constructor or field mismatch would leave one null and fail here). */ + @Test + public void injectionReachedEveryField() { + assertSame(authenticationApiService, ReflectionTestUtils.getField(job, "authenticationApiService")); + assertSame(commonConfig, ReflectionTestUtils.getField(job, "commonConfig")); + assertSame(userService, ReflectionTestUtils.getField(job, "userService")); + assertSame(orgMemberService, ReflectionTestUtils.getField(job, "orgMemberService")); + } + + @Test + public void configuredNameAndPasswordAreUsedAndTheStepsRunInOrderForANewUser() { + superAdmin(CONFIGURED_NAME, CONFIGURED_PASSWORD); + everythingSucceeds(true); + + job.addOrUpdateSuperAdmin(); + + AuthUser authUser = capturedAuthUser(); + assertEquals(CONFIGURED_NAME, authUser.getUid()); + assertEquals(CONFIGURED_NAME, authUser.getUsername()); + assertEquals(CONFIGURED_NAME, authUser.getEmail()); + FormAuthRequestContext context = assertInstanceOf(FormAuthRequestContext.class, authUser.getAuthContext()); + assertEquals(CONFIGURED_PASSWORD, context.getPassword()); + assertEquals(CONFIGURED_NAME, context.getLoginId()); + assertNotNull(context.getAuthConfig()); + InOrder order = Mockito.inOrder(authenticationApiService, userService, orgMemberService); + order.verify(authenticationApiService).updateOrCreateUser(any(AuthUser.class), eq(false), eq(true)); + order.verify(authenticationApiService).onUserRegister(any(User.class), eq(true)); + order.verify(userService).setPassword(USER_ID, CONFIGURED_PASSWORD); + order.verify(userService).markAsSuperAdmin(USER_ID); + order.verify(orgMemberService).addToAllOrgAsAdminIfNot(USER_ID); + } + + @Test + public void anExistingUserIsNotRegisteredAgainButTheOtherStepsStillRun() { + superAdmin(CONFIGURED_NAME, CONFIGURED_PASSWORD); + everythingSucceeds(false); + job.addOrUpdateSuperAdmin(); + verify(authenticationApiService, never()).onUserRegister(any(User.class), anyBoolean()); + verify(userService).setPassword(USER_ID, CONFIGURED_PASSWORD); + verify(userService).markAsSuperAdmin(USER_ID); + verify(orgMemberService).addToAllOrgAsAdminIfNot(USER_ID); + } + + @Test + public void aBlankOrMissingNameFallsBackToTheDefault() { + for (String blank : new String[] {null, "", " "}) { + Mockito.reset(authenticationApiService, userService, orgMemberService, commonConfig); + superAdmin(blank, CONFIGURED_PASSWORD); + everythingSucceeds(false); + job.addOrUpdateSuperAdmin(); + AuthUser authUser = capturedAuthUser(); + assertEquals(DEFAULT_NAME, authUser.getUsername(), "name [" + blank + "]"); + assertEquals(DEFAULT_NAME, authUser.getEmail()); + } + } + + @Test + public void aBlankPasswordIsReplacedByAGeneratedOneThatDiffersEachTime() { + List passwords = new ArrayList<>(); + for (String blank : new String[] {null, " "}) { + Mockito.reset(authenticationApiService, userService, orgMemberService, commonConfig); + superAdmin(CONFIGURED_NAME, blank); + everythingSucceeds(false); + job.addOrUpdateSuperAdmin(); + String fromContext = ((FormAuthRequestContext) capturedAuthUser().getAuthContext()).getPassword(); + ArgumentCaptor sent = ArgumentCaptor.forClass(String.class); + verify(userService).setPassword(eq(USER_ID), sent.capture()); + System.out.println(TAG + "generated password length " + fromContext.length()); + assertEquals(fromContext, sent.getValue(), "the password set is the one in the login context"); + assertTrue(fromContext.length() >= 8, "length " + fromContext.length()); + assertFalse(fromContext.isBlank()); + passwords.add(fromContext); + } + assertNotEquals(passwords.get(0), passwords.get(1)); + } + + /** + * Pins the plan section 9 row "super-admin migration (changeset 020) runs fire-and-forget via subscribe(): a failure is lost and + * the changeset is recorded as done". The job ends its pipeline with {@code .subscribe()} + * ({@code AddSuperAdminUserImpl.java:51}) and the changeset ({@code DatabaseChangelog.java:216-218}) returns as soon as the job + * does, so Mongock records the changeset as executed while the later steps may still be pending, and an error in a step never + * reaches the caller. Two assertions: a step that is still pending when the job returns (the later steps have not run yet), and + * a step that fails (the job returns normally, the later steps never run, the error goes to Reactor's dropped-error hook). + * A fix (returning or blocking on the pipeline) changes both on purpose. + */ + @Test + public void theJobReturnsWhileAStepIsPendingAndAFailureNeverReachesTheCaller_pinsTheSection9Row() { + superAdmin(CONFIGURED_NAME, CONFIGURED_PASSWORD); + when(authenticationApiService.updateOrCreateUser(any(AuthUser.class), anyBoolean(), anyBoolean())).thenReturn(Mono.just(user(false))); + when(userService.setPassword(anyString(), anyString())).thenReturn(Mono.just(true)); + Sinks.One pending = Sinks.one(); + when(userService.markAsSuperAdmin(USER_ID)).thenReturn(pending.asMono()); + Mockito.lenient().when(orgMemberService.addToAllOrgAsAdminIfNot(USER_ID)).thenReturn(Mono.empty()); // never reached today + + job.addOrUpdateSuperAdmin(); + + System.out.println(TAG + "the job has returned; the marking step is still pending"); + verify(userService).markAsSuperAdmin(USER_ID); + verify(orgMemberService, never()).addToAllOrgAsAdminIfNot(anyString()); + AtomicReference dropped = new AtomicReference<>(); + Hooks.onErrorDropped(dropped::set); + try { + pending.tryEmitError(new IllegalStateException("marking failed")); + System.out.println(TAG + "the step failed after the job returned: dropped error " + dropped.get()); + assertNotNull(dropped.get(), "the error went to Reactor's dropped-error hook, not to the caller"); + assertEquals("marking failed", dropped.get().getCause() == null ? dropped.get().getMessage() : dropped.get().getCause().getMessage()); + verify(orgMemberService, never()).addToAllOrgAsAdminIfNot(anyString()); + } finally { + Hooks.resetOnErrorDropped(); + } + } + + @Test + public void aStepThatFailsAtOnceDoesNotMakeTheJobThrow() { + superAdmin(CONFIGURED_NAME, CONFIGURED_PASSWORD); + when(authenticationApiService.updateOrCreateUser(any(AuthUser.class), anyBoolean(), anyBoolean())).thenReturn(Mono.just(user(false))); + when(userService.setPassword(anyString(), anyString())).thenReturn(Mono.error(new IllegalStateException("password failed"))); + AtomicReference dropped = new AtomicReference<>(); + Hooks.onErrorDropped(dropped::set); + try { + job.addOrUpdateSuperAdmin(); + System.out.println(TAG + "failing step, the job returned normally; dropped error " + dropped.get()); + assertNotNull(dropped.get()); + } finally { + Hooks.resetOnErrorDropped(); + } + verify(userService, never()).markAsSuperAdmin(anyString()); + verify(orgMemberService, never()).addToAllOrgAsAdminIfNot(anyString()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobCompleteAuthTypeTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobCompleteAuthTypeTest.java new file mode 100644 index 0000000000..1ebe8e5677 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobCompleteAuthTypeTest.java @@ -0,0 +1,127 @@ +package org.lowcoder.runner.migrations.job; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.model.OrganizationDomain; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.sdk.auth.AbstractAuthConfig; +import org.lowcoder.sdk.auth.EmailAuthConfig; +import org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig; +import org.lowcoder.sdk.auth.constants.AuthTypeConstants; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit K3 (task L2-12, lane L5): {@code CompleteAuthTypeImpl}, the job behind changeset 018 ({@code complete-auth-type}): it reads + * the organizations that have a domain, sets the auth type of their e-mail configs to {@code FORM}, leaves every other config + * alone, skips organizations without configs, and updates each of the others once. Mockito only. + * + *

    Limits: the query is captured and read, not run against a database. + */ +@ExtendWith(MockitoExtension.class) +public class MigrationJobCompleteAuthTypeTest { + + static final String TAG = "[MigrationJobCompleteAuthTypeTest] "; + static final String ORG_ONE = "org-1"; + static final String ORG_TWO = "org-2"; + static final String ORG_EMPTY = "org-empty"; + + @Mock + private ReactiveMongoTemplate reactiveMongoTemplate; + @Mock + private OrganizationService organizationService; + @InjectMocks + private CompleteAuthTypeImpl job; + + private static Organization organization(String id, List configs) { + Organization organization = new Organization(); + organization.setId(id); + OrganizationDomain domain = new OrganizationDomain(); + domain.setConfigs(new ArrayList<>(configs)); + organization.setOrganizationDomain(domain); + return organization; + } + + private static Oauth2SimpleAuthConfig oauth() { + return Oauth2SimpleAuthConfig.builder().id("g").source("GOOGLE").sourceName("GOOGLE").enable(true).enableRegister(true).clientId("c").clientSecret("s").build(); + } + + @Test + public void injectionReachedEveryField() { + assertSame(reactiveMongoTemplate, ReflectionTestUtils.getField(job, "reactiveMongoTemplate")); + assertSame(organizationService, ReflectionTestUtils.getField(job, "organizationService")); + } + + @Test + public void emailConfigsGetFormAndOthersAreLeftAloneAndEachOrganizationIsUpdatedOnce() { + EmailAuthConfig email = new EmailAuthConfig("e", true, true); + ReflectionTestUtils.setField(email, "authType", "OTHER"); + Oauth2SimpleAuthConfig google = oauth(); + String googleType = google.getAuthType(); + Organization one = organization(ORG_ONE, List.of(email, google)); + EmailAuthConfig secondEmail = new EmailAuthConfig("e2", true, false); + ReflectionTestUtils.setField(secondEmail, "authType", null); + Organization two = organization(ORG_TWO, List.of(secondEmail)); + Organization empty = organization(ORG_EMPTY, List.of()); + when(reactiveMongoTemplate.find(any(Query.class), eq(Organization.class))).thenReturn(Flux.just(one, empty, two)); + when(organizationService.update(anyString(), any(Organization.class))).thenReturn(Mono.just(true)); + + job.complete(); + + assertEquals(AuthTypeConstants.FORM, email.getAuthType(), "an e-mail config is completed, over any previous type"); + assertEquals(AuthTypeConstants.FORM, secondEmail.getAuthType(), "also from a missing type"); + assertEquals(googleType, google.getAuthType(), "a config of another kind is not touched"); + ArgumentCaptor ids = ArgumentCaptor.forClass(String.class); + verify(organizationService, times(2)).update(ids.capture(), any(Organization.class)); + assertEquals(List.of(ORG_ONE, ORG_TWO), ids.getAllValues()); + verify(organizationService, never()).update(eq(ORG_EMPTY), any(Organization.class)); + } + + @Test + public void theQuerySelectsOrganizationsThatHaveADomain() { + when(reactiveMongoTemplate.find(any(Query.class), eq(Organization.class))).thenReturn(Flux.empty()); + job.complete(); + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(reactiveMongoTemplate).find(query.capture(), eq(Organization.class)); + String text = query.getValue().getQueryObject().toJson(); + System.out.println(TAG + "query: " + text); + assertTrue(text.contains("organizationDomain") && text.contains("$ne"), text); + assertNotNull(query.getValue().getQueryObject().get("organizationDomain")); + verify(organizationService, never()).update(anyString(), any(Organization.class)); + } + + @Test + public void completeAuthTypeSetsFormOnlyOnEmailConfigs() { + EmailAuthConfig email = new EmailAuthConfig("e", true, true); + ReflectionTestUtils.setField(email, "authType", "OTHER"); + job.completeAuthType(email); + assertEquals(AuthTypeConstants.FORM, email.getAuthType()); + Oauth2SimpleAuthConfig google = oauth(); + String before = google.getAuthType(); + job.completeAuthType(google); + assertEquals(before, google.getAuthType()); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobMigrateAuthConfigTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobMigrateAuthConfigTest.java new file mode 100644 index 0000000000..d9d5e81c7e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/MigrationJobMigrateAuthConfigTest.java @@ -0,0 +1,229 @@ +package org.lowcoder.runner.migrations.job; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.lowcoder.domain.organization.model.Organization; +import org.lowcoder.domain.organization.model.OrganizationDomain; +import org.lowcoder.domain.organization.repository.OrganizationRepository; +import org.lowcoder.domain.organization.service.OrganizationService; +import org.lowcoder.sdk.auth.AbstractAuthConfig; +import org.lowcoder.sdk.auth.EmailAuthConfig; +import org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig; +import org.lowcoder.sdk.config.AuthProperties; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.constants.AuthSourceConstants; +import org.lowcoder.sdk.constants.WorkspaceMode; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit K3 (task L2-12, lane L5): {@code MigrateAuthConfigJobImpl}, the job behind changeset 017 ({@code migrate-auth-configs}): + * in SAAS mode every organization that has a domain gets new ids and switched-on login and registration for its auth configs; + * in enterprise mode the organization of the instance gets the configs of the server's {@code AuthProperties} as its domain + * configs, minus the e-mail source. Mockito only: the services, the repository and the config are mocks, the organizations and + * auth configs are real objects. + * + *

    Limits: no database; the update is a mock, so what it would persist is not under test. + */ +@ExtendWith(MockitoExtension.class) +public class MigrationJobMigrateAuthConfigTest { + + static final String TAG = "[MigrationJobMigrateAuthConfigTest] "; + static final String ORG_ONE = "org-1"; + static final String ORG_TWO = "org-2"; + static final String OLD_ID = "old-id"; + + @Mock + private OrganizationService organizationService; + @Mock + private CommonConfig commonConfig; + @Mock + private AuthProperties authProperties; + @Mock + private OrganizationRepository organizationRepository; + @InjectMocks + private MigrateAuthConfigJobImpl job; + + private static Organization organization(String id, List configs) { + Organization organization = new Organization(); + organization.setId(id); + OrganizationDomain domain = new OrganizationDomain(); + domain.setConfigs(new ArrayList<>(configs)); + organization.setOrganizationDomain(domain); + return organization; + } + + private static Oauth2SimpleAuthConfig oauth(String id, String source) { + return Oauth2SimpleAuthConfig.builder().id(id).source(source).sourceName(source).enable(false).enableRegister(false).clientId("c").clientSecret("s").build(); + } + + private void mode(WorkspaceMode mode) { + CommonConfig.Workspace workspace = new CommonConfig.Workspace(); + workspace.setMode(mode); + when(commonConfig.getWorkspace()).thenReturn(workspace); + } + + /** Every field of the job received its mock (a constructor or field mismatch would leave one null and fail here). */ + @Test + public void injectionReachedEveryField() { + for (String field : List.of("organizationService", "commonConfig", "authProperties", "organizationRepository")) { + assertNotNull(ReflectionTestUtils.getField(job, field), field); + } + assertSame(organizationService, ReflectionTestUtils.getField(job, "organizationService")); + assertSame(commonConfig, ReflectionTestUtils.getField(job, "commonConfig")); + assertSame(authProperties, ReflectionTestUtils.getField(job, "authProperties")); + assertSame(organizationRepository, ReflectionTestUtils.getField(job, "organizationRepository")); + } + + @Test + public void saasModeGivesEveryConfigANewIdAndSwitchesLoginAndRegistrationOn() { + mode(WorkspaceMode.SAAS); + Organization one = organization(ORG_ONE, List.of(oauth(OLD_ID, "GOOGLE"), new EmailAuthConfig(OLD_ID, false, false))); + Organization two = organization(ORG_TWO, List.of(oauth(OLD_ID, "GITHUB"))); + when(organizationRepository.findByOrganizationDomainIsNotNull()).thenReturn(Flux.just(one, two)); + when(organizationService.update(anyString(), any(Organization.class))).thenReturn(Mono.just(true)); + + job.migrateAuthConfig(); + + ArgumentCaptor updated = ArgumentCaptor.forClass(Organization.class); + ArgumentCaptor ids = ArgumentCaptor.forClass(String.class); + verify(organizationService, times(2)).update(ids.capture(), updated.capture()); + assertEquals(List.of(ORG_ONE, ORG_TWO), ids.getAllValues()); + assertSame(one, updated.getAllValues().get(0)); + assertSame(two, updated.getAllValues().get(1)); + for (Organization organization : List.of(one, two)) { + for (AbstractAuthConfig config : organization.getAuthConfigs()) { + System.out.println(TAG + organization.getId() + " " + config.getSource() + " id=" + config.getId() + " enable=" + config.isEnable() + " register=" + config.isEnableRegister()); + assertNotEquals(OLD_ID, config.getId(), "a new id"); + assertTrue(config.isEnable()); + assertTrue(config.isEnableRegister()); + } + } + assertNotEquals(one.getAuthConfigs().get(0).getId(), one.getAuthConfigs().get(1).getId(), "each config gets its own id"); + verify(organizationService, never()).getOrganizationInEnterpriseMode(); + } + + @Test + public void saasModeWithNoOrganizationUpdatesNothingAndDoesNotFail() { + mode(WorkspaceMode.SAAS); + when(organizationRepository.findByOrganizationDomainIsNotNull()).thenReturn(Flux.empty()); + job.migrateAuthConfig(); + verify(organizationService, never()).update(anyString(), any(Organization.class)); + } + + @Test + public void enterpriseModeCopiesThePropertyConfigsToTheDomainWithoutTheEmailSource() { + mode(WorkspaceMode.ENTERPRISE); + Organization instanceOrganization = new Organization(); + instanceOrganization.setId(ORG_ONE); + assertNull(instanceOrganization.getOrganizationDomain()); + when(organizationService.getOrganizationInEnterpriseMode()).thenReturn(Mono.just(instanceOrganization)); + EmailAuthConfig email = new EmailAuthConfig(null, true, true); + Oauth2SimpleAuthConfig google = oauth(null, "GOOGLE"); + Oauth2SimpleAuthConfig github = oauth(null, "GITHUB"); + when(authProperties.getAuthConfigs()).thenReturn(List.of(email, google, github)); + when(organizationService.update(anyString(), any(Organization.class))).thenReturn(Mono.just(true)); + + job.migrateAuthConfig(); + + OrganizationDomain domain = instanceOrganization.getOrganizationDomain(); + assertNotNull(domain, "the domain is created when the organization has none"); + assertEquals(List.of("GOOGLE", "GITHUB"), domain.getConfigs().stream().map(AbstractAuthConfig::getSource).toList(), "the e-mail source is dropped, the order kept"); + assertNotEquals("GOOGLE", google.getId(), "an unset id reads as the source, so a generated id differs from it"); + assertNotEquals("GITHUB", github.getId()); + assertNotEquals(google.getId(), github.getId()); + verify(organizationService).update(ORG_ONE, instanceOrganization); + verify(organizationRepository, never()).findByOrganizationDomainIsNotNull(); + } + + @Test + public void enterpriseModeKeepsAnExistingDomainAndItsDomainName() { + mode(WorkspaceMode.ENTERPRISE); + Organization instanceOrganization = organization(ORG_ONE, List.of()); + instanceOrganization.getOrganizationDomain().setDomain("corp.example.com"); + OrganizationDomain existing = instanceOrganization.getOrganizationDomain(); + when(organizationService.getOrganizationInEnterpriseMode()).thenReturn(Mono.just(instanceOrganization)); + when(authProperties.getAuthConfigs()).thenReturn(List.of(oauth(null, "GOOGLE"))); + when(organizationService.update(anyString(), any(Organization.class))).thenReturn(Mono.just(true)); + job.migrateAuthConfig(); + assertSame(existing, instanceOrganization.getOrganizationDomain()); + assertEquals("corp.example.com", existing.getDomain()); + assertEquals(1, existing.getConfigs().size()); + } + + /** + * Observation: with no configured authentication ({@code AuthProperties} gives an empty list) the enterprise branch is a no-op on + * the organization (no domain is created, nothing is replaced) but the organization is still updated. + */ + @Test + public void enterpriseModeWithNoConfiguredAuthLeavesTheOrganizationAsItIsButStillUpdatesIt() { + mode(WorkspaceMode.ENTERPRISE); + Organization instanceOrganization = organization(ORG_ONE, List.of(oauth("keep", "GOOGLE"))); + when(organizationService.getOrganizationInEnterpriseMode()).thenReturn(Mono.just(instanceOrganization)); + when(authProperties.getAuthConfigs()).thenReturn(List.of()); + when(organizationService.update(anyString(), any(Organization.class))).thenReturn(Mono.just(true)); + job.migrateAuthConfig(); + assertEquals(List.of("keep"), instanceOrganization.getAuthConfigs().stream().map(AbstractAuthConfig::getId).toList()); + verify(organizationService).update(ORG_ONE, instanceOrganization); + + Organization bare = new Organization(); + bare.setId(ORG_TWO); + job.setAuthConfigs2OrganizationDomain(bare, null); + assertNull(bare.getOrganizationDomain(), "a null list is also a no-op"); + } + + /** + * Observation (server-controlled data, run once at migration time): a property config whose source is null makes the filter throw a + * NullPointerException, and an error from the update propagates raw out of the job (the changeset would fail the start-up). + */ + @Test + public void aNullSourceAndAFailingUpdateAreRawErrors() { + mode(WorkspaceMode.ENTERPRISE); + Organization instanceOrganization = new Organization(); + instanceOrganization.setId(ORG_ONE); + when(organizationService.getOrganizationInEnterpriseMode()).thenReturn(Mono.just(instanceOrganization)); + when(authProperties.getAuthConfigs()).thenReturn(List.of(oauth(null, null))); + assertThrows(NullPointerException.class, () -> job.migrateAuthConfig()); + + Organization other = organization(ORG_TWO, List.of(oauth(OLD_ID, "GOOGLE"))); + when(organizationService.getOrganizationInEnterpriseMode()).thenReturn(Mono.just(other)); + when(authProperties.getAuthConfigs()).thenReturn(List.of(oauth(null, "GOOGLE"))); + when(organizationService.update(anyString(), any(Organization.class))).thenReturn(Mono.error(new IllegalStateException("update failed"))); + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> job.migrateAuthConfig()); + assertEquals("update failed", thrown.getMessage()); + } + + /** The real {@code AuthProperties} builds a fresh list on every call, so the ids the job writes do not stay in the properties. */ + @Test + public void realAuthPropertiesGiveFreshConfigsEachCall() { + AuthProperties properties = new AuthProperties(); + properties.getEmail().setEnable(true); + List first = properties.getAuthConfigs(); + List second = properties.getAuthConfigs(); + assertEquals(1, first.size()); + assertEquals(AuthSourceConstants.EMAIL, first.get(0).getSource()); + assertTrue(first.get(0) != second.get(0)); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/task/ArchiveSnapshotTaskBelow5Test.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/task/ArchiveSnapshotTaskBelow5Test.java new file mode 100644 index 0000000000..4b20b17f7d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/task/ArchiveSnapshotTaskBelow5Test.java @@ -0,0 +1,225 @@ +package org.lowcoder.runner.task; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.lowcoder.api.ServerApplication; +import org.lowcoder.sdk.config.CommonConfig; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.test.context.ActiveProfiles; + +import com.mongodb.client.MongoDatabase; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import lombok.extern.slf4j.Slf4j; + +/** + * The {@code < 5} path of {@link ArchiveSnapshotTask} against the real MongoDB test container ({@code mongo:4.0.28}). + * + *

    Profile {@code archiveSnapshotBelow5}: a context and database ({@code lowcoder_test_}) of its own. The task is + * scheduled with initialDelay 0 and fires once at context start (thread {@value #SCHEDULER_THREAD}); the class waits, + * bounded, for that thread to be idle before it seeds anything, and then calls {@code archive()} itself, so the test + * decides when the task runs. The daily re-run is a day away and cannot fire during a test. + * + *

    Pinned under D-6, plan §9 row "ArchiveSnapshotTask's pipeline for MongoDB < 5" (real-server answer): see + * {@link #threeOldSnapshots_pinsTheSection9Row}. + * + *

    Not covered: the {@code >= 5} path (the container is 4.x), failed deletes and cursor failures (covered with mocks by + * {@code ArchiveSnapshotTaskTest}; a real server cannot be made to fail them here). + */ +@SpringBootTest(classes = ServerApplication.class) +@ActiveProfiles("archiveSnapshotBelow5") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +public class ArchiveSnapshotTaskBelow5Test { + + private static final String TAG = "[ArchiveSnapshotTaskBelow5Test] "; + private static final String SNAPSHOT = "applicationHistorySnapshot"; + private static final String SNAPSHOT_TS = "applicationHistorySnapshotTS"; + private static final String SCHEDULER_THREAD = "scheduling-1"; + private static final Duration SCHEDULER_WAIT = Duration.ofSeconds(60); + private static final int MONGO_NO_PIPELINE_FIX_MAJOR = 5; + private static final long OLD_EXTRA_DAYS = 1; + private static final String RUNNING_BELOW_5 = "Running archival for MongoDB version < 5"; + private static final String RUNNING_5_AND_ABOVE = "Running archival for MongoDB version >= 5"; + private static final String FAILED_AGGREGATE = "Failed to aggregate and insert document"; + private static final String TO_ARCHIVE = "Total documents to archive: "; + private static final String PROCESSED = "Processed document"; + private static final String COMPLETED = "Archival process completed. Total documents archived: "; + + @Autowired + private ArchiveSnapshotTask task; + @Autowired + private MongoTemplate mongoTemplate; + @Autowired + private CommonConfig commonConfig; + + private MongoDatabase db; + private final ListAppender logs = new ListAppender<>(); + private Logger taskLogger; + + @BeforeAll + public void awaitSchedulerOfContextStart() throws InterruptedException { + long deadline = System.nanoTime() + SCHEDULER_WAIT.toNanos(); + while (System.nanoTime() < deadline) { + Thread scheduler = Thread.getAllStackTraces().keySet().stream() + .filter(t -> SCHEDULER_THREAD.equals(t.getName())).findFirst().orElse(null); + if (scheduler != null && scheduler.getState() != Thread.State.RUNNABLE) { + return; + } + TimeUnit.MILLISECONDS.sleep(50); + } + throw new AssertionError("the scheduler thread did not become idle within " + SCHEDULER_WAIT); + } + + @BeforeEach + public void setUp() { + db = mongoTemplate.getDb(); + db.getCollection(SNAPSHOT).deleteMany(new Document()); + db.getCollection(SNAPSHOT_TS).deleteMany(new Document()); + taskLogger = (Logger) LoggerFactory.getLogger(ArchiveSnapshotTask.class); + taskLogger.setLevel(Level.INFO); + logs.list.clear(); + logs.start(); + taskLogger.addAppender(logs); + } + + @AfterEach + public void tearDown() { + taskLogger.detachAppender(logs); + logs.stop(); + } + + private Instant old() { + return Instant.now().minus(commonConfig.getQuery().getAppSnapshotKeepDuration() + OLD_EXTRA_DAYS, ChronoUnit.DAYS); + } + + private static Document snapshot(ObjectId id, String applicationId, Instant createdAt) { + return new Document("_id", id).append("applicationId", applicationId) + .append("dsl", new Document("marker", "dsl-of-" + applicationId)) + .append("context", new Document("ctx", 1)).append("createdAt", Date.from(createdAt)) + .append("createdBy", "u").append("modifiedBy", "u").append("updatedAt", Date.from(createdAt)); + } + + private List messages() { + synchronized (logs.list) { + return logs.list.stream().map(ILoggingEvent::getFormattedMessage).collect(Collectors.toList()); + } + } + + private List applicationIds(String collection) { + return db.getCollection(collection).find().into(new ArrayList<>()).stream() + .map(d -> d.getString("applicationId")).collect(Collectors.toList()); + } + + private void assertBelow5() { + Document buildInfo = db.runCommand(new Document("buildInfo", 1)); + int major = Integer.parseInt(buildInfo.getString("version").split("\\.")[0]); + assertThat(major).as("MongoDB major version of the container (the < 5 path is the one that runs)") + .isLessThan(MONGO_NO_PIPELINE_FIX_MAJOR); + } + + /** + * The real server's answer to the task's per-document pipeline + * {@code aggregate([Filters.eq("_id", id), $project, $out "applicationHistorySnapshotTS"])} + * (ArchiveSnapshotTask:111-123): the first element is a filter, not a {@code $match} stage, and mongo:4.0.28 + * rejects it. Each of the three old snapshots fails the aggregate (logged, ArchiveSnapshotTask:125), is not + * deleted (the {@code continue} at :126) and nothing is archived: on MongoDB below 5 the archive does not work. + * The recent snapshot is not selected. Pins plan §9 row "ArchiveSnapshotTask's pipeline for MongoDB < 5". + */ + @Test + public void threeOldSnapshots_pinsTheSection9Row() { + assertBelow5(); + ObjectId a = new ObjectId(); + ObjectId b = new ObjectId(); + ObjectId c = new ObjectId(); + ObjectId recent = new ObjectId(); + db.getCollection(SNAPSHOT).insertMany(List.of( + snapshot(a, "app-a", old()), snapshot(b, "app-b", old().minusSeconds(60)), snapshot(c, "app-c", old().minusSeconds(120)), + snapshot(recent, "app-recent", Instant.now().minus(1, ChronoUnit.DAYS)))); + + task.archive(); + + List source = applicationIds(SNAPSHOT); + List target = applicationIds(SNAPSHOT_TS); + log.info("{}source={} target={} log={}", TAG, source, target, messages()); + assertThat(messages()).contains(RUNNING_BELOW_5).doesNotContain(RUNNING_5_AND_ABOVE); + assertThat(messages()).contains(TO_ARCHIVE + 3); + assertThat(messages().stream().filter(m -> m.startsWith(FAILED_AGGREGATE))).as("one failed aggregate per old snapshot").hasSize(3); + assertThat(messages().stream().filter(m -> m.startsWith(PROCESSED))).isEmpty(); + assertThat(messages()).contains(COMPLETED + 0); + assertThat(source).as("nothing is deleted").containsExactlyInAnyOrder("app-a", "app-b", "app-c", "app-recent"); + assertThat(target).as("nothing is archived").isEmpty(); + } + + @Test + public void theRecentSnapshotStaysUnchanged() { + assertBelow5(); + ObjectId recent = new ObjectId(); + Document recentDoc = snapshot(recent, "app-recent", Instant.now().minus(1, ChronoUnit.DAYS)); + db.getCollection(SNAPSHOT).insertMany(List.of(recentDoc, snapshot(new ObjectId(), "app-old", old()))); + + task.archive(); + + Document after = db.getCollection(SNAPSHOT).find(new Document("_id", recent)).first(); + assertThat(after).isEqualTo(recentDoc); + assertThat(messages().stream().filter(m -> m.startsWith(FAILED_AGGREGATE))).hasSize(1); + } + + /** A row already in the target is untouched when every aggregate is rejected (the server never reaches {@code $out}). */ + @Test + public void aRowAlreadyInTheTarget_isLeftAlone() { + assertBelow5(); + db.getCollection(SNAPSHOT_TS).insertOne(snapshot(new ObjectId(), "archived-earlier", old())); + db.getCollection(SNAPSHOT).insertMany(List.of(snapshot(new ObjectId(), "app-1", old()), snapshot(new ObjectId(), "app-2", old()))); + + task.archive(); + + assertThat(applicationIds(SNAPSHOT_TS)).containsExactly("archived-earlier"); + assertThat(applicationIds(SNAPSHOT)).containsExactlyInAnyOrder("app-1", "app-2"); + } + + @Test + public void emptySource_writesNothing() { + assertBelow5(); + db.getCollection(SNAPSHOT_TS).insertOne(snapshot(new ObjectId(), "archived-earlier", old())); + db.getCollection(SNAPSHOT).insertOne(snapshot(new ObjectId(), "app-recent", Instant.now().minus(1, ChronoUnit.DAYS))); + + task.archive(); + + assertThat(messages()).contains(RUNNING_BELOW_5, COMPLETED + 0); + assertThat(messages().stream().filter(m -> m.startsWith(FAILED_AGGREGATE))).isEmpty(); + assertThat(applicationIds(SNAPSHOT_TS)).containsExactly("archived-earlier"); + assertThat(applicationIds(SNAPSHOT)).containsExactly("app-recent"); + } + + @Test + public void theRealServerTakesTheBelow5Branch() { + assertBelow5(); + + task.archive(); + + assertThat(messages()).contains(RUNNING_BELOW_5).doesNotContain(RUNNING_5_AND_ABOVE); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/task/ArchiveSnapshotTaskTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/task/ArchiveSnapshotTaskTest.java new file mode 100644 index 0000000000..c3e9623eaf --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/task/ArchiveSnapshotTaskTest.java @@ -0,0 +1,330 @@ +package org.lowcoder.runner.task; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.Iterator; +import java.util.List; + +import org.bson.BsonDocument; +import org.bson.Document; +import org.bson.conversions.Bson; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.sdk.config.CommonConfig; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.MongoTemplate; + +import com.mongodb.MongoClientSettings; +import com.mongodb.client.AggregateIterable; +import com.mongodb.client.FindIterable; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoCursor; +import com.mongodb.client.MongoDatabase; + +/** + * Direct tests of {@link ArchiveSnapshotTask#archive()} with mocked driver types: the dispatch on the server's major + * version, the age threshold, and every success and failure arm of both archival paths. {@code archive()} is + * {@code @Scheduled(initialDelay = 0)}, so it runs once at each Spring context start, racing the test that started + * it (plan §2.3); calling it here makes its coverage deterministic. + * + *

    What a mock can and cannot show: for MongoDB < 5 the pipeline the code hands to the driver is asserted + * (plan §9 row "ArchiveSnapshotTask's pipeline for MongoDB < 5"); that a real server rejects the first stage, and that + * {@code $out} replaces the target on each call, cannot be shown with mocks and stays with K2a. + */ +@ExtendWith(MockitoExtension.class) +class ArchiveSnapshotTaskTest { + + private static final String SOURCE_NAME = "applicationHistorySnapshot"; + private static final String TARGET_NAME = "applicationHistorySnapshotTS"; + private static final long WINDOW_MILLIS = 60_000L; + private static final String VERSION_BELOW_5 = "4.0.2"; + private static final String VERSION_5 = "5.0.3"; + + @Mock private MongoTemplate mongoTemplate; + @Mock private MongoDatabase database; + @Mock private MongoCollection source; + @Mock private MongoCollection target; + @Mock private FindIterable findIterable; + @Mock private MongoCursor cursor; + @Mock private AggregateIterable aggregateIterable; + + private CommonConfig commonConfig; + private ArchiveSnapshotTask task; + private final List events = new ArrayList<>(); + + @BeforeEach + void setUp() { + commonConfig = new CommonConfig(); + task = new ArchiveSnapshotTask(commonConfig, mongoTemplate); + lenient().when(mongoTemplate.getDb()).thenReturn(database); + lenient().when(database.getCollection(SOURCE_NAME)).thenReturn(source); + lenient().when(database.getCollection(TARGET_NAME)).thenReturn(target); + lenient().when(source.find(any(Bson.class))).thenReturn(findIterable); + lenient().when(findIterable.iterator()).thenReturn(cursor); + lenient().when(source.aggregate(anyList())).thenReturn(aggregateIterable); + } + + // ---------------------------------------------------------------- helpers + + private void serverVersion(String version) { + lenient().when(database.runCommand(any(Bson.class))).thenReturn(new Document("version", version)); + } + + private void oldDocuments(Document... documents) { + Iterator iterator = Arrays.asList(documents).iterator(); + lenient().when(cursor.hasNext()).thenAnswer(invocation -> iterator.hasNext()); + lenient().when(cursor.next()).thenAnswer(invocation -> iterator.next()); + } + + private static Document snapshot(ObjectId id) { + return new Document("_id", id) + .append("applicationId", "app-" + id.toHexString()) + .append("dsl", new Document("k", "v")) + .append("context", new Document("c", 1)) + .append("createdAt", new Date(0L)) + .append("createdBy", "creator") + .append("modifiedBy", "modifier") + .append("updatedAt", new Date(1L)); + } + + private static BsonDocument render(Bson bson) { + return bson.toBsonDocument(BsonDocument.class, MongoClientSettings.getDefaultCodecRegistry()); + } + + private static BsonDocument idFilter(ObjectId id) { + return new BsonDocument("_id", new org.bson.BsonObjectId(id)); + } + + private List deletedFilters() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Bson.class); + verify(source, org.mockito.Mockito.atLeast(0)).deleteOne(captor.capture()); + return captor.getAllValues().stream().map(ArchiveSnapshotTaskTest::render).toList(); + } + + // ---------------------------------------------------------------- dispatch + + /** Catches the wrong archival pipeline running for a server version: >= 5 copies, < 5 aggregates. */ + @ParameterizedTest(name = "version {0} -> copy path: {1}") + @CsvSource({"3.6.23,false", "4.0.2,false", "5.0.0,true", "5.0.3,true", "6.0.14,true", "10.0.0,true"}) + void archive_dispatchesOnTheMajorVersion(String version, boolean copyPath) { + serverVersion(version); + oldDocuments(snapshot(new ObjectId())); + + task.archive(); + + if (copyPath) { + verify(target).insertOne(any(Document.class)); + verify(source, never()).aggregate(anyList()); + } else { + verify(source).aggregate(anyList()); + verify(target, never()).insertOne(any(Document.class)); + } + System.out.println("[ArchiveSnapshotTaskTest] version " + version + " -> " + (copyPath ? "insertOne copy path" : "aggregate path")); + } + + /** Catches fresh snapshots being archived: both paths select {@code createdAt <= now - keepDuration days}. */ + @ParameterizedTest(name = "version {0}, keep {1} days") + @CsvSource({"5.0.3,30", "5.0.3,7", "4.0.2,30", "4.0.2,7"}) + void archive_selectsDocumentsOlderThanTheKeepDuration(String version, long keepDays) { + commonConfig.getQuery().setAppSnapshotKeepDuration(keepDays); + serverVersion(version); + oldDocuments(); + long expected = Instant.now().minus(keepDays, ChronoUnit.DAYS).toEpochMilli(); + + task.archive(); + + ArgumentCaptor countFilter = ArgumentCaptor.forClass(Bson.class); + ArgumentCaptor findFilter = ArgumentCaptor.forClass(Bson.class); + verify(source).countDocuments(countFilter.capture()); + verify(source).find(findFilter.capture()); + for (Bson filter : List.of(countFilter.getValue(), findFilter.getValue())) { + long threshold = render(filter).getDocument("createdAt").getDateTime("$lte").getValue(); + assertThat(threshold).isBetween(expected - WINDOW_MILLIS, expected + WINDOW_MILLIS); + } + System.out.println("[ArchiveSnapshotTaskTest] " + version + " keep=" + keepDays + " filter " + render(findFilter.getValue())); + } + + /** Catches a scan when nothing is old enough: no write of any kind, on both paths. */ + @ParameterizedTest + @ValueSource(strings = {VERSION_5, VERSION_BELOW_5}) + void archive_noOldDocuments_touchesNothing(String version) { + serverVersion(version); + oldDocuments(); + + task.archive(); + + verify(target, never()).insertOne(any(Document.class)); + verify(source, never()).aggregate(anyList()); + verify(source, never()).deleteOne(any(Bson.class)); + verify(cursor).close(); + System.out.println("[ArchiveSnapshotTaskTest] " + version + ": nothing old -> no insert, aggregate or delete"); + } + + // ------------------------------------------------------ MongoDB >= 5 path + + /** Catches a snapshot losing its id, or being deleted before it was copied. */ + @Test + void archive_v5_copiesTheDocumentWithIdRenamed_thenDeletesTheSource() { + serverVersion(VERSION_5); + ObjectId id = new ObjectId(); + oldDocuments(snapshot(id)); + List inserted = new ArrayList<>(); + org.mockito.Mockito.doAnswer(invocation -> { + events.add("insert"); + inserted.add(invocation.getArgument(0)); + return null; + }).when(target).insertOne(any(Document.class)); + org.mockito.Mockito.doAnswer(invocation -> { + events.add("delete"); + return null; + }).when(source).deleteOne(any(Bson.class)); + + task.archive(); + + assertThat(events).containsExactly("insert", "delete"); + assertThat(inserted).hasSize(1); + assertThat(inserted.get(0).containsKey("_id")).isFalse(); + assertThat(inserted.get(0).get("id")).isEqualTo(id); + assertThat(inserted.get(0).get("applicationId")).isEqualTo("app-" + id.toHexString()); + assertThat(deletedFilters()).containsExactly(idFilter(id)); + System.out.println("[ArchiveSnapshotTaskTest] v5 events " + events + ", copy has id " + inserted.get(0).get("id") + " and no _id"); + } + + /** Catches snapshot loss on a failed copy: the source of a failed insert stays, the next document is archived. */ + @Test + void archive_v5_failedInsert_keepsTheSource_andContinuesWithTheNextDocument() { + serverVersion(VERSION_5); + ObjectId first = new ObjectId(); + ObjectId second = new ObjectId(); + oldDocuments(snapshot(first), snapshot(second)); + doThrow(new IllegalStateException("duplicate key")).doReturn(null).when(target).insertOne(any(Document.class)); + + task.archive(); + + verify(target, times(2)).insertOne(any(Document.class)); + assertThat(deletedFilters()).containsExactly(idFilter(second)); + System.out.println("[ArchiveSnapshotTaskTest] v5 failed insert of " + first + " -> only " + second + " deleted"); + } + + /** Catches one failed delete aborting the whole run: the next document is still copied and deleted. */ + @Test + void archive_v5_failedDelete_continuesWithTheNextDocument() { + serverVersion(VERSION_5); + ObjectId first = new ObjectId(); + ObjectId second = new ObjectId(); + oldDocuments(snapshot(first), snapshot(second)); + doThrow(new IllegalStateException("write concern")).doReturn(null).when(source).deleteOne(any(Bson.class)); + + task.archive(); + + verify(target, times(2)).insertOne(any(Document.class)); + assertThat(deletedFilters()).containsExactly(idFilter(first), idFilter(second)); + System.out.println("[ArchiveSnapshotTaskTest] v5 failed delete of " + first + " -> " + second + " still processed"); + } + + /** Catches a cursor failure escaping the scheduled method, or a leaked cursor. */ + @ParameterizedTest + @ValueSource(strings = {VERSION_5, VERSION_BELOW_5}) + void archive_cursorFailure_isSwallowed_andTheCursorIsClosed(String version) { + serverVersion(version); + org.mockito.Mockito.when(cursor.hasNext()).thenThrow(new IllegalStateException("cursor lost")); + + task.archive(); + + verify(cursor).close(); + verify(source, never()).deleteOne(any(Bson.class)); + System.out.println("[ArchiveSnapshotTaskTest] " + version + ": cursor failure swallowed, cursor closed"); + } + + // ------------------------------------------------------ MongoDB < 5 path + + /** + * Pins the plan §9 row "ArchiveSnapshotTask's pipeline for MongoDB < 5" as far as a mock can show it: per + * document the code hands the driver three stages, the first of which is the filter document {@code {_id: id}} + * (not a {@code $match} stage) and the last {@code $out} to the same target for every document, then deletes the + * source. A fix changes this test on purpose. That a real server rejects the first stage and that {@code $out} + * replaces the target on each call is not shown here (K2a). + */ + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void archive_below5_pinsThePipelineHandedToTheDriver_pinsMongoDbBelow5Defect() { + serverVersion(VERSION_BELOW_5); + ObjectId first = new ObjectId(); + ObjectId second = new ObjectId(); + oldDocuments(snapshot(first), snapshot(second)); + ArgumentCaptor pipelines = ArgumentCaptor.forClass(List.class); + + task.archive(); + + verify(source, times(2)).aggregate(pipelines.capture()); + verify(aggregateIterable, times(2)).first(); + ObjectId[] ids = {first, second}; + for (int i = 0; i < 2; i++) { + List stages = pipelines.getAllValues().get(i); + assertThat(stages).hasSize(3); + BsonDocument stage0 = render(stages.get(0)); + assertThat(stage0).isEqualTo(idFilter(ids[i])); + assertThat(stage0.containsKey("$match")).as("first stage is not a $match stage").isFalse(); + BsonDocument project = render(stages.get(1)).getDocument("$project"); + assertThat(project.keySet()).containsExactlyInAnyOrder("applicationId", "dsl", "context", "createdAt", + "createdBy", "modifiedBy", "updatedAt", "id"); + assertThat(project.getObjectId("id").getValue()).isEqualTo(ids[i]); + assertThat(render(stages.get(2)).getString("$out").getValue()).isEqualTo(TARGET_NAME); + } + assertThat(deletedFilters()).containsExactly(idFilter(first), idFilter(second)); + System.out.println("[ArchiveSnapshotTaskTest] below-5 pipeline stage0=" + render((Bson) pipelines.getValue().get(0)) + + " stage2=" + render((Bson) pipelines.getValue().get(2)) + " (two documents -> two $out to " + TARGET_NAME + ")"); + } + + /** Catches a failed aggregate deleting its source (snapshot loss), and aborting the run. */ + @Test + void archive_below5_failedAggregate_keepsTheSource_andContinuesWithTheNextDocument() { + serverVersion(VERSION_BELOW_5); + ObjectId first = new ObjectId(); + ObjectId second = new ObjectId(); + oldDocuments(snapshot(first), snapshot(second)); + org.mockito.Mockito.when(source.aggregate(anyList())).thenThrow(new IllegalStateException("bad stage")).thenReturn(aggregateIterable); + + task.archive(); + + verify(source, times(2)).aggregate(anyList()); + assertThat(deletedFilters()).containsExactly(idFilter(second)); + System.out.println("[ArchiveSnapshotTaskTest] below-5 failed aggregate of " + first + " -> only " + second + " deleted"); + } + + /** Catches one failed delete aborting the below-5 run. */ + @Test + void archive_below5_failedDelete_continuesWithTheNextDocument() { + serverVersion(VERSION_BELOW_5); + ObjectId first = new ObjectId(); + ObjectId second = new ObjectId(); + oldDocuments(snapshot(first), snapshot(second)); + doThrow(new IllegalStateException("write concern")).doReturn(null).when(source).deleteOne(any(Bson.class)); + + task.archive(); + + verify(source, times(2)).aggregate(anyList()); + assertThat(deletedFilters()).containsExactly(idFilter(first), idFilter(second)); + System.out.println("[ArchiveSnapshotTaskTest] below-5 failed delete of " + first + " -> " + second + " still processed"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/task/IoHeartBeatTaskTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/task/IoHeartBeatTaskTest.java new file mode 100644 index 0000000000..8f50d11da6 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/task/IoHeartBeatTaskTest.java @@ -0,0 +1,105 @@ +package org.lowcoder.runner.task; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.lowcoder.infra.perf.PerfEvent; +import org.lowcoder.infra.perf.PerfHelper; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import reactor.core.publisher.Mono; + +/** + * Direct tests of {@link IoHeartBeatTask#ping()}, the scheduled Redis heartbeat. The schedule (first run after one + * minute) makes its coverage depend on how long the test JVM lives (plan §2.3); calling {@code ping()} here makes + * that coverage deterministic. A real {@link PerfHelper} over a {@link SimpleMeterRegistry} is used so the + * {@code PerfHelper.count} lines the task calls are covered too, and the counter can be read back. + */ +@ExtendWith(MockitoExtension.class) +class IoHeartBeatTaskTest { + + private static final String WARMUP_KEY = "#warmup"; + private static final String WARMUP_VALUE = "1"; + private static final Duration WARMUP_TTL = Duration.ofSeconds(1); + private static final String REDIS_TAG = "redis"; + + @Mock private ReactiveRedisTemplate reactiveRedisTemplate; + @Mock private ReactiveValueOperations ops; + + private SimpleMeterRegistry registry; + private IoHeartBeatTask task; + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + PerfHelper perfHelper = new PerfHelper(); + ReflectionTestUtils.setField(perfHelper, "meterRegistry", registry); + task = new IoHeartBeatTask(reactiveRedisTemplate, perfHelper); + when(reactiveRedisTemplate.opsForValue()).thenReturn(ops); + } + + private Counter heartBeatCounter(boolean redisResult) { + return registry.find(PerfEvent.IO_HEART_BEAT.perfKey()).tag(REDIS_TAG, String.valueOf(redisResult)).counter(); + } + + /** Catches a heartbeat that never reaches Redis: the key/value/TTL are fixed and the Mono must be subscribed. */ + @Test + void ping_writesTheWarmupKeyWithAOneSecondTtl_andSubscribes() { + AtomicBoolean subscribed = new AtomicBoolean(); + when(ops.set(WARMUP_KEY, WARMUP_VALUE, WARMUP_TTL)).thenReturn(Mono.fromSupplier(() -> { + subscribed.set(true); + return true; + })); + + task.ping(); + + verify(ops).set(WARMUP_KEY, WARMUP_VALUE, WARMUP_TTL); + assertThat(subscribed).isTrue(); + System.out.println("[IoHeartBeatTaskTest] set(" + WARMUP_KEY + ", " + WARMUP_VALUE + ", " + WARMUP_TTL + ") subscribed=" + subscribed.get()); + } + + /** Catches the Redis result not being recorded: the counter carries the result as its {@code redis} tag. */ + @ParameterizedTest(name = "redis result {0}") + @ValueSource(booleans = {true, false}) + void ping_countsTheHeartBeatWithTheRedisResultTag(boolean redisResult) { + when(ops.set(WARMUP_KEY, WARMUP_VALUE, WARMUP_TTL)).thenReturn(Mono.just(redisResult)); + + task.ping(); + + Counter counter = heartBeatCounter(redisResult); + assertThat(counter).as("counter for redis=" + redisResult).isNotNull(); + assertThat(counter.count()).isEqualTo(1.0); + assertThat(heartBeatCounter(!redisResult)).as("counter for the other result").isNull(); + System.out.println("[IoHeartBeatTaskTest] " + PerfEvent.IO_HEART_BEAT.perfKey() + "{redis=" + redisResult + "} = " + counter.count()); + } + + /** + * {@code subscribe(consumer)} has no error consumer, so a failing Redis write goes to Reactor's default + * onErrorDropped hook (logged as "Operator called default onErrorDropped") and does not reach the caller of the + * scheduled method; nothing is counted. Pinned as today's behaviour. + */ + @Test + void ping_redisError_isNotThrown_andNothingIsCounted() { + when(ops.set(WARMUP_KEY, WARMUP_VALUE, WARMUP_TTL)).thenReturn(Mono.error(new IllegalStateException("redis down"))); + + org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> task.ping()); + + assertThat(registry.getMeters()).isEmpty(); + System.out.println("[IoHeartBeatTaskTest] redis error -> ping() returned normally, no counter"); + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/META-INF/spring.factories b/server/api-service/lowcoder-server/src/test/resources/META-INF/spring.factories new file mode 100644 index 0000000000..eb88a4522f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +# Points every Spring Boot test context at the MongoDB and Redis test containers (see TestContainersInitializer) +org.springframework.context.ApplicationContextInitializer=org.lowcoder.api.common.TestContainersInitializer diff --git a/server/api-service/lowcoder-server/src/test/resources/application.yml b/server/api-service/lowcoder-server/src/test/resources/application.yml index b1dfbe2422..cc7a0ddcde 100644 --- a/server/api-service/lowcoder-server/src/test/resources/application.yml +++ b/server/api-service/lowcoder-server/src/test/resources/application.yml @@ -1,18 +1,9 @@ spring: main: allow-circular-references: true - data: - redis: - url: redis://localhost:6370 profiles: active: test -de: - flapdoodle: - mongodb: - embedded: - version: 4.0.2 - server: port: 8080 diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-control.out b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-control.out new file mode 100644 index 0000000000..cd03a1ade3 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-control.out @@ -0,0 +1,22 @@ +lowcoder-sdk/src/main/java/Ctl.java:4 read readTree +lowcoder-sdk/src/main/java/Ctl.java:7 read fromJson +lowcoder-sdk/src/main/java/Ctl.java:9 convert ::fromJsonNode +lowcoder-sdk/src/main/java/Ctl.java:10 mapper new ObjectMapper +lowcoder-sdk/src/main/java/Ctl.java:11 codec-encode bodyValue +lowcoder-sdk/src/main/java/Ctl.java:11 codec-decode bodyToMono +lowcoder-sdk/src/main/java/Ctl.java:12 codec-encode BodyInserters.fromValue +lowcoder-sdk/src/main/java/Ctl.java:14 codec-config codecs +lowcoder-sdk/src/main/java/Ctl.java:17 codec-config jackson2JsonEncoder +lowcoder-sdk/src/main/java/Ctl.java:17 mapper-access getObjectMapper +lowcoder-sdk/src/main/java/FqnUse.java:0 import import +lowcoder-sdk/src/main/java/ImportOnly.java:0 import import +lowcoder-sdk/src/main/java/OtherLibraryJackson.java:0 import import +lowcoder-sdk/src/main/java/ReaderWriter.java:3 write writeValue +lowcoder-sdk/src/main/java/ReaderWriter.java:4 reader-writer readerFor +lowcoder-sdk/src/main/java/ReaderWriter.java:0 import import +lowcoder-sdk/src/main/java/StaticInserter.java:0 import import +lowcoder-sdk/src/main/java/TreeText.java:0 import import +lowcoder-sdk/src/main/java/Wrap.java:3 write toJson +lowcoder-sdk/src/main/java/Wrap.java:7 write toJson +# unlisted wrapper lowcoder-sdk/src/main/java/Wrap.java:3 render +# 20 sites diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-groups.tsv b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-groups.tsv new file mode 100644 index 0000000000..60afab3156 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-groups.tsv @@ -0,0 +1,27 @@ +# The test groups of docs/API_PAYLOAD_TEST_PLAN.md §4.10: group, owning WP(s), and the test source roots its tests live in +# (the "Test module(s)" column of §4.10). Columns are tab-separated; lists are comma-separated. Read by JsonBoundaryGateTest (§6.2): +# a group is enforced once every WP it lists is in lowcoder-server/src/test/resources/json-contract/wp-status.json. +config-binding WP8 lowcoder-plugins/clickHousePlugin/src/test/java,lowcoder-plugins/elasticSearchPlugin/src/test/java,lowcoder-plugins/googleSheetsPlugin/src/test/java,lowcoder-plugins/graphqlPlugin/src/test/java,lowcoder-plugins/mongoPlugin/src/test/java,lowcoder-plugins/mssqlPlugin/src/test/java,lowcoder-plugins/mysqlPlugin/src/test/java,lowcoder-plugins/postgresPlugin/src/test/java,lowcoder-plugins/redisPlugin/src/test/java,lowcoder-plugins/restApiPlugin/src/test/java,lowcoder-plugins/smtpPlugin/src/test/java,lowcoder-plugins/sqlBasedPlugin/src/test/java,lowcoder-plugins/oraclePlugin/src/test/java,lowcoder-plugins/snowflakePlugin/src/test/java,lowcoder-sdk/src/test/java +external-json WP8 lowcoder-plugins/elasticSearchPlugin/src/test/java,lowcoder-plugins/googleSheetsPlugin/src/test/java,lowcoder-plugins/graphqlPlugin/src/test/java,lowcoder-plugins/mongoPlugin/src/test/java,lowcoder-plugins/postgresPlugin/src/test/java,lowcoder-plugins/restApiPlugin/src/test/java,lowcoder-sdk/src/test/java +header-json WP8 lowcoder-plugins/restApiPlugin/src/test/java,lowcoder-plugins/graphqlPlugin/src/test/java +smtp-lists WP8 lowcoder-plugins/smtpPlugin/src/test/java +node-conversion WP8 lowcoder-sdk/src/test/java +tree-text WP8 lowcoder-sdk/src/test/java,lowcoder-plugins/restApiPlugin/src/test/java,lowcoder-plugins/mongoPlugin/src/test/java,lowcoder-plugins/elasticSearchPlugin/src/test/java,lowcoder-plugins/smtpPlugin/src/test/java +downstream-render WP8 lowcoder-sdk/src/test/java,lowcoder-plugins/sqlBasedPlugin/src/test/java,lowcoder-plugins/clickHousePlugin/src/test/java,lowcoder-plugins/mongoPlugin/src/test/java +downstream-encode WP8 lowcoder-plugins/restApiPlugin/src/test/java,lowcoder-plugins/graphqlPlugin/src/test/java,lowcoder-sdk/src/test/java +error-message-json WP9,WP8 lowcoder-server/src/test/java,lowcoder-sdk/src/test/java +oauth WP9 lowcoder-server/src/test/java +node-service WP9 lowcoder-domain/src/test/java +client-codec-guard WP0 lowcoder-server/src/test/java,lowcoder-plugins/graphqlPlugin/src/test/java,lowcoder-plugins/restApiPlugin/src/test/java +error-handler WP1 lowcoder-server/src/test/java +api-flow WP7 lowcoder-server/src/test/java +js-library WP7 lowcoder-server/src/test/java +npm-outbound WP7 lowcoder-server/src/test/java +payload-getters WP2,WP4,WP6 lowcoder-server/src/test/java +upsert-datasource WP5 lowcoder-server/src/test/java +dynamic-roundtrip WP9 lowcoder-domain/src/test/java +server-codec-guard WP0 lowcoder-server/src/test/java +jwt-claims WP9 lowcoder-server/src/test/java +session-cache WP9 lowcoder-server/src/test/java +persistence-roundtrip WP9 lowcoder-domain/src/test/java +config-center WP9 lowcoder-infra/src/test/java diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-sites.tsv b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-sites.tsv new file mode 100644 index 0000000000..6cfbaad1fd --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json-boundary-sites.tsv @@ -0,0 +1,268 @@ +# Classification of every JSON boundary site found by docs/tools/json_boundary_sites.py. +# Columns (tab-separated): :, callee, disposition, note, key. The key (###) is the stable row key of the Java gate (JsonBoundaryScanner); the prototype checks the first two columns. Check with: +# python3 docs/tools/json_boundary_sites.py check +# Test groups are defined in docs/API_PAYLOAD_TEST_PLAN.md §4.10. +lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/ClickHouseQueryExecutor.java:214 toJson test:downstream-render JSON text bound as a SQL string parameter lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/ClickHouseQueryExecutor.java#ClickHouseQueryExecutor.bindPreparedStatementParams#toJson#1 +lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseDatasourceConfig.java:32 fromJson test:config-binding lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseDatasourceConfig.java#ClickHouseDatasourceConfig.buildFrom#fromJson#1 +lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseDatasourceConfig.java:32 toJson test:config-binding lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseDatasourceConfig.java#ClickHouseDatasourceConfig.buildFrom#toJson#1 +lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseQueryConfig.java:32 fromJson test:config-binding lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseQueryConfig.java#ClickHouseQueryConfig.from#fromJson#1 +lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseQueryConfig.java:32 toJson test:config-binding lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/model/ClickHouseQueryConfig.java#ClickHouseQueryConfig.from#toJson#1 +lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsConnector.java:67 fromJson test:config-binding lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsConnector.java#EsConnector.resolveConfig#fromJson#1 +lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsConnector.java:67 toJson test:config-binding lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsConnector.java#EsConnector.resolveConfig#toJson#1 +lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsConnector.java:162 toJson log lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsConnector.java#EsConnector.testConnection#toJson#1 +lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsConnector.java:166 toJson log lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsConnector.java#EsConnector.testConnection#toJson#2 +lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java:41 fromJson test:config-binding lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java#EsQueryExecutor.buildQueryExecutionContext#fromJson#1 +lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java:41 toJson test:config-binding lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java#EsQueryExecutor.buildQueryExecutionContext#toJson#1 +lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java:82 fromJsonMap test:external-json lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java#EsQueryExecutor.executeQuery#fromJsonMap#1 +lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/GoogleSheetsPlugin.java:92 fromJson test:config-binding requestClass dispatch, GoogleSheetsPlugin.java:85-92 lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/GoogleSheetsPlugin.java#GoogleSheetsPlugin.GoogleSheetsEngine.parseGoogleSheetsActionRequest#fromJson#1 +lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/GoogleSheetsPlugin.java:92 toJson test:config-binding requestClass dispatch, GoogleSheetsPlugin.java:85-92 lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/GoogleSheetsPlugin.java#GoogleSheetsPlugin.GoogleSheetsEngine.parseGoogleSheetsActionRequest#toJson#1 +lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/model/GoogleSheetsDatasourceConfig.java:30 fromJson test:config-binding lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/model/GoogleSheetsDatasourceConfig.java#GoogleSheetsDatasourceConfig.buildFrom#fromJson#1 +lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/model/GoogleSheetsDatasourceConfig.java:30 toJson test:config-binding lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/model/GoogleSheetsDatasourceConfig.java#GoogleSheetsDatasourceConfig.buildFrom#toJson#1 +lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/model/ServiceAccountJsonUtils.java:19 fromJsonMap test:external-json service-account key JSON from the datasource config lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/model/ServiceAccountJsonUtils.java#ServiceAccountJsonUtils.getData#fromJsonMap#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java:279 toEntity not-jackson toEntity(byte[].class): raw bytes lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.executeQuery#toEntity#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java:392 toJsonThrows test:header-json lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.parseExecuteResultHeaders#toJsonThrows#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java:399 readTree test:header-json lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.parseExecuteResultHeaders#readTree#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java:410 readTree test:external-json lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.parseResponseDataInfo#readTree#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java:452 BodyInserters.fromValue not-jackson byte[] body lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.buildBodyInserter#BodyInserters.fromValue#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java:454 BodyInserters.fromValue test:downstream-encode Map{query, variables: JsonNode} from GraphQLBodyUtils.convertToGraphQLBody, encoded by the plugin client codecs (GraphQLExecutor.java:87-90) lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.buildBodyInserter#BodyInserters.fromValue#2 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/model/GraphQLQueryConfig.java:49 fromJson test:config-binding lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/model/GraphQLQueryConfig.java#GraphQLQueryConfig.from#fromJson#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/model/GraphQLQueryConfig.java:49 toJson test:config-binding lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/model/GraphQLQueryConfig.java#GraphQLQueryConfig.from#toJson#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java:132 toJson not-jackson org.bson.Document#toJson lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java#MongoPlugin.MongoEngine.executeQuery#toJson#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java:279 toJson test:downstream-render JSON-quoting of a Mongo query argument lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java#MongoPlugin.MongoEngine.removeOrAddQuotesAroundSpecialTypes#toJson#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/commands/MongoCommand.java:56 new ObjectMapper unreachable field declared and never used (no other reference to objectMapper in lowcoder-plugins/mongoPlugin) lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/commands/MongoCommand.java#MongoCommand.objectMapper#new ObjectMapper#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfig.java:72 fromJson test:config-binding lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfig.java#MongoDatasourceConfig.buildFrom#fromJson#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfig.java:72 toJson test:config-binding lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/model/MongoDatasourceConfig.java#MongoDatasourceConfig.buildFrom#toJson#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java:158 readTree test:external-json lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java:168 readTree test:external-json lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#2 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java:178 readTree test:external-json lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#3 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java:187 readTree test:external-json lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#4 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java:202 readTree test:external-json lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#5 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java:204 readTree test:external-json lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java#MongoQueryUtils.parseResultBody#readTree#6 +lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlDatasourceConfig.java:36 fromJson test:config-binding lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlDatasourceConfig.java#MssqlDatasourceConfig.buildFrom#fromJson#1 +lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlDatasourceConfig.java:36 toJson test:config-binding lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlDatasourceConfig.java#MssqlDatasourceConfig.buildFrom#toJson#1 +lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlQueryConfig.java:42 fromJson test:config-binding lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlQueryConfig.java#MssqlQueryConfig.from#fromJson#1 +lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlQueryConfig.java:42 toJson test:config-binding lowcoder-plugins/mssqlPlugin/src/main/java/org/lowcoder/plugin/mssql/model/MssqlQueryConfig.java#MssqlQueryConfig.from#toJson#1 +lowcoder-plugins/mysqlPlugin/src/main/java/org/lowcoder/plugin/mysql/model/MysqlQueryConfig.java:44 fromJson test:config-binding lowcoder-plugins/mysqlPlugin/src/main/java/org/lowcoder/plugin/mysql/model/MysqlQueryConfig.java#MysqlQueryConfig.from#fromJson#1 +lowcoder-plugins/mysqlPlugin/src/main/java/org/lowcoder/plugin/mysql/model/MysqlQueryConfig.java:44 toJson test:config-binding lowcoder-plugins/mysqlPlugin/src/main/java/org/lowcoder/plugin/mysql/model/MysqlQueryConfig.java#MysqlQueryConfig.from#toJson#1 +lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresDatasourceConfig.java:35 fromJson test:config-binding lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresDatasourceConfig.java#PostgresDatasourceConfig.buildFrom#fromJson#1 +lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresDatasourceConfig.java:35 toJson test:config-binding lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresDatasourceConfig.java#PostgresDatasourceConfig.buildFrom#toJson#1 +lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresQueryConfig.java:49 fromJson test:config-binding lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresQueryConfig.java#PostgresQueryConfig.from#fromJson#1 +lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresQueryConfig.java:49 toJson test:config-binding lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/model/PostgresQueryConfig.java#PostgresQueryConfig.from#toJson#1 +lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/utils/PostgresResultParser.java:231 readTree test:external-json JSON column values lowcoder-plugins/postgresPlugin/src/main/java/org/lowcoder/plugin/postgres/utils/PostgresResultParser.java#PostgresResultParser.parseValue#readTree#1 +lowcoder-plugins/redisPlugin/src/main/java/org/lowcoder/plugin/redis/model/RedisDatasourceConfig.java:39 fromJson test:config-binding lowcoder-plugins/redisPlugin/src/main/java/org/lowcoder/plugin/redis/model/RedisDatasourceConfig.java#RedisDatasourceConfig.buildFrom#fromJson#1 +lowcoder-plugins/redisPlugin/src/main/java/org/lowcoder/plugin/redis/model/RedisDatasourceConfig.java:39 toJson test:config-binding lowcoder-plugins/redisPlugin/src/main/java/org/lowcoder/plugin/redis/model/RedisDatasourceConfig.java#RedisDatasourceConfig.buildFrom#toJson#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:296 toEntity not-jackson toEntity(byte[].class): raw bytes lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.httpCall#toEntity#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:392 readTree test:external-json lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.parseResponseDataInfo#readTree#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:424 toJsonThrows test:header-json lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.parseExecuteResultHeaders#toJsonThrows#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:431 readTree test:header-json lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.parseExecuteResultHeaders#readTree#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:475 BodyInserters.fromValue not-jackson byte[] body lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.buildBodyInserter#BodyInserters.fromValue#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:479 BodyInserters.fromValue not-jackson byte[] body lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.buildBodyInserter#BodyInserters.fromValue#2 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:483 BodyInserters.fromValue test:tree-text body text is JsonNode#toString of the special-JSON QueryBody (:153), written by Jackson's internal default mapper lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.buildBodyInserter#BodyInserters.fromValue#3 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:487 BodyInserters.fromValue test:downstream-encode JsonNode body encoded by the plugin client codecs (RestApiExecutor.java:100-103) lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.buildBodyInserter#BodyInserters.fromValue#4 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:494 BodyInserters.fromValue not-jackson String body: QueryBody value is a String when not JSON (QueryBody.java:5-8, RestApiExecutor.java:155) lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.buildBodyInserter#BodyInserters.fromValue#5 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/model/RestApiQueryConfig.java:38 fromJson test:config-binding lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/model/RestApiQueryConfig.java#RestApiQueryConfig.from#fromJson#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/model/RestApiQueryConfig.java:38 toJson test:config-binding lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/model/RestApiQueryConfig.java#RestApiQueryConfig.from#toJson#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/model/RestApiQueryConfig.java:40 toJson log lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/model/RestApiQueryConfig.java#RestApiQueryConfig.from#toJson#2 +lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java:144 fromJson test:config-binding lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.resolveConfig#fromJson#1 +lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java:144 toJson test:config-binding lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.resolveConfig#toJson#1 +lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java:174 fromJsonList test:smtp-lists lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.buildQueryExecutionContext#fromJsonList#1 +lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java:191 fromJsonList test:smtp-lists lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.renderAsArrayAndParse#fromJsonList#1 +lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/GeneralSqlExecutor.java:310 toJson test:downstream-render JSON text bound as a SQL string parameter lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/GeneralSqlExecutor.java#GeneralSqlExecutor.bindParam#toJson#1 +lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/SqlQueryConfig.java:48 fromJson test:config-binding lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/SqlQueryConfig.java#SqlQueryConfig.from#fromJson#1 +lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/SqlQueryConfig.java:48 toJson test:config-binding lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/SqlQueryConfig.java#SqlQueryConfig.from#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java:34 fromJson test:config-binding lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#fromJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java:34 toJson test:config-binding lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/common/DatasourceConnector.java#DatasourceConnector.resolveConfig#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/graphql/GraphQLDatasourceConfig.java:60 fromJson test:config-binding lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/graphql/GraphQLDatasourceConfig.java#GraphQLDatasourceConfig.buildFrom#fromJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/graphql/GraphQLDatasourceConfig.java:60 toJson test:config-binding lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/graphql/GraphQLDatasourceConfig.java#GraphQLDatasourceConfig.buildFrom#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpHandlerHelper.java:34 BodyInserters.fromValue not-jackson byte[] body lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpHandlerHelper.java#RawHttpHandlerHelper.buildBodyInserter#BodyInserters.fromValue#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpHandlerHelper.java:38 BodyInserters.fromValue not-jackson byte[] body lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpHandlerHelper.java#RawHttpHandlerHelper.buildBodyInserter#BodyInserters.fromValue#2 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpHandlerHelper.java:42 BodyInserters.fromValue test:downstream-encode DataUtils.parseJsonBody: JsonNode from renderMustacheJson, or byte[] lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpHandlerHelper.java#RawHttpHandlerHelper.buildBodyInserter#BodyInserters.fromValue#3 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpHandlerHelper.java:49 BodyInserters.fromValue not-jackson String body lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpHandlerHelper.java#RawHttpHandlerHelper.buildBodyInserter#BodyInserters.fromValue#4 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/mysql/MysqlDatasourceConfig.java:34 fromJson test:config-binding lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/mysql/MysqlDatasourceConfig.java#MysqlDatasourceConfig.buildFrom#fromJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/mysql/MysqlDatasourceConfig.java:34 toJson test:config-binding lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/mysql/MysqlDatasourceConfig.java#MysqlDatasourceConfig.buildFrom#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java:63 new ObjectMapper unreachable field assigned and configured but never read (DataUtils.java:55, :63-64 are its only references) lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java#DataUtils.DataUtils#new ObjectMapper#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java:77 BodyInserters.fromValue not-jackson byte[] body lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java#DataUtils.buildBodyInserter#BodyInserters.fromValue#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java:83 BodyInserters.fromValue not-jackson byte[] body lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java#DataUtils.buildBodyInserter#BodyInserters.fromValue#2 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java:85 BodyInserters.fromValue not-jackson String form data lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java#DataUtils.buildBodyInserter#BodyInserters.fromValue#3 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java:89 BodyInserters.fromValue unreachable every caller passes only form-urlencoded or multipart content types (RestApiExecutor.java:490-492, RawHttpHandlerHelper.java:45-47, GraphQLExecutor.java:446-447) lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java#DataUtils.buildBodyInserter#BodyInserters.fromValue#4 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java:132 BodyInserters.fromValue not-jackson byte[] body lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java#DataUtils.parseMultipartFileData#BodyInserters.fromValue#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/RestApiDatasourceConfig.java:64 fromJson test:config-binding lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/RestApiDatasourceConfig.java#RestApiDatasourceConfig.buildFrom#fromJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/RestApiDatasourceConfig.java:64 toJson test:config-binding lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/RestApiDatasourceConfig.java#RestApiDatasourceConfig.buildFrom#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetBulkObjectChangeSet.java:28 fromJsonNode test:node-conversion lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetBulkObjectChangeSet.java#SheetBulkObjectChangeSet.render#fromJsonNode#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetChangeSetRow.java:41 jsonNodeToObject test:node-conversion lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetChangeSetRow.java#SheetChangeSetRow.fromJsonNode#jsonNodeToObject#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetChangeSetRows.java:25 ::fromJsonNode test:node-conversion lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetChangeSetRows.java#SheetChangeSetRows.fromJsonNode#::fromJsonNode#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetKeyValuePairChangeSet.java:48 jsonNodeToObject test:node-conversion lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetKeyValuePairChangeSet.java#SheetKeyValuePairChangeSet.render#jsonNodeToObject#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetObjectChangeSet.java:28 fromJsonNode test:node-conversion lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetObjectChangeSet.java#SheetObjectChangeSet.render#fromJsonNode#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/BulkObjectChangeSet.java:30 fromJsonNode test:node-conversion lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/BulkObjectChangeSet.java#BulkObjectChangeSet.render#fromJsonNode#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ChangeSetRow.java:40 fromJsonNode test:node-conversion lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ChangeSetRow.java#ChangeSetRow.parseChangeSetItems#fromJsonNode#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/KeyValuePairChangeSet.java:37 toJson test:error-message-json PluginException argument; surfaces as a query error message lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/KeyValuePairChangeSet.java#KeyValuePairChangeSet.parseColumnValueMap#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/filter/FilterSet.java:116 toJson test:downstream-render collection/map rendered into GUI SQL lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/filter/FilterSet.java#FilterSet.getCollectionStr#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java:377 toJson test:downstream-render lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java#MustacheHelper.replaceParamWithInOperator#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java:495 toJson test:downstream-render lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java#MustacheHelper.generateNewValue#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java:570 toJson test:downstream-render lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java#MustacheHelper.convertToStringValue#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/RjsonMustacheParser.java:168 valueToTree test:node-conversion lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/RjsonMustacheParser.java#RjsonMustacheParser.convertToJsonNode#valueToTree#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java:37 jsonNodeToObject test:node-conversion lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java#SqlGuiUtils.renderPsBindValue#jsonNodeToObject#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java:60 jsonNodeToObject test:node-conversion lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java#SqlGuiUtils.GuiSqlValue.fromJsonNode#jsonNodeToObject#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java:68 toJson test:downstream-render lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java#SqlGuiUtils.GuiSqlValue.getValue#toJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java:91 toJson test:downstream-render lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java#SqlGuiUtils.GuiSqlValue.getConcatSqlStr#toJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/application/model/Application.java:96 fromJsonSet test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/application/model/Application.java#Application.editingQueries#fromJsonSet#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/application/model/Application.java:96 toJson test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/application/model/Application.java#Application.editingQueries#toJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/application/model/Application.java:107 fromJsonSet test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/application/model/Application.java#Application.getLiveQueries#fromJsonSet#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/application/model/Application.java:107 toJson test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/application/model/Application.java#Application.getLiveQueries#toJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/datasource/repository/DatasourceRepository.java:182 toJson log inside log.error, DatasourceRepository.java:181-182 lowcoder-domain/src/main/java/org/lowcoder/domain/datasource/repository/DatasourceRepository.java#DatasourceRepository.convertToDomainObjectAndDecrypt#toJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/datasource/repository/DatasourceRepository.java:209 fromJsonMap test:persistence-roundtrip encrypted datasource config to Map for persistence lowcoder-domain/src/main/java/org/lowcoder/domain/datasource/repository/DatasourceRepository.java#DatasourceRepository.encryptDataAndConvertToDataObject#fromJsonMap#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/datasource/repository/DatasourceRepository.java:209 toJson test:persistence-roundtrip encrypted datasource config to Map for persistence lowcoder-domain/src/main/java/org/lowcoder/domain/datasource/repository/DatasourceRepository.java#DatasourceRepository.encryptDataAndConvertToDataObject#toJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/organization/model/OrganizationDomain.java:33 fromJsonSafely test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/organization/model/OrganizationDomain.java#OrganizationDomain.beforeMongodbWrite#fromJsonSafely#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/organization/model/OrganizationDomain.java:33 toJsonSafely test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/organization/model/OrganizationDomain.java#OrganizationDomain.beforeMongodbWrite#toJsonSafely#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/organization/model/OrganizationDomain.java:38 fromJsonSafely test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/organization/model/OrganizationDomain.java#OrganizationDomain.afterMongodbRead#fromJsonSafely#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/organization/model/OrganizationDomain.java:38 toJson test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/organization/model/OrganizationDomain.java#OrganizationDomain.afterMongodbRead#toJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:61 new ObjectMapper test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.OBJECT_MAPPER#new ObjectMapper#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:81 bodyValue test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.getPluginDynamicConfig#bodyValue#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:84 bodyToMono test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.getPluginDynamicConfig#bodyToMono#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:111 bodyToMono test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.getDatasourcePluginDefinitions#bodyToMono#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:138 writeValueAsString test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.executeQuery#writeValueAsString#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:155 bodyValue test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.executeQuery#bodyValue#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:158 bodyToMono test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.executeQuery#bodyToMono#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:162 bodyToMono test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.executeQuery#bodyToMono#2 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:180 bodyValue test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.test#bodyValue#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:183 bodyToMono test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.test#bodyToMono#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:191 bodyToMono test:node-service lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.test#bodyToMono#2 +lowcoder-domain/src/main/java/org/lowcoder/domain/query/model/LibraryQuery.java:33 fromJson test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/query/model/LibraryQuery.java#LibraryQuery.baseQuerySupplier#fromJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/query/model/LibraryQuery.java:33 toJson test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/query/model/LibraryQuery.java#LibraryQuery.baseQuerySupplier#toJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/query/model/LibraryQueryRecord.java:37 fromJson test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/query/model/LibraryQueryRecord.java#LibraryQueryRecord.baseQuerySupplier#fromJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/query/model/LibraryQueryRecord.java:37 toJson test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/query/model/LibraryQueryRecord.java#LibraryQueryRecord.baseQuerySupplier#toJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/solutions/TemplateSolutionServiceImpl.java:65 toJson test:dynamic-roundtrip DSL copied through toJson/fromJsonMap into a new application lowcoder-domain/src/main/java/org/lowcoder/domain/solutions/TemplateSolutionServiceImpl.java#TemplateSolutionServiceImpl.createFromTemplate#toJson#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/solutions/TemplateSolutionServiceImpl.java:69 fromJsonMap test:dynamic-roundtrip lowcoder-domain/src/main/java/org/lowcoder/domain/solutions/TemplateSolutionServiceImpl.java#TemplateSolutionServiceImpl.createFromTemplate#fromJsonMap#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/User.java:150 fromJsonSafely test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/User.java#User.beforeMongodbWrite#fromJsonSafely#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/User.java:150 toJsonSafely test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/User.java#User.beforeMongodbWrite#toJsonSafely#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/User.java:159 fromJsonSafely test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/User.java#User.afterMongodbRead#fromJsonSafely#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/User.java:159 toJson test:payload-getters lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/User.java#User.afterMongodbRead#toJson#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigFactory.java:47 toJson test:config-center config-center value rendered as JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigFactory.java#AutoReloadConfigFactory.getValue#toJson#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:25 fromJson test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofInteger#fromJson#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:30 fromJson test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofString#fromJson#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:35 fromJson test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofBoolean#fromJson#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:40 fromJson test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofJson#fromJson#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:45 fromJsonList test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofList#fromJsonList#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:50 fromJsonList test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofStringList#fromJsonList#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:55 fromJsonList test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofIntList#fromJsonList#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:60 fromJsonList test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofLongList#fromJsonList#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:66 fromJsonMap test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.ofMap#fromJsonMap#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:71 fromJson test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.fromJson#fromJson#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java:76 fromJsonList test:config-center config-center value parsed from JSON text lowcoder-infra/src/main/java/org/lowcoder/infra/config/AutoReloadConfigInstanceImpl.java#AutoReloadConfigInstanceImpl.fromJsonList#fromJsonList#1 +lowcoder-infra/src/main/java/org/lowcoder/infra/util/NetworkUtils.java:33 toJson log lowcoder-infra/src/main/java/org/lowcoder/infra/util/NetworkUtils.java#NetworkUtils.getRemoteIp#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/AbstractOauth2Request.java:34 toJson log lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/AbstractOauth2Request.java#AbstractOauth2Request.auth#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java:48 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java#GenericAuthRequest.getAuthToken#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java:51 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java#GenericAuthRequest.getAuthToken#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java:70 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java#GenericAuthRequest.refreshAuthToken#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java:73 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java#GenericAuthRequest.refreshAuthToken#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java:105 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java#GenericAuthRequest.getAuthUser#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java:108 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GenericAuthRequest.java#GenericAuthRequest.getAuthUser#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GithubRequest.java:54 bodyToMono not-jackson String response, parsed as form-encoded token response lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GithubRequest.java#GithubRequest.getAuthToken#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GithubRequest.java:58 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GithubRequest.java#GithubRequest.getAuthToken#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GithubRequest.java:96 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GithubRequest.java#GithubRequest.getAuthUser#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GithubRequest.java:100 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GithubRequest.java#GithubRequest.getAuthUser#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java:50 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java#GoogleRequest.getAuthToken#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java:54 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java#GoogleRequest.getAuthToken#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java:86 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java#GoogleRequest.refreshAuthToken#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java:90 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java#GoogleRequest.refreshAuthToken#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java:110 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java#GoogleRequest.getAuthUser#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java:114 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/GoogleRequest.java#GoogleRequest.getAuthUser#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java:54 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java#KeycloakRequest.getAuthToken#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java:58 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java#KeycloakRequest.getAuthToken#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java:90 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java#KeycloakRequest.refreshAuthToken#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java:94 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java#KeycloakRequest.refreshAuthToken#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java:116 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java#KeycloakRequest.getAuthUser#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java:120 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java#KeycloakRequest.getAuthUser#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java:51 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java#OryRequest.getAuthToken#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java:55 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java#OryRequest.getAuthToken#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java:87 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java#OryRequest.refreshAuthToken#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java:91 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java#OryRequest.refreshAuthToken#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java:112 bodyToMono test:oauth provider JSON decoded by WebClientBuildHelper client codecs (E8) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java#OryRequest.getAuthUser#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java:116 toJson test:error-message-json AuthException message; becomes a FAIL_TO_GET_OIDC_INFO argument (AbstractOauth2Request.java:33-35) lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java#OryRequest.getAuthUser#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/util/JwtDecoderUtil.java:9 new ObjectMapper test:jwt-claims bare new ObjectMapper(); JWT payload claims read during authentication lowcoder-server/src/main/java/org/lowcoder/api/authentication/util/JwtDecoderUtil.java#JwtDecoderUtil.objectMapper#new ObjectMapper#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/util/JwtDecoderUtil.java:33 readValue test:jwt-claims bare new ObjectMapper(); JWT payload claims read during authentication lowcoder-server/src/main/java/org/lowcoder/api/authentication/util/JwtDecoderUtil.java#JwtDecoderUtil.decodeJwtPayload#readValue#1 +lowcoder-server/src/main/java/org/lowcoder/api/datasource/UpsertDatasourceRequestMapper.java:43 fromJson test:upsert-datasource lowcoder-server/src/main/java/org/lowcoder/api/datasource/UpsertDatasourceRequestMapper.java#UpsertDatasourceRequestMapper.resolve#fromJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/datasource/UpsertDatasourceRequestMapper.java:43 toJson test:upsert-datasource lowcoder-server/src/main/java/org/lowcoder/api/datasource/UpsertDatasourceRequestMapper.java#UpsertDatasourceRequestMapper.resolve#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/framework/exception/CustomErrorWebExceptionHandler.java:85 BodyInserters.fromValue test:error-handler lowcoder-server/src/main/java/org/lowcoder/api/framework/exception/CustomErrorWebExceptionHandler.java#CustomErrorWebExceptionHandler.render#BodyInserters.fromValue#1 +lowcoder-server/src/main/java/org/lowcoder/api/framework/exception/CustomErrorWebExceptionHandler.java:103 BodyInserters.fromValue test:error-handler lowcoder-server/src/main/java/org/lowcoder/api/framework/exception/CustomErrorWebExceptionHandler.java#CustomErrorWebExceptionHandler.render#BodyInserters.fromValue#2 +lowcoder-server/src/main/java/org/lowcoder/api/framework/plugin/data/PluginServerRequest.java:50 bodyToMono not-jackson byte[] request body lowcoder-server/src/main/java/org/lowcoder/api/framework/plugin/data/PluginServerRequest.java#PluginServerRequest.fromServerRequest#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/framework/plugin/endpoint/PluginEndpointHandlerImpl.java:279 bodyValue not-jackson byte[] plugin response body (EndpointResponse.body()) lowcoder-server/src/main/java/org/lowcoder/api/framework/plugin/endpoint/PluginEndpointHandlerImpl.java#PluginEndpointHandlerImpl.createServerResponse#bodyValue#1 +lowcoder-server/src/main/java/org/lowcoder/api/home/SessionUserServiceImpl.java:141 fromJsonQuietly test:session-cache legacy User JSON or a plain user id; fromJsonQuietly must return null for the latter lowcoder-server/src/main/java/org/lowcoder/api/home/SessionUserServiceImpl.java#SessionUserServiceImpl.resolveSessionUserFromCookie#fromJsonQuietly#1 +lowcoder-server/src/main/java/org/lowcoder/api/misc/ApiFlowController.java:38 new ObjectMapper test:api-flow lowcoder-server/src/main/java/org/lowcoder/api/misc/ApiFlowController.java#ApiFlowController.flow#new ObjectMapper#1 +lowcoder-server/src/main/java/org/lowcoder/api/misc/ApiFlowController.java:39 writeValueAsString test:api-flow lowcoder-server/src/main/java/org/lowcoder/api/misc/ApiFlowController.java#ApiFlowController.flow#writeValueAsString#1 +lowcoder-server/src/main/java/org/lowcoder/api/misc/ApiFlowController.java:46 BodyInserters.fromValue not-jackson String body (already serialized at :39) lowcoder-server/src/main/java/org/lowcoder/api/misc/ApiFlowController.java#ApiFlowController.flow#BodyInserters.fromValue#1 +lowcoder-server/src/main/java/org/lowcoder/api/misc/ApiFlowController.java:49 bodyToMono not-jackson String response lowcoder-server/src/main/java/org/lowcoder/api/misc/ApiFlowController.java#ApiFlowController.flow#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/misc/JsLibraryController.java:70 fromJsonSafely test:js-library lowcoder-server/src/main/java/org/lowcoder/api/misc/JsLibraryController.java#JsLibraryController.#fromJsonSafely#1 +lowcoder-server/src/main/java/org/lowcoder/api/misc/JsLibraryController.java:85 toJson log lowcoder-server/src/main/java/org/lowcoder/api/misc/JsLibraryController.java#JsLibraryController.#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/api/misc/JsLibraryController.java:129 bodyToMono test:js-library lowcoder-server/src/main/java/org/lowcoder/api/misc/JsLibraryController.java#JsLibraryController.fetch#bodyToMono#1 +lowcoder-server/src/main/java/org/lowcoder/api/npm/PrivateNpmRegistryController.java:77 BodyInserters.fromValue test:npm-outbound lowcoder-server/src/main/java/org/lowcoder/api/npm/PrivateNpmRegistryController.java#PrivateNpmRegistryController.forwardToNodeService#BodyInserters.fromValue#1 +lowcoder-server/src/main/java/org/lowcoder/api/npm/PrivateNpmRegistryController.java:79 toEntity not-jackson Resource bytes lowcoder-server/src/main/java/org/lowcoder/api/npm/PrivateNpmRegistryController.java#PrivateNpmRegistryController.forwardToNodeService#toEntity#1 +lowcoder-server/src/main/java/org/lowcoder/api/npm/PrivateNpmRegistryController.java:96 BodyInserters.fromValue test:npm-outbound lowcoder-server/src/main/java/org/lowcoder/api/npm/PrivateNpmRegistryController.java#PrivateNpmRegistryController.forwardToNodeService#BodyInserters.fromValue#2 +lowcoder-server/src/main/java/org/lowcoder/api/npm/PrivateNpmRegistryController.java:97 toEntity not-jackson Resource bytes lowcoder-server/src/main/java/org/lowcoder/api/npm/PrivateNpmRegistryController.java#PrivateNpmRegistryController.forwardToNodeService#toEntity#2 +lowcoder-server/src/main/java/org/lowcoder/runner/eventlistener/OrgAndGroupEventListener.java:60 toJson log lowcoder-server/src/main/java/org/lowcoder/runner/eventlistener/OrgAndGroupEventListener.java#OrgAndGroupEventListener.onOrgDeleted#toJson#1 +lowcoder-server/src/main/java/org/lowcoder/runner/eventlistener/OrgAndGroupEventListener.java:108 toJson log lowcoder-server/src/main/java/org/lowcoder/runner/eventlistener/OrgAndGroupEventListener.java#OrgAndGroupEventListener.onGroupDeleted#toJson#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java:89 codecs test:client-codec-guard default codecs, maxInMemorySize only lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.EXCHANGE_STRATEGIES#codecs#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:102 codecs test:client-codec-guard default codecs, maxInMemorySize only lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.exchangeStrategies#codecs#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/webclient/WebClientBuildHelper.java:132 codecs test:client-codec-guard default codecs, maxInMemorySize only lowcoder-sdk/src/main/java/org/lowcoder/sdk/webclient/WebClientBuildHelper.java#WebClientBuildHelper.toWebClientBuilder#codecs#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:44 codecs test:client-codec-guard default codecs, maxInMemorySize only lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.EXCHANGE_STRATEGIES#codecs#1 +lowcoder-server/src/main/java/org/lowcoder/api/misc/JsLibraryController.java:63 codecs test:client-codec-guard default codecs, maxInMemorySize only lowcoder-server/src/main/java/org/lowcoder/api/misc/JsLibraryController.java#JsLibraryController.EXCHANGE_STRATEGY#codecs#1 +lowcoder-server/src/main/java/org/lowcoder/api/framework/configuration/CustomWebFluxConfiguration.java:34 getObjectMapper test:server-codec-guard production mapper bean for the server codecs lowcoder-server/src/main/java/org/lowcoder/api/framework/configuration/CustomWebFluxConfiguration.java#CustomWebFluxConfiguration.objectMapper#getObjectMapper#1 +lowcoder-server/src/main/java/org/lowcoder/api/framework/configuration/CustomWebFluxConfiguration.java:49 jackson2JsonDecoder test:server-codec-guard server decoder with the production mapper lowcoder-server/src/main/java/org/lowcoder/api/framework/configuration/CustomWebFluxConfiguration.java#CustomWebFluxConfiguration.configureHttpMessageCodecs#jackson2JsonDecoder#1 +lowcoder-server/src/main/java/org/lowcoder/api/framework/configuration/CustomWebFluxConfiguration.java:51 jackson2JsonEncoder test:server-codec-guard server encoder with the production mapper lowcoder-server/src/main/java/org/lowcoder/api/framework/configuration/CustomWebFluxConfiguration.java#CustomWebFluxConfiguration.configureHttpMessageCodecs#jackson2JsonEncoder#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/commands/MongoCommand.java:0 import unreachable sha256=04231fea60c1adb1; only Jackson use is the never-read field at :56 lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/commands/MongoCommand.java##import#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java:0 import test:node-service sha256=c94453e304c1445c; OBJECT_MAPPER (:61) is used only at :138, a listed site lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java##import#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/util/JwtDecoderUtil.java:0 import test:jwt-claims sha256=7dbde8bc0b0aca3c; objectMapper (:9) is used only at :33, a listed site lowcoder-server/src/main/java/org/lowcoder/api/authentication/util/JwtDecoderUtil.java##import#1 +lowcoder-server/src/main/java/org/lowcoder/api/framework/configuration/CustomWebFluxConfiguration.java:0 import test:server-codec-guard sha256=8add23907fd09443; Jackson uses are :34, :49, :51, all listed sites lowcoder-server/src/main/java/org/lowcoder/api/framework/configuration/CustomWebFluxConfiguration.java##import#1 +lowcoder-server/src/main/java/org/lowcoder/api/misc/ApiFlowController.java:0 import test:api-flow sha256=2dfed65b61d73366; the mapper of :38 is used only at :39, a listed site lowcoder-server/src/main/java/org/lowcoder/api/misc/ApiFlowController.java##import#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java:0 import not-jackson sha256=d26e552e9a6eb265; the only static import from BodyInserters is fromFormData (form encoding, :49, :86); its Jackson calls are listed sites lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/KeycloakRequest.java##import#1 +lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java:0 import not-jackson sha256=79bf516d34636763; the only static import from BodyInserters is fromFormData (form encoding, :46, :83); its Jackson calls are listed sites lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/oauth2/request/OryRequest.java##import#1 +lowcoder-domain/src/main/java/org/lowcoder/domain/configurations/RedisConfig.java:0 import test:session-cache sha256=5cf1d318355bc001; the Jackson2JsonRedisSerializer bean (:22-27) is never injected: every ReactiveRedisTemplate injection point receives reactiveStringRedisTemplate (E12, docs/tools/redis-probe.out); session-cache asserts that wiring lowcoder-domain/src/main/java/org/lowcoder/domain/configurations/RedisConfig.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/RjsonParser.java:19 createParser not-jackson tv.twelvetone RJsonParserFactory#createParser (import RjsonParser.java:7, field :11), not Jackson lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/RjsonParser.java#RjsonParser.getJsonParser#createParser#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/RjsonMustacheParser.java:0 import test:tree-text sha256=808e43d6893959f0; renderMustacheJsonString returns renderMustacheJson(...).toString() (:33), a JsonNode written by Jackson's internal default mapper; :94 and :179 call toString on an rjson JsonValue and on non-Number/Boolean param values, :127 textValue reads a node lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/RjsonMustacheParser.java##import#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:0 import test:tree-text sha256=d18f37c417521fe5; the special-JSON body is queryBody.getJsonValue().toString() (:483); other tree use builds ObjectNodes whose conversions are listed sites lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java##import#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java:0 import test:tree-text sha256=973dd323fbc16228; resultNode.toString() (:204) is re-read by the listed readTree at :204; the other toString calls (:158-202) are org.json JSONObject/JSONArray lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/utils/MongoQueryUtils.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java:0 import tree-only sha256=ed09595a4676c041; the ObjectMapper field (:55, :63-64) is never read; renderMustacheJson nodes are navigated, asText only after isTextual (:203-212), and parseJsonBody's node is encoded at the listed RawHttpHandlerHelper.java:42; reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none else lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java##import#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java:0 import tree-only sha256=87a422e580ae77df; ObjectNodes are built for headers and request params; their conversions are listed sites (header-json, codecs); reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java##import#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/model/GraphQLQueryExecutionContext.java:0 import tree-only sha256=ca8494af6557abc9; holds variablesParams as a JsonNode (:31); reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/model/GraphQLQueryExecutionContext.java##import#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/utils/GraphQLBodyUtils.java:0 import tree-only sha256=ee5f8671e61ff22c; puts the variables JsonNode into the body map (:20-22), encoded by GraphQLExecutor's listed codec site; reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/utils/GraphQLBodyUtils.java##import#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/model/QueryBody.java:0 import tree-only sha256=78fab8baf8c11727; holds a JsonNode or String (:6-11); the JsonNode is written at RestApiExecutor.java:483 (tree-text); reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/model/QueryBody.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/models/QueryExecutionResult.java:0 import tree-only sha256=7de7af1d27feb18e; holds response headers as a JsonNode (:23, :75), serialized by the server codec with QueryResultView (WP5); reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-sdk/src/main/java/org/lowcoder/sdk/models/QueryExecutionResult.java##import#1 +lowcoder-server/src/main/java/org/lowcoder/api/query/view/QueryResultView.java:0 import tree-only sha256=32e890eadd5cf0d5; exposes headers as a JsonNode (:36), serialized by the server codec (WP5 QueryResultView test); reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-server/src/main/java/org/lowcoder/api/query/view/QueryResultView.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetBulkObjectChangeSet.java:0 import tree-only sha256=a832da76a4fb79e7; renderMustacheJson result navigated (:23); values converted at listed jsonNodeToObject/fromJsonNode sites; reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetBulkObjectChangeSet.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetChangeSetRow.java:0 import tree-only sha256=8df07e85d3a90bf5; iterates ObjectNode fields (:36) and converts values with the listed JsonUtils.jsonNodeToObject; reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetChangeSetRow.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetChangeSetRows.java:0 import tree-only sha256=1bba1d871b219b6f; iterates ArrayNode elements into SheetChangeSetRow; reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetChangeSetRows.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetObjectChangeSet.java:0 import tree-only sha256=7c9ec8f805beb21a; renderMustacheJson result navigated (:24); reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetObjectChangeSet.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/BulkObjectChangeSet.java:0 import tree-only sha256=2eb2c5530b7b1f09; renderMustacheJson result navigated (:25); reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/BulkObjectChangeSet.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ChangeSetRow.java:0 import tree-only sha256=d563e3bae4940668; iterates ObjectNode fields (:36) and converts values with the listed GuiSqlValue.fromJsonNode; reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ChangeSetRow.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ChangeSetRows.java:0 import tree-only sha256=532978febad32096; iterates ArrayNode elements into ChangeSetRow; reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ChangeSetRows.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ObjectChangeSet.java:0 import tree-only sha256=ddb8c67e5736ff01; renderMustacheJson result navigated (:23); reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ObjectChangeSet.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java:0 import tree-only sha256=075dfaf092c94dbc; renderMustacheJson/renderMustacheJsonString delegate to RjsonMustacheParser (:259-264), whose toString is tree-text; reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none (its toString calls are on StringBuilders and Number/Boolean/String values, :177, :247, :316, :387, :492) lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java##import#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java:0 import tree-only sha256=9ec66681361fc4a1; renderMustacheJson result (:36) converted with the listed fromJsonNode; String.valueOf at :83 and :94 is on converted Java values; reviewed for toString/toPrettyString/asText/as/String.valueOf/concatenation on nodes: none lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java##import#1 +lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java:53 renderMustacheJsonString test:tree-text Elasticsearch DSL text lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java#EsQueryExecutor.buildQueryExecutionContext#renderMustacheJsonString#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java:178 renderMustacheJsonString test:tree-text raw Mongo command text lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java#MongoPlugin.MongoEngine.buildQueryExecutionContext#renderMustacheJsonString#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java:249 renderMustacheJsonString test:tree-text Mongo GUI command text lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java#MongoPlugin.MongoEngine.evaluateString#renderMustacheJsonString#1 +lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java:174 renderMustacheJsonString test:tree-text attachments text, then read by the listed fromJsonList (smtp-lists) lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.buildQueryExecutionContext#renderMustacheJsonString#1 +lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java:190 renderMustacheArrayJsonString test:tree-text address-list text, then read by the listed fromJsonList at :191 (smtp-lists) lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.renderAsArrayAndParse#renderMustacheArrayJsonString#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpRequest.java:38 renderMustacheJsonString test:tree-text raw HTTP JSON body text lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/http/RawHttpRequest.java#RawHttpRequest.renderParams#renderMustacheJsonString#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java:256 renderMustacheJsonString test:tree-text renderMustacheArrayJsonString delegates to RjsonMustacheParser lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java#MustacheHelper.renderMustacheArrayJsonString#renderMustacheJsonString#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java:260 renderMustacheJsonString test:tree-text delegation to RjsonMustacheParser lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java#MustacheHelper.renderMustacheJsonString#renderMustacheJsonString#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/RjsonMustacheParser.java:33 renderMustacheJson test:tree-text renderMustacheJsonString builds the node it writes with toString lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/RjsonMustacheParser.java#RjsonMustacheParser.renderMustacheJsonString#renderMustacheJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java:264 renderMustacheJson test:node-conversion delegation to RjsonMustacheParser lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/MustacheHelper.java#MustacheHelper.renderMustacheJson#renderMustacheJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetBulkObjectChangeSet.java:23 renderMustacheJson test:node-conversion sheet bulk change set lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetBulkObjectChangeSet.java#SheetBulkObjectChangeSet.render#renderMustacheJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetKeyValuePairChangeSet.java:48 renderMustacheJson test:node-conversion value read back by the listed jsonNodeToObject on the same line lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetKeyValuePairChangeSet.java#SheetKeyValuePairChangeSet.render#renderMustacheJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetObjectChangeSet.java:24 renderMustacheJson test:node-conversion sheet object change set lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sheet/changeset/SheetObjectChangeSet.java#SheetObjectChangeSet.render#renderMustacheJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/BulkObjectChangeSet.java:25 renderMustacheJson test:node-conversion SQL bulk change set lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/BulkObjectChangeSet.java#BulkObjectChangeSet.render#renderMustacheJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ObjectChangeSet.java:23 renderMustacheJson test:node-conversion SQL object change set lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/changeset/ObjectChangeSet.java#ObjectChangeSet.render#renderMustacheJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java:36 renderMustacheJson test:node-conversion renderPsBindValue, read back by the listed jsonNodeToObject at :37 lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/SqlGuiUtils.java#SqlGuiUtils.renderPsBindValue#renderMustacheJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java:181 renderMustacheJson test:node-conversion multipart file value: object or array of {data, name} text nodes lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java#DataUtils.convertToMultiformFileValue#renderMustacheJson#1 +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java:145 renderMustacheJson test:downstream-encode GraphQL variables node, encoded at the listed :454 lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.buildQueryExecutionContext#renderMustacheJson#1 +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java:153 renderMustacheJson test:downstream-encode REST JSON body node, encoded at the listed :487 or written at :483 (tree-text) lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.buildQueryExecutionContext#renderMustacheJson#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java:98 renderMustacheJson test:downstream-encode raw HTTP JSON body node, encoded at the listed RawHttpHandlerHelper.java:42 lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/restapi/DataUtils.java#DataUtils.parseJsonBody#renderMustacheJson#1 +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java:137 parseResultBody test:external-json Mongo command result tree from the listed readTree sites of MongoQueryUtils lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java#MongoPlugin.MongoEngine.executeQuery#parseResultBody#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkInsertCommand.java:70 getConcatSqlStr test:downstream-render SQL text from GuiSqlValue.getConcatSqlStr (listed toJson at SqlGuiUtils.java:91) lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkInsertCommand.java#BulkInsertCommand.render#getConcatSqlStr#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkUpdateCommand.java:72 getConcatSqlStr test:downstream-render SQL text from GuiSqlValue.getConcatSqlStr (listed toJson at SqlGuiUtils.java:91) lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkUpdateCommand.java#BulkUpdateCommand.appendWhere#getConcatSqlStr#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkUpdateCommand.java:120 getConcatSqlStr test:downstream-render SQL text from GuiSqlValue.getConcatSqlStr (listed toJson at SqlGuiUtils.java:91) lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkUpdateCommand.java#BulkUpdateCommand.appendCaseWhen#getConcatSqlStr#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkUpdateCommand.java:122 getConcatSqlStr test:downstream-render SQL text from GuiSqlValue.getConcatSqlStr (listed toJson at SqlGuiUtils.java:91) lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/BulkUpdateCommand.java#BulkUpdateCommand.appendCaseWhen#getConcatSqlStr#2 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/InsertCommand.java:74 getConcatSqlStr test:downstream-render SQL text from GuiSqlValue.getConcatSqlStr (listed toJson at SqlGuiUtils.java:91) lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/InsertCommand.java#InsertCommand.buildRawSql#getConcatSqlStr#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/UpdateCommand.java:84 getConcatSqlStr test:downstream-render SQL text from GuiSqlValue.getConcatSqlStr (listed toJson at SqlGuiUtils.java:91) lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/command/UpdateCommand.java#UpdateCommand.appendSet#getConcatSqlStr#1 +lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/filter/FilterSet.java:83 getConcatSqlStr test:downstream-render SQL text from GuiSqlValue.getConcatSqlStr (listed toJson at SqlGuiUtils.java:91) lowcoder-sdk/src/main/java/org/lowcoder/sdk/plugin/sqlcommand/filter/FilterSet.java#FilterSet.renderCondition#getConcatSqlStr#1 diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/Ctl.java b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/Ctl.java new file mode 100644 index 0000000000..5fb65355f4 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/Ctl.java @@ -0,0 +1,18 @@ +// Control input for docs/tools/json_boundary_sites.py (never compiled). Expected output: json-boundary-control.out +class Ctl { + public static Object readTree(String s) { return null; } + Object a() { return readTree("x"); } + // fromJson(x) + String s = "fromJson("; + Object b() { return JsonUtils.fromJson( + "x", Object.class); } + java.util.function.Function f = Ctl::fromJsonNode; + Object m = new ObjectMapper(); + void w(Object x) { client.post().bodyValue(x).retrieve().bodyToMono(Map.class); } + Object i = BodyInserters.fromValue(x); + Object r = Role.fromValue("admin"); + Object e = ExchangeStrategies.builder().codecs(c -> c.defaultCodecs().maxInMemorySize(-1)).build(); +} +class CtlCodecs { + void configure(ServerCodecConfigurer c) { c.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(JsonUtils.getObjectMapper())); } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/FqnUse.java b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/FqnUse.java new file mode 100644 index 0000000000..a867cd6692 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/FqnUse.java @@ -0,0 +1,4 @@ +// Control input: names a raw Jackson type fully qualified, without an import; must be reported as `FqnUse.java:0 import`. +class FqnUse { + com.fasterxml.jackson.core.JsonParser parser; +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/ImportOnly.java b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/ImportOnly.java new file mode 100644 index 0000000000..81064d1923 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/ImportOnly.java @@ -0,0 +1,6 @@ +// Control input: imports a Jackson mapper type but contains no listed call; must be reported as `ImportOnly.java:0 import`. +import com.fasterxml.jackson.databind.ObjectMapper; + +class ImportOnly { + void use(ObjectMapper mapper) { mapper.someMethodNotListed(); } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/LombokOnly.java b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/LombokOnly.java new file mode 100644 index 0000000000..935815cad6 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/LombokOnly.java @@ -0,0 +1,4 @@ +import lombok.extern.jackson.Jacksonized; + +class LombokOnly { +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/OtherLibraryJackson.java b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/OtherLibraryJackson.java new file mode 100644 index 0000000000..76ebe01466 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/OtherLibraryJackson.java @@ -0,0 +1,8 @@ +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import lombok.extern.jackson.Jacksonized; + +class OtherLibraryJackson { + Object serializer() { + return new Jackson2JsonRedisSerializer<>(String.class); + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/ReaderWriter.java b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/ReaderWriter.java new file mode 100644 index 0000000000..bbd62b887f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/ReaderWriter.java @@ -0,0 +1,6 @@ +class ReaderWriter { + void use(Object mapper, java.io.OutputStream out, Object value) throws Exception { + ((com.fasterxml.jackson.databind.ObjectMapper) mapper).writeValue(out, value); + ((com.fasterxml.jackson.databind.ObjectMapper) mapper).readerFor(String.class); + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/StaticInserter.java b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/StaticInserter.java new file mode 100644 index 0000000000..fd3c2f99f0 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/StaticInserter.java @@ -0,0 +1,7 @@ +import static org.springframework.web.reactive.function.BodyInserters.fromValue; + +class StaticInserter { + Object body(Object value) { + return fromValue(value); + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/TreeText.java b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/TreeText.java new file mode 100644 index 0000000000..88f9a78c77 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/TreeText.java @@ -0,0 +1,7 @@ +import com.fasterxml.jackson.databind.JsonNode; + +class TreeText { + String text(JsonNode node) { + return node.toString(); + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/Wrap.java b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/Wrap.java new file mode 100644 index 0000000000..271e5e6ecc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary-sites/json_boundary_control/lowcoder-sdk/src/main/java/Wrap.java @@ -0,0 +1,9 @@ +class Wrap { + public static String render(Object value) { + return toJson(value); + } + + private static String hidden(Object value) { + return toJson(value); + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/api-flow/representative.body.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/api-flow/representative.body.json new file mode 100644 index 0000000000..0a1f996f6d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/api-flow/representative.body.json @@ -0,0 +1 @@ +{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/GenericAuthRequest.messages.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/GenericAuthRequest.messages.json new file mode 100644 index 0000000000..a832bd9ae8 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/GenericAuthRequest.messages.json @@ -0,0 +1,6 @@ +{ + "GenericAuthRequest.getAuthToken" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}", + "GenericAuthRequest.refreshAuthToken" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}", + "GenericAuthRequest.getAuthUser" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}", + "GenericAuthRequest.getAuthToken (HTTP 400)" : "org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest 400 BAD_REQUEST" +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/GithubRequest.messages.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/GithubRequest.messages.json new file mode 100644 index 0000000000..7ea80c3104 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/GithubRequest.messages.json @@ -0,0 +1,4 @@ +{ + "GithubRequest.getAuthToken" : "{\"error_description\":\"The code passed is incorrect or expired.\",\"error\":\"bad_verification_code\",\"error_uri\":\"https://docs.github.com/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code\"}", + "GithubRequest.getAuthUser" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}" +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/GoogleRequest.messages.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/GoogleRequest.messages.json new file mode 100644 index 0000000000..3136996644 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/GoogleRequest.messages.json @@ -0,0 +1,5 @@ +{ + "GoogleRequest.getAuthToken" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}", + "GoogleRequest.refreshAuthToken" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}", + "GoogleRequest.getAuthUser" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}" +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/KeycloakRequest.messages.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/KeycloakRequest.messages.json new file mode 100644 index 0000000000..208b776ab0 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/KeycloakRequest.messages.json @@ -0,0 +1,7 @@ +{ + "KeycloakRequest.getAuthToken" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}", + "KeycloakRequest.refreshAuthToken" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}", + "KeycloakRequest.getAuthUser" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}", + "AbstractOauth2Request.auth (Keycloak token error)" : "Failed to get OIDC information, error message: {\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}.", + "AbstractOauth2Request.refresh (Keycloak token error)" : "Failed to get OIDC information, error message: {\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}." +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/OryRequest.messages.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/OryRequest.messages.json new file mode 100644 index 0000000000..c150224305 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/OryRequest.messages.json @@ -0,0 +1,5 @@ +{ + "OryRequest.getAuthToken" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}", + "OryRequest.refreshAuthToken" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}", + "OryRequest.getAuthUser" : "{\"error\":\"invalid_grant\",\"error_description\":\"Code \\\"c0de\\\" expired – réessayez\",\"error_uri\":\"https://idp.example.com/errors?code=invalid_grant&lang=fr\",\"status\":400,\"retry_after\":1.5,\"details\":{\"attempts\":[1,2],\"trace\":null}}" +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/github.token.error.txt b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/github.token.error.txt new file mode 100644 index 0000000000..39dc8e11be --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/github.token.error.txt @@ -0,0 +1 @@ +error=bad_verification_code&error_description=The+code+passed+is+incorrect+or+expired.&error_uri=https%3A%2F%2Fdocs.github.com%2Fapps%2Fmanaging-oauth-apps%2Ftroubleshooting-oauth-app-access-token-request-errors%2F%23bad-verification-code \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/provider.error.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/provider.error.json new file mode 100644 index 0000000000..912109ddb9 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/error-message-json/provider.error.json @@ -0,0 +1 @@ +{"error":"invalid_grant","error_description":"Code \"c0de\" expired – réessayez","error_uri":"https://idp.example.com/errors?code=invalid_grant&lang=fr","status":400,"retry_after":1.50,"details":{"attempts":[1,2],"trace":null}} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/fetch.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/fetch.json new file mode 100644 index 0000000000..6dd293e508 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/fetch.json @@ -0,0 +1,31 @@ +{ + "full" : { + "name" : "full", + "latestVersion" : "4.10.0", + "homepage" : "https://full.example.com/docs", + "description" : "A library whose registry document has every field JsLibraryController reads: é ✓ \"quoted\"", + "downloadUrl" : null + }, + "repository-homepage" : { + "name" : "repository-homepage", + "latestVersion" : "1.0.0", + "homepage" : "https://github.com/example/repository-homepage.git", + "description" : "A library without a homepage: JsLibraryController falls back to its git repository", + "downloadUrl" : null + }, + "no-homepage" : { + "name" : "no-homepage", + "latestVersion" : null, + "homepage" : null, + "description" : "A library without homepage or dist-tags, whose repository is not git", + "downloadUrl" : null + }, + "not-found" : { + "name" : "not-found", + "latestVersion" : null, + "homepage" : null, + "description" : null, + "downloadUrl" : null + }, + "not-json" : "failsWith org.springframework.core.codec.DecodingException" +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/recommended.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/recommended.json new file mode 100644 index 0000000000..5cdff4850a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/recommended.json @@ -0,0 +1,25 @@ +[ { + "name" : "i18next", + "latestVersion" : "4.10.0", + "homepage" : "https://full.example.com/docs", + "description" : "A library whose registry document has every field JsLibraryController reads: é ✓ \"quoted\"", + "downloadUrl" : "https://unpkg.com/i18next/dist/umd/i18next.min.js" +}, { + "name" : "jmespath", + "latestVersion" : "4.10.0", + "homepage" : "https://full.example.com/docs", + "description" : "A library whose registry document has every field JsLibraryController reads: é ✓ \"quoted\"", + "downloadUrl" : "https://unpkg.com/jmespath/jmespath.js" +}, { + "name" : "jsonpath", + "latestVersion" : "4.10.0", + "homepage" : "https://full.example.com/docs", + "description" : "A library whose registry document has every field JsLibraryController reads: é ✓ \"quoted\"", + "downloadUrl" : "https://unpkg.com/jsonpath/jsonpath.js" +}, { + "name" : "jspdf", + "latestVersion" : "4.10.0", + "homepage" : "https://full.example.com/docs", + "description" : "A library whose registry document has every field JsLibraryController reads: é ✓ \"quoted\"", + "downloadUrl" : "https://unpkg.com/jspdf/dist/jspdf.umd.min.js" +} ] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.full.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.full.json new file mode 100644 index 0000000000..72cdabdb2e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.full.json @@ -0,0 +1,16 @@ +{ + "_id" : "full", + "_rev" : "40-3000000001", + "name" : "full", + "description" : "A library whose registry document has every field JsLibraryController reads: é ✓ \"quoted\"", + "homepage" : "https://full.example.com/docs", + "dist-tags" : { "latest" : "4.10.0", "next" : "5.0.0-beta.1" }, + "repository" : { "type" : "git", "url" : "git+https://github.com/example/full.git" }, + "versions" : { + "4.10.0" : { "name" : "full", "version" : "4.10.0", "dist" : { "unpackedSize" : 3000000001, "fileCount" : 40012, "ratio" : 1.50 } } + }, + "time" : { "created" : "2016-01-01T00:00:00.000Z", "4.10.0" : "2026-01-01T00:00:00.000Z" }, + "maintainers" : [ { "name" : "maintainer-1" }, { "name" : "maintainer-2" } ], + "deprecated" : null, + "private" : false +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.no-homepage.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.no-homepage.json new file mode 100644 index 0000000000..8701655704 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.no-homepage.json @@ -0,0 +1,5 @@ +{ + "name" : "no-homepage", + "description" : "A library without homepage or dist-tags, whose repository is not git", + "repository" : { "type" : "svn", "url" : "svn://svn.example.com/no-homepage" } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.not-json.txt b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.not-json.txt new file mode 100644 index 0000000000..e2ca8fe45b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.not-json.txt @@ -0,0 +1 @@ +the registry answered with a page, not JSON diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.repository-homepage.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.repository-homepage.json new file mode 100644 index 0000000000..1526ea4ac9 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/js-library/registry.repository-homepage.json @@ -0,0 +1,7 @@ +{ + "name" : "repository-homepage", + "description" : "A library without a homepage: JsLibraryController falls back to its git repository", + "homepage" : " ", + "dist-tags" : { "latest" : "1.0.0" }, + "repository" : { "type" : "git", "url" : "git+https://github.com/example/repository-homepage.git" } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/jwt-claims/claims.input.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/jwt-claims/claims.input.json new file mode 100644 index 0000000000..51ed0dd857 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/jwt-claims/claims.input.json @@ -0,0 +1,21 @@ +{ + "iss": "https://idp.example.com/realms/lowcoder", + "sub": "f3b1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "aud": ["lowcoder", "account"], + "iat": 1767225650, + "exp": 4102444800, + "nbf": 1767225650.5, + "auth_time": 0, + "email_verified": true, + "preferred_username": "tech.ninja", + "name": "J\u00e1n \"Tech\" Ninja\\", + "email": "tech.ninja@example.com", + "picture": "https://cdn.example.com/a/tech.png?s=96&v=2", + "groups": ["/admins", "/devs"], + "realm_access": {"roles": ["offline_access", "uma_authorization"], "level": 2}, + "quota": 12.50, + "ratio": 1e-3, + "serial": 18446744073709551616, + "mixed": [1, "two", false, null, 2.0, {"deep": [[]]}], + "acr": null +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/jwt-claims/claims.read.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/jwt-claims/claims.read.json new file mode 100644 index 0000000000..d44905d304 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/jwt-claims/claims.read.json @@ -0,0 +1,76 @@ +{ + "classes" : { + "" : "java.util.LinkedHashMap", + "/iss" : "java.lang.String", + "/sub" : "java.lang.String", + "/aud" : "java.util.ArrayList", + "/aud/0" : "java.lang.String", + "/aud/1" : "java.lang.String", + "/iat" : "java.lang.Integer", + "/exp" : "java.lang.Long", + "/nbf" : "java.lang.Double", + "/auth_time" : "java.lang.Integer", + "/email_verified" : "java.lang.Boolean", + "/preferred_username" : "java.lang.String", + "/name" : "java.lang.String", + "/email" : "java.lang.String", + "/picture" : "java.lang.String", + "/groups" : "java.util.ArrayList", + "/groups/0" : "java.lang.String", + "/groups/1" : "java.lang.String", + "/realm_access" : "java.util.LinkedHashMap", + "/realm_access/roles" : "java.util.ArrayList", + "/realm_access/roles/0" : "java.lang.String", + "/realm_access/roles/1" : "java.lang.String", + "/realm_access/level" : "java.lang.Integer", + "/quota" : "java.lang.Double", + "/ratio" : "java.lang.Double", + "/serial" : "java.math.BigInteger", + "/mixed" : "java.util.ArrayList", + "/mixed/0" : "java.lang.Integer", + "/mixed/1" : "java.lang.String", + "/mixed/2" : "java.lang.Boolean", + "/mixed/3" : "null", + "/mixed/4" : "java.lang.Double", + "/mixed/5" : "java.util.LinkedHashMap", + "/mixed/5/deep" : "java.util.ArrayList", + "/mixed/5/deep/0" : "java.util.ArrayList", + "/acr" : "null" + }, + "keyOrder" : { + "/realm_access" : [ "roles", "level" ], + "/mixed/5" : [ "deep" ], + "" : [ "iss", "sub", "aud", "iat", "exp", "nbf", "auth_time", "email_verified", "preferred_username", "name", "email", "picture", "groups", "realm_access", "quota", "ratio", "serial", "mixed", "acr" ] + }, + "values" : { + "iss" : "https://idp.example.com/realms/lowcoder", + "sub" : "f3b1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "aud" : [ "lowcoder", "account" ], + "iat" : 1767225650, + "exp" : 4102444800, + "nbf" : 1.7672256505E9, + "auth_time" : 0, + "email_verified" : true, + "preferred_username" : "tech.ninja", + "name" : "Ján \"Tech\" Ninja\\", + "email" : "tech.ninja@example.com", + "picture" : "https://cdn.example.com/a/tech.png?s=96&v=2", + "groups" : [ "/admins", "/devs" ], + "realm_access" : { + "roles" : [ "offline_access", "uma_authorization" ], + "level" : 2 + }, + "quota" : 12.5, + "ratio" : 0.001, + "serial" : 18446744073709551616, + "mixed" : [ 1, "two", false, null, 2.0, { + "deep" : [ [ ] ] + } ], + "acr" : null + }, + "errors" : { + "noPayloadSegment" : "java.lang.IllegalArgumentException: Invalid JWT format.", + "payloadNotJson" : "com.fasterxml.jackson.core.JsonParseException", + "payloadNotAnObject" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/npm-outbound/default-registries.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/npm-outbound/default-registries.json new file mode 100644 index 0000000000..bc69e4796e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/npm-outbound/default-registries.json @@ -0,0 +1 @@ +{"npmRegistries":[],"workspaceId":"PrivateNpmRegistryEndpointContractTest.orgId"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/npm-outbound/with-registries.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/npm-outbound/with-registries.json new file mode 100644 index 0000000000..049a7aa5f5 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/npm-outbound/with-registries.json @@ -0,0 +1 @@ +{"npmRegistries":[{"scope":{"type":"organization","pattern":"@lowcoder"},"registry":{"url":"https://npm.example.com/","auth":{"type":"bearer","credentials":"PrivateNpmRegistryEndpointContractTest.bearer.credentials"}}},{"scope":{"type":"global","pattern":""},"registry":{"url":"https://registry.example.com/npm/","auth":{"type":"basic","credentials":"PrivateNpmRegistryEndpointContractTest.basic.credentials"}}}],"workspaceId":"PrivateNpmRegistryEndpointContractTest.orgId"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.decoded.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.decoded.json new file mode 100644 index 0000000000..7584c72fcb --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.decoded.json @@ -0,0 +1,185 @@ +{ + "token" : { + "request" : "POST /generic/token", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/access_token" : "java.lang.String", + "/expires_in" : "java.lang.Integer", + "/refresh_expires_in" : "java.lang.Integer", + "/refresh_token" : "java.lang.String", + "/token_type" : "java.lang.String", + "/id_token" : "java.lang.String", + "/not-before-policy" : "java.lang.Integer", + "/session_state" : "java.lang.String", + "/scope" : "java.lang.String" + }, + "keyOrder" : { + "" : [ "access_token", "expires_in", "refresh_expires_in", "refresh_token", "token_type", "id_token", "not-before-policy", "session_state", "scope" ] + } + }, + "authToken" : { + "accessToken" : "at.generic.0001", + "expireIn" : 3599, + "refreshToken" : "rt.generic.0001", + "refreshTokenExpireIn" : 0, + "jwt" : "" + } + }, + "refresh" : { + "request" : "POST /generic/token", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/access_token" : "java.lang.String", + "/expires_in" : "java.lang.String", + "/refresh_token" : "java.lang.String", + "/token_type" : "java.lang.String", + "/id_token" : "java.lang.String", + "/scope" : "java.lang.String" + }, + "keyOrder" : { + "" : [ "access_token", "expires_in", "refresh_token", "token_type", "id_token", "scope" ] + } + }, + "authToken" : { + "accessToken" : "at.generic.0002", + "expireIn" : 3599, + "refreshToken" : "rt.generic.0002", + "refreshTokenExpireIn" : 0, + "jwt" : "" + } + }, + "user" : { + "request" : "GET /generic/userinfo", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/sub" : "java.lang.String", + "/email_verified" : "java.lang.Boolean", + "/name" : "java.lang.String", + "/preferred_username" : "java.lang.String", + "/given_name" : "java.lang.String", + "/family_name" : "java.lang.String", + "/email" : "java.lang.String", + "/picture" : "java.lang.String", + "/groups" : "java.util.ArrayList", + "/groups/0" : "java.lang.String", + "/groups/1" : "java.lang.String", + "/updated_at" : "java.lang.Integer", + "/quota" : "java.lang.Double", + "/address" : "java.util.LinkedHashMap", + "/address/country" : "java.lang.String", + "/address/postal_code" : "java.lang.String" + }, + "keyOrder" : { + "/address" : [ "country", "postal_code" ], + "" : [ "sub", "email_verified", "name", "preferred_username", "given_name", "family_name", "email", "picture", "groups", "updated_at", "quota", "address" ] + } + }, + "authUser" : { + "uid" : "f3b1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "username" : "tech.ninja", + "email" : "Tech.Ninja@Example.com", + "avatar" : "https://cdn.example.com/a/tech.png?s=96&v=2", + "groupId" : null, + "rawUserInfo" : { + "sub" : "f3b1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "email_verified" : true, + "name" : "Tech Ninja", + "preferred_username" : "tech.ninja", + "given_name" : "Tech", + "family_name" : "Ninja", + "email" : "Tech.Ninja@Example.com", + "picture" : "https://cdn.example.com/a/tech.png?s=96&v=2", + "groups" : [ "/admins", "/devs" ], + "updated_at" : 1767225650, + "quota" : 12.5, + "address" : { + "country" : "SK", + "postal_code" : "81101" + } + } + } + }, + "userFromIdToken" : { + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/iss" : "java.lang.String", + "/sub" : "java.lang.String", + "/aud" : "java.util.ArrayList", + "/aud/0" : "java.lang.String", + "/aud/1" : "java.lang.String", + "/iat" : "java.lang.Integer", + "/exp" : "java.lang.Long", + "/nbf" : "java.lang.Double", + "/auth_time" : "java.lang.Integer", + "/email_verified" : "java.lang.Boolean", + "/preferred_username" : "java.lang.String", + "/name" : "java.lang.String", + "/email" : "java.lang.String", + "/picture" : "java.lang.String", + "/groups" : "java.util.ArrayList", + "/groups/0" : "java.lang.String", + "/groups/1" : "java.lang.String", + "/realm_access" : "java.util.LinkedHashMap", + "/realm_access/roles" : "java.util.ArrayList", + "/realm_access/roles/0" : "java.lang.String", + "/realm_access/roles/1" : "java.lang.String", + "/realm_access/level" : "java.lang.Integer", + "/quota" : "java.lang.Double", + "/ratio" : "java.lang.Double", + "/serial" : "java.math.BigInteger", + "/mixed" : "java.util.ArrayList", + "/mixed/0" : "java.lang.Integer", + "/mixed/1" : "java.lang.String", + "/mixed/2" : "java.lang.Boolean", + "/mixed/3" : "null", + "/mixed/4" : "java.lang.Double", + "/mixed/5" : "java.util.LinkedHashMap", + "/mixed/5/deep" : "java.util.ArrayList", + "/mixed/5/deep/0" : "java.util.ArrayList", + "/acr" : "null" + }, + "keyOrder" : { + "/realm_access" : [ "roles", "level" ], + "/mixed/5" : [ "deep" ], + "" : [ "iss", "sub", "aud", "iat", "exp", "nbf", "auth_time", "email_verified", "preferred_username", "name", "email", "picture", "groups", "realm_access", "quota", "ratio", "serial", "mixed", "acr" ] + } + }, + "authUser" : { + "uid" : "f3b1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "username" : "tech.ninja", + "email" : "tech.ninja@example.com", + "avatar" : "https://cdn.example.com/a/tech.png?s=96&v=2", + "groupId" : "/admins", + "rawUserInfo" : { + "iss" : "https://idp.example.com/realms/lowcoder", + "sub" : "f3b1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "aud" : [ "lowcoder", "account" ], + "iat" : 1767225650, + "exp" : 4102444800, + "nbf" : 1.7672256505E9, + "auth_time" : 0, + "email_verified" : true, + "preferred_username" : "tech.ninja", + "name" : "Ján \"Tech\" Ninja\\", + "email" : "tech.ninja@example.com", + "picture" : "https://cdn.example.com/a/tech.png?s=96&v=2", + "groups" : [ "/admins", "/devs" ], + "realm_access" : { + "roles" : [ "offline_access", "uma_authorization" ], + "level" : 2 + }, + "quota" : 12.5, + "ratio" : 0.001, + "serial" : 18446744073709551616, + "mixed" : [ 1, "two", false, null, 2.0, { + "deep" : [ [ ] ] + } ], + "acr" : null + } + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.refresh.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.refresh.json new file mode 100644 index 0000000000..332c9d8f07 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.refresh.json @@ -0,0 +1 @@ +{"access_token":"at.generic.0002","expires_in":"3599","refresh_token":"rt.generic.0002","token_type":"Bearer","id_token":"$ID_TOKEN","scope":"openid email profile"} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.token.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.token.json new file mode 100644 index 0000000000..2bf6c54ab3 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.token.json @@ -0,0 +1 @@ +{"access_token":"at.generic.0001","expires_in":3599,"refresh_expires_in":86400,"refresh_token":"rt.generic.0001","token_type":"Bearer","id_token":"$ID_TOKEN","not-before-policy":0,"session_state":"5c1d0a1e-8f2b-4c3d-9e4f-a5b6c7d8e9f0","scope":"openid email profile"} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.user.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.user.json new file mode 100644 index 0000000000..93de9ac732 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/generic.user.json @@ -0,0 +1 @@ +{"sub":"f3b1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d","email_verified":true,"name":"Tech Ninja","preferred_username":"tech.ninja","given_name":"Tech","family_name":"Ninja","email":"Tech.Ninja@Example.com","picture":"https://cdn.example.com/a/tech.png?s=96&v=2","groups":["/admins","/devs"],"updated_at":1767225650,"quota":12.50,"address":{"country":"SK","postal_code":"81101"}} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/github.decoded.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/github.decoded.json new file mode 100644 index 0000000000..7fdc90679b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/github.decoded.json @@ -0,0 +1,81 @@ +{ + "token" : { + "request" : "POST /login/oauth/access_token?code=c0de&client_id=lowcoder-client&client_secret=s3cr3t&grant_type=authorization_code&redirect_uri=https%3A%2F%2Flowcoder.example.com%2Fapps%2Foauth%2Fredirect", + "authToken" : { + "accessToken" : "gho_16C7e42F292c6912E7710c838347Ae178B4a", + "expireIn" : 0, + "refreshToken" : null, + "refreshTokenExpireIn" : 0, + "jwt" : null + } + }, + "refresh" : "refresh returns an empty Mono", + "user" : { + "request" : "GET /user", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/login" : "java.lang.String", + "/id" : "java.lang.Integer", + "/node_id" : "java.lang.String", + "/avatar_url" : "java.lang.String", + "/gravatar_id" : "java.lang.String", + "/type" : "java.lang.String", + "/site_admin" : "java.lang.Boolean", + "/name" : "java.lang.String", + "/company" : "java.lang.String", + "/blog" : "java.lang.String", + "/location" : "java.lang.String", + "/email" : "null", + "/hireable" : "null", + "/bio" : "null", + "/public_repos" : "java.lang.Integer", + "/followers" : "java.lang.Integer", + "/created_at" : "java.lang.String", + "/disk_usage" : "java.lang.Long", + "/plan" : "java.util.LinkedHashMap", + "/plan/name" : "java.lang.String", + "/plan/space" : "java.lang.Integer", + "/plan/collaborators" : "java.lang.Integer", + "/plan/private_repos" : "java.lang.Integer" + }, + "keyOrder" : { + "/plan" : [ "name", "space", "collaborators", "private_repos" ], + "" : [ "login", "id", "node_id", "avatar_url", "gravatar_id", "type", "site_admin", "name", "company", "blog", "location", "email", "hireable", "bio", "public_repos", "followers", "created_at", "disk_usage", "plan" ] + } + }, + "authUser" : { + "uid" : "583231", + "username" : "octocat", + "email" : null, + "avatar" : "https://avatars.githubusercontent.com/u/583231?v=4", + "groupId" : null, + "rawUserInfo" : { + "login" : "octocat", + "id" : 583231, + "node_id" : "MDQ6VXNlcjU4MzIzMQ==", + "avatar_url" : "https://avatars.githubusercontent.com/u/583231?v=4", + "gravatar_id" : "", + "type" : "User", + "site_admin" : false, + "name" : "The Octocat", + "company" : "@github", + "blog" : "https://github.blog", + "location" : "San Francisco", + "email" : null, + "hireable" : null, + "bio" : null, + "public_repos" : 8, + "followers" : 20000, + "created_at" : "2011-01-25T18:44:36Z", + "disk_usage" : 10000000000, + "plan" : { + "name" : "pro", + "space" : 976562499, + "collaborators" : 0, + "private_repos" : 9999 + } + } + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/github.token.txt b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/github.token.txt new file mode 100644 index 0000000000..5705d86037 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/github.token.txt @@ -0,0 +1 @@ +access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/github.user.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/github.user.json new file mode 100644 index 0000000000..e0d4e08d72 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/github.user.json @@ -0,0 +1 @@ +{"login":"octocat","id":583231,"node_id":"MDQ6VXNlcjU4MzIzMQ==","avatar_url":"https://avatars.githubusercontent.com/u/583231?v=4","gravatar_id":"","type":"User","site_admin":false,"name":"The Octocat","company":"@github","blog":"https://github.blog","location":"San Francisco","email":null,"hireable":null,"bio":null,"public_repos":8,"followers":20000,"created_at":"2011-01-25T18:44:36Z","disk_usage":10000000000,"plan":{"name":"pro","space":976562499,"collaborators":0,"private_repos":9999}} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.decoded.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.decoded.json new file mode 100644 index 0000000000..3044872e46 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.decoded.json @@ -0,0 +1,87 @@ +{ + "token" : { + "request" : "POST /oauth2/v4/token?code=c0de&client_id=lowcoder-client&client_secret=s3cr3t&grant_type=authorization_code&redirect_uri=https%3A%2F%2Flowcoder.example.com%2Fapps%2Foauth%2Fredirect", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/access_token" : "java.lang.String", + "/expires_in" : "java.lang.Integer", + "/refresh_token" : "java.lang.String", + "/scope" : "java.lang.String", + "/token_type" : "java.lang.String", + "/id_token" : "java.lang.String" + }, + "keyOrder" : { + "" : [ "access_token", "expires_in", "refresh_token", "scope", "token_type", "id_token" ] + } + }, + "authToken" : { + "accessToken" : "ya29.a0AfH6SMBgoogle0001", + "expireIn" : 3599, + "refreshToken" : "1//0gGoogleRefresh0001", + "refreshTokenExpireIn" : 0, + "jwt" : null + } + }, + "refresh" : { + "request" : "POST /oauth2/v4/token?refresh_token=rt.input.0001&client_id=lowcoder-client&client_secret=s3cr3t&grant_type=refresh_token", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/access_token" : "java.lang.String", + "/expires_in" : "java.lang.Integer", + "/scope" : "java.lang.String", + "/token_type" : "java.lang.String", + "/id_token" : "java.lang.String" + }, + "keyOrder" : { + "" : [ "access_token", "expires_in", "scope", "token_type", "id_token" ] + } + }, + "authToken" : { + "accessToken" : "ya29.a0AfH6SMBgoogle0002", + "expireIn" : 3599, + "refreshToken" : null, + "refreshTokenExpireIn" : 0, + "jwt" : null + } + }, + "user" : { + "request" : "POST /oauth2/v3/userinfo", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/sub" : "java.lang.String", + "/name" : "java.lang.String", + "/given_name" : "java.lang.String", + "/family_name" : "java.lang.String", + "/picture" : "java.lang.String", + "/email" : "java.lang.String", + "/email_verified" : "java.lang.Boolean", + "/locale" : "java.lang.String", + "/hd" : "null" + }, + "keyOrder" : { + "" : [ "sub", "name", "given_name", "family_name", "picture", "email", "email_verified", "locale", "hd" ] + } + }, + "authUser" : { + "uid" : "113249926842638649371", + "username" : "tech.ninja@gmail.com", + "email" : null, + "avatar" : "https://lh3.googleusercontent.com/a/ACg8ocL_s96-c", + "groupId" : null, + "rawUserInfo" : { + "sub" : "113249926842638649371", + "name" : "Tech Ninja", + "given_name" : "Tech", + "family_name" : "Ninja", + "picture" : "https://lh3.googleusercontent.com/a/ACg8ocL_s96-c", + "email" : "tech.ninja@gmail.com", + "email_verified" : true, + "locale" : "sk", + "hd" : null + } + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.refresh.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.refresh.json new file mode 100644 index 0000000000..9aa12cd7f0 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.refresh.json @@ -0,0 +1 @@ +{"access_token":"ya29.a0AfH6SMBgoogle0002","expires_in":3599,"scope":"https://www.googleapis.com/auth/userinfo.email openid","token_type":"Bearer","id_token":"$ID_TOKEN"} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.token.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.token.json new file mode 100644 index 0000000000..bad0040dd9 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.token.json @@ -0,0 +1 @@ +{"access_token":"ya29.a0AfH6SMBgoogle0001","expires_in":3599,"refresh_token":"1//0gGoogleRefresh0001","scope":"https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid","token_type":"Bearer","id_token":"$ID_TOKEN"} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.user.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.user.json new file mode 100644 index 0000000000..6c72f0a40c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/google.user.json @@ -0,0 +1 @@ +{"sub":"113249926842638649371","name":"Tech Ninja","given_name":"Tech","family_name":"Ninja","picture":"https://lh3.googleusercontent.com/a/ACg8ocL_s96-c","email":"tech.ninja@gmail.com","email_verified":true,"locale":"sk","hd":null} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.decoded.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.decoded.json new file mode 100644 index 0000000000..5622b6191f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.decoded.json @@ -0,0 +1,102 @@ +{ + "token" : { + "request" : "POST /realms/lowcoder/protocol/openid-connect/token", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/access_token" : "java.lang.String", + "/expires_in" : "java.lang.Integer", + "/refresh_expires_in" : "java.lang.Integer", + "/refresh_token" : "java.lang.String", + "/token_type" : "java.lang.String", + "/id_token" : "java.lang.String", + "/not-before-policy" : "java.lang.Integer", + "/session_state" : "java.lang.String", + "/scope" : "java.lang.String" + }, + "keyOrder" : { + "" : [ "access_token", "expires_in", "refresh_expires_in", "refresh_token", "token_type", "id_token", "not-before-policy", "session_state", "scope" ] + } + }, + "authToken" : { + "accessToken" : "eyJhbGciOiJSUzI1NiJ9.keycloak.0001", + "expireIn" : 300, + "refreshToken" : "eyJhbGciOiJIUzI1NiJ9.keycloak.refresh.0001", + "refreshTokenExpireIn" : 0, + "jwt" : null + } + }, + "refresh" : { + "request" : "POST /realms/lowcoder/protocol/openid-connect/token", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/access_token" : "java.lang.String", + "/expires_in" : "java.lang.Integer", + "/refresh_expires_in" : "java.lang.Integer", + "/refresh_token" : "java.lang.String", + "/token_type" : "java.lang.String", + "/not-before-policy" : "java.lang.Integer", + "/session_state" : "java.lang.String", + "/scope" : "java.lang.String" + }, + "keyOrder" : { + "" : [ "access_token", "expires_in", "refresh_expires_in", "refresh_token", "token_type", "not-before-policy", "session_state", "scope" ] + } + }, + "authToken" : { + "accessToken" : "eyJhbGciOiJSUzI1NiJ9.keycloak.0002", + "expireIn" : 300, + "refreshToken" : "eyJhbGciOiJIUzI1NiJ9.keycloak.refresh.0002", + "refreshTokenExpireIn" : 1799, + "jwt" : null + } + }, + "user" : { + "request" : "POST /realms/lowcoder/protocol/openid-connect/userinfo", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/sub" : "java.lang.String", + "/email_verified" : "java.lang.Boolean", + "/name" : "java.lang.String", + "/preferred_username" : "java.lang.String", + "/given_name" : "java.lang.String", + "/family_name" : "java.lang.String", + "/email" : "java.lang.String", + "/groups" : "java.util.ArrayList", + "/groups/0" : "java.lang.String", + "/realm_access" : "java.util.LinkedHashMap", + "/realm_access/roles" : "java.util.ArrayList", + "/realm_access/roles/0" : "java.lang.String", + "/realm_access/roles/1" : "java.lang.String", + "/login_count" : "java.lang.Long" + }, + "keyOrder" : { + "/realm_access" : [ "roles" ], + "" : [ "sub", "email_verified", "name", "preferred_username", "given_name", "family_name", "email", "groups", "realm_access", "login_count" ] + } + }, + "authUser" : { + "uid" : "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", + "username" : "kay@example.com", + "email" : null, + "avatar" : null, + "groupId" : null, + "rawUserInfo" : { + "sub" : "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", + "email_verified" : false, + "name" : "Kay Cloak", + "preferred_username" : "kay", + "given_name" : "Kay", + "family_name" : "Cloak", + "email" : "kay@example.com", + "groups" : [ "/admins" ], + "realm_access" : { + "roles" : [ "offline_access", "uma_authorization" ] + }, + "login_count" : 4294967296 + } + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.refresh.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.refresh.json new file mode 100644 index 0000000000..6657c30add --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.refresh.json @@ -0,0 +1 @@ +{"access_token":"eyJhbGciOiJSUzI1NiJ9.keycloak.0002","expires_in":300,"refresh_expires_in":1799,"refresh_token":"eyJhbGciOiJIUzI1NiJ9.keycloak.refresh.0002","token_type":"Bearer","not-before-policy":1767225650,"session_state":"0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f","scope":"openid email profile"} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.token.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.token.json new file mode 100644 index 0000000000..818359819f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.token.json @@ -0,0 +1 @@ +{"access_token":"eyJhbGciOiJSUzI1NiJ9.keycloak.0001","expires_in":300,"refresh_expires_in":1800,"refresh_token":"eyJhbGciOiJIUzI1NiJ9.keycloak.refresh.0001","token_type":"Bearer","id_token":"$ID_TOKEN","not-before-policy":0,"session_state":"0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f","scope":"openid email profile"} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.user.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.user.json new file mode 100644 index 0000000000..688ea45181 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/keycloak.user.json @@ -0,0 +1 @@ +{"sub":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","email_verified":false,"name":"Kay Cloak","preferred_username":"kay","given_name":"Kay","family_name":"Cloak","email":"kay@example.com","groups":["/admins"],"realm_access":{"roles":["offline_access","uma_authorization"]},"login_count":4294967296} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.decoded.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.decoded.json new file mode 100644 index 0000000000..9a71473c53 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.decoded.json @@ -0,0 +1,105 @@ +{ + "token" : { + "request" : "POST /oauth2/token", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/access_token" : "java.lang.String", + "/expires_in" : "java.lang.Integer", + "/id_token" : "java.lang.String", + "/refresh_token" : "java.lang.String", + "/scope" : "java.lang.String", + "/token_type" : "java.lang.String" + }, + "keyOrder" : { + "" : [ "access_token", "expires_in", "id_token", "refresh_token", "scope", "token_type" ] + } + }, + "authToken" : { + "accessToken" : "ory_at_0001", + "expireIn" : 3599, + "refreshToken" : "ory_rt_0001", + "refreshTokenExpireIn" : 0, + "jwt" : null + } + }, + "refresh" : { + "request" : "POST /oauth2/token", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/access_token" : "java.lang.String", + "/expires_in" : "java.lang.Double", + "/id_token" : "java.lang.String", + "/refresh_token" : "java.lang.String", + "/scope" : "java.lang.String", + "/token_type" : "java.lang.String" + }, + "keyOrder" : { + "" : [ "access_token", "expires_in", "id_token", "refresh_token", "scope", "token_type" ] + } + }, + "authToken" : { + "accessToken" : "ory_at_0002", + "expireIn" : 3599, + "refreshToken" : "ory_rt_0002", + "refreshTokenExpireIn" : 0, + "jwt" : null + } + }, + "user" : { + "request" : "POST /userinfo", + "decoded" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/sub" : "java.lang.String", + "/email" : "java.lang.String", + "/email_verified" : "java.lang.Boolean", + "/picture" : "java.lang.String", + "/amr" : "java.util.ArrayList", + "/amr/0" : "java.lang.String", + "/amr/1" : "java.lang.String", + "/aud" : "java.util.ArrayList", + "/aud/0" : "java.lang.String", + "/auth_time" : "java.lang.Integer", + "/iat" : "java.lang.Integer", + "/rat" : "java.lang.Double", + "/iss" : "java.lang.String", + "/ext" : "java.util.LinkedHashMap", + "/ext/tenant" : "java.util.LinkedHashMap", + "/ext/tenant/id" : "java.lang.Integer", + "/ext/tenant/weight" : "java.lang.Double" + }, + "keyOrder" : { + "/ext/tenant" : [ "id", "weight" ], + "/ext" : [ "tenant" ], + "" : [ "sub", "email", "email_verified", "picture", "amr", "aud", "auth_time", "iat", "rat", "iss", "ext" ] + } + }, + "authUser" : { + "uid" : "8d6e5f4a-3b2c-4d1e-9f0a-1b2c3d4e5f6a", + "username" : "ory@example.com", + "email" : null, + "avatar" : "https://cdn.example.com/ory.png", + "groupId" : null, + "rawUserInfo" : { + "sub" : "8d6e5f4a-3b2c-4d1e-9f0a-1b2c3d4e5f6a", + "email" : "ory@example.com", + "email_verified" : true, + "picture" : "https://cdn.example.com/ory.png", + "amr" : [ "password", "totp" ], + "aud" : [ "lowcoder" ], + "auth_time" : 1767225600, + "iat" : 1767225650, + "rat" : 1.76722560025E9, + "iss" : "https://ory.example.com", + "ext" : { + "tenant" : { + "id" : 7, + "weight" : 0.1 + } + } + } + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.refresh.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.refresh.json new file mode 100644 index 0000000000..f6cc40cc25 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.refresh.json @@ -0,0 +1 @@ +{"access_token":"ory_at_0002","expires_in":3599.0,"id_token":"$ID_TOKEN","refresh_token":"ory_rt_0002","scope":"openid offline_access email","token_type":"bearer"} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.token.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.token.json new file mode 100644 index 0000000000..7b006e58de --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.token.json @@ -0,0 +1 @@ +{"access_token":"ory_at_0001","expires_in":3599,"id_token":"$ID_TOKEN","refresh_token":"ory_rt_0001","scope":"openid offline_access email","token_type":"bearer"} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.user.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.user.json new file mode 100644 index 0000000000..670b8baa81 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/oauth/ory.user.json @@ -0,0 +1 @@ +{"sub":"8d6e5f4a-3b2c-4d1e-9f0a-1b2c3d4e5f6a","email":"ory@example.com","email_verified":true,"picture":"https://cdn.example.com/ory.png","amr":["password","totp"],"aud":["lowcoder"],"auth_time":1767225600,"iat":1767225650,"rat":1767225600.25,"iss":"https://ory.example.com","ext":{"tenant":{"id":7,"weight":0.10}}} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/Application.editingQueries.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/Application.editingQueries.json new file mode 100644 index 0000000000..17150ed4ba --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/Application.editingQueries.json @@ -0,0 +1 @@ +[{"id":"ApplicationPayloadGettersTest.editing.queries[0].id","gid":"ApplicationPayloadGettersTest.editing.queries[0].gid","name":"ApplicationPayloadGettersTest.editing.queries[0].name","triggerType":"ApplicationPayloadGettersTest.editing.queries[0].triggerType","timeout":"ApplicationPayloadGettersTest.editing.queries[0].timeout","baseQuery":{"datasourceId":"ApplicationPayloadGettersTest.editing.queries[0].datasourceId","compType":"restApi","comp":{"method":"ApplicationPayloadGettersTest.editing.queries[0].comp.method","path":"ApplicationPayloadGettersTest.editing.queries[0].comp.path"},"timeout":"ApplicationPayloadGettersTest.editing.queries[0].timeout"},"usingLibraryQuery":false,"libraryRecordQueryId":{"libraryQueryId":null,"libraryQueryRecordId":null,"usingLiveRecord":false,"usingEditingRecord":true}},{"id":"ApplicationPayloadGettersTest.editing.queries[1].id","gid":"ApplicationPayloadGettersTest.editing.queries[1].gid","name":"ApplicationPayloadGettersTest.editing.queries[1].name","triggerType":"ApplicationPayloadGettersTest.editing.queries[1].triggerType","timeout":"ApplicationPayloadGettersTest.editing.queries[1].timeout","baseQuery":{"datasourceId":"ApplicationPayloadGettersTest.editing.queries[1].datasourceId","compType":"libraryQuery","comp":{"libraryQueryId":"ApplicationPayloadGettersTest.editing.queries[1].comp.libraryQueryId","libraryQueryRecordId":"ApplicationPayloadGettersTest.editing.queries[1].comp.libraryQueryRecordId"},"timeout":"ApplicationPayloadGettersTest.editing.queries[1].timeout"},"usingLibraryQuery":true,"libraryRecordQueryId":{"libraryQueryId":"ApplicationPayloadGettersTest.editing.queries[1].comp.libraryQueryId","libraryQueryRecordId":"ApplicationPayloadGettersTest.editing.queries[1].comp.libraryQueryRecordId","usingLiveRecord":false,"usingEditingRecord":false}},{"id":"ApplicationPayloadGettersTest.editing.queries[2].id","gid":"ApplicationPayloadGettersTest.editing.queries[2].gid","name":"ApplicationPayloadGettersTest.editing.queries[2].name","triggerType":"ApplicationPayloadGettersTest.editing.queries[2].triggerType","timeout":"ApplicationPayloadGettersTest.editing.queries[2].timeout","baseQuery":{"datasourceId":"ApplicationPayloadGettersTest.editing.queries[2].datasourceId","compType":"restApi","comp":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":"9223372036854775808","longMinMinusOne":"-9223372036854775809","decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"timeout":"ApplicationPayloadGettersTest.editing.queries[2].timeout"},"usingLibraryQuery":false,"libraryRecordQueryId":{"libraryQueryId":null,"libraryQueryRecordId":null,"usingLiveRecord":false,"usingEditingRecord":true}}] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/Application.liveQueries.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/Application.liveQueries.json new file mode 100644 index 0000000000..13645288fe --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/Application.liveQueries.json @@ -0,0 +1 @@ +[{"id":"ApplicationPayloadGettersTest.live.queries[0].id","gid":"ApplicationPayloadGettersTest.live.queries[0].gid","name":"ApplicationPayloadGettersTest.live.queries[0].name","triggerType":"ApplicationPayloadGettersTest.live.queries[0].triggerType","timeout":"ApplicationPayloadGettersTest.live.queries[0].timeout","baseQuery":{"datasourceId":"ApplicationPayloadGettersTest.live.queries[0].datasourceId","compType":"restApi","comp":{"method":"ApplicationPayloadGettersTest.live.queries[0].comp.method","path":"ApplicationPayloadGettersTest.live.queries[0].comp.path"},"timeout":"ApplicationPayloadGettersTest.live.queries[0].timeout"},"usingLibraryQuery":false,"libraryRecordQueryId":{"libraryQueryId":null,"libraryQueryRecordId":null,"usingLiveRecord":false,"usingEditingRecord":true}},{"id":"ApplicationPayloadGettersTest.live.queries[1].id","gid":"ApplicationPayloadGettersTest.live.queries[1].gid","name":"ApplicationPayloadGettersTest.live.queries[1].name","triggerType":"ApplicationPayloadGettersTest.live.queries[1].triggerType","timeout":"ApplicationPayloadGettersTest.live.queries[1].timeout","baseQuery":{"datasourceId":"ApplicationPayloadGettersTest.live.queries[1].datasourceId","compType":"libraryQuery","comp":{"libraryQueryId":"ApplicationPayloadGettersTest.live.queries[1].comp.libraryQueryId","libraryQueryRecordId":"ApplicationPayloadGettersTest.live.queries[1].comp.libraryQueryRecordId"},"timeout":"ApplicationPayloadGettersTest.live.queries[1].timeout"},"usingLibraryQuery":true,"libraryRecordQueryId":{"libraryQueryId":"ApplicationPayloadGettersTest.live.queries[1].comp.libraryQueryId","libraryQueryRecordId":"ApplicationPayloadGettersTest.live.queries[1].comp.libraryQueryRecordId","usingLiveRecord":false,"usingEditingRecord":false}},{"id":"ApplicationPayloadGettersTest.live.queries[2].id","gid":"ApplicationPayloadGettersTest.live.queries[2].gid","name":"ApplicationPayloadGettersTest.live.queries[2].name","triggerType":"ApplicationPayloadGettersTest.live.queries[2].triggerType","timeout":"ApplicationPayloadGettersTest.live.queries[2].timeout","baseQuery":{"datasourceId":"ApplicationPayloadGettersTest.live.queries[2].datasourceId","compType":"restApi","comp":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":"9223372036854775808","longMinMinusOne":"-9223372036854775809","decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"timeout":"ApplicationPayloadGettersTest.live.queries[2].timeout"},"usingLibraryQuery":false,"libraryRecordQueryId":{"libraryQueryId":null,"libraryQueryRecordId":null,"usingLiveRecord":false,"usingEditingRecord":true}}] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/LibraryQuery.query.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/LibraryQuery.query.json new file mode 100644 index 0000000000..182da655bc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/LibraryQuery.query.json @@ -0,0 +1 @@ +{"datasourceId":"LibraryQueryPayloadGettersTest.libraryQuery.query.datasourceId","compType":"restApi","comp":{"method":"LibraryQueryPayloadGettersTest.libraryQuery.query.comp.method","path":"LibraryQueryPayloadGettersTest.libraryQuery.query.comp.path","bodyLimit":40100,"maxBytes":3000000001,"ratio":1.5,"representative":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":"9223372036854775808","longMinMinusOne":"-9223372036854775809","decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"}},"timeout":"LibraryQueryPayloadGettersTest.libraryQuery.query.timeout"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/LibraryQueryRecord.query.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/LibraryQueryRecord.query.json new file mode 100644 index 0000000000..8d8fec3487 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/LibraryQueryRecord.query.json @@ -0,0 +1 @@ +{"datasourceId":"LibraryQueryPayloadGettersTest.record.query.datasourceId","compType":"restApi","comp":{"method":"LibraryQueryPayloadGettersTest.record.query.comp.method","path":"LibraryQueryPayloadGettersTest.record.query.comp.path","bodyLimit":40100,"maxBytes":3000000001,"ratio":1.5,"representative":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":"9223372036854775808","longMinMinusOne":"-9223372036854775809","decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"}},"timeout":"LibraryQueryPayloadGettersTest.record.query.timeout"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/OrganizationDomain.beforeMongodbWrite.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/OrganizationDomain.beforeMongodbWrite.json new file mode 100644 index 0000000000..e3f51a275a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/OrganizationDomain.beforeMongodbWrite.json @@ -0,0 +1 @@ +[{"id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"encrypted:SECRET-Organization.authConfigs[1].clientSecret"},{"id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"encrypted:SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true},{"id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"encrypted:SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope"},{"id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"encrypted:SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope"}] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/User.beforeMongodbWrite.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/User.beforeMongodbWrite.json new file mode 100644 index 0000000000..3ef1d60443 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/payload-getters/User.beforeMongodbWrite.json @@ -0,0 +1 @@ +[{"id":"User.apiKeysList[0].id","name":"User.apiKeysList[0].name","description":"User.apiKeysList[0].description","token":"encrypted:KNOWN-EXPOSURE-User.apiKeysList[0].token"},{"id":"User.apiKeysList[1].id","name":"User.apiKeysList[1].name","description":"User.apiKeysList[1].description","token":"encrypted:KNOWN-EXPOSURE-User.apiKeysList[1].token"}] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/session-cache/legacy-user.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/session-cache/legacy-user.json new file mode 100644 index 0000000000..49ba2ed9dd --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/session-cache/legacy-user.json @@ -0,0 +1 @@ +{"id":"user01","createdBy":"user01","modifiedBy":"user01","createdAt":1640995200.000000000,"updatedAt":1640995200.123456789,"name":"Iron Man","email":"iron@avengers.com","avatar":null,"tpAvatarLink":null,"superAdmin":false,"state":"ACTIVATED","isEnabled":true,"activeAuthId":"EMAIL","connections":[{"authId":"EMAIL","source":"EMAIL","rawId":"iron@avengers.com","name":"iron@avengers.com","email":"iron@avengers.com","avatar":null,"orgIds":["org01"],"authConnectionAuthToken":null,"rawUserInfo":{"email":"iron@avengers.com"},"tokens":["t0ken01"]}],"hasSetNickname":false,"orgTransformedUserInfo":null,"isAnonymous":false,"avatarUrl":"","legacyField":{"dropped":true}} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/session-cache/session-values.read.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/session-cache/session-values.read.json new file mode 100644 index 0000000000..80c489e594 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/boundary/session-cache/session-values.read.json @@ -0,0 +1,29 @@ +{ + "fromJsonQuietly" : { + "legacyUserJson" : { + "class" : "org.lowcoder.domain.user.model.User", + "id" : "user01", + "name" : "Iron Man", + "email" : "iron@avengers.com", + "state" : "ACTIVATED", + "isEnabled" : true, + "superAdmin" : false, + "connectionRawIds" : [ "iron@avengers.com" ] + }, + "plainUserId" : null, + "numericUserId" : null, + "quotedUserId" : null + }, + "throughRedis" : { + "legacyUserJson" : { + "storedBytesAreTheValueInUtf8" : true, + "resolvedUserId" : "user01", + "resolvedUserName" : "Iron Man" + }, + "plainUserId" : { + "storedBytesAreTheValueInUtf8" : true, + "resolvedUserId" : "user01", + "resolvedUserName" : "Iron Man" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D5.builder.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D5.builder.json new file mode 100644 index 0000000000..5d1abd3116 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D5.builder.json @@ -0,0 +1,673 @@ +{ + "java.lang.String" : { + "\"1\"" : { + "class" : "java.lang.String", + "value" : "1" + }, + "1" : { + "class" : "java.lang.String", + "value" : "1" + }, + "1.5" : { + "class" : "java.lang.String", + "value" : "1.5" + }, + "\"1.5\"" : { + "class" : "java.lang.String", + "value" : "1.5" + }, + "1e2" : { + "class" : "java.lang.String", + "value" : "1e2" + }, + "-0" : { + "class" : "java.lang.String", + "value" : "-0" + }, + "\" 1 \"" : { + "class" : "java.lang.String", + "value" : " 1 " + }, + "true" : { + "class" : "java.lang.String", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.String", + "value" : "true" + }, + "\"\"" : { + "class" : "java.lang.String", + "value" : "" + }, + "\"null\"" : { + "class" : "java.lang.String", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.lang.Integer" : { + "\"1\"" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "1" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "1.5" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "class" : "java.lang.Integer", + "value" : "100" + }, + "-0" : { + "class" : "java.lang.Integer", + "value" : "0" + }, + "\" 1 \"" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "class" : "null", + "value" : "null" + }, + "\"null\"" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.lang.Boolean" : { + "\"1\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "\" 1 \"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "true" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"\"" : { + "class" : "null", + "value" : "null" + }, + "\"null\"" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "boolean" : { + "\"1\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "\" 1 \"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "true" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"\"" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "\"null\"" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.time.Instant" : { + "\"1\"" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01Z" + }, + "1" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01Z" + }, + "1.5" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01.500Z" + }, + "\"1.5\"" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01.500Z" + }, + "1e2" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:01:40Z" + }, + "-0" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:00Z" + }, + "\" 1 \"" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01Z" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "class" : "null", + "value" : "null" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.lang.Object" : { + "\"1\"" : { + "class" : "java.lang.String", + "value" : "1" + }, + "1" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "1.5" : { + "class" : "java.lang.Double", + "value" : "1.5" + }, + "\"1.5\"" : { + "class" : "java.lang.String", + "value" : "1.5" + }, + "1e2" : { + "class" : "java.lang.Double", + "value" : "100.0" + }, + "-0" : { + "class" : "java.lang.Integer", + "value" : "0" + }, + "\" 1 \"" : { + "class" : "java.lang.String", + "value" : " 1 " + }, + "true" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.String", + "value" : "true" + }, + "\"\"" : { + "class" : "java.lang.String", + "value" : "" + }, + "\"null\"" : { + "class" : "java.lang.String", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "class" : "java.util.ArrayList", + "value" : "[]" + }, + "[\"1\"]" : { + "class" : "java.util.ArrayList", + "value" : "[1]" + }, + "{}" : { + "class" : "java.util.LinkedHashMap", + "value" : "{}" + } + }, + "org.lowcoder.domain.application.model.ApplicationStatus" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "RECYCLED" + }, + "1" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "RECYCLED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "NORMAL" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "RECYCLED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"NORMAL\"" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "NORMAL" + }, + "\"normal\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "NORMAL" + } + }, + "org.lowcoder.domain.bundle.model.BundleStatus" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "RECYCLED" + }, + "1" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "RECYCLED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "NORMAL" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "RECYCLED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"NORMAL\"" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "NORMAL" + }, + "\"normal\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "NORMAL" + } + }, + "org.lowcoder.domain.datasource.model.DatasourceStatus" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "DELETED" + }, + "1" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "DELETED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "NORMAL" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "DELETED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"NORMAL\"" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "NORMAL" + }, + "\"normal\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "NORMAL" + } + }, + "org.lowcoder.domain.material.model.MaterialType" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "LOGO" + }, + "1" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "LOGO" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "COMMON" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "LOGO" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"COMMON\"" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "COMMON" + }, + "\"common\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "COMMON" + } + }, + "org.lowcoder.domain.organization.model.OrganizationState" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "DELETED" + }, + "1" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "DELETED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "ACTIVE" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "DELETED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"ACTIVE\"" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "ACTIVE" + }, + "\"active\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "ACTIVE" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D5.creator.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D5.creator.json new file mode 100644 index 0000000000..5d1abd3116 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D5.creator.json @@ -0,0 +1,673 @@ +{ + "java.lang.String" : { + "\"1\"" : { + "class" : "java.lang.String", + "value" : "1" + }, + "1" : { + "class" : "java.lang.String", + "value" : "1" + }, + "1.5" : { + "class" : "java.lang.String", + "value" : "1.5" + }, + "\"1.5\"" : { + "class" : "java.lang.String", + "value" : "1.5" + }, + "1e2" : { + "class" : "java.lang.String", + "value" : "1e2" + }, + "-0" : { + "class" : "java.lang.String", + "value" : "-0" + }, + "\" 1 \"" : { + "class" : "java.lang.String", + "value" : " 1 " + }, + "true" : { + "class" : "java.lang.String", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.String", + "value" : "true" + }, + "\"\"" : { + "class" : "java.lang.String", + "value" : "" + }, + "\"null\"" : { + "class" : "java.lang.String", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.lang.Integer" : { + "\"1\"" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "1" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "1.5" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "class" : "java.lang.Integer", + "value" : "100" + }, + "-0" : { + "class" : "java.lang.Integer", + "value" : "0" + }, + "\" 1 \"" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "class" : "null", + "value" : "null" + }, + "\"null\"" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.lang.Boolean" : { + "\"1\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "\" 1 \"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "true" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"\"" : { + "class" : "null", + "value" : "null" + }, + "\"null\"" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "boolean" : { + "\"1\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "\" 1 \"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "true" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"\"" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "\"null\"" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.time.Instant" : { + "\"1\"" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01Z" + }, + "1" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01Z" + }, + "1.5" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01.500Z" + }, + "\"1.5\"" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01.500Z" + }, + "1e2" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:01:40Z" + }, + "-0" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:00Z" + }, + "\" 1 \"" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01Z" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "class" : "null", + "value" : "null" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.lang.Object" : { + "\"1\"" : { + "class" : "java.lang.String", + "value" : "1" + }, + "1" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "1.5" : { + "class" : "java.lang.Double", + "value" : "1.5" + }, + "\"1.5\"" : { + "class" : "java.lang.String", + "value" : "1.5" + }, + "1e2" : { + "class" : "java.lang.Double", + "value" : "100.0" + }, + "-0" : { + "class" : "java.lang.Integer", + "value" : "0" + }, + "\" 1 \"" : { + "class" : "java.lang.String", + "value" : " 1 " + }, + "true" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.String", + "value" : "true" + }, + "\"\"" : { + "class" : "java.lang.String", + "value" : "" + }, + "\"null\"" : { + "class" : "java.lang.String", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "class" : "java.util.ArrayList", + "value" : "[]" + }, + "[\"1\"]" : { + "class" : "java.util.ArrayList", + "value" : "[1]" + }, + "{}" : { + "class" : "java.util.LinkedHashMap", + "value" : "{}" + } + }, + "org.lowcoder.domain.application.model.ApplicationStatus" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "RECYCLED" + }, + "1" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "RECYCLED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "NORMAL" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "RECYCLED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"NORMAL\"" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "NORMAL" + }, + "\"normal\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "NORMAL" + } + }, + "org.lowcoder.domain.bundle.model.BundleStatus" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "RECYCLED" + }, + "1" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "RECYCLED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "NORMAL" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "RECYCLED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"NORMAL\"" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "NORMAL" + }, + "\"normal\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "NORMAL" + } + }, + "org.lowcoder.domain.datasource.model.DatasourceStatus" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "DELETED" + }, + "1" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "DELETED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "NORMAL" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "DELETED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"NORMAL\"" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "NORMAL" + }, + "\"normal\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "NORMAL" + } + }, + "org.lowcoder.domain.material.model.MaterialType" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "LOGO" + }, + "1" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "LOGO" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "COMMON" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "LOGO" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"COMMON\"" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "COMMON" + }, + "\"common\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "COMMON" + } + }, + "org.lowcoder.domain.organization.model.OrganizationState" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "DELETED" + }, + "1" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "DELETED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "ACTIVE" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "DELETED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"ACTIVE\"" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "ACTIVE" + }, + "\"active\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "ACTIVE" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D5.field-setter.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D5.field-setter.json new file mode 100644 index 0000000000..5d1abd3116 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D5.field-setter.json @@ -0,0 +1,673 @@ +{ + "java.lang.String" : { + "\"1\"" : { + "class" : "java.lang.String", + "value" : "1" + }, + "1" : { + "class" : "java.lang.String", + "value" : "1" + }, + "1.5" : { + "class" : "java.lang.String", + "value" : "1.5" + }, + "\"1.5\"" : { + "class" : "java.lang.String", + "value" : "1.5" + }, + "1e2" : { + "class" : "java.lang.String", + "value" : "1e2" + }, + "-0" : { + "class" : "java.lang.String", + "value" : "-0" + }, + "\" 1 \"" : { + "class" : "java.lang.String", + "value" : " 1 " + }, + "true" : { + "class" : "java.lang.String", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.String", + "value" : "true" + }, + "\"\"" : { + "class" : "java.lang.String", + "value" : "" + }, + "\"null\"" : { + "class" : "java.lang.String", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.lang.Integer" : { + "\"1\"" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "1" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "1.5" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "class" : "java.lang.Integer", + "value" : "100" + }, + "-0" : { + "class" : "java.lang.Integer", + "value" : "0" + }, + "\" 1 \"" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "class" : "null", + "value" : "null" + }, + "\"null\"" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.lang.Boolean" : { + "\"1\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "\" 1 \"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "true" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"\"" : { + "class" : "null", + "value" : "null" + }, + "\"null\"" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "boolean" : { + "\"1\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "\" 1 \"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "true" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"\"" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "\"null\"" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.time.Instant" : { + "\"1\"" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01Z" + }, + "1" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01Z" + }, + "1.5" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01.500Z" + }, + "\"1.5\"" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01.500Z" + }, + "1e2" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:01:40Z" + }, + "-0" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:00Z" + }, + "\" 1 \"" : { + "class" : "java.time.Instant", + "value" : "1970-01-01T00:00:01Z" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "class" : "null", + "value" : "null" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.lang.Object" : { + "\"1\"" : { + "class" : "java.lang.String", + "value" : "1" + }, + "1" : { + "class" : "java.lang.Integer", + "value" : "1" + }, + "1.5" : { + "class" : "java.lang.Double", + "value" : "1.5" + }, + "\"1.5\"" : { + "class" : "java.lang.String", + "value" : "1.5" + }, + "1e2" : { + "class" : "java.lang.Double", + "value" : "100.0" + }, + "-0" : { + "class" : "java.lang.Integer", + "value" : "0" + }, + "\" 1 \"" : { + "class" : "java.lang.String", + "value" : " 1 " + }, + "true" : { + "class" : "java.lang.Boolean", + "value" : "true" + }, + "\"true\"" : { + "class" : "java.lang.String", + "value" : "true" + }, + "\"\"" : { + "class" : "java.lang.String", + "value" : "" + }, + "\"null\"" : { + "class" : "java.lang.String", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "class" : "java.util.ArrayList", + "value" : "[]" + }, + "[\"1\"]" : { + "class" : "java.util.ArrayList", + "value" : "[1]" + }, + "{}" : { + "class" : "java.util.LinkedHashMap", + "value" : "{}" + } + }, + "org.lowcoder.domain.application.model.ApplicationStatus" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "RECYCLED" + }, + "1" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "RECYCLED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "NORMAL" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "RECYCLED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"NORMAL\"" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "NORMAL" + }, + "\"normal\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.application.model.ApplicationStatus", + "value" : "NORMAL" + } + }, + "org.lowcoder.domain.bundle.model.BundleStatus" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "RECYCLED" + }, + "1" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "RECYCLED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "NORMAL" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "RECYCLED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"NORMAL\"" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "NORMAL" + }, + "\"normal\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.bundle.model.BundleStatus", + "value" : "NORMAL" + } + }, + "org.lowcoder.domain.datasource.model.DatasourceStatus" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "DELETED" + }, + "1" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "DELETED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "NORMAL" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "DELETED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"NORMAL\"" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "NORMAL" + }, + "\"normal\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.datasource.model.DatasourceStatus", + "value" : "NORMAL" + } + }, + "org.lowcoder.domain.material.model.MaterialType" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "LOGO" + }, + "1" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "LOGO" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "COMMON" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "LOGO" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"COMMON\"" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "COMMON" + }, + "\"common\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.material.model.MaterialType", + "value" : "COMMON" + } + }, + "org.lowcoder.domain.organization.model.OrganizationState" : { + "\"1\"" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "DELETED" + }, + "1" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "DELETED" + }, + "1.5" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"1.5\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "1e2" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "-0" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "ACTIVE" + }, + "\" 1 \"" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "DELETED" + }, + "true" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"true\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"null\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "value" : "null" + }, + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"1\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"ACTIVE\"" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "ACTIVE" + }, + "\"active\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "\"D5_UNKNOWN\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "0" : { + "class" : "org.lowcoder.domain.organization.model.OrganizationState", + "value" : "ACTIVE" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D6.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D6.json new file mode 100644 index 0000000000..6b14b0bc03 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/coercion/D6.json @@ -0,0 +1,528 @@ +{ + "[Ljava.lang.String; @ creator (InviteEmailRequest.emails)" : { + "[]" : { + "class" : "[Ljava.lang.String;", + "elementClasses" : [ ], + "json" : "[]" + }, + "[\"a\",\"a\"]" : { + "class" : "[Ljava.lang.String;", + "elementClasses" : [ "java.lang.String", "java.lang.String" ], + "json" : "[\"a\",\"a\"]" + }, + "[\"a\",null]" : { + "class" : "[Ljava.lang.String;", + "elementClasses" : [ "java.lang.String", "null" ], + "json" : "[\"a\",null]" + }, + "[1, true]" : { + "class" : "[Ljava.lang.String;", + "elementClasses" : [ "java.lang.String", "java.lang.String" ], + "json" : "[\"1\",\"true\"]" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidDefinitionException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "[Ljava.lang.String; @ field-setter (QueryExecutionRequest.path)" : { + "[]" : { + "class" : "[Ljava.lang.String;", + "elementClasses" : [ ], + "json" : "[]" + }, + "[\"a\",\"a\"]" : { + "class" : "[Ljava.lang.String;", + "elementClasses" : [ "java.lang.String", "java.lang.String" ], + "json" : "[\"a\",\"a\"]" + }, + "[\"a\",null]" : { + "class" : "[Ljava.lang.String;", + "elementClasses" : [ "java.lang.String", "null" ], + "json" : "[\"a\",null]" + }, + "[1, true]" : { + "class" : "[Ljava.lang.String;", + "elementClasses" : [ "java.lang.String", "java.lang.String" ], + "json" : "[\"1\",\"true\"]" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidDefinitionException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.lang.Object @ creator (UpdateOrgCommonSettingsRequest.value)" : { + "[]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ ], + "json" : "[]" + }, + "[\"a\",\"a\"]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ "java.lang.String", "java.lang.String" ], + "json" : "[\"a\",\"a\"]" + }, + "[\"a\",null]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ "java.lang.String", "null" ], + "json" : "[\"a\",null]" + }, + "[1, true]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ "java.lang.Integer", "java.lang.Boolean" ], + "json" : "[1,true]" + }, + "\"a\"" : { + "class" : "java.lang.String", + "elementClasses" : [ ], + "json" : "\"a\"" + }, + "\"\"" : { + "class" : "java.lang.String", + "elementClasses" : [ ], + "json" : "\"\"" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ ], + "json" : "{}" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ "java.lang.Integer", "java.lang.Boolean", "null", "java.lang.Double" ], + "json" : "{\"a\":1,\"b\":true,\"c\":null,\"d\":1.5}" + } + }, + "java.lang.Object @ field-setter (Param.value)" : { + "[]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ ], + "json" : "[]" + }, + "[\"a\",\"a\"]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ "java.lang.String", "java.lang.String" ], + "json" : "[\"a\",\"a\"]" + }, + "[\"a\",null]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ "java.lang.String", "null" ], + "json" : "[\"a\",null]" + }, + "[1, true]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ "java.lang.Integer", "java.lang.Boolean" ], + "json" : "[1,true]" + }, + "\"a\"" : { + "class" : "java.lang.String", + "elementClasses" : [ ], + "json" : "\"a\"" + }, + "\"\"" : { + "class" : "java.lang.String", + "elementClasses" : [ ], + "json" : "\"\"" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ ], + "json" : "{}" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ "java.lang.Integer", "java.lang.Boolean", "null", "java.lang.Double" ], + "json" : "{\"a\":1,\"b\":true,\"c\":null,\"d\":1.5}" + } + }, + "java.util.HashMap @ builder (Oauth2GenericAuthConfig.sourceMappings)" : { + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",\"a\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",null]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[1, true]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "class" : "java.util.HashMap", + "elementClasses" : [ ], + "json" : "{}" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "class" : "java.util.HashMap", + "elementClasses" : [ "java.lang.String", "java.lang.String", "null", "java.lang.String" ], + "json" : "{\"a\":\"1\",\"b\":\"true\",\"c\":null,\"d\":\"1.5\"}" + } + }, + "java.util.List @ creator (GetMetaDataRequest.appIds)" : { + "[]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ ], + "json" : "[]" + }, + "[\"a\",\"a\"]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ "java.lang.String", "java.lang.String" ], + "json" : "[\"a\",\"a\"]" + }, + "[\"a\",null]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ "java.lang.String", "null" ], + "json" : "[\"a\",null]" + }, + "[1, true]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ "java.lang.String", "java.lang.String" ], + "json" : "[\"1\",\"true\"]" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.util.List @ field-setter (OrganizationDomain.configs)" : { + "[]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ ], + "json" : "[]" + }, + "[\"a\",\"a\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidTypeIdException" + }, + "[\"a\",null]" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidTypeIdException" + }, + "[1, true]" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidTypeIdException" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.util.List @ field-setter (LibraryQueryRequestFromJs.params)" : { + "[]" : { + "class" : "java.util.ArrayList", + "elementClasses" : [ ], + "json" : "[]" + }, + "[\"a\",\"a\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",null]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[1, true]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "java.util.Map @ builder (Application.editingApplicationDSL)" : { + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",\"a\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",null]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[1, true]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ ], + "json" : "{}" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ "java.lang.Integer", "java.lang.Boolean", "null", "java.lang.Double" ], + "json" : "{\"a\":1,\"b\":true,\"c\":null,\"d\":1.5}" + } + }, + "java.util.Map @ creator (CreateApplicationRequest.editingApplicationDSL)" : { + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",\"a\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",null]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[1, true]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ ], + "json" : "{}" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ "java.lang.Integer", "java.lang.Boolean", "null", "java.lang.Double" ], + "json" : "{\"a\":1,\"b\":true,\"c\":null,\"d\":1.5}" + } + }, + "java.util.Map @ field-setter (UpsertDatasourceRequest.datasourceConfig)" : { + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",\"a\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",null]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[1, true]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ ], + "json" : "{}" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ "java.lang.Integer", "java.lang.Boolean", "null", "java.lang.Double" ], + "json" : "{\"a\":1,\"b\":true,\"c\":null,\"d\":1.5}" + } + }, + "java.util.Map @ creator (FlowRequest.headers)" : { + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",\"a\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",null]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[1, true]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ ], + "json" : "{}" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "class" : "java.util.LinkedHashMap", + "elementClasses" : [ "java.lang.String", "java.lang.String", "null", "java.lang.String" ], + "json" : "{\"a\":\"1\",\"b\":\"true\",\"c\":null,\"d\":\"1.5\"}" + } + }, + "java.util.Set @ creator (BatchAddPermissionRequest.groupIds)" : { + "[]" : { + "class" : "java.util.HashSet", + "elementClasses" : [ ], + "json" : "[]" + }, + "[\"a\",\"a\"]" : { + "class" : "java.util.HashSet", + "elementClasses" : [ "java.lang.String" ], + "json" : "[\"a\"]" + }, + "[\"a\",null]" : { + "class" : "java.util.HashSet", + "elementClasses" : [ "null", "java.lang.String" ], + "json" : "[null,\"a\"]" + }, + "[1, true]" : { + "class" : "java.util.HashSet", + "elementClasses" : [ "java.lang.String", "java.lang.String" ], + "json" : "[\"1\",\"true\"]" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + } + }, + "org.lowcoder.domain.organization.model.Organization$OrganizationCommonSettings @ builder (Organization.commonSettings)" : { + "[]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",\"a\"]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[\"a\",null]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "[1, true]" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"a\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.MismatchedInputException" + }, + "\"\"" : { + "exception" : "com.fasterxml.jackson.databind.exc.InvalidFormatException" + }, + "null" : { + "class" : "null", + "elementClasses" : [ ], + "json" : "null" + }, + "{}" : { + "class" : "org.lowcoder.domain.organization.model.Organization$OrganizationCommonSettings", + "elementClasses" : [ ], + "json" : "{}" + }, + "{\"a\": 1, \"b\": true, \"c\": null, \"d\": 1.5}" : { + "class" : "org.lowcoder.domain.organization.model.Organization$OrganizationCommonSettings", + "elementClasses" : [ "java.lang.Integer", "java.lang.Boolean", "null", "java.lang.Double" ], + "json" : "{\"a\":1,\"b\":true,\"c\":null,\"d\":1.5}" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/contract-registry.tsv b/server/api-service/lowcoder-server/src/test/resources/json-contract/contract-registry.tsv new file mode 100644 index 0000000000..0dae153fbb --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/contract-registry.tsv @@ -0,0 +1,179 @@ +# ContractRegistry (docs/API_PAYLOAD_TEST_PLAN.md §3.2, §6.1): one row per endpoint. Columns (tab-separated): +# endpoint, level (codec | decode-only), wp, test (planned test class), group (pass-through | assembling; set by the owning WP), +# concrete (comma-separated concrete types of untyped roots; owning WP), branches (' =' joined by '; '; owning WP), reason. +# Generated from PayloadTypeWalker on 2026-10-03 (task T0.6); PayloadContractCompletenessTest keeps it complete. +ApplicationEndpoints#create(CreateApplicationRequest) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 45 return=create +ApplicationEndpoints#createFromTemplate(String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 52 return=createFromTemplate +ApplicationEndpoints#delete(String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 84 return=delete +ApplicationEndpoints#getAgencyProfileApplication(String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 119 return=getAgencyProfileApplication +ApplicationEndpoints#getAgencyProfileApplications(Integer) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 220 return=getAgencyProfileApplications +ApplicationEndpoints#getApplicationPermissions(String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 270 return=getApplicationPermissions +ApplicationEndpoints#getApplications(Integer, ApplicationStatus, boolean, String, String, Integer, Integer) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest assembling 205 return=getApplications +ApplicationEndpoints#getEditingApplication(String, Boolean) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 93 return=getEditingApplication +ApplicationEndpoints#getGroupsOrMembersWithoutPermissions(String, String, Integer, Integer) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest assembling org.lowcoder.api.usermanagement.view.GroupView,org.lowcoder.api.usermanagement.view.OrgMemberListView$OrgMemberView 310 return=getGroupsOrMembersWithoutPermissions; 314 return=getGroupsOrMembersWithoutPermissions; 315 return=getGroupsOrMembersWithoutPermissions; 319 return=getGroupsOrMembersWithoutPermissions; 322 return=getGroupsOrMembersWithoutPermissions; 324 return=getGroupsOrMembersWithoutPermissions; 330 return=getGroupsOrMembersWithoutPermissions +ApplicationEndpoints#getMarketplaceApplications(Integer) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 212 return=getMarketplaceApplications +ApplicationEndpoints#getPublishedApplication(String, Boolean) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 101 return=getPublishedApplication +ApplicationEndpoints#getPublishedMarketPlaceApplication(String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 110 return=getPublishedMarketPlaceApplication +ApplicationEndpoints#getRecycledApplications(String, String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 77 return=getRecycledApplications +ApplicationEndpoints#getUserHomePage(int) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 187 return=getUserHomePage +ApplicationEndpoints#grantPermission(String, BatchAddPermissionRequest) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 256 return=grantPermissionWithUnknownRole; 258 return=grantPermission +ApplicationEndpoints#publish(String, ApplicationPublishRequest) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 140 return=publish; 156 return=publish; 158 switchIfEmpty=publish; 161 return=publish; 163 return=publish; 164 return=publish +ApplicationEndpoints#recycle(String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 59 return=recycle +ApplicationEndpoints#removePermission(String, String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 244 return=removePermission +ApplicationEndpoints#restore(String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 68 return=restore +ApplicationEndpoints#setApplicationAsAgencyProfile(String, ApplicationAsAgencyProfileRequest) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 298 return=setApplicationAsAgencyProfile +ApplicationEndpoints#setApplicationPublicToAll(String, ApplicationPublicToAllRequest) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 278 return=setApplicationPublicToAll +ApplicationEndpoints#setApplicationPublicToMarketplace(String, ApplicationPublicToMarketplaceRequest) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 288 return=setApplicationPublicToMarketplace +ApplicationEndpoints#update(String, Application, Boolean) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 130 return=update +ApplicationEndpoints#updateEditState(String, UpdateEditStateRequest) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 173 return=updateEditState +ApplicationEndpoints#updatePermission(String, String, UpdatePermissionRequest) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 232 return=updatePermissionWithUnknownRole; 234 return=updatePermission +ApplicationEndpoints#updateSlug(String, String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationEndpointsContractTest pass-through 180 return=updateSlug +ApplicationHistorySnapshotEndpoints#create(ApplicationHistorySnapshotRequest) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationHistorySnapshotEndpointsContractTest pass-through 47 return=create +ApplicationHistorySnapshotEndpoints#getHistorySnapshotDsl(String, String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationHistorySnapshotEndpointsContractTest assembling 136 return=getHistorySnapshotDsl; 145 return=getHistorySnapshotDsl +ApplicationHistorySnapshotEndpoints#getHistorySnapshotDslArchived(String, String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationHistorySnapshotEndpointsContractTest assembling 161 return=getHistorySnapshotDslArchived; 170 return=getHistorySnapshotDslArchived +ApplicationHistorySnapshotEndpoints#listAllHistorySnapshotBriefInfo(String, int, int, String, String, Instant, Instant) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationHistorySnapshotEndpointsContractTest assembling org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotBriefInfo 70 return=listAllHistorySnapshotBriefInfo; 90 return=listAllHistorySnapshotBriefInfo +ApplicationHistorySnapshotEndpoints#listAllHistorySnapshotBriefInfoArchived(String, int, int, String, String, Instant, Instant) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationHistorySnapshotEndpointsContractTest assembling org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotBriefInfo 107 return=listAllHistorySnapshotBriefInfoArchived; 127 return=listAllHistorySnapshotBriefInfoArchived +ApplicationRecordEndpoints#delete(String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationRecordEndpointsContractTest pass-through 24 return=delete +ApplicationRecordEndpoints#dslById(String, String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationRecordEndpointsContractTest pass-through 37 return=dslById +ApplicationRecordEndpoints#getByApplicationId(String) codec WP2 org.lowcoder.api.contract.endpoint.ApplicationRecordEndpointsContractTest pass-through 29 return=getByApplicationId +AuthenticationEndpoints#bindEmail(String) codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through java.lang.Boolean 138 return=bindEmail +AuthenticationEndpoints#createAPIKey(APIKeyRequest) codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through org.lowcoder.api.authentication.dto.APIKeyRequest 119 return=createAPIKey +AuthenticationEndpoints#deleteAPIKey(String) codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through 125 return=deleteAPIKey +AuthenticationEndpoints#disableAuthConfig(String, boolean) codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through 104 return=disableAuthConfig +AuthenticationEndpoints#enableAuthConfig(AuthConfigRequest) codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through org.lowcoder.api.authentication.dto.AuthConfigRequest 98 return=enableAuthConfig +AuthenticationEndpoints#formLogin(FormLoginRequest, String, String, ServerWebExchange) codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through 49 return=formLogin +AuthenticationEndpoints#getAllAPIKeys() codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through 131 return=getAllAPIKeys +AuthenticationEndpoints#getAllConfigs() codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through 110 return=getAllConfigs +AuthenticationEndpoints#linkAccountWithThirdParty(String, String, String, String, String, ServerWebExchange) codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through 80 return=linkAccountWithThirdParty +AuthenticationEndpoints#loginWithThirdParty(String, String, String, String, String, String, ServerWebExchange) codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through 67 return=loginWithThirdParty +AuthenticationEndpoints#logout(ServerWebExchange) codec WP7 org.lowcoder.api.contract.endpoint.AuthenticationEndpointsContractTest pass-through 90 return=logout +BundleEndpoints#addApp(String, String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 143 return=addApp +BundleEndpoints#create(CreateBundleRequest) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 44 return=create +BundleEndpoints#delete(String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 51 return=delete +BundleEndpoints#getAgencyProfileBundle(String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 217 return=getAgencyProfileBundle +BundleEndpoints#getAgencyProfileBundles() codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 239 return=getAgencyProfileBundles +BundleEndpoints#getBundlePermissions(String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 194 return=getBundlePermissions +BundleEndpoints#getBundles(BundleStatus) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 225 return=getBundles +BundleEndpoints#getElements(String, ApplicationType, Integer, Integer) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest assembling org.lowcoder.domain.bundle.model.BundleApplication 125 return=getElements +BundleEndpoints#getMarketplaceBundles() codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 232 return=getMarketplaceBundles +BundleEndpoints#getPublishedBundle(String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 201 return=getPublishedBundle +BundleEndpoints#getPublishedMarketPlaceBundle(String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 209 return=getPublishedMarketPlaceBundle +BundleEndpoints#getRecycledBundles() codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 111 return=getRecycledBundles +BundleEndpoints#grantPermission(String, BatchAddPermissionRequest) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 185 return=grantPermissionWithUnknownRole; 187 return=grantPermission +BundleEndpoints#moveApp(String, String, String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 132 return=moveApp +BundleEndpoints#publish(String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 89 return=publish +BundleEndpoints#recycle(String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 95 return=recycle +BundleEndpoints#removePermission(String, String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 174 return=removePermission +BundleEndpoints#reorder(String, List) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 152 return=reorder +BundleEndpoints#restore(String) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 103 return=restore +BundleEndpoints#setBundleAsAgencyProfile(String, BundleAsAgencyProfileRequest) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 263 return=setBundleAsAgencyProfile +BundleEndpoints#setBundlePublicToAll(String, BundlePublicToAllRequest) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 247 return=setBundlePublicToAll +BundleEndpoints#setBundlePublicToMarketplace(String, BundlePublicToMarketplaceRequest) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 255 return=setBundlePublicToMarketplace +BundleEndpoints#update(Bundle) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 78 return=update; 82 return=update +BundleEndpoints#updatePermission(String, String, UpdatePermissionRequest) codec WP3 org.lowcoder.api.contract.endpoint.BundleEndpointsContractTest pass-through 162 return=updatePermissionWithUnknownRole; 165 return=updatePermission +ConfigEndpoints#getConfig(ServerWebExchange, String) codec WP7 org.lowcoder.api.contract.endpoint.ConfigEndpointsContractTest pass-through 53 return=getConfig +ConfigEndpoints#getDeploymentId() codec WP7 org.lowcoder.api.contract.endpoint.ConfigEndpointsContractTest pass-through 35 return=getDeploymentId +ConfigEndpoints#getServerConfig(String) codec WP7 org.lowcoder.api.contract.endpoint.ConfigEndpointsContractTest pass-through 40 return=getServerConfig; 41 defaultIfEmpty=getServerConfigNotStored +ConfigEndpoints#updateServerConfig(String, UpdateConfigRequest) codec WP7 org.lowcoder.api.contract.endpoint.ConfigEndpointsContractTest pass-through 47 return=updateServerConfig +ServerSettingEndpoints#getServerSettings() codec WP7 org.lowcoder.api.contract.endpoint.ServerSettingEndpointsContractTest pass-through 18 return=getServerSettings +DatasourceEndpoints#create(UpsertDatasourceRequest) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 56 return=create +DatasourceEndpoints#delete(String) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 84 return=delete; 89 return=delete; 91 return=delete +DatasourceEndpoints#deletePermission(String) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 218 return=deletePermission +DatasourceEndpoints#getById(String) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 64 return=getById +DatasourceEndpoints#getPermissions(String) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 172 return=getPermissions +DatasourceEndpoints#getPluginDynamicConfig(List) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 141 return=getPluginDynamicConfigWithoutRequests; 143 return=getPluginDynamicConfig +DatasourceEndpoints#getStructure(String, boolean) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 116 return=getStructure +DatasourceEndpoints#grantPermission(String, BatchAddPermissionRequest) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 182 return=grantPermissionWithUnknownRole; 184 return=grantPermission; 189 return=grantPermission; 193 return=grantPermission +DatasourceEndpoints#info(String) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 226 return=info +DatasourceEndpoints#listAppDataSources(String, String, String, int, int) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest assembling org.lowcoder.api.datasource.DatasourceView 164 return=listAppDataSourcesWithBlankAppId; 166 return=listAppDataSources +DatasourceEndpoints#listJsDatasourcePlugins(String, String, String, int, int) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest assembling org.lowcoder.domain.datasource.model.Datasource 129 return=listJsDatasourcePlugins +DatasourceEndpoints#listOrgDataSources(String, String, String, int, int) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest assembling org.lowcoder.api.datasource.DatasourceView 153 return=listOrgDataSourcesWithBlankOrgId; 155 return=listOrgDataSources +DatasourceEndpoints#testDatasource(UpsertDatasourceRequest) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest assembling 99 return=testDatasource; 101 return=testDatasource +DatasourceEndpoints#update(String, UpsertDatasourceRequest) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 74 return=update +DatasourceEndpoints#updatePermission(String, UpdatePermissionRequest) codec WP5 org.lowcoder.api.contract.endpoint.DatasourceEndpointsContractTest pass-through 202 return=updatePermissionWithUnknownRole; 204 return=updatePermission; 208 return=updatePermission; 211 return=updatePermission +IndexController#index() codec WP7 org.lowcoder.api.contract.endpoint.IndexControllerContractTest pass-through 26 return=index +StateEndpoints#healthCheck() codec WP7 org.lowcoder.api.contract.endpoint.StateEndpointsContractTest pass-through 25 return=healthCheckBeforeWarmup; 29 return=healthCheck +FolderEndpoints#create(Folder) codec WP6 org.lowcoder.api.contract.endpoint.FolderEndpointsContractTest pass-through 43 return=create +FolderEndpoints#delete(String) codec WP6 org.lowcoder.api.contract.endpoint.FolderEndpointsContractTest pass-through 51 return=delete +FolderEndpoints#getApplicationPermissions(String) codec WP6 org.lowcoder.api.contract.endpoint.FolderEndpointsContractTest pass-through 144 return=getApplicationPermissions +FolderEndpoints#getElements(String, ApplicationType, String, String, Integer, Integer) codec WP6 org.lowcoder.api.contract.endpoint.FolderEndpointsContractTest assembling org.lowcoder.api.application.view.ApplicationInfoView,org.lowcoder.api.home.FolderInfoView 82 return=getElements; 88 return=getElements +FolderEndpoints#grantPermission(String, BatchAddPermissionRequest) codec WP6 org.lowcoder.api.contract.endpoint.FolderEndpointsContractTest pass-through 135 return=grantPermissionWithUnknownRole; 137 return=grantPermission +FolderEndpoints#move(String, String) codec WP6 org.lowcoder.api.contract.endpoint.FolderEndpointsContractTest pass-through 98 return=move; 98 defaultIfEmpty=moveWithoutCurrentFolder +FolderEndpoints#removePermission(String, String) codec WP6 org.lowcoder.api.contract.endpoint.FolderEndpointsContractTest pass-through 124 return=removePermission +FolderEndpoints#update(Folder) codec WP6 org.lowcoder.api.contract.endpoint.FolderEndpointsContractTest pass-through 62 return=update; 66 return=update +FolderEndpoints#updatePermission(String, String, UpdatePermissionRequest) codec WP6 org.lowcoder.api.contract.endpoint.FolderEndpointsContractTest pass-through 112 return=updatePermissionWithUnknownRole; 115 return=updatePermission +AssetEndpoints#getById(String, ServerWebExchange) codec WP7 org.lowcoder.api.contract.endpoint.AssetEndpointsContractTest pass-through 21 return=getById +MaterialEndpoints#delete(String) codec WP7 org.lowcoder.api.contract.endpoint.MaterialEndpointsContractTest pass-through 74 return=delete +MaterialEndpoints#download(String, String, ServerHttpResponse) codec WP7 org.lowcoder.api.contract.endpoint.MaterialEndpointsContractTest pass-through 50 return=download; 51 switchIfEmpty=downloadMissing; 51 Mono.error=downloadMissing +MaterialEndpoints#getFileList() codec WP7 org.lowcoder.api.contract.endpoint.MaterialEndpointsContractTest pass-through 68 return=getFileList +MaterialEndpoints#upload(UploadMaterialRequestDTO) codec WP7 org.lowcoder.api.contract.endpoint.MaterialEndpointsContractTest assembling 32 return=upload; 39 return=upload +MetaEndpoints#getMetaData(GetMetaDataRequest) codec WP7 org.lowcoder.api.contract.endpoint.MetaEndpointsContractTest assembling 54 return=getMetaData +ApiFlowEndpoints#flow(FlowRequest) decode-only WP7 org.lowcoder.api.contract.endpoint.ApiFlowEndpointsContractTest pass-through 40 return=flow; 50 onErrorResume=flowUpstreamFailed; 55 return=flowWithoutHeaders; 55 Mono.error=flowWithoutHeaders §1.3: the outbound HTTPS client is built inline (ApiFlowController.java:40-48) and the answer is the upstream's text, not JSON; FlowRequest is decoded at codec level, the outbound request is run against a local server through a test subclass that redirects the client, and the outbound body is pinned by api-flow +JsLibraryEndpoints#getMeta(Collection) codec WP7 org.lowcoder.api.contract.endpoint.JsLibraryEndpointsContractTest pass-through 103 return=getMetaWithoutNames; 105 return=getMeta; 108 return=getMetaRecommended; 110 return=getMeta; 111 onErrorReturn=getMetaFetchFailed +JsLibraryEndpoints#getRecommendationMetas() codec WP7 org.lowcoder.api.contract.endpoint.JsLibraryEndpointsContractTest pass-through 97 return=getRecommendationMetas +PrivateNpmRegistryEndpoint#getNpmPackageAsset(String, String) codec WP7 org.lowcoder.api.contract.endpoint.PrivateNpmRegistryEndpointContractTest pass-through 46 return=getNpmPackageAsset +PrivateNpmRegistryEndpoint#getNpmPackageMeta(String, String) codec WP7 org.lowcoder.api.contract.endpoint.PrivateNpmRegistryEndpointContractTest pass-through 41 return=getNpmPackageMeta +LibraryQueryEndpoints#create(LibraryQuery) codec WP6 org.lowcoder.api.contract.endpoint.LibraryQueryEndpointsContractTest pass-through 72 return=create +LibraryQueryEndpoints#delete(String) codec WP6 org.lowcoder.api.contract.endpoint.LibraryQueryEndpointsContractTest pass-through 93 return=delete +LibraryQueryEndpoints#dropDownList(String) codec WP6 org.lowcoder.api.contract.endpoint.LibraryQueryEndpointsContractTest pass-through 51 return=dropDownList +LibraryQueryEndpoints#get(String) codec WP6 org.lowcoder.api.contract.endpoint.LibraryQueryEndpointsContractTest pass-through 66 return=get +LibraryQueryEndpoints#list(String, int, int) codec WP6 org.lowcoder.api.contract.endpoint.LibraryQueryEndpointsContractTest assembling org.lowcoder.api.query.view.LibraryQueryView 61 return=list +LibraryQueryEndpoints#publish(String, LibraryQueryPublishRequest) codec WP6 org.lowcoder.api.contract.endpoint.LibraryQueryEndpointsContractTest pass-through 104 return=publish; 105 defaultIfEmpty=publishFirstVersion +LibraryQueryEndpoints#update(String, UpsertLibraryQueryRequest) codec WP6 org.lowcoder.api.contract.endpoint.LibraryQueryEndpointsContractTest pass-through 82 return=update +LibraryQueryRecordEndpoints#delete(String) codec WP6 org.lowcoder.api.contract.endpoint.LibraryQueryRecordEndpointsContractTest pass-through 28 return=delete +LibraryQueryRecordEndpoints#dslById(String, String) codec WP6 org.lowcoder.api.contract.endpoint.LibraryQueryRecordEndpointsContractTest pass-through 42 return=dslById +LibraryQueryRecordEndpoints#getByLibraryQueryId(String, int, int) codec WP6 org.lowcoder.api.contract.endpoint.LibraryQueryRecordEndpointsContractTest assembling org.lowcoder.api.query.view.LibraryQueryRecordMetaView 35 return=getByLibraryQueryId +QueryEndpoints#execute(ServerWebExchange, QueryExecutionRequest) codec WP5 org.lowcoder.api.contract.endpoint.QueryEndpointsContractTest pass-through 46 return=execute; 48 return=execute; 50 onErrorResume=executeFailing; 54 return=executeWithExpiredLogin; 57 Mono.error=executeWithExpiredLogin; 59 return=executeFailing; 59 Mono.error=executeFailing +QueryEndpoints#executeLibraryQueryFromJs(ServerWebExchange, LibraryQueryRequestFromJs) codec WP5 org.lowcoder.api.contract.endpoint.QueryEndpointsContractTest pass-through 67 return=executeLibraryQueryFromJs; 72 return=executeLibraryQueryFromJsFromAnotherHost; 74 return=executeLibraryQueryFromJs +GroupEndpoints#addGroupMember(String, AddMemberRequest) codec WP4 org.lowcoder.api.contract.endpoint.GroupEndpointsContractTest pass-through 136 return=addGroupMemberWithBlankGroupId; 139 return=addGroupMemberWithoutUserId; 142 return=addGroupMemberWithoutRole; 144 return=addGroupMember +GroupEndpoints#create(CreateGroupRequest) codec WP4 org.lowcoder.api.contract.endpoint.GroupEndpointsContractTest assembling 50 return=create +GroupEndpoints#delete(String) codec WP4 org.lowcoder.api.contract.endpoint.GroupEndpointsContractTest pass-through 68 return=delete +GroupEndpoints#getGroupMembers(String, String, String, String, String, Integer, Integer) codec WP4 org.lowcoder.api.contract.endpoint.GroupEndpointsContractTest pass-through 127 return=getGroupMembers +GroupEndpoints#getOrgGroups(Integer, Integer) codec WP4 org.lowcoder.api.contract.endpoint.GroupEndpointsContractTest assembling 78 return=getOrgGroups; 79 return=getOrgGroupsEmpty; 81 return=getOrgGroups; 101 return=getOrgGroups +GroupEndpoints#leaveGroup(String) codec WP4 org.lowcoder.api.contract.endpoint.GroupEndpointsContractTest pass-through 164 return=leaveGroup +GroupEndpoints#removeUser(String, String) codec WP4 org.lowcoder.api.contract.endpoint.GroupEndpointsContractTest pass-through 177 return=removeUserWithBlankUserId; 179 return=removeUser +GroupEndpoints#searchPotentialGroupMembers(String, String, Integer, Integer) codec WP4 org.lowcoder.api.contract.endpoint.GroupEndpointsContractTest pass-through 193 return=searchPotentialGroupMembers +GroupEndpoints#update(String, UpdateGroupRequest) codec WP4 org.lowcoder.api.contract.endpoint.GroupEndpointsContractTest pass-through 59 return=update +GroupEndpoints#updateRoleForMember(UpdateRoleRequest, String) codec WP4 org.lowcoder.api.contract.endpoint.GroupEndpointsContractTest pass-through 153 return=updateRoleForMember +InvitationEndpoints#create(String) codec WP4 org.lowcoder.api.contract.endpoint.InvitationEndpointsContractTest pass-through 37 return=create +InvitationEndpoints#get(String) codec WP4 org.lowcoder.api.contract.endpoint.InvitationEndpointsContractTest pass-through 43 return=get +InvitationEndpoints#inviteUser(String) codec WP4 org.lowcoder.api.contract.endpoint.InvitationEndpointsContractTest pass-through java.lang.Boolean,org.lowcoder.api.usermanagement.view.InvitationVO 49 return=inviteUser; 52 return=inviteUserNotLoggedIn; 55 return=inviteUser +InvitationEndpoints#sendInvitationEmails(InviteEmailRequest) codec WP4 org.lowcoder.api.contract.endpoint.InvitationEndpointsContractTest pass-through 63 return=sendInvitationEmails +OrganizationEndpoints#create(Organization) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 86 return=create +OrganizationEndpoints#deleteLogo(String) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 108 return=deleteLogo +OrganizationEndpoints#getOrgApiUsageCount(String, Boolean) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 186 return=getOrgApiUsageCount +OrganizationEndpoints#getOrgCommonSettings(String) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through org.lowcoder.domain.organization.model.Organization$OrganizationCommonSettings 172 return=getOrgCommonSettings +OrganizationEndpoints#getOrgMembers(String, int, int) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 117 return=getOrgMembers +OrganizationEndpoints#getOrganization(String, Boolean) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 199 return=getOrganization; 201 switchIfEmpty=getOrganizationNotFound; 201 Mono.error=getOrganizationNotFound +OrganizationEndpoints#getOrganizationByUser(String, Integer, Integer) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest assembling org.lowcoder.api.usermanagement.view.OrgView 76 return=getOrganizationByUserWithUnnamedOrganization; 78 return=getOrganizationByUser; 80 return=getOrganizationByUser +OrganizationEndpoints#getSupportedDatasourceTypes(String) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 165 return=getSupportedDatasourceTypes +OrganizationEndpoints#leaveOrganization(String) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 150 return=leaveOrganization +OrganizationEndpoints#removeOrg(String) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 143 return=removeOrg +OrganizationEndpoints#removeUserFromOrg(String, String) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 158 return=removeUserFromOrg +OrganizationEndpoints#setCurrentOrganization(String, ServerWebExchange) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through java.lang.Boolean,org.lowcoder.api.authentication.dto.RedirectView 132 return=setCurrentOrganizationRedirect; 138 defaultIfEmpty=setCurrentOrganization +OrganizationEndpoints#update(String, UpdateOrgRequest) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 93 return=update +OrganizationEndpoints#updateOrgCommonSettings(String, UpdateOrgCommonSettingsRequest) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 179 return=updateOrgCommonSettings +OrganizationEndpoints#updateRoleForMember(UpdateRoleRequest, String) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 125 return=updateRoleForMember +OrganizationEndpoints#updateSlug(String, String) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through 193 return=updateSlug +OrganizationEndpoints#uploadLogo(String, Mono) codec WP4 org.lowcoder.api.contract.endpoint.OrganizationEndpointsContractTest pass-through org.springframework.http.codec.multipart.FilePart 101 return=uploadLogo +UserEndpoints#createSCIMUserAndAddToOrg(String, CreateUserRequest) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through org.lowcoder.domain.user.model.User 92 return=createSCIMUserAndAddToOrg; 95 return=createSCIMUserAndAddToOrgWithInvalidEmail; 102 return=createSCIMUserAndAddToOrg; 105 return=createSCIMUserAndAddToOrg; 108 switchIfEmpty=createSCIMUserAndAddToOrgForNewUser; 119 return=createSCIMUserAndAddToOrgForNewUser +UserEndpoints#createUserAndAddToOrg(String, CreateUserRequest) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through org.lowcoder.domain.user.model.User 68 return=createUserAndAddToOrg +UserEndpoints#deleteProfilePhoto() codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 237 return=deleteProfilePhoto +UserEndpoints#deleteProfilePhotoById(String, String) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 244 return=deleteProfilePhotoById +UserEndpoints#getCurrentUser(ServerWebExchange) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 316 return=getCurrentUser +UserEndpoints#getProfilePhoto(ServerWebExchange) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 251 return=getProfilePhoto; 257 return=getProfilePhoto; 258 switchIfEmpty=getProfilePhotoNotFound +UserEndpoints#getProfilePhoto(ServerWebExchange, String) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 257 return=getProfilePhotoOfUser; 258 switchIfEmpty=getProfilePhotoOfUserNotFound +UserEndpoints#getUserDetail(String) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through org.lowcoder.domain.user.model.UserDetail 323 return=getUserDetail +UserEndpoints#getUserOrgs(ServerWebExchange, String, Integer, Integer) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest assembling org.lowcoder.api.usermanagement.view.OrgView 142 return=getUserOrgs; 148 return=getUserOrgs; 164 return=getUserOrgs; 169 return=getUserOrgs +UserEndpoints#getUserProfile(ServerWebExchange) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through org.lowcoder.api.usermanagement.view.UserProfileView,org.lowcoder.api.authentication.dto.RedirectView 130 return=getUserProfileRedirect; 134 switchIfEmpty=getUserProfile +UserEndpoints#lostPassword(LostPasswordRequest) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 287 return=lostPasswordWithoutEmail; 289 return=lostPassword +UserEndpoints#markStatus(MarkUserStatusRequest) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 186 return=markStatusWithUnknownType; 189 return=markStatus +UserEndpoints#newUserGuidanceShown() codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 177 return=newUserGuidanceShown +UserEndpoints#resetLostPassword(ResetLostPasswordRequest) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 297 return=resetLostPasswordWithMissingField; 300 return=resetLostPassword +UserEndpoints#resetPassword(ResetPasswordRequest) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 274 return=resetPasswordOutsideEnterpriseMode; 277 return=resetPasswordWithoutUserId; 279 return=resetPassword +UserEndpoints#setPassword(String) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 307 return=setPasswordEmpty; 309 return=setPassword +UserEndpoints#update(String, String, UpdateUserRequest, ServerWebExchange) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 202 return=updateById +UserEndpoints#update(UpdateUserRequest, ServerWebExchange) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 196 return=update; 202 return=update +UserEndpoints#updatePassword(UpdatePasswordRequest) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through 264 return=updatePasswordWithEmptyPassword; 266 return=updatePassword +UserEndpoints#uploadProfilePhoto(Mono) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through org.springframework.http.codec.multipart.FilePart 222 return=uploadProfilePhoto +UserEndpoints#uploadProfilePhotoById(String, String, Mono) codec WP4 org.lowcoder.api.contract.endpoint.UserEndpointsContractTest pass-through org.springframework.http.codec.multipart.FilePart 229 return=uploadProfilePhotoById diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/DatasourceEndpoints.info.output.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/DatasourceEndpoints.info.output.json new file mode 100644 index 0000000000..20abbe3a4d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/DatasourceEndpoints.info.output.json @@ -0,0 +1,16 @@ +[ { + "connections" : { + "total" : 40601, + "idle" : 40602, + "active" : 40603, + "waiting" : 40604 + }, + "datasource" : { + "jdbcUrl" : "DatasourceEndpointsContractTest.jdbcUrl", + "maximumPoolSize" : "40605" + }, + "healthCheck" : { + "connectivityCheckTimeoutMs" : "40606", + "expected99thPercentileMs" : "DatasourceEndpointsContractTest.expected99thPercentileMs" + } +} ] diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/QueryResultView.mixed.output.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/QueryResultView.mixed.output.json new file mode 100644 index 0000000000..5eb8893660 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/QueryResultView.mixed.output.json @@ -0,0 +1 @@ +{"message":null,"data":{"jdbcRow":{"date":"2026-01-02","datetime":"2026-01-02 13:14:15","year":2026,"blob":"AP9/gA==","varchar":"ResultSet.string é\u0000","decimalTrailingZero":12.50,"decimalExponent":1E+3,"bigintUnsigned":9223372036854775808,"bit":true,"int":-2147483648,"bigint":9223372036854775807,"real":0.1,"double":0.1,"doubleWhole":2.0,"varbinary":"AP9/gA==","sqlDate":1767225600000,"sqlTime":"13:14:15","sqlTimestamp":1767225600123,"uuid":"123e4567-e89b-12d3-a456-426614174000","nullColumn":null},"sqlStatements":[[{"date":"2026-01-02","datetime":"2026-01-02 13:14:15","year":2026,"blob":"AP9/gA==","varchar":"ResultSet.string é\u0000","decimalTrailingZero":12.50,"decimalExponent":1E+3,"bigintUnsigned":9223372036854775808,"bit":true,"int":-2147483648,"bigint":9223372036854775807,"real":0.1,"double":0.1,"doubleWhole":2.0,"varbinary":"AP9/gA==","sqlDate":1767225600000,"sqlTime":"13:14:15","sqlTimestamp":1767225600123,"uuid":"123e4567-e89b-12d3-a456-426614174000","nullColumn":null}],{"affectedRows":2,"generatedKeys":[9223372036854775807,9223372036854775808]}],"postgresRow":{"integerArray":[1,null,2147483647],"textArray":["PostgresResultParser.text",null],"decimalArray":[12.50],"json":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"citext":"PGobject.value"},"restBase64Body":"YVcxaFoyVT0=","restRawBody":"AP9/gA==","nodeServiceResult":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"redisValues":["RedisPlugin.value",""],"smtpResult":null},"headers":null,"success":true,"queryCode":"OK","code":1,"hintMessages":["Duplicated columns found: QueryResultView.mixed.hint, use keyword 'as' to rename duplicated columns."]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.input.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.input.json new file mode 100644 index 0000000000..a21446154c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.input.json @@ -0,0 +1,32 @@ +{ + "zeta": "first key, so that sorted output would differ from input order", + "int": 1, + "negativeInt": -40003, + "intMax": 2147483647, + "intMaxPlusOne": 2147483648, + "intMinMinusOne": -2147483649, + "longMax": 9223372036854775807, + "longMaxPlusOne": 9223372036854775808, + "longMinMinusOne": -9223372036854775809, + "decimal": 1.5, + "trailingZeroDecimal": 1.50, + "zeroPointZero": 0.0, + "negativeZero": -0, + "exponent": 1e2, + "exponentUpperCase": 1E+2, + "negativeExponent": 2.5e-3, + "longDecimal": 0.1000000000000000055511151231257827, + "true": true, + "false": false, + "null": null, + "emptyObject": {}, + "emptyArray": [], + "nested": { + "array": [1, "two", [3, {"four": 4.0}], null, true, 3000000001], + "object": {"z": 1, "a": {"deeper": [[], {}]}} + }, + "unicode": "žluťoučký kůň 🐎 中文", + "escapes": "\u00e9 \u0000 \n \t \" \\ \/ \ud83d\ude00", + "k\u0065y with escaped name": "escaped key", + "alpha": "last key" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.output.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.output.json new file mode 100644 index 0000000000..0a1f996f6d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.output.json @@ -0,0 +1 @@ +{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.read.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.read.json new file mode 100644 index 0000000000..746262a35a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.read.json @@ -0,0 +1,402 @@ +{ + "MAP" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/zeta" : "java.lang.String", + "/int" : "java.lang.Integer", + "/negativeInt" : "java.lang.Integer", + "/intMax" : "java.lang.Integer", + "/intMaxPlusOne" : "java.lang.Long", + "/intMinMinusOne" : "java.lang.Long", + "/longMax" : "java.lang.Long", + "/longMaxPlusOne" : "java.math.BigInteger", + "/longMinMinusOne" : "java.math.BigInteger", + "/decimal" : "java.lang.Double", + "/trailingZeroDecimal" : "java.lang.Double", + "/zeroPointZero" : "java.lang.Double", + "/negativeZero" : "java.lang.Integer", + "/exponent" : "java.lang.Double", + "/exponentUpperCase" : "java.lang.Double", + "/negativeExponent" : "java.lang.Double", + "/longDecimal" : "java.lang.Double", + "/true" : "java.lang.Boolean", + "/false" : "java.lang.Boolean", + "/null" : "null", + "/emptyObject" : "java.util.LinkedHashMap", + "/emptyArray" : "java.util.ArrayList", + "/nested" : "java.util.LinkedHashMap", + "/nested/array" : "java.util.ArrayList", + "/nested/array/0" : "java.lang.Integer", + "/nested/array/1" : "java.lang.String", + "/nested/array/2" : "java.util.ArrayList", + "/nested/array/2/0" : "java.lang.Integer", + "/nested/array/2/1" : "java.util.LinkedHashMap", + "/nested/array/2/1/four" : "java.lang.Double", + "/nested/array/3" : "null", + "/nested/array/4" : "java.lang.Boolean", + "/nested/array/5" : "java.lang.Long", + "/nested/object" : "java.util.LinkedHashMap", + "/nested/object/z" : "java.lang.Integer", + "/nested/object/a" : "java.util.LinkedHashMap", + "/nested/object/a/deeper" : "java.util.ArrayList", + "/nested/object/a/deeper/0" : "java.util.ArrayList", + "/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/unicode" : "java.lang.String", + "/escapes" : "java.lang.String", + "/key with escaped name" : "java.lang.String", + "/alpha" : "java.lang.String" + }, + "keyOrder" : { + "/emptyObject" : [ ], + "/nested/array/2/1" : [ "four" ], + "/nested/object/a/deeper/1" : [ ], + "/nested/object/a" : [ "deeper" ], + "/nested/object" : [ "z", "a" ], + "/nested" : [ "array", "object" ], + "" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ] + } + }, + "LIST" : { + "classes" : { + "" : "java.util.ArrayList", + "/0" : "java.util.LinkedHashMap", + "/0/zeta" : "java.lang.String", + "/0/int" : "java.lang.Integer", + "/0/negativeInt" : "java.lang.Integer", + "/0/intMax" : "java.lang.Integer", + "/0/intMaxPlusOne" : "java.lang.Long", + "/0/intMinMinusOne" : "java.lang.Long", + "/0/longMax" : "java.lang.Long", + "/0/longMaxPlusOne" : "java.math.BigInteger", + "/0/longMinMinusOne" : "java.math.BigInteger", + "/0/decimal" : "java.lang.Double", + "/0/trailingZeroDecimal" : "java.lang.Double", + "/0/zeroPointZero" : "java.lang.Double", + "/0/negativeZero" : "java.lang.Integer", + "/0/exponent" : "java.lang.Double", + "/0/exponentUpperCase" : "java.lang.Double", + "/0/negativeExponent" : "java.lang.Double", + "/0/longDecimal" : "java.lang.Double", + "/0/true" : "java.lang.Boolean", + "/0/false" : "java.lang.Boolean", + "/0/null" : "null", + "/0/emptyObject" : "java.util.LinkedHashMap", + "/0/emptyArray" : "java.util.ArrayList", + "/0/nested" : "java.util.LinkedHashMap", + "/0/nested/array" : "java.util.ArrayList", + "/0/nested/array/0" : "java.lang.Integer", + "/0/nested/array/1" : "java.lang.String", + "/0/nested/array/2" : "java.util.ArrayList", + "/0/nested/array/2/0" : "java.lang.Integer", + "/0/nested/array/2/1" : "java.util.LinkedHashMap", + "/0/nested/array/2/1/four" : "java.lang.Double", + "/0/nested/array/3" : "null", + "/0/nested/array/4" : "java.lang.Boolean", + "/0/nested/array/5" : "java.lang.Long", + "/0/nested/object" : "java.util.LinkedHashMap", + "/0/nested/object/z" : "java.lang.Integer", + "/0/nested/object/a" : "java.util.LinkedHashMap", + "/0/nested/object/a/deeper" : "java.util.ArrayList", + "/0/nested/object/a/deeper/0" : "java.util.ArrayList", + "/0/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/0/unicode" : "java.lang.String", + "/0/escapes" : "java.lang.String", + "/0/key with escaped name" : "java.lang.String", + "/0/alpha" : "java.lang.String" + }, + "keyOrder" : { + "/0/emptyObject" : [ ], + "/0/nested/array/2/1" : [ "four" ], + "/0/nested/object/a/deeper/1" : [ ], + "/0/nested/object/a" : [ "deeper" ], + "/0/nested/object" : [ "z", "a" ], + "/0/nested" : [ "array", "object" ], + "/0" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ] + } + }, + "OBJECT" : { + "classes" : { + "" : "java.util.LinkedHashMap", + "/zeta" : "java.lang.String", + "/int" : "java.lang.Integer", + "/negativeInt" : "java.lang.Integer", + "/intMax" : "java.lang.Integer", + "/intMaxPlusOne" : "java.lang.Long", + "/intMinMinusOne" : "java.lang.Long", + "/longMax" : "java.lang.Long", + "/longMaxPlusOne" : "java.math.BigInteger", + "/longMinMinusOne" : "java.math.BigInteger", + "/decimal" : "java.lang.Double", + "/trailingZeroDecimal" : "java.lang.Double", + "/zeroPointZero" : "java.lang.Double", + "/negativeZero" : "java.lang.Integer", + "/exponent" : "java.lang.Double", + "/exponentUpperCase" : "java.lang.Double", + "/negativeExponent" : "java.lang.Double", + "/longDecimal" : "java.lang.Double", + "/true" : "java.lang.Boolean", + "/false" : "java.lang.Boolean", + "/null" : "null", + "/emptyObject" : "java.util.LinkedHashMap", + "/emptyArray" : "java.util.ArrayList", + "/nested" : "java.util.LinkedHashMap", + "/nested/array" : "java.util.ArrayList", + "/nested/array/0" : "java.lang.Integer", + "/nested/array/1" : "java.lang.String", + "/nested/array/2" : "java.util.ArrayList", + "/nested/array/2/0" : "java.lang.Integer", + "/nested/array/2/1" : "java.util.LinkedHashMap", + "/nested/array/2/1/four" : "java.lang.Double", + "/nested/array/3" : "null", + "/nested/array/4" : "java.lang.Boolean", + "/nested/array/5" : "java.lang.Long", + "/nested/object" : "java.util.LinkedHashMap", + "/nested/object/z" : "java.lang.Integer", + "/nested/object/a" : "java.util.LinkedHashMap", + "/nested/object/a/deeper" : "java.util.ArrayList", + "/nested/object/a/deeper/0" : "java.util.ArrayList", + "/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/unicode" : "java.lang.String", + "/escapes" : "java.lang.String", + "/key with escaped name" : "java.lang.String", + "/alpha" : "java.lang.String" + }, + "keyOrder" : { + "/emptyObject" : [ ], + "/nested/array/2/1" : [ "four" ], + "/nested/object/a/deeper/1" : [ ], + "/nested/object/a" : [ "deeper" ], + "/nested/object" : [ "z", "a" ], + "/nested" : [ "array", "object" ], + "" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ] + } + }, + "JSON_NODE" : { + "classes" : { + "" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/zeta" : "com.fasterxml.jackson.databind.node.TextNode", + "/int" : "com.fasterxml.jackson.databind.node.IntNode", + "/negativeInt" : "com.fasterxml.jackson.databind.node.IntNode", + "/intMax" : "com.fasterxml.jackson.databind.node.IntNode", + "/intMaxPlusOne" : "com.fasterxml.jackson.databind.node.LongNode", + "/intMinMinusOne" : "com.fasterxml.jackson.databind.node.LongNode", + "/longMax" : "com.fasterxml.jackson.databind.node.LongNode", + "/longMaxPlusOne" : "com.fasterxml.jackson.databind.node.BigIntegerNode", + "/longMinMinusOne" : "com.fasterxml.jackson.databind.node.BigIntegerNode", + "/decimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/trailingZeroDecimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/zeroPointZero" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/negativeZero" : "com.fasterxml.jackson.databind.node.IntNode", + "/exponent" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/exponentUpperCase" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/negativeExponent" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/longDecimal" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/true" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/false" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/null" : "com.fasterxml.jackson.databind.node.NullNode", + "/emptyObject" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/emptyArray" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/nested" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/nested/array" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/nested/array/0" : "com.fasterxml.jackson.databind.node.IntNode", + "/nested/array/1" : "com.fasterxml.jackson.databind.node.TextNode", + "/nested/array/2" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/nested/array/2/0" : "com.fasterxml.jackson.databind.node.IntNode", + "/nested/array/2/1" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/nested/array/2/1/four" : "com.fasterxml.jackson.databind.node.DoubleNode", + "/nested/array/3" : "com.fasterxml.jackson.databind.node.NullNode", + "/nested/array/4" : "com.fasterxml.jackson.databind.node.BooleanNode", + "/nested/array/5" : "com.fasterxml.jackson.databind.node.LongNode", + "/nested/object" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/nested/object/z" : "com.fasterxml.jackson.databind.node.IntNode", + "/nested/object/a" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/nested/object/a/deeper" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/nested/object/a/deeper/0" : "com.fasterxml.jackson.databind.node.ArrayNode", + "/nested/object/a/deeper/1" : "com.fasterxml.jackson.databind.node.ObjectNode", + "/unicode" : "com.fasterxml.jackson.databind.node.TextNode", + "/escapes" : "com.fasterxml.jackson.databind.node.TextNode", + "/key with escaped name" : "com.fasterxml.jackson.databind.node.TextNode", + "/alpha" : "com.fasterxml.jackson.databind.node.TextNode" + }, + "keyOrder" : { + "/emptyObject" : [ ], + "/nested/array/2/1" : [ "four" ], + "/nested/object/a/deeper/1" : [ ], + "/nested/object/a" : [ "deeper" ], + "/nested/object" : [ "z", "a" ], + "/nested" : [ "array", "object" ], + "" : [ "zeta", "int", "negativeInt", "intMax", "intMaxPlusOne", "intMinMinusOne", "longMax", "longMaxPlusOne", "longMinMinusOne", "decimal", "trailingZeroDecimal", "zeroPointZero", "negativeZero", "exponent", "exponentUpperCase", "negativeExponent", "longDecimal", "true", "false", "null", "emptyObject", "emptyArray", "nested", "unicode", "escapes", "key with escaped name", "alpha" ] + } + }, + "ORGANIZATION_COMMON_SETTINGS" : { + "classes" : { + "" : "org.lowcoder.domain.organization.model.Organization$OrganizationCommonSettings", + "/intMinMinusOne" : "java.lang.Long", + "/longMax" : "java.lang.Long", + "/trailingZeroDecimal" : "java.lang.Double", + "/intMax" : "java.lang.Integer", + "/longDecimal" : "java.lang.Double", + "/nested" : "java.util.LinkedHashMap", + "/nested/array" : "java.util.ArrayList", + "/nested/array/0" : "java.lang.Integer", + "/nested/array/1" : "java.lang.String", + "/nested/array/2" : "java.util.ArrayList", + "/nested/array/2/0" : "java.lang.Integer", + "/nested/array/2/1" : "java.util.LinkedHashMap", + "/nested/array/2/1/four" : "java.lang.Double", + "/nested/array/3" : "null", + "/nested/array/4" : "java.lang.Boolean", + "/nested/array/5" : "java.lang.Long", + "/nested/object" : "java.util.LinkedHashMap", + "/nested/object/z" : "java.lang.Integer", + "/nested/object/a" : "java.util.LinkedHashMap", + "/nested/object/a/deeper" : "java.util.ArrayList", + "/nested/object/a/deeper/0" : "java.util.ArrayList", + "/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/negativeExponent" : "java.lang.Double", + "/alpha" : "java.lang.String", + "/emptyObject" : "java.util.LinkedHashMap", + "/intMaxPlusOne" : "java.lang.Long", + "/exponent" : "java.lang.Double", + "/zeta" : "java.lang.String", + "/escapes" : "java.lang.String", + "/false" : "java.lang.Boolean", + "/negativeZero" : "java.lang.Integer", + "/int" : "java.lang.Integer", + "/exponentUpperCase" : "java.lang.Double", + "/longMaxPlusOne" : "java.math.BigInteger", + "/null" : "null", + "/true" : "java.lang.Boolean", + "/key with escaped name" : "java.lang.String", + "/unicode" : "java.lang.String", + "/longMinMinusOne" : "java.math.BigInteger", + "/emptyArray" : "java.util.ArrayList", + "/negativeInt" : "java.lang.Integer", + "/decimal" : "java.lang.Double", + "/zeroPointZero" : "java.lang.Double" + }, + "keyOrder" : { + "/nested/array/2/1" : [ "four" ], + "/nested/object/a/deeper/1" : [ ], + "/nested/object/a" : [ "deeper" ], + "/nested/object" : [ "z", "a" ], + "/nested" : [ "array", "object" ], + "/emptyObject" : [ ], + "" : [ "intMinMinusOne", "longMax", "trailingZeroDecimal", "intMax", "longDecimal", "nested", "negativeExponent", "alpha", "emptyObject", "intMaxPlusOne", "exponent", "zeta", "escapes", "false", "negativeZero", "int", "exponentUpperCase", "longMaxPlusOne", "null", "true", "key with escaped name", "unicode", "longMinMinusOne", "emptyArray", "negativeInt", "decimal", "zeroPointZero" ] + } + }, + "AUTH_CONFIG_REQUEST" : { + "classes" : { + "" : "org.lowcoder.api.authentication.dto.AuthConfigRequest", + "/intMinMinusOne" : "java.lang.Long", + "/longMax" : "java.lang.Long", + "/trailingZeroDecimal" : "java.lang.Double", + "/intMax" : "java.lang.Integer", + "/longDecimal" : "java.lang.Double", + "/nested" : "java.util.LinkedHashMap", + "/nested/array" : "java.util.ArrayList", + "/nested/array/0" : "java.lang.Integer", + "/nested/array/1" : "java.lang.String", + "/nested/array/2" : "java.util.ArrayList", + "/nested/array/2/0" : "java.lang.Integer", + "/nested/array/2/1" : "java.util.LinkedHashMap", + "/nested/array/2/1/four" : "java.lang.Double", + "/nested/array/3" : "null", + "/nested/array/4" : "java.lang.Boolean", + "/nested/array/5" : "java.lang.Long", + "/nested/object" : "java.util.LinkedHashMap", + "/nested/object/z" : "java.lang.Integer", + "/nested/object/a" : "java.util.LinkedHashMap", + "/nested/object/a/deeper" : "java.util.ArrayList", + "/nested/object/a/deeper/0" : "java.util.ArrayList", + "/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/negativeExponent" : "java.lang.Double", + "/alpha" : "java.lang.String", + "/emptyObject" : "java.util.LinkedHashMap", + "/intMaxPlusOne" : "java.lang.Long", + "/exponent" : "java.lang.Double", + "/zeta" : "java.lang.String", + "/escapes" : "java.lang.String", + "/false" : "java.lang.Boolean", + "/negativeZero" : "java.lang.Integer", + "/int" : "java.lang.Integer", + "/exponentUpperCase" : "java.lang.Double", + "/longMaxPlusOne" : "java.math.BigInteger", + "/null" : "null", + "/true" : "java.lang.Boolean", + "/key with escaped name" : "java.lang.String", + "/unicode" : "java.lang.String", + "/longMinMinusOne" : "java.math.BigInteger", + "/emptyArray" : "java.util.ArrayList", + "/negativeInt" : "java.lang.Integer", + "/decimal" : "java.lang.Double", + "/zeroPointZero" : "java.lang.Double" + }, + "keyOrder" : { + "/nested/array/2/1" : [ "four" ], + "/nested/object/a/deeper/1" : [ ], + "/nested/object/a" : [ "deeper" ], + "/nested/object" : [ "z", "a" ], + "/nested" : [ "array", "object" ], + "/emptyObject" : [ ], + "" : [ "intMinMinusOne", "longMax", "trailingZeroDecimal", "intMax", "longDecimal", "nested", "negativeExponent", "alpha", "emptyObject", "intMaxPlusOne", "exponent", "zeta", "escapes", "false", "negativeZero", "int", "exponentUpperCase", "longMaxPlusOne", "null", "true", "key with escaped name", "unicode", "longMinMinusOne", "emptyArray", "negativeInt", "decimal", "zeroPointZero" ] + } + }, + "API_KEY_REQUEST" : { + "classes" : { + "" : "org.lowcoder.api.authentication.dto.APIKeyRequest", + "/intMinMinusOne" : "java.lang.Long", + "/longMax" : "java.lang.Long", + "/trailingZeroDecimal" : "java.lang.Double", + "/intMax" : "java.lang.Integer", + "/longDecimal" : "java.lang.Double", + "/nested" : "java.util.LinkedHashMap", + "/nested/array" : "java.util.ArrayList", + "/nested/array/0" : "java.lang.Integer", + "/nested/array/1" : "java.lang.String", + "/nested/array/2" : "java.util.ArrayList", + "/nested/array/2/0" : "java.lang.Integer", + "/nested/array/2/1" : "java.util.LinkedHashMap", + "/nested/array/2/1/four" : "java.lang.Double", + "/nested/array/3" : "null", + "/nested/array/4" : "java.lang.Boolean", + "/nested/array/5" : "java.lang.Long", + "/nested/object" : "java.util.LinkedHashMap", + "/nested/object/z" : "java.lang.Integer", + "/nested/object/a" : "java.util.LinkedHashMap", + "/nested/object/a/deeper" : "java.util.ArrayList", + "/nested/object/a/deeper/0" : "java.util.ArrayList", + "/nested/object/a/deeper/1" : "java.util.LinkedHashMap", + "/negativeExponent" : "java.lang.Double", + "/alpha" : "java.lang.String", + "/emptyObject" : "java.util.LinkedHashMap", + "/intMaxPlusOne" : "java.lang.Long", + "/exponent" : "java.lang.Double", + "/zeta" : "java.lang.String", + "/escapes" : "java.lang.String", + "/false" : "java.lang.Boolean", + "/negativeZero" : "java.lang.Integer", + "/int" : "java.lang.Integer", + "/exponentUpperCase" : "java.lang.Double", + "/longMaxPlusOne" : "java.math.BigInteger", + "/null" : "null", + "/true" : "java.lang.Boolean", + "/key with escaped name" : "java.lang.String", + "/unicode" : "java.lang.String", + "/longMinMinusOne" : "java.math.BigInteger", + "/emptyArray" : "java.util.ArrayList", + "/negativeInt" : "java.lang.Integer", + "/decimal" : "java.lang.Double", + "/zeroPointZero" : "java.lang.Double" + }, + "keyOrder" : { + "/nested/array/2/1" : [ "four" ], + "/nested/object/a/deeper/1" : [ ], + "/nested/object/a" : [ "deeper" ], + "/nested/object" : [ "z", "a" ], + "/nested" : [ "array", "object" ], + "/emptyObject" : [ ], + "" : [ "intMinMinusOne", "longMax", "trailingZeroDecimal", "intMax", "longDecimal", "nested", "negativeExponent", "alpha", "emptyObject", "intMaxPlusOne", "exponent", "zeta", "escapes", "false", "negativeZero", "int", "exponentUpperCase", "longMaxPlusOne", "null", "true", "key with escaped name", "unicode", "longMinMinusOne", "emptyArray", "negativeInt", "decimal", "zeroPointZero" ] + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.stored.output.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.stored.output.json new file mode 100644 index 0000000000..b66c216dc7 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/dynamic/representative.stored.output.json @@ -0,0 +1 @@ +{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":"9223372036854775808","longMinMinusOne":"-9223372036854775809","decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/GroupListResponseView.success.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/GroupListResponseView.success.json new file mode 100644 index 0000000000..c1e3f5cf12 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/GroupListResponseView.success.json @@ -0,0 +1 @@ +{"code": 1, "message": "", "data": {"name": "GroupListResponseView.data.name", "count": 40005}, "totalAdminsAndDevelopers": 40007, "totalDevelopersOnly": 40008, "totalAdmins": 40006, "totalOtherMembers": 40009, "total": 40004, "pageNum": 2, "pageSize": 40003, "success": true} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/PageResponseView.error.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/PageResponseView.error.json new file mode 100644 index 0000000000..fbf9b09bb1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/PageResponseView.error.json @@ -0,0 +1 @@ +{"code": 5000, "message": "EnvelopeGoldensTest.message", "data": [], "pageNum": 2, "pageSize": 40003, "total": 40004, "success": false} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/PageResponseView.success.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/PageResponseView.success.json new file mode 100644 index 0000000000..d12b5bd884 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/PageResponseView.success.json @@ -0,0 +1 @@ +{"code": 1, "message": "", "data": [{"name": "PageResponseView[0].data.name", "count": 40005}, {"name": "PageResponseView[1].data.name", "count": 40005}], "pageNum": 2, "pageSize": 40003, "total": 40004, "success": true} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.error-with-data.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.error-with-data.json new file mode 100644 index 0000000000..8eea88c253 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.error-with-data.json @@ -0,0 +1 @@ +{"code": 5000, "message": "EnvelopeGoldensTest.message", "data": {"name": "ResponseView.data.name", "count": 40005}, "success": false} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.error.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.error.json new file mode 100644 index 0000000000..a4542b5621 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.error.json @@ -0,0 +1 @@ +{"code": 5000, "message": "EnvelopeGoldensTest.message", "success": false} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.redirect.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.redirect.json new file mode 100644 index 0000000000..9e4f7cba2a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.redirect.json @@ -0,0 +1 @@ +{"code": 5011, "message": "EnvelopeGoldensTest.message", "data": {"name": "ResponseView.data.name", "count": 40005}, "success": true} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.success-null-data.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.success-null-data.json new file mode 100644 index 0000000000..7d9ee36e6d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.success-null-data.json @@ -0,0 +1 @@ +{"code": 1, "message": "", "success": true} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.success.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.success.json new file mode 100644 index 0000000000..44d3794105 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/envelope/ResponseView.success.json @@ -0,0 +1 @@ +{"code": 1, "message": "", "data": {"name": "ResponseView.data.name", "count": 40005}, "success": true} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/errors/X5.unmapped-path.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/errors/X5.unmapped-path.json new file mode 100644 index 0000000000..d59b96ea82 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/errors/X5.unmapped-path.json @@ -0,0 +1 @@ +{"code": 5000, "message": "Oops! Service is busy, please try again later.", "success": false} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/errors/X6.filter-runtime-exception.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/errors/X6.filter-runtime-exception.json new file mode 100644 index 0000000000..d59b96ea82 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/errors/X6.filter-runtime-exception.json @@ -0,0 +1 @@ +{"code": 5000, "message": "Oops! Service is busy, please try again later.", "success": false} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/errors/X7.filter-response-status-403.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/errors/X7.filter-response-status-403.json new file mode 100644 index 0000000000..ff68aa88ea --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/errors/X7.filter-response-status-403.json @@ -0,0 +1 @@ +{"code": 403, "message": "Forbidden", "success": false} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/functional-routes.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/functional-routes.json new file mode 100644 index 0000000000..51ce47d738 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/functional-routes.json @@ -0,0 +1,23 @@ +{ + "comment": "Rule 7 of docs/API_PAYLOAD_TEST_PLAN.md §6.1: every main-source call that produces a RouterFunction, by the stable key of ContractSources.routeSites. body is 'json' (a test class must cover it) or 'bytes' (§1.4, with the reason in note).", + "routes": [ + { + "key": "lowcoder-server/src/main/java/org/lowcoder/api/framework/configuration/PluginConfiguration.java#PluginConfiguration.pluginEndpoints#route#1", + "body": "json", + "test": "org.lowcoder.api.contract.endpoint.PluginRouteContractTest", + "note": "GET /api/plugins/ writes the loaded plugin infos as JSON: golden routes/plugins.json (T7.4); first shown by E10 (ContractTestClientTest#e10PluginRouteListsLoadedPlugins)" + }, + { + "key": "lowcoder-server/src/main/java/org/lowcoder/api/framework/exception/CustomErrorWebExceptionHandler.java#CustomErrorWebExceptionHandler.getRoutingFunction#route#1", + "body": "json", + "test": "org.lowcoder.api.contract.error.ErrorCasesContractTest", + "note": "the error handler's ResponseView JSON: X6 (5xx branch) and X7 (4xx branch) goldens under errors/ (T1.2); X5 is written by GlobalExceptionHandler, not this route" + }, + { + "key": "lowcoder-server/src/main/java/org/lowcoder/api/framework/plugin/endpoint/PluginEndpointHandlerImpl.java#PluginEndpointHandlerImpl.registerEndpointHandler#route#1", + "body": "bytes", + "test": "", + "note": "plugin endpoints pass the plugin's response bytes through (§1.4)" + } + ] +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/producer-sites.tsv b/server/api-service/lowcoder-server/src/test/resources/json-contract/producer-sites.tsv new file mode 100644 index 0000000000..0420c47784 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/producer-sites.tsv @@ -0,0 +1,23 @@ +# Query-result producer sites (docs/API_PAYLOAD_TEST_PLAN.md §4.6, §6.1 rule 9): every call in main code that puts a value +# into QueryExecutionResult, and the test that pins the value it produces. Columns (tab-separated): +# site (ContractSources#producerSites key: ###), test (class in any module's test sources; +# it builds its report with QueryResults.report), producer (the row of the §4.6 producer table). +# Written in task T10.1 (2026-10-04); PayloadContractCompletenessTest rule 9 keeps it complete. +lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/ClickHouseQueryExecutor.java#ClickHouseQueryExecutor.parseExecuteResult#success#1 org.lowcoder.plugin.clickhouse.ClickHouseResultContractTest ClickHouse +lowcoder-plugins/clickHousePlugin/src/main/java/org/lowcoder/plugin/clickhouse/ClickHouseQueryExecutor.java#ClickHouseQueryExecutor.parseExecuteResult#success#2 org.lowcoder.plugin.clickhouse.ClickHouseResultContractTest ClickHouse +lowcoder-plugins/elasticSearchPlugin/src/main/java/org/lowcoder/plugin/es/EsQueryExecutor.java#EsQueryExecutor.executeQuery#success#1 org.lowcoder.plugin.es.EsResultContractTest Elasticsearch +lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsAppendDataHandler.java#GoogleSheetsAppendDataHandler.execute#success#1 org.lowcoder.plugin.googlesheets.GoogleSheetsResultContractTest Google Sheets append, update, delete, clear +lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsClearDataHandler.java#GoogleSheetsClearDataHandler.execute#success#1 org.lowcoder.plugin.googlesheets.GoogleSheetsResultContractTest Google Sheets append, update, delete, clear +lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsDeleteDataHandler.java#GoogleSheetsDeleteDataHandler.execute#success#1 org.lowcoder.plugin.googlesheets.GoogleSheetsResultContractTest Google Sheets append, update, delete, clear +lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsReadDataHandler.java#GoogleSheetsReadDataHandler.execute#success#1 org.lowcoder.plugin.googlesheets.GoogleSheetsResultContractTest Google Sheets read +lowcoder-plugins/googleSheetsPlugin/src/main/java/org/lowcoder/plugin/googlesheets/queryhandler/GoogleSheetsUpdateDataHandler.java#GoogleSheetsUpdateDataHandler.execute#success#1 org.lowcoder.plugin.googlesheets.GoogleSheetsResultContractTest Google Sheets append, update, delete, clear +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.convertToQueryExecutionResult#ofRestApiResult#1 org.lowcoder.plugin.graphql.GraphQLResponseContractTest REST API, GraphQL +lowcoder-plugins/graphqlPlugin/src/main/java/org/lowcoder/plugin/graphql/GraphQLExecutor.java#GraphQLExecutor.convertToQueryExecutionResult#ofRestApiResult#2 org.lowcoder.plugin.graphql.GraphQLResponseContractTest REST API, GraphQL +lowcoder-plugins/mongoPlugin/src/main/java/org/lowcoder/plugin/mongo/MongoPlugin.java#MongoPlugin.MongoEngine.executeQuery#success#1 org.lowcoder.plugin.mongo.MongoResultContractTest Mongo +lowcoder-plugins/redisPlugin/src/main/java/org/lowcoder/plugin/redis/RedisPlugin.java#RedisPlugin.RedisEngine.executeQuery#success#1 org.lowcoder.plugin.redis.RedisResultContractTest Redis +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.convertToQueryExecutionResult#ofRestApiResult#1 org.lowcoder.plugin.restapi.RestApiResponseContractTest REST API, GraphQL +lowcoder-plugins/restApiPlugin/src/main/java/org/lowcoder/plugin/restapi/RestApiExecutor.java#RestApiExecutor.convertToQueryExecutionResult#ofRestApiResult#2 org.lowcoder.plugin.restapi.RestApiResponseContractTest REST API, GraphQL +lowcoder-plugins/smtpPlugin/src/main/java/org/lowcoder/plugins/SmtpPlugin.java#SmtpPlugin.SmtpEngine.executeQuery#success#1 org.lowcoder.plugins.SmtpResultContractTest SMTP +lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/GeneralSqlExecutor.java#GeneralSqlExecutor.parseExecuteResult#success#1 org.lowcoder.plugin.sql.SqlResultContractTest SQL plugins +lowcoder-plugins/sqlBasedPlugin/src/main/java/org/lowcoder/plugin/sql/GeneralSqlExecutor.java#GeneralSqlExecutor.parseExecuteResult#success#2 org.lowcoder.plugin.sql.SqlResultContractTest SQL plugins +lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java#DatasourcePluginClient.executeQuery#::success#1 org.lowcoder.domain.plugin.client.DatasourcePluginClientContractTest JS datasource plugins via the node service diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/routes/plugins.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/routes/plugins.json new file mode 100644 index 0000000000..19b853c0dc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/routes/plugins.json @@ -0,0 +1 @@ +[{"id":"PluginInfo[0].id","description":"PluginInfo[0].description","info":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň \uD83D\uDC0E 中文","escapes":"é \u0000 \n \t \" \\ / \uD83D\uDE00","key with escaped name":"escaped key","alpha":"last key"}},{"id":"PluginInfo[1].id","description":"PluginInfo[1].description","info":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň \uD83D\uDC0E 中文","escapes":"é \u0000 \n \t \" \\ / \uD83D\uDE00","key with escaped name":"escaped key","alpha":"last key"}}] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationAsAgencyProfileRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationAsAgencyProfileRequest.D1.json new file mode 100644 index 0000000000..2df7ef1e07 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationAsAgencyProfileRequest.D1.json @@ -0,0 +1,3 @@ +{ + "agencyProfile" : true +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationAsAgencyProfileRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationAsAgencyProfileRequest.D3.json new file mode 100644 index 0000000000..a2b4ca259f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationAsAgencyProfileRequest.D3.json @@ -0,0 +1,12 @@ +{ + "agencyProfile" : { + "absent" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToAllRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToAllRequest.D1.json new file mode 100644 index 0000000000..1e3b6a18f5 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToAllRequest.D1.json @@ -0,0 +1,3 @@ +{ + "publicToAll" : true +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToAllRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToAllRequest.D3.json new file mode 100644 index 0000000000..305a787461 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToAllRequest.D3.json @@ -0,0 +1,12 @@ +{ + "publicToAll" : { + "absent" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToMarketplaceRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToMarketplaceRequest.D1.json new file mode 100644 index 0000000000..8a9a0fce7c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToMarketplaceRequest.D1.json @@ -0,0 +1,3 @@ +{ + "publicToMarketplace" : false +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToMarketplaceRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToMarketplaceRequest.D3.json new file mode 100644 index 0000000000..4fb17554ec --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$ApplicationPublicToMarketplaceRequest.D3.json @@ -0,0 +1,12 @@ +{ + "publicToMarketplace" : { + "absent" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$BatchAddPermissionRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$BatchAddPermissionRequest.D1.json new file mode 100644 index 0000000000..39c26ae7a5 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$BatchAddPermissionRequest.D1.json @@ -0,0 +1,5 @@ +{ + "role" : "editor", + "userIds" : [ "BatchAddPermissionRequest.userIds[0]", "BatchAddPermissionRequest.userIds[1]" ], + "groupIds" : [ "BatchAddPermissionRequest.groupIds[1]", "BatchAddPermissionRequest.groupIds[0]" ] +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$BatchAddPermissionRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$BatchAddPermissionRequest.D3.json new file mode 100644 index 0000000000..4acb2046ef --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$BatchAddPermissionRequest.D3.json @@ -0,0 +1,32 @@ +{ + "role" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "userIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "groupIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$CreateApplicationRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$CreateApplicationRequest.D1.json new file mode 100644 index 0000000000..0e8c71c6a1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$CreateApplicationRequest.D1.json @@ -0,0 +1,30 @@ +{ + "orgId": "CreateApplicationRequest.orgId", + "gid": "CreateApplicationRequest.gid", + "name": "CreateApplicationRequest.name", + "applicationType": 40001, + "editingApplicationDSL": { + "ui": { + "compType": "editingApplicationDSL.ui.compType", + "comp": { + "text": "editingApplicationDSL.ui.comp.text", + "hidden": false + } + }, + "queries": [ + { + "id": "editingApplicationDSL.queries[0]", + "timeout": 40002 + }, + { + "id": "editingApplicationDSL.queries[1]", + "timeout": 3000000001 + } + ], + "ratio": 1.50, + "removed": null + }, + "folderId": "CreateApplicationRequest.folderId", + "publicToAll": true, + "publicToMarketplace": false +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$CreateApplicationRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$CreateApplicationRequest.D3.json new file mode 100644 index 0000000000..2481d90ebe --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$CreateApplicationRequest.D3.json @@ -0,0 +1,82 @@ +{ + "orgId" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "gid" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "name" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "applicationType" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "editingApplicationDSL" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "folderId" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "publicToAll" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "publicToMarketplace" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdateEditStateRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdateEditStateRequest.D1.json new file mode 100644 index 0000000000..90af789d42 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdateEditStateRequest.D1.json @@ -0,0 +1,3 @@ +{ + "editingFinished" : false +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdateEditStateRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdateEditStateRequest.D3.json new file mode 100644 index 0000000000..77c3ccdbfb --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdateEditStateRequest.D3.json @@ -0,0 +1,12 @@ +{ + "editingFinished" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdatePermissionRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdatePermissionRequest.D1.json new file mode 100644 index 0000000000..05e7ee4001 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdatePermissionRequest.D1.json @@ -0,0 +1,3 @@ +{ + "role" : "viewer" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdatePermissionRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdatePermissionRequest.D3.json new file mode 100644 index 0000000000..aa8e4e511b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationEndpoints$UpdatePermissionRequest.D3.json @@ -0,0 +1,12 @@ +{ + "role" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotBriefInfo.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotBriefInfo.S1.json new file mode 100644 index 0000000000..c2bab49012 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotBriefInfo.S1.json @@ -0,0 +1 @@ +{"snapshotId":"ApplicationHistorySnapshotBriefInfo.snapshotId","context":{"operations":[{"type":"ApplicationHistorySnapshotBriefInfo.context.operations[0].type","compName":"ApplicationHistorySnapshotBriefInfo.context.operations[0].compName"},{"type":"ApplicationHistorySnapshotBriefInfo.context.operations[1].type","compName":"ApplicationHistorySnapshotBriefInfo.context.operations[1].compName"}],"version":40080},"userId":"ApplicationHistorySnapshotBriefInfo.userId","userName":"ApplicationHistorySnapshotBriefInfo.userName","userAvatar":"ApplicationHistorySnapshotBriefInfo.userAvatar","createTime":1767225680123} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotBriefInfo.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotBriefInfo.S2.json new file mode 100644 index 0000000000..27552e99f1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotBriefInfo.S2.json @@ -0,0 +1 @@ +{"snapshotId":null,"context":null,"userId":null,"userName":null,"userAvatar":null,"createTime":1767225680123} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotRequest.D1.json new file mode 100644 index 0000000000..c645ca51b5 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotRequest.D1.json @@ -0,0 +1,70 @@ +{ + "applicationId" : "ApplicationHistorySnapshotRequest.applicationId", + "dsl" : { + "ui" : { + "compType" : "normal", + "comp" : { + "title" : "ApplicationHistorySnapshotRequest.dsl.ui.comp.title", + "width" : 1.50 + }, + "items" : [ { + "compType" : "module", + "comp" : { + "appId" : "ApplicationHistorySnapshotRequest.dsl.ui.items[0].comp.appId", + "height" : 40060 + } + }, { + "compType" : "module", + "comp" : { + "appId" : "ApplicationHistorySnapshotRequest.dsl.ui.items[1].comp.appId", + "height" : 40061 + } + }, { + "compType" : "text", + "comp" : { + "text" : "ApplicationHistorySnapshotRequest.dsl.ui.items[2].comp.text", + "hidden" : false + } + } ] + }, + "queries" : [ { + "id" : "ApplicationHistorySnapshotRequest.dsl.queries[0].id", + "gid" : "ApplicationHistorySnapshotRequest.dsl.queries[0].gid", + "name" : "ApplicationHistorySnapshotRequest.dsl.queries[0].name", + "datasourceId" : "ApplicationHistorySnapshotRequest.dsl.queries[0].datasourceId", + "comp" : { + "method" : "ApplicationHistorySnapshotRequest.dsl.queries[0].comp.method", + "path" : "ApplicationHistorySnapshotRequest.dsl.queries[0].comp.path" + }, + "triggerType" : "ApplicationHistorySnapshotRequest.dsl.queries[0].triggerType", + "timeout" : "ApplicationHistorySnapshotRequest.dsl.queries[0].timeout", + "compType" : "restApi" + }, { + "id" : "ApplicationHistorySnapshotRequest.dsl.queries[1].id", + "gid" : "ApplicationHistorySnapshotRequest.dsl.queries[1].gid", + "name" : "ApplicationHistorySnapshotRequest.dsl.queries[1].name", + "datasourceId" : "ApplicationHistorySnapshotRequest.dsl.queries[1].datasourceId", + "comp" : { + "libraryQueryId" : "ApplicationHistorySnapshotRequest.dsl.queries[1].comp.libraryQueryId", + "libraryQueryRecordId" : "ApplicationHistorySnapshotRequest.dsl.queries[1].comp.libraryQueryRecordId" + }, + "triggerType" : "ApplicationHistorySnapshotRequest.dsl.queries[1].triggerType", + "timeout" : "ApplicationHistorySnapshotRequest.dsl.queries[1].timeout", + "compType" : "libraryQuery" + } ], + "settings" : { + "title" : "ApplicationHistorySnapshotRequest.dsl.settings.title", + "maxWidth" : 40062 + } + }, + "context" : { + "operations" : [ { + "type" : "ApplicationHistorySnapshotRequest.context.operations[0].type", + "compName" : "ApplicationHistorySnapshotRequest.context.operations[0].compName" + }, { + "type" : "ApplicationHistorySnapshotRequest.context.operations[1].type", + "compName" : "ApplicationHistorySnapshotRequest.context.operations[1].compName" + } ], + "version" : 40080 + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotRequest.D3.json new file mode 100644 index 0000000000..3ed4994898 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints$ApplicationHistorySnapshotRequest.D3.json @@ -0,0 +1,32 @@ +{ + "applicationId" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "dsl" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "context" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationInfoView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationInfoView.S1.json new file mode 100644 index 0000000000..05924097a7 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationInfoView.S1.json @@ -0,0 +1 @@ +{"orgId":"ApplicationInfoView.orgId","applicationId":"ApplicationInfoView.applicationId","applicationGid":"ApplicationInfoView.applicationGid","name":"ApplicationInfoView.name","createAt":3000000010,"createBy":"ApplicationInfoView.createBy","role":"ApplicationInfoView.role","applicationType":40010,"applicationStatus":"RECYCLED","containerSize":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"folderId":"ApplicationInfoView.folderId","folderIdFrom":"ApplicationInfoView.folderIdFrom","lastViewTime":1767225610123,"lastModifyTime":1767225611123,"lastEditedAt":1767225612123,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":"ApplicationInfoView.editingUserId","title":"ApplicationInfoView.title","description":"ApplicationInfoView.description","category":"ApplicationInfoView.category","icon":"ApplicationInfoView.icon","published":true,"publishedVersion":"ApplicationInfoView.publishedVersion","lastPublishedTime":1767225613.123456789,"folder":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationInfoView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationInfoView.S2.json new file mode 100644 index 0000000000..39130d9a38 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationInfoView.S2.json @@ -0,0 +1 @@ +{"orgId":null,"applicationId":null,"applicationGid":null,"name":null,"createAt":3000000010,"createBy":null,"role":null,"applicationType":40010,"applicationStatus":null,"folderId":null,"lastViewTime":0,"lastModifyTime":0,"lastEditedAt":0,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":null,"published":true,"folder":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPermissionView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPermissionView.S1.json new file mode 100644 index 0000000000..b72587e9ae --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPermissionView.S1.json @@ -0,0 +1 @@ +{"orgName":"ApplicationPermissionView.orgName","groupPermissions":[{"permissionId":"ApplicationPermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"ApplicationPermissionView.groupPermissions[0].id","avatar":"ApplicationPermissionView.groupPermissions[0].avatar","name":"ApplicationPermissionView.groupPermissions[0].name","role":"ApplicationPermissionView.groupPermissions[0].role"},{"permissionId":"ApplicationPermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"ApplicationPermissionView.groupPermissions[1].id","avatar":"ApplicationPermissionView.groupPermissions[1].avatar","name":"ApplicationPermissionView.groupPermissions[1].name","role":"ApplicationPermissionView.groupPermissions[1].role"}],"userPermissions":[{"permissionId":"ApplicationPermissionView.userPermissions[0].permissionId","type":"USER","id":"ApplicationPermissionView.userPermissions[0].id","avatar":"ApplicationPermissionView.userPermissions[0].avatar","name":"ApplicationPermissionView.userPermissions[0].name","role":"ApplicationPermissionView.userPermissions[0].role"},{"permissionId":"ApplicationPermissionView.userPermissions[1].permissionId","type":"USER","id":"ApplicationPermissionView.userPermissions[1].id","avatar":"ApplicationPermissionView.userPermissions[1].avatar","name":"ApplicationPermissionView.userPermissions[1].name","role":"ApplicationPermissionView.userPermissions[1].role"}],"creatorId":"ApplicationPermissionView.creatorId","publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"permissions":[{"permissionId":"ApplicationPermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"ApplicationPermissionView.groupPermissions[0].id","avatar":"ApplicationPermissionView.groupPermissions[0].avatar","name":"ApplicationPermissionView.groupPermissions[0].name","role":"ApplicationPermissionView.groupPermissions[0].role"},{"permissionId":"ApplicationPermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"ApplicationPermissionView.groupPermissions[1].id","avatar":"ApplicationPermissionView.groupPermissions[1].avatar","name":"ApplicationPermissionView.groupPermissions[1].name","role":"ApplicationPermissionView.groupPermissions[1].role"},{"permissionId":"ApplicationPermissionView.userPermissions[0].permissionId","type":"USER","id":"ApplicationPermissionView.userPermissions[0].id","avatar":"ApplicationPermissionView.userPermissions[0].avatar","name":"ApplicationPermissionView.userPermissions[0].name","role":"ApplicationPermissionView.userPermissions[0].role"},{"permissionId":"ApplicationPermissionView.userPermissions[1].permissionId","type":"USER","id":"ApplicationPermissionView.userPermissions[1].id","avatar":"ApplicationPermissionView.userPermissions[1].avatar","name":"ApplicationPermissionView.userPermissions[1].name","role":"ApplicationPermissionView.userPermissions[1].role"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPermissionView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPermissionView.S2.json new file mode 100644 index 0000000000..ed8d969eaf --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPermissionView.S2.json @@ -0,0 +1 @@ +{"orgName":null,"groupPermissions":[{"permissionId":"ApplicationPermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"ApplicationPermissionView.groupPermissions[0].id","avatar":"ApplicationPermissionView.groupPermissions[0].avatar","name":"ApplicationPermissionView.groupPermissions[0].name","role":"ApplicationPermissionView.groupPermissions[0].role"},{"permissionId":"ApplicationPermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"ApplicationPermissionView.groupPermissions[1].id","avatar":"ApplicationPermissionView.groupPermissions[1].avatar","name":"ApplicationPermissionView.groupPermissions[1].name","role":"ApplicationPermissionView.groupPermissions[1].role"}],"userPermissions":[{"permissionId":"ApplicationPermissionView.userPermissions[0].permissionId","type":"USER","id":"ApplicationPermissionView.userPermissions[0].id","avatar":"ApplicationPermissionView.userPermissions[0].avatar","name":"ApplicationPermissionView.userPermissions[0].name","role":"ApplicationPermissionView.userPermissions[0].role"},{"permissionId":"ApplicationPermissionView.userPermissions[1].permissionId","type":"USER","id":"ApplicationPermissionView.userPermissions[1].id","avatar":"ApplicationPermissionView.userPermissions[1].avatar","name":"ApplicationPermissionView.userPermissions[1].name","role":"ApplicationPermissionView.userPermissions[1].role"}],"creatorId":null,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"permissions":[{"permissionId":"ApplicationPermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"ApplicationPermissionView.groupPermissions[0].id","avatar":"ApplicationPermissionView.groupPermissions[0].avatar","name":"ApplicationPermissionView.groupPermissions[0].name","role":"ApplicationPermissionView.groupPermissions[0].role"},{"permissionId":"ApplicationPermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"ApplicationPermissionView.groupPermissions[1].id","avatar":"ApplicationPermissionView.groupPermissions[1].avatar","name":"ApplicationPermissionView.groupPermissions[1].name","role":"ApplicationPermissionView.groupPermissions[1].role"},{"permissionId":"ApplicationPermissionView.userPermissions[0].permissionId","type":"USER","id":"ApplicationPermissionView.userPermissions[0].id","avatar":"ApplicationPermissionView.userPermissions[0].avatar","name":"ApplicationPermissionView.userPermissions[0].name","role":"ApplicationPermissionView.userPermissions[0].role"},{"permissionId":"ApplicationPermissionView.userPermissions[1].permissionId","type":"USER","id":"ApplicationPermissionView.userPermissions[1].id","avatar":"ApplicationPermissionView.userPermissions[1].avatar","name":"ApplicationPermissionView.userPermissions[1].name","role":"ApplicationPermissionView.userPermissions[1].role"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPermissionView.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPermissionView.S2Throwing.json new file mode 100644 index 0000000000..28a540731f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPermissionView.S2Throwing.json @@ -0,0 +1,8 @@ +{ + "groupPermissions" : { + "permissions" : "java.lang.NullPointerException" + }, + "userPermissions" : { + "permissions" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPublishRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPublishRequest.D1.json new file mode 100644 index 0000000000..3ca215e6a7 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPublishRequest.D1.json @@ -0,0 +1,4 @@ +{ + "commitMessage" : "ApplicationPublishRequest.commitMessage", + "tag" : "ApplicationPublishRequest.tag" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPublishRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPublishRequest.D3.json new file mode 100644 index 0000000000..7f8ccd6432 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationPublishRequest.D3.json @@ -0,0 +1,22 @@ +{ + "commitMessage" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "tag" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationRecordMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationRecordMetaView.S1.json new file mode 100644 index 0000000000..ab58a0d83d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationRecordMetaView.S1.json @@ -0,0 +1 @@ +{"id":"ApplicationRecordMetaView.id","applicationId":"ApplicationRecordMetaView.applicationId","tag":"ApplicationRecordMetaView.tag","commitMessage":"ApplicationRecordMetaView.commitMessage","createTime":1767225690123,"creatorName":"ApplicationRecordMetaView.creatorName"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationRecordMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationRecordMetaView.S2.json new file mode 100644 index 0000000000..d2d079443e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationRecordMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"applicationId":null,"tag":null,"commitMessage":null,"createTime":1767225690123,"creatorName":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationView.S1.json new file mode 100644 index 0000000000..1fb7ad3786 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationView.S1.json @@ -0,0 +1 @@ +{"applicationInfoView":{"orgId":"ApplicationView.applicationInfoView.orgId","applicationId":"ApplicationView.applicationInfoView.applicationId","applicationGid":"ApplicationView.applicationInfoView.applicationGid","name":"ApplicationView.applicationInfoView.name","createAt":3000000110,"createBy":"ApplicationView.applicationInfoView.createBy","role":"ApplicationView.applicationInfoView.role","applicationType":40110,"applicationStatus":"RECYCLED","containerSize":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"folderId":"ApplicationView.applicationInfoView.folderId","folderIdFrom":"ApplicationView.applicationInfoView.folderIdFrom","lastViewTime":1767225710123,"lastModifyTime":1767225711123,"lastEditedAt":1767225712123,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":"ApplicationView.applicationInfoView.editingUserId","title":"ApplicationView.applicationInfoView.title","description":"ApplicationView.applicationInfoView.description","category":"ApplicationView.applicationInfoView.category","icon":"ApplicationView.applicationInfoView.icon","published":true,"publishedVersion":"ApplicationView.applicationInfoView.publishedVersion","lastPublishedTime":1767225713.123456789,"folder":false},"applicationDSL":{"ui":{"compType":"normal","comp":{"title":"ApplicationView.applicationDSL.ui.comp.title","width":1.5},"items":[{"compType":"module","comp":{"appId":"ApplicationView.applicationDSL.ui.items[0].comp.appId","height":40060}},{"compType":"module","comp":{"appId":"ApplicationView.applicationDSL.ui.items[1].comp.appId","height":40061}},{"compType":"text","comp":{"text":"ApplicationView.applicationDSL.ui.items[2].comp.text","hidden":false}}]},"queries":[{"id":"ApplicationView.applicationDSL.queries[0].id","gid":"ApplicationView.applicationDSL.queries[0].gid","name":"ApplicationView.applicationDSL.queries[0].name","datasourceId":"ApplicationView.applicationDSL.queries[0].datasourceId","comp":{"method":"ApplicationView.applicationDSL.queries[0].comp.method","path":"ApplicationView.applicationDSL.queries[0].comp.path"},"triggerType":"ApplicationView.applicationDSL.queries[0].triggerType","timeout":"ApplicationView.applicationDSL.queries[0].timeout","compType":"restApi"},{"id":"ApplicationView.applicationDSL.queries[1].id","gid":"ApplicationView.applicationDSL.queries[1].gid","name":"ApplicationView.applicationDSL.queries[1].name","datasourceId":"ApplicationView.applicationDSL.queries[1].datasourceId","comp":{"libraryQueryId":"ApplicationView.applicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"ApplicationView.applicationDSL.queries[1].comp.libraryQueryRecordId"},"triggerType":"ApplicationView.applicationDSL.queries[1].triggerType","timeout":"ApplicationView.applicationDSL.queries[1].timeout","compType":"libraryQuery"}],"settings":{"title":"ApplicationView.applicationDSL.settings.title","maxWidth":40062}},"moduleDSL":{"ApplicationView.moduleDSL.first":{"ui":"ApplicationView.moduleDSL.first.ui","version":40021},"ApplicationView.moduleDSL.second":{"ui":"ApplicationView.moduleDSL.second.ui","version":40021}},"orgCommonSettings":{"themeId":"ApplicationView.orgCommonSettings.themeId","maxRows":40020},"templateId":"ApplicationView.templateId"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationView.S2.json new file mode 100644 index 0000000000..578dd7f72e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.ApplicationView.S2.json @@ -0,0 +1 @@ +{"applicationInfoView":null,"applicationDSL":null,"moduleDSL":null,"orgCommonSettings":null,"templateId":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.HistorySnapshotDslView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.HistorySnapshotDslView.S1.json new file mode 100644 index 0000000000..35af7a786e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.HistorySnapshotDslView.S1.json @@ -0,0 +1 @@ +{"applicationsDsl":{"ui":{"compType":"normal","comp":{"title":"HistorySnapshotDslView.applicationsDsl.ui.comp.title","width":1.5},"items":[{"compType":"module","comp":{"appId":"HistorySnapshotDslView.applicationsDsl.ui.items[0].comp.appId","height":40060}},{"compType":"module","comp":{"appId":"HistorySnapshotDslView.applicationsDsl.ui.items[1].comp.appId","height":40061}},{"compType":"text","comp":{"text":"HistorySnapshotDslView.applicationsDsl.ui.items[2].comp.text","hidden":false}}]},"queries":[{"id":"HistorySnapshotDslView.applicationsDsl.queries[0].id","gid":"HistorySnapshotDslView.applicationsDsl.queries[0].gid","name":"HistorySnapshotDslView.applicationsDsl.queries[0].name","datasourceId":"HistorySnapshotDslView.applicationsDsl.queries[0].datasourceId","comp":{"method":"HistorySnapshotDslView.applicationsDsl.queries[0].comp.method","path":"HistorySnapshotDslView.applicationsDsl.queries[0].comp.path"},"triggerType":"HistorySnapshotDslView.applicationsDsl.queries[0].triggerType","timeout":"HistorySnapshotDslView.applicationsDsl.queries[0].timeout","compType":"restApi"},{"id":"HistorySnapshotDslView.applicationsDsl.queries[1].id","gid":"HistorySnapshotDslView.applicationsDsl.queries[1].gid","name":"HistorySnapshotDslView.applicationsDsl.queries[1].name","datasourceId":"HistorySnapshotDslView.applicationsDsl.queries[1].datasourceId","comp":{"libraryQueryId":"HistorySnapshotDslView.applicationsDsl.queries[1].comp.libraryQueryId","libraryQueryRecordId":"HistorySnapshotDslView.applicationsDsl.queries[1].comp.libraryQueryRecordId"},"triggerType":"HistorySnapshotDslView.applicationsDsl.queries[1].triggerType","timeout":"HistorySnapshotDslView.applicationsDsl.queries[1].timeout","compType":"libraryQuery"}],"settings":{"title":"HistorySnapshotDslView.applicationsDsl.settings.title","maxWidth":40062}},"moduleDSL":{"HistorySnapshotDslView.moduleDSL.first":{"ui":"HistorySnapshotDslView.moduleDSL.first.ui","version":40021},"HistorySnapshotDslView.moduleDSL.second":{"ui":"HistorySnapshotDslView.moduleDSL.second.ui","version":40021}}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.HistorySnapshotDslView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.HistorySnapshotDslView.S2.json new file mode 100644 index 0000000000..cbfcd70737 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.HistorySnapshotDslView.S2.json @@ -0,0 +1 @@ +{"applicationsDsl":null,"moduleDSL":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.MarketplaceApplicationInfoView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.MarketplaceApplicationInfoView.S1.json new file mode 100644 index 0000000000..41acba11d7 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.MarketplaceApplicationInfoView.S1.json @@ -0,0 +1 @@ +{"title":"MarketplaceApplicationInfoView.title","description":"MarketplaceApplicationInfoView.description","category":"MarketplaceApplicationInfoView.category","image":"MarketplaceApplicationInfoView.image","orgId":"MarketplaceApplicationInfoView.orgId","orgName":"MarketplaceApplicationInfoView.orgName","creatorEmail":"MarketplaceApplicationInfoView.creatorEmail","applicationId":"MarketplaceApplicationInfoView.applicationId","name":"MarketplaceApplicationInfoView.name","createAt":3000000030,"createBy":"MarketplaceApplicationInfoView.createBy","applicationType":40030,"applicationStatus":"NORMAL"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.MarketplaceApplicationInfoView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.MarketplaceApplicationInfoView.S2.json new file mode 100644 index 0000000000..52bee27341 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.application.view.MarketplaceApplicationInfoView.S2.json @@ -0,0 +1 @@ +{"title":null,"description":null,"category":null,"image":null,"orgId":null,"orgName":null,"creatorEmail":null,"applicationId":null,"name":null,"createAt":3000000030,"createBy":null,"applicationType":40030,"applicationStatus":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.AuthenticationEndpoints$FormLoginRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.AuthenticationEndpoints$FormLoginRequest.D1.json new file mode 100644 index 0000000000..96ecce2dbb --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.AuthenticationEndpoints$FormLoginRequest.D1.json @@ -0,0 +1,7 @@ +{ + "loginId" : "FormLoginRequest.loginId", + "password" : "SECRET-FormLoginRequest.password", + "register" : true, + "source" : "FormLoginRequest.source", + "authId" : "FormLoginRequest.authId" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.AuthenticationEndpoints$FormLoginRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.AuthenticationEndpoints$FormLoginRequest.D3.json new file mode 100644 index 0000000000..3bccc6a224 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.AuthenticationEndpoints$FormLoginRequest.D3.json @@ -0,0 +1,52 @@ +{ + "loginId" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "password" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "register" : { + "absent" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + } + }, + "source" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "authId" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.APIKeyRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.APIKeyRequest.D1.json new file mode 100644 index 0000000000..d83d2ba426 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.APIKeyRequest.D1.json @@ -0,0 +1,5 @@ +{ + "id" : "APIKeyRequest.id", + "name" : "APIKeyRequest.name", + "description" : "APIKeyRequest.description" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.AuthConfigRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.AuthConfigRequest.D1.json new file mode 100644 index 0000000000..66c1e45b12 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.AuthConfigRequest.D1.json @@ -0,0 +1,24 @@ +{ + "id": "AuthConfigRequest.id", + "authType": "GENERIC", + "source": "AuthConfigRequest.source", + "sourceName": "AuthConfigRequest.sourceName", + "sourceDescription": "AuthConfigRequest.sourceDescription", + "sourceIcon": "AuthConfigRequest.sourceIcon", + "sourceCategory": "AuthConfigRequest.sourceCategory", + "clientId": "AuthConfigRequest.clientId", + "clientSecret": "AuthConfigRequest.clientSecret", + "issuerUri": "AuthConfigRequest.issuerUri", + "authorizationEndpoint": "AuthConfigRequest.authorizationEndpoint", + "tokenEndpoint": "AuthConfigRequest.tokenEndpoint", + "userInfoEndpoint": "AuthConfigRequest.userInfoEndpoint", + "scope": "AuthConfigRequest.scope", + "enableRegister": false, + "userInfoIntrospection": true, + "userCanSelectAccounts": false, + "postForUserEndpoint": true, + "sourceMappings": { + "uid": "AuthConfigRequest.sourceMappings.uid", + "email": "AuthConfigRequest.sourceMappings.email" + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.RedirectView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.RedirectView.S1.json new file mode 100644 index 0000000000..b840370807 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.RedirectView.S1.json @@ -0,0 +1 @@ +{"redirectUri":"https://redirectview.redirecturi.example.com"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.RedirectView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.RedirectView.S2.json new file mode 100644 index 0000000000..4819440649 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.authentication.dto.RedirectView.S2.json @@ -0,0 +1 @@ +{"redirectUri":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BatchAddPermissionRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BatchAddPermissionRequest.D1.json new file mode 100644 index 0000000000..264dae7f09 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BatchAddPermissionRequest.D1.json @@ -0,0 +1,5 @@ +{ + "role" : "editor", + "userIds" : [ "BundleEndpoints.BatchAddPermissionRequest.userIds[0]", "BundleEndpoints.BatchAddPermissionRequest.userIds[1]" ], + "groupIds" : [ "BundleEndpoints.BatchAddPermissionRequest.groupIds[0]", "BundleEndpoints.BatchAddPermissionRequest.groupIds[1]" ] +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BatchAddPermissionRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BatchAddPermissionRequest.D3.json new file mode 100644 index 0000000000..4acb2046ef --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BatchAddPermissionRequest.D3.json @@ -0,0 +1,32 @@ +{ + "role" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "userIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "groupIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundleAsAgencyProfileRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundleAsAgencyProfileRequest.D1.json new file mode 100644 index 0000000000..2df7ef1e07 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundleAsAgencyProfileRequest.D1.json @@ -0,0 +1,3 @@ +{ + "agencyProfile" : true +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundleAsAgencyProfileRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundleAsAgencyProfileRequest.D3.json new file mode 100644 index 0000000000..a2b4ca259f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundleAsAgencyProfileRequest.D3.json @@ -0,0 +1,12 @@ +{ + "agencyProfile" : { + "absent" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToAllRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToAllRequest.D1.json new file mode 100644 index 0000000000..1e3b6a18f5 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToAllRequest.D1.json @@ -0,0 +1,3 @@ +{ + "publicToAll" : true +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToAllRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToAllRequest.D3.json new file mode 100644 index 0000000000..305a787461 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToAllRequest.D3.json @@ -0,0 +1,12 @@ +{ + "publicToAll" : { + "absent" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToMarketplaceRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToMarketplaceRequest.D1.json new file mode 100644 index 0000000000..8420f9c3c3 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToMarketplaceRequest.D1.json @@ -0,0 +1,3 @@ +{ + "publicToMarketplace" : true +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToMarketplaceRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToMarketplaceRequest.D3.json new file mode 100644 index 0000000000..4fb17554ec --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$BundlePublicToMarketplaceRequest.D3.json @@ -0,0 +1,12 @@ +{ + "publicToMarketplace" : { + "absent" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$CreateBundleRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$CreateBundleRequest.D1.json new file mode 100644 index 0000000000..1bff0b6518 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$CreateBundleRequest.D1.json @@ -0,0 +1,10 @@ +{ + "orgId" : "CreateBundleRequest.organizationId", + "gid" : "CreateBundleRequest.gid", + "name" : "CreateBundleRequest.name", + "title" : "CreateBundleRequest.title", + "description" : "CreateBundleRequest.description", + "category" : "CreateBundleRequest.category", + "image" : "CreateBundleRequest.image", + "folderId" : "CreateBundleRequest.folderId" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$CreateBundleRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$CreateBundleRequest.D3.json new file mode 100644 index 0000000000..e69d383725 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$CreateBundleRequest.D3.json @@ -0,0 +1,82 @@ +{ + "orgId" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "gid" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "name" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "title" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "description" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "category" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "image" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "folderId" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$UpdatePermissionRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$UpdatePermissionRequest.D1.json new file mode 100644 index 0000000000..05e7ee4001 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$UpdatePermissionRequest.D1.json @@ -0,0 +1,3 @@ +{ + "role" : "viewer" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$UpdatePermissionRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$UpdatePermissionRequest.D3.json new file mode 100644 index 0000000000..aa8e4e511b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.BundleEndpoints$UpdatePermissionRequest.D3.json @@ -0,0 +1,12 @@ +{ + "role" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundleInfoView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundleInfoView.S1.json new file mode 100644 index 0000000000..e0102d5f26 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundleInfoView.S1.json @@ -0,0 +1 @@ +{"userId":"BundleInfoView.userId","bundleId":"BundleInfoView.bundleId","bundleGid":"BundleInfoView.bundleGid","name":"BundleInfoView.name","title":"BundleInfoView.title","description":"BundleInfoView.description","category":"BundleInfoView.category","image":"BundleInfoView.image","createAt":3000000200,"createBy":"BundleInfoView.createBy","folderId":"BundleInfoView.folderId","folderIdFrom":"BundleInfoView.folderIdFrom","publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingBundleDSL":{"layout":{"items":[{"appId":"BundleInfoView.editingBundleDSL.layout.items[0].appId","order":40200},{"appId":"BundleInfoView.editingBundleDSL.layout.items[1].appId","order":40201}],"width":1.5},"settings":{"title":"BundleInfoView.editingBundleDSL.settings.title","maxWidth":40202}},"publishedBundleDSL":{"layout":{"items":[{"appId":"BundleInfoView.publishedBundleDSL.layout.items[0].appId","order":40200},{"appId":"BundleInfoView.publishedBundleDSL.layout.items[1].appId","order":40201}],"width":1.5},"settings":{"title":"BundleInfoView.publishedBundleDSL.settings.title","maxWidth":40202}},"createTime":1767225800123,"visible":true,"manageable":false,"bundle":true} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundleInfoView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundleInfoView.S2.json new file mode 100644 index 0000000000..5c69a4a01e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundleInfoView.S2.json @@ -0,0 +1 @@ +{"userId":null,"bundleId":null,"bundleGid":null,"name":null,"title":null,"description":null,"category":null,"image":null,"createAt":null,"createBy":null,"folderId":null,"folderIdFrom":null,"publicToAll":null,"publicToMarketplace":null,"agencyProfile":null,"editingBundleDSL":null,"publishedBundleDSL":null,"createTime":0,"visible":true,"manageable":false,"bundle":true} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundlePermissionView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundlePermissionView.S1.json new file mode 100644 index 0000000000..9c611620cc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundlePermissionView.S1.json @@ -0,0 +1 @@ +{"orgName":"BundlePermissionView.orgName","groupPermissions":[{"permissionId":"BundlePermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"BundlePermissionView.groupPermissions[0].id","avatar":"BundlePermissionView.groupPermissions[0].avatar","name":"BundlePermissionView.groupPermissions[0].name","role":"BundlePermissionView.groupPermissions[0].role"},{"permissionId":"BundlePermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"BundlePermissionView.groupPermissions[1].id","avatar":"BundlePermissionView.groupPermissions[1].avatar","name":"BundlePermissionView.groupPermissions[1].name","role":"BundlePermissionView.groupPermissions[1].role"}],"userPermissions":[{"permissionId":"BundlePermissionView.userPermissions[0].permissionId","type":"USER","id":"BundlePermissionView.userPermissions[0].id","avatar":"BundlePermissionView.userPermissions[0].avatar","name":"BundlePermissionView.userPermissions[0].name","role":"BundlePermissionView.userPermissions[0].role"},{"permissionId":"BundlePermissionView.userPermissions[1].permissionId","type":"USER","id":"BundlePermissionView.userPermissions[1].id","avatar":"BundlePermissionView.userPermissions[1].avatar","name":"BundlePermissionView.userPermissions[1].name","role":"BundlePermissionView.userPermissions[1].role"}],"creatorId":"BundlePermissionView.creatorId","publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"permissions":[{"permissionId":"BundlePermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"BundlePermissionView.groupPermissions[0].id","avatar":"BundlePermissionView.groupPermissions[0].avatar","name":"BundlePermissionView.groupPermissions[0].name","role":"BundlePermissionView.groupPermissions[0].role"},{"permissionId":"BundlePermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"BundlePermissionView.groupPermissions[1].id","avatar":"BundlePermissionView.groupPermissions[1].avatar","name":"BundlePermissionView.groupPermissions[1].name","role":"BundlePermissionView.groupPermissions[1].role"},{"permissionId":"BundlePermissionView.userPermissions[0].permissionId","type":"USER","id":"BundlePermissionView.userPermissions[0].id","avatar":"BundlePermissionView.userPermissions[0].avatar","name":"BundlePermissionView.userPermissions[0].name","role":"BundlePermissionView.userPermissions[0].role"},{"permissionId":"BundlePermissionView.userPermissions[1].permissionId","type":"USER","id":"BundlePermissionView.userPermissions[1].id","avatar":"BundlePermissionView.userPermissions[1].avatar","name":"BundlePermissionView.userPermissions[1].name","role":"BundlePermissionView.userPermissions[1].role"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundlePermissionView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundlePermissionView.S2.json new file mode 100644 index 0000000000..0e224a78b7 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundlePermissionView.S2.json @@ -0,0 +1 @@ +{"orgName":null,"groupPermissions":[{"permissionId":"BundlePermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"BundlePermissionView.groupPermissions[0].id","avatar":"BundlePermissionView.groupPermissions[0].avatar","name":"BundlePermissionView.groupPermissions[0].name","role":"BundlePermissionView.groupPermissions[0].role"},{"permissionId":"BundlePermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"BundlePermissionView.groupPermissions[1].id","avatar":"BundlePermissionView.groupPermissions[1].avatar","name":"BundlePermissionView.groupPermissions[1].name","role":"BundlePermissionView.groupPermissions[1].role"}],"userPermissions":[{"permissionId":"BundlePermissionView.userPermissions[0].permissionId","type":"USER","id":"BundlePermissionView.userPermissions[0].id","avatar":"BundlePermissionView.userPermissions[0].avatar","name":"BundlePermissionView.userPermissions[0].name","role":"BundlePermissionView.userPermissions[0].role"},{"permissionId":"BundlePermissionView.userPermissions[1].permissionId","type":"USER","id":"BundlePermissionView.userPermissions[1].id","avatar":"BundlePermissionView.userPermissions[1].avatar","name":"BundlePermissionView.userPermissions[1].name","role":"BundlePermissionView.userPermissions[1].role"}],"creatorId":null,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"permissions":[{"permissionId":"BundlePermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"BundlePermissionView.groupPermissions[0].id","avatar":"BundlePermissionView.groupPermissions[0].avatar","name":"BundlePermissionView.groupPermissions[0].name","role":"BundlePermissionView.groupPermissions[0].role"},{"permissionId":"BundlePermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"BundlePermissionView.groupPermissions[1].id","avatar":"BundlePermissionView.groupPermissions[1].avatar","name":"BundlePermissionView.groupPermissions[1].name","role":"BundlePermissionView.groupPermissions[1].role"},{"permissionId":"BundlePermissionView.userPermissions[0].permissionId","type":"USER","id":"BundlePermissionView.userPermissions[0].id","avatar":"BundlePermissionView.userPermissions[0].avatar","name":"BundlePermissionView.userPermissions[0].name","role":"BundlePermissionView.userPermissions[0].role"},{"permissionId":"BundlePermissionView.userPermissions[1].permissionId","type":"USER","id":"BundlePermissionView.userPermissions[1].id","avatar":"BundlePermissionView.userPermissions[1].avatar","name":"BundlePermissionView.userPermissions[1].name","role":"BundlePermissionView.userPermissions[1].role"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundlePermissionView.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundlePermissionView.S2Throwing.json new file mode 100644 index 0000000000..28a540731f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.BundlePermissionView.S2Throwing.json @@ -0,0 +1,8 @@ +{ + "groupPermissions" : { + "permissions" : "java.lang.NullPointerException" + }, + "userPermissions" : { + "permissions" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.MarketplaceBundleInfoView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.MarketplaceBundleInfoView.S1.json new file mode 100644 index 0000000000..247bf5ea25 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.MarketplaceBundleInfoView.S1.json @@ -0,0 +1 @@ +{"title":"MarketplaceBundleInfoView.title","description":"MarketplaceBundleInfoView.description","category":"MarketplaceBundleInfoView.category","image":"MarketplaceBundleInfoView.image","orgId":"MarketplaceBundleInfoView.orgId","orgName":"MarketplaceBundleInfoView.orgName","creatorEmail":"MarketplaceBundleInfoView.creatorEmail","bundleId":"MarketplaceBundleInfoView.bundleId","bundleGid":"MarketplaceBundleInfoView.bundleGid","name":"MarketplaceBundleInfoView.name","createAt":3000000220,"createBy":"MarketplaceBundleInfoView.createBy","bundleStatus":"NORMAL"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.MarketplaceBundleInfoView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.MarketplaceBundleInfoView.S2.json new file mode 100644 index 0000000000..21ba4c075f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.bundle.view.MarketplaceBundleInfoView.S2.json @@ -0,0 +1 @@ +{"title":null,"description":null,"category":null,"image":null,"orgId":null,"orgName":null,"creatorEmail":null,"bundleId":null,"bundleGid":null,"name":null,"createAt":3000000220,"createBy":null,"bundleStatus":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigEndpoints$UpdateConfigRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigEndpoints$UpdateConfigRequest.D1.json new file mode 100644 index 0000000000..62559a5656 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigEndpoints$UpdateConfigRequest.D1.json @@ -0,0 +1,3 @@ +{ + "value" : "UpdateConfigRequest.value" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigEndpoints$UpdateConfigRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigEndpoints$UpdateConfigRequest.D3.json new file mode 100644 index 0000000000..305980e8a6 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigEndpoints$UpdateConfigRequest.D3.json @@ -0,0 +1,12 @@ +{ + "value" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S1.json new file mode 100644 index 0000000000..9320b02064 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S1.json @@ -0,0 +1 @@ +{"authConfigs":[{"authType":"FORM","id":"ConfigView.authConfigs[0].id","enable":true,"enableRegister":false,"source":"ConfigView.authConfigs[0].source","sourceName":"ConfigView.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"ConfigView.authConfigs[1].id","source":"ConfigView.authConfigs[1].source","sourceName":"ConfigView.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"ConfigView.authConfigs[1].clientId","clientSecret":"SECRET-ConfigView.authConfigs[1].clientSecret","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=ConfigView.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"ConfigView.authConfigs[2].id","source":"ConfigView.authConfigs[2].source","sourceName":"ConfigView.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"ConfigView.authConfigs[2].clientId","clientSecret":"SECRET-ConfigView.authConfigs[2].clientSecret","sourceDescription":"ConfigView.authConfigs[2].sourceDescription","sourceIcon":"ConfigView.authConfigs[2].sourceIcon","sourceCategory":"ConfigView.authConfigs[2].sourceCategory","issuerUri":"ConfigView.authConfigs[2].issuerUri","authorizationEndpoint":"ConfigView.authConfigs[2].authorizationEndpoint","tokenEndpoint":"ConfigView.authConfigs[2].tokenEndpoint","userInfoEndpoint":"ConfigView.authConfigs[2].userInfoEndpoint","scope":"ConfigView.authConfigs[2].scope","sourceMappings":{"uid":"ConfigView.authConfigs[2].sourceMappings.uid","email":"ConfigView.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"ConfigView.authConfigs[2].authorizationEndpoint?response_type=code&client_id=ConfigView.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=ConfigView.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"ConfigView.authConfigs[3].id","source":"ConfigView.authConfigs[3].source","sourceName":"ConfigView.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"ConfigView.authConfigs[3].clientId","clientSecret":"SECRET-ConfigView.authConfigs[3].clientSecret","baseUrl":"ConfigView.authConfigs[3].baseUrl","realm":"ConfigView.authConfigs[3].realm","scope":"ConfigView.authConfigs[3].scope","authorizeUrl":"ConfigView.authConfigs[3].baseUrl/realms/ConfigView.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=ConfigView.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=ConfigView.authConfigs[3].scope"},{"authType":"ORY","id":"ConfigView.authConfigs[4].id","source":"ConfigView.authConfigs[4].source","sourceName":"ConfigView.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"ConfigView.authConfigs[4].clientId","clientSecret":"SECRET-ConfigView.authConfigs[4].clientSecret","baseUrl":"ConfigView.authConfigs[4].baseUrl","scope":"ConfigView.authConfigs[4].scope","authorizeUrl":"ConfigView.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=ConfigView.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=ConfigView.authConfigs[4].scope"}],"workspaceMode":"ENTERPRISE","selfDomain":true,"cookieName":"ConfigView.cookieName","cloudHosting":true} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S1Internal.json new file mode 100644 index 0000000000..1fa9b3423f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S1Internal.json @@ -0,0 +1 @@ +{"authConfigs":[{"authType":"FORM","id":"ConfigView.authConfigs[0].id","enable":true,"enableRegister":false,"source":"ConfigView.authConfigs[0].source","sourceName":"ConfigView.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"ConfigView.authConfigs[1].id","source":"ConfigView.authConfigs[1].source","sourceName":"ConfigView.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"ConfigView.authConfigs[1].clientId","clientSecret":"SECRET-ConfigView.authConfigs[1].clientSecret"},{"authType":"GENERIC","id":"ConfigView.authConfigs[2].id","source":"ConfigView.authConfigs[2].source","sourceName":"ConfigView.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"ConfigView.authConfigs[2].clientId","clientSecret":"SECRET-ConfigView.authConfigs[2].clientSecret","sourceDescription":"ConfigView.authConfigs[2].sourceDescription","sourceIcon":"ConfigView.authConfigs[2].sourceIcon","sourceCategory":"ConfigView.authConfigs[2].sourceCategory","issuerUri":"ConfigView.authConfigs[2].issuerUri","authorizationEndpoint":"ConfigView.authConfigs[2].authorizationEndpoint","tokenEndpoint":"ConfigView.authConfigs[2].tokenEndpoint","userInfoEndpoint":"ConfigView.authConfigs[2].userInfoEndpoint","scope":"ConfigView.authConfigs[2].scope","sourceMappings":{"uid":"ConfigView.authConfigs[2].sourceMappings.uid","email":"ConfigView.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true},{"authType":"KEYCLOAK","id":"ConfigView.authConfigs[3].id","source":"ConfigView.authConfigs[3].source","sourceName":"ConfigView.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"ConfigView.authConfigs[3].clientId","clientSecret":"SECRET-ConfigView.authConfigs[3].clientSecret","baseUrl":"ConfigView.authConfigs[3].baseUrl","realm":"ConfigView.authConfigs[3].realm","scope":"ConfigView.authConfigs[3].scope"},{"authType":"ORY","id":"ConfigView.authConfigs[4].id","source":"ConfigView.authConfigs[4].source","sourceName":"ConfigView.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"ConfigView.authConfigs[4].clientId","clientSecret":"SECRET-ConfigView.authConfigs[4].clientSecret","baseUrl":"ConfigView.authConfigs[4].baseUrl","scope":"ConfigView.authConfigs[4].scope"}],"workspaceMode":"ENTERPRISE","selfDomain":true,"cookieName":"ConfigView.cookieName","cloudHosting":true} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S1Public.json new file mode 100644 index 0000000000..d46546cf4c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S1Public.json @@ -0,0 +1 @@ +{"authConfigs":[{"authType":"FORM","id":"ConfigView.authConfigs[0].id","enable":true,"enableRegister":false,"source":"ConfigView.authConfigs[0].source","sourceName":"ConfigView.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"ConfigView.authConfigs[1].id","source":"ConfigView.authConfigs[1].source","sourceName":"ConfigView.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"ConfigView.authConfigs[1].clientId","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=ConfigView.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"ConfigView.authConfigs[2].id","source":"ConfigView.authConfigs[2].source","sourceName":"ConfigView.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"ConfigView.authConfigs[2].clientId","sourceDescription":"ConfigView.authConfigs[2].sourceDescription","sourceIcon":"ConfigView.authConfigs[2].sourceIcon","sourceCategory":"ConfigView.authConfigs[2].sourceCategory","issuerUri":"ConfigView.authConfigs[2].issuerUri","authorizationEndpoint":"ConfigView.authConfigs[2].authorizationEndpoint","tokenEndpoint":"ConfigView.authConfigs[2].tokenEndpoint","userInfoEndpoint":"ConfigView.authConfigs[2].userInfoEndpoint","scope":"ConfigView.authConfigs[2].scope","sourceMappings":{"uid":"ConfigView.authConfigs[2].sourceMappings.uid","email":"ConfigView.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"ConfigView.authConfigs[2].authorizationEndpoint?response_type=code&client_id=ConfigView.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=ConfigView.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"ConfigView.authConfigs[3].id","source":"ConfigView.authConfigs[3].source","sourceName":"ConfigView.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"ConfigView.authConfigs[3].clientId","baseUrl":"ConfigView.authConfigs[3].baseUrl","realm":"ConfigView.authConfigs[3].realm","scope":"ConfigView.authConfigs[3].scope","authorizeUrl":"ConfigView.authConfigs[3].baseUrl/realms/ConfigView.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=ConfigView.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=ConfigView.authConfigs[3].scope"},{"authType":"ORY","id":"ConfigView.authConfigs[4].id","source":"ConfigView.authConfigs[4].source","sourceName":"ConfigView.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"ConfigView.authConfigs[4].clientId","baseUrl":"ConfigView.authConfigs[4].baseUrl","scope":"ConfigView.authConfigs[4].scope","authorizeUrl":"ConfigView.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=ConfigView.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=ConfigView.authConfigs[4].scope"}],"workspaceMode":"ENTERPRISE","selfDomain":true,"cookieName":"ConfigView.cookieName","cloudHosting":true} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S2.json new file mode 100644 index 0000000000..deeea4c798 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.config.ConfigView.S2.json @@ -0,0 +1 @@ +{"authConfigs":null,"workspaceMode":null,"selfDomain":true,"cookieName":null,"cloudHosting":true} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$BatchAddPermissionRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$BatchAddPermissionRequest.D1.json new file mode 100644 index 0000000000..457a60fbc8 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$BatchAddPermissionRequest.D1.json @@ -0,0 +1,11 @@ +{ + "role" : "editor", + "userIds" : [ + "DatasourceEndpoints.BatchAddPermissionRequest.userIds[0]", + "DatasourceEndpoints.BatchAddPermissionRequest.userIds[1]" + ], + "groupIds" : [ + "DatasourceEndpoints.BatchAddPermissionRequest.groupIds[0]", + "DatasourceEndpoints.BatchAddPermissionRequest.groupIds[1]" + ] +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$BatchAddPermissionRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$BatchAddPermissionRequest.D3.json new file mode 100644 index 0000000000..4acb2046ef --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$BatchAddPermissionRequest.D3.json @@ -0,0 +1,32 @@ +{ + "role" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "userIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "groupIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$UpdatePermissionRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$UpdatePermissionRequest.D1.json new file mode 100644 index 0000000000..05e7ee4001 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$UpdatePermissionRequest.D1.json @@ -0,0 +1,3 @@ +{ + "role" : "viewer" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$UpdatePermissionRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$UpdatePermissionRequest.D3.json new file mode 100644 index 0000000000..aa8e4e511b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceEndpoints$UpdatePermissionRequest.D3.json @@ -0,0 +1,12 @@ +{ + "role" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S1.json new file mode 100644 index 0000000000..8bfa9582fe --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S1.json @@ -0,0 +1 @@ +{"datasource":{"id":"DatasourceView.datasource.id","createdBy":"DatasourceView.datasource.createdBy","gid":"DatasourceView.datasource.gid","name":"DatasourceView.datasource.name","type":"DatasourceView.datasource.type","organizationId":"DatasourceView.datasource.organizationId","creationSource":40501,"datasourceStatus":"DELETED","pluginDefinition":{"name":"DatasourceView.datasource.pluginDefinition.name","dataSourceConfig":{"type":"DatasourceView.datasource.pluginDefinition.dataSourceConfig.type","params":["DatasourceView.datasource.pluginDefinition.dataSourceConfig.params[0]","DatasourceView.datasource.pluginDefinition.dataSourceConfig.params[1]"]},"id":"DatasourceView.datasource.pluginDefinition.id"},"createTime":1767226100123,"datasourceConfig":{"host":"DatasourceView.datasource.detailConfig.host","password":"SECRET-DatasourceView.datasource.detailConfig.password"}},"edit":true,"creatorName":"DatasourceView.creatorName"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S1Internal.json new file mode 100644 index 0000000000..d6ae9ca089 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S1Internal.json @@ -0,0 +1 @@ +{"datasource":{"id":"DatasourceView.datasource.id","createdBy":"DatasourceView.datasource.createdBy","gid":"DatasourceView.datasource.gid","name":"DatasourceView.datasource.name","type":"DatasourceView.datasource.type","organizationId":"DatasourceView.datasource.organizationId","creationSource":40501,"datasourceStatus":"DELETED","pluginDefinition":{"name":"DatasourceView.datasource.pluginDefinition.name","dataSourceConfig":{"type":"DatasourceView.datasource.pluginDefinition.dataSourceConfig.type","params":["DatasourceView.datasource.pluginDefinition.dataSourceConfig.params[0]","DatasourceView.datasource.pluginDefinition.dataSourceConfig.params[1]"]},"id":"DatasourceView.datasource.pluginDefinition.id"},"createTime":1767226100123,"datasourceConfig":{"password":"SECRET-DatasourceView.datasource.detailConfig.password"}},"edit":true,"creatorName":"DatasourceView.creatorName"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S1Public.json new file mode 100644 index 0000000000..e3e971b1be --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S1Public.json @@ -0,0 +1 @@ +{"datasource":{"id":"DatasourceView.datasource.id","createdBy":"DatasourceView.datasource.createdBy","gid":"DatasourceView.datasource.gid","name":"DatasourceView.datasource.name","type":"DatasourceView.datasource.type","organizationId":"DatasourceView.datasource.organizationId","creationSource":40501,"datasourceStatus":"DELETED","pluginDefinition":{"name":"DatasourceView.datasource.pluginDefinition.name","dataSourceConfig":{"type":"DatasourceView.datasource.pluginDefinition.dataSourceConfig.type","params":["DatasourceView.datasource.pluginDefinition.dataSourceConfig.params[0]","DatasourceView.datasource.pluginDefinition.dataSourceConfig.params[1]"]},"id":"DatasourceView.datasource.pluginDefinition.id"},"createTime":1767226100123,"datasourceConfig":{"host":"DatasourceView.datasource.detailConfig.host"}},"edit":true,"creatorName":"DatasourceView.creatorName"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S2.json new file mode 100644 index 0000000000..1f68eef9ee --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.DatasourceView.S2.json @@ -0,0 +1 @@ +{"datasource":null,"edit":true,"creatorName":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.UpsertDatasourceRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.UpsertDatasourceRequest.D1.json new file mode 100644 index 0000000000..2649ca3fc1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.datasource.UpsertDatasourceRequest.D1.json @@ -0,0 +1,23 @@ +{ + "id" : "UpsertDatasourceRequest.id", + "gid" : "UpsertDatasourceRequest.gid", + "name" : "UpsertDatasourceRequest.name", + "type" : "UpsertDatasourceRequest.type", + "organizationId" : "UpsertDatasourceRequest.organizationId", + "status" : "DELETED", + "datasourceConfig" : { + "host" : "UpsertDatasourceRequest.datasourceConfig.host", + "port" : 40503, + "timeoutMillis" : 3000000502, + "ratio" : 2.5, + "ssl" : true, + "databases" : [ + "UpsertDatasourceRequest.datasourceConfig.databases[0]", + "UpsertDatasourceRequest.datasourceConfig.databases[1]" + ], + "extra" : { + "region" : "UpsertDatasourceRequest.datasourceConfig.extra.region", + "retries" : 40503 + } + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$BatchAddPermissionRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$BatchAddPermissionRequest.D1.json new file mode 100644 index 0000000000..2f68d758e8 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$BatchAddPermissionRequest.D1.json @@ -0,0 +1,5 @@ +{ + "role" : "editor", + "userIds" : [ "FolderEndpoints.BatchAddPermissionRequest.userIds[0]", "FolderEndpoints.BatchAddPermissionRequest.userIds[1]" ], + "groupIds" : [ "FolderEndpoints.BatchAddPermissionRequest.groupIds[0]", "FolderEndpoints.BatchAddPermissionRequest.groupIds[1]" ] +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$BatchAddPermissionRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$BatchAddPermissionRequest.D3.json new file mode 100644 index 0000000000..4acb2046ef --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$BatchAddPermissionRequest.D3.json @@ -0,0 +1,32 @@ +{ + "role" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "userIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "groupIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$UpdatePermissionRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$UpdatePermissionRequest.D1.json new file mode 100644 index 0000000000..05e7ee4001 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$UpdatePermissionRequest.D1.json @@ -0,0 +1,3 @@ +{ + "role" : "viewer" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$UpdatePermissionRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$UpdatePermissionRequest.D3.json new file mode 100644 index 0000000000..aa8e4e511b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderEndpoints$UpdatePermissionRequest.D3.json @@ -0,0 +1,12 @@ +{ + "role" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderInfoView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderInfoView.S1.json new file mode 100644 index 0000000000..1de959b752 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderInfoView.S1.json @@ -0,0 +1 @@ +{"orgId":"FolderInfoView.orgId","folderId":"FolderInfoView.folderId","folderGid":"FolderInfoView.folderGid","parentFolderId":"FolderInfoView.parentFolderId","parentFolderGid":"FolderInfoView.parentFolderGid","name":"FolderInfoView.name","title":"FolderInfoView.title","description":"FolderInfoView.description","category":"FolderInfoView.category","type":"FolderInfoView.type","image":"FolderInfoView.image","createAt":3000000040,"createBy":"FolderInfoView.createBy","subFolders":[{"orgId":"FolderInfoView.subFolders[0].orgId","folderId":"FolderInfoView.subFolders[0].folderId","folderGid":"FolderInfoView.subFolders[0].folderGid","parentFolderId":"FolderInfoView.subFolders[0].parentFolderId","parentFolderGid":"FolderInfoView.subFolders[0].parentFolderGid","name":"FolderInfoView.subFolders[0].name","title":"FolderInfoView.subFolders[0].title","description":"FolderInfoView.subFolders[0].description","category":"FolderInfoView.subFolders[0].category","type":"FolderInfoView.subFolders[0].type","image":"FolderInfoView.subFolders[0].image","createAt":3000000041,"createBy":"FolderInfoView.subFolders[0].createBy","subFolders":null,"subApplications":null,"createTime":1767225641123,"lastViewTime":1767225642123,"visible":true,"folder":true,"manageable":false},{"orgId":"FolderInfoView.subFolders[1].orgId","folderId":"FolderInfoView.subFolders[1].folderId","folderGid":"FolderInfoView.subFolders[1].folderGid","parentFolderId":"FolderInfoView.subFolders[1].parentFolderId","parentFolderGid":"FolderInfoView.subFolders[1].parentFolderGid","name":"FolderInfoView.subFolders[1].name","title":"FolderInfoView.subFolders[1].title","description":"FolderInfoView.subFolders[1].description","category":"FolderInfoView.subFolders[1].category","type":"FolderInfoView.subFolders[1].type","image":"FolderInfoView.subFolders[1].image","createAt":3000000042,"createBy":"FolderInfoView.subFolders[1].createBy","subFolders":null,"subApplications":null,"createTime":1767225642123,"lastViewTime":1767225643123,"visible":true,"folder":true,"manageable":false}],"subApplications":[{"orgId":"FolderInfoView.subApplications[0].orgId","applicationId":"FolderInfoView.subApplications[0].applicationId","applicationGid":"FolderInfoView.subApplications[0].applicationGid","name":"FolderInfoView.subApplications[0].name","createAt":3000000210,"createBy":"FolderInfoView.subApplications[0].createBy","role":"FolderInfoView.subApplications[0].role","applicationType":40210,"applicationStatus":"RECYCLED","containerSize":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"folderId":"FolderInfoView.subApplications[0].folderId","folderIdFrom":"FolderInfoView.subApplications[0].folderIdFrom","lastViewTime":1767225810123,"lastModifyTime":1767225811123,"lastEditedAt":1767225812123,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":"FolderInfoView.subApplications[0].editingUserId","title":"FolderInfoView.subApplications[0].title","description":"FolderInfoView.subApplications[0].description","category":"FolderInfoView.subApplications[0].category","icon":"FolderInfoView.subApplications[0].icon","published":true,"publishedVersion":"FolderInfoView.subApplications[0].publishedVersion","lastPublishedTime":1767225813.123456789,"folder":false},{"orgId":"FolderInfoView.subApplications[1].orgId","applicationId":"FolderInfoView.subApplications[1].applicationId","applicationGid":"FolderInfoView.subApplications[1].applicationGid","name":"FolderInfoView.subApplications[1].name","createAt":3000000310,"createBy":"FolderInfoView.subApplications[1].createBy","role":"FolderInfoView.subApplications[1].role","applicationType":40310,"applicationStatus":"RECYCLED","containerSize":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"folderId":"FolderInfoView.subApplications[1].folderId","folderIdFrom":"FolderInfoView.subApplications[1].folderIdFrom","lastViewTime":1767225910123,"lastModifyTime":1767225911123,"lastEditedAt":1767225912123,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":"FolderInfoView.subApplications[1].editingUserId","title":"FolderInfoView.subApplications[1].title","description":"FolderInfoView.subApplications[1].description","category":"FolderInfoView.subApplications[1].category","icon":"FolderInfoView.subApplications[1].icon","published":true,"publishedVersion":"FolderInfoView.subApplications[1].publishedVersion","lastPublishedTime":1767225913.123456789,"folder":false}],"createTime":1767225640123,"lastViewTime":1767225641123,"visible":true,"folder":true,"manageable":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderInfoView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderInfoView.S2.json new file mode 100644 index 0000000000..107e112561 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.FolderInfoView.S2.json @@ -0,0 +1 @@ +{"orgId":null,"folderId":null,"folderGid":null,"parentFolderId":null,"parentFolderGid":null,"name":null,"title":null,"description":null,"category":null,"type":null,"image":null,"createAt":null,"createBy":null,"subFolders":null,"subApplications":null,"createTime":0,"lastViewTime":0,"visible":true,"folder":true,"manageable":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S1.json new file mode 100644 index 0000000000..fd8322591e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S1.json @@ -0,0 +1 @@ +{"user":{"id":"User.id","createdBy":"User.createdBy","name":"User.name","email":"User.email","uiLanguage":"User.uiLanguage","avatar":"User.avatar","tpAvatarLink":"User.tpAvatarLink","superAdmin":true,"state":"INVITED","isEnabled":false,"activeAuthId":"User.activeAuthId","passwordResetToken":"KNOWN-EXPOSURE-User.passwordResetToken","passwordResetTokenExpiry":1767225660.123456789,"isAnonymous":true,"connections":[{"authId":"User.connections[0].authId","source":"User.connections[0].source","name":"User.connections[0].name","email":"User.connections[0].email","avatar":"User.connections[0].avatar","rawUserInfo":{"sub":"User.connections[0].rawUserInfo.sub","loginCount":40070},"tokens":["User.connections[0].tokens[0]","User.connections[0].tokens[1]"]},{"authId":"User.connections[1].authId","source":"User.connections[1].source","name":"User.connections[1].name","email":"User.connections[1].email","avatar":"User.connections[1].avatar","rawUserInfo":{"sub":"User.connections[1].rawUserInfo.sub","loginCount":40071},"tokens":["User.connections[1].tokens[0]","User.connections[1].tokens[1]"]}],"apiKeysList":[{"id":"User.apiKeysList[0].id","name":"User.apiKeysList[0].name","description":"User.apiKeysList[0].description","token":"KNOWN-EXPOSURE-User.apiKeysList[0].token"},{"id":"User.apiKeysList[1].id","name":"User.apiKeysList[1].name","description":"User.apiKeysList[1].description","token":"KNOWN-EXPOSURE-User.apiKeysList[1].token"}],"apiKeys":[{"id":"User.apiKeys[0].id","token":"KNOWN-EXPOSURE-User.apiKeys[0].token"},{"id":"User.apiKeys[1].id","token":"KNOWN-EXPOSURE-User.apiKeys[1].token"}],"hasSetNickname":true,"orgTransformedUserInfo":{"User.orgTransformedUserInfo.second":{"updateTime":3000000091,"extra":{"department":"User.orgTransformedUserInfo.second.extra.department","level":40091}},"User.orgTransformedUserInfo.first":{"updateTime":3000000090,"extra":{"department":"User.orgTransformedUserInfo.first.extra.department","level":40090}}}},"organization":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"SECRET-Organization.authConfigs[1].clientSecret","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Organization.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Organization.authConfigs[2].authorizationEndpoint?response_type=code&client_id=Organization.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope","authorizeUrl":"Organization.authConfigs[3].baseUrl/realms/Organization.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=Organization.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope","authorizeUrl":"Organization.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=Organization.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[4].scope"}]},"folderInfoViews":[{"orgId":"UserHomepageView.folderInfoViews[0].orgId","folderId":"UserHomepageView.folderInfoViews[0].folderId","folderGid":"UserHomepageView.folderInfoViews[0].folderGid","parentFolderId":"UserHomepageView.folderInfoViews[0].parentFolderId","parentFolderGid":"UserHomepageView.folderInfoViews[0].parentFolderGid","name":"UserHomepageView.folderInfoViews[0].name","title":"UserHomepageView.folderInfoViews[0].title","description":"UserHomepageView.folderInfoViews[0].description","category":"UserHomepageView.folderInfoViews[0].category","type":"UserHomepageView.folderInfoViews[0].type","image":"UserHomepageView.folderInfoViews[0].image","createAt":3000000043,"createBy":"UserHomepageView.folderInfoViews[0].createBy","subFolders":null,"subApplications":null,"createTime":1767225643123,"lastViewTime":1767225644123,"visible":true,"folder":true,"manageable":false},{"orgId":"UserHomepageView.folderInfoViews[1].orgId","folderId":"UserHomepageView.folderInfoViews[1].folderId","folderGid":"UserHomepageView.folderInfoViews[1].folderGid","parentFolderId":"UserHomepageView.folderInfoViews[1].parentFolderId","parentFolderGid":"UserHomepageView.folderInfoViews[1].parentFolderGid","name":"UserHomepageView.folderInfoViews[1].name","title":"UserHomepageView.folderInfoViews[1].title","description":"UserHomepageView.folderInfoViews[1].description","category":"UserHomepageView.folderInfoViews[1].category","type":"UserHomepageView.folderInfoViews[1].type","image":"UserHomepageView.folderInfoViews[1].image","createAt":3000000044,"createBy":"UserHomepageView.folderInfoViews[1].createBy","subFolders":null,"subApplications":null,"createTime":1767225644123,"lastViewTime":1767225645123,"visible":true,"folder":true,"manageable":false}],"homeApplicationViews":[{"orgId":"UserHomepageView.homeApplicationViews[0].orgId","applicationId":"UserHomepageView.homeApplicationViews[0].applicationId","applicationGid":"UserHomepageView.homeApplicationViews[0].applicationGid","name":"UserHomepageView.homeApplicationViews[0].name","createAt":3000000410,"createBy":"UserHomepageView.homeApplicationViews[0].createBy","role":"UserHomepageView.homeApplicationViews[0].role","applicationType":40410,"applicationStatus":"RECYCLED","containerSize":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"folderId":"UserHomepageView.homeApplicationViews[0].folderId","folderIdFrom":"UserHomepageView.homeApplicationViews[0].folderIdFrom","lastViewTime":1767226010123,"lastModifyTime":1767226011123,"lastEditedAt":1767226012123,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":"UserHomepageView.homeApplicationViews[0].editingUserId","title":"UserHomepageView.homeApplicationViews[0].title","description":"UserHomepageView.homeApplicationViews[0].description","category":"UserHomepageView.homeApplicationViews[0].category","icon":"UserHomepageView.homeApplicationViews[0].icon","published":true,"publishedVersion":"UserHomepageView.homeApplicationViews[0].publishedVersion","lastPublishedTime":1767226013.123456789,"folder":false},{"orgId":"UserHomepageView.homeApplicationViews[1].orgId","applicationId":"UserHomepageView.homeApplicationViews[1].applicationId","applicationGid":"UserHomepageView.homeApplicationViews[1].applicationGid","name":"UserHomepageView.homeApplicationViews[1].name","createAt":3000000510,"createBy":"UserHomepageView.homeApplicationViews[1].createBy","role":"UserHomepageView.homeApplicationViews[1].role","applicationType":40510,"applicationStatus":"RECYCLED","containerSize":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"folderId":"UserHomepageView.homeApplicationViews[1].folderId","folderIdFrom":"UserHomepageView.homeApplicationViews[1].folderIdFrom","lastViewTime":1767226110123,"lastModifyTime":1767226111123,"lastEditedAt":1767226112123,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":"UserHomepageView.homeApplicationViews[1].editingUserId","title":"UserHomepageView.homeApplicationViews[1].title","description":"UserHomepageView.homeApplicationViews[1].description","category":"UserHomepageView.homeApplicationViews[1].category","icon":"UserHomepageView.homeApplicationViews[1].icon","published":true,"publishedVersion":"UserHomepageView.homeApplicationViews[1].publishedVersion","lastPublishedTime":1767226113.123456789,"folder":false}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S1Internal.json new file mode 100644 index 0000000000..8d87e8d965 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S1Internal.json @@ -0,0 +1 @@ +{"user":{"id":"User.id","createdBy":"User.createdBy","name":"User.name","email":"User.email","uiLanguage":"User.uiLanguage","avatar":"User.avatar","tpAvatarLink":"User.tpAvatarLink","superAdmin":true,"state":"INVITED","isEnabled":false,"activeAuthId":"User.activeAuthId","passwordResetToken":"KNOWN-EXPOSURE-User.passwordResetToken","passwordResetTokenExpiry":1767225660.123456789,"isAnonymous":true,"connections":[{"authId":"User.connections[0].authId","source":"User.connections[0].source","name":"User.connections[0].name","email":"User.connections[0].email","avatar":"User.connections[0].avatar","rawUserInfo":{"sub":"User.connections[0].rawUserInfo.sub","loginCount":40070},"tokens":["User.connections[0].tokens[0]","User.connections[0].tokens[1]"]},{"authId":"User.connections[1].authId","source":"User.connections[1].source","name":"User.connections[1].name","email":"User.connections[1].email","avatar":"User.connections[1].avatar","rawUserInfo":{"sub":"User.connections[1].rawUserInfo.sub","loginCount":40071},"tokens":["User.connections[1].tokens[0]","User.connections[1].tokens[1]"]}],"apiKeysList":[{"id":"User.apiKeysList[0].id","name":"User.apiKeysList[0].name","description":"User.apiKeysList[0].description","token":"KNOWN-EXPOSURE-User.apiKeysList[0].token"},{"id":"User.apiKeysList[1].id","name":"User.apiKeysList[1].name","description":"User.apiKeysList[1].description","token":"KNOWN-EXPOSURE-User.apiKeysList[1].token"}],"apiKeys":[{"id":"User.apiKeys[0].id","token":"KNOWN-EXPOSURE-User.apiKeys[0].token"},{"id":"User.apiKeys[1].id","token":"KNOWN-EXPOSURE-User.apiKeys[1].token"}],"hasSetNickname":true,"orgTransformedUserInfo":{"User.orgTransformedUserInfo.second":{"updateTime":3000000091,"extra":{"department":"User.orgTransformedUserInfo.second.extra.department","level":40091}},"User.orgTransformedUserInfo.first":{"updateTime":3000000090,"extra":{"department":"User.orgTransformedUserInfo.first.extra.department","level":40090}}}},"organization":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"SECRET-Organization.authConfigs[1].clientSecret"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope"}]},"folderInfoViews":[{"orgId":"UserHomepageView.folderInfoViews[0].orgId","folderId":"UserHomepageView.folderInfoViews[0].folderId","folderGid":"UserHomepageView.folderInfoViews[0].folderGid","parentFolderId":"UserHomepageView.folderInfoViews[0].parentFolderId","parentFolderGid":"UserHomepageView.folderInfoViews[0].parentFolderGid","name":"UserHomepageView.folderInfoViews[0].name","title":"UserHomepageView.folderInfoViews[0].title","description":"UserHomepageView.folderInfoViews[0].description","category":"UserHomepageView.folderInfoViews[0].category","type":"UserHomepageView.folderInfoViews[0].type","image":"UserHomepageView.folderInfoViews[0].image","createAt":3000000043,"createBy":"UserHomepageView.folderInfoViews[0].createBy","subFolders":null,"subApplications":null,"createTime":1767225643123,"lastViewTime":1767225644123,"visible":true,"folder":true,"manageable":false},{"orgId":"UserHomepageView.folderInfoViews[1].orgId","folderId":"UserHomepageView.folderInfoViews[1].folderId","folderGid":"UserHomepageView.folderInfoViews[1].folderGid","parentFolderId":"UserHomepageView.folderInfoViews[1].parentFolderId","parentFolderGid":"UserHomepageView.folderInfoViews[1].parentFolderGid","name":"UserHomepageView.folderInfoViews[1].name","title":"UserHomepageView.folderInfoViews[1].title","description":"UserHomepageView.folderInfoViews[1].description","category":"UserHomepageView.folderInfoViews[1].category","type":"UserHomepageView.folderInfoViews[1].type","image":"UserHomepageView.folderInfoViews[1].image","createAt":3000000044,"createBy":"UserHomepageView.folderInfoViews[1].createBy","subFolders":null,"subApplications":null,"createTime":1767225644123,"lastViewTime":1767225645123,"visible":true,"folder":true,"manageable":false}],"homeApplicationViews":[{"orgId":"UserHomepageView.homeApplicationViews[0].orgId","applicationId":"UserHomepageView.homeApplicationViews[0].applicationId","applicationGid":"UserHomepageView.homeApplicationViews[0].applicationGid","name":"UserHomepageView.homeApplicationViews[0].name","createAt":3000000410,"createBy":"UserHomepageView.homeApplicationViews[0].createBy","role":"UserHomepageView.homeApplicationViews[0].role","applicationType":40410,"applicationStatus":"RECYCLED","containerSize":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"folderId":"UserHomepageView.homeApplicationViews[0].folderId","folderIdFrom":"UserHomepageView.homeApplicationViews[0].folderIdFrom","lastViewTime":1767226010123,"lastModifyTime":1767226011123,"lastEditedAt":1767226012123,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":"UserHomepageView.homeApplicationViews[0].editingUserId","title":"UserHomepageView.homeApplicationViews[0].title","description":"UserHomepageView.homeApplicationViews[0].description","category":"UserHomepageView.homeApplicationViews[0].category","icon":"UserHomepageView.homeApplicationViews[0].icon","published":true,"publishedVersion":"UserHomepageView.homeApplicationViews[0].publishedVersion","lastPublishedTime":1767226013.123456789,"folder":false},{"orgId":"UserHomepageView.homeApplicationViews[1].orgId","applicationId":"UserHomepageView.homeApplicationViews[1].applicationId","applicationGid":"UserHomepageView.homeApplicationViews[1].applicationGid","name":"UserHomepageView.homeApplicationViews[1].name","createAt":3000000510,"createBy":"UserHomepageView.homeApplicationViews[1].createBy","role":"UserHomepageView.homeApplicationViews[1].role","applicationType":40510,"applicationStatus":"RECYCLED","containerSize":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"folderId":"UserHomepageView.homeApplicationViews[1].folderId","folderIdFrom":"UserHomepageView.homeApplicationViews[1].folderIdFrom","lastViewTime":1767226110123,"lastModifyTime":1767226111123,"lastEditedAt":1767226112123,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":"UserHomepageView.homeApplicationViews[1].editingUserId","title":"UserHomepageView.homeApplicationViews[1].title","description":"UserHomepageView.homeApplicationViews[1].description","category":"UserHomepageView.homeApplicationViews[1].category","icon":"UserHomepageView.homeApplicationViews[1].icon","published":true,"publishedVersion":"UserHomepageView.homeApplicationViews[1].publishedVersion","lastPublishedTime":1767226113.123456789,"folder":false}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S1Public.json new file mode 100644 index 0000000000..58ffbd4c12 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S1Public.json @@ -0,0 +1 @@ +{"user":{"id":"User.id","createdBy":"User.createdBy","name":"User.name","email":"User.email","uiLanguage":"User.uiLanguage","avatar":"User.avatar","tpAvatarLink":"User.tpAvatarLink","superAdmin":true,"state":"INVITED","isEnabled":false,"activeAuthId":"User.activeAuthId","passwordResetToken":"KNOWN-EXPOSURE-User.passwordResetToken","passwordResetTokenExpiry":1767225660.123456789,"isAnonymous":true,"connections":[{"authId":"User.connections[0].authId","source":"User.connections[0].source","name":"User.connections[0].name","email":"User.connections[0].email","avatar":"User.connections[0].avatar","rawUserInfo":{"sub":"User.connections[0].rawUserInfo.sub","loginCount":40070},"tokens":["User.connections[0].tokens[0]","User.connections[0].tokens[1]"]},{"authId":"User.connections[1].authId","source":"User.connections[1].source","name":"User.connections[1].name","email":"User.connections[1].email","avatar":"User.connections[1].avatar","rawUserInfo":{"sub":"User.connections[1].rawUserInfo.sub","loginCount":40071},"tokens":["User.connections[1].tokens[0]","User.connections[1].tokens[1]"]}],"apiKeysList":[{"id":"User.apiKeysList[0].id","name":"User.apiKeysList[0].name","description":"User.apiKeysList[0].description","token":"KNOWN-EXPOSURE-User.apiKeysList[0].token"},{"id":"User.apiKeysList[1].id","name":"User.apiKeysList[1].name","description":"User.apiKeysList[1].description","token":"KNOWN-EXPOSURE-User.apiKeysList[1].token"}],"apiKeys":[{"id":"User.apiKeys[0].id","token":"KNOWN-EXPOSURE-User.apiKeys[0].token"},{"id":"User.apiKeys[1].id","token":"KNOWN-EXPOSURE-User.apiKeys[1].token"}],"hasSetNickname":true,"orgTransformedUserInfo":{"User.orgTransformedUserInfo.second":{"updateTime":3000000091,"extra":{"department":"User.orgTransformedUserInfo.second.extra.department","level":40091}},"User.orgTransformedUserInfo.first":{"updateTime":3000000090,"extra":{"department":"User.orgTransformedUserInfo.first.extra.department","level":40090}}}},"organization":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Organization.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Organization.authConfigs[2].authorizationEndpoint?response_type=code&client_id=Organization.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope","authorizeUrl":"Organization.authConfigs[3].baseUrl/realms/Organization.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=Organization.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope","authorizeUrl":"Organization.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=Organization.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[4].scope"}]},"folderInfoViews":[{"orgId":"UserHomepageView.folderInfoViews[0].orgId","folderId":"UserHomepageView.folderInfoViews[0].folderId","folderGid":"UserHomepageView.folderInfoViews[0].folderGid","parentFolderId":"UserHomepageView.folderInfoViews[0].parentFolderId","parentFolderGid":"UserHomepageView.folderInfoViews[0].parentFolderGid","name":"UserHomepageView.folderInfoViews[0].name","title":"UserHomepageView.folderInfoViews[0].title","description":"UserHomepageView.folderInfoViews[0].description","category":"UserHomepageView.folderInfoViews[0].category","type":"UserHomepageView.folderInfoViews[0].type","image":"UserHomepageView.folderInfoViews[0].image","createAt":3000000043,"createBy":"UserHomepageView.folderInfoViews[0].createBy","subFolders":null,"subApplications":null,"createTime":1767225643123,"lastViewTime":1767225644123,"visible":true,"folder":true,"manageable":false},{"orgId":"UserHomepageView.folderInfoViews[1].orgId","folderId":"UserHomepageView.folderInfoViews[1].folderId","folderGid":"UserHomepageView.folderInfoViews[1].folderGid","parentFolderId":"UserHomepageView.folderInfoViews[1].parentFolderId","parentFolderGid":"UserHomepageView.folderInfoViews[1].parentFolderGid","name":"UserHomepageView.folderInfoViews[1].name","title":"UserHomepageView.folderInfoViews[1].title","description":"UserHomepageView.folderInfoViews[1].description","category":"UserHomepageView.folderInfoViews[1].category","type":"UserHomepageView.folderInfoViews[1].type","image":"UserHomepageView.folderInfoViews[1].image","createAt":3000000044,"createBy":"UserHomepageView.folderInfoViews[1].createBy","subFolders":null,"subApplications":null,"createTime":1767225644123,"lastViewTime":1767225645123,"visible":true,"folder":true,"manageable":false}],"homeApplicationViews":[{"orgId":"UserHomepageView.homeApplicationViews[0].orgId","applicationId":"UserHomepageView.homeApplicationViews[0].applicationId","applicationGid":"UserHomepageView.homeApplicationViews[0].applicationGid","name":"UserHomepageView.homeApplicationViews[0].name","createAt":3000000410,"createBy":"UserHomepageView.homeApplicationViews[0].createBy","role":"UserHomepageView.homeApplicationViews[0].role","applicationType":40410,"applicationStatus":"RECYCLED","containerSize":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"folderId":"UserHomepageView.homeApplicationViews[0].folderId","folderIdFrom":"UserHomepageView.homeApplicationViews[0].folderIdFrom","lastViewTime":1767226010123,"lastModifyTime":1767226011123,"lastEditedAt":1767226012123,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":"UserHomepageView.homeApplicationViews[0].editingUserId","title":"UserHomepageView.homeApplicationViews[0].title","description":"UserHomepageView.homeApplicationViews[0].description","category":"UserHomepageView.homeApplicationViews[0].category","icon":"UserHomepageView.homeApplicationViews[0].icon","published":true,"publishedVersion":"UserHomepageView.homeApplicationViews[0].publishedVersion","lastPublishedTime":1767226013.123456789,"folder":false},{"orgId":"UserHomepageView.homeApplicationViews[1].orgId","applicationId":"UserHomepageView.homeApplicationViews[1].applicationId","applicationGid":"UserHomepageView.homeApplicationViews[1].applicationGid","name":"UserHomepageView.homeApplicationViews[1].name","createAt":3000000510,"createBy":"UserHomepageView.homeApplicationViews[1].createBy","role":"UserHomepageView.homeApplicationViews[1].role","applicationType":40510,"applicationStatus":"RECYCLED","containerSize":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"folderId":"UserHomepageView.homeApplicationViews[1].folderId","folderIdFrom":"UserHomepageView.homeApplicationViews[1].folderIdFrom","lastViewTime":1767226110123,"lastModifyTime":1767226111123,"lastEditedAt":1767226112123,"publicToAll":true,"publicToMarketplace":false,"agencyProfile":true,"editingUserId":"UserHomepageView.homeApplicationViews[1].editingUserId","title":"UserHomepageView.homeApplicationViews[1].title","description":"UserHomepageView.homeApplicationViews[1].description","category":"UserHomepageView.homeApplicationViews[1].category","icon":"UserHomepageView.homeApplicationViews[1].icon","published":true,"publishedVersion":"UserHomepageView.homeApplicationViews[1].publishedVersion","lastPublishedTime":1767226113.123456789,"folder":false}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S2.json new file mode 100644 index 0000000000..12b7ded8ff --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.home.UserHomepageView.S2.json @@ -0,0 +1 @@ +{"user":null,"organization":null,"folderInfoViews":null,"homeApplicationViews":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.material.MaterialEndpoints$MaterialView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.material.MaterialEndpoints$MaterialView.S1.json new file mode 100644 index 0000000000..588537fab1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.material.MaterialEndpoints$MaterialView.S1.json @@ -0,0 +1 @@ +{"id":"MaterialView.id","filename":"MaterialView.filename"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.material.MaterialEndpoints$MaterialView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.material.MaterialEndpoints$MaterialView.S2.json new file mode 100644 index 0000000000..62c6b64e7a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.material.MaterialEndpoints$MaterialView.S2.json @@ -0,0 +1 @@ +{"id":null,"filename":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.material.MaterialEndpoints$UploadMaterialRequestDTO.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.material.MaterialEndpoints$UploadMaterialRequestDTO.D1.json new file mode 100644 index 0000000000..c2e77ac1c7 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.material.MaterialEndpoints$UploadMaterialRequestDTO.D1.json @@ -0,0 +1,5 @@ +{ + "filename" : "UploadMaterialRequestDTO.filename", + "content" : "TWF0ZXJpYWxTYW1wbGVzLmZpbGU6IMOpIOKckw==", + "type" : "LOGO" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.MetaEndpoints$GetMetaDataRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.MetaEndpoints$GetMetaDataRequest.D1.json new file mode 100644 index 0000000000..b84046609d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.MetaEndpoints$GetMetaDataRequest.D1.json @@ -0,0 +1,34 @@ +{ + "appIds" : [ + "MetaView.apps[0].id", + "MetaView.apps[1].id" + ], + "orgIds" : [ + "MetaView.orgs[0].id", + "MetaView.orgs[1].id" + ], + "userIds" : [ + "MetaView.users[0].id", + "MetaView.users[1].id" + ], + "groupIds" : [ + "MetaView.groups[0].id", + "MetaView.groups[1].id" + ], + "bundleIds" : [ + "MetaView.bundles[0].id", + "MetaView.bundles[1].id" + ], + "datasourceIds" : [ + "MetaView.datasources[0].id", + "MetaView.datasources[1].id" + ], + "folderIds" : [ + "MetaView.folders[0].id", + "MetaView.folders[1].id" + ], + "libraryQueryIds" : [ + "MetaView.queries[0].id", + "MetaView.queries[1].id" + ] +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.MetaEndpoints$GetMetaDataRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.MetaEndpoints$GetMetaDataRequest.D3.json new file mode 100644 index 0000000000..1754a22e6c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.MetaEndpoints$GetMetaDataRequest.D3.json @@ -0,0 +1,82 @@ +{ + "appIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "orgIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "userIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "groupIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "bundleIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "datasourceIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "folderIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "libraryQueryIds" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.ApplicationMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.ApplicationMetaView.S1.json new file mode 100644 index 0000000000..468b9a9c8a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.ApplicationMetaView.S1.json @@ -0,0 +1 @@ +{"id":"ApplicationMetaView.id","name":"ApplicationMetaView.name","title":"ApplicationMetaView.title","description":"ApplicationMetaView.description","category":"ApplicationMetaView.category","icon":"ApplicationMetaView.icon"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.ApplicationMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.ApplicationMetaView.S2.json new file mode 100644 index 0000000000..5d75318726 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.ApplicationMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"name":null,"title":null,"description":null,"category":null,"icon":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.BundleMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.BundleMetaView.S1.json new file mode 100644 index 0000000000..65e4ac0953 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.BundleMetaView.S1.json @@ -0,0 +1 @@ +{"id":"BundleMetaView.id","name":"BundleMetaView.name"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.BundleMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.BundleMetaView.S2.json new file mode 100644 index 0000000000..37360d0fcc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.BundleMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"name":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.DatasourceMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.DatasourceMetaView.S1.json new file mode 100644 index 0000000000..7282341f9d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.DatasourceMetaView.S1.json @@ -0,0 +1 @@ +{"id":"DatasourceMetaView.id","name":"DatasourceMetaView.name"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.DatasourceMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.DatasourceMetaView.S2.json new file mode 100644 index 0000000000..37360d0fcc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.DatasourceMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"name":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.FolderMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.FolderMetaView.S1.json new file mode 100644 index 0000000000..6724e4294d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.FolderMetaView.S1.json @@ -0,0 +1 @@ +{"id":"FolderMetaView.id","name":"FolderMetaView.name"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.FolderMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.FolderMetaView.S2.json new file mode 100644 index 0000000000..37360d0fcc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.FolderMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"name":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.GroupMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.GroupMetaView.S1.json new file mode 100644 index 0000000000..e2d0f93090 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.GroupMetaView.S1.json @@ -0,0 +1 @@ +{"id":"GroupMetaView.id","name":"GroupMetaView.name"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.GroupMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.GroupMetaView.S2.json new file mode 100644 index 0000000000..37360d0fcc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.GroupMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"name":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.LibraryQueryMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.LibraryQueryMetaView.S1.json new file mode 100644 index 0000000000..c73a427f0f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.LibraryQueryMetaView.S1.json @@ -0,0 +1 @@ +{"id":"LibraryQueryMetaView.id","name":"LibraryQueryMetaView.name"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.LibraryQueryMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.LibraryQueryMetaView.S2.json new file mode 100644 index 0000000000..37360d0fcc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.LibraryQueryMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"name":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.MetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.MetaView.S1.json new file mode 100644 index 0000000000..b2ef3fc87b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.MetaView.S1.json @@ -0,0 +1 @@ +{"apps":[{"id":"MetaView.apps[0].id","name":"MetaView.apps[0].name","title":"MetaView.apps[0].title","description":"MetaView.apps[0].description","category":"MetaView.apps[0].category","icon":"MetaView.apps[0].icon"},{"id":"MetaView.apps[1].id","name":"MetaView.apps[1].name","title":"MetaView.apps[1].title","description":"MetaView.apps[1].description","category":"MetaView.apps[1].category","icon":"MetaView.apps[1].icon"}],"users":[{"id":"MetaView.users[0].id","name":"MetaView.users[0].name","email":"MetaView.users[0].email"},{"id":"MetaView.users[1].id","name":"MetaView.users[1].name","email":"MetaView.users[1].email"}],"orgs":[{"id":"MetaView.orgs[0].id","name":"MetaView.orgs[0].name"},{"id":"MetaView.orgs[1].id","name":"MetaView.orgs[1].name"}],"folders":[{"id":"MetaView.folders[0].id","name":"MetaView.folders[0].name"},{"id":"MetaView.folders[1].id","name":"MetaView.folders[1].name"}],"datasources":[{"id":"MetaView.datasources[0].id","name":"MetaView.datasources[0].name"},{"id":"MetaView.datasources[1].id","name":"MetaView.datasources[1].name"}],"bundles":[{"id":"MetaView.bundles[0].id","name":"MetaView.bundles[0].name"},{"id":"MetaView.bundles[1].id","name":"MetaView.bundles[1].name"}],"groups":[{"id":"MetaView.groups[0].id","name":"MetaView.groups[0].name"},{"id":"MetaView.groups[1].id","name":"MetaView.groups[1].name"}],"queries":[{"id":"MetaView.queries[0].id","name":"MetaView.queries[0].name"},{"id":"MetaView.queries[1].id","name":"MetaView.queries[1].name"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.MetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.MetaView.S2.json new file mode 100644 index 0000000000..173696cf9f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.MetaView.S2.json @@ -0,0 +1 @@ +{"apps":null,"users":null,"orgs":null,"folders":null,"datasources":null,"bundles":null,"groups":null,"queries":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.OrgMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.OrgMetaView.S1.json new file mode 100644 index 0000000000..5e61d6920e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.OrgMetaView.S1.json @@ -0,0 +1 @@ +{"id":"OrgMetaView.id","name":"OrgMetaView.name"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.OrgMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.OrgMetaView.S2.json new file mode 100644 index 0000000000..37360d0fcc --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.OrgMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"name":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.UserMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.UserMetaView.S1.json new file mode 100644 index 0000000000..57aa50b987 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.UserMetaView.S1.json @@ -0,0 +1 @@ +{"id":"UserMetaView.id","name":"UserMetaView.name","email":"UserMetaView.email"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.UserMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.UserMetaView.S2.json new file mode 100644 index 0000000000..d752b8c4a4 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.meta.view.UserMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"name":null,"email":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.ApiFlowEndpoints$FlowRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.ApiFlowEndpoints$FlowRequest.D1.json new file mode 100644 index 0000000000..82ad14f966 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.ApiFlowEndpoints$FlowRequest.D1.json @@ -0,0 +1,21 @@ +{ + "path" : "FlowRequest.path", + "method" : "post", + "data" : { + "text" : "FlowRequest.data.text", + "count" : 40093, + "total" : 3000000001, + "ratio" : 1.5, + "items" : [ "FlowRequest.data.items[0]", "FlowRequest.data.items[1]" ], + "nested" : { + "first" : "FlowRequest.data.nested.first", + "second" : "FlowRequest.data.nested.second" + } + }, + "headers" : { + "X-Flow-First" : "FlowRequest.headers.X-Flow-First", + "X-Flow-Second" : "FlowRequest.headers.X-Flow-Second" + }, + "timeout" : 40091, + "retries" : 40092 +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.ApiFlowEndpoints$FlowRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.ApiFlowEndpoints$FlowRequest.D3.json new file mode 100644 index 0000000000..6458e10606 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.ApiFlowEndpoints$FlowRequest.D3.json @@ -0,0 +1,62 @@ +{ + "path" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "method" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "data" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "headers" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "timeout" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "retries" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.JsLibraryController$JsLibraryMeta.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.JsLibraryController$JsLibraryMeta.S1.json new file mode 100644 index 0000000000..06b9e59318 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.JsLibraryController$JsLibraryMeta.S1.json @@ -0,0 +1 @@ +{"name":"JsLibraryMeta.name","latestVersion":"JsLibraryMeta.latestVersion","homepage":"JsLibraryMeta.homepage","description":"JsLibraryMeta.description","downloadUrl":"JsLibraryMeta.downloadUrl"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.JsLibraryController$JsLibraryMeta.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.JsLibraryController$JsLibraryMeta.S2.json new file mode 100644 index 0000000000..2b888d2e42 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.misc.JsLibraryController$JsLibraryMeta.S2.json @@ -0,0 +1 @@ +{"name":null,"latestVersion":null,"homepage":null,"description":null,"downloadUrl":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.CommonPermissionView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.CommonPermissionView.S1.json new file mode 100644 index 0000000000..a7f65fd586 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.CommonPermissionView.S1.json @@ -0,0 +1 @@ +{"orgName":"CommonPermissionView.orgName","groupPermissions":[{"permissionId":"CommonPermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"CommonPermissionView.groupPermissions[0].id","avatar":"CommonPermissionView.groupPermissions[0].avatar","name":"CommonPermissionView.groupPermissions[0].name","role":"CommonPermissionView.groupPermissions[0].role"},{"permissionId":"CommonPermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"CommonPermissionView.groupPermissions[1].id","avatar":"CommonPermissionView.groupPermissions[1].avatar","name":"CommonPermissionView.groupPermissions[1].name","role":"CommonPermissionView.groupPermissions[1].role"}],"userPermissions":[{"permissionId":"CommonPermissionView.userPermissions[0].permissionId","type":"USER","id":"CommonPermissionView.userPermissions[0].id","avatar":"CommonPermissionView.userPermissions[0].avatar","name":"CommonPermissionView.userPermissions[0].name","role":"CommonPermissionView.userPermissions[0].role"},{"permissionId":"CommonPermissionView.userPermissions[1].permissionId","type":"USER","id":"CommonPermissionView.userPermissions[1].id","avatar":"CommonPermissionView.userPermissions[1].avatar","name":"CommonPermissionView.userPermissions[1].name","role":"CommonPermissionView.userPermissions[1].role"}],"creatorId":"CommonPermissionView.creatorId","permissions":[{"permissionId":"CommonPermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"CommonPermissionView.groupPermissions[0].id","avatar":"CommonPermissionView.groupPermissions[0].avatar","name":"CommonPermissionView.groupPermissions[0].name","role":"CommonPermissionView.groupPermissions[0].role"},{"permissionId":"CommonPermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"CommonPermissionView.groupPermissions[1].id","avatar":"CommonPermissionView.groupPermissions[1].avatar","name":"CommonPermissionView.groupPermissions[1].name","role":"CommonPermissionView.groupPermissions[1].role"},{"permissionId":"CommonPermissionView.userPermissions[0].permissionId","type":"USER","id":"CommonPermissionView.userPermissions[0].id","avatar":"CommonPermissionView.userPermissions[0].avatar","name":"CommonPermissionView.userPermissions[0].name","role":"CommonPermissionView.userPermissions[0].role"},{"permissionId":"CommonPermissionView.userPermissions[1].permissionId","type":"USER","id":"CommonPermissionView.userPermissions[1].id","avatar":"CommonPermissionView.userPermissions[1].avatar","name":"CommonPermissionView.userPermissions[1].name","role":"CommonPermissionView.userPermissions[1].role"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.CommonPermissionView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.CommonPermissionView.S2.json new file mode 100644 index 0000000000..83d7a2c889 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.CommonPermissionView.S2.json @@ -0,0 +1 @@ +{"orgName":null,"groupPermissions":[{"permissionId":"CommonPermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"CommonPermissionView.groupPermissions[0].id","avatar":"CommonPermissionView.groupPermissions[0].avatar","name":"CommonPermissionView.groupPermissions[0].name","role":"CommonPermissionView.groupPermissions[0].role"},{"permissionId":"CommonPermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"CommonPermissionView.groupPermissions[1].id","avatar":"CommonPermissionView.groupPermissions[1].avatar","name":"CommonPermissionView.groupPermissions[1].name","role":"CommonPermissionView.groupPermissions[1].role"}],"userPermissions":[{"permissionId":"CommonPermissionView.userPermissions[0].permissionId","type":"USER","id":"CommonPermissionView.userPermissions[0].id","avatar":"CommonPermissionView.userPermissions[0].avatar","name":"CommonPermissionView.userPermissions[0].name","role":"CommonPermissionView.userPermissions[0].role"},{"permissionId":"CommonPermissionView.userPermissions[1].permissionId","type":"USER","id":"CommonPermissionView.userPermissions[1].id","avatar":"CommonPermissionView.userPermissions[1].avatar","name":"CommonPermissionView.userPermissions[1].name","role":"CommonPermissionView.userPermissions[1].role"}],"creatorId":null,"permissions":[{"permissionId":"CommonPermissionView.groupPermissions[0].permissionId","type":"GROUP","id":"CommonPermissionView.groupPermissions[0].id","avatar":"CommonPermissionView.groupPermissions[0].avatar","name":"CommonPermissionView.groupPermissions[0].name","role":"CommonPermissionView.groupPermissions[0].role"},{"permissionId":"CommonPermissionView.groupPermissions[1].permissionId","type":"GROUP","id":"CommonPermissionView.groupPermissions[1].id","avatar":"CommonPermissionView.groupPermissions[1].avatar","name":"CommonPermissionView.groupPermissions[1].name","role":"CommonPermissionView.groupPermissions[1].role"},{"permissionId":"CommonPermissionView.userPermissions[0].permissionId","type":"USER","id":"CommonPermissionView.userPermissions[0].id","avatar":"CommonPermissionView.userPermissions[0].avatar","name":"CommonPermissionView.userPermissions[0].name","role":"CommonPermissionView.userPermissions[0].role"},{"permissionId":"CommonPermissionView.userPermissions[1].permissionId","type":"USER","id":"CommonPermissionView.userPermissions[1].id","avatar":"CommonPermissionView.userPermissions[1].avatar","name":"CommonPermissionView.userPermissions[1].name","role":"CommonPermissionView.userPermissions[1].role"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.CommonPermissionView.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.CommonPermissionView.S2Throwing.json new file mode 100644 index 0000000000..28a540731f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.CommonPermissionView.S2Throwing.json @@ -0,0 +1,8 @@ +{ + "groupPermissions" : { + "permissions" : "java.lang.NullPointerException" + }, + "userPermissions" : { + "permissions" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.PermissionItemView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.PermissionItemView.S1.json new file mode 100644 index 0000000000..11d322cede --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.PermissionItemView.S1.json @@ -0,0 +1 @@ +{"permissionId":"PermissionItemView.permissionId","type":"USER","id":"PermissionItemView.id","avatar":"PermissionItemView.avatar","name":"PermissionItemView.name","role":"PermissionItemView.role"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.PermissionItemView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.PermissionItemView.S2.json new file mode 100644 index 0000000000..454951c865 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.permission.view.PermissionItemView.S2.json @@ -0,0 +1 @@ +{"permissionId":null,"type":null,"id":null,"avatar":null,"name":null,"role":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryAggregateView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryAggregateView.S1.json new file mode 100644 index 0000000000..25dd9b3468 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryAggregateView.S1.json @@ -0,0 +1 @@ +{"libraryQueryMetaView":{"id":"LibraryQueryAggregateView.libraryQueryMetaView.id","gid":"LibraryQueryAggregateView.libraryQueryMetaView.gid","datasourceType":"restApi","organizationId":"LibraryQueryAggregateView.libraryQueryMetaView.organizationId","name":"LibraryQueryAggregateView.libraryQueryMetaView.name","createTime":1767225700123,"creatorName":"LibraryQueryAggregateView.libraryQueryMetaView.creatorName"},"recordMetaViewList":[{"id":"LibraryQueryAggregateView.recordMetaViewList[0].id","libraryQueryId":"LibraryQueryAggregateView.recordMetaViewList[0].libraryQueryId","datasourceType":"restApi","tag":"LibraryQueryAggregateView.recordMetaViewList[0].tag","commitMessage":"LibraryQueryAggregateView.recordMetaViewList[0].commitMessage","createTime":1767225710123,"creatorName":"LibraryQueryAggregateView.recordMetaViewList[0].creatorName"},{"id":"LibraryQueryAggregateView.recordMetaViewList[1].id","libraryQueryId":"LibraryQueryAggregateView.recordMetaViewList[1].libraryQueryId","datasourceType":"restApi","tag":"LibraryQueryAggregateView.recordMetaViewList[1].tag","commitMessage":"LibraryQueryAggregateView.recordMetaViewList[1].commitMessage","createTime":1767225710123,"creatorName":"LibraryQueryAggregateView.recordMetaViewList[1].creatorName"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryAggregateView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryAggregateView.S2.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryAggregateView.S2.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryMetaView.S1.json new file mode 100644 index 0000000000..5a452b47ac --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryMetaView.S1.json @@ -0,0 +1 @@ +{"id":"LibraryQueryMetaView.id","gid":"LibraryQueryMetaView.gid","datasourceType":"restApi","organizationId":"LibraryQueryMetaView.organizationId","name":"LibraryQueryMetaView.name","createTime":1767225700123,"creatorName":"LibraryQueryMetaView.creatorName"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryMetaView.S2.json new file mode 100644 index 0000000000..149f8480ae --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"gid":null,"datasourceType":null,"organizationId":null,"name":null,"createTime":1767225700123,"creatorName":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryPublishRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryPublishRequest.D1.json new file mode 100644 index 0000000000..0b49c0cb3a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryPublishRequest.D1.json @@ -0,0 +1,4 @@ +{ + "commitMessage": "LibraryQueryPublishRequest.commitMessage", + "tag": "LibraryQueryPublishRequest.tag" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryPublishRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryPublishRequest.D3.json new file mode 100644 index 0000000000..7f8ccd6432 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryPublishRequest.D3.json @@ -0,0 +1,22 @@ +{ + "commitMessage" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "tag" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryRecordMetaView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryRecordMetaView.S1.json new file mode 100644 index 0000000000..ed5eda8487 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryRecordMetaView.S1.json @@ -0,0 +1 @@ +{"id":"LibraryQueryRecordMetaView.id","libraryQueryId":"LibraryQueryRecordMetaView.libraryQueryId","datasourceType":"restApi","tag":"LibraryQueryRecordMetaView.tag","commitMessage":"LibraryQueryRecordMetaView.commitMessage","createTime":1767225710123,"creatorName":"LibraryQueryRecordMetaView.creatorName"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryRecordMetaView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryRecordMetaView.S2.json new file mode 100644 index 0000000000..5e28618ecd --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryRecordMetaView.S2.json @@ -0,0 +1 @@ +{"id":null,"libraryQueryId":null,"datasourceType":null,"tag":null,"commitMessage":null,"createTime":1767225710123,"creatorName":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryRequestFromJs.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryRequestFromJs.D1.json new file mode 100644 index 0000000000..8b63254891 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryRequestFromJs.D1.json @@ -0,0 +1,73 @@ +{ + "libraryQueryName" : "LibraryQueryRequestFromJs.libraryQueryName", + "libraryQueryRecordId" : "LibraryQueryRequestFromJs.libraryQueryRecordId", + "params" : [ { + "key" : "LibraryQueryRequestFromJs.params[0].key", + "value" : { + "zeta": "first key, so that sorted output would differ from input order", + "int": 1, + "negativeInt": -40003, + "intMax": 2147483647, + "intMaxPlusOne": 2147483648, + "intMinMinusOne": -2147483649, + "longMax": 9223372036854775807, + "longMaxPlusOne": 9223372036854775808, + "longMinMinusOne": -9223372036854775809, + "decimal": 1.5, + "trailingZeroDecimal": 1.50, + "zeroPointZero": 0.0, + "negativeZero": -0, + "exponent": 1e2, + "exponentUpperCase": 1E+2, + "negativeExponent": 2.5e-3, + "longDecimal": 0.1000000000000000055511151231257827, + "true": true, + "false": false, + "null": null, + "emptyObject": {}, + "emptyArray": [], + "nested": { + "array": [1, "two", [3, {"four": 4.0}], null, true, 3000000001], + "object": {"z": 1, "a": {"deeper": [[], {}]}} + }, + "unicode": "žluťoučký kůň 🐎 中文", + "escapes": "\u00e9 \u0000 \n \t \" \\ \/ \ud83d\ude00", + "k\u0065y with escaped name": "escaped key", + "alpha": "last key" + } + }, { + "key" : "LibraryQueryRequestFromJs.params[1].key", + "value" : { + "zeta": "first key, so that sorted output would differ from input order", + "int": 1, + "negativeInt": -40003, + "intMax": 2147483647, + "intMaxPlusOne": 2147483648, + "intMinMinusOne": -2147483649, + "longMax": 9223372036854775807, + "longMaxPlusOne": 9223372036854775808, + "longMinMinusOne": -9223372036854775809, + "decimal": 1.5, + "trailingZeroDecimal": 1.50, + "zeroPointZero": 0.0, + "negativeZero": -0, + "exponent": 1e2, + "exponentUpperCase": 1E+2, + "negativeExponent": 2.5e-3, + "longDecimal": 0.1000000000000000055511151231257827, + "true": true, + "false": false, + "null": null, + "emptyObject": {}, + "emptyArray": [], + "nested": { + "array": [1, "two", [3, {"four": 4.0}], null, true, 3000000001], + "object": {"z": 1, "a": {"deeper": [[], {}]}} + }, + "unicode": "žluťoučký kůň 🐎 中文", + "escapes": "\u00e9 \u0000 \n \t \" \\ \/ \ud83d\ude00", + "k\u0065y with escaped name": "escaped key", + "alpha": "last key" + } + } ] +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryView.S1.json new file mode 100644 index 0000000000..cbb4229393 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryView.S1.json @@ -0,0 +1 @@ +{"id":"LibraryQueryView.id","gid":"LibraryQueryView.gid","organizationId":"LibraryQueryView.organizationId","name":"LibraryQueryView.name","libraryQueryDSL":{"query":{"id":"LibraryQueryView.libraryQueryDSL.query.id","name":"LibraryQueryView.libraryQueryDSL.query.name","datasourceId":"LibraryQueryView.libraryQueryDSL.query.datasourceId","compType":"restApi","comp":{"method":"LibraryQueryView.libraryQueryDSL.query.comp.method","path":"LibraryQueryView.libraryQueryDSL.query.comp.path","bodyLimit":40100,"maxBytes":3000000001,"ratio":1.5},"triggerType":"LibraryQueryView.libraryQueryDSL.query.triggerType","timeout":"LibraryQueryView.libraryQueryDSL.query.timeout"},"version":40101},"createTime":1767225700123,"creatorName":"LibraryQueryView.creatorName"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryView.S2.json new file mode 100644 index 0000000000..584e15efad --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.LibraryQueryView.S2.json @@ -0,0 +1 @@ +{"id":null,"gid":null,"organizationId":null,"name":null,"libraryQueryDSL":null,"createTime":1767225700123,"creatorName":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryExecutionRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryExecutionRequest.D1.json new file mode 100644 index 0000000000..cfb59c1f51 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryExecutionRequest.D1.json @@ -0,0 +1,77 @@ +{ + "applicationId" : "QueryExecutionRequest.applicationId", + "queryId" : "QueryExecutionRequest.queryId", + "libraryQueryId" : "QueryExecutionRequest.libraryQueryId", + "libraryQueryRecordId" : "QueryExecutionRequest.libraryQueryRecordId", + "params" : [ { + "key" : "QueryExecutionRequest.params[0].key", + "value" : { + "zeta": "first key, so that sorted output would differ from input order", + "int": 1, + "negativeInt": -40003, + "intMax": 2147483647, + "intMaxPlusOne": 2147483648, + "intMinMinusOne": -2147483649, + "longMax": 9223372036854775807, + "longMaxPlusOne": 9223372036854775808, + "longMinMinusOne": -9223372036854775809, + "decimal": 1.5, + "trailingZeroDecimal": 1.50, + "zeroPointZero": 0.0, + "negativeZero": -0, + "exponent": 1e2, + "exponentUpperCase": 1E+2, + "negativeExponent": 2.5e-3, + "longDecimal": 0.1000000000000000055511151231257827, + "true": true, + "false": false, + "null": null, + "emptyObject": {}, + "emptyArray": [], + "nested": { + "array": [1, "two", [3, {"four": 4.0}], null, true, 3000000001], + "object": {"z": 1, "a": {"deeper": [[], {}]}} + }, + "unicode": "žluťoučký kůň 🐎 中文", + "escapes": "\u00e9 \u0000 \n \t \" \\ \/ \ud83d\ude00", + "k\u0065y with escaped name": "escaped key", + "alpha": "last key" + } + }, { + "key" : "QueryExecutionRequest.params[1].key", + "value" : { + "zeta": "first key, so that sorted output would differ from input order", + "int": 1, + "negativeInt": -40003, + "intMax": 2147483647, + "intMaxPlusOne": 2147483648, + "intMinMinusOne": -2147483649, + "longMax": 9223372036854775807, + "longMaxPlusOne": 9223372036854775808, + "longMinMinusOne": -9223372036854775809, + "decimal": 1.5, + "trailingZeroDecimal": 1.50, + "zeroPointZero": 0.0, + "negativeZero": -0, + "exponent": 1e2, + "exponentUpperCase": 1E+2, + "negativeExponent": 2.5e-3, + "longDecimal": 0.1000000000000000055511151231257827, + "true": true, + "false": false, + "null": null, + "emptyObject": {}, + "emptyArray": [], + "nested": { + "array": [1, "two", [3, {"four": 4.0}], null, true, 3000000001], + "object": {"z": 1, "a": {"deeper": [[], {}]}} + }, + "unicode": "žluťoučký kůň 🐎 中文", + "escapes": "\u00e9 \u0000 \n \t \" \\ \/ \ud83d\ude00", + "k\u0065y with escaped name": "escaped key", + "alpha": "last key" + } + } ], + "viewMode" : true, + "path" : [ "QueryExecutionRequest.path[0]", "QueryExecutionRequest.path[1]" ] +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryExecutionRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryExecutionRequest.D3.json new file mode 100644 index 0000000000..69d36b0a88 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryExecutionRequest.D3.json @@ -0,0 +1,12 @@ +{ + "viewMode" : { + "absent" : { + "class" : "java.lang.Boolean", + "value" : "false" + }, + "null" : { + "class" : "java.lang.Boolean", + "value" : "false" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryResultView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryResultView.S1.json new file mode 100644 index 0000000000..5f710a91ef --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryResultView.S1.json @@ -0,0 +1 @@ +{"message":"Query execution error: QueryResultView.message.","data":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"headers":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"success":false,"code":1,"queryCode":"HTTPBAD_GATEWAY","hintMessages":["Duplicated columns found: QueryResultView.hintMessages[0], use keyword 'as' to rename duplicated columns.","Duplicated columns found: QueryResultView.hintMessages[1], use keyword 'as' to rename duplicated columns."]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryResultView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryResultView.S2.json new file mode 100644 index 0000000000..5f710a91ef --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryResultView.S2.json @@ -0,0 +1 @@ +{"message":"Query execution error: QueryResultView.message.","data":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"headers":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"success":false,"code":1,"queryCode":"HTTPBAD_GATEWAY","hintMessages":["Duplicated columns found: QueryResultView.hintMessages[0], use keyword 'as' to rename duplicated columns.","Duplicated columns found: QueryResultView.hintMessages[1], use keyword 'as' to rename duplicated columns."]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryResultView.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryResultView.S2Throwing.json new file mode 100644 index 0000000000..98708c2ea3 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.QueryResultView.S2Throwing.json @@ -0,0 +1,14 @@ +{ + "queryResult" : { + "data" : "java.lang.NullPointerException", + "headers" : "java.lang.NullPointerException", + "hintMessages" : "java.lang.NullPointerException", + "message" : "java.lang.NullPointerException", + "queryCode" : "java.lang.NullPointerException", + "success" : "java.lang.NullPointerException" + }, + "locale" : { + "hintMessages" : "java.lang.NullPointerException", + "message" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.UpsertLibraryQueryRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.UpsertLibraryQueryRequest.D1.json new file mode 100644 index 0000000000..59ba26ad1f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.query.view.UpsertLibraryQueryRequest.D1.json @@ -0,0 +1,21 @@ +{ + "name": "UpsertLibraryQueryRequest.name", + "libraryQueryDSL": { + "query": { + "id": "UpsertLibraryQueryRequest.libraryQueryDSL.query.id", + "name": "UpsertLibraryQueryRequest.libraryQueryDSL.query.name", + "datasourceId": "UpsertLibraryQueryRequest.libraryQueryDSL.query.datasourceId", + "compType": "restApi", + "comp": { + "method": "UpsertLibraryQueryRequest.libraryQueryDSL.query.comp.method", + "path": "UpsertLibraryQueryRequest.libraryQueryDSL.query.comp.path", + "bodyLimit": 40100, + "maxBytes": 3000000001, + "ratio": 1.50 + }, + "triggerType": "UpsertLibraryQueryRequest.libraryQueryDSL.query.triggerType", + "timeout": "UpsertLibraryQueryRequest.libraryQueryDSL.query.timeout" + }, + "version": 40101 + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.InvitationEndpoints$InviteEmailRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.InvitationEndpoints$InviteEmailRequest.D1.json new file mode 100644 index 0000000000..9474c31893 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.InvitationEndpoints$InviteEmailRequest.D1.json @@ -0,0 +1,4 @@ +{ + "emails" : [ "InviteEmailRequest.emails[0]", "InviteEmailRequest.emails[1]" ], + "orgId" : "InviteEmailRequest.orgId" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.InvitationEndpoints$InviteEmailRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.InvitationEndpoints$InviteEmailRequest.D3.json new file mode 100644 index 0000000000..ac83079af2 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.InvitationEndpoints$InviteEmailRequest.D3.json @@ -0,0 +1,22 @@ +{ + "emails" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "orgId" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.OrganizationEndpoints$UpdateOrgCommonSettingsRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.OrganizationEndpoints$UpdateOrgCommonSettingsRequest.D1.json new file mode 100644 index 0000000000..e17ae3fb5c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.OrganizationEndpoints$UpdateOrgCommonSettingsRequest.D1.json @@ -0,0 +1,35 @@ +{ + "key" : "UpdateOrgCommonSettingsRequest.key", + "value" : { + "zeta": "first key, so that sorted output would differ from input order", + "int": 1, + "negativeInt": -40003, + "intMax": 2147483647, + "intMaxPlusOne": 2147483648, + "intMinMinusOne": -2147483649, + "longMax": 9223372036854775807, + "longMaxPlusOne": 9223372036854775808, + "longMinMinusOne": -9223372036854775809, + "decimal": 1.5, + "trailingZeroDecimal": 1.50, + "zeroPointZero": 0.0, + "negativeZero": -0, + "exponent": 1e2, + "exponentUpperCase": 1E+2, + "negativeExponent": 2.5e-3, + "longDecimal": 0.1000000000000000055511151231257827, + "true": true, + "false": false, + "null": null, + "emptyObject": {}, + "emptyArray": [], + "nested": { + "array": [1, "two", [3, {"four": 4.0}], null, true, 3000000001], + "object": {"z": 1, "a": {"deeper": [[], {}]}} + }, + "unicode": "žluťoučký kůň 🐎 中文", + "escapes": "\u00e9 \u0000 \n \t \" \\ \/ \ud83d\ude00", + "k\u0065y with escaped name": "escaped key", + "alpha": "last key" + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.OrganizationEndpoints$UpdateOrgCommonSettingsRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.OrganizationEndpoints$UpdateOrgCommonSettingsRequest.D3.json new file mode 100644 index 0000000000..9426b84284 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.OrganizationEndpoints$UpdateOrgCommonSettingsRequest.D3.json @@ -0,0 +1,22 @@ +{ + "key" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "value" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$CreateUserRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$CreateUserRequest.D1.json new file mode 100644 index 0000000000..311f1750b2 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$CreateUserRequest.D1.json @@ -0,0 +1,4 @@ +{ + "email" : "createuserrequest.email@example.com", + "password" : "CreateUserRequest.password" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$CreateUserRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$CreateUserRequest.D3.json new file mode 100644 index 0000000000..157671b5f0 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$CreateUserRequest.D3.json @@ -0,0 +1,22 @@ +{ + "email" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "password" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$LostPasswordRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$LostPasswordRequest.D1.json new file mode 100644 index 0000000000..4c7f0b759c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$LostPasswordRequest.D1.json @@ -0,0 +1,3 @@ +{ + "userEmail" : "LostPasswordRequest.userEmail" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$LostPasswordRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$LostPasswordRequest.D3.json new file mode 100644 index 0000000000..def451e91b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$LostPasswordRequest.D3.json @@ -0,0 +1,12 @@ +{ + "userEmail" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$MarkUserStatusRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$MarkUserStatusRequest.D1.json new file mode 100644 index 0000000000..689688299f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$MarkUserStatusRequest.D1.json @@ -0,0 +1,35 @@ +{ + "type" : "newUserGuidance", + "value" : { + "zeta": "first key, so that sorted output would differ from input order", + "int": 1, + "negativeInt": -40003, + "intMax": 2147483647, + "intMaxPlusOne": 2147483648, + "intMinMinusOne": -2147483649, + "longMax": 9223372036854775807, + "longMaxPlusOne": 9223372036854775808, + "longMinMinusOne": -9223372036854775809, + "decimal": 1.5, + "trailingZeroDecimal": 1.50, + "zeroPointZero": 0.0, + "negativeZero": -0, + "exponent": 1e2, + "exponentUpperCase": 1E+2, + "negativeExponent": 2.5e-3, + "longDecimal": 0.1000000000000000055511151231257827, + "true": true, + "false": false, + "null": null, + "emptyObject": {}, + "emptyArray": [], + "nested": { + "array": [1, "two", [3, {"four": 4.0}], null, true, 3000000001], + "object": {"z": 1, "a": {"deeper": [[], {}]}} + }, + "unicode": "žluťoučký kůň 🐎 中文", + "escapes": "\u00e9 \u0000 \n \t \" \\ \/ \ud83d\ude00", + "k\u0065y with escaped name": "escaped key", + "alpha": "last key" + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$MarkUserStatusRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$MarkUserStatusRequest.D3.json new file mode 100644 index 0000000000..351a114394 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$MarkUserStatusRequest.D3.json @@ -0,0 +1,22 @@ +{ + "type" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "value" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetLostPasswordRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetLostPasswordRequest.D1.json new file mode 100644 index 0000000000..e5aab79028 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetLostPasswordRequest.D1.json @@ -0,0 +1,5 @@ +{ + "token" : "ResetLostPasswordRequest.token", + "userEmail" : "ResetLostPasswordRequest.userEmail", + "newPassword" : "ResetLostPasswordRequest.newPassword" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetLostPasswordRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetLostPasswordRequest.D3.json new file mode 100644 index 0000000000..28debcc20c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetLostPasswordRequest.D3.json @@ -0,0 +1,32 @@ +{ + "token" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "userEmail" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "newPassword" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetPasswordRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetPasswordRequest.D1.json new file mode 100644 index 0000000000..333b5d21d5 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetPasswordRequest.D1.json @@ -0,0 +1,3 @@ +{ + "userId" : "ResetPasswordRequest.userId" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetPasswordRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetPasswordRequest.D3.json new file mode 100644 index 0000000000..946dbe2258 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$ResetPasswordRequest.D3.json @@ -0,0 +1,12 @@ +{ + "userId" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$UpdatePasswordRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$UpdatePasswordRequest.D1.json new file mode 100644 index 0000000000..99c3e1b34b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$UpdatePasswordRequest.D1.json @@ -0,0 +1,4 @@ +{ + "oldPassword" : "UpdatePasswordRequest.oldPassword", + "newPassword" : "UpdatePasswordRequest.newPassword" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$UpdatePasswordRequest.D3.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$UpdatePasswordRequest.D3.json new file mode 100644 index 0000000000..bea9ebc3ff --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.UserEndpoints$UpdatePasswordRequest.D3.json @@ -0,0 +1,22 @@ +{ + "oldPassword" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + }, + "newPassword" : { + "absent" : { + "class" : "null", + "value" : "null" + }, + "null" : { + "class" : "null", + "value" : "null" + } + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.APIKeyVO.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.APIKeyVO.S1.json new file mode 100644 index 0000000000..48f5fb6dbf --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.APIKeyVO.S1.json @@ -0,0 +1 @@ +{"id":"APIKeyVO.id","token":"APIKeyVO.token"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.APIKeyVO.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.APIKeyVO.S2.json new file mode 100644 index 0000000000..56745322ce --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.APIKeyVO.S2.json @@ -0,0 +1 @@ +{"id":null,"token":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.AddMemberRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.AddMemberRequest.D1.json new file mode 100644 index 0000000000..cdf88b6c26 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.AddMemberRequest.D1.json @@ -0,0 +1,4 @@ +{ + "userId" : "AddMemberRequest.userId", + "role" : "AddMemberRequest.role" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.CreateGroupRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.CreateGroupRequest.D1.json new file mode 100644 index 0000000000..c04816a6d7 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.CreateGroupRequest.D1.json @@ -0,0 +1,4 @@ +{ + "name" : "CreateGroupRequest.name", + "dynamicRule" : "CreateGroupRequest.dynamicRule" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberAggregateView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberAggregateView.S1.json new file mode 100644 index 0000000000..39893d1ed8 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberAggregateView.S1.json @@ -0,0 +1 @@ +{"visitorRole":"GroupMemberAggregateView.visitorRole","members":[{"userName":"GroupMemberAggregateView.members[0].userName","groupId":"GroupMemberAggregateView.members[0].groupId","orgId":"GroupMemberAggregateView.members[0].orgId","joinTime":3000000190,"userId":"GroupMemberAggregateView.members[0].userId","role":"super_admin","avatarUrl":"GroupMemberAggregateView.members[0].avatarUrl"},{"userName":"GroupMemberAggregateView.members[1].userName","groupId":"GroupMemberAggregateView.members[1].groupId","orgId":"GroupMemberAggregateView.members[1].orgId","joinTime":3000000191,"userId":"GroupMemberAggregateView.members[1].userId","role":"super_admin","avatarUrl":"GroupMemberAggregateView.members[1].avatarUrl"}],"total":40190,"pageNum":40191,"pageSize":40192} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberAggregateView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberAggregateView.S2.json new file mode 100644 index 0000000000..23ec5f534d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberAggregateView.S2.json @@ -0,0 +1 @@ +{"visitorRole":null,"members":null,"total":null,"pageNum":null,"pageSize":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberView.S1.json new file mode 100644 index 0000000000..e84016e70a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberView.S1.json @@ -0,0 +1 @@ +{"userName":"GroupMemberView.userName","groupId":"GroupMemberView.groupId","orgId":"GroupMemberView.orgId","joinTime":3000000180,"userId":"GroupMemberView.userId","role":"super_admin","avatarUrl":"GroupMemberView.avatarUrl"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberView.S2.json new file mode 100644 index 0000000000..e84016e70a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberView.S2.json @@ -0,0 +1 @@ +{"userName":"GroupMemberView.userName","groupId":"GroupMemberView.groupId","orgId":"GroupMemberView.orgId","joinTime":3000000180,"userId":"GroupMemberView.userId","role":"super_admin","avatarUrl":"GroupMemberView.avatarUrl"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberView.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberView.S2Throwing.json new file mode 100644 index 0000000000..398bfb7111 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupMemberView.S2Throwing.json @@ -0,0 +1,13 @@ +{ + "groupMember" : { + "groupId" : "java.lang.NullPointerException", + "joinTime" : "java.lang.NullPointerException", + "orgId" : "java.lang.NullPointerException", + "role" : "java.lang.NullPointerException" + }, + "user" : { + "avatarUrl" : "java.lang.NullPointerException", + "userId" : "java.lang.NullPointerException", + "userName" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupView.S1.json new file mode 100644 index 0000000000..b505570275 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupView.S1.json @@ -0,0 +1 @@ +{"groupId":"GroupView.groupId","groupGid":"GroupView.groupGid","groupName":"GroupView.groupName","allUsersGroup":true,"visitorRole":"GroupView.visitorRole","createTime":3000000110,"dynamicRule":"GroupView.dynamicRule","stats":{"adminUserCount":40110,"userCount":40111,"users":["GroupView.stats.users[0]","GroupView.stats.users[1]"]},"syncGroup":true,"devGroup":false,"syncDelete":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupView.S2.json new file mode 100644 index 0000000000..b5fe93589f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.GroupView.S2.json @@ -0,0 +1 @@ +{"groupId":null,"groupGid":null,"groupName":null,"allUsersGroup":true,"visitorRole":null,"createTime":3000000110,"dynamicRule":null,"stats":null,"syncGroup":true,"devGroup":false,"syncDelete":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.InvitationVO.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.InvitationVO.S1.json new file mode 100644 index 0000000000..4d7d656184 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.InvitationVO.S1.json @@ -0,0 +1 @@ +{"inviteCode":"InvitationVO.inviteCode","createUserName":"InvitationVO.createUserName","invitedOrganizationName":"InvitationVO.invitedOrganizationName","invitedOrganizationId":"InvitationVO.invitedOrganizationId"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.InvitationVO.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.InvitationVO.S2.json new file mode 100644 index 0000000000..5a8cedf9ce --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.InvitationVO.S2.json @@ -0,0 +1 @@ +{"inviteCode":null,"createUserName":null,"invitedOrganizationName":null,"invitedOrganizationId":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S1.json new file mode 100644 index 0000000000..b34926902c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S1.json @@ -0,0 +1 @@ +{"org":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"SECRET-Organization.authConfigs[1].clientSecret","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Organization.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Organization.authConfigs[2].authorizationEndpoint?response_type=code&client_id=Organization.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope","authorizeUrl":"Organization.authConfigs[3].baseUrl/realms/Organization.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=Organization.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope","authorizeUrl":"Organization.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=Organization.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[4].scope"}]},"role":"OrgAndVisitorRoleView.role"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S1Internal.json new file mode 100644 index 0000000000..acd7c7ddf3 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S1Internal.json @@ -0,0 +1 @@ +{"org":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"SECRET-Organization.authConfigs[1].clientSecret"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope"}]},"role":"OrgAndVisitorRoleView.role"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S1Public.json new file mode 100644 index 0000000000..3d56783273 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S1Public.json @@ -0,0 +1 @@ +{"org":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Organization.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Organization.authConfigs[2].authorizationEndpoint?response_type=code&client_id=Organization.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope","authorizeUrl":"Organization.authConfigs[3].baseUrl/realms/Organization.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=Organization.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope","authorizeUrl":"Organization.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=Organization.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[4].scope"}]},"role":"OrgAndVisitorRoleView.role"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S2.json new file mode 100644 index 0000000000..ff393df738 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgAndVisitorRoleView.S2.json @@ -0,0 +1 @@ +{"org":null,"role":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView$OrgMemberView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView$OrgMemberView.S1.json new file mode 100644 index 0000000000..7dd2262bef --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView$OrgMemberView.S1.json @@ -0,0 +1 @@ +{"userId":"OrgMemberView.userId","name":"OrgMemberView.name","avatarUrl":"OrgMemberView.avatarUrl","role":"OrgMemberView.role","joinTime":3000000120,"rawUserInfos":{"OrgMemberView.rawUserInfos.first":{"sub":"OrgMemberView.rawUserInfos.first.sub","loginCount":40120},"OrgMemberView.rawUserInfos.second":{"sub":"OrgMemberView.rawUserInfos.second.sub","loginCount":40121}}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView$OrgMemberView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView$OrgMemberView.S2.json new file mode 100644 index 0000000000..636441d23c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView$OrgMemberView.S2.json @@ -0,0 +1 @@ +{"userId":null,"name":null,"avatarUrl":null,"role":null,"joinTime":3000000120,"rawUserInfos":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView.S1.json new file mode 100644 index 0000000000..ab99963b71 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView.S1.json @@ -0,0 +1 @@ +{"visitorRole":"OrgMemberListView.visitorRole","members":[{"userId":"OrgMemberListView.members[0].userId","name":"OrgMemberListView.members[0].name","avatarUrl":"OrgMemberListView.members[0].avatarUrl","role":"OrgMemberListView.members[0].role","joinTime":3000000130,"rawUserInfos":{"OrgMemberListView.members[0].rawUserInfos.first":{"sub":"OrgMemberListView.members[0].rawUserInfos.first.sub","loginCount":40130},"OrgMemberListView.members[0].rawUserInfos.second":{"sub":"OrgMemberListView.members[0].rawUserInfos.second.sub","loginCount":40131}}},{"userId":"OrgMemberListView.members[1].userId","name":"OrgMemberListView.members[1].name","avatarUrl":"OrgMemberListView.members[1].avatarUrl","role":"OrgMemberListView.members[1].role","joinTime":3000000131,"rawUserInfos":{"OrgMemberListView.members[1].rawUserInfos.first":{"sub":"OrgMemberListView.members[1].rawUserInfos.first.sub","loginCount":40131},"OrgMemberListView.members[1].rawUserInfos.second":{"sub":"OrgMemberListView.members[1].rawUserInfos.second.sub","loginCount":40132}}}],"total":40150,"pageNum":40151,"pageSize":40152} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView.S2.json new file mode 100644 index 0000000000..23ec5f534d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgMemberListView.S2.json @@ -0,0 +1 @@ +{"visitorRole":null,"members":null,"total":null,"pageNum":null,"pageSize":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgView.S1.json new file mode 100644 index 0000000000..e9468a7cc2 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgView.S1.json @@ -0,0 +1 @@ +{"createdAt":1767225730.123456789,"updatedAt":1767225731.123456789,"orgId":"OrgView.orgId","orgName":"OrgView.orgName"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgView.S2.json new file mode 100644 index 0000000000..e9468a7cc2 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgView.S2.json @@ -0,0 +1 @@ +{"createdAt":1767225730.123456789,"updatedAt":1767225731.123456789,"orgId":"OrgView.orgId","orgName":"OrgView.orgName"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgView.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgView.S2Throwing.json new file mode 100644 index 0000000000..928b3ef156 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.OrgView.S2Throwing.json @@ -0,0 +1,8 @@ +{ + "organization" : { + "createdAt" : "java.lang.NullPointerException", + "orgId" : "java.lang.NullPointerException", + "orgName" : "java.lang.NullPointerException", + "updatedAt" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateGroupRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateGroupRequest.D1.json new file mode 100644 index 0000000000..1fe32ead9b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateGroupRequest.D1.json @@ -0,0 +1,4 @@ +{ + "groupName" : "UpdateGroupRequest.groupName", + "dynamicRule" : "UpdateGroupRequest.dynamicRule" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateOrgRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateOrgRequest.D1.json new file mode 100644 index 0000000000..ae207b55d4 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateOrgRequest.D1.json @@ -0,0 +1,7 @@ +{ + "orgName" : "UpdateOrgRequest.orgName", + "contactName" : "UpdateOrgRequest.contactName", + "contactEmail" : "UpdateOrgRequest.contactEmail", + "contactPhoneNumber" : "UpdateOrgRequest.contactPhoneNumber", + "state" : "DELETED" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateRoleRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateRoleRequest.D1.json new file mode 100644 index 0000000000..0296a15b8d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateRoleRequest.D1.json @@ -0,0 +1,4 @@ +{ + "userId" : "UpdateRoleRequest.userId", + "role" : "admin" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateUserRequest.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateUserRequest.D1.json new file mode 100644 index 0000000000..1c5ae883a1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UpdateUserRequest.D1.json @@ -0,0 +1,4 @@ +{ + "name" : "UpdateUserRequest.name", + "uiLanguage" : "UpdateUserRequest.uiLanguage" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S1.json new file mode 100644 index 0000000000..b4ae3b444d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S1.json @@ -0,0 +1 @@ +{"id":"UserProfileView.id","orgAndRoles":[{"org":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"SECRET-Organization.authConfigs[1].clientSecret","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Organization.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Organization.authConfigs[2].authorizationEndpoint?response_type=code&client_id=Organization.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope","authorizeUrl":"Organization.authConfigs[3].baseUrl/realms/Organization.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=Organization.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope","authorizeUrl":"Organization.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=Organization.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[4].scope"}]},"role":"UserProfileView.orgAndRoles[0].role"},{"org":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"SECRET-Organization.authConfigs[1].clientSecret","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Organization.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Organization.authConfigs[2].authorizationEndpoint?response_type=code&client_id=Organization.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope","authorizeUrl":"Organization.authConfigs[3].baseUrl/realms/Organization.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=Organization.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope","authorizeUrl":"Organization.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=Organization.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[4].scope"}]},"role":"UserProfileView.orgAndRoles[1].role"}],"currentOrgId":"UserProfileView.currentOrgId","username":"UserProfileView.username","connections":[{"authId":"UserProfileView.connections[0].authId","source":"UserProfileView.connections[0].source","name":"UserProfileView.connections[0].name","email":"UserProfileView.connections[0].email","avatar":"UserProfileView.connections[0].avatar","rawUserInfo":{"sub":"UserProfileView.connections[0].rawUserInfo.sub","loginCount":40080},"tokens":["UserProfileView.connections[0].tokens[1]","UserProfileView.connections[0].tokens[0]"]},{"authId":"UserProfileView.connections[1].authId","source":"UserProfileView.connections[1].source","name":"UserProfileView.connections[1].name","email":"UserProfileView.connections[1].email","avatar":"UserProfileView.connections[1].avatar","rawUserInfo":{"sub":"UserProfileView.connections[1].rawUserInfo.sub","loginCount":40081},"tokens":["UserProfileView.connections[1].tokens[0]","UserProfileView.connections[1].tokens[1]"]}],"uiLanguage":"UserProfileView.uiLanguage","avatar":"UserProfileView.avatar","avatarUrl":"UserProfileView.avatarUrl","hasPassword":true,"hasSetNickname":false,"hasShownNewUserGuidance":true,"userStatus":{"newUserGuidance":true,"olderUserNonDevPopup":40130},"createdTimeMs":3000000130,"ip":"UserProfileView.ip","enabled":false,"anonymous":true,"orgDev":false,"isAnonymous":true,"isEnabled":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S1Internal.json new file mode 100644 index 0000000000..300e762a58 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S1Internal.json @@ -0,0 +1 @@ +{"id":"UserProfileView.id","orgAndRoles":[{"org":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"SECRET-Organization.authConfigs[1].clientSecret"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope"}]},"role":"UserProfileView.orgAndRoles[0].role"},{"org":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"SECRET-Organization.authConfigs[1].clientSecret"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope"}]},"role":"UserProfileView.orgAndRoles[1].role"}],"currentOrgId":"UserProfileView.currentOrgId","username":"UserProfileView.username","connections":[{"authId":"UserProfileView.connections[0].authId","source":"UserProfileView.connections[0].source","name":"UserProfileView.connections[0].name","email":"UserProfileView.connections[0].email","avatar":"UserProfileView.connections[0].avatar","rawUserInfo":{"sub":"UserProfileView.connections[0].rawUserInfo.sub","loginCount":40080},"tokens":["UserProfileView.connections[0].tokens[1]","UserProfileView.connections[0].tokens[0]"]},{"authId":"UserProfileView.connections[1].authId","source":"UserProfileView.connections[1].source","name":"UserProfileView.connections[1].name","email":"UserProfileView.connections[1].email","avatar":"UserProfileView.connections[1].avatar","rawUserInfo":{"sub":"UserProfileView.connections[1].rawUserInfo.sub","loginCount":40081},"tokens":["UserProfileView.connections[1].tokens[0]","UserProfileView.connections[1].tokens[1]"]}],"uiLanguage":"UserProfileView.uiLanguage","avatar":"UserProfileView.avatar","avatarUrl":"UserProfileView.avatarUrl","hasPassword":true,"hasSetNickname":false,"hasShownNewUserGuidance":true,"userStatus":{"newUserGuidance":true,"olderUserNonDevPopup":40130},"createdTimeMs":3000000130,"ip":"UserProfileView.ip","enabled":false,"anonymous":true,"orgDev":false,"isAnonymous":true,"isEnabled":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S1Public.json new file mode 100644 index 0000000000..50cc5e211f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S1Public.json @@ -0,0 +1 @@ +{"id":"UserProfileView.id","orgAndRoles":[{"org":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Organization.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Organization.authConfigs[2].authorizationEndpoint?response_type=code&client_id=Organization.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope","authorizeUrl":"Organization.authConfigs[3].baseUrl/realms/Organization.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=Organization.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope","authorizeUrl":"Organization.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=Organization.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[4].scope"}]},"role":"UserProfileView.orgAndRoles[0].role"},{"org":{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Organization.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Organization.authConfigs[2].authorizationEndpoint?response_type=code&client_id=Organization.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope","authorizeUrl":"Organization.authConfigs[3].baseUrl/realms/Organization.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=Organization.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope","authorizeUrl":"Organization.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=Organization.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[4].scope"}]},"role":"UserProfileView.orgAndRoles[1].role"}],"currentOrgId":"UserProfileView.currentOrgId","username":"UserProfileView.username","connections":[{"authId":"UserProfileView.connections[0].authId","source":"UserProfileView.connections[0].source","name":"UserProfileView.connections[0].name","email":"UserProfileView.connections[0].email","avatar":"UserProfileView.connections[0].avatar","rawUserInfo":{"sub":"UserProfileView.connections[0].rawUserInfo.sub","loginCount":40080},"tokens":["UserProfileView.connections[0].tokens[1]","UserProfileView.connections[0].tokens[0]"]},{"authId":"UserProfileView.connections[1].authId","source":"UserProfileView.connections[1].source","name":"UserProfileView.connections[1].name","email":"UserProfileView.connections[1].email","avatar":"UserProfileView.connections[1].avatar","rawUserInfo":{"sub":"UserProfileView.connections[1].rawUserInfo.sub","loginCount":40081},"tokens":["UserProfileView.connections[1].tokens[0]","UserProfileView.connections[1].tokens[1]"]}],"uiLanguage":"UserProfileView.uiLanguage","avatar":"UserProfileView.avatar","avatarUrl":"UserProfileView.avatarUrl","hasPassword":true,"hasSetNickname":false,"hasShownNewUserGuidance":true,"userStatus":{"newUserGuidance":true,"olderUserNonDevPopup":40130},"createdTimeMs":3000000130,"ip":"UserProfileView.ip","enabled":false,"anonymous":true,"orgDev":false,"isAnonymous":true,"isEnabled":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S2.json new file mode 100644 index 0000000000..90f2f9f381 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.api.usermanagement.view.UserProfileView.S2.json @@ -0,0 +1 @@ +{"id":null,"orgAndRoles":null,"currentOrgId":null,"username":null,"connections":null,"uiLanguage":null,"avatar":null,"avatarUrl":null,"hasPassword":true,"hasSetNickname":false,"hasShownNewUserGuidance":true,"userStatus":null,"createdTimeMs":3000000130,"ip":null,"enabled":false,"anonymous":true,"orgDev":false,"isAnonymous":true,"isEnabled":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.D1.json new file mode 100644 index 0000000000..82a35a62ed --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.D1.json @@ -0,0 +1,72 @@ +{ + "id" : "Application.id", + "createdBy" : "Application.createdBy", + "gid" : "Application.gid", + "slug" : "Application.slug", + "organizationId" : "Application.organizationId", + "name" : "Application.name", + "applicationType" : 40050, + "applicationStatus" : "DELETED", + "editingApplicationDSL" : { + "ui" : { + "compType" : "normal", + "comp" : { + "title" : "Application.editingApplicationDSL.ui.comp.title", + "width" : 1.50 + }, + "items" : [ { + "compType" : "module", + "comp" : { + "appId" : "Application.editingApplicationDSL.ui.items[0].comp.appId", + "height" : 40060 + } + }, { + "compType" : "module", + "comp" : { + "appId" : "Application.editingApplicationDSL.ui.items[1].comp.appId", + "height" : 40061 + } + }, { + "compType" : "text", + "comp" : { + "text" : "Application.editingApplicationDSL.ui.items[2].comp.text", + "hidden" : false + } + } ] + }, + "queries" : [ { + "id" : "Application.editingApplicationDSL.queries[0].id", + "gid" : "Application.editingApplicationDSL.queries[0].gid", + "name" : "Application.editingApplicationDSL.queries[0].name", + "datasourceId" : "Application.editingApplicationDSL.queries[0].datasourceId", + "comp" : { + "method" : "Application.editingApplicationDSL.queries[0].comp.method", + "path" : "Application.editingApplicationDSL.queries[0].comp.path" + }, + "triggerType" : "Application.editingApplicationDSL.queries[0].triggerType", + "timeout" : "Application.editingApplicationDSL.queries[0].timeout", + "compType" : "restApi" + }, { + "id" : "Application.editingApplicationDSL.queries[1].id", + "gid" : "Application.editingApplicationDSL.queries[1].gid", + "name" : "Application.editingApplicationDSL.queries[1].name", + "datasourceId" : "Application.editingApplicationDSL.queries[1].datasourceId", + "comp" : { + "libraryQueryId" : "Application.editingApplicationDSL.queries[1].comp.libraryQueryId", + "libraryQueryRecordId" : "Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId" + }, + "triggerType" : "Application.editingApplicationDSL.queries[1].triggerType", + "timeout" : "Application.editingApplicationDSL.queries[1].timeout", + "compType" : "libraryQuery" + } ], + "settings" : { + "title" : "Application.editingApplicationDSL.settings.title", + "maxWidth" : 40062 + } + }, + "publicToAll" : true, + "publicToMarketplace" : false, + "agencyProfile" : true, + "editingUserId" : "Application.editingUserId", + "lastEditedAt" : 1767225650.123456789 +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.D1Instant.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.D1Instant.json new file mode 100644 index 0000000000..9f75bc264e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.D1Instant.json @@ -0,0 +1,7 @@ +{ + "lastEditedAt" : { + "integer" : "2026-01-01T00:00:50Z", + "decimal" : "2026-01-01T00:00:50.123456789Z", + "iso" : "2026-01-01T00:00:50.123456789Z" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.S1.json new file mode 100644 index 0000000000..c233a941ee --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.S1.json @@ -0,0 +1 @@ +{"id":"Application.id","orgId":"Application.organizationId","gid":"Application.gid","name":"Application.name","applicationType":40050,"applicationStatus":"DELETED","editingApplicationDSL":{"ui":{"compType":"normal","comp":{"title":"Application.editingApplicationDSL.ui.comp.title","width":1.5},"items":[{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[0].comp.appId","height":40060}},{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[1].comp.appId","height":40061}},{"compType":"text","comp":{"text":"Application.editingApplicationDSL.ui.items[2].comp.text","hidden":false}}]},"queries":[{"id":"Application.editingApplicationDSL.queries[0].id","gid":"Application.editingApplicationDSL.queries[0].gid","name":"Application.editingApplicationDSL.queries[0].name","datasourceId":"Application.editingApplicationDSL.queries[0].datasourceId","comp":{"method":"Application.editingApplicationDSL.queries[0].comp.method","path":"Application.editingApplicationDSL.queries[0].comp.path"},"triggerType":"Application.editingApplicationDSL.queries[0].triggerType","timeout":"Application.editingApplicationDSL.queries[0].timeout","compType":"restApi"},{"id":"Application.editingApplicationDSL.queries[1].id","gid":"Application.editingApplicationDSL.queries[1].gid","name":"Application.editingApplicationDSL.queries[1].name","datasourceId":"Application.editingApplicationDSL.queries[1].datasourceId","comp":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId"},"triggerType":"Application.editingApplicationDSL.queries[1].triggerType","timeout":"Application.editingApplicationDSL.queries[1].timeout","compType":"libraryQuery"}],"settings":{"title":"Application.editingApplicationDSL.settings.title","maxWidth":40062}},"publicToAll":true,"publicToMarketplace":false,"editingUserId":"Application.editingUserId","lastEditedAt":1767225650.123456789,"createdBy":"Application.createdBy","slug":"Application.slug","editingQueries":[{"id":"Application.editingApplicationDSL.queries[0].id","gid":"Application.editingApplicationDSL.queries[0].gid","name":"Application.editingApplicationDSL.queries[0].name","triggerType":"Application.editingApplicationDSL.queries[0].triggerType","timeout":"Application.editingApplicationDSL.queries[0].timeout","baseQuery":{"datasourceId":"Application.editingApplicationDSL.queries[0].datasourceId","compType":"restApi","comp":{"method":"Application.editingApplicationDSL.queries[0].comp.method","path":"Application.editingApplicationDSL.queries[0].comp.path"},"timeout":"Application.editingApplicationDSL.queries[0].timeout"},"usingLibraryQuery":false,"libraryRecordQueryId":{"libraryQueryId":null,"libraryQueryRecordId":null,"usingLiveRecord":false,"usingEditingRecord":true}},{"id":"Application.editingApplicationDSL.queries[1].id","gid":"Application.editingApplicationDSL.queries[1].gid","name":"Application.editingApplicationDSL.queries[1].name","triggerType":"Application.editingApplicationDSL.queries[1].triggerType","timeout":"Application.editingApplicationDSL.queries[1].timeout","baseQuery":{"datasourceId":"Application.editingApplicationDSL.queries[1].datasourceId","compType":"libraryQuery","comp":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId"},"timeout":"Application.editingApplicationDSL.queries[1].timeout"},"usingLibraryQuery":true,"libraryRecordQueryId":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId","usingLiveRecord":false,"usingEditingRecord":false}}],"editingModules":["Application.editingApplicationDSL.ui.items[0].comp.appId","Application.editingApplicationDSL.ui.items[1].comp.appId"],"editingApplicationDSLOrNull":{"ui":{"compType":"normal","comp":{"title":"Application.editingApplicationDSL.ui.comp.title","width":1.5},"items":[{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[0].comp.appId","height":40060}},{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[1].comp.appId","height":40061}},{"compType":"text","comp":{"text":"Application.editingApplicationDSL.ui.items[2].comp.text","hidden":false}}]},"queries":[{"id":"Application.editingApplicationDSL.queries[0].id","gid":"Application.editingApplicationDSL.queries[0].gid","name":"Application.editingApplicationDSL.queries[0].name","datasourceId":"Application.editingApplicationDSL.queries[0].datasourceId","comp":{"method":"Application.editingApplicationDSL.queries[0].comp.method","path":"Application.editingApplicationDSL.queries[0].comp.path"},"triggerType":"Application.editingApplicationDSL.queries[0].triggerType","timeout":"Application.editingApplicationDSL.queries[0].timeout","compType":"restApi"},{"id":"Application.editingApplicationDSL.queries[1].id","gid":"Application.editingApplicationDSL.queries[1].gid","name":"Application.editingApplicationDSL.queries[1].name","datasourceId":"Application.editingApplicationDSL.queries[1].datasourceId","comp":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId"},"triggerType":"Application.editingApplicationDSL.queries[1].triggerType","timeout":"Application.editingApplicationDSL.queries[1].timeout","compType":"libraryQuery"}],"settings":{"title":"Application.editingApplicationDSL.settings.title","maxWidth":40062}}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.S2.json new file mode 100644 index 0000000000..2c80c52ba4 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.S2.json @@ -0,0 +1 @@ +{"id":null,"orgId":null,"gid":null,"name":null,"applicationType":1,"applicationStatus":null,"editingApplicationDSL":{"ui":{"compType":"normal","comp":{"title":"Application.editingApplicationDSL.ui.comp.title","width":1.5},"items":[{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[0].comp.appId","height":40060}},{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[1].comp.appId","height":40061}},{"compType":"text","comp":{"text":"Application.editingApplicationDSL.ui.items[2].comp.text","hidden":false}}]},"queries":[{"id":"Application.editingApplicationDSL.queries[0].id","gid":"Application.editingApplicationDSL.queries[0].gid","name":"Application.editingApplicationDSL.queries[0].name","datasourceId":"Application.editingApplicationDSL.queries[0].datasourceId","comp":{"method":"Application.editingApplicationDSL.queries[0].comp.method","path":"Application.editingApplicationDSL.queries[0].comp.path"},"triggerType":"Application.editingApplicationDSL.queries[0].triggerType","timeout":"Application.editingApplicationDSL.queries[0].timeout","compType":"restApi"},{"id":"Application.editingApplicationDSL.queries[1].id","gid":"Application.editingApplicationDSL.queries[1].gid","name":"Application.editingApplicationDSL.queries[1].name","datasourceId":"Application.editingApplicationDSL.queries[1].datasourceId","comp":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId"},"triggerType":"Application.editingApplicationDSL.queries[1].triggerType","timeout":"Application.editingApplicationDSL.queries[1].timeout","compType":"libraryQuery"}],"settings":{"title":"Application.editingApplicationDSL.settings.title","maxWidth":40062}},"publicToAll":false,"publicToMarketplace":false,"editingUserId":null,"lastEditedAt":null,"createdBy":null,"slug":null,"editingQueries":[{"id":"Application.editingApplicationDSL.queries[0].id","gid":"Application.editingApplicationDSL.queries[0].gid","name":"Application.editingApplicationDSL.queries[0].name","triggerType":"Application.editingApplicationDSL.queries[0].triggerType","timeout":"Application.editingApplicationDSL.queries[0].timeout","baseQuery":{"datasourceId":"Application.editingApplicationDSL.queries[0].datasourceId","compType":"restApi","comp":{"method":"Application.editingApplicationDSL.queries[0].comp.method","path":"Application.editingApplicationDSL.queries[0].comp.path"},"timeout":"Application.editingApplicationDSL.queries[0].timeout"},"usingLibraryQuery":false,"libraryRecordQueryId":{"libraryQueryId":null,"libraryQueryRecordId":null,"usingLiveRecord":false,"usingEditingRecord":true}},{"id":"Application.editingApplicationDSL.queries[1].id","gid":"Application.editingApplicationDSL.queries[1].gid","name":"Application.editingApplicationDSL.queries[1].name","triggerType":"Application.editingApplicationDSL.queries[1].triggerType","timeout":"Application.editingApplicationDSL.queries[1].timeout","baseQuery":{"datasourceId":"Application.editingApplicationDSL.queries[1].datasourceId","compType":"libraryQuery","comp":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId"},"timeout":"Application.editingApplicationDSL.queries[1].timeout"},"usingLibraryQuery":true,"libraryRecordQueryId":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId","usingLiveRecord":false,"usingEditingRecord":false}}],"editingModules":["Application.editingApplicationDSL.ui.items[0].comp.appId","Application.editingApplicationDSL.ui.items[1].comp.appId"],"editingApplicationDSLOrNull":{"ui":{"compType":"normal","comp":{"title":"Application.editingApplicationDSL.ui.comp.title","width":1.5},"items":[{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[0].comp.appId","height":40060}},{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[1].comp.appId","height":40061}},{"compType":"text","comp":{"text":"Application.editingApplicationDSL.ui.items[2].comp.text","hidden":false}}]},"queries":[{"id":"Application.editingApplicationDSL.queries[0].id","gid":"Application.editingApplicationDSL.queries[0].gid","name":"Application.editingApplicationDSL.queries[0].name","datasourceId":"Application.editingApplicationDSL.queries[0].datasourceId","comp":{"method":"Application.editingApplicationDSL.queries[0].comp.method","path":"Application.editingApplicationDSL.queries[0].comp.path"},"triggerType":"Application.editingApplicationDSL.queries[0].triggerType","timeout":"Application.editingApplicationDSL.queries[0].timeout","compType":"restApi"},{"id":"Application.editingApplicationDSL.queries[1].id","gid":"Application.editingApplicationDSL.queries[1].gid","name":"Application.editingApplicationDSL.queries[1].name","datasourceId":"Application.editingApplicationDSL.queries[1].datasourceId","comp":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId"},"triggerType":"Application.editingApplicationDSL.queries[1].triggerType","timeout":"Application.editingApplicationDSL.queries[1].timeout","compType":"libraryQuery"}],"settings":{"title":"Application.editingApplicationDSL.settings.title","maxWidth":40062}}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.S2Throwing.json new file mode 100644 index 0000000000..de102f82ce --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.Application.S2Throwing.json @@ -0,0 +1,5 @@ +{ + "editingApplicationDSL" : { + "editingModules" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.ApplicationStatus.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.ApplicationStatus.S1.json new file mode 100644 index 0000000000..7120d365db --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.application.model.ApplicationStatus.S1.json @@ -0,0 +1 @@ +["NORMAL","RECYCLED","DELETED"] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.Bundle.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.Bundle.D1.json new file mode 100644 index 0000000000..896255d829 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.Bundle.D1.json @@ -0,0 +1,47 @@ +{ + "id" : "Bundle.id", + "createdBy" : "Bundle.createdBy", + "gid" : "Bundle.gid", + "organizationId" : "Bundle.organizationId", + "name" : "Bundle.name", + "title" : "Bundle.title", + "description" : "Bundle.description", + "category" : "Bundle.category", + "image" : "Bundle.image", + "bundleStatus" : "RECYCLED", + "publicToAll" : true, + "publicToMarketplace" : false, + "agencyProfile" : true, + "editingBundleDSL" : { + "layout" : { + "items" : [ { + "appId" : "Bundle.editingBundleDSL.layout.items[0].appId", + "order" : 40200 + }, { + "appId" : "Bundle.editingBundleDSL.layout.items[1].appId", + "order" : 40201 + } ], + "width" : 1.50 + }, + "settings" : { + "title" : "Bundle.editingBundleDSL.settings.title", + "maxWidth" : 40202 + } + }, + "publishedBundleDSL" : { + "layout" : { + "items" : [ { + "appId" : "Bundle.publishedBundleDSL.layout.items[0].appId", + "order" : 40200 + }, { + "appId" : "Bundle.publishedBundleDSL.layout.items[1].appId", + "order" : 40201 + } ], + "width" : 1.50 + }, + "settings" : { + "title" : "Bundle.publishedBundleDSL.settings.title", + "maxWidth" : 40202 + } + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.BundleApplication.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.BundleApplication.S1.json new file mode 100644 index 0000000000..3984c1a379 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.BundleApplication.S1.json @@ -0,0 +1 @@ +{"application":{"id":"Application.id","orgId":"Application.organizationId","gid":"Application.gid","name":"Application.name","applicationType":40050,"applicationStatus":"DELETED","editingApplicationDSL":{"ui":{"compType":"normal","comp":{"title":"Application.editingApplicationDSL.ui.comp.title","width":1.5},"items":[{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[0].comp.appId","height":40060}},{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[1].comp.appId","height":40061}},{"compType":"text","comp":{"text":"Application.editingApplicationDSL.ui.items[2].comp.text","hidden":false}}]},"queries":[{"id":"Application.editingApplicationDSL.queries[0].id","gid":"Application.editingApplicationDSL.queries[0].gid","name":"Application.editingApplicationDSL.queries[0].name","datasourceId":"Application.editingApplicationDSL.queries[0].datasourceId","comp":{"method":"Application.editingApplicationDSL.queries[0].comp.method","path":"Application.editingApplicationDSL.queries[0].comp.path"},"triggerType":"Application.editingApplicationDSL.queries[0].triggerType","timeout":"Application.editingApplicationDSL.queries[0].timeout","compType":"restApi"},{"id":"Application.editingApplicationDSL.queries[1].id","gid":"Application.editingApplicationDSL.queries[1].gid","name":"Application.editingApplicationDSL.queries[1].name","datasourceId":"Application.editingApplicationDSL.queries[1].datasourceId","comp":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId"},"triggerType":"Application.editingApplicationDSL.queries[1].triggerType","timeout":"Application.editingApplicationDSL.queries[1].timeout","compType":"libraryQuery"}],"settings":{"title":"Application.editingApplicationDSL.settings.title","maxWidth":40062}},"publicToAll":true,"publicToMarketplace":false,"editingUserId":"Application.editingUserId","lastEditedAt":1767225650.123456789,"createdBy":"Application.createdBy","slug":"Application.slug","editingQueries":[{"id":"Application.editingApplicationDSL.queries[0].id","gid":"Application.editingApplicationDSL.queries[0].gid","name":"Application.editingApplicationDSL.queries[0].name","triggerType":"Application.editingApplicationDSL.queries[0].triggerType","timeout":"Application.editingApplicationDSL.queries[0].timeout","baseQuery":{"datasourceId":"Application.editingApplicationDSL.queries[0].datasourceId","compType":"restApi","comp":{"method":"Application.editingApplicationDSL.queries[0].comp.method","path":"Application.editingApplicationDSL.queries[0].comp.path"},"timeout":"Application.editingApplicationDSL.queries[0].timeout"},"usingLibraryQuery":false,"libraryRecordQueryId":{"libraryQueryId":null,"libraryQueryRecordId":null,"usingEditingRecord":true,"usingLiveRecord":false}},{"id":"Application.editingApplicationDSL.queries[1].id","gid":"Application.editingApplicationDSL.queries[1].gid","name":"Application.editingApplicationDSL.queries[1].name","triggerType":"Application.editingApplicationDSL.queries[1].triggerType","timeout":"Application.editingApplicationDSL.queries[1].timeout","baseQuery":{"datasourceId":"Application.editingApplicationDSL.queries[1].datasourceId","compType":"libraryQuery","comp":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId"},"timeout":"Application.editingApplicationDSL.queries[1].timeout"},"usingLibraryQuery":true,"libraryRecordQueryId":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId","usingEditingRecord":false,"usingLiveRecord":false}}],"editingModules":["Application.editingApplicationDSL.ui.items[0].comp.appId","Application.editingApplicationDSL.ui.items[1].comp.appId"],"editingApplicationDSLOrNull":{"ui":{"compType":"normal","comp":{"title":"Application.editingApplicationDSL.ui.comp.title","width":1.5},"items":[{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[0].comp.appId","height":40060}},{"compType":"module","comp":{"appId":"Application.editingApplicationDSL.ui.items[1].comp.appId","height":40061}},{"compType":"text","comp":{"text":"Application.editingApplicationDSL.ui.items[2].comp.text","hidden":false}}]},"queries":[{"id":"Application.editingApplicationDSL.queries[0].id","gid":"Application.editingApplicationDSL.queries[0].gid","name":"Application.editingApplicationDSL.queries[0].name","datasourceId":"Application.editingApplicationDSL.queries[0].datasourceId","comp":{"method":"Application.editingApplicationDSL.queries[0].comp.method","path":"Application.editingApplicationDSL.queries[0].comp.path"},"triggerType":"Application.editingApplicationDSL.queries[0].triggerType","timeout":"Application.editingApplicationDSL.queries[0].timeout","compType":"restApi"},{"id":"Application.editingApplicationDSL.queries[1].id","gid":"Application.editingApplicationDSL.queries[1].gid","name":"Application.editingApplicationDSL.queries[1].name","datasourceId":"Application.editingApplicationDSL.queries[1].datasourceId","comp":{"libraryQueryId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryId","libraryQueryRecordId":"Application.editingApplicationDSL.queries[1].comp.libraryQueryRecordId"},"triggerType":"Application.editingApplicationDSL.queries[1].triggerType","timeout":"Application.editingApplicationDSL.queries[1].timeout","compType":"libraryQuery"}],"settings":{"title":"Application.editingApplicationDSL.settings.title","maxWidth":40062}}},"position":3000000210} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.BundleApplication.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.BundleApplication.S2.json new file mode 100644 index 0000000000..8f4232f636 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.BundleApplication.S2.json @@ -0,0 +1 @@ +{"application":null,"position":3000000210} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.BundleStatus.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.BundleStatus.S1.json new file mode 100644 index 0000000000..7120d365db --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.bundle.model.BundleStatus.S1.json @@ -0,0 +1 @@ +["NORMAL","RECYCLED","DELETED"] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S1.json new file mode 100644 index 0000000000..875dd45ee2 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S1.json @@ -0,0 +1 @@ +{"id":"Datasource.id","createdBy":"Datasource.createdBy","gid":"Datasource.gid","name":"Datasource.name","type":"Datasource.type","organizationId":"Datasource.organizationId","creationSource":40501,"datasourceStatus":"DELETED","pluginDefinition":{"name":"Datasource.pluginDefinition.name","dataSourceConfig":{"type":"Datasource.pluginDefinition.dataSourceConfig.type","params":["Datasource.pluginDefinition.dataSourceConfig.params[0]","Datasource.pluginDefinition.dataSourceConfig.params[1]"]},"id":"Datasource.pluginDefinition.id"},"createTime":1767226100123,"datasourceConfig":{"host":"Datasource.detailConfig.host","password":"SECRET-Datasource.detailConfig.password"}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S1Internal.json new file mode 100644 index 0000000000..f18b2495f5 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S1Internal.json @@ -0,0 +1 @@ +{"id":"Datasource.id","createdBy":"Datasource.createdBy","gid":"Datasource.gid","name":"Datasource.name","type":"Datasource.type","organizationId":"Datasource.organizationId","creationSource":40501,"datasourceStatus":"DELETED","pluginDefinition":{"name":"Datasource.pluginDefinition.name","dataSourceConfig":{"type":"Datasource.pluginDefinition.dataSourceConfig.type","params":["Datasource.pluginDefinition.dataSourceConfig.params[0]","Datasource.pluginDefinition.dataSourceConfig.params[1]"]},"id":"Datasource.pluginDefinition.id"},"createTime":1767226100123,"datasourceConfig":{"password":"SECRET-Datasource.detailConfig.password"}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S1Public.json new file mode 100644 index 0000000000..c700d537d6 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S1Public.json @@ -0,0 +1 @@ +{"id":"Datasource.id","createdBy":"Datasource.createdBy","gid":"Datasource.gid","name":"Datasource.name","type":"Datasource.type","organizationId":"Datasource.organizationId","creationSource":40501,"datasourceStatus":"DELETED","pluginDefinition":{"name":"Datasource.pluginDefinition.name","dataSourceConfig":{"type":"Datasource.pluginDefinition.dataSourceConfig.type","params":["Datasource.pluginDefinition.dataSourceConfig.params[0]","Datasource.pluginDefinition.dataSourceConfig.params[1]"]},"id":"Datasource.pluginDefinition.id"},"createTime":1767226100123,"datasourceConfig":{"host":"Datasource.detailConfig.host"}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S2.json new file mode 100644 index 0000000000..3587a319d6 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S2.json @@ -0,0 +1 @@ +{"id":null,"createdBy":null,"gid":null,"name":null,"type":null,"organizationId":null,"creationSource":40501,"datasourceStatus":"NORMAL","pluginDefinition":null,"createTime":1767226100123,"datasourceConfig":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S2Throwing.json new file mode 100644 index 0000000000..240fa1c447 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.Datasource.S2Throwing.json @@ -0,0 +1,5 @@ +{ + "createdAt" : { + "createTime" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.DatasourceStatus.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.DatasourceStatus.S1.json new file mode 100644 index 0000000000..32964f319e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.datasource.model.DatasourceStatus.S1.json @@ -0,0 +1 @@ +["NORMAL","DELETED"] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.folder.model.Folder.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.folder.model.Folder.D1.json new file mode 100644 index 0000000000..aa6d3ef92a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.folder.model.Folder.D1.json @@ -0,0 +1,14 @@ +{ + "id": "Folder.id", + "createdBy": "Folder.createdBy", + "organizationId": "Folder.organizationId", + "gid": "Folder.gid", + "parentFolderId": "Folder.parentFolderId", + "parentFolderGid": "Folder.parentFolderGid", + "name": "Folder.name", + "title": "Folder.title", + "description": "Folder.description", + "category": "Folder.category", + "type": "Folder.type", + "image": "Folder.image" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.material.model.MaterialType.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.material.model.MaterialType.S1.json new file mode 100644 index 0000000000..034c280e6f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.material.model.MaterialType.S1.json @@ -0,0 +1 @@ +["COMMON","LOGO","FAVICON"] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization$OrganizationCommonSettings.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization$OrganizationCommonSettings.S1.json new file mode 100644 index 0000000000..d5c62478be --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization$OrganizationCommonSettings.S1.json @@ -0,0 +1 @@ +{"maxRows":40160,"themeId":"OrganizationCommonSettings.themeId"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.D1.json new file mode 100644 index 0000000000..7f2e01d26f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.D1.json @@ -0,0 +1,86 @@ +{ + "id" : "Organization.id", + "createdBy" : "Organization.createdBy", + "gid" : "Organization.gid", + "slug" : "Organization.slug", + "name" : "Organization.name", + "isAutoGeneratedOrganization" : true, + "contactName" : "Organization.contactName", + "contactEmail" : "Organization.contactEmail", + "contactPhoneNumber" : "Organization.contactPhoneNumber", + "isEmailDisabled" : true, + "source" : "Organization.source", + "thirdPartyCompanyId" : "Organization.thirdPartyCompanyId", + "state" : "DELETED", + "organizationDomain" : { + "domain" : "Organization.organizationDomain.domain", + "configs" : [ { + "authType" : "FORM", + "id" : "Organization.authConfigs[0].id", + "enable" : true, + "enableRegister" : false, + "source" : "Organization.authConfigs[0].source", + "sourceName" : "Organization.authConfigs[0].sourceName" + }, { + "authType" : "GOOGLE", + "id" : "Organization.authConfigs[1].id", + "source" : "Organization.authConfigs[1].source", + "sourceName" : "Organization.authConfigs[1].sourceName", + "enable" : true, + "enableRegister" : false, + "clientId" : "Organization.authConfigs[1].clientId", + "clientSecret" : "SECRET-Organization.authConfigs[1].clientSecret" + }, { + "authType" : "GENERIC", + "id" : "Organization.authConfigs[2].id", + "source" : "Organization.authConfigs[2].source", + "sourceName" : "Organization.authConfigs[2].sourceName", + "enable" : true, + "enableRegister" : false, + "clientId" : "Organization.authConfigs[2].clientId", + "clientSecret" : "SECRET-Organization.authConfigs[2].clientSecret", + "sourceDescription" : "Organization.authConfigs[2].sourceDescription", + "sourceIcon" : "Organization.authConfigs[2].sourceIcon", + "sourceCategory" : "Organization.authConfigs[2].sourceCategory", + "issuerUri" : "Organization.authConfigs[2].issuerUri", + "authorizationEndpoint" : "Organization.authConfigs[2].authorizationEndpoint", + "tokenEndpoint" : "Organization.authConfigs[2].tokenEndpoint", + "userInfoEndpoint" : "Organization.authConfigs[2].userInfoEndpoint", + "scope" : "Organization.authConfigs[2].scope", + "sourceMappings" : { + "uid" : "Organization.authConfigs[2].sourceMappings.uid", + "email" : "Organization.authConfigs[2].sourceMappings.email" + }, + "userInfoIntrospection" : true, + "userCanSelectAccounts" : false, + "postForUserEndpoint" : true + }, { + "authType" : "KEYCLOAK", + "id" : "Organization.authConfigs[3].id", + "source" : "Organization.authConfigs[3].source", + "sourceName" : "Organization.authConfigs[3].sourceName", + "enable" : true, + "enableRegister" : false, + "clientId" : "Organization.authConfigs[3].clientId", + "clientSecret" : "SECRET-Organization.authConfigs[3].clientSecret", + "baseUrl" : "Organization.authConfigs[3].baseUrl", + "realm" : "Organization.authConfigs[3].realm", + "scope" : "Organization.authConfigs[3].scope" + }, { + "authType" : "ORY", + "id" : "Organization.authConfigs[4].id", + "source" : "Organization.authConfigs[4].source", + "sourceName" : "Organization.authConfigs[4].sourceName", + "enable" : true, + "enableRegister" : false, + "clientId" : "Organization.authConfigs[4].clientId", + "clientSecret" : "SECRET-Organization.authConfigs[4].clientSecret", + "baseUrl" : "Organization.authConfigs[4].baseUrl", + "scope" : "Organization.authConfigs[4].scope" + } ] + }, + "commonSettings" : { + "maxRows" : 40100, + "themeId" : "Organization.commonSettings.themeId" + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S1.json new file mode 100644 index 0000000000..963da4f602 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S1.json @@ -0,0 +1 @@ +{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"SECRET-Organization.authConfigs[1].clientSecret","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Organization.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Organization.authConfigs[2].authorizationEndpoint?response_type=code&client_id=Organization.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope","authorizeUrl":"Organization.authConfigs[3].baseUrl/realms/Organization.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=Organization.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope","authorizeUrl":"Organization.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=Organization.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[4].scope"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S1Internal.json new file mode 100644 index 0000000000..c02f5954dd --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S1Internal.json @@ -0,0 +1 @@ +{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","clientSecret":"SECRET-Organization.authConfigs[1].clientSecret"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","clientSecret":"SECRET-Organization.authConfigs[2].clientSecret","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","clientSecret":"SECRET-Organization.authConfigs[3].clientSecret","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","clientSecret":"SECRET-Organization.authConfigs[4].clientSecret","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S1Public.json new file mode 100644 index 0000000000..6dfdbd41a1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S1Public.json @@ -0,0 +1 @@ +{"id":"Organization.id","createdBy":"Organization.createdBy","gid":"Organization.gid","slug":"Organization.slug","name":"Organization.name","isAutoGeneratedOrganization":true,"contactName":"Organization.contactName","contactEmail":"Organization.contactEmail","contactPhoneNumber":"Organization.contactPhoneNumber","isEmailDisabled":true,"source":"Organization.source","thirdPartyCompanyId":"Organization.thirdPartyCompanyId","state":"DELETED","commonSettings":{"maxRows":40100,"themeId":"Organization.commonSettings.themeId"},"logoUrl":"/api/assets/Organization.logoAssetId","createTime":1767225700123,"authConfigs":[{"authType":"FORM","id":"Organization.authConfigs[0].id","enable":true,"enableRegister":false,"source":"Organization.authConfigs[0].source","sourceName":"Organization.authConfigs[0].sourceName","authType":"FORM"},{"authType":"GITHUB","id":"Organization.authConfigs[1].id","source":"Organization.authConfigs[1].source","sourceName":"Organization.authConfigs[1].sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Organization.authConfigs[1].clientId","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Organization.authConfigs[1].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"},{"authType":"GENERIC","id":"Organization.authConfigs[2].id","source":"Organization.authConfigs[2].source","sourceName":"Organization.authConfigs[2].sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Organization.authConfigs[2].clientId","sourceDescription":"Organization.authConfigs[2].sourceDescription","sourceIcon":"Organization.authConfigs[2].sourceIcon","sourceCategory":"Organization.authConfigs[2].sourceCategory","issuerUri":"Organization.authConfigs[2].issuerUri","authorizationEndpoint":"Organization.authConfigs[2].authorizationEndpoint","tokenEndpoint":"Organization.authConfigs[2].tokenEndpoint","userInfoEndpoint":"Organization.authConfigs[2].userInfoEndpoint","scope":"Organization.authConfigs[2].scope","sourceMappings":{"uid":"Organization.authConfigs[2].sourceMappings.uid","email":"Organization.authConfigs[2].sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Organization.authConfigs[2].authorizationEndpoint?response_type=code&client_id=Organization.authConfigs[2].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[2].scope&access_type=offline"},{"authType":"KEYCLOAK","id":"Organization.authConfigs[3].id","source":"Organization.authConfigs[3].source","sourceName":"Organization.authConfigs[3].sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Organization.authConfigs[3].clientId","baseUrl":"Organization.authConfigs[3].baseUrl","realm":"Organization.authConfigs[3].realm","scope":"Organization.authConfigs[3].scope","authorizeUrl":"Organization.authConfigs[3].baseUrl/realms/Organization.authConfigs[3].realm/protocol/openid-connect/auth?response_type=code&client_id=Organization.authConfigs[3].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[3].scope"},{"authType":"ORY","id":"Organization.authConfigs[4].id","source":"Organization.authConfigs[4].source","sourceName":"Organization.authConfigs[4].sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Organization.authConfigs[4].clientId","baseUrl":"Organization.authConfigs[4].baseUrl","scope":"Organization.authConfigs[4].scope","authorizeUrl":"Organization.authConfigs[4].baseUrl/oauth2/auth?response_type=code&client_id=Organization.authConfigs[4].clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Organization.authConfigs[4].scope"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S2.json new file mode 100644 index 0000000000..dd8b35a5d9 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.Organization.S2.json @@ -0,0 +1 @@ +{"id":null,"createdBy":null,"gid":null,"slug":null,"name":null,"isAutoGeneratedOrganization":null,"contactName":null,"contactEmail":null,"contactPhoneNumber":null,"isEmailDisabled":false,"source":null,"thirdPartyCompanyId":null,"state":null,"commonSettings":{},"logoUrl":"","createTime":0,"authConfigs":[]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.OrganizationDomain.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.OrganizationDomain.D1.json new file mode 100644 index 0000000000..aa35af108f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.OrganizationDomain.D1.json @@ -0,0 +1,35 @@ +{ + "domain" : "OrganizationDomain.domain", + "configs" : [ { + "authType" : "FORM", + "id" : "OrganizationDomain.configs[0].id", + "enable" : true, + "enableRegister" : false, + "source" : "OrganizationDomain.configs[0].source", + "sourceName" : "OrganizationDomain.configs[0].sourceName" + }, { + "authType" : "GENERIC", + "id" : "OrganizationDomain.configs[1].id", + "source" : "OrganizationDomain.configs[1].source", + "sourceName" : "OrganizationDomain.configs[1].sourceName", + "enable" : true, + "enableRegister" : false, + "clientId" : "OrganizationDomain.configs[1].clientId", + "clientSecret" : "SECRET-OrganizationDomain.configs[1].clientSecret", + "sourceDescription" : "OrganizationDomain.configs[1].sourceDescription", + "sourceIcon" : "OrganizationDomain.configs[1].sourceIcon", + "sourceCategory" : "OrganizationDomain.configs[1].sourceCategory", + "issuerUri" : "OrganizationDomain.configs[1].issuerUri", + "authorizationEndpoint" : "OrganizationDomain.configs[1].authorizationEndpoint", + "tokenEndpoint" : "OrganizationDomain.configs[1].tokenEndpoint", + "userInfoEndpoint" : "OrganizationDomain.configs[1].userInfoEndpoint", + "scope" : "OrganizationDomain.configs[1].scope", + "sourceMappings" : { + "uid" : "OrganizationDomain.configs[1].sourceMappings.uid", + "email" : "OrganizationDomain.configs[1].sourceMappings.email" + }, + "userInfoIntrospection" : true, + "userCanSelectAccounts" : false, + "postForUserEndpoint" : true + } ] +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.OrganizationState.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.OrganizationState.S1.json new file mode 100644 index 0000000000..09d2f51c29 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.organization.model.OrganizationState.S1.json @@ -0,0 +1 @@ +["ACTIVE","DELETED"] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.permission.model.ResourceHolder.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.permission.model.ResourceHolder.S1.json new file mode 100644 index 0000000000..d6bf1233f9 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.permission.model.ResourceHolder.S1.json @@ -0,0 +1 @@ +["USER","GROUP"] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.plugin.DatasourceMetaInfo.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.plugin.DatasourceMetaInfo.S1.json new file mode 100644 index 0000000000..41be914b50 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.plugin.DatasourceMetaInfo.S1.json @@ -0,0 +1 @@ +{"version":"DatasourceMetaInfo.version","hasStructureInfo":true,"definition":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"},"id":"DatasourceMetaInfo.type","name":"DatasourceMetaInfo.displayName"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.plugin.DatasourceMetaInfo.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.plugin.DatasourceMetaInfo.S2.json new file mode 100644 index 0000000000..36776fa69f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.plugin.DatasourceMetaInfo.S2.json @@ -0,0 +1 @@ +{"version":"default","hasStructureInfo":true,"definition":null,"id":null,"name":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.plugin.client.dto.GetPluginDynamicConfigRequestDTO.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.plugin.client.dto.GetPluginDynamicConfigRequestDTO.D1.json new file mode 100644 index 0000000000..5fbbe3763b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.plugin.client.dto.GetPluginDynamicConfigRequestDTO.D1.json @@ -0,0 +1,20 @@ +{ + "dataSourceId" : "GetPluginDynamicConfigRequestDTO.dataSourceId", + "pluginName" : "GetPluginDynamicConfigRequestDTO.pluginName", + "path" : "GetPluginDynamicConfigRequestDTO.path", + "dataSourceConfig" : { + "host" : "GetPluginDynamicConfigRequestDTO.dataSourceConfig.host", + "port" : 40503, + "timeoutMillis" : 3000000502, + "ratio" : 2.5, + "ssl" : true, + "databases" : [ + "GetPluginDynamicConfigRequestDTO.dataSourceConfig.databases[0]", + "GetPluginDynamicConfigRequestDTO.dataSourceConfig.databases[1]" + ], + "extra" : { + "region" : "GetPluginDynamicConfigRequestDTO.dataSourceConfig.extra.region", + "retries" : 40503 + } + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.ApplicationQuery.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.ApplicationQuery.S1.json new file mode 100644 index 0000000000..2514a43a5a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.ApplicationQuery.S1.json @@ -0,0 +1 @@ +{"id":"ApplicationQuery.id","gid":"ApplicationQuery.gid","name":"ApplicationQuery.name","triggerType":"ApplicationQuery.triggerType","timeout":"ApplicationQuery.timeout","baseQuery":{"datasourceId":"ApplicationQuery.datasourceId","compType":"libraryQuery","comp":{"libraryQueryId":"ApplicationQuery.comp.libraryQueryId","libraryQueryRecordId":"ApplicationQuery.comp.libraryQueryRecordId"},"timeout":"ApplicationQuery.timeout"},"usingLibraryQuery":true,"libraryRecordQueryId":{"libraryQueryId":"ApplicationQuery.comp.libraryQueryId","libraryQueryRecordId":"ApplicationQuery.comp.libraryQueryRecordId","usingLiveRecord":false,"usingEditingRecord":false}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.ApplicationQuery.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.ApplicationQuery.S2.json new file mode 100644 index 0000000000..762a4dc208 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.ApplicationQuery.S2.json @@ -0,0 +1 @@ +{"id":null,"gid":null,"name":null,"triggerType":null,"timeout":null,"baseQuery":{"datasourceId":"ApplicationQuery.datasourceId","compType":"libraryQuery","comp":{"libraryQueryId":"ApplicationQuery.comp.libraryQueryId","libraryQueryRecordId":"ApplicationQuery.comp.libraryQueryRecordId"},"timeout":"ApplicationQuery.timeout"},"usingLibraryQuery":true,"libraryRecordQueryId":{"libraryQueryId":"ApplicationQuery.comp.libraryQueryId","libraryQueryRecordId":"ApplicationQuery.comp.libraryQueryRecordId","usingLiveRecord":false,"usingEditingRecord":false}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.ApplicationQuery.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.ApplicationQuery.S2Throwing.json new file mode 100644 index 0000000000..df26af4120 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.ApplicationQuery.S2Throwing.json @@ -0,0 +1,6 @@ +{ + "baseQuery" : { + "libraryRecordQueryId" : "java.lang.NullPointerException", + "usingLibraryQuery" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.BaseQuery.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.BaseQuery.S1.json new file mode 100644 index 0000000000..700de54e65 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.BaseQuery.S1.json @@ -0,0 +1 @@ +{"datasourceId":"BaseQuery.datasourceId","compType":"BaseQuery.compType","comp":{"sql":"BaseQuery.comp.sql","limit":40070},"timeout":"BaseQuery.timeout"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.BaseQuery.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.BaseQuery.S2.json new file mode 100644 index 0000000000..a0c12e9b5f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.BaseQuery.S2.json @@ -0,0 +1 @@ +{"datasourceId":null,"compType":null,"comp":null,"timeout":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.LibraryQuery.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.LibraryQuery.D1.json new file mode 100644 index 0000000000..84448ae120 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.LibraryQuery.D1.json @@ -0,0 +1,25 @@ +{ + "id": "LibraryQuery.id", + "createdBy": "LibraryQuery.createdBy", + "gid": "LibraryQuery.gid", + "organizationId": "LibraryQuery.organizationId", + "name": "LibraryQuery.name", + "libraryQueryDSL": { + "query": { + "id": "LibraryQuery.libraryQueryDSL.query.id", + "name": "LibraryQuery.libraryQueryDSL.query.name", + "datasourceId": "LibraryQuery.libraryQueryDSL.query.datasourceId", + "compType": "restApi", + "comp": { + "method": "LibraryQuery.libraryQueryDSL.query.comp.method", + "path": "LibraryQuery.libraryQueryDSL.query.comp.path", + "bodyLimit": 40100, + "maxBytes": 3000000001, + "ratio": 1.50 + }, + "triggerType": "LibraryQuery.libraryQueryDSL.query.triggerType", + "timeout": "LibraryQuery.libraryQueryDSL.query.timeout" + }, + "version": 40101 + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.LibraryQueryCombineId.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.LibraryQueryCombineId.S1.json new file mode 100644 index 0000000000..ac0ae15e01 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.LibraryQueryCombineId.S1.json @@ -0,0 +1 @@ +{"libraryQueryId":"LibraryQueryCombineId.libraryQueryId","libraryQueryRecordId":"LibraryQueryCombineId.libraryQueryRecordId","usingLiveRecord":false,"usingEditingRecord":false} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.LibraryQueryCombineId.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.LibraryQueryCombineId.S2.json new file mode 100644 index 0000000000..c3b217da0c --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.query.model.LibraryQueryCombineId.S2.json @@ -0,0 +1 @@ +{"libraryQueryId":null,"libraryQueryRecordId":null,"usingLiveRecord":false,"usingEditingRecord":true} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.APIKey.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.APIKey.S1.json new file mode 100644 index 0000000000..7be6377710 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.APIKey.S1.json @@ -0,0 +1 @@ +{"id":"APIKey.id","name":"APIKey.name","description":"APIKey.description","token":"KNOWN-EXPOSURE-APIKey.token"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.APIKey.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.APIKey.S2.json new file mode 100644 index 0000000000..71c77d105e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.APIKey.S2.json @@ -0,0 +1 @@ +{"id":null,"name":null,"description":null,"token":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.Connection.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.Connection.S1.json new file mode 100644 index 0000000000..9999cc6518 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.Connection.S1.json @@ -0,0 +1 @@ +{"authId":"Connection.authId","source":"Connection.source","name":"Connection.name","email":"Connection.email","avatar":"Connection.avatar","rawUserInfo":{"sub":"Connection.rawUserInfo.sub","loginCount":40072},"tokens":["Connection.tokens[0]","Connection.tokens[1]"]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.Connection.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.Connection.S2.json new file mode 100644 index 0000000000..e9a57671d4 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.Connection.S2.json @@ -0,0 +1 @@ +{"authId":null,"source":null,"name":null,"email":null,"avatar":null,"rawUserInfo":{},"tokens":[]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User$TransformedUserInfo.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User$TransformedUserInfo.S1.json new file mode 100644 index 0000000000..ab444dd4ea --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User$TransformedUserInfo.S1.json @@ -0,0 +1 @@ +{"updateTime":3000000092,"extra":{"department":"TransformedUserInfo.extra.department","level":40092}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User$TransformedUserInfo.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User$TransformedUserInfo.S2.json new file mode 100644 index 0000000000..c681227d0f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User$TransformedUserInfo.S2.json @@ -0,0 +1 @@ +{"updateTime":3000000092,"extra":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User.S1.json new file mode 100644 index 0000000000..8925a0628b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User.S1.json @@ -0,0 +1 @@ +{"id":"User.id","createdBy":"User.createdBy","name":"User.name","email":"User.email","uiLanguage":"User.uiLanguage","avatar":"User.avatar","tpAvatarLink":"User.tpAvatarLink","superAdmin":true,"state":"INVITED","isEnabled":false,"activeAuthId":"User.activeAuthId","passwordResetToken":"KNOWN-EXPOSURE-User.passwordResetToken","passwordResetTokenExpiry":1767225660.123456789,"isAnonymous":true,"connections":[{"authId":"User.connections[0].authId","source":"User.connections[0].source","name":"User.connections[0].name","email":"User.connections[0].email","avatar":"User.connections[0].avatar","rawUserInfo":{"sub":"User.connections[0].rawUserInfo.sub","loginCount":40070},"tokens":["User.connections[0].tokens[0]","User.connections[0].tokens[1]"]},{"authId":"User.connections[1].authId","source":"User.connections[1].source","name":"User.connections[1].name","email":"User.connections[1].email","avatar":"User.connections[1].avatar","rawUserInfo":{"sub":"User.connections[1].rawUserInfo.sub","loginCount":40071},"tokens":["User.connections[1].tokens[0]","User.connections[1].tokens[1]"]}],"apiKeysList":[{"id":"User.apiKeysList[0].id","name":"User.apiKeysList[0].name","description":"User.apiKeysList[0].description","token":"KNOWN-EXPOSURE-User.apiKeysList[0].token"},{"id":"User.apiKeysList[1].id","name":"User.apiKeysList[1].name","description":"User.apiKeysList[1].description","token":"KNOWN-EXPOSURE-User.apiKeysList[1].token"}],"apiKeys":[{"id":"User.apiKeys[0].id","token":"KNOWN-EXPOSURE-User.apiKeys[0].token"},{"id":"User.apiKeys[1].id","token":"KNOWN-EXPOSURE-User.apiKeys[1].token"}],"hasSetNickname":true,"orgTransformedUserInfo":{"User.orgTransformedUserInfo.second":{"updateTime":3000000091,"extra":{"department":"User.orgTransformedUserInfo.second.extra.department","level":40091}},"User.orgTransformedUserInfo.first":{"updateTime":3000000090,"extra":{"department":"User.orgTransformedUserInfo.first.extra.department","level":40090}}}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User.S2.json new file mode 100644 index 0000000000..92fded9c15 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.User.S2.json @@ -0,0 +1 @@ +{"id":null,"createdBy":null,"name":null,"email":null,"uiLanguage":null,"avatar":null,"tpAvatarLink":null,"superAdmin":null,"state":null,"isEnabled":null,"activeAuthId":null,"passwordResetToken":null,"passwordResetTokenExpiry":null,"isAnonymous":null,"connections":[],"apiKeysList":null,"apiKeys":null,"hasSetNickname":true,"orgTransformedUserInfo":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.UserDetail.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.UserDetail.S1.json new file mode 100644 index 0000000000..3ff609ec4e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.UserDetail.S1.json @@ -0,0 +1 @@ +{"id":"UserDetail.id","name":"UserDetail.name","avatarUrl":"UserDetail.avatarUrl","uiLanguage":"UserDetail.uiLanguage","email":"UserDetail.email","ip":"UserDetail.ip","groups":[{"groupId":"UserDetail.groups[0].groupId","groupName":"UserDetail.groups[0].groupName"},{"groupId":"UserDetail.groups[1].groupId","groupName":"UserDetail.groups[1].groupName"}],"extra":{"department":"UserDetail.extra.department","level":40140},"userAuth":{"source":"UserDetail.userAuth.source","expiresIn":40141}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.UserDetail.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.UserDetail.S2.json new file mode 100644 index 0000000000..2bd54f6ef2 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.UserDetail.S2.json @@ -0,0 +1 @@ +{"id":null,"name":null,"avatarUrl":null,"uiLanguage":null,"email":null,"ip":null,"groups":null,"extra":null,"userAuth":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.UserState.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.UserState.S1.json new file mode 100644 index 0000000000..3a72abceb4 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.domain.user.model.UserState.S1.json @@ -0,0 +1 @@ +["NEW","INVITED","ACTIVATED","DELETED"] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.infra.config.model.ServerConfig.NotStored.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.infra.config.model.ServerConfig.NotStored.json new file mode 100644 index 0000000000..bc358e8372 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.infra.config.model.ServerConfig.NotStored.json @@ -0,0 +1 @@ +{"id":null,"createdBy":null,"key":"ConfigEndpointsContractTest.key","value":null} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.infra.config.model.ServerConfig.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.infra.config.model.ServerConfig.S1.json new file mode 100644 index 0000000000..ba0deb3630 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.infra.config.model.ServerConfig.S1.json @@ -0,0 +1 @@ +{"id":"ServerConfig.id","createdBy":"ServerConfig.createdBy","key":"ServerConfig.key","value":{"zeta":"first key, so that sorted output would differ from input order","int":1,"negativeInt":-40003,"intMax":2147483647,"intMaxPlusOne":2147483648,"intMinMinusOne":-2147483649,"longMax":9223372036854775807,"longMaxPlusOne":9223372036854775808,"longMinMinusOne":-9223372036854775809,"decimal":1.5,"trailingZeroDecimal":1.5,"zeroPointZero":0.0,"negativeZero":0,"exponent":100.0,"exponentUpperCase":100.0,"negativeExponent":0.0025,"longDecimal":0.1,"true":true,"false":false,"null":null,"emptyObject":{},"emptyArray":[],"nested":{"array":[1,"two",[3,{"four":4.0}],null,true,3000000001],"object":{"z":1,"a":{"deeper":[[],{}]}}},"unicode":"žluťoučký kůň 🐎 中文","escapes":"é \u0000 \n \t \" \\ / 😀","key with escaped name":"escaped key","alpha":"last key"}} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.infra.config.model.ServerConfig.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.infra.config.model.ServerConfig.S2.json new file mode 100644 index 0000000000..b387bb2bbd --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.infra.config.model.ServerConfig.S2.json @@ -0,0 +1 @@ +{"id":null,"createdBy":null,"key":null,"value":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.AbstractAuthConfig.ids.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.AbstractAuthConfig.ids.json new file mode 100644 index 0000000000..37dfabc60d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.AbstractAuthConfig.ids.json @@ -0,0 +1,8 @@ +{ + "FORM" : "org.lowcoder.sdk.auth.EmailAuthConfig", + "GENERIC" : "org.lowcoder.sdk.auth.Oauth2GenericAuthConfig", + "GITHUB" : "org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig", + "GOOGLE" : "org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig", + "KEYCLOAK" : "org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig", + "ORY" : "org.lowcoder.sdk.auth.Oauth2OryAuthConfig" +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.EmailAuthConfig.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.EmailAuthConfig.D1.json new file mode 100644 index 0000000000..ef433baa22 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.EmailAuthConfig.D1.json @@ -0,0 +1,8 @@ +{ + "id" : "EmailAuthConfig.id", + "source" : "EmailAuthConfig.source", + "sourceName" : "EmailAuthConfig.sourceName", + "enable" : true, + "enableRegister" : false, + "authType" : "FORM" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.EmailAuthConfig.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.EmailAuthConfig.S1.json new file mode 100644 index 0000000000..b1b99fc657 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.EmailAuthConfig.S1.json @@ -0,0 +1 @@ +{"authType":"FORM","id":"EmailAuthConfig.id","enable":true,"enableRegister":false,"source":"EmailAuthConfig.source","sourceName":"EmailAuthConfig.sourceName","authType":"FORM"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.EmailAuthConfig.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.EmailAuthConfig.S2.json new file mode 100644 index 0000000000..655247d021 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.EmailAuthConfig.S2.json @@ -0,0 +1 @@ +{"authType":"FORM","id":null,"enable":null,"enableRegister":null,"source":null,"sourceName":null,"authType":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.D1.json new file mode 100644 index 0000000000..7f1830e95d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.D1.json @@ -0,0 +1,25 @@ +{ + "id" : "Oauth2GenericAuthConfig.id", + "source" : "Oauth2GenericAuthConfig.source", + "sourceName" : "Oauth2GenericAuthConfig.sourceName", + "enable" : true, + "enableRegister" : false, + "authType" : "GENERIC", + "clientId" : "Oauth2GenericAuthConfig.clientId", + "clientSecret" : "SECRET-Oauth2GenericAuthConfig.clientSecret", + "sourceDescription" : "Oauth2GenericAuthConfig.sourceDescription", + "sourceIcon" : "Oauth2GenericAuthConfig.sourceIcon", + "sourceCategory" : "Oauth2GenericAuthConfig.sourceCategory", + "issuerUri" : "Oauth2GenericAuthConfig.issuerUri", + "authorizationEndpoint" : "Oauth2GenericAuthConfig.authorizationEndpoint", + "tokenEndpoint" : "Oauth2GenericAuthConfig.tokenEndpoint", + "userInfoEndpoint" : "Oauth2GenericAuthConfig.userInfoEndpoint", + "scope" : "Oauth2GenericAuthConfig.scope", + "sourceMappings" : { + "uid" : "Oauth2GenericAuthConfig.sourceMappings.uid", + "email" : "Oauth2GenericAuthConfig.sourceMappings.email" + }, + "userInfoIntrospection" : true, + "userCanSelectAccounts" : false, + "postForUserEndpoint" : true +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S1.json new file mode 100644 index 0000000000..30f632cf7b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S1.json @@ -0,0 +1 @@ +{"authType":"GENERIC","id":"Oauth2GenericAuthConfig.id","source":"Oauth2GenericAuthConfig.source","sourceName":"Oauth2GenericAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Oauth2GenericAuthConfig.clientId","clientSecret":"SECRET-Oauth2GenericAuthConfig.clientSecret","sourceDescription":"Oauth2GenericAuthConfig.sourceDescription","sourceIcon":"Oauth2GenericAuthConfig.sourceIcon","sourceCategory":"Oauth2GenericAuthConfig.sourceCategory","issuerUri":"Oauth2GenericAuthConfig.issuerUri","authorizationEndpoint":"Oauth2GenericAuthConfig.authorizationEndpoint","tokenEndpoint":"Oauth2GenericAuthConfig.tokenEndpoint","userInfoEndpoint":"Oauth2GenericAuthConfig.userInfoEndpoint","scope":"Oauth2GenericAuthConfig.scope","sourceMappings":{"uid":"Oauth2GenericAuthConfig.sourceMappings.uid","email":"Oauth2GenericAuthConfig.sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Oauth2GenericAuthConfig.authorizationEndpoint?response_type=code&client_id=Oauth2GenericAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Oauth2GenericAuthConfig.scope&access_type=offline"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S1Internal.json new file mode 100644 index 0000000000..ad83a16b85 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S1Internal.json @@ -0,0 +1 @@ +{"authType":"GENERIC","id":"Oauth2GenericAuthConfig.id","source":"Oauth2GenericAuthConfig.source","sourceName":"Oauth2GenericAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Oauth2GenericAuthConfig.clientId","clientSecret":"SECRET-Oauth2GenericAuthConfig.clientSecret","sourceDescription":"Oauth2GenericAuthConfig.sourceDescription","sourceIcon":"Oauth2GenericAuthConfig.sourceIcon","sourceCategory":"Oauth2GenericAuthConfig.sourceCategory","issuerUri":"Oauth2GenericAuthConfig.issuerUri","authorizationEndpoint":"Oauth2GenericAuthConfig.authorizationEndpoint","tokenEndpoint":"Oauth2GenericAuthConfig.tokenEndpoint","userInfoEndpoint":"Oauth2GenericAuthConfig.userInfoEndpoint","scope":"Oauth2GenericAuthConfig.scope","sourceMappings":{"uid":"Oauth2GenericAuthConfig.sourceMappings.uid","email":"Oauth2GenericAuthConfig.sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S1Public.json new file mode 100644 index 0000000000..826890f955 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S1Public.json @@ -0,0 +1 @@ +{"authType":"GENERIC","id":"Oauth2GenericAuthConfig.id","source":"Oauth2GenericAuthConfig.source","sourceName":"Oauth2GenericAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"GENERIC","clientId":"Oauth2GenericAuthConfig.clientId","sourceDescription":"Oauth2GenericAuthConfig.sourceDescription","sourceIcon":"Oauth2GenericAuthConfig.sourceIcon","sourceCategory":"Oauth2GenericAuthConfig.sourceCategory","issuerUri":"Oauth2GenericAuthConfig.issuerUri","authorizationEndpoint":"Oauth2GenericAuthConfig.authorizationEndpoint","tokenEndpoint":"Oauth2GenericAuthConfig.tokenEndpoint","userInfoEndpoint":"Oauth2GenericAuthConfig.userInfoEndpoint","scope":"Oauth2GenericAuthConfig.scope","sourceMappings":{"uid":"Oauth2GenericAuthConfig.sourceMappings.uid","email":"Oauth2GenericAuthConfig.sourceMappings.email"},"userInfoIntrospection":true,"userCanSelectAccounts":false,"postForUserEndpoint":true,"authorizeUrl":"Oauth2GenericAuthConfig.authorizationEndpoint?response_type=code&client_id=Oauth2GenericAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Oauth2GenericAuthConfig.scope&access_type=offline"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S2.json new file mode 100644 index 0000000000..b149b90913 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S2.json @@ -0,0 +1 @@ +{"authType":"GENERIC","id":null,"source":null,"sourceName":null,"enable":null,"enableRegister":null,"authType":"GENERIC","clientId":"Oauth2GenericAuthConfig.clientId","clientSecret":null,"sourceDescription":null,"sourceIcon":null,"sourceCategory":null,"issuerUri":null,"authorizationEndpoint":"Oauth2GenericAuthConfig.authorizationEndpoint","tokenEndpoint":null,"userInfoEndpoint":null,"scope":"Oauth2GenericAuthConfig.scope","sourceMappings":null,"userInfoIntrospection":null,"userCanSelectAccounts":null,"postForUserEndpoint":null,"authorizeUrl":"Oauth2GenericAuthConfig.authorizationEndpoint?response_type=code&client_id=Oauth2GenericAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Oauth2GenericAuthConfig.scope&access_type=offline&prompt=select_account"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S2Throwing.json new file mode 100644 index 0000000000..4e185c367a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2GenericAuthConfig.S2Throwing.json @@ -0,0 +1,14 @@ +{ + "authorizationEndpoint" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "scope" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "clientId" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "authType" : { + "authorizeUrl" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.D1.json new file mode 100644 index 0000000000..44cc118585 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.D1.json @@ -0,0 +1,13 @@ +{ + "id" : "Oauth2KeycloakAuthConfig.id", + "source" : "Oauth2KeycloakAuthConfig.source", + "sourceName" : "Oauth2KeycloakAuthConfig.sourceName", + "enable" : true, + "enableRegister" : false, + "authType" : "KEYCLOAK", + "clientId" : "Oauth2KeycloakAuthConfig.clientId", + "clientSecret" : "SECRET-Oauth2KeycloakAuthConfig.clientSecret", + "baseUrl" : "Oauth2KeycloakAuthConfig.baseUrl", + "realm" : "Oauth2KeycloakAuthConfig.realm", + "scope" : "Oauth2KeycloakAuthConfig.scope" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S1.json new file mode 100644 index 0000000000..65cb8becbd --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S1.json @@ -0,0 +1 @@ +{"authType":"KEYCLOAK","id":"Oauth2KeycloakAuthConfig.id","source":"Oauth2KeycloakAuthConfig.source","sourceName":"Oauth2KeycloakAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Oauth2KeycloakAuthConfig.clientId","clientSecret":"SECRET-Oauth2KeycloakAuthConfig.clientSecret","baseUrl":"Oauth2KeycloakAuthConfig.baseUrl","realm":"Oauth2KeycloakAuthConfig.realm","scope":"Oauth2KeycloakAuthConfig.scope","authorizeUrl":"Oauth2KeycloakAuthConfig.baseUrl/realms/Oauth2KeycloakAuthConfig.realm/protocol/openid-connect/auth?response_type=code&client_id=Oauth2KeycloakAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Oauth2KeycloakAuthConfig.scope"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S1Internal.json new file mode 100644 index 0000000000..c011bce6d2 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S1Internal.json @@ -0,0 +1 @@ +{"authType":"KEYCLOAK","id":"Oauth2KeycloakAuthConfig.id","source":"Oauth2KeycloakAuthConfig.source","sourceName":"Oauth2KeycloakAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Oauth2KeycloakAuthConfig.clientId","clientSecret":"SECRET-Oauth2KeycloakAuthConfig.clientSecret","baseUrl":"Oauth2KeycloakAuthConfig.baseUrl","realm":"Oauth2KeycloakAuthConfig.realm","scope":"Oauth2KeycloakAuthConfig.scope"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S1Public.json new file mode 100644 index 0000000000..5a16aff07b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S1Public.json @@ -0,0 +1 @@ +{"authType":"KEYCLOAK","id":"Oauth2KeycloakAuthConfig.id","source":"Oauth2KeycloakAuthConfig.source","sourceName":"Oauth2KeycloakAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"KEYCLOAK","clientId":"Oauth2KeycloakAuthConfig.clientId","baseUrl":"Oauth2KeycloakAuthConfig.baseUrl","realm":"Oauth2KeycloakAuthConfig.realm","scope":"Oauth2KeycloakAuthConfig.scope","authorizeUrl":"Oauth2KeycloakAuthConfig.baseUrl/realms/Oauth2KeycloakAuthConfig.realm/protocol/openid-connect/auth?response_type=code&client_id=Oauth2KeycloakAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Oauth2KeycloakAuthConfig.scope"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S2.json new file mode 100644 index 0000000000..abbcb16a49 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S2.json @@ -0,0 +1 @@ +{"authType":"KEYCLOAK","id":null,"source":null,"sourceName":null,"enable":null,"enableRegister":null,"authType":"KEYCLOAK","clientId":"Oauth2KeycloakAuthConfig.clientId","clientSecret":null,"baseUrl":"Oauth2KeycloakAuthConfig.baseUrl","realm":"Oauth2KeycloakAuthConfig.realm","scope":"Oauth2KeycloakAuthConfig.scope","authorizeUrl":"Oauth2KeycloakAuthConfig.baseUrl/realms/Oauth2KeycloakAuthConfig.realm/protocol/openid-connect/auth?response_type=code&client_id=Oauth2KeycloakAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Oauth2KeycloakAuthConfig.scope"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S2Throwing.json new file mode 100644 index 0000000000..f205c2cbed --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2KeycloakAuthConfig.S2Throwing.json @@ -0,0 +1,17 @@ +{ + "baseUrl" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "realm" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "scope" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "clientId" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "authType" : { + "authorizeUrl" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.D1.json new file mode 100644 index 0000000000..db3f54b74d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.D1.json @@ -0,0 +1,12 @@ +{ + "id" : "Oauth2OryAuthConfig.id", + "source" : "Oauth2OryAuthConfig.source", + "sourceName" : "Oauth2OryAuthConfig.sourceName", + "enable" : true, + "enableRegister" : false, + "authType" : "ORY", + "clientId" : "Oauth2OryAuthConfig.clientId", + "clientSecret" : "SECRET-Oauth2OryAuthConfig.clientSecret", + "baseUrl" : "Oauth2OryAuthConfig.baseUrl", + "scope" : "Oauth2OryAuthConfig.scope" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S1.json new file mode 100644 index 0000000000..b6a8c0ce4e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S1.json @@ -0,0 +1 @@ +{"authType":"ORY","id":"Oauth2OryAuthConfig.id","source":"Oauth2OryAuthConfig.source","sourceName":"Oauth2OryAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Oauth2OryAuthConfig.clientId","clientSecret":"SECRET-Oauth2OryAuthConfig.clientSecret","baseUrl":"Oauth2OryAuthConfig.baseUrl","scope":"Oauth2OryAuthConfig.scope","authorizeUrl":"Oauth2OryAuthConfig.baseUrl/oauth2/auth?response_type=code&client_id=Oauth2OryAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Oauth2OryAuthConfig.scope"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S1Internal.json new file mode 100644 index 0000000000..5556ca4870 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S1Internal.json @@ -0,0 +1 @@ +{"authType":"ORY","id":"Oauth2OryAuthConfig.id","source":"Oauth2OryAuthConfig.source","sourceName":"Oauth2OryAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Oauth2OryAuthConfig.clientId","clientSecret":"SECRET-Oauth2OryAuthConfig.clientSecret","baseUrl":"Oauth2OryAuthConfig.baseUrl","scope":"Oauth2OryAuthConfig.scope"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S1Public.json new file mode 100644 index 0000000000..06c42dbe41 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S1Public.json @@ -0,0 +1 @@ +{"authType":"ORY","id":"Oauth2OryAuthConfig.id","source":"Oauth2OryAuthConfig.source","sourceName":"Oauth2OryAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"ORY","clientId":"Oauth2OryAuthConfig.clientId","baseUrl":"Oauth2OryAuthConfig.baseUrl","scope":"Oauth2OryAuthConfig.scope","authorizeUrl":"Oauth2OryAuthConfig.baseUrl/oauth2/auth?response_type=code&client_id=Oauth2OryAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Oauth2OryAuthConfig.scope"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S2.json new file mode 100644 index 0000000000..ee351d9021 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S2.json @@ -0,0 +1 @@ +{"authType":"ORY","id":null,"source":null,"sourceName":null,"enable":null,"enableRegister":null,"authType":"ORY","clientId":"Oauth2OryAuthConfig.clientId","clientSecret":null,"baseUrl":"Oauth2OryAuthConfig.baseUrl","scope":"Oauth2OryAuthConfig.scope","authorizeUrl":"Oauth2OryAuthConfig.baseUrl/oauth2/auth?response_type=code&client_id=Oauth2OryAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&scope=Oauth2OryAuthConfig.scope"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S2Throwing.json new file mode 100644 index 0000000000..48c32d9392 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2OryAuthConfig.S2Throwing.json @@ -0,0 +1,14 @@ +{ + "baseUrl" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "scope" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "clientId" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "authType" : { + "authorizeUrl" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.D1.json new file mode 100644 index 0000000000..d384a8769d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.D1.json @@ -0,0 +1,10 @@ +{ + "id" : "Oauth2SimpleAuthConfig.id", + "source" : "Oauth2SimpleAuthConfig.source", + "sourceName" : "Oauth2SimpleAuthConfig.sourceName", + "enable" : true, + "enableRegister" : false, + "authType" : "GOOGLE", + "clientId" : "Oauth2SimpleAuthConfig.clientId", + "clientSecret" : "SECRET-Oauth2SimpleAuthConfig.clientSecret" +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S1.json new file mode 100644 index 0000000000..d2cbd8ee07 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S1.json @@ -0,0 +1 @@ +{"authType":"GITHUB","id":"Oauth2SimpleAuthConfig.id","source":"Oauth2SimpleAuthConfig.source","sourceName":"Oauth2SimpleAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Oauth2SimpleAuthConfig.clientId","clientSecret":"SECRET-Oauth2SimpleAuthConfig.clientSecret","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Oauth2SimpleAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S1Internal.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S1Internal.json new file mode 100644 index 0000000000..dd46d54a66 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S1Internal.json @@ -0,0 +1 @@ +{"authType":"GITHUB","id":"Oauth2SimpleAuthConfig.id","source":"Oauth2SimpleAuthConfig.source","sourceName":"Oauth2SimpleAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Oauth2SimpleAuthConfig.clientId","clientSecret":"SECRET-Oauth2SimpleAuthConfig.clientSecret"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S1Public.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S1Public.json new file mode 100644 index 0000000000..a186be8823 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S1Public.json @@ -0,0 +1 @@ +{"authType":"GITHUB","id":"Oauth2SimpleAuthConfig.id","source":"Oauth2SimpleAuthConfig.source","sourceName":"Oauth2SimpleAuthConfig.sourceName","enable":true,"enableRegister":false,"authType":"GOOGLE","clientId":"Oauth2SimpleAuthConfig.clientId","authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Oauth2SimpleAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S2.json new file mode 100644 index 0000000000..14b76acfb1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S2.json @@ -0,0 +1 @@ +{"authType":"GITHUB","id":null,"source":null,"sourceName":null,"enable":null,"enableRegister":null,"authType":"GOOGLE","clientId":"Oauth2SimpleAuthConfig.clientId","clientSecret":null,"authorizeUrl":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=Oauth2SimpleAuthConfig.clientId&redirect_uri=$REDIRECT_URL&state=$STATE&access_type=offline&scope=openid email profile&prompt=select_account"} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S2Throwing.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S2Throwing.json new file mode 100644 index 0000000000..8174e582b4 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.auth.Oauth2SimpleAuthConfig.S2Throwing.json @@ -0,0 +1,8 @@ +{ + "clientId" : { + "authorizeUrl" : "java.lang.NullPointerException" + }, + "authType" : { + "authorizeUrl" : "java.lang.NullPointerException" + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.constants.WorkspaceMode.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.constants.WorkspaceMode.S1.json new file mode 100644 index 0000000000..8ba8adf536 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.constants.WorkspaceMode.S1.json @@ -0,0 +1 @@ +["SAAS","ENTERPRISE"] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Column.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Column.S1.json new file mode 100644 index 0000000000..8c85bd370e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Column.S1.json @@ -0,0 +1 @@ +{"name":"Column.name","type":"Column.type","defaultValue":"Column.defaultValue","isAutogenerated":true} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Column.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Column.S2.json new file mode 100644 index 0000000000..a0d8bfe72d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Column.S2.json @@ -0,0 +1 @@ +{"name":null,"type":null,"defaultValue":null,"isAutogenerated":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Table.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Table.S1.json new file mode 100644 index 0000000000..b88eaf85a6 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Table.S1.json @@ -0,0 +1 @@ +{"type":"COLLECTION","schema":"Table.schema","name":"Table.name","columns":[{"name":"Table.columns[0].name","type":"Table.columns[0].type","defaultValue":"Table.columns[0].defaultValue","isAutogenerated":true},{"name":"Table.columns[1].name","type":"Table.columns[1].type","defaultValue":"Table.columns[1].defaultValue","isAutogenerated":true}],"keys":[{"name":"Table.keys[0].name","columnNames":["Table.keys[0].columnNames[0]","Table.keys[0].columnNames[1]"],"type":"primary key"},{"name":"Table.keys[1].name","fromColumns":["Table.keys[1].fromColumns[0]","Table.keys[1].fromColumns[1]"],"toColumns":["Table.keys[1].toColumns[0]","Table.keys[1].toColumns[1]"],"type":"foreign key"}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Table.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Table.S2.json new file mode 100644 index 0000000000..2fac998dfe --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$Table.S2.json @@ -0,0 +1 @@ +{"type":null,"schema":null,"name":null,"columns":null,"keys":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$TableType.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$TableType.S1.json new file mode 100644 index 0000000000..e3c7a08962 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure$TableType.S1.json @@ -0,0 +1 @@ +["TABLE","VIEW","ALIAS","COLLECTION"] \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure.S1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure.S1.json new file mode 100644 index 0000000000..1679a0758f --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure.S1.json @@ -0,0 +1 @@ +{"tables":[{"type":"TABLE","schema":"DatasourceStructure.tables[0].schema","name":"DatasourceStructure.tables[0].name","columns":[{"name":"DatasourceStructure.tables[0].columns[0].name","type":"DatasourceStructure.tables[0].columns[0].type","defaultValue":"DatasourceStructure.tables[0].columns[0].defaultValue","isAutogenerated":true},{"name":"DatasourceStructure.tables[0].columns[1].name","type":"DatasourceStructure.tables[0].columns[1].type","defaultValue":"DatasourceStructure.tables[0].columns[1].defaultValue","isAutogenerated":true}],"keys":[{"name":"DatasourceStructure.tables[0].keys[0].name","columnNames":["DatasourceStructure.tables[0].keys[0].columnNames[0]","DatasourceStructure.tables[0].keys[0].columnNames[1]"],"type":"primary key"},{"name":"DatasourceStructure.tables[0].keys[1].name","fromColumns":["DatasourceStructure.tables[0].keys[1].fromColumns[0]","DatasourceStructure.tables[0].keys[1].fromColumns[1]"],"toColumns":["DatasourceStructure.tables[0].keys[1].toColumns[0]","DatasourceStructure.tables[0].keys[1].toColumns[1]"],"type":"foreign key"}]},{"type":"VIEW","schema":"DatasourceStructure.tables[1].schema","name":"DatasourceStructure.tables[1].name","columns":[{"name":"DatasourceStructure.tables[1].columns[0].name","type":"DatasourceStructure.tables[1].columns[0].type","defaultValue":"DatasourceStructure.tables[1].columns[0].defaultValue","isAutogenerated":true},{"name":"DatasourceStructure.tables[1].columns[1].name","type":"DatasourceStructure.tables[1].columns[1].type","defaultValue":"DatasourceStructure.tables[1].columns[1].defaultValue","isAutogenerated":true}],"keys":[{"name":"DatasourceStructure.tables[1].keys[0].name","columnNames":["DatasourceStructure.tables[1].keys[0].columnNames[0]","DatasourceStructure.tables[1].keys[0].columnNames[1]"],"type":"primary key"},{"name":"DatasourceStructure.tables[1].keys[1].name","fromColumns":["DatasourceStructure.tables[1].keys[1].fromColumns[0]","DatasourceStructure.tables[1].keys[1].fromColumns[1]"],"toColumns":["DatasourceStructure.tables[1].keys[1].toColumns[0]","DatasourceStructure.tables[1].keys[1].toColumns[1]"],"type":"foreign key"}]}]} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure.S2.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure.S2.json new file mode 100644 index 0000000000..1740bf7306 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.DatasourceStructure.S2.json @@ -0,0 +1 @@ +{"tables":null} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.Param.D1.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.Param.D1.json new file mode 100644 index 0000000000..b4f27318e7 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/types/org.lowcoder.sdk.models.Param.D1.json @@ -0,0 +1,35 @@ +{ + "key" : "Param.key", + "value" : { + "zeta": "first key, so that sorted output would differ from input order", + "int": 1, + "negativeInt": -40003, + "intMax": 2147483647, + "intMaxPlusOne": 2147483648, + "intMinMinusOne": -2147483649, + "longMax": 9223372036854775807, + "longMaxPlusOne": 9223372036854775808, + "longMinMinusOne": -9223372036854775809, + "decimal": 1.5, + "trailingZeroDecimal": 1.50, + "zeroPointZero": 0.0, + "negativeZero": -0, + "exponent": 1e2, + "exponentUpperCase": 1E+2, + "negativeExponent": 2.5e-3, + "longDecimal": 0.1000000000000000055511151231257827, + "true": true, + "false": false, + "null": null, + "emptyObject": {}, + "emptyArray": [], + "nested": { + "array": [1, "two", [3, {"four": 4.0}], null, true, 3000000001], + "object": {"z": 1, "a": {"deeper": [[], {}]}} + }, + "unicode": "žluťoučký kůň 🐎 中文", + "escapes": "\u00e9 \u0000 \n \t \" \\ \/ \ud83d\ude00", + "k\u0065y with escaped name": "escaped key", + "alpha": "last key" + } +} diff --git a/server/api-service/lowcoder-server/src/test/resources/json-contract/wp-status.json b/server/api-service/lowcoder-server/src/test/resources/json-contract/wp-status.json new file mode 100644 index 0000000000..baa1f3547b --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/resources/json-contract/wp-status.json @@ -0,0 +1,3 @@ +{ + "done": ["WP0", "WP1", "WP2", "WP3", "WP4", "WP5", "WP6", "WP7", "WP8", "WP9", "WP10"] +} diff --git a/server/api-service/lowcoder-server/src/test/resources/users.ldif b/server/api-service/lowcoder-server/src/test/resources/users.ldif deleted file mode 100644 index 3cdcfd1c3e..0000000000 --- a/server/api-service/lowcoder-server/src/test/resources/users.ldif +++ /dev/null @@ -1,40 +0,0 @@ -version: 1 -dn: dc=openblocks,dc=dev -objectClass: domain -objectClass: top -dc: openblocks - -dn: ou=Users,dc=openblocks,dc=dev -objectClass: organizationalUnit -objectClass: top -ou: Users - -dn: cn=Joe Simms,ou=Users,dc=openblocks,dc=dev -objectClass: inetOrgPerson -objectClass: organizationalPerson -objectClass: person -objectClass: top -cn: Joe Simms -sn: Simms -uid: user1 -userPassword: 12345 - -dn: cn=lv,ou=Users,dc=openblocks,dc=dev -objectClass: inetOrgPerson -objectClass: organizationalPerson -objectClass: person -objectClass: top -cn: lv -sn: lv -uid: 1 -userPassword: 1111qqqq - -dn: cn=yu,ou=Users,dc=openblocks,dc=dev -objectClass: inetOrgPerson -objectClass: organizationalPerson -objectClass: person -objectClass: top -cn: yu -sn: yu -uid: 2 -userPassword: 1111qqqq \ No newline at end of file diff --git a/server/api-service/pom.xml b/server/api-service/pom.xml index 2d2a0e1519..01a27e5bd3 100644 --- a/server/api-service/pom.xml +++ b/server/api-service/pom.xml @@ -19,6 +19,14 @@ true true true + 0.8.14 + + false + + @@ -85,6 +93,45 @@ maven-dependency-plugin 3.6.1 + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-agent + + prepare-agent + + + + prepare-agent-integration + + prepare-agent-integration + + + + report + + report + + + + report-integration + + report-integration + + + + @@ -152,6 +199,8 @@ lowcoder-domain lowcoder-plugins lowcoder-server + + lowcoder-coverage-report