AuditService.java

1
package com.rbctcsworld.ecommerce.audit;
2
3
import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
4
import jakarta.persistence.EntityManager;
5
import org.springframework.jdbc.core.JdbcTemplate;
6
import org.springframework.jdbc.core.RowMapper;
7
import org.springframework.stereotype.Service;
8
import org.springframework.transaction.annotation.Transactional;
9
import org.springframework.transaction.event.TransactionPhase;
10
import org.springframework.transaction.event.TransactionalEventListener;
11
12
import java.time.Clock;
13
import java.time.LocalDateTime;
14
import java.time.temporal.ChronoUnit;
15
import java.util.ArrayList;
16
import java.util.List;
17
import java.util.Locale;
18
19
/**
20
 * Writes and reads the audit trail (audit_log).
21
 *
22
 * Writing: services publish an AuditEvent inside their transaction. Just before that transaction commits,
23
 * this listener first flushes the pending entity changes (so every row lock the transaction needs is already
24
 * taken), then locks the chain head, appends one row per changed field and moves the head. The head is the
25
 * LAST lock any transaction takes, so audited transactions cannot deadlock on it. If writing fails, the
26
 * whole transaction rolls back: no change without its audit row.
27
 *
28
 * Reading (store admin): filter, verify the hash chain, export as CSV.
29
 */
30
@Service
31
public class AuditService {
32
33
    public static final int DEFAULT_LIMIT = 200;
34
    public static final int MAX_LIMIT = 1000;
35
36
    private static final RowMapper<AuditRecord> ROW = (rs, i) -> new AuditRecord(rs.getLong("seq"),
37
            rs.getTimestamp("occurred_at").toLocalDateTime(), rs.getString("actor"), rs.getString("entity_type"),
38
            rs.getLong("entity_id"), rs.getString("action"), rs.getString("field"), rs.getString("old_value"),
39
            rs.getString("new_value"), rs.getString("prev_hash"), rs.getString("hash"));
40
    private static final String COLUMNS =
41
            "seq, occurred_at, actor, entity_type, entity_id, action, field, old_value, new_value, prev_hash, hash";
42
43
    private final JdbcTemplate jdbc;
44
    private final EntityManager em;
45
    private final Clock clock;
46
47
    public AuditService(JdbcTemplate jdbc, EntityManager em, Clock clock) {
48
        this.jdbc = jdbc;
49
        this.em = em;
50
        this.clock = clock;
51
    }
52
53
    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
54
    public void on(AuditEvent event) {
55 1 1. on : removed conditional - replaced equality check with false → KILLED
        if (event.changes().isEmpty()) return;
56 1 1. on : removed call to jakarta/persistence/EntityManager::flush → KILLED
        em.flush();
57 1 1. on : removed call to com/rbctcsworld/ecommerce/audit/AuditService::append → KILLED
        append(event.actor(), event.changes());
58
    }
59
60
    void append(String actor, List<AuditChange> changes) {
61
        long[] seq = new long[1];
62
        String[] prev = new String[1];
63 1 1. append : removed call to org/springframework/jdbc/core/JdbcTemplate::query → KILLED
        jdbc.query("select last_seq, last_hash from audit_head where id = 1 for update", rs -> {
64
            seq[0] = rs.getLong(1);
65
            prev[0] = rs.getString(2);
66
        });
67 1 1. append : removed conditional - replaced equality check with false → KILLED
        if (prev[0] == null) throw new IllegalStateException("audit_head row is missing");
68
        LocalDateTime now = LocalDateTime.now(clock).truncatedTo(ChronoUnit.MILLIS);
69
        for (AuditChange c : changes) {
70 1 1. append : Replaced long addition with subtraction → KILLED
            seq[0]++;
71
            String hash = AuditChain.hash(prev[0], seq[0], now, actor, c.entityType(), c.entityId(), c.action(),
72
                    c.field(), c.oldValue(), c.newValue());
73
            jdbc.update("insert into audit_log (" + COLUMNS + ") values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
74
                    seq[0], now, actor, c.entityType(), c.entityId(), c.action(), c.field(), c.oldValue(),
75
                    c.newValue(), prev[0], hash);
76
            prev[0] = hash;
77
        }
78
        jdbc.update("update audit_head set last_seq = ?, last_hash = ? where id = 1", seq[0], prev[0]);
79
    }
80
81
    /** Newest first. Every filter is optional; limit 1..MAX_LIMIT (400 otherwise). */
82
    @Transactional(readOnly = true)
83
    public List<AuditRecord> find(String entityType, Long entityId, String actor, Integer limit) {
84 1 1. find : removed conditional - replaced equality check with false → KILLED
        int n = limit == null ? DEFAULT_LIMIT : limit;
85 4 1. find : removed conditional - replaced comparison check with false → KILLED
2. find : removed conditional - replaced comparison check with false → KILLED
3. find : changed conditional boundary → KILLED
4. find : changed conditional boundary → KILLED
        if (n < 1 || n > MAX_LIMIT) throw new BusinessRuleException("limit must be between 1 and " + MAX_LIMIT);
86
        StringBuilder sql = new StringBuilder("select " + COLUMNS + " from audit_log where 1 = 1");
87
        List<Object> args = new ArrayList<>();
88 2 1. find : removed conditional - replaced equality check with false → KILLED
2. find : removed conditional - replaced equality check with false → KILLED
        if (entityType != null && !entityType.isBlank()) {
89
            sql.append(" and entity_type = ?");
90
            args.add(entityType.trim().toUpperCase(Locale.ROOT));
91
        }
92 1 1. find : removed conditional - replaced equality check with false → KILLED
        if (entityId != null) {
93
            sql.append(" and entity_id = ?");
94
            args.add(entityId);
95
        }
96 2 1. find : removed conditional - replaced equality check with false → KILLED
2. find : removed conditional - replaced equality check with false → KILLED
        if (actor != null && !actor.isBlank()) {
97
            sql.append(" and lower(actor) = ?");
98
            args.add(actor.trim().toLowerCase(Locale.ROOT));
99
        }
100
        sql.append(" order by seq desc limit ").append(n);
101 1 1. find : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/audit/AuditService::find → KILLED
        return jdbc.query(sql.toString(), ROW, args.toArray());
102
    }
103
104
    /** Recomputes the whole chain and compares its end with the head. */
105
    @Transactional(readOnly = true)
106
    public AuditChain.Verification verify() {
107
        List<AuditRecord> rows = jdbc.query("select " + COLUMNS + " from audit_log order by seq", ROW);
108
        long[] headSeq = new long[1];
109
        String[] headHash = new String[1];
110 1 1. verify : removed call to org/springframework/jdbc/core/JdbcTemplate::query → KILLED
        jdbc.query("select last_seq, last_hash from audit_head where id = 1", rs -> {
111
            headSeq[0] = rs.getLong(1);
112
            headHash[0] = rs.getString(2);
113
        });
114 1 1. verify : replaced return value with null for com/rbctcsworld/ecommerce/audit/AuditService::verify → KILLED
        return AuditChain.verify(rows, headSeq[0], headHash[0]);
115
    }
116
117
    /** The filtered rows as CSV (oldest first), safe to open in a spreadsheet. */
118
    @Transactional(readOnly = true)
119
    public String csv(String entityType, Long entityId, String actor) {
120
        List<AuditRecord> rows = new ArrayList<>(find(entityType, entityId, actor, MAX_LIMIT));
121 1 1. csv : removed call to java/util/Collections::reverse → KILLED
        java.util.Collections.reverse(rows);
122
        StringBuilder s = new StringBuilder("seq,occurred_at,actor,entity_type,entity_id,action,field,old_value,new_value,hash\r\n");
123
        for (AuditRecord r : rows) {
124
            s.append(r.seq()).append(',').append(cell(r.occurredAt().toString())).append(',').append(cell(r.actor()))
125
                    .append(',').append(cell(r.entityType())).append(',').append(r.entityId()).append(',')
126
                    .append(cell(r.action())).append(',').append(cell(r.field())).append(',')
127
                    .append(cell(r.oldValue())).append(',').append(cell(r.newValue())).append(',')
128
                    .append(cell(r.hash())).append("\r\n");
129
        }
130 1 1. csv : replaced return value with "" for com/rbctcsworld/ecommerce/audit/AuditService::csv → KILLED
        return s.toString();
131
    }
132
133
    /**
134
     * One CSV cell: quoted, inner quotes doubled. A value that a spreadsheet would run as a formula
135
     * (=, +, -, @, tab, carriage return at the start) gets a leading apostrophe - CSV injection (OWASP).
136
     */
137
    static String cell(String value) {
138 1 1. cell : replaced return value with "" for com/rbctcsworld/ecommerce/audit/AuditService::cell → KILLED
        return com.rbctcsworld.ecommerce.common.Csv.cell(value);
139
    }
140
}

Mutations

55

1.1
Location : on
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:anEventWithoutChangesWritesNothing()]
removed conditional - replaced equality check with false → KILLED

56

1.1
Location : on
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:pendingEntityChangesAreFlushedBeforeTheHeadIsLocked()]
removed call to jakarta/persistence/EntityManager::flush → KILLED

57

1.1
Location : on
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:verifyFindsAnEditedRowAndRemovedNewestRows()]
removed call to com/rbctcsworld/ecommerce/audit/AuditService::append → KILLED

63

1.1
Location : append
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:verifyFindsAnEditedRowAndRemovedNewestRows()]
removed call to org/springframework/jdbc/core/JdbcTemplate::query → KILLED

67

1.1
Location : append
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:aMissingHeadRowStopsTheWrite()]
removed conditional - replaced equality check with false → KILLED

70

1.1
Location : append
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:verifyFindsAnEditedRowAndRemovedNewestRows()]
Replaced long addition with subtraction → KILLED

84

1.1
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:laterEventsContinueTheSameChain()]
removed conditional - replaced equality check with false → KILLED

85

1.1
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:theLimitMustBeBetweenOneAndOneThousand()]
removed conditional - replaced comparison check with false → KILLED

2.2
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:theLimitMustBeBetweenOneAndOneThousand()]
removed conditional - replaced comparison check with false → KILLED

3.3
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:theLimitMustBeBetweenOneAndOneThousand()]
changed conditional boundary → KILLED

4.4
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:theLimitMustBeBetweenOneAndOneThousand()]
changed conditional boundary → KILLED

88

1.1
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:filtersAreCombinedAndForgiving()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:filtersAreCombinedAndForgiving()]
removed conditional - replaced equality check with false → KILLED

92

1.1
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:filtersAreCombinedAndForgiving()]
removed conditional - replaced equality check with false → KILLED

96

1.1
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:filtersAreCombinedAndForgiving()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:filtersAreCombinedAndForgiving()]
removed conditional - replaced equality check with false → KILLED

101

1.1
Location : find
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:laterEventsContinueTheSameChain()]
replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/audit/AuditService::find → KILLED

110

1.1
Location : verify
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:anEmptyTrailVerifies()]
removed call to org/springframework/jdbc/core/JdbcTemplate::query → KILLED

114

1.1
Location : verify
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:anEmptyTrailVerifies()]
replaced return value with null for com/rbctcsworld/ecommerce/audit/AuditService::verify → KILLED

121

1.1
Location : csv
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:theCsvIsOldestFirstWithEveryColumn()]
removed call to java/util/Collections::reverse → KILLED

130

1.1
Location : csv
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:theCsvIsOldestFirstWithEveryColumn()]
replaced return value with "" for com/rbctcsworld/ecommerce/audit/AuditService::csv → KILLED

138

1.1
Location : cell
Killed by : com.rbctcsworld.ecommerce.audit.AuditServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.audit.AuditServiceTest]/[method:cellsThatASpreadsheetWouldRunAsAFormulaAreDefused()]
replaced return value with "" for com/rbctcsworld/ecommerce/audit/AuditService::cell → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.0 support