feat(audit): add quarkus-audit-tools module
All checks were successful
Build / build (pull_request) Successful in 1m57s

This commit is contained in:
Jorge Bornhausen 2025-06-18 01:41:16 +02:00
parent db0026b723
commit f268c4a27a
Signed by: jorge.bornhausen
SSH key fingerprint: SHA256:X2ootOwvCeP4FoNfmVUFIKIbhq95tAgnt7Oqg3x+lfs
18 changed files with 834 additions and 0 deletions

View file

@ -0,0 +1,136 @@
package ch.phoenix.oss.quarkus.commons.audit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.mockito.Mockito.when;
import ch.phoenix.oss.quarkus.commons.tracing.TracingService;
import io.quarkus.narayana.jta.QuarkusTransaction;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.mockito.InjectSpy;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import java.time.Instant;
import org.hibernate.envers.AuditReaderFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@QuarkusTest
class AuditedPanacheEntityBaseTest {
@Inject
EntityManager entityManager;
@InjectSpy
TracingService tracingService;
@SuppressWarnings("Convert2MethodRef")
@BeforeEach
void setup() {
QuarkusTransaction.requiringNew().run(() -> TestEntity2.deleteAll());
}
@Test
void persistAndUpdate() {
when(tracingService.getRequestId()).thenReturn("00000000-0000-0000-0000-000000000000");
when(tracingService.getSpanId()).thenReturn("0000000000000000");
when(tracingService.getTraceId()).thenReturn("00000000000000000000000000000000");
when(tracingService.getActor()).thenReturn("unknown");
when(tracingService.getClientIp()).thenReturn("unknown");
var now = Instant.now();
QuarkusTransaction.requiringNew().run(() -> {
var entity = new TestEntity2("something");
entity.persist();
});
QuarkusTransaction.requiringNew().run(() -> {
var entity = TestEntity2.findBySomething("something");
assertAll(
() -> assertThat(entity.createdAt)
.as("createdAt should be after or equal to expected value")
.isAfterOrEqualTo(now),
() -> assertThat(entity.lastUpdatedAt)
.as("lastUpdatedAt should be equal to createdAt")
.isEqualTo(entity.createdAt));
var auditReader = AuditReaderFactory.get(entityManager);
var revisions = auditReader.getRevisions(TestEntity2.class, entity.id);
assertThat(revisions).hasSize(1);
var revInfo = entityManager.find(Revision.class, revisions.getFirst());
assertThat(revInfo).isNotNull();
assertAll(
() -> assertThat(revInfo.timestamp)
.as("revision timestamp should be equal to entity's createdAt timestamp")
.isEqualTo(entity.createdAt),
() -> assertThat(revInfo.actor)
.as("actor should match expected value")
.isEqualTo("unknown"),
() -> assertThat(revInfo.traceId)
.as("traceId should match expected value")
.isEqualTo("00000000000000000000000000000000"),
() -> assertThat(revInfo.spanId)
.as("spanId should match expected value")
.isEqualTo("0000000000000000"),
() -> assertThat(revInfo.requestId)
.as("requestId should match expected value")
.isEqualTo("00000000-0000-0000-0000-000000000000"),
() -> assertThat(revInfo.clientIp)
.as("clientIp should match expected value")
.isEqualTo("unknown"),
() -> assertThat(revInfo.hostName)
.as("hostName should not be blank")
.isNotBlank());
});
QuarkusTransaction.requiringNew().run(() -> {
var entity = TestEntity2.findBySomething("something");
entity.something = "else";
});
QuarkusTransaction.requiringNew().run(() -> {
var entity = TestEntity2.findBySomething("else");
assertAll(() -> assertThat(entity.createdAt)
.as("createdAt should be before lastUpdatedAt")
.isBefore(entity.lastUpdatedAt));
var auditReader = AuditReaderFactory.get(entityManager);
var revisions = auditReader.getRevisions(TestEntity2.class, entity.id);
assertThat(revisions).hasSize(2);
Revision revInfo = Revision.findById(revisions.getLast());
assertThat(revInfo).isNotNull();
assertAll(
() -> assertThat(revInfo.timestamp)
.as("revision timestamp should not be equal to entity's createdAt")
.isNotEqualTo(entity.createdAt),
() -> assertThat(revInfo.timestamp)
.as("revision timestamp should be equal to entity's lastUpdatedAt")
.isEqualTo(entity.lastUpdatedAt),
() -> assertThat(revInfo.actor)
.as("actor should match expected value")
.isEqualTo("unknown"),
() -> assertThat(revInfo.traceId)
.as("traceId should match expected value")
.isEqualTo("00000000000000000000000000000000"),
() -> assertThat(revInfo.spanId)
.as("spanId should match expected value")
.isEqualTo("0000000000000000"),
() -> assertThat(revInfo.requestId)
.as("requestId should match expected value")
.isEqualTo("00000000-0000-0000-0000-000000000000"),
() -> assertThat(revInfo.clientIp)
.as("clientIp should match expected value")
.isEqualTo("unknown"),
() -> assertThat(revInfo.hostName)
.as("hostName should not be blank")
.isNotBlank());
});
}
}

View file

@ -0,0 +1,136 @@
package ch.phoenix.oss.quarkus.commons.audit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.mockito.Mockito.when;
import ch.phoenix.oss.quarkus.commons.tracing.TracingService;
import io.quarkus.narayana.jta.QuarkusTransaction;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.mockito.InjectSpy;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import java.time.Instant;
import org.hibernate.envers.AuditReaderFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@QuarkusTest
class AuditedPanacheEntityTest {
@Inject
EntityManager entityManager;
@InjectSpy
TracingService tracingService;
@SuppressWarnings("Convert2MethodRef")
@BeforeEach
void setup() {
QuarkusTransaction.requiringNew().run(() -> TestEntity.deleteAll());
}
@Test
void persistAndUpdate() {
when(tracingService.getRequestId()).thenReturn("00000000-0000-0000-0000-000000000000");
when(tracingService.getSpanId()).thenReturn("0000000000000000");
when(tracingService.getTraceId()).thenReturn("00000000000000000000000000000000");
when(tracingService.getActor()).thenReturn("unknown");
when(tracingService.getClientIp()).thenReturn("unknown");
var now = Instant.now();
QuarkusTransaction.requiringNew().run(() -> {
var entity = new TestEntity("something");
entity.persist();
});
QuarkusTransaction.requiringNew().run(() -> {
var entity = TestEntity.findBySomething("something");
assertAll(
() -> assertThat(entity.createdAt)
.as("createdAt should be after or equal to expected value")
.isAfterOrEqualTo(now),
() -> assertThat(entity.lastUpdatedAt)
.as("lastUpdatedAt should be equal to createdAt")
.isEqualTo(entity.createdAt));
var auditReader = AuditReaderFactory.get(entityManager);
var revisions = auditReader.getRevisions(TestEntity.class, entity.id);
assertThat(revisions).hasSize(1);
var revInfo = entityManager.find(Revision.class, revisions.getFirst());
assertThat(revInfo).isNotNull();
assertAll(
() -> assertThat(revInfo.timestamp)
.as("revision timestamp should be equal to entity's createdAt timestamp")
.isEqualTo(entity.createdAt),
() -> assertThat(revInfo.actor)
.as("actor should match expected value")
.isEqualTo("unknown"),
() -> assertThat(revInfo.traceId)
.as("traceId should match expected value")
.isEqualTo("00000000000000000000000000000000"),
() -> assertThat(revInfo.spanId)
.as("spanId should match expected value")
.isEqualTo("0000000000000000"),
() -> assertThat(revInfo.requestId)
.as("requestId should match expected value")
.isEqualTo("00000000-0000-0000-0000-000000000000"),
() -> assertThat(revInfo.clientIp)
.as("clientIp should match expected value")
.isEqualTo("unknown"),
() -> assertThat(revInfo.hostName)
.as("hostName should not be blank")
.isNotBlank());
});
QuarkusTransaction.requiringNew().run(() -> {
var entity = TestEntity.findBySomething("something");
entity.something = "else";
});
QuarkusTransaction.requiringNew().run(() -> {
var entity = TestEntity.findBySomething("else");
assertAll(() -> assertThat(entity.createdAt)
.as("createdAt should be before lastUpdatedAt")
.isBefore(entity.lastUpdatedAt));
var auditReader = AuditReaderFactory.get(entityManager);
var revisions = auditReader.getRevisions(TestEntity.class, entity.id);
assertThat(revisions).hasSize(2);
Revision revInfo = Revision.findById(revisions.getLast());
assertThat(revInfo).isNotNull();
assertAll(
() -> assertThat(revInfo.timestamp)
.as("revision timestamp should not be equal to entity's createdAt")
.isNotEqualTo(entity.createdAt),
() -> assertThat(revInfo.timestamp)
.as("revision timestamp should be equal to entity's lastUpdatedAt")
.isEqualTo(entity.lastUpdatedAt),
() -> assertThat(revInfo.actor)
.as("actor should match expected value")
.isEqualTo("unknown"),
() -> assertThat(revInfo.traceId)
.as("traceId should match expected value")
.isEqualTo("00000000000000000000000000000000"),
() -> assertThat(revInfo.spanId)
.as("spanId should match expected value")
.isEqualTo("0000000000000000"),
() -> assertThat(revInfo.requestId)
.as("requestId should match expected value")
.isEqualTo("00000000-0000-0000-0000-000000000000"),
() -> assertThat(revInfo.clientIp)
.as("clientIp should match expected value")
.isEqualTo("unknown"),
() -> assertThat(revInfo.hostName)
.as("hostName should not be blank")
.isNotBlank());
});
}
}

View file

@ -0,0 +1,32 @@
package ch.phoenix.oss.quarkus.commons.audit;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mockStatic;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.net.InetAddress;
import java.net.UnknownHostException;
import org.junit.jupiter.api.Test;
@QuarkusTest
class DefaultRevisionContextProviderTest {
@Inject
DefaultRevisionContextProvider underTest;
@Test
void getHostName() {
assertThat(underTest.getHostName()).isNotBlank();
}
@Test
void getHostNameWhenUnknown() {
try (var inetMock = mockStatic(InetAddress.class)) {
inetMock.when(InetAddress::getLocalHost).thenThrow(new UnknownHostException("simulated failure"));
assertThat(underTest.getHostName()).isEqualTo("unknown");
}
}
}

View file

@ -0,0 +1,28 @@
package ch.phoenix.oss.quarkus.commons.audit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
@QuarkusTest
class RevisionTest {
@Test
void testEquals() {
var r1 = new Revision();
r1.rev = 1;
var r2 = new Revision();
r2.rev = 1;
assertThat(r1).as("Revisions should be equal").isEqualTo(r2);
}
@Test
void testHashCode() {}
@Test
void testToString() {}
}

View file

@ -0,0 +1,23 @@
package ch.phoenix.oss.quarkus.commons.audit;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import org.hibernate.envers.Audited;
@Entity
@Audited
@Table(name = "test_entity")
public class TestEntity extends AuditedPanacheEntity {
public String something;
public TestEntity() {}
public TestEntity(String something) {
this.something = something;
}
public static TestEntity findBySomething(String something) {
return find("something", something).singleResult();
}
}

View file

@ -0,0 +1,29 @@
package ch.phoenix.oss.quarkus.commons.audit;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.envers.Audited;
@Entity
@Audited
@Table(name = "test_entity")
public class TestEntity2 extends AuditedPanacheEntityBase {
@Id
@GeneratedValue
public Long id;
public String something;
public TestEntity2() {}
public TestEntity2(String something) {
this.something = something;
}
public static TestEntity2 findBySomething(String something) {
return find("something", something).singleResult();
}
}

View file

@ -0,0 +1,22 @@
quarkus:
flyway:
migrate-at-start: true
datasource:
db-kind: postgresql
hibernate-orm:
sql-load-script: no-file
schema-management:
strategy: none
log:
sql: true
bind-parameters: true
hibernate-envers:
audit-strategy: org.hibernate.envers.strategy.internal.ValidityAuditStrategy
revision-listener: ch.phoenix.oss.quarkus.commons.audit.AuditRevisionListener
security:
users:
embedded:
enabled: true
plain-text: true
users:
jon: doe

View file

@ -0,0 +1,46 @@
create sequence revinfo_seq start with 1 increment by 50;
create sequence test_entity_seq start with 1 increment by 50;
create table revinfo
(
rev bigint not null,
timestamp timestamp(6) with time zone not null,
actor varchar(255),
spanId varchar(255),
traceId varchar(255),
requestId varchar(255),
clientIp varchar(255),
hostName varchar(255),
primary key (rev)
);
create table test_entity
(
id bigint primary key not null,
something varchar(255),
createdAt timestamp(6) with time zone,
lastUpdatedAt timestamp(6) with time zone
);
create table test_entity_aud
(
revtype smallint,
rev bigint not null,
revend bigint,
id bigint not null,
something varchar(255),
primary key (rev, id)
);
alter table if exists test_entity_aud
add constraint fk_rev__revinfo_rev
foreign key (rev)
references revinfo;
alter table if exists test_entity_aud
add constraint fk_revend__revinfo_rev
foreign key (revend)
references revinfo;

View file

@ -0,0 +1,35 @@
CREATE OR REPLACE FUNCTION trg_test_entity_aud_apply_rev()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $func$
DECLARE
ts TIMESTAMP;
BEGIN
-- fetch the exact revision timestamp from revinfo
SELECT r.timestamp
INTO ts
FROM revinfo r
WHERE r.rev = NEW.rev;
-- only set created_at once, when still NULL
UPDATE test_entity
SET createdAt = ts
WHERE id = NEW.id
AND createdAt IS NULL;
-- always bump last_updated_at
UPDATE test_entity
SET lastUpdatedAt = ts
WHERE id = NEW.id;
RETURN NULL;
END;
$func$;
DROP TRIGGER IF EXISTS trg_test_entity_aud_after_insert
ON test_entity_aud;
CREATE TRIGGER trg_test_entity_aud_after_insert
AFTER INSERT ON test_entity_aud
FOR EACH ROW
EXECUTE FUNCTION trg_test_entity_aud_apply_rev();

View file

@ -0,0 +1,37 @@
-- 1) Create or replace the trigger function
CREATE OR REPLACE FUNCTION trg_test_entity_aud_apply_rev()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $func$
DECLARE
ts TIMESTAMP;
BEGIN
-- fetch the exact revision timestamp from revinfo
SELECT r.timestamp
INTO ts
FROM revinfo r
WHERE r.rev = NEW.rev;
-- only set created_at once, when still NULL
UPDATE test_entity
SET created_at = ts
WHERE id = NEW.id
AND created_at IS NULL;
-- always bump last_updated_at
UPDATE test_entity
SET last_updated_at = ts
WHERE id = NEW.id;
RETURN NULL; -- AFTER trigger ignores return value
END;
$func$;
-- 2) Drop any existing trigger, then attach the new one
DROP TRIGGER IF EXISTS trg_test_entity_aud_after_insert
ON test_entity_aud;
CREATE TRIGGER trg_test_entity_aud_after_insert
AFTER INSERT ON test_entity_aud
FOR EACH ROW
EXECUTE FUNCTION trg_test_entity_aud_apply_rev();