io.hyperfoil.tools.horreum.svc.TestServiceImpl Maven / Gradle / Ivy
The newest version!
package io.hyperfoil.tools.horreum.svc;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceException;
import jakarta.persistence.Tuple;
import jakarta.transaction.TransactionManager;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.hibernate.Hibernate;
import org.hibernate.ScrollMode;
import org.hibernate.ScrollableResults;
import org.hibernate.Session;
import org.hibernate.query.NativeQuery;
import org.hibernate.type.StandardBasicTypes;
import org.jboss.logging.Logger;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.hyperfoil.tools.horreum.api.SortDirection;
import io.hyperfoil.tools.horreum.api.data.Access;
import io.hyperfoil.tools.horreum.api.data.ExportedLabelValues;
import io.hyperfoil.tools.horreum.api.data.Fingerprints;
import io.hyperfoil.tools.horreum.api.data.Test;
import io.hyperfoil.tools.horreum.api.data.TestExport;
import io.hyperfoil.tools.horreum.api.data.TestToken;
import io.hyperfoil.tools.horreum.api.data.datastore.DatastoreType;
import io.hyperfoil.tools.horreum.api.services.TestService;
import io.hyperfoil.tools.horreum.bus.AsyncEventChannels;
import io.hyperfoil.tools.horreum.entity.alerting.WatchDAO;
import io.hyperfoil.tools.horreum.entity.backend.DatastoreConfigDAO;
import io.hyperfoil.tools.horreum.entity.data.DatasetDAO;
import io.hyperfoil.tools.horreum.entity.data.RunDAO;
import io.hyperfoil.tools.horreum.entity.data.TestDAO;
import io.hyperfoil.tools.horreum.entity.data.TestTokenDAO;
import io.hyperfoil.tools.horreum.entity.data.TransformerDAO;
import io.hyperfoil.tools.horreum.entity.data.ViewDAO;
import io.hyperfoil.tools.horreum.hibernate.JsonBinaryType;
import io.hyperfoil.tools.horreum.hibernate.JsonbSetType;
import io.hyperfoil.tools.horreum.mapper.DatasourceMapper;
import io.hyperfoil.tools.horreum.mapper.TestMapper;
import io.hyperfoil.tools.horreum.mapper.TestTokenMapper;
import io.hyperfoil.tools.horreum.server.EncryptionManager;
import io.hyperfoil.tools.horreum.server.WithRoles;
import io.hyperfoil.tools.horreum.server.WithToken;
import io.quarkus.hibernate.orm.panache.PanacheQuery;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import io.quarkus.security.identity.SecurityIdentity;
@ApplicationScoped
public class TestServiceImpl implements TestService {
private static final Logger log = Logger.getLogger(TestServiceImpl.class);
private static final String FILTER_BY_NAME_FIELD = "name";
protected static final String WILDCARD = "*";
//using find and replace because ASC or DESC cannot be set with a parameter
//@formatter:off
protected static final String FILTER_PREFIX = "WHERE ";
protected static final String FILTER_SEPARATOR = " AND ";
protected static final String FILTER_BEFORE = " combined.stop < :before";
protected static final String FILTER_AFTER = " combined.start > :after";
protected static final String LABEL_VALUES_FILTER_CONTAINS_JSON = "combined.values @> :filter";
//a solution does exist! https://github.com/spring-projects/spring-data-jpa/issues/2551
//use @\\?\\? to turn into a @? in the query
protected static final String LABEL_VALUES_FILTER_MATCHES_NOT_NULL = "combined.values @\\?\\? CAST( :filter as jsonpath)"; //"jsonb_path_match(combined.values,CAST( :filter as jsonpath))";
//unused atm because we need to either try both PREDICATE and matching jsonpath or differentiate before sending to the DB
protected static final String LABEL_VALUES_FILTER_MATCHES_PREDICATE = "combined.values @@ CAST( :filter as jsonpath)";
protected static final String LABEL_VALUES_SORT = "";//""jsonb_path_query(combined.values,CAST( :orderBy as jsonpath))";
protected static final String LABEL_ORDER_PREFIX = "order by ";
protected static final String LABEL_ORDER_START= "combined.start";
protected static final String LABEL_ORDER_STOP= "combined.stop";
protected static final String LABEL_ORDER_JSONPATH = "jsonb_path_query(combined.values,CAST( :orderBy as jsonpath))";
private static final String CHECK_TEST_EXISTS_BY_ID_QUERY = "SELECT EXISTS(SELECT 1 FROM test WHERE id = ?1)";
protected static final String LABEL_VALUES_QUERY = """
WITH
combined as (
SELECT COALESCE(jsonb_object_agg(label.name, lv.value) FILTER (WHERE label.name IS NOT NULL INCLUDE_EXCLUDE_PLACEHOLDER), '{}'::jsonb) AS values, runId, dataset.id AS datasetId, dataset.start AS start, dataset.stop AS stop
FROM dataset
LEFT JOIN label_values lv ON dataset.id = lv.dataset_id
LEFT JOIN label ON label.id = lv.label_id
WHERE dataset.testid = :testId
AND (label.id IS NULL OR (:filteringLabels AND label.filtering) OR (:metricLabels AND label.metrics))
GROUP BY dataset.id, runId
) select * from combined FILTER_PLACEHOLDER ORDER_PLACEHOLDER LIMIT_PLACEHOLDER
""";
protected static final String LABEL_VALUES_SUMMARY_QUERY = """
SELECT DISTINCT COALESCE(jsonb_object_agg(label.name, lv.value), '{}'::jsonb) AS values
FROM dataset
INNER JOIN label_values lv ON dataset.id = lv.dataset_id
INNER JOIN label ON label.id = lv.label_id
WHERE dataset.testid = :testId AND label.filtering
GROUP BY dataset.id, runId
""";
//@formatter:on
@Inject
@Util.FailUnknownProperties
ObjectMapper mapper;
@Inject
EntityManager em;
@Inject
SecurityIdentity identity;
@Inject
EncryptionManager encryptionManager;
@Inject
ServiceMediator mediator;
@Inject
TransactionManager tm;
private final ConcurrentHashMap recalculations = new ConcurrentHashMap<>();
@RolesAllowed(Roles.TESTER)
@WithRoles
@Transactional
@Override
public void delete(int id) {
TestDAO test = TestDAO.findById(id);
if (test == null) {
throw ServiceException.notFound("No test with id " + id);
} else if (!identity.getRoles().contains(test.owner)) {
throw ServiceException.forbidden("You are not an owner of test " + id);
}
log.debugf("Deleting test %s (%d)", test.name, test.id);
mediator.deleteTest(test.id);
test.delete();
if (mediator.testMode())
Util.registerTxSynchronization(tm,
txStatus -> mediator.publishEvent(AsyncEventChannels.TEST_DELETED, test.id, TestMapper.from(test)));
;
}
@Override
@WithToken
@WithRoles
@PermitAll
public Test get(int id, String token) {
TestDAO test = TestDAO.find("id", id).firstResult();
if (test == null) {
throw ServiceException.notFound("No test with name " + id);
}
return TestMapper.from(test);
}
@Override
public Test getByNameOrId(String input) {
TestDAO test = null;
if (input.matches("-?\\d+")) {
int id = Integer.parseInt(input);
// there could be some issue if name is numeric and corresponds to another test id
test = TestDAO.find("name = ?1 or id = ?2", input, id).firstResult();
}
if (test == null) {
test = TestDAO.find("name", input).firstResult();
}
if (test == null) {
throw ServiceException.notFound("No test with name or id " + input);
}
return TestMapper.from(test);
}
/**
* Checks whether the provided id belongs to an existing test and if the user can access it the security check is performed
* by triggering the RLS at database level
*
* @param id test ID
*/
@WithRoles
@Transactional
protected boolean checkTestExists(int id) {
return (Boolean) em.createNativeQuery(CHECK_TEST_EXISTS_BY_ID_QUERY, Boolean.class)
.setParameter(1, id)
.getSingleResult();
}
@WithRoles(extras = Roles.HORREUM_SYSTEM)
public TestDAO ensureTestExists(String testNameOrId, String token) {
TestDAO test;// = TestMapper.to(getByNameOrId(input)); //why does getByNameOrId not work to create the DAO?
if (testNameOrId.matches("-?\\d+")) {
int id = Integer.parseInt(testNameOrId);
test = TestDAO.find("name = ?1 or id = ?2", testNameOrId, id).firstResult();
} else {
test = TestDAO.find("name", testNameOrId).firstResult();
}
if (test != null) {// we won't return the whole entity with any data
TestDAO detached = new TestDAO();
detached.id = test.id;
detached.owner = test.owner;
detached.name = testNameOrId;
detached.backendConfig = test.backendConfig;
if (Roles.hasRoleWithSuffix(identity, test.owner, "-uploader")) {
return detached;
} else if (token != null && test.tokens.stream().anyMatch(tt -> tt.valueEquals(token) && tt.hasUpload())) {
return detached;
}
log.debugf("Failed to retrieve test %s as this user (%s = %s) is not uploader for %s and token %s does not match",
testNameOrId, identity.getPrincipal().getName(), identity.getRoles(), test.owner, token);
} else {
log.debugf("Failed to retrieve test %s - could not find it in the database", testNameOrId);
}
// we need to be vague about the test existence
throw ServiceException.badRequest("Cannot upload to test " + testNameOrId);
}
@Override
@RolesAllowed(Roles.TESTER)
@WithRoles
@Transactional
public Test add(Test dto) {
if (!identity.hasRole(dto.owner)) {
throw ServiceException.forbidden("This user does not have the " + dto.owner + " role!");
}
if (dto.name == null || dto.name.isBlank())
throw ServiceException.badRequest("Test name can not be empty");
log.debugf("Creating new test: %s", dto.toString());
TestDAO test = addAuthenticated(dto);
Hibernate.initialize(test.tokens);
return TestMapper.from(test);
}
TestDAO addAuthenticated(Test dto) {
TestDAO existing = TestDAO.find("id", dto.id).firstResult();
if (existing == null)
dto.clearIds();
TestDAO test = TestMapper.to(dto);
if (test.notificationsEnabled == null) {
test.notificationsEnabled = true;
}
test.folder = normalizeFolderName(test.folder);
checkWildcardFolder(test.folder);
if (test.transformers != null && !test.transformers.isEmpty())
verifyTransformersBeforeAdd(test);
if (existing != null) {
test.ensureLinked();
if (!identity.hasRole(existing.owner)) {
throw ServiceException.forbidden("This user does not have the " + existing.owner + " role!");
}
// We're not updating views using this method
boolean shouldRecalculateLables = false;
if (!Objects.equals(test.fingerprintFilter, existing.fingerprintFilter) ||
!Objects.equals(test.fingerprintLabels, existing.fingerprintLabels))
shouldRecalculateLables = true;
test.views = existing.views;
test.tokens = existing.tokens;
test = em.merge(test);
if (shouldRecalculateLables)
mediator.updateFingerprints(test.id);
} else {
// We need to persist the test before view in order for RLS to work
if (test.views == null || test.views.isEmpty()) {
test.views = Collections.singleton(new ViewDAO("Default", test));
}
try {
test = em.merge(test);
em.flush();
} catch (PersistenceException e) {
if (e instanceof org.hibernate.exception.ConstraintViolationException) {
throw new ServiceException(Response.Status.CONFLICT, "Could not persist test due to another test.");
} else {
throw new WebApplicationException(e, Response.serverError().build());
}
}
mediator.newTest(TestMapper.from(test));
if (mediator.testMode()) {
int testId = test.id;
Test testDTO = TestMapper.from(test);
Util.registerTxSynchronization(tm,
txStatus -> mediator.publishEvent(AsyncEventChannels.TEST_NEW, testId, testDTO));
}
}
return test;
}
private void verifyTransformersBeforeAdd(TestDAO test) {
List tmp = new ArrayList<>();
for (var t : test.transformers) {
if (TransformerDAO.findById(t.id) == null) {
TransformerDAO trans = TransformerDAO.find("targetSchemaUri", t.targetSchemaUri).firstResult();
if (trans != null)
tmp.add(trans);
} else
tmp.add(t);
}
test.transformers = tmp;
}
@Override
@PermitAll
@WithRoles
public TestQueryResult list(String roles, Integer limit, Integer page, String sort, SortDirection direction) {
PanacheQuery query;
StringBuilder whereClause = new StringBuilder();
Map params = new HashMap<>();
// configure roles
Set actualRoles = null;
if (Roles.hasRolesParam(roles)) {
if (roles.equals(Roles.MY_ROLES)) {
if (!identity.isAnonymous()) {
actualRoles = identity.getRoles();
}
} else {
actualRoles = new HashSet<>(Arrays.asList(roles.split(";")));
}
}
if (actualRoles != null && !actualRoles.isEmpty()) {
whereClause.append("owner IN :owner");
params.put("owner", actualRoles);
}
// configure sorting
Sort.Direction sortDirection = direction == null ? null : Sort.Direction.valueOf(direction.name());
Sort sortOptions = sort != null ? Sort.by(sort).direction(sortDirection) : null;
// create TestDAO query
if (!whereClause.isEmpty()) {
query = TestDAO.find(whereClause.toString(), sortOptions, params);
} else {
query = TestDAO.findAll(sortOptions);
}
// configure paging
if (limit != null && page != null) {
query.page(Page.of(page, limit));
}
return new TestQueryResult(query.list().stream().map(TestMapper::from).toList(), TestDAO.count());
}
@Override
@PermitAll
@WithRoles
public TestListing summary(String roles, String folder, Integer limit, Integer page, SortDirection direction,
String name) {
folder = normalizeFolderName(folder);
StringBuilder testSql = new StringBuilder();
testSql.append(
"WITH runs AS (SELECT testid, count(id) as count FROM run WHERE run.trashed = false OR run.trashed IS NULL GROUP BY testid), ");
testSql.append("datasets AS (SELECT testid, count(id) as count FROM dataset GROUP BY testid) ");
testSql.append(
"SELECT test.id,test.name,test.folder,test.description, COALESCE(datasets.count, 0) AS datasets, COALESCE(runs.count, 0) AS runs,test.owner,test.access ");
testSql.append("FROM test LEFT JOIN runs ON runs.testid = test.id LEFT JOIN datasets ON datasets.testid = test.id");
boolean anyFolder = WILDCARD.equals(folder);
if (anyFolder) {
Roles.addRolesSql(identity, "test", testSql, roles, 1, " WHERE");
} else {
testSql.append(" WHERE COALESCE(folder, '') = COALESCE((?1)::text, '')");
Roles.addRolesSql(identity, "test", testSql, roles, 2, " AND");
}
// configure search by
if (name != null) {
testSql.append(testSql.toString().contains("WHERE") ? " AND " : " WHERE ")
.append("LOWER(")
.append(FILTER_BY_NAME_FIELD)
.append(") LIKE :searchValue");
}
// page set to 0 means return all results, no limits nor ordering
if (limit > 0 && page > 0) {
Util.addPaging(testSql, limit, page, "test.name", direction);
}
org.hibernate.query.Query testQuery = em.unwrap(Session.class)
.createNativeQuery(testSql.toString(), Tuple.class)
.setTupleTransformer((tuples, aliases) -> new TestSummary((int) tuples[0], (String) tuples[1],
(String) tuples[2], (String) tuples[3],
(Number) tuples[4], (Number) tuples[5], (String) tuples[6], Access.fromInt((int) tuples[7])));
if (anyFolder) {
Roles.addRolesParam(identity, testQuery, 1, roles);
} else {
testQuery.setParameter(1, folder);
Roles.addRolesParam(identity, testQuery, 2, roles);
}
if (name != null) {
testQuery.setParameter("searchValue", "%" + name.toLowerCase() + "%");
}
List summaryList = testQuery.getResultList();
if (!identity.isAnonymous()) {
List testIdSet = new ArrayList<>();
Map> subscriptionMap = new HashMap<>();
summaryList.forEach(summary -> testIdSet.add(summary.id));
List subscriptions = em.createNativeQuery("SELECT * FROM watch w WHERE w.testid IN (?1)", WatchDAO.class)
.setParameter(1, testIdSet).getResultList();
String username = identity.getPrincipal().getName();
Set teams = identity.getRoles().stream().filter(role -> role.endsWith("-team")).collect(Collectors.toSet());
subscriptions.forEach(subscription -> {
Set subscriptionSet = subscriptionMap.computeIfAbsent(subscription.test.id, k -> new HashSet<>());
if (subscription.users.contains(username)) {
subscriptionSet.add(username);
}
if (subscription.optout.contains(username)) {
subscriptionSet.add("!" + username);
}
subscription.teams.forEach(team -> {
if (teams.contains(team)) {
subscriptionSet.add(team);
}
});
});
summaryList.forEach(
summary -> summary.watching = subscriptionMap.computeIfAbsent(summary.id, k -> Collections.emptySet()));
}
if (folder == null) {
folder = "";
}
TestListing listing = new TestListing();
listing.tests = summaryList;
StringBuilder countQuery = new StringBuilder();
List ordinals = new ArrayList<>();
if (anyFolder) {
Roles.addRoles(identity, countQuery, roles, false, ordinals);
} else {
ordinals.add(folder);
countQuery.append(" COALESCE(folder, '') IN (?1) ");
Roles.addRoles(identity, countQuery, roles, true, ordinals);
}
listing.count = TestDAO.count(countQuery.toString(), ordinals.toArray(new Object[] {}));
return listing;
}
private static String normalizeFolderName(String folder) {
if (folder == null) {
return null;
}
// cleanup the string from leading and trailing spaces before additional cleanups
folder = folder.trim();
if (folder.endsWith("/")) {
folder = folder.substring(0, folder.length() - 1).trim();
}
if (folder.isEmpty()) {
folder = null;
}
return folder;
}
/**
* This will return all distinct folder based on the provided roles plus the root folder which is represented by a null
* object in the returned list
*
* @param roles user roles
* @return list of distinct strings (the folders)
*/
@Override
@PermitAll
@WithRoles
public List folders(String roles) {
StringBuilder sql = new StringBuilder("SELECT DISTINCT folder FROM test");
Roles.addRolesSql(identity, "test", sql, roles, 1, " WHERE");
NativeQuery query = em.unwrap(Session.class).createNativeQuery(sql.toString(), String.class);
Roles.addRolesParam(identity, query, 1, roles);
Set result = new HashSet<>();
List folders = query.getResultList();
for (String folder : folders) {
if (folder == null || folder.isEmpty()) {
continue;
}
int index = -1;
for (;;) {
index = folder.indexOf('/', index + 1);
if (index >= 0) {
result.add(folder.substring(0, index));
} else {
result.add(folder);
break;
}
}
}
folders = new ArrayList<>(result);
folders.sort(String::compareTo);
// this represents the root folder
folders.add(0, null);
return folders;
}
@Override
@RolesAllowed(Roles.TESTER)
@WithRoles
@Transactional
public int addToken(int testId, TestToken dto) {
if (dto.hasUpload() && !dto.hasRead()) {
throw ServiceException.badRequest("Upload permission requires read permission as well.");
}
TestDAO test = getTestForUpdate(testId);
TestTokenDAO token = TestTokenMapper.to(dto);
token.id = null; // this is always a new token, ignore -1 in the request
token.test = test;
test.tokens.add(token);
test.persistAndFlush();
return token.id;
}
@Override
@RolesAllowed(Roles.TESTER)
@WithRoles
public Collection tokens(int testId) {
TestDAO t = TestDAO.findById(testId);
Hibernate.initialize(t.tokens);
return t.tokens.stream().map(TestTokenMapper::from).collect(Collectors.toList());
}
@Override
@RolesAllowed(Roles.TESTER)
@WithRoles
@Transactional
public void dropToken(int testId, int tokenId) {
TestDAO test = getTestForUpdate(testId);
test.tokens.removeIf(t -> Objects.equals(t.id, tokenId));
test.persist();
}
@Override
@RolesAllowed(Roles.TESTER)
@WithRoles
@Transactional
// TODO: it would be nicer to use @FormParams but fetchival on client side doesn't support that
public void updateAccess(int testId, String owner, Access access) {
TestDAO test = (TestDAO) TestDAO.findByIdOptional(testId)
.orElseThrow(() -> ServiceException.notFound("Test not found"));
test.owner = owner;
test.access = access;
try {
// need persistAndFlush otherwise we won't catch SQLGrammarException
test.persistAndFlush();
} catch (Exception e) {
throw ServiceException.serverError("Access change failed (missing permissions?)");
}
}
@Override
@RolesAllowed(Roles.TESTER)
@WithRoles
@Transactional
public void updateNotifications(int testId, boolean enabled) {
TestDAO test = (TestDAO) TestDAO.findByIdOptional(testId)
.orElseThrow(() -> ServiceException.notFound("Test not found"));
test.notificationsEnabled = enabled;
try {
// need persistAndFlush otherwise we won't catch SQLGrammarException
test.persistAndFlush();
} catch (Exception e) {
throw ServiceException.serverError("Notification change failed (missing permissions?)");
}
}
@RolesAllowed(Roles.TESTER)
@WithRoles
@Transactional
@Override
public void updateFolder(int id, String folder) {
// normalize the folder before checking the wildcard
String normalizedFolder = normalizeFolderName(folder);
checkWildcardFolder(normalizedFolder);
TestDAO test = getTestForUpdate(id);
test.folder = normalizedFolder;
test.persist();
}
@WithRoles
@SuppressWarnings("unchecked")
@Override
public List listFingerprints(int testId) {
if (!checkTestExists(testId)) {
throw ServiceException.serverError("Cannot find test " + testId);
}
return Fingerprints.parse(em.createNativeQuery("""
SELECT DISTINCT fingerprint
FROM fingerprint fp
JOIN dataset ON dataset.id = dataset_id
WHERE dataset.testid = ?1
""")
.setParameter(1, testId)
.unwrap(NativeQuery.class).addScalar("fingerprint", JsonBinaryType.INSTANCE)
.getResultList());
}
/**
* returns true if the jsonpath input appears to be a predicate jsonpath (always returns true or false) versus a filtering
* path (returns null or a value)
*
* @param input
* @return
*/
private boolean isPredicate(String input) {
if (input == null || input.isEmpty()) {
return false;
}
if (input.matches("\\?.*?@.*?[><]|==")) {// ? (@ [comp]
return true;
}
return false;
}
// returns a map of label name to a set of possible label types
private Map> getValueTypes(int testId) {
Map> rtrn = new HashMap<>();
List