fix: persist system update history
Release / release (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-19 00:22:23 +08:00
parent 1230116a6f
commit 12d9c46323
12 changed files with 465 additions and 25 deletions
@@ -22,6 +22,54 @@ public interface AuditMapper {
""")
int insert(AuditEntry entry);
@Select("""
SELECT request_id, user_public_id, username, active_role, company_public_id, project_public_id,
CAST(after_json AS CHAR) AS after_json, ip_address, user_agent
FROM audit_log
WHERE object_type = 'SYSTEM_UPDATE'
AND action_code = #{actionCode}
AND request_id = #{requestId}
ORDER BY id DESC
LIMIT 1
""")
SystemUpdateAuditSource findSystemUpdateSourceByRequestId(@Param("requestId") String requestId,
@Param("actionCode") String actionCode);
@Select("""
SELECT request_id, user_public_id, username, active_role, company_public_id, project_public_id,
CAST(after_json AS CHAR) AS after_json, ip_address, user_agent
FROM audit_log
WHERE object_type = 'SYSTEM_UPDATE'
AND action_code = #{actionCode}
AND (
JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.targetVersion')) = #{targetVersion}
OR JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.latestVersion')) = #{targetVersion}
)
AND created_at >= DATE_SUB(#{completedAt}, INTERVAL 7 DAY)
AND created_at <= DATE_ADD(#{completedAt}, INTERVAL 5 MINUTE)
ORDER BY id DESC
LIMIT 1
""")
SystemUpdateAuditSource findSystemUpdateSourceByVersion(@Param("targetVersion") String targetVersion,
@Param("actionCode") String actionCode,
@Param("completedAt") LocalDateTime completedAt);
@Insert("""
INSERT INTO audit_log (
public_id, request_id, user_public_id, username, active_role, company_public_id,
project_public_id, action_code, object_type, object_public_id, result_code, reason,
before_json, after_json, ip_address, user_agent, dedupe_key, created_at
) VALUES (
#{entry.publicId}, #{entry.requestId}, #{entry.userPublicId}, #{entry.username}, #{entry.activeRole},
#{entry.companyPublicId}, #{entry.projectPublicId}, #{entry.actionCode}, #{entry.objectType},
#{entry.objectPublicId}, #{entry.resultCode}, #{entry.reason}, #{entry.beforeJson}, #{entry.afterJson},
#{entry.ipAddress}, #{entry.userAgent}, #{dedupeKey}, #{occurredAt}
)
ON DUPLICATE KEY UPDATE dedupe_key = #{dedupeKey}
""")
int insertSystemUpdateTerminal(@Param("entry") AuditEntry entry, @Param("dedupeKey") String dedupeKey,
@Param("occurredAt") LocalDateTime occurredAt);
@Select("""
SELECT created_at, ip_address, user_agent
FROM audit_log
@@ -36,4 +84,9 @@ public interface AuditMapper {
record LoginAuditRow(LocalDateTime occurredAt, String ipAddress, String userAgent) {
}
record SystemUpdateAuditSource(String requestId, String userPublicId, String username, String activeRole,
String companyPublicId, String projectPublicId, String afterJson,
String ipAddress, String userAgent) {
}
}
@@ -7,6 +7,16 @@ import com.kaidi.finance.shared.id.UlidGenerator;
import com.kaidi.finance.shared.infrastructure.RequestContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@@ -54,6 +64,71 @@ public class AuditService {
null, null);
}
/**
* Persists the updater's terminal state as a separate immutable audit event.
*
* The shell updater finishes while the application is restarting, so this method is invoked by the first
* successful status read after restart. A database-level dedupe key makes repeated polling and application
* restarts idempotent. When possible the terminal event inherits the actor and request id from the original
* download/install request; legacy status files without a request id fall back to the most recent matching
* target version.
*
* @return the stable dedupe key, or {@code null} when the supplied state is not persistable
*/
public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state,
String targetVersion, String message, Instant updatedAt) {
String normalizedState = normalizeTerminalState(state);
String normalizedVersion = clean(targetVersion, 128);
if (normalizedState == null || normalizedVersion == null || updatedAt == null) return null;
String normalizedRequestId = validRequestId(updateRequestId) ? updateRequestId : null;
String normalizedAction = clean(updateAction, 16);
if (normalizedAction != null) normalizedAction = normalizedAction.toUpperCase(Locale.ROOT);
String sourceAction = sourceAction(normalizedState, normalizedAction);
AuditMapper.SystemUpdateAuditSource source = findSystemUpdateSource(
normalizedRequestId, normalizedVersion, sourceAction, updatedAt);
if (source == null && normalizedAction == null && !"SYSTEM_UPDATE_DOWNLOAD_REQUEST".equals(sourceAction)) {
source = findSystemUpdateSource(normalizedRequestId, normalizedVersion,
"SYSTEM_UPDATE_DOWNLOAD_REQUEST", updatedAt);
}
String correlation = normalizedRequestId == null
? normalizedVersion + '|' + normalizedState + '|' + updatedAt
: normalizedRequestId + '|' + normalizedState;
String dedupeKey = "SYSUPD:" + sha256(correlation);
String terminalAction = switch (normalizedState) {
case "SUCCEEDED" -> "SYSTEM_UPDATE_SUCCEEDED";
case "RECOVERY_REQUIRED" -> "SYSTEM_UPDATE_RECOVERY_REQUIRED";
default -> "SYSTEM_UPDATE_FAILED";
};
String result = "SUCCEEDED".equals(normalizedState)
? "SUCCESS" : ("RECOVERY_REQUIRED".equals(normalizedState) ? "BLOCKED" : "FAILED");
String terminalReason = truncate(sanitizeReason(clean(message, 1000)), 500);
Map<String, Object> terminal = new LinkedHashMap<>();
terminal.put("state", normalizedState);
terminal.put("targetVersion", normalizedVersion);
terminal.put("message", terminalReason == null ? "" : terminalReason);
terminal.put("updatedAt", updatedAt);
if (normalizedAction != null) terminal.put("action", normalizedAction);
if (normalizedRequestId != null) terminal.put("requestId", normalizedRequestId);
Actor actor = source == null
? currentActor() : new Actor(source.userPublicId(), source.username(), source.activeRole());
AuditEntry entry = new AuditEntry(
ulidGenerator.next(), source == null ? RequestContext.requestId() : source.requestId(),
actor.publicId(), actor.username(), actor.activeRole(),
source == null ? null : source.companyPublicId(), source == null ? null : source.projectPublicId(),
terminalAction, "SYSTEM_UPDATE", "SYSTEM_UPDATE", result, terminalReason,
source == null ? null : source.afterJson(), json(terminal),
source == null ? clientIp() : source.ipAddress(), source == null
? truncate(request.getHeader("User-Agent"), 500) : source.userAgent()
);
auditMapper.insertSystemUpdateTerminal(entry, dedupeKey,
LocalDateTime.ofInstant(updatedAt, ZoneOffset.UTC));
return dedupeKey;
}
private void insert(Actor actor, String companyPublicId, String projectPublicId, String action,
String objectType, String objectPublicId, String result, String reason,
Object before, Object after) {
@@ -76,6 +151,49 @@ public class AuditService {
return new Actor(null, null, null);
}
private AuditMapper.SystemUpdateAuditSource findSystemUpdateSource(String requestId, String targetVersion,
String actionCode, Instant completedAt) {
AuditMapper.SystemUpdateAuditSource source = requestId == null
? null : auditMapper.findSystemUpdateSourceByRequestId(requestId, actionCode);
return source == null ? auditMapper.findSystemUpdateSourceByVersion(targetVersion, actionCode,
LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC)) : source;
}
private static String normalizeTerminalState(String state) {
String normalized = clean(state, 32);
if (normalized == null) return null;
normalized = normalized.toUpperCase(Locale.ROOT);
return switch (normalized) {
case "SUCCEEDED", "FAILED", "RECOVERY_REQUIRED" -> normalized;
default -> null;
};
}
private static String sourceAction(String state, String action) {
if ("SUCCEEDED".equals(state) || "INSTALL".equals(action)) return "SYSTEM_UPDATE_REQUEST";
if ("DOWNLOAD".equals(action)) return "SYSTEM_UPDATE_DOWNLOAD_REQUEST";
return "SYSTEM_UPDATE_REQUEST";
}
private static boolean validRequestId(String requestId) {
return requestId != null && requestId.matches("^[0-9A-HJKMNP-TV-Z]{26}$");
}
private static String clean(String value, int max) {
if (value == null || value.isBlank()) return null;
String cleaned = value.trim();
return cleaned.length() <= max ? cleaned : cleaned.substring(0, max);
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private String json(Object value) {
if (value == null) {
return null;
@@ -36,12 +36,16 @@ import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@Service
public class SystemUpdateApplicationService {
private static final Logger log = LoggerFactory.getLogger(SystemUpdateApplicationService.class);
private static final int MAX_MANIFEST_BYTES = 64 * 1024;
private static final int MAX_RELEASE_API_BYTES = 256 * 1024;
private static final int MAX_STATUS_BYTES = 64 * 1024;
@@ -71,6 +75,7 @@ public class SystemUpdateApplicationService {
private final HttpClient httpClient;
private volatile ReleaseManifest lastManifest;
private volatile Instant lastCheckedAt;
private volatile String lastTerminalAuditFingerprint;
public SystemUpdateApplicationService(SystemUpdateProperties properties,
AuthorizationService authorizationService,
@@ -90,7 +95,9 @@ public class SystemUpdateApplicationService {
public SystemUpdateView status() {
requireIsolatedSystemAdministrator();
return view(lastManifest, readStatus());
UpdateStatus status = readStatus();
persistTerminalAudit(status);
return view(lastManifest, status);
}
public SystemUpdateView check() {
@@ -99,7 +106,9 @@ public class SystemUpdateApplicationService {
ReleaseManifest manifest = fetchManifest();
lastManifest = manifest;
lastCheckedAt = Instant.now();
SystemUpdateView result = view(manifest, readStatus());
UpdateStatus status = readStatus();
persistTerminalAudit(status);
SystemUpdateView result = view(manifest, status);
auditService.record("SYSTEM_UPDATE_CHECK", "SYSTEM_UPDATE", "SYSTEM_UPDATE", "SUCCESS", null, null,
result);
return result;
@@ -120,6 +129,7 @@ public class SystemUpdateApplicationService {
throw validation("当前已是相同或更高版本");
}
UpdateStatus currentStatus = readStatus();
persistTerminalAudit(currentStatus);
if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) {
throw validation("该版本已经下载并通过校验,请确认安装");
}
@@ -137,6 +147,7 @@ public class SystemUpdateApplicationService {
requireConfigured();
String requested = request.version().trim();
UpdateStatus ready = readStatus();
persistTerminalAudit(ready);
if (!"READY".equals(ready.state()) || !requested.equals(ready.targetVersion())) {
throw validation("该版本尚未完成下载和签名校验");
}
@@ -369,7 +380,8 @@ public class SystemUpdateApplicationService {
"已有系统更新请求等待执行");
}
writeRequest(requestFile, version, reason, action);
UpdateStatus queued = new UpdateStatus(queuedState, queuedMessage, version, Instant.now());
UpdateStatus queued = new UpdateStatus(queuedState, queuedMessage, version, Instant.now(),
RequestContext.requestId(), action);
return new QueueTransition(currentStatus, queued);
} catch (IOException exception) {
throw storage("系统更新队列锁定失败");
@@ -431,7 +443,8 @@ public class SystemUpdateApplicationService {
blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)),
nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"),
nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100),
boundedInteger(root, "restartExpectedSeconds", 0, 300));
boundedInteger(root, "restartExpectedSeconds", 0, 300),
boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null)));
} catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
}
@@ -525,10 +538,13 @@ public class SystemUpdateApplicationService {
String version = blank(root.path("version").asText(null));
String action = root.path("action").asText("INSTALL").toUpperCase(Locale.ROOT);
Instant requestedAt = parseInstant(root.path("requestedAt").asText(null));
String requestId = boundedText(root, "requestId", 64);
if ("DOWNLOAD".equals(action)) {
return new UpdateStatus("DOWNLOAD_QUEUED", "下载请求已排队,等待更新服务处理", version, requestedAt);
return new UpdateStatus("DOWNLOAD_QUEUED", "下载请求已排队,等待更新服务处理", version,
requestedAt, requestId, action);
}
return new UpdateStatus("INSTALL_QUEUED", "安装请求已排队,等待更新服务处理", version, requestedAt);
return new UpdateStatus("INSTALL_QUEUED", "安装请求已排队,等待更新服务处理", version,
requestedAt, requestId, action);
} catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新请求读取失败", null, null);
}
@@ -631,6 +647,39 @@ public class SystemUpdateApplicationService {
return parsed < minimum || parsed > maximum ? null : parsed;
}
private static String boundedText(JsonNode root, String field, int maximum) {
JsonNode value = root.path(field);
if (!value.isTextual()) return null;
String parsed = blank(value.asText(null));
return parsed != null && parsed.length() <= maximum ? parsed : null;
}
private static String updateAction(String value) {
String action = blank(value);
if (action == null) return null;
action = action.toUpperCase(Locale.ROOT);
return "DOWNLOAD".equals(action) || "INSTALL".equals(action) ? action : null;
}
private void persistTerminalAudit(UpdateStatus status) {
if (status == null || status.updatedAt() == null || status.targetVersion() == null
|| !("SUCCEEDED".equals(status.state()) || "FAILED".equals(status.state())
|| "RECOVERY_REQUIRED".equals(status.state()))) {
return;
}
String fingerprint = String.join("|", status.state(), String.valueOf(status.requestId()),
status.targetVersion(), status.updatedAt().toString(), String.valueOf(status.action()));
if (fingerprint.equals(lastTerminalAuditFingerprint)) return;
try {
String persistedKey = auditService.recordSystemUpdateTerminal(status.requestId(), status.action(),
status.state(), status.targetVersion(), status.message(), status.updatedAt());
if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint;
} catch (RuntimeException exception) {
log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(),
status.targetVersion(), exception);
}
}
private BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
}
@@ -649,9 +698,15 @@ public class SystemUpdateApplicationService {
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
Long downloadedBytes, Long totalBytes, Long bytesPerSecond,
Integer downloadPercent, Integer restartExpectedSeconds) {
Integer downloadPercent, Integer restartExpectedSeconds,
String requestId, String action) {
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null);
this(state, message, targetVersion, updatedAt, null, null, null, null, null, null, null);
}
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
String requestId, String action) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null, requestId, action);
}
}
@@ -0,0 +1,3 @@
ALTER TABLE audit_log
ADD COLUMN dedupe_key VARCHAR(96) NULL AFTER user_agent,
ADD UNIQUE KEY uk_audit_log_dedupe_key (dedupe_key);
@@ -0,0 +1,83 @@
package com.kaidi.finance.shared.audit;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.finance.shared.id.UlidGenerator;
import java.time.Instant;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockHttpServletRequest;
class AuditServiceTest {
@Test
void terminalUpdateInheritsTheOriginalActorAndProducesAStableDedupeKey() {
AuditMapper mapper = mock(AuditMapper.class);
UlidGenerator ulids = mock(UlidGenerator.class);
when(ulids.next()).thenReturn("01M00000000000000000000999");
when(mapper.findSystemUpdateSourceByRequestId(
"01M00000000000000000000092", "SYSTEM_UPDATE_REQUEST"))
.thenReturn(new AuditMapper.SystemUpdateAuditSource(
"01M00000000000000000000092", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN",
null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\"}",
"127.0.0.1", "fixture-agent"));
when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1);
AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(),
new MockHttpServletRequest("GET", "/api/v1/admin/system-update"));
Instant completedAt = Instant.parse("2026-08-19T00:10:00Z");
String firstKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt);
String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt);
assertEquals(firstKey, secondKey);
assertTrue(firstKey.startsWith("SYSUPD:"));
ArgumentCaptor<AuditEntry> entry = ArgumentCaptor.forClass(AuditEntry.class);
verify(mapper, org.mockito.Mockito.times(2)).insertSystemUpdateTerminal(entry.capture(),
org.mockito.ArgumentMatchers.eq(firstKey), any());
AuditEntry persisted = entry.getAllValues().get(0);
assertEquals("01M00000000000000000000092", persisted.requestId());
assertEquals("01M00000000000000000000001", persisted.userPublicId());
assertEquals("SYSTEM_UPDATE_SUCCEEDED", persisted.actionCode());
assertEquals("SUCCESS", persisted.resultCode());
assertTrue(persisted.beforeJson().contains("INSTALL_QUEUED"));
assertTrue(persisted.afterJson().contains("1.0.0-preview.44"));
assertTrue(persisted.afterJson().contains("SUCCEEDED"));
}
@Test
void legacyTerminalStatusFindsTheRecentInstallRequestByTargetVersion() {
AuditMapper mapper = mock(AuditMapper.class);
UlidGenerator ulids = mock(UlidGenerator.class);
when(ulids.next()).thenReturn("01M00000000000000000000998");
LocalDateTime completedAt = LocalDateTime.parse("2026-08-18T23:50:00");
when(mapper.findSystemUpdateSourceByVersion(
"1.0.0-preview.43", "SYSTEM_UPDATE_REQUEST", completedAt))
.thenReturn(new AuditMapper.SystemUpdateAuditSource(
"01M00000000000000000000088", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN",
null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.43\"}",
"127.0.0.1", "fixture-agent"));
when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1);
AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(),
new MockHttpServletRequest("GET", "/api/v1/admin/system-update"));
service.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", Instant.parse("2026-08-18T23:50:00Z"));
verify(mapper).findSystemUpdateSourceByVersion(
"1.0.0-preview.43", "SYSTEM_UPDATE_REQUEST", completedAt);
ArgumentCaptor<AuditEntry> entry = ArgumentCaptor.forClass(AuditEntry.class);
verify(mapper).insertSystemUpdateTerminal(entry.capture(), anyString(), any());
assertEquals("01M00000000000000000000088", entry.getValue().requestId());
assertEquals("SYSTEM_UPDATE_SUCCEEDED", entry.getValue().actionCode());
}
}
@@ -6,6 +6,8 @@ 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.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -286,6 +288,64 @@ class SystemUpdateApplicationServiceTest {
}
}
@Test
void persistsCorrelatedTerminalUpdateAuditOnlyOncePerApplicationProcess() throws Exception {
Path inbox = Files.createDirectory(tempDir.resolve("terminal-inbox"));
Path statusFile = tempDir.resolve("terminal-status.json");
Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"新版本已通过健康检查","targetVersion":"1.0.0-preview.44",
"updatedAt":"2026-08-19T00:10:00Z","requestId":"01M00000000000000000000092",
"action":"INSTALL"}
""");
AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", "SUCCEEDED",
"1.0.0-preview.44", "新版本已通过健康检查", java.time.Instant.parse("2026-08-19T00:10:00Z")))
.thenReturn("SYSUPD:terminal");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
assertEquals("SUCCEEDED", service.status().state());
assertEquals("SUCCEEDED", service.status().state());
verify(auditService, times(1)).recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查",
java.time.Instant.parse("2026-08-19T00:10:00Z"));
}
@Test
void persistsLegacyTerminalStatusWithoutRequestCorrelation() throws Exception {
Path inbox = Files.createDirectory(tempDir.resolve("legacy-terminal-inbox"));
Path statusFile = tempDir.resolve("legacy-terminal-status.json");
Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"Release 1.0.0-preview.43 is running",
"targetVersion":"1.0.0-preview.43","updatedAt":"2026-08-18T23:50:00Z"}
""");
AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z")))
.thenReturn("SYSUPD:legacy");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
assertEquals("SUCCEEDED", service.status().state());
verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"));
}
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) {
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", null,
"https://release.fixture.invalid/", null, null, inbox.resolve("request.json"), statusFile,
Duration.ofSeconds(2), Duration.ofSeconds(2), true);
AuthorizationService authorization = mock(AuthorizationService.class);
when(authorization.hasPermission(any())).thenReturn(true);
IdentityContext identity = mock(IdentityContext.class);
when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN");
when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(1, "01M00000000000000000000001",
"admin", "系统管理员", "信息中心", false,
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "系统管理员", "系统治理"))));
return new SystemUpdateApplicationService(properties, authorization, identity, auditService,
new ObjectMapper());
}
@Test
void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);