feat: deliver first finance preview with setup wizard
Release / release (push) Failing after 18s

This commit is contained in:
Qiufeng
2026-08-17 14:08:40 +08:00
parent 47403fd337
commit c8b0151769
558 changed files with 140774 additions and 3 deletions
@@ -0,0 +1,51 @@
package com.kaidi.finance.architecture;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
class ControllerAuthorizationTest {
private static final Set<String> SERVICE_GUARDED_CONTROLLERS = Set.of(
"com.kaidi.finance.iam.api.AuthController",
"com.kaidi.finance.governance.api.GovernanceController",
"com.kaidi.finance.workbench.api.WorkbenchController",
"com.kaidi.finance.operations.api.FinanceOperationsController"
);
@Test
void businessEndpointsRequireMethodAuthorization() {
List<String> missing = new ArrayList<>();
for (JavaClass type : new ClassFileImporter().importPackages("com.kaidi.finance")) {
if (!type.getSimpleName().endsWith("Controller") || SERVICE_GUARDED_CONTROLLERS.contains(type.getName())) {
continue;
}
for (JavaMethod method : type.getMethods()) {
if (isEndpoint(method) && !method.isAnnotatedWith(PreAuthorize.class)) {
missing.add(type.getName() + "#" + method.getName());
}
}
}
assertTrue(missing.isEmpty(), "Business endpoints missing @PreAuthorize: " + missing);
}
private boolean isEndpoint(JavaMethod method) {
return method.isAnnotatedWith(GetMapping.class)
|| method.isAnnotatedWith(PostMapping.class)
|| method.isAnnotatedWith(PatchMapping.class)
|| method.isAnnotatedWith(PutMapping.class)
|| method.isAnnotatedWith(DeleteMapping.class);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,545 @@
package com.kaidi.finance.audit;
import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.jayway.jsonpath.JsonPath;
import jakarta.servlet.http.Cookie;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/** Regression coverage for the PAGE-20 system-administrator audit contract. */
@Testcontainers(disabledWithoutDocker = true)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class AuditIntegrationTest {
private static final String ALLOWED_COMPANY = "01J00000000000000000000001";
private static final String ALLOWED_PROJECT = "01J00000000000000000000002";
private static final String DENIED_COMPANY = "01J00000000000000000000003";
private static final String DENIED_PROJECT = "01J00000000000000000000004";
private static final String ALLOWED_AUDIT = "01J00000000000000000000005";
private static final String DENIED_AUDIT = "01J00000000000000000000006";
private static final String DATE_BEFORE_AUDIT = "01J00000000000000000000009";
private static final String DATE_START_AUDIT = "01J0000000000000000000000A";
private static final String DATE_END_AUDIT = "01J0000000000000000000000B";
private static final String DATE_AFTER_AUDIT = "01J0000000000000000000000C";
private static final String EXPORT_ACTION = "TEST_AUDIT_EXPORT_FULL";
private static final String SORT_ACTION = "TEST_AUDIT_SORT";
private static final String SORT_FIRST_AUDIT = "01J0000000000000000000000Q";
private static final String SORT_SECOND_AUDIT = "01J0000000000000000000000R";
private static final String FORMULA_ACTION = "=SUM(1+1)";
private static final String PREAUTHORIZE_DENIED_REQUEST = "01J0000000000000000000000J";
private static final String CSRF_DENIED_REQUEST = "01J0000000000000000000000K";
private static final String AUTHENTICATION_DENIED_REQUEST = "01J0000000000000000000000M";
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_audit_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
// The audit API treats request bounds and stored timestamps as UTC instants.
// Keep the container connection aligned with production so the default
// current-timestamp seed and explicit boundary fixtures have the same meaning.
registry.add("spring.datasource.url", () -> MYSQL.getJdbcUrl()
+ "?connectionTimeZone=UTC&serverTimezone=UTC");
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("finance.bootstrap.enabled", () -> true);
registry.add("finance.bootstrap.password", () -> "LocalOnly@123");
}
@Autowired
MockMvc mockMvc;
@Autowired
JdbcTemplate jdbcTemplate;
@BeforeEach
void seedRows() {
jdbcTemplate.update("DELETE FROM audit_log WHERE public_id IN (?, ?, ?, ?, ?, ?)", ALLOWED_AUDIT,
DENIED_AUDIT, DATE_BEFORE_AUDIT, DATE_START_AUDIT, DATE_END_AUDIT, DATE_AFTER_AUDIT);
insertAudit(ALLOWED_AUDIT, "01J00000000000000000000007", ALLOWED_COMPANY, ALLOWED_PROJECT,
"{\"password\":\"secret\",\"visible\":\"ok\"}");
insertAudit(DENIED_AUDIT, "01J00000000000000000000008", DENIED_COMPANY, DENIED_PROJECT,
"{\"token\":\"private\"}");
}
@AfterEach
void removeSeedRows() {
jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = 'HTTP_ACCESS_DENIED'");
jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = ?", EXPORT_ACTION);
jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = ?", SORT_ACTION);
jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = ?", FORMULA_ACTION);
jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = 'AUDIT_LOG_EXPORT' AND object_type = 'AUDIT_LOG'");
jdbcTemplate.update("DELETE FROM export_log WHERE resource_type = 'AUDIT_LOG'");
jdbcTemplate.update("DELETE FROM audit_log WHERE public_id IN (?, ?, ?, ?, ?, ?)", ALLOWED_AUDIT,
DENIED_AUDIT, DATE_BEFORE_AUDIT, DATE_START_AUDIT, DATE_END_AUDIT, DATE_AFTER_AUDIT);
}
@Test
void financeManagerCannotReadOrExportAuditLogs() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
mockMvc.perform(get("/api/v1/audit/logs")
.param("page", "1")
.param("size", "20")
.header("X-Request-Id", PREAUTHORIZE_DENIED_REQUEST)
.cookie(finance.cookies()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("PERMISSION_DENIED"))
.andExpect(jsonPath("$.requestId").value(PREAUTHORIZE_DENIED_REQUEST));
assertDeniedAudit(PREAUTHORIZE_DENIED_REQUEST, "GET", "/api/v1/audit/logs", "finance",
"FINANCE_MANAGER");
mockMvc.perform(post("/api/v1/audit/exports")
.cookie(finance.cookies())
.header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content("{}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("PERMISSION_DENIED"));
}
@Test
void csrfFilterRejectionIsAuditedWithoutEnteringTheController() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
mockMvc.perform(post("/api/v1/audit/exports")
.header("X-Request-Id", CSRF_DENIED_REQUEST)
.cookie(finance.cookies())
.contentType("application/json")
.content("{}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("PERMISSION_DENIED"))
.andExpect(jsonPath("$.requestId").value(CSRF_DENIED_REQUEST));
assertDeniedAudit(CSRF_DENIED_REQUEST, "POST", "/api/v1/audit/exports", "finance",
"FINANCE_MANAGER");
}
@Test
void anonymousAuthenticationEntryPointRejectionIsAudited() throws Exception {
mockMvc.perform(get("/restricted-area")
.header("X-Request-Id", AUTHENTICATION_DENIED_REQUEST))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("AUTH_SESSION_REQUIRED"))
.andExpect(jsonPath("$.requestId").value(AUTHENTICATION_DENIED_REQUEST));
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM audit_log
WHERE request_id = ?
AND user_public_id IS NULL
AND username IS NULL
AND active_role IS NULL
AND action_code = 'HTTP_ACCESS_DENIED'
AND object_type = 'HTTP_ENDPOINT'
AND result_code = 'DENIED'
AND reason = 'AUTH_SESSION_REQUIRED'
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.method')) = 'GET'
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.uri')) = '/restricted-area'
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.status')) = '401'
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.errorCode')) = 'AUTH_SESSION_REQUIRED'
""", Integer.class, AUTHENTICATION_DENIED_REQUEST);
assertEquals(1, count);
}
@Test
void systemAdminQueriesAllScopesAndMasksSensitiveValues() throws Exception {
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
jdbcTemplate.update(
"UPDATE audit_log SET reason = ? WHERE public_id = ?",
"审批备注账号 6222020202020202020", ALLOWED_AUDIT
);
mockMvc.perform(get("/api/v1/audit/logs")
.param("action", "TEST_AUDIT_SCOPE")
.param("page", "1")
.param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(2));
mockMvc.perform(get("/api/v1/audit/logs/{publicId}", ALLOWED_AUDIT)
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.publicId").value(ALLOWED_AUDIT))
.andExpect(jsonPath("$.data.reason").value("审批备注账号 ***2020"))
.andExpect(jsonPath("$.data.beforeJson").value(org.hamcrest.Matchers.containsString("***")))
.andExpect(jsonPath("$.data.beforeJson").value(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("secret"))));
mockMvc.perform(get("/api/v1/audit/logs/{publicId}", DENIED_AUDIT)
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.publicId").value(DENIED_AUDIT));
}
@Test
void auditListRejectsUnknownAndMalformedParametersButAcceptsThePublishedWhitelist() throws Exception {
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
mockMvc.perform(get("/api/v1/audit/logs")
.param("unexpected", "value")
.cookie(admin.cookies()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID"));
mockMvc.perform(get("/api/v1/audit/logs")
.param("page", "not-a-number")
.cookie(admin.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(get("/api/v1/audit/logs")
.param("actorId", "A".repeat(27))
.cookie(admin.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(get("/api/v1/audit/logs")
.param("sort", "username,asc")
.cookie(admin.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(post("/api/v1/audit/exports")
.cookie(admin.cookies())
.header("X-XSRF-TOKEN", admin.csrfToken())
.contentType("application/json")
.content("{\"sort\":\"username,asc\"}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
String financeId = jdbcTemplate.queryForObject(
"SELECT public_id FROM iam_user WHERE username = 'finance'", String.class);
mockMvc.perform(get("/api/v1/audit/logs")
.param("actorId", financeId)
.param("action", "TEST_AUDIT_SCOPE")
.param("sort", "eventSequence,asc")
.param("page", "1")
.param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(2))
.andExpect(jsonPath("$.data[0].eventSequence").isNumber());
}
@Test
void listAndExportUseThePublishedStableAuditSortOrder() throws Exception {
insertAuditWithAction(SORT_FIRST_AUDIT, "01J0000000000000000000000S", ALLOWED_COMPANY, ALLOWED_PROJECT,
SORT_ACTION, "first sort row");
insertAuditWithAction(SORT_SECOND_AUDIT, "01J0000000000000000000000T", ALLOWED_COMPANY, ALLOWED_PROJECT,
SORT_ACTION, "second sort row");
jdbcTemplate.update("UPDATE audit_log SET created_at = ? WHERE public_id = ?",
LocalDateTime.parse("2026-08-15T08:00:00"), SORT_FIRST_AUDIT);
jdbcTemplate.update("UPDATE audit_log SET created_at = ? WHERE public_id = ?",
LocalDateTime.parse("2026-08-15T09:00:00"), SORT_SECOND_AUDIT);
Long firstSequence = jdbcTemplate.queryForObject(
"SELECT id FROM audit_log WHERE public_id = ?", Long.class, SORT_FIRST_AUDIT);
Long secondSequence = jdbcTemplate.queryForObject(
"SELECT id FROM audit_log WHERE public_id = ?", Long.class, SORT_SECOND_AUDIT);
org.junit.jupiter.api.Assertions.assertTrue(firstSequence < secondSequence);
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
mockMvc.perform(get("/api/v1/audit/logs")
.param("occurredFrom", "2026-08-15T00:00:00Z")
.param("occurredTo", "2026-08-16T00:00:00Z")
.param("action", SORT_ACTION)
.param("sort", "eventSequence,asc")
.param("page", "1")
.param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].publicId").value(SORT_FIRST_AUDIT))
.andExpect(jsonPath("$.data[0].eventSequence").value(firstSequence))
.andExpect(jsonPath("$.data[1].publicId").value(SORT_SECOND_AUDIT))
.andExpect(jsonPath("$.data[1].eventSequence").value(secondSequence));
mockMvc.perform(get("/api/v1/audit/logs")
.param("occurredFrom", "2026-08-15T00:00:00Z")
.param("occurredTo", "2026-08-16T00:00:00Z")
.param("action", SORT_ACTION)
.param("sort", "occurredAt,desc")
.param("page", "1")
.param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].publicId").value(SORT_SECOND_AUDIT))
.andExpect(jsonPath("$.data[1].publicId").value(SORT_FIRST_AUDIT));
MvcResult export = mockMvc.perform(post("/api/v1/audit/exports")
.cookie(admin.cookies())
.header("X-XSRF-TOKEN", admin.csrfToken())
.contentType("application/json")
.content("""
{"occurredFrom":"2026-08-15T00:00:00Z","occurredTo":"2026-08-16T00:00:00Z",
"action":"TEST_AUDIT_SORT","sort":"eventSequence,asc"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.rowCount").value(2))
.andReturn();
String content = JsonPath.read(export.getResponse().getContentAsString(), "$.data.content");
org.junit.jupiter.api.Assertions.assertTrue(
content.indexOf(SORT_FIRST_AUDIT) < content.indexOf(SORT_SECOND_AUDIT));
String storedSort = jdbcTemplate.queryForObject("""
SELECT JSON_UNQUOTE(JSON_EXTRACT(filter_json, '$.sort'))
FROM export_log
WHERE resource_type = 'AUDIT_LOG'
ORDER BY id DESC LIMIT 1
""", String.class);
assertEquals("eventSequence,asc", storedSort);
Integer sequenceColumn = jdbcTemplate.queryForObject("""
SELECT JSON_CONTAINS(columns_json, JSON_QUOTE('eventSequence'))
FROM export_log
WHERE resource_type = 'AUDIT_LOG'
ORDER BY id DESC LIMIT 1
""", Integer.class);
assertEquals(1, sequenceColumn);
}
@Test
void requestIdLookupReconstructsHistoryBeyondTheDefaultTwentyFourHours() throws Exception {
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
jdbcTemplate.update("UPDATE audit_log SET created_at = UTC_TIMESTAMP(3) - INTERVAL 2 DAY WHERE public_id = ?",
ALLOWED_AUDIT);
mockMvc.perform(get("/api/v1/audit/logs")
.param("requestId", "01J00000000000000000000007")
.param("page", "1")
.param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(1))
.andExpect(jsonPath("$.data[0].publicId").value(ALLOWED_AUDIT));
mockMvc.perform(get("/api/v1/audit/logs")
.param("action", "TEST_AUDIT_SCOPE")
.param("page", "1")
.param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(1))
.andExpect(jsonPath("$.data[0].publicId").value(DENIED_AUDIT));
}
@Test
void dateRangeUsesUtcHalfOpenBoundariesAndReturnsExplicitUtcInstants() throws Exception {
insertAuditAt(DATE_BEFORE_AUDIT, "01J0000000000000000000000D", "2026-08-11 15:59:59.999");
insertAuditAt(DATE_START_AUDIT, "01J0000000000000000000000E", "2026-08-11 16:00:00.000");
insertAuditAt(DATE_END_AUDIT, "01J0000000000000000000000F", "2026-08-12 15:59:59.999");
insertAuditAt(DATE_AFTER_AUDIT, "01J0000000000000000000000G", "2026-08-12 16:00:00.000");
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
mockMvc.perform(get("/api/v1/audit/logs")
.param("occurredFrom", "2026-08-11T16:00:00Z")
.param("occurredTo", "2026-08-12T16:00:00Z")
.param("action", "TEST_AUDIT_DATE")
.param("page", "1")
.param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(2))
.andExpect(jsonPath("$.data[0].publicId").value(DATE_END_AUDIT))
.andExpect(jsonPath("$.data[0].occurredAt").value("2026-08-12T15:59:59.999Z"))
.andExpect(jsonPath("$.data[1].publicId").value(DATE_START_AUDIT))
.andExpect(jsonPath("$.data[1].occurredAt").value("2026-08-11T16:00:00Z"));
}
@Test
void systemAdminExportReturnsEveryMatchingRowBeyondTheListPageLimit() throws Exception {
jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = ?", EXPORT_ACTION);
for (int index = 0; index < 101; index++) {
String publicId = "01J00000000000000000000" + String.format("%03d", 200 + index);
insertAuditWithAction(publicId, "01J00000000000000000000" + String.format("%03d", 400 + index),
DENIED_COMPANY, DENIED_PROJECT, EXPORT_ACTION, "export row " + index);
}
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
mockMvc.perform(get("/api/v1/audit/logs")
.param("action", EXPORT_ACTION)
.param("page", "1")
.param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(101));
MvcResult exportResult = mockMvc.perform(post("/api/v1/audit/exports")
.cookie(admin.cookies())
.header("X-XSRF-TOKEN", admin.csrfToken())
.contentType("application/json")
.content("{\"action\":\"%s\"}".formatted(EXPORT_ACTION)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.rowCount").value(101))
.andExpect(jsonPath("$.data.content").value(containsString("01J00000000000000000000300")))
.andReturn();
String content = JsonPath.read(exportResult.getResponse().getContentAsString(), "$.data.content");
String responseSha256 = JsonPath.read(exportResult.getResponse().getContentAsString(), "$.data.sha256");
assertEquals('\uFEFF', content.charAt(0));
assertEquals(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(content.getBytes(StandardCharsets.UTF_8))), responseSha256);
Integer exportLogs = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM export_log
WHERE resource_type = 'AUDIT_LOG' AND row_count = 101
""", Integer.class);
org.junit.jupiter.api.Assertions.assertEquals(1, exportLogs);
String storedSha256 = jdbcTemplate.queryForObject("""
SELECT sha256 FROM export_log
WHERE resource_type = 'AUDIT_LOG' AND row_count = 101
ORDER BY id DESC LIMIT 1
""", String.class);
assertEquals(responseSha256, storedSha256);
Integer auditEvents = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM audit_log
WHERE action_code = 'AUDIT_LOG_EXPORT'
AND object_type = 'AUDIT_LOG'
AND JSON_EXTRACT(after_json, '$.rowCount') = 101
""", Integer.class);
org.junit.jupiter.api.Assertions.assertEquals(1, auditEvents);
}
@Test
void auditExportNeutralizesSpreadsheetFormulaCells() throws Exception {
String publicId = "01J0000000000000000000000N";
insertAuditWithAction(publicId, "01J0000000000000000000000P", ALLOWED_COMPANY, ALLOWED_PROJECT,
FORMULA_ACTION, "formula regression");
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
mockMvc.perform(post("/api/v1/audit/exports")
.cookie(admin.cookies())
.header("X-XSRF-TOKEN", admin.csrfToken())
.contentType("application/json")
.content("{\"action\":\"%s\"}".formatted(FORMULA_ACTION)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.rowCount").value(1))
.andExpect(jsonPath("$.data.content").value(containsString("\"'=SUM(1+1)\"")));
}
private void insertAudit(String publicId, String requestId, String companyId, String projectId,
String beforeJson) {
insertAuditWithAction(publicId, requestId, companyId, projectId, "TEST_AUDIT_SCOPE", "scope regression",
beforeJson);
}
private void insertAuditWithAction(String publicId, String requestId, String companyId, String projectId,
String action, String reason) {
insertAuditWithAction(publicId, requestId, companyId, projectId, action, reason,
"{\"password\":\"secret\",\"visible\":\"ok\"}");
}
private void insertAuditWithAction(String publicId, String requestId, String companyId, String projectId,
String action, String reason, String beforeJson) {
String userPublicId = jdbcTemplate.queryForObject(
"SELECT public_id FROM iam_user WHERE username = 'finance'", String.class);
jdbcTemplate.update("""
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, created_at
) VALUES (?, ?, ?, 'finance', 'FINANCE_MANAGER', ?, ?, ?,
'TEST', ?, 'SUCCESS', ?, CAST(? AS JSON), CAST(? AS JSON), ?)
""", publicId, requestId, userPublicId, companyId, projectId, action, publicId, reason,
beforeJson, "{\"result\":\"ok\"}", LocalDateTime.now(ZoneOffset.UTC).minusMinutes(1));
}
private void insertAuditAt(String publicId, String requestId, String occurredAt) {
String userPublicId = jdbcTemplate.queryForObject(
"SELECT public_id FROM iam_user WHERE username = 'finance'", String.class);
jdbcTemplate.update("""
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, created_at
) VALUES (?, ?, ?, 'finance', 'FINANCE_MANAGER', ?, ?, 'TEST_AUDIT_DATE',
'TEST', ?, 'SUCCESS', 'date boundary', CAST(? AS JSON), CAST(? AS JSON), ?)
""", publicId, requestId, userPublicId, ALLOWED_COMPANY, ALLOWED_PROJECT, publicId,
"{\"before\":true}", "{\"after\":true}", LocalDateTime.parse(occurredAt.replace(' ', 'T')));
}
private void assertDeniedAudit(String requestId, String method, String uri, String username, String activeRole) {
String userPublicId = jdbcTemplate.queryForObject(
"SELECT public_id FROM iam_user WHERE username = ?", String.class, username);
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM audit_log
WHERE request_id = ?
AND user_public_id = ?
AND username = ?
AND active_role = ?
AND action_code = 'HTTP_ACCESS_DENIED'
AND object_type = 'HTTP_ENDPOINT'
AND object_public_id IS NULL
AND result_code = 'DENIED'
AND reason = 'PERMISSION_DENIED'
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.method')) = ?
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.uri')) = ?
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.status')) = '403'
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.errorCode')) = 'PERMISSION_DENIED'
""", Integer.class, requestId, userPublicId, username, activeRole, method, uri);
assertEquals(1, count);
}
private ClientSession loginAndSelect(String username, String role) throws Exception {
MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf"))
.andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN");
assertNotNull(csrfCookie);
String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token");
MvcResult login = mockMvc.perform(post("/api/v1/auth/login")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username)))
.andExpect(status().isOk()).andReturn();
Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION");
assertNotNull(sessionCookie);
MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(sessionCookie, csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"roleCode\":\"%s\"}".formatted(role)))
.andExpect(status().isOk()).andReturn();
Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION");
return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken);
}
private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) {
Cookie[] cookies() {
return new Cookie[]{sessionCookie, csrfCookie};
}
}
}
@@ -0,0 +1,343 @@
package com.kaidi.finance.governance;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.jayway.jsonpath.JsonPath;
import jakarta.servlet.http.Cookie;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class GovernanceIntegrationTest {
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_governance_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("finance.bootstrap.enabled", () -> true);
registry.add("finance.bootstrap.password", () -> "LocalOnly@123");
}
@Autowired
MockMvc mockMvc;
@Autowired
JdbcTemplate jdbcTemplate;
@Test
void systemUpdateStatusIsAdministratorOnlyAndDisabledConfigurationDoesNotQueueWork() throws Exception {
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
mockMvc.perform(get("/api/v1/admin/system-update").cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.enabled").value(false))
.andExpect(jsonPath("$.data.state").value("DISABLED"))
.andExpect(jsonPath("$.data.allowedActions.length()").value(0));
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
mockMvc.perform(get("/api/v1/admin/system-update").cookie(finance.cookies()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("PERMISSION_DENIED"));
mockMvc.perform(post("/api/v1/admin/system-update/check")
.cookie(admin.cookies()).header("X-XSRF-TOKEN", admin.csrfToken()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
}
@Test
void retiredTemplateAndParameterRequireExplicitRestoreAndAreAudited() throws Exception {
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
String templateId = create(admin, "templates", """
{"formType":"OA-01","templateVersion":2,"name":"治理恢复测试模板","schemaVersion":2}
""");
assertVersionLifecycle(admin, "templates", templateId, "ADMIN_TEMPLATE_RESTORE");
String parameterId = create(admin, "parameters", """
{"parameterGroup":"GOVERNANCE_RESTORE_TEST","valueJson":"{}"}
""");
assertVersionLifecycle(admin, "parameters", parameterId, "ADMIN_PARAMETER_RESTORE");
}
@Test
void userDisableInvalidatesExistingRuntimeSessionAndLeavesAuditTrail() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
String financeId = jdbcTemplate.queryForObject(
"SELECT public_id FROM iam_user WHERE username = 'finance'", String.class);
Long version = jdbcTemplate.queryForObject(
"SELECT version FROM iam_user WHERE username = 'finance'", Long.class);
command(admin, "users", financeId, "enable", version)
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION"));
command(admin, "users", financeId, "disable", version)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DISABLED"));
mockMvc.perform(get("/api/v1/auth/session").cookie(finance.cookies()))
.andExpect(status().isUnauthorized());
Integer auditCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE action_code = 'ADMIN_USER_DISABLE' "
+ "AND object_public_id = ? AND result_code = 'SUCCESS'",
Integer.class, financeId);
org.junit.jupiter.api.Assertions.assertEquals(1, auditCount);
command(admin, "users", financeId, "enable", version + 1).andExpect(status().isOk());
}
@Test
void scopeCreationInvalidatesExistingSessionAndVersionGroupCreatesSerialize() throws Exception {
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
String scopedUsername = "scope" + System.nanoTime();
MvcResult createdUser = mockMvc.perform(post("/api/v1/admin/users").cookie(admin.cookies())
.header("X-XSRF-TOKEN", admin.csrfToken()).contentType("application/json")
.content("""
{"username":"%s","displayName":"范围会话测试","password":"LocalOnly@123","roleCode":"FINANCE_MANAGER"}
""".formatted(scopedUsername)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"))
.andReturn();
String scopedUserId = JsonPath.read(createdUser.getResponse().getContentAsString(), "$.data.publicId");
ClientSession scopedUser = loginAndSelect(scopedUsername, "FINANCE_MANAGER");
String permissionCode = "payment:request:view";
mockMvc.perform(post("/api/v1/admin/scopes").cookie(admin.cookies())
.header("X-XSRF-TOKEN", admin.csrfToken()).contentType("application/json")
.content("""
{"userId":"%s","roleCode":"FINANCE_MANAGER","permissionCode":"%s","scopeType":"GLOBAL"}
""".formatted(scopedUserId, permissionCode)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"));
mockMvc.perform(get("/api/v1/auth/session").cookie(scopedUser.cookies()))
.andExpect(status().isUnauthorized());
String parameterGroup = "GOVERNANCE_PARALLEL_CREATE";
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<MvcResult> first = executor.submit(() -> concurrentParameterCreate(admin, parameterGroup, ready, start));
Future<MvcResult> second = executor.submit(() -> concurrentParameterCreate(admin, parameterGroup, ready, start));
assertTrue(ready.await(5, TimeUnit.SECONDS), "并发参数创建请求未按时就绪");
start.countDown();
List<MvcResult> results = List.of(first.get(15, TimeUnit.SECONDS), second.get(15, TimeUnit.SECONDS));
String responses = results.stream().map(result -> "status=" + result.getResponse().getStatus()
+ ", body=" + new String(result.getResponse().getContentAsByteArray(),
java.nio.charset.StandardCharsets.UTF_8)).collect(java.util.stream.Collectors.joining("\n"));
assertEquals(2, results.stream().filter(result -> result.getResponse().getStatus() == 200).count(), responses);
} finally {
start.countDown();
executor.shutdownNow();
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS), "并发参数创建线程未按时结束");
}
List<Integer> versions = jdbcTemplate.queryForList(
"SELECT version_no FROM sys_parameter_version WHERE parameter_group = ? ORDER BY version_no",
Integer.class, parameterGroup);
assertEquals(List.of(1, 2), versions);
}
@Test
void governanceListsExposeStableDtoAndRejectUnknownQueryParameters() throws Exception {
ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN");
String scopedUsername = "dto" + System.nanoTime();
MvcResult createdUser = mockMvc.perform(post("/api/v1/admin/users")
.cookie(admin.cookies()).header("X-XSRF-TOKEN", admin.csrfToken())
.contentType("application/json")
.content("""
{"username":"%s","displayName":"DTO 映射测试","password":"LocalOnly@123",
"roleCode":"FINANCE_MANAGER"}
""".formatted(scopedUsername)))
.andExpect(status().isOk())
.andReturn();
String scopedUserId = JsonPath.read(createdUser.getResponse().getContentAsString(), "$.data.publicId");
mockMvc.perform(post("/api/v1/admin/scopes")
.cookie(admin.cookies()).header("X-XSRF-TOKEN", admin.csrfToken())
.contentType("application/json")
.content("""
{"userId":"%s","roleCode":"FINANCE_MANAGER","permissionCode":"masterdata:company:view",
"scopeType":"GLOBAL","amountLimit":12345.67}
""".formatted(scopedUserId)))
.andExpect(status().isOk());
mockMvc.perform(get("/api/v1/admin/users")
.param("keyword", "finance").param("page", "1").param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].resource").value("users"))
.andExpect(jsonPath("$.data[0].username").value("finance"))
.andExpect(jsonPath("$.data[0].publicId").isString())
.andExpect(jsonPath("$.data[0].roleCodes").isArray())
.andExpect(jsonPath("$.data[0].status").value("ACTIVE"))
.andExpect(jsonPath("$.data[0].updatedAt").isString())
.andExpect(jsonPath("$.data[0].public_id").doesNotExist())
.andExpect(jsonPath("$.data[0].role_codes").doesNotExist());
mockMvc.perform(get("/api/v1/admin/roles").param("keyword", "FINANCE_MANAGER")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].resource").value("roles"))
.andExpect(jsonPath("$.data[0].code").value("FINANCE_MANAGER"))
.andExpect(jsonPath("$.data[0].memberCount").isNumber())
.andExpect(jsonPath("$.data[0].updatedAt").isString());
mockMvc.perform(get("/api/v1/admin/scopes").param("keyword", scopedUsername)
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].resource").value("scopes"))
.andExpect(jsonPath("$.data[0].userPublicId").value(scopedUserId))
.andExpect(jsonPath("$.data[0].roleCode").value("FINANCE_MANAGER"))
.andExpect(jsonPath("$.data[0].permissionCode").value("masterdata:company:view"))
.andExpect(jsonPath("$.data[0].amountLimit").value("12345.67"))
.andExpect(jsonPath("$.data[0].updatedAt").isString())
.andExpect(jsonPath("$.data[0].amount_limit").doesNotExist());
mockMvc.perform(get("/api/v1/admin/templates").param("page", "1").param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isNotEmpty())
.andExpect(jsonPath("$.data[0].resource").value("templates"))
.andExpect(jsonPath("$.data[0].formType").isString())
.andExpect(jsonPath("$.data[0].templateVersion").isNumber())
.andExpect(jsonPath("$.data[0].updatedAt").isString());
mockMvc.perform(get("/api/v1/admin/parameters").param("page", "1").param("size", "20")
.cookie(admin.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isNotEmpty())
.andExpect(jsonPath("$.data[0].resource").value("parameters"))
.andExpect(jsonPath("$.data[0].parameterGroup").isString())
.andExpect(jsonPath("$.data[0].versionNo").isNumber())
.andExpect(jsonPath("$.data[0].createdAt").isString())
.andExpect(jsonPath("$.data[0].updatedAt").isString());
mockMvc.perform(get("/api/v1/admin/users").param("unexpected", "ignored-before-fix")
.cookie(admin.cookies()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID"));
mockMvc.perform(get("/api/v1/admin/unknown-resource").cookie(admin.cookies()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID"));
}
private String create(ClientSession session, String resource, String body) throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/admin/" + resource)
.cookie(session.cookies()).header("X-XSRF-TOKEN", session.csrfToken())
.contentType("application/json").content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DRAFT"))
.andExpect(jsonPath("$.data.version").value(0))
.andReturn();
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.publicId");
}
private void assertVersionLifecycle(ClientSession session, String resource, String publicId, String restoreAudit)
throws Exception {
command(session, resource, publicId, "publish", 0)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"))
.andExpect(jsonPath("$.data.version").value(1))
.andExpect(jsonPath("$.data.allowedActions[0]").value("DISABLE"));
command(session, resource, publicId, "disable", 0)
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("VERSION_CONFLICT"));
command(session, resource, publicId, "disable", 1)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("RETIRED"))
.andExpect(jsonPath("$.data.version").value(2))
.andExpect(jsonPath("$.data.allowedActions[0]").value("RESTORE"));
command(session, resource, publicId, "publish", 2)
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION"));
command(session, resource, publicId, "restore", 2)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"))
.andExpect(jsonPath("$.data.version").value(3))
.andExpect(jsonPath("$.data.allowedActions[0]").value("DISABLE"));
mockMvc.perform(get("/api/v1/admin/" + resource + "/" + publicId).cookie(session.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.allowedActions").isNotEmpty());
Integer auditCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE action_code = ? AND object_public_id = ? AND result_code = 'SUCCESS'",
Integer.class, restoreAudit, publicId);
org.junit.jupiter.api.Assertions.assertEquals(1, auditCount);
}
private org.springframework.test.web.servlet.ResultActions command(ClientSession session, String resource,
String publicId, String command,
long version) throws Exception {
return mockMvc.perform(post("/api/v1/admin/{resource}/{publicId}/{command}", resource, publicId, command)
.cookie(session.cookies()).header("X-XSRF-TOKEN", session.csrfToken())
.contentType("application/json").content("{\"version\":%d,\"reason\":\"治理版本测试\"}".formatted(version)));
}
private MvcResult concurrentParameterCreate(ClientSession session, String group, CountDownLatch ready,
CountDownLatch start) throws Exception {
ready.countDown();
if (!start.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("并发参数创建启动信号超时");
return mockMvc.perform(post("/api/v1/admin/parameters").cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).contentType("application/json")
.content("{\"parameterGroup\":\"%s\",\"valueJson\":\"{}\"}".formatted(group)))
.andReturn();
}
private ClientSession loginAndSelect(String username, String role) throws Exception {
MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN");
assertNotNull(csrfCookie);
String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token");
MvcResult login = mockMvc.perform(post("/api/v1/auth/login")
.cookie(csrfCookie).header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username)))
.andExpect(status().isOk()).andReturn();
Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION");
assertNotNull(sessionCookie);
MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(sessionCookie, csrfCookie).header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json").content("{\"roleCode\":\"%s\"}".formatted(role)))
.andExpect(status().isOk()).andReturn();
Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION");
return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken);
}
private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) {
Cookie[] cookies() {
return new Cookie[]{sessionCookie, csrfCookie};
}
}
}
@@ -0,0 +1,603 @@
package com.kaidi.finance.iam;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.jayway.jsonpath.JsonPath;
import com.kaidi.finance.iam.application.BootstrapUserInitializer;
import jakarta.servlet.http.Cookie;
import com.kaidi.finance.shared.security.SessionLifecycle;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class AuthIntegrationTest {
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("finance.bootstrap.enabled", () -> true);
registry.add("finance.bootstrap.password", () -> "LocalOnly@123");
}
@Autowired
MockMvc mockMvc;
@Autowired
JdbcTemplate jdbcTemplate;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
FindByIndexNameSessionRepository<? extends Session> sessionRepository;
@Autowired
BootstrapUserInitializer bootstrapUserInitializer;
@BeforeEach
void resetAccounts() {
for (String username : List.of("admin", "project", "finance", "archive", "demo")) {
sessionRepository.findByPrincipalName(username).keySet().forEach(sessionRepository::deleteById);
}
jdbcTemplate.update("""
DELETE assignment
FROM iam_user_role assignment
JOIN iam_user user_account ON user_account.id = assignment.user_id
JOIN iam_role role ON role.id = assignment.role_id
WHERE user_account.username = 'project' AND role.code = 'SYSTEM_ADMIN'
""");
jdbcTemplate.update("""
UPDATE iam_user SET enabled = TRUE, must_change_password = FALSE,
password_hash = ?, failed_login_count = 0, locked_until = NULL
""", passwordEncoder.encode("LocalOnly@123"));
jdbcTemplate.update("DELETE FROM audit_log WHERE action_code='AUTH_LOGIN'");
}
@Test
void csrfEndpointIsAvailableWithoutAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/auth/csrf"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.headerName").value("X-XSRF-TOKEN"))
.andExpect(jsonPath("$.data.token").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty())
.andExpect(result -> assertNotNull(result.getResponse().getCookie("XSRF-TOKEN"),
"CSRF endpoint must issue the readable CSRF cookie"));
}
@Test
void unknownApiPathReturnsUniformNotFoundProblemWithoutInternalDetails() throws Exception {
ClientSession session = clientSession(login("finance", "LocalOnly@123").andReturn());
mockMvc.perform(get("/api/v1/unknown-resource").cookie(session.cookies()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"))
.andExpect(jsonPath("$.detail").value("请求的资源不存在"));
mockMvc.perform(get("/api/v1/unknown-resource"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"))
.andExpect(jsonPath("$.detail").value("请求的资源不存在"));
}
@Test
void everyLoginRequiresExplicitRoleSelection() throws Exception {
MvcResult login = login("project", "LocalOnly@123")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.authenticated").value(true))
.andExpect(jsonPath("$.data.activeRole").doesNotExist())
.andExpect(jsonPath("$.data.roles[0].code").value("PROJECT_MANAGER"))
.andExpect(jsonPath("$.data.permissions").isEmpty())
.andReturn();
ClientSession session = clientSession(login);
mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken())
.contentType("application/json")
.content("{\"roleCode\":\"PROJECT_MANAGER\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.activeRole").value("PROJECT_MANAGER"))
.andExpect(jsonPath("$.data.permissions").isNotEmpty());
}
@Test
void sessionProfileAndRoleContractsExposeOnlyCurrentUsersNavigationData() throws Exception {
MvcResult login = loginWithClient("project", "LocalOnly@123", "198.51.100.42",
"Mozilla/5.0 (Macintosh) Chrome/126.0")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.roles[0].workbenchRoute").value("/workbench/project"))
.andExpect(jsonPath("$.data.permissionVersion").isNotEmpty())
.andExpect(jsonPath("$.data.sessionExpiresAt").isNotEmpty())
.andExpect(jsonPath("$.data.absoluteSessionExpiresAt").isNotEmpty())
.andReturn();
ClientSession session = clientSession(login);
mockMvc.perform(get("/api/v1/auth/profile").cookie(session.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.username").value("project"))
.andExpect(jsonPath("$.data.authorizationScopes[0].roleCode").value("PROJECT_MANAGER"))
.andExpect(jsonPath("$.data.authorizationScopes[0].scopeType").value("GLOBAL"))
.andExpect(jsonPath("$.data.recentLogins[0].maskedIp").value("198.51.*.*"))
.andExpect(jsonPath("$.data.recentLogins[0].deviceSummary").value("Chrome · macOS"))
.andExpect(jsonPath("$.data.recentLogins[0].ipAddress").doesNotExist())
.andExpect(jsonPath("$.data.recentLogins[0].userAgent").doesNotExist())
.andExpect(jsonPath("$.data.passwordHash").doesNotExist());
mockMvc.perform(get("/api/v1/auth/roles").cookie(session.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].code").value("PROJECT_MANAGER"));
mockMvc.perform(get("/api/v1/auth/sessions").cookie(session.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].deviceSummary").value("Chrome · macOS"))
.andExpect(jsonPath("$.data[0].maskedIp").value("198.51.*.*"));
}
@Test
void systemAdministratorUsesAnIsolatedCanonicalRole() throws Exception {
MvcResult login = login("admin", "LocalOnly@123")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.roles.length()").value(1))
.andExpect(jsonPath("$.data.roles[0].code").value("SYSTEM_ADMIN"))
.andExpect(jsonPath("$.data.roles[0].name").value("超级管理员"))
.andExpect(jsonPath("$.data.roles[0].workbenchRoute").value("/governance/settings"))
.andReturn();
ClientSession session = clientSession(login);
MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken())
.contentType("application/json")
.content("{\"roleCode\":\"SYSTEM_ADMIN\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.activeRole").value("SYSTEM_ADMIN"))
.andExpect(jsonPath("$.data.permissions").isNotEmpty())
.andReturn();
List<String> permissions = JsonPath.read(selected.getResponse().getContentAsString(), "$.data.permissions");
Integer permissionCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM iam_permission", Integer.class);
Integer globalScopeCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM iam_scope scope_grant
JOIN iam_user user_account ON user_account.id = scope_grant.user_id
JOIN iam_role role ON role.id = scope_grant.role_id
WHERE user_account.username = 'admin'
AND role.code = 'SYSTEM_ADMIN'
AND scope_grant.scope_type = 'GLOBAL'
AND scope_grant.status = 'ACTIVE'
AND scope_grant.amount_limit IS NULL
AND scope_grant.valid_from <= UTC_TIMESTAMP(3)
AND scope_grant.valid_to IS NULL
""", Integer.class);
assertEquals(permissionCount, permissions.size());
assertEquals(permissionCount, globalScopeCount);
}
@Test
void mixedSystemAdministratorAndBusinessRolesAreBlockedAndAuditedAtLogin() throws Exception {
jdbcTemplate.update("""
INSERT INTO iam_user_role (user_id, role_id)
SELECT user_account.id, role.id
FROM iam_user user_account
JOIN iam_role role ON role.code = 'SYSTEM_ADMIN'
WHERE user_account.username = 'project'
""");
login("project", "LocalOnly@123")
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("ACCESS_DENIED"));
assertTrue(jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM audit_log
WHERE username = 'project' AND action_code = 'AUTH_LOGIN'
AND result_code = 'FAILED' AND reason = 'ROLE_CONFIGURATION_CONFLICT'
""", Integer.class) == 1);
}
@Test
void passwordConfirmationMismatchIsRejectedWithoutChangingPassword() throws Exception {
ClientSession session = clientSession(login("project", "LocalOnly@123").andReturn());
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch(
"/api/v1/auth/password")
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken())
.contentType("application/json")
.content("""
{
"currentPassword":"LocalOnly@123",
"newPassword":"ChangedOnly@123",
"confirmPassword":"DifferentOnly@123"
}
"""))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fieldErrors.confirmPassword").isNotEmpty());
login("project", "LocalOnly@123").andExpect(status().isOk());
}
@Test
void requiredPasswordChangeBlocksBusinessAccessAndInvalidatesOtherSessions() throws Exception {
jdbcTemplate.update("UPDATE iam_user SET must_change_password = TRUE WHERE username = 'project'");
ClientSession first = clientSession(login("project", "LocalOnly@123")
.andExpect(jsonPath("$.data.user.mustChangePassword").value(true)).andReturn());
ClientSession second = clientSession(login("project", "LocalOnly@123").andReturn());
MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(first.cookies()).header("X-XSRF-TOKEN", first.csrfToken())
.contentType("application/json").content("{\"roleCode\":\"PROJECT_MANAGER\"}"))
.andExpect(status().isOk()).andReturn();
Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION");
ClientSession active = new ClientSession(rotated == null ? first.sessionCookie() : rotated,
first.csrfCookie(), first.csrfToken());
mockMvc.perform(get("/api/v1/workbenches/project").cookie(active.cookies()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("AUTH_PASSWORD_CHANGE_REQUIRED"));
mockMvc.perform(get("/api/v1/auth/sessions").cookie(active.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(org.hamcrest.Matchers.greaterThanOrEqualTo(2)));
mockMvc.perform(patch("/api/v1/auth/password")
.cookie(active.cookies()).header("X-XSRF-TOKEN", active.csrfToken())
.contentType("application/json").content("""
{"currentPassword":"LocalOnly@123","newPassword":"ChangedOnly@123",
"confirmPassword":"ChangedOnly@123"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.user.mustChangePassword").value(false));
mockMvc.perform(get("/api/v1/workbenches/project").cookie(active.cookies()))
.andExpect(status().isOk());
mockMvc.perform(get("/api/v1/auth/session").cookie(second.cookies()))
.andExpect(status().isUnauthorized());
}
@Test
void ungrantedRoleAndAnonymousSessionAreRejected() throws Exception {
mockMvc.perform(get("/api/v1/auth/session"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("AUTH_SESSION_REQUIRED"));
ClientSession session = clientSession(login("project", "LocalOnly@123").andReturn());
mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken())
.contentType("application/json")
.content("{\"roleCode\":\"FINANCE_MANAGER\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("AUTH_ROLE_NOT_GRANTED"));
}
@Test
void roleSelectionRechecksCurrentDatabaseAssignmentsAndSessionDropsRevokedActiveRole() throws Exception {
ClientSession session = clientSession(login("project", "LocalOnly@123").andReturn());
MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken())
.contentType("application/json")
.content("{\"roleCode\":\"PROJECT_MANAGER\"}"))
.andExpect(status().isOk())
.andReturn();
Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION");
ClientSession active = new ClientSession(rotated == null ? session.sessionCookie() : rotated,
session.csrfCookie(), session.csrfToken());
jdbcTemplate.update("""
DELETE assignment
FROM iam_user_role assignment
JOIN iam_user user_account ON user_account.id = assignment.user_id
JOIN iam_role role ON role.id = assignment.role_id
WHERE user_account.username = 'project' AND role.code = 'PROJECT_MANAGER'
""");
try {
mockMvc.perform(get("/api/v1/auth/session").cookie(active.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.activeRole").doesNotExist())
.andExpect(jsonPath("$.data.roles").isEmpty())
.andExpect(jsonPath("$.data.permissions").isEmpty());
mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(active.cookies()).header("X-XSRF-TOKEN", active.csrfToken())
.contentType("application/json").content("{\"roleCode\":\"PROJECT_MANAGER\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("AUTH_ROLE_NOT_GRANTED"));
} finally {
jdbcTemplate.update("""
INSERT INTO iam_user_role (user_id, role_id)
SELECT user_account.id, role.id
FROM iam_user user_account
JOIN iam_role role ON role.code = 'PROJECT_MANAGER'
WHERE user_account.username = 'project'
""");
}
}
@Test
void roleSelectionRejectsAdministratorBusinessRoleConflictIntroducedAfterLogin() throws Exception {
ClientSession session = clientSession(login("project", "LocalOnly@123").andReturn());
jdbcTemplate.update("""
INSERT INTO iam_user_role (user_id, role_id)
SELECT user_account.id, role.id
FROM iam_user user_account
JOIN iam_role role ON role.code = 'SYSTEM_ADMIN'
WHERE user_account.username = 'project'
""");
try {
mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(session.cookies()).header("X-XSRF-TOKEN", session.csrfToken())
.contentType("application/json").content("{\"roleCode\":\"PROJECT_MANAGER\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("ACCESS_DENIED"));
assertTrue(jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM audit_log
WHERE username = 'project' AND action_code = 'AUTH_ROLE_CONFIGURATION_CONFLICT'
AND result_code = 'FAILED' AND reason = 'ROLE_CONFIGURATION_CONFLICT'
""", Integer.class) == 1);
} finally {
jdbcTemplate.update("""
DELETE assignment
FROM iam_user_role assignment
JOIN iam_user user_account ON user_account.id = assignment.user_id
JOIN iam_role role ON role.id = assignment.role_id
WHERE user_account.username = 'project' AND role.code = 'SYSTEM_ADMIN'
""");
}
}
@Test
void fiveBadPasswordsLockTheAccountAndLogoutInvalidatesSession() throws Exception {
for (int attempt = 0; attempt < 5; attempt++) {
login("finance", "wrong-password")
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("AUTH_BAD_CREDENTIALS"));
}
login("finance", "LocalOnly@123")
.andExpect(status().isLocked())
.andExpect(jsonPath("$.code").value("AUTH_ACCOUNT_LOCKED"));
jdbcTemplate.update("""
UPDATE iam_user SET failed_login_count = 0, locked_until = NULL WHERE username = 'finance'
""");
ClientSession session = clientSession(login("finance", "LocalOnly@123").andReturn());
mockMvc.perform(post("/api/v1/auth/logout")
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()))
.andExpect(status().isOk());
mockMvc.perform(get("/api/v1/auth/session").cookie(session.cookies()))
.andExpect(status().isUnauthorized());
}
@Test
void fourthLoginEvictsOldestSessionAndKeepsOnlyThree() throws Exception {
ClientSession first = clientSession(login("project", "LocalOnly@123").andReturn());
Thread.sleep(5);
ClientSession second = clientSession(login("project", "LocalOnly@123").andReturn());
Thread.sleep(5);
ClientSession third = clientSession(login("project", "LocalOnly@123").andReturn());
Thread.sleep(5);
ClientSession fourth = clientSession(login("project", "LocalOnly@123").andReturn());
mockMvc.perform(get("/api/v1/auth/session").cookie(first.cookies()))
.andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/v1/auth/session").cookie(second.cookies())).andExpect(status().isOk());
mockMvc.perform(get("/api/v1/auth/session").cookie(third.cookies())).andExpect(status().isOk());
mockMvc.perform(get("/api/v1/auth/sessions").cookie(fourth.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(3))
.andExpect(jsonPath("$.data[?(@.current == true)]", hasSize(1)));
assertTrue(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE action_code='AUTH_SESSION_EVICTED'", Integer.class) > 0);
}
@Test
void sessionDeletionIsOwnerScopedCsrfProtectedAndCanDeleteAll() throws Exception {
ClientSession first = clientSession(login("project", "LocalOnly@123").andReturn());
ClientSession second = clientSession(login("project", "LocalOnly@123").andReturn());
String firstId = sessionIds("project").stream()
.filter(id -> !id.equals(rawSessionId(second.sessionCookie())))
.findFirst().orElseThrow();
mockMvc.perform(delete("/api/v1/auth/sessions/{sessionId}", firstId).cookie(second.cookies()))
.andExpect(status().isForbidden());
mockMvc.perform(delete("/api/v1/auth/sessions/{sessionId}", firstId).cookie(second.cookies())
.header("X-XSRF-TOKEN", second.csrfToken()))
.andExpect(status().isOk());
mockMvc.perform(get("/api/v1/auth/session").cookie(first.cookies()))
.andExpect(status().isUnauthorized());
ClientSession finance = clientSession(login("finance", "LocalOnly@123").andReturn());
String financeId = sessionIds("finance").get(0);
mockMvc.perform(delete("/api/v1/auth/sessions/{sessionId}", financeId).cookie(second.cookies())
.header("X-XSRF-TOKEN", second.csrfToken()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"));
mockMvc.perform(delete("/api/v1/auth/sessions").cookie(second.cookies())
.header("X-XSRF-TOKEN", second.csrfToken()))
.andExpect(status().isOk());
mockMvc.perform(get("/api/v1/auth/session").cookie(second.cookies()))
.andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/v1/auth/session").cookie(finance.cookies())).andExpect(status().isOk());
}
@Test
void absoluteDeadlineIsEnforcedWithoutBeingExtendedByActivity() throws Exception {
ClientSession client = clientSession(login("project", "LocalOnly@123").andReturn());
String sessionId = rawSessionId(client.sessionCookie());
Session stored = sessionRepository.findById(sessionId);
assertNotNull(stored);
Instant deadline = (Instant) stored.getAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT);
assertNotNull(deadline);
assertTrue(deadline.isAfter(stored.getCreationTime().plusSeconds(8 * 60 * 60 - 5)));
stored.setAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT, Instant.now().minusSeconds(1));
saveSession(stored);
mockMvc.perform(get("/api/v1/auth/session").cookie(client.cookies()))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("AUTH_SESSION_REQUIRED"));
org.junit.jupiter.api.Assertions.assertNull(sessionRepository.findById(sessionId));
}
@Test
void bootstrapIsIdempotentAndCreatesPermissionScopesForExistingUsers() {
Integer duplicateAssignments = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM (
SELECT user_id, role_id, COUNT(*) AS assignment_count
FROM iam_user_role
GROUP BY user_id, role_id
HAVING COUNT(*) > 1
) duplicate_rows
""", Integer.class);
Integer missingScopes = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM iam_user_role user_role
JOIN iam_role_permission role_permission ON role_permission.role_id = user_role.role_id
LEFT JOIN iam_scope scope
ON scope.user_id = user_role.user_id
AND scope.role_id = user_role.role_id
AND scope.permission_id = role_permission.permission_id
AND scope.scope_type = 'GLOBAL'
AND scope.status = 'ACTIVE'
JOIN iam_user user_account ON user_account.id = user_role.user_id
WHERE user_account.username IN ('admin', 'project', 'finance', 'archive', 'demo')
AND scope.id IS NULL
""", Integer.class);
org.junit.jupiter.api.Assertions.assertEquals(0, duplicateAssignments);
org.junit.jupiter.api.Assertions.assertEquals(0, missingScopes);
Long scopeId = jdbcTemplate.queryForObject("""
SELECT scope_grant.id
FROM iam_scope scope_grant
JOIN iam_user user_account ON user_account.id = scope_grant.user_id
JOIN iam_role role ON role.id = scope_grant.role_id
WHERE user_account.username = 'admin'
AND role.code = 'SYSTEM_ADMIN'
AND scope_grant.scope_type = 'GLOBAL'
ORDER BY scope_grant.id
LIMIT 1
""", Long.class);
jdbcTemplate.update("""
UPDATE iam_scope
SET status = 'DISABLED', amount_limit = 1,
valid_from = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 1 DAY),
valid_to = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 2 DAY)
WHERE id = ?
""", scopeId);
Integer versionBeforeSync = jdbcTemplate.queryForObject(
"SELECT version FROM iam_scope WHERE id = ?", Integer.class, scopeId);
bootstrapUserInitializer.ensureSystemAdministratorGrants();
Map<String, Object> normalized = jdbcTemplate.queryForMap("""
SELECT status, amount_limit, valid_from, valid_to, version
FROM iam_scope WHERE id = ?
""", scopeId);
assertEquals("ACTIVE", normalized.get("status"));
org.junit.jupiter.api.Assertions.assertNull(normalized.get("amount_limit"));
org.junit.jupiter.api.Assertions.assertNull(normalized.get("valid_to"));
assertEquals(versionBeforeSync + 1, ((Number) normalized.get("version")).intValue());
bootstrapUserInitializer.ensureSystemAdministratorGrants();
Integer versionAfterSecondSync = jdbcTemplate.queryForObject(
"SELECT version FROM iam_scope WHERE id = ?", Integer.class, scopeId);
assertEquals(((Number) normalized.get("version")).intValue(), versionAfterSecondSync);
}
private org.springframework.test.web.servlet.ResultActions login(String username, String password)
throws Exception {
return loginWithClient(username, password, null, null);
}
private org.springframework.test.web.servlet.ResultActions loginWithClient(String username, String password,
String forwardedFor,
String userAgent) throws Exception {
MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf"))
.andExpect(status().isOk())
.andReturn();
Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN");
assertNotNull(csrfCookie, "CSRF endpoint must issue the readable CSRF cookie");
String csrfToken = com.jayway.jsonpath.JsonPath.read(csrf.getResponse().getContentAsString(),
"$.data.token");
var request = post("/api/v1/auth/login")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("""
{"username":"%s","password":"%s"}
""".formatted(username, password));
if (forwardedFor != null) request.header("X-Forwarded-For", forwardedFor);
if (userAgent != null) request.header("User-Agent", userAgent);
return mockMvc.perform(request);
}
private ClientSession clientSession(MvcResult result) {
Cookie sessionCookie = result.getResponse().getCookie("KAIDI_SESSION");
assertNotNull(sessionCookie, "login must issue the Spring Session cookie");
Cookie csrfCookie = Arrays.stream(result.getRequest().getCookies())
.filter(cookie -> cookie.getName().equals("XSRF-TOKEN"))
.findFirst()
.orElseThrow();
return new ClientSession(sessionCookie, csrfCookie,
result.getRequest().getHeader("X-XSRF-TOKEN"));
}
private List<String> sessionIds(String username) {
return sessionRepository.findByPrincipalName(username).values().stream()
.map(Session::getId).sorted().toList();
}
@SuppressWarnings({"rawtypes", "unchecked"})
private void saveSession(Session session) {
((org.springframework.session.SessionRepository) sessionRepository).save(session);
}
private String rawSessionId(Cookie cookie) {
return new String(java.util.Base64.getDecoder().decode(cookie.getValue()), java.nio.charset.StandardCharsets.UTF_8);
}
private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) {
Cookie[] cookies() {
return new Cookie[]{sessionCookie, csrfCookie};
}
}
}
@@ -0,0 +1,868 @@
package com.kaidi.finance.masterdata;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.jayway.jsonpath.JsonPath;
import jakarta.servlet.http.Cookie;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class MasterDataIntegrationTest {
private static final AtomicInteger SEQUENCE = new AtomicInteger();
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_masterdata_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("finance.bootstrap.enabled", () -> true);
registry.add("finance.bootstrap.password", () -> "LocalOnly@123");
}
@Autowired
MockMvc mockMvc;
@Autowired
JdbcTemplate jdbcTemplate;
@AfterEach
void restoreProjectUserGlobalCompanyViewScope() {
jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
SET scope.amount_limit = NULL
WHERE user_account.username = 'finance'
""");
jdbcTemplate.update("""
INSERT INTO iam_scope (
user_id, role_id, permission_id, scope_type, company_public_id, project_public_id,
amount_limit, status
)
SELECT user_account.id, role.id, permission.id, 'GLOBAL', NULL, NULL, NULL, 'ACTIVE'
FROM iam_user user_account
JOIN iam_role role ON role.code = 'PROJECT_MANAGER'
JOIN iam_permission permission ON permission.code = 'masterdata:company:view'
WHERE user_account.username = 'project'
AND NOT EXISTS (
SELECT 1 FROM iam_scope existing
WHERE existing.user_id = user_account.id
AND existing.role_id = role.id
AND existing.permission_id = permission.id
AND existing.scope_type = 'GLOBAL'
)
""");
}
@Test
void companyLifecycleRequiresAnotherReviewerAndMaintainsVersion() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER");
String suffix = nextSuffix();
MvcResult created = postJson("/api/v1/masterdata/companies", finance, """
{"businessNo":"C-%s","name":"测试公司 %s","taxNo":"TAX-%s"}
""".formatted(suffix, suffix, suffix))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DRAFT"))
.andExpect(jsonPath("$.data.version").value(0))
.andExpect(jsonPath("$.data.allowedActions").isArray())
.andReturn();
String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
patchJson("/api/v1/masterdata/companies/" + id, finance, """
{"name":"测试公司更新 %s","taxNo":"TAX-%s","version":0}
""".formatted(suffix, suffix))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.version").value(1));
postJson("/api/v1/masterdata/companies/" + id + "/submit", finance, "{\"version\":1}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("REVIEWING"))
.andExpect(jsonPath("$.data.version").value(2));
postJson("/api/v1/masterdata/companies/" + id + "/review", finance,
"{\"version\":2,\"decision\":\"APPROVE\",\"opinion\":\"同意生效\"}")
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("SOD_VIOLATION"));
postJson("/api/v1/masterdata/companies/" + id + "/review", reviewer,
"{\"version\":2,\"decision\":\"APPROVE\",\"opinion\":\"复核通过\"}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"))
.andExpect(jsonPath("$.data.version").value(3));
mockMvc.perform(get("/api/v1/masterdata/companies")
.param("keyword", suffix)
.cookie(reviewer.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(1))
.andExpect(jsonPath("$.data[0].publicId").value(id));
postJson("/api/v1/masterdata/companies/" + id + "/disable", reviewer, "{\"version\":3}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DISABLED"))
.andExpect(jsonPath("$.data.version").value(4));
}
@Test
void duplicateAndStaleCompanyWritesAreRejected() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
String suffix = nextSuffix();
String payload = """
{"businessNo":"DUP-%s","name":"重复测试公司","taxNo":"DUP-TAX-%s"}
""".formatted(suffix, suffix);
MvcResult created = postJson("/api/v1/masterdata/companies", finance, payload)
.andExpect(status().isOk()).andReturn();
String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/companies", finance, payload)
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE"));
patchJson("/api/v1/masterdata/companies/" + id, finance,
"{\"name\":\"第一次更新\",\"taxNo\":\"DUP-TAX-%s\",\"version\":0}".formatted(suffix))
.andExpect(status().isOk());
patchJson("/api/v1/masterdata/companies/" + id, finance,
"{\"name\":\"过期更新\",\"taxNo\":\"DUP-TAX-%s\",\"version\":0}".formatted(suffix))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("VERSION_CONFLICT"));
}
@Test
void companyListAndDetailEnforceObjectScope() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession project = loginAndSelect("project", "PROJECT_MANAGER");
String firstSuffix = nextSuffix();
String secondSuffix = nextSuffix();
String firstId = createCompany(finance, firstSuffix);
String secondId = createCompany(finance, secondSuffix);
jdbcTemplate.update("""
DELETE scope
FROM iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE user_account.username = 'project'
AND role.code = 'PROJECT_MANAGER'
AND permission.code = 'masterdata:company:view'
""");
jdbcTemplate.update("""
INSERT INTO iam_scope (
user_id, role_id, permission_id, scope_type, company_public_id, status
)
SELECT user_account.id, role.id, permission.id, 'COMPANY', ?, 'ACTIVE'
FROM iam_user user_account
JOIN iam_role role ON role.code = 'PROJECT_MANAGER'
JOIN iam_permission permission ON permission.code = 'masterdata:company:view'
WHERE user_account.username = 'project'
""", firstId);
mockMvc.perform(get("/api/v1/masterdata/companies").cookie(project.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(1))
.andExpect(jsonPath("$.data[0].publicId").value(firstId));
mockMvc.perform(get("/api/v1/masterdata/companies/{id}", secondId).cookie(project.cookies()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED"));
postJson("/api/v1/masterdata/companies/" + firstId + "/legacy-identifiers", finance,
"{\"sourceSystem\":\"LEGACY_SCOPE\",\"legacyCode\":\"FIRST-%s\"}".formatted(firstSuffix))
.andExpect(status().isOk());
postJson("/api/v1/masterdata/companies/" + secondId + "/legacy-identifiers", finance,
"{\"sourceSystem\":\"LEGACY_SCOPE\",\"legacyCode\":\"SECOND-%s\"}".formatted(secondSuffix))
.andExpect(status().isOk());
mockMvc.perform(get("/api/v1/masterdata/companies/{id}/legacy-identifiers", firstId)
.cookie(project.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.allowedActions").isEmpty())
.andExpect(jsonPath("$.meta.totalElements").value(1));
mockMvc.perform(get("/api/v1/masterdata/companies/legacy-identifiers/resolve")
.param("sourceSystem", "legacy_scope")
.param("legacyCode", "first-" + firstSuffix)
.cookie(project.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.targetPublicId").value(firstId));
mockMvc.perform(get("/api/v1/masterdata/companies/legacy-identifiers/resolve")
.param("sourceSystem", "LEGACY_SCOPE")
.param("legacyCode", "SECOND-" + secondSuffix)
.cookie(project.cookies()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED"));
postJson("/api/v1/masterdata/companies/" + firstId + "/legacy-identifiers", project,
"{\"sourceSystem\":\"LEGACY_SCOPE\",\"legacyCode\":\"FORBIDDEN-%s\"}".formatted(firstSuffix))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("PERMISSION_DENIED"));
}
@Test
void counterpartyLifecycleAndDuplicateTaxNumberAreEnforced() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER");
String suffix = nextSuffix();
String payload = """
{"businessNo":"S-%s","type":"SUPPLIER","name":"测试供应商 %s",
"taxNo":"SUP-TAX-%s","contactName":"联系人","contactPhone":"13800000000"}
""".formatted(suffix, suffix, suffix);
MvcResult created = postJson("/api/v1/masterdata/counterparties", finance, payload)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DRAFT"))
.andReturn();
String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/counterparties", finance, payload)
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE"));
postJson("/api/v1/masterdata/counterparties/" + id + "/submit", finance, "{\"version\":0}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("REVIEWING"));
postJson("/api/v1/masterdata/counterparties/" + id + "/review", reviewer,
"{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"资料完整\"}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"));
}
@Test
void catalogResourcesUseStronglyTypedLifecycleAndMaskBankAccounts() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER");
String suffix = nextSuffix();
String companyId = createActiveCompany(finance, reviewer, "CAT-C-" + suffix, "CAT-TAX-" + suffix);
String counterpartyId = createActiveCounterparty(finance, reviewer, "CAT-S-" + suffix,
"CAT-SUP-TAX-" + suffix);
String projectId = createProject(finance, companyId, "CAT-P-" + suffix);
mockMvc.perform(get("/api/v1/masterdata/references").cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(companyId)).exists())
.andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(projectId)).exists())
.andExpect(jsonPath("$.data.counterparties[?(@.publicId == '%s')]".formatted(counterpartyId)).exists());
MvcResult bank = postJson("/api/v1/masterdata/bank-accounts", finance, """
{"ownerType":"COMPANY","ownerId":"%s","accountCategory":"BASIC",
"accountName":"测试公司","bankName":"测试银行","accountNo":"6222 0000-0000-7890",
"validFrom":"2026-01-01"}
""".formatted(companyId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DRAFT"))
.andExpect(jsonPath("$.data.maskedAccountNo").value("**** **** 7890"))
.andExpect(jsonPath("$.data.accountNo").doesNotExist())
.andReturn();
String bankId = JsonPath.read(bank.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/bank-accounts", finance, """
{"ownerType":"COMPANY","ownerId":"%s","accountCategory":"BASIC",
"accountName":"重复账号","bankName":"测试银行","accountNo":"6222000000007890",
"validFrom":"2026-02-01"}
""".formatted(companyId))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE"));
postJson("/api/v1/masterdata/bank-accounts/" + bankId + "/submit", finance, "{\"version\":0}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("REVIEWING"));
postJson("/api/v1/masterdata/bank-accounts/" + bankId + "/review", finance,
"{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"同人复核\"}")
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("SOD_VIOLATION"));
postJson("/api/v1/masterdata/bank-accounts/" + bankId + "/review", reviewer,
"{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"账户资料已复核\"}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"))
.andExpect(jsonPath("$.data.version").value(2));
postJson("/api/v1/masterdata/bank-accounts/" + bankId + "/disable", reviewer, "{\"version\":1}")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("VERSION_CONFLICT"));
postJson("/api/v1/masterdata/bank-accounts/" + bankId + "/disable", reviewer, "{\"version\":2}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DISABLED"));
jdbcTemplate.update("""
UPDATE audit_log
SET after_json = JSON_SET(
after_json,
'$.accountNo', '6222000000007890',
'$.contact', JSON_OBJECT('phone', '13800000000'),
'$.identity', JSON_OBJECT('idCardNo', '110101199001011234'),
'$.contacts', JSON_ARRAY(JSON_OBJECT(
'mobile', '13800000000',
'allowedActions', JSON_ARRAY('EDIT')
))
)
WHERE object_type = 'BANK_ACCOUNT'
AND object_public_id = ?
AND action_code = 'MASTERDATA_BANK_ACCOUNT_CREATE'
""", bankId);
mockMvc.perform(get("/api/v1/masterdata/bank-accounts/{id}/versions", bankId)
.param("page", "1")
.param("size", "20")
.cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(4))
.andExpect(jsonPath("$.data[0].before.maskedAccountNo").value("***"))
.andExpect(jsonPath("$.data[0].after.maskedAccountNo").value("***"))
.andExpect(jsonPath("$.data[3].after.accountNo").value("***"))
.andExpect(jsonPath("$.data[3].after.contact.phone").value("***"))
.andExpect(jsonPath("$.data[3].after.identity.idCardNo").value("***"))
.andExpect(jsonPath("$.data[3].after.contacts[0].mobile").value("***"))
.andExpect(jsonPath("$.data[3].after.contacts[0].allowedActions").doesNotExist());
String validAuditSnapshot = jdbcTemplate.queryForObject("""
SELECT after_json
FROM audit_log
WHERE object_type = 'BANK_ACCOUNT'
AND object_public_id = ?
AND action_code = 'MASTERDATA_BANK_ACCOUNT_CREATE'
""", String.class, bankId);
try {
jdbcTemplate.update("""
UPDATE audit_log
SET after_json = JSON_ARRAY(JSON_OBJECT('accountNo', '6222000000007890'))
WHERE object_type = 'BANK_ACCOUNT'
AND object_public_id = ?
AND action_code = 'MASTERDATA_BANK_ACCOUNT_CREATE'
""", bankId);
mockMvc.perform(get("/api/v1/masterdata/bank-accounts/{id}/versions", bankId)
.param("page", "1")
.param("size", "20")
.cookie(finance.cookies()))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
} finally {
jdbcTemplate.update("""
UPDATE audit_log
SET after_json = ?
WHERE object_type = 'BANK_ACCOUNT'
AND object_public_id = ?
AND action_code = 'MASTERDATA_BANK_ACCOUNT_CREATE'
""", validAuditSnapshot, bankId);
}
MvcResult contract = postJson("/api/v1/masterdata/contracts", finance, """
{"companyId":"%s","projectId":"%s","counterpartyId":"%s",
"businessNo":"CAT-CON-%s","name":"测试成本合同","originalAmount":"1200.00","currency":"CNY"}
""".formatted(companyId, projectId, counterpartyId, suffix))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.originalAmount").value("1200.00"))
.andReturn();
String contractId = JsonPath.read(contract.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/contracts/" + contractId + "/submit", finance, "{\"version\":0}")
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REVIEWING"));
postJson("/api/v1/masterdata/contracts/" + contractId + "/review", reviewer,
"{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"合同资料已复核\"}")
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("ACTIVE"));
MvcResult category = postJson("/api/v1/masterdata/cost-categories", finance, """
{"code":"CAT-%s","name":"测试成本类别"}
""".formatted(suffix))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DRAFT")).andReturn();
String categoryId = JsonPath.read(category.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/cost-categories/" + categoryId + "/submit", finance, "{\"version\":0}")
.andExpect(status().isOk());
postJson("/api/v1/masterdata/cost-categories/" + categoryId + "/review", reviewer,
"{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"类别已复核\"}")
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("ACTIVE"));
postJson("/api/v1/masterdata/accounts", finance, """
{"code":"9%s","name":"测试会计科目","accountType":"COST","auxiliaryRequired":true}
""".formatted(suffix))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DRAFT"));
postJson("/api/v1/masterdata/tax-rates", finance, """
{"code":"CAT-VAT-%s","name":"测试税率","rate":"0.130000"}
""".formatted(suffix))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DRAFT"));
}
@Test
void departmentLifecycleRejectsDuplicatesAndSamePersonReviewAndKeepsAuditTrail() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER");
ClientSession project = loginAndSelect("project", "PROJECT_MANAGER");
String suffix = nextSuffix();
String code = "DEPT-" + suffix;
MvcResult created = postJson("/api/v1/masterdata/departments", finance, """
{"code":"%s","name":"财务共享中心"}
""".formatted(code))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.resource").value("departments"))
.andExpect(jsonPath("$.data.code").value(code))
.andExpect(jsonPath("$.data.status").value("DRAFT"))
.andExpect(jsonPath("$.data.version").value(0))
.andExpect(jsonPath("$.data.allowedActions[?(@ == 'EDIT')]").exists())
.andExpect(jsonPath("$.data.allowedActions[?(@ == 'SUBMIT')]").exists())
.andReturn();
String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/departments", finance,
"{\"code\":\"%s\",\"name\":\"重复部门\"}".formatted(code))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE"));
mockMvc.perform(get("/api/v1/masterdata/departments")
.param("keyword", suffix)
.cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(1))
.andExpect(jsonPath("$.data[0].publicId").value(id));
mockMvc.perform(get("/api/v1/masterdata/departments/{id}", id).cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.name").value("财务共享中心"));
patchJson("/api/v1/masterdata/departments/" + id, finance,
"{\"name\":\"财务管理部\",\"version\":0}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.name").value("财务管理部"))
.andExpect(jsonPath("$.data.version").value(1));
postJson("/api/v1/masterdata/departments/" + id + "/submit", finance, "{\"version\":1}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("REVIEWING"))
.andExpect(jsonPath("$.data.version").value(2));
postJson("/api/v1/masterdata/departments/" + id + "/review", finance,
"{\"version\":2,\"decision\":\"APPROVE\",\"opinion\":\"同人复核\"}")
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("SOD_VIOLATION"));
postJson("/api/v1/masterdata/departments/" + id + "/review", reviewer,
"{\"version\":2,\"decision\":\"APPROVE\",\"opinion\":\"部门资料已复核\"}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"))
.andExpect(jsonPath("$.data.version").value(3))
.andExpect(jsonPath("$.data.allowedActions[?(@ == 'DISABLE')]").exists());
postJson("/api/v1/masterdata/departments/" + id + "/disable", reviewer, "{\"version\":2}")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("VERSION_CONFLICT"));
postJson("/api/v1/masterdata/departments/" + id + "/disable", reviewer, "{\"version\":3}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DISABLED"))
.andExpect(jsonPath("$.data.version").value(4));
mockMvc.perform(get("/api/v1/masterdata/departments/{id}/versions", id)
.param("page", "1")
.param("size", "20")
.cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(5))
.andExpect(jsonPath("$.meta.page").value(1))
.andExpect(jsonPath("$.data.length()").value(5))
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')].version").value(4))
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')].status")
.value("DISABLED"))
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')].actorName")
.value("demo"))
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')].before.version")
.value(3))
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')].after.version")
.value(4))
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')]"
+ ".before.allowedActions").doesNotExist())
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')]"
+ ".after.allowedActions").doesNotExist())
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_UPDATE')].before.name")
.value("财务共享中心"))
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_UPDATE')].after.name")
.value("财务管理部"))
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_CREATE')].version").value(0))
.andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_CREATE')].status")
.value("DRAFT"));
mockMvc.perform(get("/api/v1/masterdata/departments/{id}/versions", id)
.param("page", "2")
.param("size", "20")
.cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(5))
.andExpect(jsonPath("$.data.length()").value(0));
mockMvc.perform(get("/api/v1/masterdata/departments/{id}/versions", id)
.param("page", "0")
.cookie(finance.cookies()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID"));
mockMvc.perform(get("/api/v1/masterdata/departments/{id}/versions", id).cookie(project.cookies()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("PERMISSION_DENIED"));
MvcResult mappingCreated = postJson(
"/api/v1/masterdata/departments/" + id + "/legacy-identifiers", finance,
"{\"sourceSystem\":\"Old_Erp\",\"legacyCode\":\" dept-old-%s \"}".formatted(suffix))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.resource").value("departments"))
.andExpect(jsonPath("$.data.sourceSystem").value("OLD_ERP"))
.andExpect(jsonPath("$.data.legacyCode").value("dept-old-" + suffix))
.andExpect(jsonPath("$.data.targetPublicId").value(id))
.andExpect(jsonPath("$.data.status").value("ACTIVE"))
.andExpect(jsonPath("$.data.version").value(0))
.andExpect(jsonPath("$.data.allowedActions[?(@ == 'DISABLE')]").exists())
.andReturn();
String mappingId = JsonPath.read(mappingCreated.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/departments/" + id + "/legacy-identifiers", finance,
"{\"sourceSystem\":\"OLD_ERP\",\"legacyCode\":\"DEPT-OLD-%s\"}".formatted(suffix))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE"));
postJson("/api/v1/masterdata/departments/" + id + "/legacy-identifiers", finance,
"{\"sourceSystem\":\"不合法系统\",\"legacyCode\":\"DEPT-INVALID-%s\"}".formatted(suffix))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(get("/api/v1/masterdata/departments/{id}/legacy-identifiers", id)
.param("page", "1")
.param("size", "20")
.cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.totalElements").value(1))
.andExpect(jsonPath("$.meta.allowedActions[?(@ == 'CREATE')]").exists())
.andExpect(jsonPath("$.data[0].publicId").value(mappingId));
mockMvc.perform(get("/api/v1/masterdata/departments/legacy-identifiers/resolve")
.param("sourceSystem", "old_erp")
.param("legacyCode", "DEPT-OLD-" + suffix)
.cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.mappingPublicId").value(mappingId))
.andExpect(jsonPath("$.data.targetPublicId").value(id));
mockMvc.perform(get("/api/v1/masterdata/departments/{id}/legacy-identifiers", id)
.cookie(project.cookies()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("PERMISSION_DENIED"));
postJson("/api/v1/masterdata/departments/" + id + "/legacy-identifiers/" + mappingId + "/disable",
finance, "{\"version\":1}")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("VERSION_CONFLICT"));
postJson("/api/v1/masterdata/departments/" + id + "/legacy-identifiers/" + mappingId + "/disable",
finance, "{\"version\":0}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DISABLED"))
.andExpect(jsonPath("$.data.version").value(1))
.andExpect(jsonPath("$.data.allowedActions").isEmpty());
mockMvc.perform(get("/api/v1/masterdata/departments/legacy-identifiers/resolve")
.param("sourceSystem", "OLD_ERP")
.param("legacyCode", "DEPT-OLD-" + suffix)
.cookie(finance.cookies()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"));
postJson("/api/v1/masterdata/departments/" + id + "/legacy-identifiers", finance,
"{\"sourceSystem\":\"OLD_ERP\",\"legacyCode\":\"DEPT-OLD-%s\"}".formatted(suffix))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"));
Long auditCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM audit_log
WHERE object_type = 'DEPARTMENT' AND object_public_id = ?
AND action_code IN (
'MASTERDATA_DICTIONARY_CREATE', 'MASTERDATA_DICTIONARY_UPDATE',
'MASTERDATA_DICTIONARY_SUBMIT', 'MASTERDATA_DICTIONARY_REVIEW',
'MASTERDATA_DICTIONARY_DISABLE'
)
""", Long.class, id);
org.junit.jupiter.api.Assertions.assertEquals(5L, auditCount);
Long mappingAuditCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM audit_log
WHERE object_type = 'MASTERDATA_LEGACY_IDENTIFIER'
AND action_code IN (
'MASTERDATA_LEGACY_IDENTIFIER_CREATE', 'MASTERDATA_LEGACY_IDENTIFIER_DISABLE'
)
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.targetPublicId')) = ?
""", Long.class, id);
org.junit.jupiter.api.Assertions.assertEquals(3L, mappingAuditCount);
}
@Test
void catalogContractRejectsMismatchedProjectAndAmountLimit() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER");
String suffix = nextSuffix();
String firstCompany = createActiveCompany(finance, reviewer, "MISMATCH-A-" + suffix, "MISMATCH-TAX-A-" + suffix);
String secondCompany = createActiveCompany(finance, reviewer, "MISMATCH-B-" + suffix, "MISMATCH-TAX-B-" + suffix);
String counterparty = createActiveCounterparty(finance, reviewer, "MISMATCH-S-" + suffix,
"MISMATCH-SUP-TAX-" + suffix);
String project = createProject(finance, firstCompany, "MISMATCH-P-" + suffix);
postJson("/api/v1/masterdata/contracts", finance, """
{"companyId":"%s","projectId":"%s","counterpartyId":"%s",
"businessNo":"MISMATCH-CON-%s","name":"公司项目不一致","originalAmount":"10.00","currency":"CNY"}
""".formatted(secondCompany, project, counterparty, suffix))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
setAmountLimit("finance", "masterdata:contract:create", "100.00");
postJson("/api/v1/masterdata/contracts", finance, """
{"companyId":"%s","projectId":"%s","counterpartyId":"%s",
"businessNo":"LIMIT-CON-%s","name":"超过额度合同","originalAmount":"100.01","currency":"CNY"}
""".formatted(firstCompany, project, counterparty, suffix))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("AMOUNT_LIMIT_EXCEEDED"));
}
@Test
void disablingReferencedMasterDataPreservesHistoryAndBlocksNewReferences() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER");
String suffix = nextSuffix();
String companyId = createActiveCompany(finance, reviewer, "REF-C-" + suffix, "REF-TAX-" + suffix);
String counterpartyId = createActiveCounterparty(finance, reviewer, "REF-S-" + suffix,
"REF-S-TAX-" + suffix);
String projectId = createProject(finance, companyId, "REF-P-" + suffix);
String contractId = createActiveContract(finance, reviewer, companyId, projectId, counterpartyId, suffix);
String categoryId = createActiveCostCategory(finance, reviewer, suffix);
String bankId = createActiveBankAccount(finance, reviewer, counterpartyId, suffix);
Long companyDbId = dbId("md_company", companyId);
Long projectDbId = dbId("md_project", projectId);
Long counterpartyDbId = dbId("md_counterparty", counterpartyId);
Long contractDbId = dbId("md_contract", contractId);
Long categoryDbId = dbId("md_cost_category", categoryId);
Long bankDbId = dbId("md_bank_account_version", bankId);
Long financeUserId = jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username = 'finance'", Long.class);
jdbcTemplate.update("""
INSERT INTO fin_payable (public_id, business_no, company_id, project_id, contract_id, counterparty_id,
cost_category_id, business_date, amount, status, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 1.00, 'CONFIRMED', ?, ?)
""", uniquePublicId(suffix, "PAY"), "REF-PAY-" + suffix, companyDbId, projectDbId, contractDbId,
counterpartyDbId, categoryDbId, financeUserId, financeUserId);
jdbcTemplate.update("""
INSERT INTO fin_payment_request (public_id, business_no, company_id, project_id, contract_id, supplier_id,
bank_account_version_id, requested_amount, currency, requested_date, purpose, status, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, 1.00, 'CNY', CURRENT_DATE, '历史账户版本引用', 'DRAFT', ?, ?)
""", uniquePublicId(suffix, "PMT"), "REF-PMT-" + suffix, companyDbId, projectDbId, contractDbId,
counterpartyDbId, bankDbId, financeUserId, financeUserId);
disable("/api/v1/masterdata/bank-accounts/" + bankId + "/disable", finance, 2);
disable("/api/v1/masterdata/cost-categories/" + categoryId + "/disable", finance, 2);
disable("/api/v1/masterdata/contracts/" + contractId + "/disable", finance, 2);
disable("/api/v1/masterdata/counterparties/" + counterpartyId + "/disable", finance, 2);
disable("/api/v1/masterdata/companies/" + companyId + "/disable", finance, 2);
org.junit.jupiter.api.Assertions.assertEquals(bankDbId, jdbcTemplate.queryForObject(
"SELECT bank_account_version_id FROM fin_payment_request WHERE business_no = ?", Long.class,
"REF-PMT-" + suffix));
org.junit.jupiter.api.Assertions.assertEquals(contractDbId, jdbcTemplate.queryForObject(
"SELECT contract_id FROM fin_payable WHERE business_no = ?", Long.class, "REF-PAY-" + suffix));
org.junit.jupiter.api.Assertions.assertEquals(categoryDbId, jdbcTemplate.queryForObject(
"SELECT cost_category_id FROM fin_payable WHERE business_no = ?", Long.class, "REF-PAY-" + suffix));
mockMvc.perform(get("/api/v1/masterdata/references").cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(companyId)).doesNotExist())
.andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(projectId)).doesNotExist())
.andExpect(jsonPath("$.data.counterparties[?(@.publicId == '%s')]".formatted(counterpartyId)).doesNotExist())
.andExpect(jsonPath("$.data.costCategories[?(@.publicId == '%s')]".formatted(categoryId)).doesNotExist());
postJson("/api/v1/masterdata/bank-accounts", finance, """
{"ownerType":"COUNTERPARTY","ownerId":"%s","accountCategory":"GENERAL",
"accountName":"已停用主体账户","bankName":"测试银行","accountNo":"62229999%s","validFrom":"2026-01-01"}
""".formatted(counterpartyId, suffix))
.andExpect(status().isNotFound());
postJson("/api/v1/masterdata/cost-categories", finance,
"{\"code\":\"CHILD-%s\",\"name\":\"停用上级\",\"parentId\":\"%s\"}".formatted(suffix, categoryId))
.andExpect(status().isNotFound());
postJson("/api/v1/masterdata/contracts", finance, """
{"companyId":"%s","projectId":"%s","counterpartyId":"%s","businessNo":"DISABLED-%s",
"name":"已停用引用合同","originalAmount":"10.00","currency":"CNY"}
""".formatted(companyId, projectId, counterpartyId, suffix))
.andExpect(status().isNotFound());
}
private String createCompany(ClientSession session, String suffix) throws Exception {
MvcResult created = postJson("/api/v1/masterdata/companies", session, """
{"businessNo":"SCOPE-%s","name":"范围公司 %s","taxNo":"SCOPE-TAX-%s"}
""".formatted(suffix, suffix, suffix)).andExpect(status().isOk()).andReturn();
return JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
}
private String createActiveCompany(ClientSession creator, ClientSession reviewer, String businessNo,
String taxNo) throws Exception {
MvcResult created = postJson("/api/v1/masterdata/companies", creator, """
{"businessNo":"%s","name":"%s","taxNo":"%s"}
""".formatted(businessNo, businessNo, taxNo)).andExpect(status().isOk()).andReturn();
String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/companies/" + id + "/submit", creator, "{\"version\":0}")
.andExpect(status().isOk());
postJson("/api/v1/masterdata/companies/" + id + "/review", reviewer,
"{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"测试复核\"}")
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("ACTIVE"));
return id;
}
private String createActiveCounterparty(ClientSession creator, ClientSession reviewer, String businessNo,
String taxNo) throws Exception {
MvcResult created = postJson("/api/v1/masterdata/counterparties", creator, """
{"businessNo":"%s","type":"SUPPLIER","name":"%s","taxNo":"%s",
"contactName":"测试联系人","contactPhone":"13800000000"}
""".formatted(businessNo, businessNo, taxNo)).andExpect(status().isOk()).andReturn();
String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/counterparties/" + id + "/submit", creator, "{\"version\":0}")
.andExpect(status().isOk());
postJson("/api/v1/masterdata/counterparties/" + id + "/review", reviewer,
"{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"测试复核\"}")
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("ACTIVE"));
return id;
}
private String createProject(ClientSession session, String companyId, String businessNo) throws Exception {
String ownerId = jdbcTemplate.queryForObject(
"SELECT public_id FROM iam_user WHERE username = 'project'", String.class);
MvcResult created = postJson("/api/v1/projects", session, """
{"businessNo":"%s","name":"%s","companyId":"%s","ownerId":"%s","currency":"CNY"}
""".formatted(businessNo, businessNo, companyId, ownerId)).andExpect(status().isOk()).andReturn();
String projectId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
jdbcTemplate.update("UPDATE md_project SET status = 'ACTIVE' WHERE public_id = ?", projectId);
return projectId;
}
private String createActiveContract(ClientSession creator, ClientSession reviewer, String companyId, String projectId,
String counterpartyId, String suffix) throws Exception {
MvcResult created = postJson("/api/v1/masterdata/contracts", creator, """
{"companyId":"%s","projectId":"%s","counterpartyId":"%s","businessNo":"REF-CON-%s",
"name":"引用保护合同","originalAmount":"10.00","currency":"CNY"}
""".formatted(companyId, projectId, counterpartyId, suffix)).andExpect(status().isOk()).andReturn();
String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/contracts/" + id + "/submit", creator, "{\"version\":0}")
.andExpect(status().isOk());
postJson("/api/v1/masterdata/contracts/" + id + "/review", reviewer,
"{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"测试复核\"}")
.andExpect(status().isOk());
return id;
}
private String createActiveCostCategory(ClientSession creator, ClientSession reviewer, String suffix) throws Exception {
MvcResult created = postJson("/api/v1/masterdata/cost-categories", creator,
"{\"code\":\"REF-CAT-%s\",\"name\":\"引用保护类别\"}".formatted(suffix))
.andExpect(status().isOk()).andReturn();
String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/cost-categories/" + id + "/submit", creator, "{\"version\":0}")
.andExpect(status().isOk());
postJson("/api/v1/masterdata/cost-categories/" + id + "/review", reviewer,
"{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"测试复核\"}")
.andExpect(status().isOk());
return id;
}
private String createActiveBankAccount(ClientSession creator, ClientSession reviewer, String counterpartyId,
String suffix) throws Exception {
MvcResult created = postJson("/api/v1/masterdata/bank-accounts", creator, """
{"ownerType":"COUNTERPARTY","ownerId":"%s","accountCategory":"GENERAL",
"accountName":"引用保护账户","bankName":"测试银行","accountNo":"62220000%s","validFrom":"2026-01-01"}
""".formatted(counterpartyId, suffix)).andExpect(status().isOk()).andReturn();
String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId");
postJson("/api/v1/masterdata/bank-accounts/" + id + "/submit", creator, "{\"version\":0}")
.andExpect(status().isOk());
postJson("/api/v1/masterdata/bank-accounts/" + id + "/review", reviewer,
"{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"测试复核\"}")
.andExpect(status().isOk());
return id;
}
private Long dbId(String table, String publicId) {
return jdbcTemplate.queryForObject("SELECT id FROM " + table + " WHERE public_id = ?", Long.class, publicId);
}
private String uniquePublicId(String suffix, String prefix) {
return prefix.substring(0, 1) + "%025d".formatted(Integer.parseInt(suffix));
}
private void disable(String path, ClientSession session, long version) throws Exception {
postJson(path, session, "{\"version\":%d}".formatted(version))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DISABLED"))
.andExpect(jsonPath("$.data.version").value(version + 1));
}
private void setAmountLimit(String username, String permissionCode, String amountLimit) {
jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
SET scope.amount_limit = ?
WHERE user_account.username = ? AND role.code = 'FINANCE_MANAGER'
AND permission.code = ? AND scope.scope_type = 'GLOBAL'
""", amountLimit, username, permissionCode);
}
private org.springframework.test.web.servlet.ResultActions postJson(String path, ClientSession session,
String json) throws Exception {
return mockMvc.perform(post(path)
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken())
.contentType("application/json")
.content(json));
}
private org.springframework.test.web.servlet.ResultActions patchJson(String path, ClientSession session,
String json) throws Exception {
return mockMvc.perform(patch(path)
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken())
.contentType("application/json")
.content(json));
}
private ClientSession loginAndSelect(String username, String role) throws Exception {
MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN");
assertNotNull(csrfCookie);
String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token");
MvcResult login = mockMvc.perform(post("/api/v1/auth/login")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username)))
.andExpect(status().isOk()).andReturn();
Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION");
assertNotNull(sessionCookie);
MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(sessionCookie, csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"roleCode\":\"%s\"}".formatted(role)))
.andExpect(status().isOk()).andReturn();
Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION");
return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken);
}
private String nextSuffix() {
return "%06d".formatted(SEQUENCE.incrementAndGet());
}
private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) {
Cookie[] cookies() {
return new Cookie[]{sessionCookie, csrfCookie};
}
}
}
@@ -0,0 +1,149 @@
package com.kaidi.finance.migration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
class V0491ReceiptPreflightMigrationTest {
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_v0491_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@Test
void blocksDuplicateReceiptSourceBeforeV050ChangesSchema() {
DataSource dataSource = prepareSchema("v0491_duplicate");
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
seedReceiptSource(jdbc, 1, true, 2);
assertThrows(FlywayException.class, () -> migrateTo50(dataSource));
assertEquals(0, columnCount(jdbc, "source_version_id"));
assertEquals(0, indexCount(jdbc, "uk_fin_receipt_source_document"));
}
@Test
void blocksAmbiguousReceiptSourceVersionBeforeV050ChangesSchema() {
DataSource dataSource = prepareSchema("v0491_ambiguous");
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
seedReceiptSource(jdbc, 2, true, 1);
assertThrows(FlywayException.class, () -> migrateTo50(dataSource));
assertEquals(0, columnCount(jdbc, "source_version_id"));
assertEquals(0, indexCount(jdbc, "uk_fin_receipt_source_document"));
}
private DataSource prepareSchema(String schema) {
DataSource root = new DriverManagerDataSource(
MYSQL.getJdbcUrl(), "root", MYSQL.getPassword()
);
JdbcTemplate rootJdbc = new JdbcTemplate(root);
rootJdbc.execute("DROP DATABASE IF EXISTS " + schema);
rootJdbc.execute("CREATE DATABASE " + schema);
rootJdbc.execute("GRANT ALL PRIVILEGES ON `" + schema + "`.* TO 'kaidi'@'%'");
String schemaUrl = MYSQL.getJdbcUrl().replace("/kaidi_finance_v0491_test", "/" + schema);
DataSource dataSource = new DriverManagerDataSource(
schemaUrl, MYSQL.getUsername(), MYSQL.getPassword()
);
Flyway.configure()
.dataSource(dataSource)
.defaultSchema(schema)
.schemas(schema)
.target("49")
.load()
.migrate();
return dataSource;
}
private void migrateTo50(DataSource dataSource) {
Flyway.configure().dataSource(dataSource).target("50").load().migrate();
}
private void seedReceiptSource(JdbcTemplate jdbc, int versionCount,
boolean currentVersion, int receiptCount) {
jdbc.update("""
INSERT INTO iam_user (public_id, username, display_name, password_hash)
VALUES ('00000000000000000000491001', 'v0491-user', 'V049.1 user', 'unused')
""");
long userId = jdbc.queryForObject(
"SELECT id FROM iam_user WHERE username = 'v0491-user'", Long.class
);
jdbc.update("""
INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by)
VALUES ('00000000000000000000491002', 'V0491-COMPANY', 'V049.1 company', 'ACTIVE', ?, ?)
""", userId, userId);
long companyId = jdbc.queryForObject(
"SELECT id FROM md_company WHERE public_id = '00000000000000000000491002'", Long.class
);
long templateId = jdbc.queryForObject(
"SELECT id FROM src_template WHERE form_type = 'OA-05' AND template_version = 1", Long.class
);
jdbc.update("""
INSERT INTO src_source_document (
public_id, business_no, form_type, source_system, source_no,
normalized_source_no, source_version, normalized_source_version,
template_id, company_id, status, created_by, updated_by
) VALUES (
'00000000000000000000491003', 'V0491-SOURCE', 'OA-05', 'MANUAL', 'V0491-SOURCE',
'V0491-SOURCE', '1', '1', ?, ?, 'APPROVED', ?, ?
)
""", templateId, companyId, userId, userId);
long sourceId = jdbc.queryForObject(
"SELECT id FROM src_source_document WHERE public_id = '00000000000000000000491003'", Long.class
);
long latestVersionId = 0;
long previousVersionId = 0;
for (int version = 1; version <= versionCount; version++) {
String publicId = "00000000000000000000491" + String.format("%02d", version + 3);
jdbc.update("""
INSERT INTO src_document_version (
public_id, source_document_id, version_no, previous_version_id,
template_id, snapshot_sha256, created_by
) VALUES (?, ?, ?, NULLIF(?, 0), ?, REPEAT('a', 64), ?)
""", publicId, sourceId, version, previousVersionId, templateId, userId);
latestVersionId = jdbc.queryForObject(
"SELECT id FROM src_document_version WHERE public_id = ?", Long.class, publicId
);
previousVersionId = latestVersionId;
}
if (currentVersion) {
jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?",
latestVersionId, sourceId);
}
for (int receipt = 1; receipt <= receiptCount; receipt++) {
String suffix = String.format("%02d", receipt);
jdbc.update("""
INSERT INTO fin_receipt (
public_id, business_no, company_id, source_document_id,
receipt_date, amount, payer_name, created_by, updated_by
) VALUES (?, ?, ?, ?, '2026-08-14', 100.00, 'V049.1 payer', ?, ?)
""", "00000000000000000000492" + suffix, "V0491-RECEIPT-" + suffix,
companyId, sourceId, userId, userId);
}
}
private int columnCount(JdbcTemplate jdbc, String column) {
return jdbc.queryForObject("""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_receipt' AND column_name = ?
""", Integer.class, column);
}
private int indexCount(JdbcTemplate jdbc, String index) {
return jdbc.queryForObject("""
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'fin_receipt' AND index_name = ?
""", Integer.class, index);
}
}
@@ -0,0 +1,88 @@
package com.kaidi.finance.migration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
class V051MigrationIntegrationTest {
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_v051_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@Test
void upgradePreservesCurrencyFromExistingInvoiceProject() {
DataSource dataSource = new DriverManagerDataSource(
MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()
);
Flyway.configure().dataSource(dataSource).target("50").load().migrate();
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
jdbc.update("""
INSERT INTO iam_user (public_id, username, display_name, password_hash)
VALUES ('00000000000000000000510101', 'v051-user', 'V051 migration user', 'unused')
""");
long userId = jdbc.queryForObject(
"SELECT id FROM iam_user WHERE username = 'v051-user'", Long.class
);
jdbc.update("""
INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by)
VALUES ('00000000000000000000510102', 'V051-COMPANY', 'V051 company', 'ACTIVE', ?, ?)
""", userId, userId);
jdbc.update("""
INSERT INTO md_counterparty (
public_id, business_no, counterparty_type, name, status, created_by, updated_by
) VALUES (
'00000000000000000000510103', 'V051-CUSTOMER', 'CUSTOMER',
'V051 customer', 'ACTIVE', ?, ?
)
""", userId, userId);
jdbc.update("""
INSERT INTO md_project (
public_id, company_id, business_no, name, customer_id, owner_user_id,
currency, status, created_by, updated_by
) SELECT
'00000000000000000000510104', company.id, 'V051-PROJECT', 'V051 project',
customer.id, ?, 'USD', 'ACTIVE', ?, ?
FROM md_company company
JOIN md_counterparty customer ON customer.public_id = '00000000000000000000510103'
WHERE company.public_id = '00000000000000000000510102'
""", userId, userId, userId);
jdbc.update("""
INSERT INTO fin_invoice (
public_id, business_no, company_id, project_id, counterparty_id,
invoice_type, amount, tax_rate, requested_date, status, created_by, updated_by
) SELECT
'00000000000000000000510105', 'V051-INVOICE', company.id, project.id, customer.id,
'NORMAL', 125.00, 0.060000, '2026-08-14', 'DRAFT', ?, ?
FROM md_company company
JOIN md_project project ON project.company_id = company.id
JOIN md_counterparty customer ON customer.id = project.customer_id
WHERE company.public_id = '00000000000000000000510102'
AND project.public_id = '00000000000000000000510104'
""", userId, userId);
// V052 is the current head; target V051 so this test asserts the migration
// under test instead of becoming coupled to later feature migrations.
Flyway.configure().dataSource(dataSource).target("51").load().migrate();
assertEquals("USD", jdbc.queryForObject(
"SELECT currency FROM fin_invoice WHERE public_id = '00000000000000000000510105'",
String.class
));
assertEquals("051", jdbc.queryForObject(
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank DESC LIMIT 1",
String.class
));
}
}
@@ -0,0 +1,145 @@
package com.kaidi.finance.migration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
class V056SourceDocumentCurrentVersionIntegrityMigrationTest {
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_v056_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@Test
void blocksExistingCrossDocumentPointerBeforeConstraintIsInstalled() {
DataSource dataSource = prepareSchema("v056_cross_document");
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
SourceIds ids = seedDocuments(jdbc, "CROSS");
jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?",
ids.versionB(), ids.documentA());
assertThrows(FlywayException.class, () -> migrateTo56(dataSource));
assertEquals(0, constraintCount(jdbc, "fk_src_document_current_version_document"));
assertEquals("055", latestVersion(jdbc));
}
@Test
void rejectsCrossDocumentPointerAfterConstraintIsInstalled() {
DataSource dataSource = prepareSchema("v056_valid_document");
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
SourceIds ids = seedDocuments(jdbc, "VALID");
jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?",
ids.versionA(), ids.documentA());
jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?",
ids.versionB(), ids.documentB());
migrateTo56(dataSource);
assertEquals(1, constraintCount(jdbc, "fk_src_document_current_version_document"));
assertThrows(DataIntegrityViolationException.class,
() -> jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?",
ids.versionB(), ids.documentA()));
assertEquals(1, jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?",
ids.versionA(), ids.documentA()));
}
private DataSource prepareSchema(String schema) {
DataSource root = new DriverManagerDataSource(MYSQL.getJdbcUrl(), "root", MYSQL.getPassword());
JdbcTemplate rootJdbc = new JdbcTemplate(root);
rootJdbc.execute("DROP DATABASE IF EXISTS `" + schema + "`");
rootJdbc.execute("CREATE DATABASE `" + schema + "`");
rootJdbc.execute("GRANT ALL PRIVILEGES ON `" + schema + "`.* TO 'kaidi'@'%'");
String schemaUrl = MYSQL.getJdbcUrl().replace("/kaidi_finance_v056_test", "/" + schema);
DataSource dataSource = new DriverManagerDataSource(schemaUrl, MYSQL.getUsername(), MYSQL.getPassword());
Flyway.configure().dataSource(dataSource).defaultSchema(schema).schemas(schema)
.target("55").load().migrate();
return dataSource;
}
private void migrateTo56(DataSource dataSource) {
Flyway.configure().dataSource(dataSource).target("56").load().migrate();
}
private SourceIds seedDocuments(JdbcTemplate jdbc, String marker) {
String suffix = marker.toLowerCase();
jdbc.update("""
INSERT INTO iam_user (public_id, username, display_name, password_hash)
VALUES (?, ?, ?, 'unused')
""", "00000000000000000000560001", "v056-" + suffix, "V056 " + marker);
long userId = jdbc.queryForObject("SELECT id FROM iam_user WHERE username = ?",
Long.class, "v056-" + suffix);
jdbc.update("""
INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by)
VALUES (?, ?, ?, 'ACTIVE', ?, ?)
""", "00000000000000000000560002", "V056-" + marker + "-COMPANY", "V056 " + marker,
userId, userId);
long companyId = jdbc.queryForObject("SELECT id FROM md_company WHERE business_no = ?",
Long.class, "V056-" + marker + "-COMPANY");
long templateId = jdbc.queryForObject(
"SELECT id FROM src_template WHERE form_type = 'OA-05' AND template_version = 1 LIMIT 1",
Long.class);
long documentA = insertDocument(jdbc, userId, companyId, templateId, marker, "A");
long documentB = insertDocument(jdbc, userId, companyId, templateId, marker, "B");
long versionA = insertVersion(jdbc, userId, templateId, documentA, marker, "A");
long versionB = insertVersion(jdbc, userId, templateId, documentB, marker, "B");
return new SourceIds(documentA, documentB, versionA, versionB);
}
private long insertDocument(JdbcTemplate jdbc, long userId, long companyId, long templateId,
String marker, String suffix) {
String publicId = "0000000000000000000056030" + ("A".equals(suffix) ? "1" : "2");
String businessNo = "V056-" + marker + "-DOC-" + suffix;
jdbc.update("""
INSERT INTO src_source_document (
public_id, business_no, form_type, source_system, source_no,
normalized_source_no, source_version, normalized_source_version,
template_id, company_id, status, created_by, updated_by
) VALUES (?, ?, 'OA-05', 'MANUAL', ?, ?, '1', '1', ?, ?, 'APPROVED', ?, ?)
""", publicId, businessNo, businessNo, businessNo, templateId, companyId, userId, userId);
return jdbc.queryForObject("SELECT id FROM src_source_document WHERE business_no = ?",
Long.class, businessNo);
}
private long insertVersion(JdbcTemplate jdbc, long userId, long templateId, long documentId,
String marker, String suffix) {
String publicId = "0000000000000000000056031" + ("A".equals(suffix) ? "1" : "2");
String businessNo = "V056-" + marker + "-DOC-" + suffix;
jdbc.update("""
INSERT INTO src_document_version (
public_id, source_document_id, version_no, template_id, snapshot_sha256, created_by
) SELECT ?, id, 1, ?, REPEAT('a', 64), ?
FROM src_source_document WHERE business_no = ?
""", publicId, templateId, userId, businessNo);
return jdbc.queryForObject("SELECT id FROM src_document_version WHERE public_id = ?",
Long.class, publicId);
}
private int constraintCount(JdbcTemplate jdbc, String name) {
return jdbc.queryForObject("""
SELECT COUNT(*) FROM information_schema.table_constraints
WHERE table_schema = DATABASE() AND table_name = 'src_source_document'
AND constraint_name = ? AND constraint_type = 'FOREIGN KEY'
""", Integer.class, name);
}
private String latestVersion(JdbcTemplate jdbc) {
return jdbc.queryForObject(
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank DESC LIMIT 1",
String.class);
}
private record SourceIds(long documentA, long documentB, long versionA, long versionB) {
}
}
@@ -0,0 +1,108 @@
package com.kaidi.finance.migration;
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.assertThrows;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
class V057AccountingSourceEventKeyMigrationTest {
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_v057_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@Test
void sourceRevisionAndSequenceArePartOfTheUniqueEventKeyAndRuleTemplateIsSeeded() {
DataSource dataSource = prepareSchema("v057_source_event_key");
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
migrateTo58(dataSource);
assertEquals("058", latestVersion(jdbc));
assertEquals(1, jdbc.queryForObject("""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='acc_voucher'
AND column_name='rule_snapshot_json'
""", Integer.class));
assertEquals(1, jdbc.queryForObject("""
SELECT COUNT(*) FROM sys_parameter_version
WHERE parameter_group='ACCOUNTING_RULE_TEMPLATE'
AND status='ACTIVE' AND version_no=1
""", Integer.class));
long userId = seedCompany(jdbc);
insertEvent(jdbc, userId, "EV-1", "REV-1", 1);
insertEvent(jdbc, userId, "EV-2", "REV-2", 1);
String firstKey = jdbc.queryForObject(
"SELECT source_event_key FROM acc_event WHERE public_id='00000000000000000000000571'", String.class);
String secondKey = jdbc.queryForObject(
"SELECT source_event_key FROM acc_event WHERE public_id='00000000000000000000000572'", String.class);
assertNotNull(firstKey);
assertNotEquals(firstKey, secondKey);
assertThrows(DuplicateKeyException.class,
() -> insertEvent(jdbc, userId, "EV-DUP", "REV-1", 1));
}
private DataSource prepareSchema(String schema) {
DataSource root = new DriverManagerDataSource(MYSQL.getJdbcUrl(), "root", MYSQL.getPassword());
JdbcTemplate rootJdbc = new JdbcTemplate(root);
rootJdbc.execute("DROP DATABASE IF EXISTS `" + schema + "`");
rootJdbc.execute("CREATE DATABASE `" + schema + "`");
rootJdbc.execute("GRANT ALL PRIVILEGES ON `" + schema + "`.* TO 'kaidi'@'%'");
String schemaUrl = MYSQL.getJdbcUrl().replace("/kaidi_finance_v057_test", "/" + schema);
DataSource dataSource = new DriverManagerDataSource(schemaUrl, MYSQL.getUsername(), MYSQL.getPassword());
Flyway.configure().dataSource(dataSource).defaultSchema(schema).schemas(schema)
.target("56").load().migrate();
return dataSource;
}
private void migrateTo58(DataSource dataSource) {
Flyway.configure().dataSource(dataSource).target("58").load().migrate();
}
private long seedCompany(JdbcTemplate jdbc) {
jdbc.update("""
INSERT INTO iam_user (public_id, username, display_name, password_hash)
VALUES ('00000000000000000000000570', 'v057-user', 'V057 user', 'unused')
""");
long userId = jdbc.queryForObject("SELECT id FROM iam_user WHERE username='v057-user'", Long.class);
jdbc.update("""
INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by)
VALUES ('00000000000000000000000573', 'V057-COMPANY', 'V057 company', 'ACTIVE', ?, ?)
""", userId, userId);
return userId;
}
private void insertEvent(JdbcTemplate jdbc, long userId, String businessNo,
String sourceVersion, int sequence) {
jdbc.update("""
INSERT INTO acc_event (
public_id, business_no, event_type, source_type, source_public_id,
source_version, event_sequence, company_id, business_date, period,
amount, currency, status, created_by
) SELECT ?, ?, 'RECEIPT', 'RECEIPT', '00000000000000000000000574', ?, ?,
company.id, '2026-08-01', '2026-08', 1.00, 'CNY', 'PENDING', ?
FROM md_company company WHERE company.public_id='00000000000000000000000573'
""", "EV-1".equals(businessNo) ? "00000000000000000000000571"
: "EV-2".equals(businessNo) ? "00000000000000000000000572"
: "00000000000000000000000575", businessNo, sourceVersion, sequence, userId);
}
private String latestVersion(JdbcTemplate jdbc) {
return jdbc.queryForObject(
"SELECT version FROM flyway_schema_history WHERE success=TRUE ORDER BY installed_rank DESC LIMIT 1",
String.class);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,444 @@
package com.kaidi.finance.receivable;
import static org.hamcrest.Matchers.hasItem;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.jayway.jsonpath.JsonPath;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReceivableIntegrationTest {
private static String idempotencyKey() {
return "test-receivable-" + java.util.UUID.randomUUID();
}
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_receivable_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@DynamicPropertySource
static void database(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("finance.bootstrap.enabled", () -> true);
registry.add("finance.bootstrap.password", () -> "LocalOnly@123");
}
@Autowired MockMvc mockMvc;
@Autowired JdbcTemplate jdbcTemplate;
@BeforeEach
void seed() {
Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM md_company WHERE business_no = 'GS-P14'", Integer.class);
if (count != null && count > 0) return;
long userId = jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username = 'finance'", Long.class);
jdbcTemplate.update("INSERT INTO md_company (public_id,business_no,name,status,created_by,updated_by) VALUES ('00000000000000000000001401','GS-P14','收款测试公司','ACTIVE',?,?)", userId, userId);
jdbcTemplate.update("INSERT INTO md_counterparty (public_id,business_no,counterparty_type,name,status,created_by,updated_by) VALUES ('00000000000000000000001402','GY-P14','SUPPLIER','收款测试单位','ACTIVE',?,?)", userId, userId);
jdbcTemplate.update("INSERT INTO md_project (public_id,company_id,business_no,name,owner_user_id,currency,status,created_by,updated_by) SELECT '00000000000000000000001403',id,'XM-P14','收款测试项目',?,'CNY','ACTIVE',?,? FROM md_company WHERE business_no='GS-P14'", userId, userId, userId);
jdbcTemplate.update("INSERT INTO md_contract (public_id,company_id,project_id,counterparty_id,business_no,name,original_amount,approved_change_amount,currency,status,created_by,updated_by) SELECT '00000000000000000000001404',c.id,p.id,x.id,'HT-P14','收款测试合同',10000,0,'CNY','ACTIVE',?,? FROM md_company c JOIN md_project p ON p.company_id=c.id JOIN md_counterparty x ON x.public_id='00000000000000000000001402' WHERE c.business_no='GS-P14'", userId, userId);
}
@Test
void receiptAndInvoiceCommandsKeepStateAndAccountingEvent() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession demo = loginAndSelect("demo", "FINANCE_MANAGER");
String invoiceId = issueInvoice(finance, demo, "1000", "FP-P14-001", "2026-08-02");
String receivableId = jdbcTemplate.queryForObject(
"SELECT public_id FROM fin_receivable WHERE source_invoice_id = (SELECT id FROM fin_invoice WHERE public_id = ?)",
String.class, invoiceId);
assertNotNull(receivableId);
mockMvc.perform(get("/api/v1/receivables").param("status", "OPEN").cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[*].publicId", hasItem(receivableId)))
.andExpect(jsonPath("$.data[?(@.publicId == '%s')].outstandingAmount".formatted(receivableId), hasItem("1000.00")));
String body = """
{"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403","customerId":"00000000000000000000001402","receiptDate":"2026-08-01","amount":1000,"currency":"CNY","payerName":"测试客户","referenceNo":"BANK-14"}
""";
String receiptId = JsonPath.read(mockMvc.perform(post("/api/v1/receipts").cookie(demo.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", demo.token())
.contentType("application/json").content(body)).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId");
mockMvc.perform(get("/api/v1/receipts")
.param("counterpartyId", "00000000000000000000001402")
.param("page", "1").param("size", "20").cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[*].publicId", hasItem(receiptId)))
.andExpect(jsonPath("$.meta.totalElements").isNumber());
String confirmKey = idempotencyKey();
mockMvc.perform(post("/api/v1/receipts/{id}/confirm", receiptId).cookie(finance.cookies()).header("Idempotency-Key", confirmKey).header("X-XSRF-TOKEN", finance.token())
.contentType("application/json").content("{\"version\":0}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CONFIRMED"))
.andExpect(jsonPath("$.data.allowedActions", hasItem("ALLOCATE")))
.andExpect(jsonPath("$.data.allowedActions", hasItem("VOID_RECEIPT")));
mockMvc.perform(post("/api/v1/receipts/{id}/confirm", receiptId).cookie(finance.cookies()).header("Idempotency-Key", confirmKey).header("X-XSRF-TOKEN", finance.token())
.contentType("application/json").content("{\"version\":0}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CONFIRMED"));
assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM fund_ledger WHERE source_public_id=? AND entry_type='RECEIPT_CONFIRMED'",
Integer.class, receiptId));
mockMvc.perform(post("/api/v1/receipts/{id}/allocations", receiptId).cookie(finance.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", finance.token())
.contentType("application/json").content("{\"receivableId\":\"%s\",\"amount\":600,\"version\":1}".formatted(receivableId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.receipt.status").value("PARTIALLY_ALLOCATED"))
.andExpect(jsonPath("$.data.allocations[0].receivable.publicId").value(receivableId))
.andExpect(jsonPath("$.data.allocations[0].sequenceNo").value(1))
.andExpect(jsonPath("$.data.allocations[0].receivableOutstandingAmount").value("400.00"));
assertEquals(new java.math.BigDecimal("400.00"), jdbcTemplate.queryForObject(
"SELECT outstanding_amount FROM fin_receivable WHERE public_id = ?", java.math.BigDecimal.class, receivableId));
assertTrue(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_event WHERE source_public_id = ?", Integer.class, receiptId) > 0);
assertTrue(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_event WHERE source_public_id = ?", Integer.class, invoiceId) > 0);
assertEquals(new java.math.BigDecimal("1000.00"), jdbcTemplate.queryForObject(
"SELECT delta_amount FROM fund_ledger WHERE source_public_id=? AND entry_type='RECEIPT_CONFIRMED'",
java.math.BigDecimal.class, receiptId));
}
@Test
void receiptCommandsEnforceSeparationAllocationLimitAndVersion() throws Exception {
ClientSession submitter = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER");
String invoiceId = issueInvoice(submitter, reviewer, "100", "FP-P14-ALLOC-LIMIT", "2026-08-04");
String receivableId = jdbcTemplate.queryForObject(
"SELECT public_id FROM fin_receivable WHERE source_invoice_id = (SELECT id FROM fin_invoice WHERE public_id = ?)",
String.class, invoiceId);
ClientSession creator = loginAndSelect("demo", "FINANCE_MANAGER");
String receiptBody = """
{"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403","customerId":"00000000000000000000001402","receiptDate":"2026-08-04","amount":100,"currency":"CNY","payerName":"测试客户","referenceNo":"BANK-14-NEG"}
""";
String receiptId = JsonPath.read(mockMvc.perform(post("/api/v1/receipts").cookie(creator.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", creator.token()).contentType("application/json").content(receiptBody))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId");
mockMvc.perform(post("/api/v1/receipts/{id}/confirm", receiptId).cookie(creator.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", creator.token()).contentType("application/json")
.content("{\"version\":0}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("SOD_VIOLATION"));
ClientSession confirmer = loginAndSelect("finance", "FINANCE_MANAGER");
mockMvc.perform(post("/api/v1/receipts/{id}/confirm", receiptId).cookie(confirmer.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json")
.content("{\"version\":0}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("CONFIRMED"));
mockMvc.perform(post("/api/v1/receipts/{id}/allocations", receiptId).cookie(confirmer.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json")
.content("{\"receivableId\":\"%s\",\"amount\":100.01,\"version\":1}".formatted(receivableId)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(post("/api/v1/receipts/{id}/allocations", receiptId).cookie(confirmer.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json")
.content("{\"receivableId\":\"%s\",\"amount\":50,\"version\":0}".formatted(receivableId)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("VERSION_CONFLICT"));
String voidableId = createReceipt(creator, "BANK-14-VOID", "50", "2026-08-08");
mockMvc.perform(post("/api/v1/receipts/{id}/confirm", voidableId).cookie(confirmer.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json").content("{\"version\":0}"))
.andExpect(status().isOk());
mockMvc.perform(post("/api/v1/receipts/{id}/void", voidableId).cookie(confirmer.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json")
.content("{\"version\":1,\"reason\":\"重复到账,人工确认作废\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("VOID"))
.andExpect(jsonPath("$.data.voidReason").value("重复到账,人工确认作废"));
assertEquals("VOID", jdbcTemplate.queryForObject(
"SELECT status FROM acc_event WHERE source_public_id = ?", String.class, voidableId));
assertEquals(2, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM fund_ledger WHERE source_public_id=?", Integer.class, voidableId));
assertEquals(new java.math.BigDecimal("0.00"), jdbcTemplate.queryForObject(
"SELECT SUM(delta_amount) FROM fund_ledger WHERE source_public_id=?",
java.math.BigDecimal.class, voidableId));
}
@Test
void receiptAllocationsSupportManyToManySettlement() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession demo = loginAndSelect("demo", "FINANCE_MANAGER");
String firstInvoice = issueInvoice(finance, demo, "400", "FP-P14-MULTI-1", "2026-08-09");
String secondInvoice = issueInvoice(finance, demo, "600", "FP-P14-MULTI-2", "2026-08-10");
String firstReceivable = receivableForInvoice(firstInvoice);
String secondReceivable = receivableForInvoice(secondInvoice);
String firstReceipt = createReceipt(demo, "BANK-14-MULTI-1", "700", "2026-08-11");
confirmReceipt(firstReceipt, finance, 0);
allocate(firstReceipt, firstReceivable, "300", 1, finance)
.andExpect(jsonPath("$.data.receipt.status").value("PARTIALLY_ALLOCATED"));
allocate(firstReceipt, secondReceivable, "400", 2, finance)
.andExpect(jsonPath("$.data.receipt.status").value("ALLOCATED"));
String secondReceipt = createReceipt(demo, "BANK-14-MULTI-2", "300", "2026-08-12");
confirmReceipt(secondReceipt, finance, 0);
allocate(secondReceipt, firstReceivable, "100", 1, finance);
allocate(secondReceipt, secondReceivable, "200", 2, finance)
.andExpect(jsonPath("$.data.receipt.status").value("ALLOCATED"));
assertEquals(2, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM fin_receipt_allocation WHERE receivable_id = (SELECT id FROM fin_receivable WHERE public_id = ?)",
Integer.class, firstReceivable));
assertEquals(0, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM fin_receivable WHERE public_id IN (?, ?) AND status <> 'SETTLED'",
Integer.class, firstReceivable, secondReceivable));
}
@Test
void invoiceCommandsEnforceReviewSeparationAndPreventRepeatedRedLetter() throws Exception {
ClientSession submitter = loginAndSelect("finance", "FINANCE_MANAGER");
String invoiceBody = "{" +
"\"companyId\":\"00000000000000000000001401\",\"projectId\":\"00000000000000000000001403\",\"contractId\":\"00000000000000000000001404\"," +
"\"counterpartyId\":\"00000000000000000000001402\",\"invoiceType\":\"SPECIAL\",\"amount\":200,\"taxRate\":0.13,\"requestedDate\":\"2026-08-05\"}";
String invoiceId = JsonPath.read(mockMvc.perform(post("/api/v1/invoices").cookie(submitter.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", submitter.token()).contentType("application/json").content(invoiceBody))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId");
mockMvc.perform(post("/api/v1/invoices/{id}/submit", invoiceId).cookie(submitter.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", submitter.token()).contentType("application/json")
.content("{\"version\":0}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REVIEWING"));
mockMvc.perform(post("/api/v1/invoices/{id}/review", invoiceId).cookie(submitter.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", submitter.token()).contentType("application/json")
.content("{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"同人不应复核\"}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("SOD_VIOLATION"));
ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER");
mockMvc.perform(post("/api/v1/invoices/{id}/review", invoiceId).cookie(reviewer.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", reviewer.token()).contentType("application/json")
.content("{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"通过\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("RESULT_PENDING"));
mockMvc.perform(post("/api/v1/invoices/{id}/record-result", invoiceId).cookie(submitter.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", submitter.token()).contentType("application/json")
.content("{\"version\":2,\"invoiceNo\":\"FP-P14-NEG-001\",\"issuedDate\":\"2026-08-06\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("ISSUED"));
String redLetterId = JsonPath.read(mockMvc.perform(post("/api/v1/invoices/{id}/red-letter", invoiceId).cookie(reviewer.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", reviewer.token()).contentType("application/json")
.content("{\"version\":3,\"invoiceNo\":\"FP-P14-NEG-RED-001\",\"issuedDate\":\"2026-08-07\",\"reason\":\"测试红冲\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.originalInvoiceId").value(invoiceId))
.andReturn().getResponse().getContentAsString(), "$.data.publicId");
assertEquals("VOID", jdbcTemplate.queryForObject(
"SELECT status FROM fin_receivable WHERE source_invoice_id = (SELECT id FROM fin_invoice WHERE public_id = ?)",
String.class, invoiceId));
assertEquals("VOID", jdbcTemplate.queryForObject(
"SELECT status FROM acc_event WHERE source_public_id = ?", String.class, invoiceId));
assertEquals(0, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM acc_event WHERE source_public_id = ?", Integer.class, redLetterId));
String original = mockMvc.perform(get("/api/v1/invoices/{id}", invoiceId).cookie(reviewer.cookies()))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
Number currentVersion = JsonPath.read(original, "$.data.version");
mockMvc.perform(post("/api/v1/invoices/{id}/red-letter", invoiceId).cookie(reviewer.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", reviewer.token()).contentType("application/json")
.content("{\"version\":%d,\"invoiceNo\":\"FP-P14-NEG-RED-002\",\"issuedDate\":\"2026-08-07\",\"reason\":\"重复红冲\"}".formatted(currentVersion.longValue())))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION"));
}
@Test
void invoiceAndReceiptControlsRejectContractOverrunAndDuplicateExternalNumbers() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
ClientSession demo = loginAndSelect("demo", "FINANCE_MANAGER");
String overLimit = createInvoice(finance, "20000", "2026-08-13");
submitInvoice(overLimit, finance, 0);
mockMvc.perform(post("/api/v1/invoices/{id}/review", overLimit).cookie(demo.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", demo.token()).contentType("application/json")
.content("{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"额度测试\"}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("CONTRACT_LIMIT_EXCEEDED"));
issueInvoice(finance, demo, "50", "FP-P14-DUPLICATE", "2026-08-14");
String duplicateInvoice = createInvoice(finance, "50", "2026-08-15");
submitInvoice(duplicateInvoice, finance, 0);
reviewInvoice(duplicateInvoice, demo, 1);
mockMvc.perform(post("/api/v1/invoices/{id}/record-result", duplicateInvoice).cookie(finance.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", finance.token()).contentType("application/json")
.content("{\"version\":2,\"invoiceNo\":\"FP-P14-DUPLICATE\",\"issuedDate\":\"2026-08-15\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE"));
createReceipt(demo, "BANK-P14-DUPLICATE", "10", "2026-08-16");
String duplicateReceiptBody = """
{"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403","customerId":"00000000000000000000001402","receiptDate":"2026-08-16","amount":10,"currency":"CNY","payerName":"测试客户","referenceNo":"BANK-P14-DUPLICATE"}
""";
mockMvc.perform(post("/api/v1/receipts").cookie(demo.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", demo.token())
.contentType("application/json").content(duplicateReceiptBody))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE"));
}
@Test
void projectManagerCannotReadReceivableResources() throws Exception {
ClientSession project = loginAndSelect("project", "PROJECT_MANAGER");
mockMvc.perform(get("/api/v1/receipts").cookie(project.cookies()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("PERMISSION_DENIED"));
}
@Test
void receiptCommandsRequireAndEnforceIdempotencyKeys() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
String body = """
{"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403",
"customerId":"00000000000000000000001402","receiptDate":"2026-08-28","amount":88,
"currency":"CNY","payerName":"幂等测试客户","referenceNo":"BANK-P14-IDEM"}
""";
mockMvc.perform(post("/api/v1/receipts").cookie(finance.cookies())
.header("X-XSRF-TOKEN", finance.token()).contentType("application/json").content(body))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
String key = idempotencyKey();
MvcResult first = mockMvc.perform(post("/api/v1/receipts").cookie(finance.cookies())
.header("Idempotency-Key", key).header("X-XSRF-TOKEN", finance.token())
.contentType("application/json").content(body))
.andExpect(status().isOk()).andReturn();
String receiptId = JsonPath.read(first.getResponse().getContentAsString(), "$.data.publicId");
mockMvc.perform(post("/api/v1/receipts").cookie(finance.cookies())
.header("Idempotency-Key", key).header("X-XSRF-TOKEN", finance.token())
.contentType("application/json").content(body))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.publicId").value(receiptId));
assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM fin_receipt WHERE reference_no='BANK-P14-IDEM'", Integer.class));
mockMvc.perform(post("/api/v1/receipts").cookie(finance.cookies())
.header("Idempotency-Key", key).header("X-XSRF-TOKEN", finance.token())
.contentType("application/json").content(body.replace("88", "89")))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED"));
}
@Test
void exportsValidateStatusFiltersBeforeQueryingScopedRows() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
mockMvc.perform(get("/api/v1/receipts/export").param("status", "NOT_A_RECEIPT_STATE")
.cookie(finance.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(get("/api/v1/invoices/export").param("status", "NOT_AN_INVOICE_STATE")
.cookie(finance.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
}
private String createInvoice(ClientSession session, String amount, String requestedDate) throws Exception {
String body = """
{"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403","contractId":"00000000000000000000001404","counterpartyId":"00000000000000000000001402","invoiceType":"SPECIAL","amount":%s,"taxRate":0.13,"requestedDate":"%s"}
""".formatted(amount, requestedDate);
return JsonPath.read(mockMvc.perform(post("/api/v1/invoices").cookie(session.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", session.token()).contentType("application/json").content(body))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId");
}
private void submitInvoice(String invoiceId, ClientSession session, long version) throws Exception {
mockMvc.perform(post("/api/v1/invoices/{id}/submit", invoiceId).cookie(session.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", session.token()).contentType("application/json")
.content("{\"version\":%d}".formatted(version)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REVIEWING"));
}
private void reviewInvoice(String invoiceId, ClientSession reviewer, long version) throws Exception {
mockMvc.perform(post("/api/v1/invoices/{id}/review", invoiceId).cookie(reviewer.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", reviewer.token()).contentType("application/json")
.content("{\"version\":%d,\"decision\":\"APPROVE\",\"opinion\":\"通过\"}".formatted(version)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("RESULT_PENDING"));
}
private String issueInvoice(ClientSession submitter, ClientSession reviewer, String amount,
String invoiceNo, String issuedDate) throws Exception {
String invoiceId = createInvoice(submitter, amount, issuedDate);
submitInvoice(invoiceId, submitter, 0);
reviewInvoice(invoiceId, reviewer, 1);
mockMvc.perform(post("/api/v1/invoices/{id}/record-result", invoiceId).cookie(submitter.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", submitter.token()).contentType("application/json")
.content("{\"version\":2,\"invoiceNo\":\"%s\",\"issuedDate\":\"%s\"}".formatted(invoiceNo, issuedDate)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ISSUED"))
.andExpect(jsonPath("$.data.allowedActions", hasItem("RED_LETTER")));
return invoiceId;
}
private String createReceipt(ClientSession creator, String referenceNo, String amount,
String receiptDate) throws Exception {
String body = """
{"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403","customerId":"00000000000000000000001402","receiptDate":"%s","amount":%s,"currency":"CNY","payerName":"测试客户","referenceNo":"%s"}
""".formatted(receiptDate, amount, referenceNo);
return JsonPath.read(mockMvc.perform(post("/api/v1/receipts").cookie(creator.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", creator.token()).contentType("application/json").content(body))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId");
}
private void confirmReceipt(String receiptId, ClientSession confirmer, long version) throws Exception {
mockMvc.perform(post("/api/v1/receipts/{id}/confirm", receiptId).cookie(confirmer.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json")
.content("{\"version\":%d}".formatted(version)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CONFIRMED"));
}
private ResultActions allocate(String receiptId, String receivableId, String amount, long version,
ClientSession allocator) throws Exception {
return mockMvc.perform(post("/api/v1/receipts/{id}/allocations", receiptId).cookie(allocator.cookies())
.header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", allocator.token()).contentType("application/json")
.content("{\"receivableId\":\"%s\",\"amount\":%s,\"version\":%d}"
.formatted(receivableId, amount, version)))
.andExpect(status().isOk());
}
private String receivableForInvoice(String invoiceId) {
return jdbcTemplate.queryForObject(
"SELECT public_id FROM fin_receivable WHERE source_invoice_id = (SELECT id FROM fin_invoice WHERE public_id = ?)",
String.class, invoiceId);
}
private ClientSession loginAndSelect(String username, String role) throws Exception {
MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN");
assertNotNull(csrfCookie);
String token = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token");
MvcResult login = mockMvc.perform(post("/api/v1/auth/login").cookie(csrfCookie).header("X-XSRF-TOKEN", token)
.contentType("application/json").content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username)))
.andExpect(status().isOk()).andReturn();
Cookie session = login.getResponse().getCookie("KAIDI_SESSION");
assertNotNull(session);
MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role").cookie(session, csrfCookie).header("X-XSRF-TOKEN", token)
.contentType("application/json").content("{\"roleCode\":\"%s\"}".formatted(role))).andExpect(status().isOk()).andReturn();
Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION");
return new ClientSession(rotated == null ? session : rotated, csrfCookie, token);
}
private record ClientSession(Cookie session, Cookie csrf, String token) { Cookie[] cookies() { return new Cookie[]{session, csrf}; } }
}
@@ -0,0 +1,661 @@
package com.kaidi.finance.reporting;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.blankOrNullString;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.Matchers.not;
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.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.jayway.jsonpath.JsonPath;
import jakarta.servlet.http.Cookie;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.util.HexFormat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReportingIntegrationTest {
private static final String PROJECT_ID = "80000000000000000000000003";
private static final String CONTRACT_ID = "80000000000000000000000004";
private static final String RECEIPT_ID = "80000000000000000000000014";
private static final String PAYMENT_ID = "80000000000000000000000017";
private static final String WORKFLOW_TASK_ID = "80000000000000000000000009";
private static final String ARCHIVE_PACKAGE_ID = "80000000000000000000000022";
private static final String SOURCE_DOCUMENT_ID = "80000000000000000000000005";
private static final String[] REPORT_CODES = {"project-ledger", "contract-execution", "receipt-invoice",
"payment-progress", "workflow-duration", "archive-completeness"};
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_reporting_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("finance.bootstrap.enabled", () -> true);
registry.add("finance.bootstrap.password", () -> "LocalOnly@123");
}
@Autowired
MockMvc mockMvc;
@Autowired
JdbcTemplate jdbcTemplate;
@Test
@Sql("/reporting-fixture.sql")
void sixReportsCalculateTraceExportAndExpireScope() throws Exception {
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
DateRange month = currentMonth();
MvcResult project = report(finance, "project-ledger", month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].rowId").value(PROJECT_ID))
.andExpect(jsonPath("$.data[0].contractAmount").value("1100.00"))
.andExpect(jsonPath("$.data[0].receiptAmount").value("300.00"))
.andExpect(jsonPath("$.data[0].costAmount").value("600.00"))
.andExpect(jsonPath("$.data[0].paymentAmount").value("350.00"))
.andExpect(jsonPath("$.data[0].completedVoucherCount").value(1))
.andExpect(jsonPath("$.data[0].completeness").value("75.00"))
.andExpect(jsonPath("$.data[0].values").doesNotExist())
.andExpect(jsonPath("$.meta.definitionVersion").value("REPORT-DRAFT-V3"))
.andExpect(jsonPath("$.meta.filterHash", not(blankOrNullString())))
.andExpect(jsonPath("$.meta.summary.metricTotal").value("1100.00"))
.andExpect(jsonPath("$.meta.allowedActions", hasItem("EXPORT")))
.andReturn();
String projectHash = JsonPath.read(project.getResponse().getContentAsString(), "$.meta.filterHash");
MvcResult contract = report(finance, "contract-execution", month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].rowId").value(CONTRACT_ID))
.andExpect(jsonPath("$.data[0].invoicedAmount").value("200.00"))
.andExpect(jsonPath("$.data[0].pendingPayableAmount").value("75.00"))
.andExpect(jsonPath("$.data[0].payableAmount").value("600.00"))
.andExpect(jsonPath("$.data[0].paidAmount").value("150.00"))
.andExpect(jsonPath("$.data[0].availableAmount").value("750.00"))
.andExpect(jsonPath("$.meta.summary.metricTotal").value("900.00"))
.andReturn();
String contractHash = JsonPath.read(contract.getResponse().getContentAsString(), "$.meta.filterHash");
assertNotEquals(projectHash, contractHash);
report(finance, "receipt-invoice", month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[*].recordType", hasItems("RECEIPT", "INVOICE")))
.andExpect(jsonPath("$.data[*].status", hasItem("PARTIALLY_ALLOCATED")))
.andExpect(jsonPath("$.data[*].amount", hasItems("300.00", "200.00")))
.andExpect(jsonPath("$.data[*].outstandingAmount", hasItem("700.00")))
.andExpect(jsonPath("$.meta.summary.metricTotal").value("500.00"));
report(finance, "payment-progress", month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].rowId").value(PAYMENT_ID))
.andExpect(jsonPath("$.data[0].offlineResult").value("REFUND"))
.andExpect(jsonPath("$.data[0].resultAmount").value("350.00"))
.andExpect(jsonPath("$.data[0].status").value("PART_PAID"))
.andExpect(jsonPath("$.data[0].receiptFileCount").value(1))
.andExpect(jsonPath("$.data[0].accountingStatus").value("COMPLETED"));
report(finance, "workflow-duration", month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].rowId").value(WORKFLOW_TASK_ID))
.andExpect(jsonPath("$.data[0].completedAt").doesNotExist())
.andExpect(jsonPath("$.data[0].durationSeconds", greaterThanOrEqualTo(120)))
.andExpect(jsonPath("$.data[0].returnCount").value(1))
.andExpect(jsonPath("$.data[0].pendingTaskCount").value(1));
report(finance, "archive-completeness", month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].rowId").value(ARCHIVE_PACKAGE_ID))
.andExpect(jsonPath("$.data[0].packageVersion").value(2))
.andExpect(jsonPath("$.data[0].completeness").value("75.00"))
.andExpect(jsonPath("$.data[0].missingCount").value(1))
.andExpect(jsonPath("$.data[0].pendingCount").value(1))
.andExpect(jsonPath("$.data[0].notApplicableCount").value(1))
.andExpect(jsonPath("$.data[0].presentCount").value(1))
.andExpect(jsonPath("$.data[0].totalItemCount").value(4))
.andExpect(jsonPath("$.data[0].frozen").isBoolean())
.andExpect(jsonPath("$.meta.summary.metricTotal").value("75.00"));
drilldown(finance, "project-ledger", PROJECT_ID, month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.filterHash").value(projectHash))
.andExpect(jsonPath("$.data.definitionVersion").value("REPORT-DRAFT-V3"))
.andExpect(jsonPath("$.data.groups[*].code", hasItems("CONTRACT", "COST", "PAYMENT", "ARCHIVE")))
.andExpect(jsonPath("$.data.relatedRecords[*].objectType", hasItems("CONTRACT", "PAYABLE",
"PAYMENT_RESULT", "VOUCHER", "ARCHIVE_PACKAGE")))
.andExpect(jsonPath("$.data.sourceForms[0].publicId").value(SOURCE_DOCUMENT_ID))
.andExpect(jsonPath("$.data.approvals[0].action").value("APPROVE"))
.andExpect(jsonPath("$.data.attachments[0].fileId").value("80000000000000000000000023"));
assertEquals(1, jdbcTemplate.update("""
UPDATE fin_payable SET business_date = DATE_SUB(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 DAY)
WHERE public_id = '80000000000000000000000013'
"""));
drilldown(finance, "project-ledger", PROJECT_ID, month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.relatedRecords[*].publicId",
not(hasItem("80000000000000000000000013"))));
removeRolePermission("FINANCE_MANAGER", "archive:file:view");
drilldown(finance, "project-ledger", PROJECT_ID, month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.source.rowId").value(PROJECT_ID))
.andExpect(jsonPath("$.data.source.values").doesNotExist())
.andExpect(jsonPath("$.data.attachments.length()").value(0));
restoreRolePermission("FINANCE_MANAGER", "archive:file:view");
restrictFinanceArchiveFileViewToMismatchedProjectCompany();
drilldown(finance, "project-ledger", PROJECT_ID, month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.relatedRecords.length()", greaterThanOrEqualTo(1)))
.andExpect(jsonPath("$.data.attachments.length()").value(0));
restoreFinanceArchiveFileViewScope();
drilldown(finance, "receipt-invoice", RECEIPT_ID, month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.attachments.length()").value(0));
drilldown(finance, "payment-progress", PAYMENT_ID, month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.relatedRecords[0].objectType").value("PAYMENT_RESULT"))
.andExpect(jsonPath("$.data.relatedRecords.length()").value(3))
.andExpect(jsonPath("$.data.relatedRecords[*].publicId",
not(hasItem("80000000000000000000000033"))))
.andExpect(jsonPath("$.data.sourceForms[*].publicId", hasItem(SOURCE_DOCUMENT_ID)))
.andExpect(jsonPath("$.data.approvals[*].action", hasItem("APPROVE")))
.andExpect(jsonPath("$.data.attachments.length()").value(2));
drilldown(finance, "workflow-duration", WORKFLOW_TASK_ID, month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.sourceForms[0].exactVersion").value(true))
.andExpect(jsonPath("$.data.relatedRecords[0].status").value("RETURN"))
.andExpect(jsonPath("$.data.approvals[0].sourceVersionId").value("80000000000000000000000006"))
.andExpect(jsonPath("$.data.attachments.length()").value(1))
.andExpect(jsonPath("$.data.attachments[0].fileId").value("80000000000000000000000023"));
String prepareBody = """
{"dateFrom":"%s","dateTo":"%s","columns":["businessNo","contractAmount"],
"sort":"updatedAt,desc;rowId,asc","expectedFilterHash":"%s",
"expectedDefinitionVersion":"REPORT-DRAFT-V3"}
""".formatted(month.from(), month.to(), projectHash);
restrictFinanceReportExportToProject();
MvcResult prepared = mockMvc.perform(post("/api/v1/reports/project-ledger/exports/prepare")
.cookie(finance.cookies())
.header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content(prepareBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.definitionVersion").value("REPORT-DRAFT-V3"))
.andExpect(jsonPath("$.data.filterHash").value(projectHash))
.andExpect(jsonPath("$.data.status").value("PREPARED"))
.andExpect(jsonPath("$.data.estimatedRows").value(1))
.andExpect(jsonPath("$.data.sort").value("updatedAt,desc;rowId,asc"))
.andReturn();
String exportId = JsonPath.read(prepared.getResponse().getContentAsString(), "$.data.exportId");
assertEquals(0, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM export_log WHERE resource_type='REPORT_PROJECT_LEDGER'", Integer.class));
MvcResult export = mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(exportId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.exportId").value(exportId))
.andExpect(jsonPath("$.data.rowCount").value(1))
.andExpect(jsonPath("$.data.sha256", not(blankOrNullString())))
.andReturn();
String csv = JsonPath.read(export.getResponse().getContentAsString(), "$.data.content");
String exportSha256 = JsonPath.read(export.getResponse().getContentAsString(), "$.data.sha256");
String actualSha256 = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(csv.getBytes(StandardCharsets.UTF_8)));
assertEquals(actualSha256, exportSha256);
assertTrue(csv.contains("businessNo,contractAmount"));
assertTrue(csv.contains("XM-REPORT-001"));
Integer exportLogs = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM export_log WHERE resource_type = 'REPORT_PROJECT_LEDGER'", Integer.class);
assertNotNull(exportLogs);
assertEquals(1, exportLogs);
assertEquals("COMPLETED", jdbcTemplate.queryForObject(
"SELECT status FROM report_export_request WHERE public_id=?", String.class, exportId));
String viewScopeExportId = JsonPath.read(mockMvc.perform(
post("/api/v1/reports/project-ledger/exports/prepare")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json").content(prepareBody))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.exportId");
restrictFinanceReportViewToProject();
mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(viewScopeExportId)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("VERSION_CONFLICT"));
assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM export_log WHERE resource_type='REPORT_PROJECT_LEDGER'", Integer.class));
restoreFinanceReportViewScopes();
String exportScopeExportId = JsonPath.read(mockMvc.perform(
post("/api/v1/reports/project-ledger/exports/prepare")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json").content(prepareBody))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.exportId");
expireFinanceReportExportScopes();
mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(exportScopeExportId)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("VERSION_CONFLICT"));
assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM export_log WHERE resource_type='REPORT_PROJECT_LEDGER'", Integer.class));
restoreFinanceReportExportScopes();
String expiredExportId = JsonPath.read(mockMvc.perform(
post("/api/v1/reports/project-ledger/exports/prepare")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json").content(prepareBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.filterHash").value(projectHash))
.andExpect(jsonPath("$.data.estimatedRows").value(1))
.andReturn().getResponse().getContentAsString(), "$.data.exportId");
assertEquals(1, jdbcTemplate.update(
"UPDATE report_export_request SET expires_at=DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND) WHERE public_id=?",
expiredExportId));
mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(expiredExportId)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("VERSION_CONFLICT"));
assertEquals("EXPIRED", jdbcTemplate.queryForObject(
"SELECT status FROM report_export_request WHERE public_id=?", String.class, expiredExportId));
assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM export_log WHERE resource_type='REPORT_PROJECT_LEDGER'", Integer.class));
MvcResult sortedReceipt = mockMvc.perform(get("/api/v1/reports/receipt-invoice")
.param("dateFrom", month.from()).param("dateTo", month.to())
.param("sort", "businessNo,asc").param("page", "1").param("size", "20")
.cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.sort").value("businessNo,asc;rowId,asc"))
.andReturn();
String receiptHash = JsonPath.read(sortedReceipt.getResponse().getContentAsString(), "$.meta.filterHash");
String firstReceiptBusinessNo = JsonPath.read(sortedReceipt.getResponse().getContentAsString(),
"$.data[0].businessNo");
String receiptPrepareBody = """
{"dateFrom":"%s","dateTo":"%s","columns":["businessNo","projectName"],
"sort":"businessNo,asc","expectedFilterHash":"%s",
"expectedDefinitionVersion":"REPORT-DRAFT-V3"}
""".formatted(month.from(), month.to(), receiptHash);
String receiptExportId = JsonPath.read(mockMvc.perform(
post("/api/v1/reports/receipt-invoice/exports/prepare")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json").content(receiptPrepareBody))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.exportId");
String receiptCsv = JsonPath.read(mockMvc.perform(
post("/api/v1/reports/receipt-invoice/exports/confirm")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(receiptExportId)))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.content");
assertTrue(receiptCsv.contains("'="), "CSV must neutralize spreadsheet formula prefixes");
assertTrue(receiptCsv.split("\\R")[1].contains(firstReceiptBusinessNo),
"CSV order must match the frozen list sort");
mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm")
.cookie(finance.cookies())
.header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content("{\"exportId\":\"%s\",\"maskingConfirmed\":false}".formatted(exportId)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(exportId)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("VERSION_CONFLICT"));
mockMvc.perform(post("/api/v1/reports/project-ledger/exports/prepare")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content("{\"dateFrom\":\"%s\",\"dateTo\":\"%s\"}".formatted(month.from(), month.to())))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(get("/api/v1/reports/project-ledger")
.param("dateFrom", month.from()).param("dateTo", month.to())
.param("sort", "unknownField,asc").cookie(finance.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(post("/api/v1/reports/project-ledger/exports/prepare")
.cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken())
.contentType("application/json")
.content("""
{"dateFrom":"%s","dateTo":"%s","sort":"unknownField,asc",
"expectedFilterHash":"%s","expectedDefinitionVersion":"REPORT-DRAFT-V3"}
""".formatted(month.from(), month.to(), projectHash)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(get("/api/v1/reports/not-a-report").cookie(finance.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(get("/api/v1/reports/project-ledger")
.param("dateFrom", month.to()).param("dateTo", month.from())
.cookie(finance.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
mockMvc.perform(get("/api/v1/reports/project-ledger")
.param("status", "PAID").cookie(finance.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
restrictFinanceReportViewToMismatchedProjectCompany();
drilldown(finance, "project-ledger", PROJECT_ID, month)
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"));
for (String code : REPORT_CODES) {
report(finance, code, month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(0));
}
restoreFinanceReportViewScopes();
report(finance, "project-ledger", month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].rowId").value(PROJECT_ID));
insertMixedCurrencyContract();
report(finance, "contract-execution", month)
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
report(finance, "project-ledger", month)
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
expireFinanceReportViewScopes();
for (String code : REPORT_CODES) {
report(finance, code, month)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(0));
}
}
private org.springframework.test.web.servlet.ResultActions report(ClientSession session, String code,
DateRange month) throws Exception {
return mockMvc.perform(get("/api/v1/reports/{code}", code)
.param("dateFrom", month.from()).param("dateTo", month.to())
.param("page", "1").param("size", "20").cookie(session.cookies()));
}
private org.springframework.test.web.servlet.ResultActions drilldown(ClientSession session, String code,
String rowId, DateRange month)
throws Exception {
return mockMvc.perform(get("/api/v1/reports/{code}/{rowId}/drilldown", code, rowId)
.param("dateFrom", month.from()).param("dateTo", month.to()).cookie(session.cookies()));
}
private void expireFinanceReportViewScopes() {
int updated = jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
SET scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 SECOND),
scope.valid_to = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND)
WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'report:finance:view'
""");
assertTrue(updated > 0);
}
private void expireFinanceReportExportScopes() {
int updated = jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
SET scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 SECOND),
scope.valid_to = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND)
WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'report:finance:export'
""");
assertTrue(updated > 0);
}
private void restrictFinanceReportViewToMismatchedProjectCompany() {
int updated = jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
SET scope.scope_type = 'PROJECT',
scope.company_public_id = '80000000000000000000000030',
scope.project_public_id = '80000000000000000000000003',
scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND),
scope.valid_to = NULL
WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'report:finance:view'
""");
assertTrue(updated > 0);
}
private void restrictFinanceReportViewToProject() {
int updated = jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
SET scope.scope_type = 'PROJECT',
scope.company_public_id = '80000000000000000000000001',
scope.project_public_id = '80000000000000000000000003',
scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND),
scope.valid_to = NULL
WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'report:finance:view'
""");
assertTrue(updated > 0);
}
private void restrictFinanceReportExportToProject() {
int updated = jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
SET scope.scope_type = 'PROJECT',
scope.company_public_id = '80000000000000000000000001',
scope.project_public_id = '80000000000000000000000003',
scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND),
scope.valid_to = NULL
WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'report:finance:export'
""");
assertTrue(updated > 0);
assertEquals("GLOBAL", jdbcTemplate.queryForObject("""
SELECT scope.scope_type FROM iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'report:finance:view' LIMIT 1
""", String.class));
}
private void restoreFinanceReportViewScopes() {
int updated = jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
SET scope.scope_type = 'GLOBAL',
scope.company_public_id = NULL,
scope.project_public_id = NULL,
scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND),
scope.valid_to = NULL
WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'report:finance:view'
""");
assertTrue(updated > 0);
}
private void restoreFinanceReportExportScopes() {
int updated = jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
SET scope.scope_type = 'GLOBAL',
scope.company_public_id = NULL,
scope.project_public_id = NULL,
scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND),
scope.valid_to = NULL
WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'report:finance:export'
""");
assertTrue(updated > 0);
}
private void insertMixedCurrencyContract() {
Long financeId = jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username='finance'", Long.class);
assertNotNull(financeId);
assertEquals(1, jdbcTemplate.update("""
INSERT INTO md_contract
(public_id, company_id, project_id, counterparty_id, business_no, name, original_amount,
approved_change_amount, currency, status, created_by, updated_by)
SELECT '80000000000000000000000044', company.id, project.id, counterparty.id,
'HT-REPORT-USD-001', '报表混币种合同', 100.00, 0.00, 'USD', 'ACTIVE', ?, ?
FROM md_company company
JOIN md_project project ON project.public_id = ?
JOIN md_counterparty counterparty ON counterparty.public_id = ?
WHERE company.public_id = ?
""", financeId, financeId, PROJECT_ID, "80000000000000000000000002",
"80000000000000000000000001"));
}
private void restrictFinanceArchiveFileViewToMismatchedProjectCompany() {
int updated = jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
SET scope.scope_type = 'PROJECT',
scope.company_public_id = '80000000000000000000000030',
scope.project_public_id = '80000000000000000000000003',
scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND),
scope.valid_to = NULL
WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'archive:file:view'
""");
assertTrue(updated > 0);
}
private void restoreFinanceArchiveFileViewScope() {
int updated = jdbcTemplate.update("""
UPDATE iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
SET scope.scope_type = 'GLOBAL', scope.company_public_id = NULL,
scope.project_public_id = NULL,
scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND),
scope.valid_to = NULL
WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'archive:file:view'
""");
assertTrue(updated > 0);
}
private void removeRolePermission(String roleCode, String permissionCode) {
int deleted = jdbcTemplate.update("""
DELETE role_permission FROM iam_role_permission role_permission
JOIN iam_role role ON role.id = role_permission.role_id
JOIN iam_permission permission ON permission.id = role_permission.permission_id
WHERE role.code = ? AND permission.code = ?
""", roleCode, permissionCode);
assertEquals(1, deleted);
}
private void restoreRolePermission(String roleCode, String permissionCode) {
assertEquals(1, jdbcTemplate.update("""
INSERT INTO iam_role_permission (role_id, permission_id)
SELECT role.id, permission.id FROM iam_role role JOIN iam_permission permission
WHERE role.code = ? AND permission.code = ?
""", roleCode, permissionCode));
}
private ClientSession loginAndSelect(String username, String role) throws Exception {
MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN");
assertNotNull(csrfCookie);
String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token");
MvcResult login = mockMvc.perform(post("/api/v1/auth/login")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username)))
.andExpect(status().isOk()).andReturn();
Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION");
assertNotNull(sessionCookie);
MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(sessionCookie, csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"roleCode\":\"%s\"}".formatted(role)))
.andExpect(status().isOk()).andReturn();
Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION");
return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken);
}
private static DateRange currentMonth() {
LocalDate today = LocalDate.now();
return new DateRange(today.withDayOfMonth(1).toString(), today.withDayOfMonth(today.lengthOfMonth()).toString());
}
private record DateRange(String from, String to) {
}
private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) {
Cookie[] cookies() {
return new Cookie[]{sessionCookie, csrfCookie};
}
}
}
@@ -0,0 +1,141 @@
package com.kaidi.finance.setup;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.JsonNode;
import com.kaidi.setup.SetupApplication;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest(classes = SetupApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers(disabledWithoutDocker = true)
class SetupApplicationIntegrationTest {
private static final String SETUP_CODE = "SETUP-CODE-TEST";
private static final String SETUP_CODE_SHA256 =
"9158568c96987884c8141d363daceffda86bb17083054b36105934090aa7e166";
private static final Path STATE_ROOT = Path.of("target", "setup-integration-" + UUID.randomUUID());
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance")
.withUsername("kaidi")
.withPassword("setup-database-password");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("finance.setup.enabled", () -> true);
registry.add("finance.setup.token-sha256", () -> SETUP_CODE_SHA256);
registry.add("finance.setup.env-file", () -> STATE_ROOT.resolve("application.env").toString());
registry.add("finance.setup.marker-file", () -> STATE_ROOT.resolve("locked").toString());
registry.add("finance.setup.restart-after-complete", () -> false);
registry.add("spring.profiles.active", () -> "setup");
}
@Autowired
private TestRestTemplate rest;
@LocalServerPort
private int port;
@Test
void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception {
ResponseEntity<JsonNode> initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue();
Map<String, Object> database = databaseRequest("wrong-code");
ResponseEntity<JsonNode> denied = rest.postForEntity(url("/api/v1/setup/test-connection"),
new HttpEntity<>(database), JsonNode.class);
assertThat(denied.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(denied.getBody().path("code").asText()).isEqualTo("SETUP_CODE_INVALID");
database.put("setupCode", SETUP_CODE);
ResponseEntity<JsonNode> tested = rest.postForEntity(url("/api/v1/setup/test-connection"),
new HttpEntity<>(database), JsonNode.class);
assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue();
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue();
Map<String, Object> complete = new LinkedHashMap<>(database);
complete.put("adminUsername", "setup-admin");
complete.put("adminDisplayName", "首次安装管理员");
complete.put("adminPassword", "SetupAdmin@2026Strong");
complete.put("adminPasswordConfirmation", "SetupAdmin@2026Strong");
ResponseEntity<JsonNode> completed = rest.postForEntity(url("/api/v1/setup/complete"),
new HttpEntity<>(complete), JsonNode.class);
assertThat(completed.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(completed.getBody().path("data").path("completed").asBoolean()).isTrue();
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
try (ResultSet result = statement.executeQuery("""
SELECT COUNT(*)
FROM iam_user user_account
JOIN iam_user_role user_role ON user_role.user_id = user_account.id
JOIN iam_role role ON role.id = user_role.role_id
WHERE user_account.username = 'setup-admin' AND role.code = 'SYSTEM_ADMIN'
""")) {
result.next();
assertThat(result.getInt(1)).isEqualTo(1);
}
try (ResultSet result = statement.executeQuery("""
SELECT COUNT(*)
FROM iam_scope scope_grant
JOIN iam_user user_account ON user_account.id = scope_grant.user_id
WHERE user_account.username = 'setup-admin' AND scope_grant.scope_type = 'GLOBAL'
""")) {
result.next();
assertThat(result.getInt(1)).isGreaterThan(0);
}
}
String environment = Files.readString(STATE_ROOT.resolve("application.env"));
assertThat(environment).contains("FINANCE_SETUP_ENABLED=false")
.contains("DB_URL=\"jdbc:mysql://")
.contains("DB_USERNAME=\"" + MYSQL.getUsername() + "\"")
.doesNotContain("SetupAdmin@2026Strong")
.doesNotContain(SETUP_CODE);
assertThat(Files.exists(STATE_ROOT.resolve("locked"))).isTrue();
ResponseEntity<JsonNode> locked = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
assertThat(locked.getBody().path("data").path("required").asBoolean()).isFalse();
assertThat(locked.getBody().path("data").path("locked").asBoolean()).isTrue();
}
private Map<String, Object> databaseRequest(String setupCode) {
Map<String, Object> request = new LinkedHashMap<>();
request.put("setupCode", setupCode);
request.put("databaseType", "MYSQL");
request.put("host", MYSQL.getHost());
request.put("port", MYSQL.getFirstMappedPort());
request.put("database", MYSQL.getDatabaseName());
request.put("username", MYSQL.getUsername());
request.put("password", MYSQL.getPassword());
return request;
}
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
}
@@ -0,0 +1,59 @@
package com.kaidi.finance.setup;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.JsonNode;
import com.kaidi.setup.SetupApplication;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest(classes = SetupApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("setup")
class SetupContextSmokeTest {
private static final Path STATE_ROOT = Path.of("target", "setup-context-smoke-" + UUID.randomUUID());
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("finance.setup.enabled", () -> true);
registry.add("finance.setup.token-sha256", () ->
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
registry.add("finance.setup.env-file", () -> STATE_ROOT.resolve("application.env").toString());
registry.add("finance.setup.marker-file", () -> STATE_ROOT.resolve("locked").toString());
registry.add("finance.setup.restart-after-complete", () -> false);
}
@Autowired
private TestRestTemplate rest;
@LocalServerPort
private int port;
@Test
void startsWithoutBusinessDatabaseAndExposesOnlySetupStatus() {
ResponseEntity<JsonNode> status = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
assertThat(status.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(status.getBody()).isNotNull();
assertThat(status.getBody().path("data").path("required").asBoolean()).isTrue();
assertThat(status.getBody().path("data").path("supportedDatabaseTypes").toString())
.isEqualTo("[\"MYSQL\"]");
ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class);
assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
}
@@ -0,0 +1,59 @@
package com.kaidi.finance.shared.audit;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
class DeniedAccessAuditServiceTest {
@Test
void recordsOnlyOncePerRequest() {
DeniedAccessAuditWriter writer = org.mockito.Mockito.mock(DeniedAccessAuditWriter.class);
DeniedAccessAuditService service = new DeniedAccessAuditService(writer);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/restricted");
service.record(request, HttpStatus.FORBIDDEN, "PERMISSION_DENIED");
service.record(request, HttpStatus.FORBIDDEN, "DATA_SCOPE_DENIED");
verify(writer, times(1)).record("GET", "/api/v1/restricted", 403, "PERMISSION_DENIED");
}
@Test
void ignoresResponsesThatAreNotAuthenticationOrAuthorizationDenials() {
DeniedAccessAuditWriter writer = org.mockito.Mockito.mock(DeniedAccessAuditWriter.class);
DeniedAccessAuditService service = new DeniedAccessAuditService(writer);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/forms");
service.record(request, HttpStatus.UNPROCESSABLE_ENTITY, "VALIDATION_FAILED");
verifyNoInteractions(writer);
}
@Test
void auditFailureDoesNotReplaceTheOriginalDeniedResponse() {
DeniedAccessAuditWriter writer = org.mockito.Mockito.mock(DeniedAccessAuditWriter.class);
DeniedAccessAuditService service = new DeniedAccessAuditService(writer);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/restricted");
doThrow(new IllegalStateException("database unavailable"))
.when(writer).record("GET", "/api/v1/restricted", 403, "PERMISSION_DENIED");
assertDoesNotThrow(() -> service.record(request, HttpStatus.FORBIDDEN, "PERMISSION_DENIED"));
}
@Test
void doesNotDuplicateTheExistingLoginFailureAudit() {
DeniedAccessAuditWriter writer = org.mockito.Mockito.mock(DeniedAccessAuditWriter.class);
DeniedAccessAuditService service = new DeniedAccessAuditService(writer);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
service.record(request, HttpStatus.UNAUTHORIZED, "AUTH_BAD_CREDENTIALS");
verifyNoInteractions(writer);
}
}
@@ -0,0 +1,390 @@
package com.kaidi.finance.shared.file;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import jakarta.servlet.http.Cookie;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
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.Primary;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(FileIntegrationTest.ScannerTestConfiguration.class)
class FileIntegrationTest {
private static String idempotencyKey() {
return "test-file-upload-" + java.util.UUID.randomUUID();
}
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_file_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("finance.bootstrap.enabled", () -> true);
registry.add("finance.bootstrap.password", () -> "LocalOnly@123");
}
@Autowired
MockMvc mockMvc;
@Autowired
JdbcTemplate jdbcTemplate;
@Autowired
TestFileScanner fileScanner;
@Autowired
FileStorageProperties storageProperties;
@BeforeEach
void resetScanner() {
fileScanner.reset();
}
@Test
void uploadAndDownloadUseAuthenticatedRoleAndControlledPath() throws Exception {
ClientSession session = loginAndSelectProjectRole();
MockMultipartFile file = new MockMultipartFile("file", "evidence.csv", "text/csv",
"project,amount\nP-001,100.00\n".getBytes());
MvcResult upload = mockMvc.perform(multipart("/api/v1/files")
.file(file)
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.publicId").isNotEmpty())
.andExpect(jsonPath("$.data.sha256").isNotEmpty())
.andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE"))
.andReturn();
String publicId = com.jayway.jsonpath.JsonPath.read(upload.getResponse().getContentAsString(),
"$.data.publicId");
mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies()))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("evidence.csv")))
.andExpect(header().string("X-Content-Type-Options", "nosniff"));
}
@Test
void genericFileUrlsDoNotExposeAnotherUsersUnlinkedFile() throws Exception {
ClientSession uploader = loginAndSelect("project", "PROJECT_MANAGER");
MockMultipartFile file = new MockMultipartFile("file", "private.csv", "text/csv",
"private,amount\nP-PRIVATE,1.00\n".getBytes());
MvcResult upload = mockMvc.perform(multipart("/api/v1/files")
.file(file).cookie(uploader.cookies()).header("X-XSRF-TOKEN", uploader.csrfToken())
.header("Idempotency-Key", idempotencyKey()))
.andExpect(status().isOk()).andReturn();
String publicId = com.jayway.jsonpath.JsonPath.read(upload.getResponse().getContentAsString(),
"$.data.publicId");
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
mockMvc.perform(get("/api/v1/files/{id}", publicId).cookie(finance.cookies()))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED"));
mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(finance.cookies()))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED"));
}
@Test
void emptyAndUnsupportedFilesAreRejected() throws Exception {
ClientSession session = loginAndSelectProjectRole();
MockMultipartFile empty = new MockMultipartFile("file", "empty.csv", "text/csv", new byte[0]);
mockMvc.perform(multipart("/api/v1/files").file(empty).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("FILE_EMPTY"));
MockMultipartFile executable = new MockMultipartFile("file", "payload.exe",
"application/octet-stream", new byte[]{1, 2, 3});
mockMvc.perform(multipart("/api/v1/files").file(executable).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("FILE_TYPE_NOT_ALLOWED"));
}
@Test
void infectedFilesAreRejectedAndCannotBeDownloaded() throws Exception {
ClientSession session = loginAndSelectProjectRole();
fileScanner.result = FileScanner.ScanResult.infected("Eicar-Test-Signature FOUND");
MockMultipartFile file = new MockMultipartFile("file", "infected.csv", "text/csv",
"malicious-test-content".getBytes());
mockMvc.perform(multipart("/api/v1/files")
.file(file)
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("FILE_REJECTED"));
String publicId = latestFileId();
org.junit.jupiter.api.Assertions.assertEquals("REJECTED", latestScanStatus());
mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("FILE_REJECTED"));
}
@Test
void unavailableScannerQuarantinesFileAndBlocksDownload() throws Exception {
ClientSession session = loginAndSelectProjectRole();
fileScanner.result = FileScanner.ScanResult.unavailable("scanner offline");
MockMultipartFile file = new MockMultipartFile("file", "pending.csv", "text/csv",
"project,amount\nP-002,200.00\n".getBytes());
String key = idempotencyKey();
mockMvc.perform(multipart("/api/v1/files")
.file(file)
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", key))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE"));
String publicId = latestFileId();
org.junit.jupiter.api.Assertions.assertEquals("QUARANTINED", latestScanStatus());
mockMvc.perform(multipart("/api/v1/files")
.file(file)
.cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", key))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE"));
org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM file_object WHERE public_id=?", Integer.class, publicId));
org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM sys_idempotency_record WHERE idempotency_key=? AND state='COMPLETED' "
+ "AND response_status=503", Integer.class, key));
org.junit.jupiter.api.Assertions.assertEquals(1, fileScanner.calls());
mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies()))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE"));
String quarantinedStorageKey = jdbcTemplate.queryForObject(
"SELECT storage_key FROM file_object WHERE public_id=?", String.class, publicId);
org.junit.jupiter.api.Assertions.assertTrue(quarantinedStorageKey.startsWith(".quarantine/"));
String unavailableRescanKey = idempotencyKey();
mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", unavailableRescanKey))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE"));
mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", unavailableRescanKey))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE"));
org.junit.jupiter.api.Assertions.assertEquals(2, fileScanner.calls());
org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE object_public_id=? AND action_code='FILE_RESCAN' "
+ "AND result_code='UNAVAILABLE'", Integer.class, publicId));
fileScanner.result = FileScanner.ScanResult.clean("scanner recovered");
String rescanKey = idempotencyKey();
mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", rescanKey))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.publicId").value(publicId))
.andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE"))
.andExpect(jsonPath("$.data.scanDetail").value("scanner recovered"));
mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", rescanKey))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE"));
org.junit.jupiter.api.Assertions.assertEquals(3, fileScanner.calls());
org.junit.jupiter.api.Assertions.assertEquals("AVAILABLE", latestScanStatus());
String availableStorageKey = jdbcTemplate.queryForObject(
"SELECT storage_key FROM file_object WHERE public_id=?", String.class, publicId);
org.junit.jupiter.api.Assertions.assertFalse(availableStorageKey.startsWith(".quarantine/"));
mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies()))
.andExpect(status().isOk());
org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE object_public_id=? AND action_code='FILE_RESCAN' "
+ "AND result_code='SUCCESS'", Integer.class, publicId));
org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM sys_idempotency_record WHERE idempotency_key=? AND state='COMPLETED' "
+ "AND response_status=200", Integer.class, rescanKey));
mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", unavailableRescanKey))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE"));
org.junit.jupiter.api.Assertions.assertEquals(3, fileScanner.calls());
}
@Test
void infectedQuarantinedFileBecomesRejectedAfterRescan() throws Exception {
ClientSession session = loginAndSelectProjectRole();
fileScanner.result = FileScanner.ScanResult.unavailable("scanner offline");
MockMultipartFile file = new MockMultipartFile("file", "rescan-infected.csv", "text/csv",
"infected-after-recovery".getBytes());
mockMvc.perform(multipart("/api/v1/files").file(file).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey()))
.andExpect(status().isServiceUnavailable());
String publicId = latestFileId();
fileScanner.result = FileScanner.ScanResult.infected("Eicar-Test-Signature FOUND");
String rescanKey = idempotencyKey();
mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", rescanKey))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("FILE_REJECTED"));
mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", rescanKey))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("FILE_REJECTED"));
org.junit.jupiter.api.Assertions.assertEquals(2, fileScanner.calls());
org.junit.jupiter.api.Assertions.assertEquals("REJECTED", latestScanStatus());
mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("FILE_REJECTED"));
org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE object_public_id=? AND action_code='FILE_RESCAN' "
+ "AND result_code='REJECTED'", Integer.class, publicId));
org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM sys_idempotency_record WHERE idempotency_key=? AND state='COMPLETED' "
+ "AND response_status=422", Integer.class, rescanKey));
}
@Test
void pendingPromotionRecoversWhenFileWasMovedBeforeDatabaseCommit() throws Exception {
ClientSession session = loginAndSelectProjectRole();
fileScanner.result = FileScanner.ScanResult.unavailable("scanner offline");
MockMultipartFile file = new MockMultipartFile("file", "recover-promotion.csv", "text/csv",
"recoverable-content".getBytes());
mockMvc.perform(multipart("/api/v1/files").file(file).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey()))
.andExpect(status().isServiceUnavailable());
String publicId = latestFileId();
String sourceKey = jdbcTemplate.queryForObject(
"SELECT storage_key FROM file_object WHERE public_id=?", String.class, publicId);
String targetKey = java.time.LocalDate.now(java.time.ZoneOffset.UTC) + "/" + publicId + ".csv";
jdbcTemplate.update("UPDATE file_object SET promotion_storage_key=?, scan_detail='scanner recovered', "
+ "scanned_at=UTC_TIMESTAMP() WHERE public_id=?", targetKey, publicId);
Path source = storageProperties.rootPath().resolve(sourceKey);
Path target = storageProperties.rootPath().resolve(targetKey);
java.nio.file.Files.createDirectories(target.getParent());
java.nio.file.Files.move(source, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
int callsBeforeRecovery = fileScanner.calls();
mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies())
.header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE"));
org.junit.jupiter.api.Assertions.assertEquals(callsBeforeRecovery, fileScanner.calls());
org.junit.jupiter.api.Assertions.assertEquals("AVAILABLE", latestScanStatus());
org.junit.jupiter.api.Assertions.assertEquals(targetKey, jdbcTemplate.queryForObject(
"SELECT storage_key FROM file_object WHERE public_id=?", String.class, publicId));
org.junit.jupiter.api.Assertions.assertEquals(0, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM file_object WHERE public_id=? AND promotion_storage_key IS NOT NULL",
Integer.class, publicId));
mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies()))
.andExpect(status().isOk());
}
private String latestFileId() {
return jdbcTemplate.queryForObject("SELECT public_id FROM file_object ORDER BY id DESC LIMIT 1", String.class);
}
private String latestScanStatus() {
return jdbcTemplate.queryForObject("SELECT scan_status FROM file_object ORDER BY id DESC LIMIT 1", String.class);
}
private ClientSession loginAndSelectProjectRole() throws Exception {
return loginAndSelect("project", "PROJECT_MANAGER");
}
private ClientSession loginAndSelect(String username, String role) throws Exception {
MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf"))
.andExpect(status().isOk())
.andReturn();
Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN");
assertNotNull(csrfCookie, "CSRF endpoint must issue the readable CSRF cookie");
String csrfToken = com.jayway.jsonpath.JsonPath.read(csrf.getResponse().getContentAsString(),
"$.data.token");
MvcResult login = mockMvc.perform(post("/api/v1/auth/login")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username)))
.andExpect(status().isOk())
.andReturn();
Cookie session = login.getResponse().getCookie("KAIDI_SESSION");
assertNotNull(session, "login must issue the Spring Session cookie");
MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(session, csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"roleCode\":\"%s\"}".formatted(role)))
.andExpect(status().isOk())
.andReturn();
Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION");
return new ClientSession(rotated == null ? session : rotated, csrfCookie, csrfToken);
}
private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) {
Cookie[] cookies() {
return new Cookie[]{sessionCookie, csrfCookie};
}
}
@TestConfiguration(proxyBeanMethods = false)
static class ScannerTestConfiguration {
@Bean
@Primary
TestFileScanner testFileScanner() {
return new TestFileScanner();
}
}
static final class TestFileScanner implements FileScanner {
private final AtomicInteger calls = new AtomicInteger();
private ScanResult result = ScanResult.clean("test scanner: clean");
@Override
public ScanResult scan(Path path) {
calls.incrementAndGet();
return result;
}
int calls() {
return calls.get();
}
void reset() {
calls.set(0);
result = ScanResult.clean("test scanner: clean");
}
}
}
@@ -0,0 +1,40 @@
package com.kaidi.finance.shared.file;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.env.MapPropertySource;
class FileScannerConfigurationTest {
@Test
void localProfileAllowsTrustedFixtureUploadsWhenExternalScannerIsDisabled() {
try (AnnotationConfigApplicationContext context = context("local", false)) {
FileScanner.ScanResult result = context.getBean(FileScanner.class).scan(Path.of("fixture.pdf"));
assertEquals(FileScanner.Status.CLEAN, result.status());
}
}
@Test
void nonLocalProfileKeepsUploadsQuarantinedWhenScannerIsDisabled() {
try (AnnotationConfigApplicationContext context = context("production", false)) {
FileScanner.ScanResult result = context.getBean(FileScanner.class).scan(Path.of("fixture.pdf"));
assertEquals(FileScanner.Status.UNAVAILABLE, result.status());
}
}
private AnnotationConfigApplicationContext context(String profile, boolean scannerEnabled) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.getEnvironment().setActiveProfiles(profile);
context.getEnvironment().getPropertySources().addFirst(new MapPropertySource(
"test",
Map.of("finance.storage.scanner.enabled", Boolean.toString(scannerEnabled))
));
context.register(FileScannerConfiguration.class);
context.refresh();
return context;
}
}
@@ -0,0 +1,30 @@
package com.kaidi.finance.shared.security;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Base64;
import org.junit.jupiter.api.Test;
class FieldEncryptionKeyTest {
@Test
void rejectsTheKnownDevelopmentKeyWhenProductionGuardIsEnabled() {
assertThrows(IllegalStateException.class,
() -> FieldEncryptionKey.decode(FieldEncryptionKey.LOCAL_DEVELOPMENT_DEFAULT, true));
}
@Test
void acceptsAValidInjectedKey() {
byte[] expected = new byte[32];
for (int index = 0; index < expected.length; index++) expected[index] = (byte) index;
assertArrayEquals(expected, FieldEncryptionKey.decode(Base64.getEncoder().encodeToString(expected), true));
}
@Test
void rejectsMissingAndMalformedKeys() {
assertThrows(IllegalStateException.class, () -> FieldEncryptionKey.decode(null, true));
assertThrows(IllegalStateException.class, () -> FieldEncryptionKey.decode("not-base64", true));
assertThrows(IllegalStateException.class, () -> FieldEncryptionKey.decode("AA==", true));
}
}
@@ -0,0 +1,85 @@
package com.kaidi.finance.shared.security;
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 com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.finance.masterdata.application.BankAccountProtector;
import com.kaidi.finance.source.application.SensitiveValueCipher;
import java.io.IOException;
import java.util.Base64;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.BeansException;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
class ProductionFieldEncryptionConfigurationTest {
@Test
void productionProfileRejectsAMissingFieldEncryptionKey() {
assertThrows(BeansException.class, () -> refreshProductionContext(null));
}
@Test
void productionProfileRejectsTheLocalDevelopmentKey() {
assertThrows(BeansException.class,
() -> refreshProductionContext(FieldEncryptionKey.LOCAL_DEVELOPMENT_DEFAULT));
}
@Test
void productionProfileAcceptsAnInjectedAes256KeyForBothSensitiveComponents() {
byte[] key = new byte[32];
java.util.Arrays.fill(key, (byte) 0x5a);
String encodedKey = Base64.getEncoder().encodeToString(key);
try (AnnotationConfigApplicationContext context = refreshProductionContext(encodedKey)) {
assertTrue(context.getEnvironment().getProperty("finance.crypto.reject-default", Boolean.class));
assertTrue(context.containsBeanDefinition("bankAccountProtector"));
assertTrue(context.containsBeanDefinition("sensitiveValueCipher"));
assertFalse(encodedKey.equals(FieldEncryptionKey.LOCAL_DEVELOPMENT_DEFAULT));
}
}
private AnnotationConfigApplicationContext refreshProductionContext(String encodedKey) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
try {
context.getEnvironment().setActiveProfiles("production");
loadConfiguration(context, "application.yml", false);
loadConfiguration(context, "application-production.yml", true);
if (encodedKey != null) {
context.getEnvironment().getPropertySources().addFirst(new MapPropertySource(
"injected-field-key", Map.of("FIELD_ENCRYPTION_KEY", encodedKey)
));
}
context.registerBean(ObjectMapper.class, () -> new ObjectMapper());
context.register(BankAccountProtector.class, SensitiveValueCipher.class);
context.refresh();
return context;
} catch (RuntimeException exception) {
context.close();
throw exception;
}
}
private void loadConfiguration(AnnotationConfigApplicationContext context, String resource,
boolean highestPrecedence) {
try {
for (PropertySource<?> propertySource : new YamlPropertySourceLoader()
.load(resource, new ClassPathResource(resource))) {
if (highestPrecedence) {
context.getEnvironment().getPropertySources().addFirst(propertySource);
} else {
context.getEnvironment().getPropertySources().addLast(propertySource);
}
}
} catch (IOException exception) {
context.close();
throw new IllegalStateException("Failed to load " + resource, exception);
}
}
}
@@ -0,0 +1,269 @@
package com.kaidi.finance.update.application;
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.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.finance.iam.domain.FinancePrincipal;
import com.kaidi.finance.iam.domain.RoleAssignment;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.audit.AuditService;
import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext;
import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest;
import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SystemUpdateApplicationServiceTest {
@TempDir
Path tempDir;
@Test
void comparesStableAndPreviewVersionsWithoutAllowingDowngrades() {
assertTrue(SystemUpdateApplicationService.compareVersions("1.0.1", "1.0.0") > 0);
assertTrue(SystemUpdateApplicationService.compareVersions("1.0.0", "1.0.0-preview.9") > 0);
assertTrue(SystemUpdateApplicationService.compareVersions("1.0.0-preview.10", "1.0.0-preview.9") > 0);
assertTrue(SystemUpdateApplicationService.compareVersions("1.0.0-preview.1", "1.0.0-preview.2") < 0);
assertEquals(0, SystemUpdateApplicationService.compareVersions("2.4.6", "2.4.6"));
}
@Test
void resolvesPrivateGiteaLatestReleaseWithServerSideToken() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> {
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
String assetUrl = "http://127.0.0.1:" + server.getAddress().getPort()
+ "/assets/release-manifest.json";
byte[] body = ("""
{"tag_name":"v1.0.0-preview.2","assets":[
{"name":"release-manifest.json","browser_download_url":"%s"}
]}
""").formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.createContext("/assets/release-manifest.json", exchange -> {
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
byte[] body = """
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"publishedAt":"2026-08-16T00:00:00Z","releaseNotes":"Gitea Preview update"}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
Path inbox = Files.createDirectory(tempDir.resolve("gitea-inbox"));
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.1", null, null,
"http://127.0.0.1:" + server.getAddress().getPort()
+ "/api/v1/repos/ERP-Team/kaidi/releases/latest",
"read-only-token", inbox.resolve("request.json"), tempDir.resolve("gitea-status.json"),
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", "系统管理员", "系统治理"))));
SystemUpdateApplicationService service = new SystemUpdateApplicationService(properties, authorization,
identity, mock(AuditService.class), new ObjectMapper());
var checked = service.check();
assertTrue(checked.enabled());
assertEquals("1.0.0-preview.2", checked.latestVersion());
assertTrue(checked.allowedActions().contains("DOWNLOAD"));
} finally {
server.stop(0);
}
}
@Test
void rejectsPrivateGiteaAssetOnAnotherOriginWithoutSendingToken() throws Exception {
AtomicInteger assetRequests = new AtomicInteger();
HttpServer assetServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
assetServer.createContext("/release-manifest.json", exchange -> {
assetRequests.incrementAndGet();
exchange.sendResponseHeaders(204, -1);
exchange.close();
});
HttpServer apiServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
apiServer.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> {
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
String assetUrl = "http://127.0.0.1:" + assetServer.getAddress().getPort()
+ "/release-manifest.json";
byte[] body = ("""
{"assets":[{"name":"release-manifest.json","browser_download_url":"%s"}]}
""").formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
assetServer.start();
apiServer.start();
try {
Path inbox = Files.createDirectory(tempDir.resolve("cross-origin-inbox"));
SystemUpdateApplicationService service = serviceForGitea(apiServer, inbox,
tempDir.resolve("cross-origin-status.json"));
assertThrows(BusinessException.class, service::check);
assertEquals(0, assetRequests.get());
} finally {
apiServer.stop(0);
assetServer.stop(0);
}
}
private SystemUpdateApplicationService serviceForGitea(HttpServer server, Path inbox, Path statusFile) {
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.1", null, null,
"http://127.0.0.1:" + server.getAddress().getPort()
+ "/api/v1/repos/ERP-Team/kaidi/releases/latest",
"read-only-token", 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, mock(AuditService.class),
new ObjectMapper());
}
@Test
void preservesRollbackFailureWhileAProcessingRequestAwaitsRecovery() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/release-manifest.json", exchange -> {
byte[] body = """
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"publishedAt":"2026-08-16T00:00:00Z","releaseNotes":"Preview update"}
""".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
Path inbox = Files.createDirectory(tempDir.resolve("inbox"));
Path processing = Files.createDirectory(tempDir.resolve("processing"));
Files.writeString(processing.resolve("request.json"), """
{"version":"1.0.0-preview.2","requestedAt":"2026-08-16T00:00:00Z"}
""");
Path statusFile = tempDir.resolve("status.json");
Files.writeString(statusFile, """
{"state":"FAILED","message":"回滚未完成,更新服务将重试","targetVersion":"1.0.0-preview.2",
"updatedAt":"2026-08-16T00:01:00Z"}
""");
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.1", null,
"http://127.0.0.1:" + server.getAddress().getPort() + "/", 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", "系统管理员", "系统治理"))));
SystemUpdateApplicationService service = new SystemUpdateApplicationService(properties, authorization,
identity, mock(AuditService.class), new ObjectMapper());
var status = service.check();
assertEquals("FAILED", status.state());
assertEquals("回滚未完成,更新服务将重试", status.message());
assertFalse(status.allowedActions().contains("INSTALL"));
} finally {
server.stop(0);
}
}
@Test
void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/release-manifest.json", exchange -> {
byte[] body = """
{"version":"1.0.0-preview.2+build.7","artifact":"kaidi-finance-1.0.0-preview.2+build.7.tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"publishedAt":"2026-08-16T00:00:00Z","releaseNotes":"Preview update"}
""".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
Path inbox = Files.createDirectory(tempDir.resolve("inbox"));
Path versionFile = tempDir.resolve("VERSION");
Files.writeString(versionFile, "1.0.0-preview.1\n");
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.1", versionFile,
"http://127.0.0.1:" + server.getAddress().getPort() + "/", null, null,
inbox.resolve("request.json"), tempDir.resolve("status.json"), 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", "系统管理员", "系统治理"))));
SystemUpdateApplicationService service = new SystemUpdateApplicationService(properties, authorization,
identity, mock(AuditService.class), new ObjectMapper());
var checked = service.check();
assertTrue(checked.updateAvailable());
assertEquals("1.0.0-preview.2+build.7", checked.latestVersion());
assertTrue(checked.allowedActions().contains("DOWNLOAD"));
assertFalse(checked.allowedActions().contains("INSTALL"));
var downloadQueued = service.download(new DownloadUpdateRequest("1.0.0-preview.2+build.7"));
assertEquals("DOWNLOAD_QUEUED", downloadQueued.state());
String downloadRequest = Files.readString(inbox.resolve("request.json"));
assertTrue(downloadRequest.contains("1.0.0-preview.2+build.7"));
assertTrue(downloadRequest.contains("\"action\":\"DOWNLOAD\""));
assertFalse(Files.isSymbolicLink(inbox.resolve("request.json")));
assertEquals("DOWNLOAD_QUEUED", service.status().state());
assertThrows(BusinessException.class,
() -> service.download(new DownloadUpdateRequest("1.0.0-preview.2+build.7")));
Files.delete(inbox.resolve("request.json"));
Files.writeString(tempDir.resolve("status.json"), """
{"state":"READY","message":"下载及校验完成","targetVersion":"1.0.0-preview.2+build.7",
"updatedAt":"2026-08-16T00:02:00Z"}
""");
var ready = service.status();
assertTrue(ready.allowedActions().contains("INSTALL"));
assertFalse(ready.allowedActions().contains("DOWNLOAD"));
var installQueued = service.install(new InstallUpdateRequest("1.0.0-preview.2+build.7", "上线 Preview"));
assertEquals("INSTALL_QUEUED", installQueued.state());
String installRequest = Files.readString(inbox.resolve("request.json"));
assertTrue(installRequest.contains("\"action\":\"INSTALL\""));
assertTrue(installRequest.contains("上线 Preview"));
} finally {
server.stop(0);
}
}
}
@@ -0,0 +1,329 @@
package com.kaidi.finance.workbench;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.jayway.jsonpath.JsonPath;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class WorkbenchIntegrationTest {
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("kaidi_finance_workbench_test")
.withUsername("kaidi")
.withPassword("kaidi_test_password");
@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("finance.bootstrap.enabled", () -> true);
registry.add("finance.bootstrap.password", () -> "LocalOnly@123");
}
@Autowired
MockMvc mockMvc;
@Autowired
JdbcTemplate jdbcTemplate;
@Test
void eachRoleOnlyReadsItsOwnRealWorkbench() throws Exception {
String deniedRequestId = "01J0000000000000000000000H";
removeLegacyWorkbenchPermissions();
ClientSession project = loginAndSelect("project", "PROJECT_MANAGER");
mockMvc.perform(get("/api/v1/workbenches/project").cookie(project.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.roleCode").value("PROJECT_MANAGER"))
.andExpect(jsonPath("$.data.metrics.length()").value(4))
.andExpect(jsonPath("$.data.metrics[0].value").value(0))
.andExpect(jsonPath("$.data.metrics[?(@.code == 'RETURNED_FORMS')].targetRoute")
.value(org.hamcrest.Matchers.contains("/forms?status=RETURNED&createdByMe=true")))
.andExpect(jsonPath("$.data.metrics[?(@.code == 'DUE_SOON_TASKS')].targetRoute")
.value(org.hamcrest.Matchers.contains("/tasks?view=TODO")));
mockMvc.perform(get("/api/v1/workbenches/finance")
.header("X-Request-Id", deniedRequestId)
.cookie(project.cookies()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("PERMISSION_DENIED"))
.andExpect(jsonPath("$.requestId").value(deniedRequestId));
Integer deniedAuditCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM audit_log
WHERE request_id = ?
AND user_public_id = ?
AND username = 'project'
AND active_role = 'PROJECT_MANAGER'
AND action_code = 'HTTP_ACCESS_DENIED'
AND object_type = 'HTTP_ENDPOINT'
AND result_code = 'DENIED'
AND reason = 'PERMISSION_DENIED'
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.method')) = 'GET'
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.uri')) = '/api/v1/workbenches/finance'
AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.status')) = '403'
""", Integer.class, deniedRequestId, userPublicId("project"));
assertEquals(1, deniedAuditCount);
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
mockMvc.perform(get("/api/v1/workbenches/finance").cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.roleCode").value("FINANCE_MANAGER"))
.andExpect(jsonPath("$.data.metrics.length()").value(4));
ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER");
mockMvc.perform(get("/api/v1/workbenches/archive").cookie(archive.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.roleCode").value("ARCHIVE_MANAGER"))
.andExpect(jsonPath("$.data.metrics.length()").value(5))
.andExpect(jsonPath("$.data.metrics[?(@.code == 'MISSING_ARCHIVE_ITEMS')].targetRoute")
.value(org.hamcrest.Matchers.contains("/archives/projects?completeness=INCOMPLETE")))
.andExpect(jsonPath("$.data.metrics[?(@.code == 'BORROWS_DUE')].targetRoute")
.value(org.hamcrest.Matchers.contains("/archives/files?resource=borrows")))
.andExpect(jsonPath("$.data.metrics[?(@.code == 'QUARANTINED_FILES')].targetRoute")
.value(org.hamcrest.Matchers.contains("/archives/files?scanStatus=QUARANTINED")));
ClientSession administrator = loginAndSelect("admin", "SYSTEM_ADMIN");
mockMvc.perform(get("/api/v1/workbenches/project").cookie(administrator.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.roleCode").value("PROJECT_MANAGER"));
mockMvc.perform(get("/api/v1/workbenches/finance").cookie(administrator.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.roleCode").value("FINANCE_MANAGER"));
mockMvc.perform(get("/api/v1/workbenches/archive").cookie(administrator.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.roleCode").value("ARCHIVE_MANAGER"));
}
@Test
void workbenchesKeepDatabaseMetricsDetailsAndDrilldownsConsistent() throws Exception {
long projectUserId = userId("project");
long financeUserId = userId("finance");
String projectUserPublicId = userPublicId("project");
String companyId = "00000000000000000000004001";
String projectId = "00000000000000000000004002";
String packageId = "00000000000000000000004003";
String itemId = "00000000000000000000004004";
String fileId = "00000000000000000000004005";
String linkSeriesId = "00000000000000000000004006";
String auditId = "00000000000000000000004007";
String otherCompanyId = "00000000000000000000004009";
String otherProjectId = "00000000000000000000004010";
String otherPackageId = "00000000000000000000004011";
String otherItemId = "00000000000000000000004012";
jdbcTemplate.update("""
INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by)
VALUES (?, 'WB-C-001', '工作台测试公司', 'ACTIVE', ?, ?)
""", companyId, financeUserId, financeUserId);
jdbcTemplate.update("""
INSERT INTO md_project (public_id, company_id, business_no, name, owner_user_id, currency,
status, created_by, updated_by)
SELECT ?, id, 'WB-P-001', '工作台测试项目', ?, 'CNY', 'ACTIVE', ?, ?
FROM md_company WHERE public_id = ?
""", projectId, projectUserId, financeUserId, financeUserId, companyId);
jdbcTemplate.update("""
INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by)
VALUES (?, 'WB-C-002', '工作台范围外公司', 'ACTIVE', ?, ?)
""", otherCompanyId, financeUserId, financeUserId);
jdbcTemplate.update("""
INSERT INTO md_project (public_id, company_id, business_no, name, owner_user_id, currency,
status, created_by, updated_by)
SELECT ?, id, 'WB-P-002', '工作台范围外项目', ?, 'CNY', 'ACTIVE', ?, ?
FROM md_company WHERE public_id = ?
""", otherProjectId, projectUserId, financeUserId, financeUserId, otherCompanyId);
long projectDatabaseId = jdbcTemplate.queryForObject(
"SELECT id FROM md_project WHERE public_id = ?", Long.class, projectId);
long otherProjectDatabaseId = jdbcTemplate.queryForObject(
"SELECT id FROM md_project WHERE public_id = ?", Long.class, otherProjectId);
jdbcTemplate.update("""
INSERT INTO project_fund_control (project_id, currency, confirmed_receipt, confirmed_deduction,
approved_adjustment, frozen_amount, approved_unpaid, paid_amount)
VALUES (?, 'CNY', 1000.00, 100.00, 50.00, 200.00, 300.00, 150.00)
""", projectDatabaseId);
jdbcTemplate.update("""
INSERT INTO arc_package (public_id, business_no, company_id, project_id, package_version,
completeness, missing_count, retention_years, status, created_by, updated_by)
VALUES (?, 'WB-ARC-001', (SELECT id FROM md_company WHERE public_id = ?), ?, 1,
50.00, 1, 10, 'CHECKED', ?, ?)
""", packageId, companyId, projectDatabaseId, financeUserId, financeUserId);
long packageDatabaseId = jdbcTemplate.queryForObject(
"SELECT id FROM arc_package WHERE public_id = ?", Long.class, packageId);
jdbcTemplate.update("""
INSERT INTO arc_package_item (public_id, package_id, item_type, item_name, required_flag, status)
VALUES (?, ?, 'CONTRACT', '缺失合同', TRUE, 'MISSING')
""", itemId, packageDatabaseId);
jdbcTemplate.update("""
INSERT INTO arc_package (public_id, business_no, company_id, project_id, package_version,
completeness, missing_count, retention_years, status, created_by, updated_by)
VALUES (?, 'WB-ARC-002', (SELECT id FROM md_company WHERE public_id = ?), ?, 1,
50.00, 1, 10, 'CHECKED', ?, ?)
""", otherPackageId, otherCompanyId, otherProjectDatabaseId, financeUserId, financeUserId);
long otherPackageDatabaseId = jdbcTemplate.queryForObject(
"SELECT id FROM arc_package WHERE public_id = ?", Long.class, otherPackageId);
jdbcTemplate.update("""
INSERT INTO arc_package_item (public_id, package_id, item_type, item_name, required_flag, status)
VALUES (?, ?, 'CONTRACT', '范围外缺失合同', TRUE, 'MISSING')
""", otherItemId, otherPackageDatabaseId);
jdbcTemplate.update("""
INSERT INTO file_object (public_id, original_name, storage_key, media_type, extension, size_bytes,
sha256, scan_status, uploaded_by)
VALUES (?, 'recent-download.pdf', 'workbench/recent-download.pdf', 'application/pdf', 'pdf', 1,
RPAD('a', 64, 'a'), 'AVAILABLE', ?)
""", fileId, projectUserPublicId);
long fileDatabaseId = jdbcTemplate.queryForObject(
"SELECT id FROM file_object WHERE public_id = ?", Long.class, fileId);
jdbcTemplate.update("""
INSERT INTO file_link (file_id, object_type, object_public_id, company_public_id, project_public_id,
document_type, version_no, active, linked_by, archive_status, series_public_id)
VALUES (?, 'PROJECT', ?, ?, ?, 'PROJECT_BASE', 1, TRUE, ?, 'ACTIVE', ?)
""", fileDatabaseId, projectId, companyId, projectId, projectUserPublicId, linkSeriesId);
jdbcTemplate.update("""
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)
VALUES (?, '00000000000000000000004008', ?, 'archive', 'ARCHIVE_MANAGER', ?, ?,
'ARCHIVE_FILE_DOWNLOAD', 'FILE', ?, 'SUCCESS')
""", auditId, userPublicId("archive"), companyId, projectId, fileId);
restrictScope(projectUserId, "PROJECT_MANAGER", "project:project:view", projectId);
restrictScope(projectUserId, "PROJECT_MANAGER", "archive:package:view", projectId);
ClientSession project = loginAndSelect("project", "PROJECT_MANAGER");
mockMvc.perform(get("/api/v1/workbenches/project").cookie(project.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.metrics[?(@.code == 'ACTIVE_PROJECTS')].value")
.value(org.hamcrest.Matchers.contains(1)))
.andExpect(jsonPath("$.data.issues[?(@.code == 'MISSING_ARCHIVE_ITEMS')].count")
.value(org.hamcrest.Matchers.contains(1)))
.andExpect(jsonPath("$.data.issues[?(@.code == 'MISSING_ARCHIVE_ITEMS')].targetRoute")
.value(org.hamcrest.Matchers.contains("/archives/projects?completeness=INCOMPLETE")));
ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER");
mockMvc.perform(get("/api/v1/workbenches/finance").cookie(finance.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.funds[?(@.projectPublicId == '%s')].availableAmount".formatted(projectId))
.value(org.hamcrest.Matchers.contains("300.00")))
.andExpect(jsonPath("$.data.funds[?(@.projectPublicId == '%s')].targetRoute".formatted(projectId))
.value(org.hamcrest.Matchers.contains(
"/finance/payments?projectId=" + projectId + "&view=fund-ledger")));
ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER");
mockMvc.perform(get("/api/v1/workbenches/archive").cookie(archive.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.metrics[?(@.code == 'MISSING_ARCHIVE_ITEMS')].value")
.value(org.hamcrest.Matchers.contains(2)))
.andExpect(jsonPath("$.data.archiveItems[?(@.publicId == '%s')].kind".formatted(itemId))
.value(org.hamcrest.Matchers.contains("MISSING")))
.andExpect(jsonPath("$.data.archiveItems[?(@.publicId == '%s')].kind".formatted(auditId))
.value(org.hamcrest.Matchers.contains("DOWNLOAD")));
ClientSession administrator = loginAndSelect("admin", "SYSTEM_ADMIN");
mockMvc.perform(get("/api/v1/workbenches/project").cookie(administrator.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.metrics[?(@.code == 'ACTIVE_PROJECTS')].value")
.value(org.hamcrest.Matchers.contains(2)))
.andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(projectId)).exists())
.andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(otherProjectId)).exists());
mockMvc.perform(get("/api/v1/workbenches/finance").cookie(administrator.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.funds[?(@.projectPublicId == '%s')]".formatted(projectId)).exists());
mockMvc.perform(get("/api/v1/workbenches/archive").cookie(administrator.cookies()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.metrics[?(@.code == 'MISSING_ARCHIVE_ITEMS')].value")
.value(org.hamcrest.Matchers.contains(2)))
.andExpect(jsonPath("$.data.archiveItems[?(@.publicId == '%s')]".formatted(itemId)).exists())
.andExpect(jsonPath("$.data.archiveItems[?(@.publicId == '%s')]".formatted(otherItemId)).exists());
}
private void removeLegacyWorkbenchPermissions() {
jdbcTemplate.update("""
DELETE scope FROM iam_scope scope
JOIN iam_user account ON account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE (account.username = 'project' AND role.code = 'PROJECT_MANAGER'
AND permission.code = 'workflow:task:read')
OR (account.username = 'finance' AND role.code = 'FINANCE_MANAGER'
AND permission.code = 'workflow:task:read')
OR (account.username = 'archive' AND role.code = 'ARCHIVE_MANAGER'
AND permission.code = 'archive:read')
""");
}
private long userId(String username) {
return jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username = ?", Long.class, username);
}
private String userPublicId(String username) {
return jdbcTemplate.queryForObject("SELECT public_id FROM iam_user WHERE username = ?", String.class,
username);
}
private void restrictScope(long userId, String roleCode, String permissionCode, String projectId) {
jdbcTemplate.update("""
DELETE scope FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = ? AND role.code = ? AND permission.code = ?
""", userId, roleCode, permissionCode);
jdbcTemplate.update("""
INSERT INTO iam_scope (user_id, role_id, permission_id, scope_type, project_public_id, status)
SELECT ?, role.id, permission.id, 'PROJECT', ?, 'ACTIVE'
FROM iam_role role JOIN iam_permission permission
WHERE role.code = ? AND permission.code = ?
""", userId, projectId, roleCode, permissionCode);
}
private ClientSession loginAndSelect(String username, String role) throws Exception {
MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN");
assertNotNull(csrfCookie);
String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token");
MvcResult login = mockMvc.perform(post("/api/v1/auth/login")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username)))
.andExpect(status().isOk()).andReturn();
Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION");
assertNotNull(sessionCookie);
MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role")
.cookie(sessionCookie, csrfCookie)
.header("X-XSRF-TOKEN", csrfToken)
.contentType("application/json")
.content("{\"roleCode\":\"%s\"}".formatted(role)))
.andExpect(status().isOk()).andReturn();
Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION");
return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie);
}
private record ClientSession(Cookie sessionCookie, Cookie csrfCookie) {
Cookie[] cookies() {
return new Cookie[]{sessionCookie, csrfCookie};
}
}
}
@@ -0,0 +1,4 @@
finance:
crypto:
field-key: ${FIELD_ENCRYPTION_KEY:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
reject-default: false
@@ -0,0 +1,422 @@
SET @finance_id = (SELECT id FROM iam_user WHERE username = 'finance');
SET @project_user_id = (SELECT id FROM iam_user WHERE username = 'project');
SET @finance_public_id = (SELECT public_id FROM iam_user WHERE username = 'finance');
SET @template_id = (SELECT id FROM src_template WHERE form_type = 'OA-06' AND status = 'ACTIVE' ORDER BY template_version DESC LIMIT 1);
SET @payable_template_id = (SELECT id FROM src_template WHERE form_type = 'OA-04' AND status = 'ACTIVE' ORDER BY template_version DESC LIMIT 1);
SET @definition_version_id = (SELECT version.id FROM wf_definition_version version JOIN wf_definition definition ON definition.id = version.definition_id WHERE definition.code = 'SOURCE_STANDARD_REVIEW' AND version.status = 'ACTIVE' LIMIT 1);
INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by)
VALUES ('80000000000000000000000001', 'GS-REPORT-001', '报表集成测试公司', 'ACTIVE', @finance_id, @finance_id);
INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by)
VALUES ('80000000000000000000000030', 'GS-REPORT-002', '报表范围错配公司', 'ACTIVE', @finance_id, @finance_id);
INSERT INTO md_counterparty
(public_id, business_no, counterparty_type, name, status, created_by, updated_by)
VALUES ('80000000000000000000000002', 'GY-REPORT-001', 'BOTH', '报表集成测试往来单位', 'ACTIVE', @finance_id, @finance_id);
INSERT INTO md_project
(public_id, company_id, business_no, name, customer_id, owner_user_id, start_date,
currency, stage, finance_status, archive_status, status, created_by, updated_by)
SELECT '80000000000000000000000003', company.id, 'XM-REPORT-001', '报表集成测试项目', counterparty.id,
@project_user_id, CURRENT_DATE(), 'CNY', 'EXECUTION', 'IN_PROGRESS', 'RETURNED', 'ACTIVE',
@finance_id, @finance_id
FROM md_company company
JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO md_contract
(public_id, company_id, project_id, counterparty_id, business_no, name, original_amount,
approved_change_amount, settlement_amount, currency, status, created_by, updated_by)
SELECT '80000000000000000000000004', company.id, project.id, counterparty.id,
'HT-REPORT-001', '报表集成测试合同', 1000.00, 100.00, 900.00, 'CNY', 'ACTIVE',
@finance_id, @finance_id
FROM md_company company
JOIN md_project project ON project.public_id = '80000000000000000000000003'
JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO src_source_document
(public_id, business_no, form_type, source_system, source_no, normalized_source_no,
source_version, normalized_source_version, template_id, company_id, project_id,
status, created_by, updated_by)
SELECT '80000000000000000000000005', 'OA06-REPORT-001', 'OA-06', 'MANUAL', 'REPORT-001',
'REPORT-001', '1', '1', @template_id, company.id, project.id, 'APPROVED', @finance_id, @finance_id
FROM md_company company
JOIN md_project project ON project.public_id = '80000000000000000000000003'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO src_document_version
(public_id, source_document_id, version_no, template_id, snapshot_sha256, submitted_at, created_by)
SELECT '80000000000000000000000006', document.id, 1, @template_id,
REPEAT('a', 64), UTC_TIMESTAMP(3), @finance_id
FROM src_source_document document
WHERE document.public_id = '80000000000000000000000005';
UPDATE src_source_document document
JOIN src_document_version version ON version.public_id = '80000000000000000000000006'
SET document.current_version_id = version.id
WHERE document.public_id = '80000000000000000000000005';
INSERT INTO src_source_document
(public_id, business_no, form_type, source_system, source_no, normalized_source_no,
source_version, normalized_source_version, template_id, company_id, project_id,
status, created_by, updated_by)
SELECT source.public_id, source.business_no, 'OA-04', 'MANUAL', source.source_no,
source.source_no, '1', '1', @payable_template_id, company.id, project.id,
'APPROVED', @finance_id, @finance_id
FROM md_company company
JOIN md_project project ON project.public_id = '80000000000000000000000003'
JOIN (
SELECT '80000000000000000000000038' AS public_id,
'OA04-REPORT-001' AS business_no, 'REPORT-PAYABLE-001' AS source_no
UNION ALL
SELECT '80000000000000000000000039',
'OA04-REPORT-002', 'REPORT-PAYABLE-002'
) source
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO src_document_version
(public_id, source_document_id, version_no, template_id, snapshot_sha256, submitted_at, created_by)
SELECT CASE document.public_id
WHEN '80000000000000000000000038' THEN '80000000000000000000000040'
ELSE '80000000000000000000000041'
END,
document.id, 1, @payable_template_id, REPEAT('f', 64), UTC_TIMESTAMP(3), @finance_id
FROM src_source_document document
WHERE document.public_id IN (
'80000000000000000000000038', '80000000000000000000000039'
);
UPDATE src_source_document document
JOIN src_document_version version ON version.source_document_id = document.id
SET document.current_version_id = version.id
WHERE document.public_id IN (
'80000000000000000000000038', '80000000000000000000000039'
);
INSERT INTO wf_instance
(public_id, definition_version_id, source_version_id, current_node_code, status,
created_by, updated_by)
SELECT CASE document.public_id
WHEN '80000000000000000000000038' THEN '80000000000000000000000042'
ELSE '80000000000000000000000043'
END,
@definition_version_id, version.id, NULL, 'APPROVED', @finance_id, @finance_id
FROM src_source_document document
JOIN src_document_version version ON version.id = document.current_version_id
WHERE document.public_id IN (
'80000000000000000000000038', '80000000000000000000000039'
);
INSERT INTO src_approval_snapshot
(public_id, document_version_id, node_code, actor_name, actor_id, action, opinion, acted_at)
SELECT '80000000000000000000000007', version.id, 'FINANCE_REVIEW', '财务测试用户',
@finance_id, 'APPROVE', '报表测试审批通过', UTC_TIMESTAMP(3)
FROM src_document_version version
WHERE version.public_id = '80000000000000000000000006';
INSERT INTO wf_instance
(public_id, definition_version_id, source_version_id, current_node_code, status,
created_by, updated_by, created_at, updated_at)
SELECT '80000000000000000000000008', @definition_version_id, version.id,
'FINANCE_REVIEW', 'RUNNING', @finance_id, @finance_id,
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE), DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE)
FROM src_document_version version
WHERE version.public_id = '80000000000000000000000006';
INSERT INTO wf_task
(public_id, instance_id, node_code, sequence_no, assignee_id, status, created_at, updated_at)
SELECT '80000000000000000000000009', instance.id, 'FINANCE_REVIEW', 1,
@finance_id, 'PENDING', DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE),
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE)
FROM wf_instance instance
WHERE instance.public_id = '80000000000000000000000008';
INSERT INTO wf_action
(public_id, task_id, sequence_no, action, opinion, actor_id, acted_at, request_id)
SELECT '80000000000000000000000010', task.id, 1, 'RETURN', '报表测试退回',
@finance_id, DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 MINUTE), '80000000000000000000000011'
FROM wf_task task
WHERE task.public_id = '80000000000000000000000009';
INSERT INTO md_bank_account_version
(public_id, owner_type, counterparty_id, account_category, account_name, bank_name,
account_no_ciphertext, account_no_hash, masked_account_no, version_no, valid_from,
status, created_by, updated_by)
SELECT '80000000000000000000000012', 'COUNTERPARTY', counterparty.id, 'BASIC',
'报表测试账户', '报表测试银行', X'01', REPEAT('b', 64), '****0001', 1,
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 DAY), 'ACTIVE', @finance_id, @finance_id
FROM md_counterparty counterparty
WHERE counterparty.public_id = '80000000000000000000000002';
INSERT INTO fin_payable
(public_id, business_no, company_id, project_id, contract_id, counterparty_id,
source_document_id, cost_category_id, business_date, amount, invoiced_amount,
paid_amount, status, confirmed_by, confirmed_at, created_by, updated_by)
SELECT '80000000000000000000000013', 'YF-REPORT-001', company.id, project.id, contract.id,
counterparty.id, document.id, category.id, CURRENT_DATE(), 600.00, 200.00, 150.00,
'PART_PAID', @finance_id, UTC_TIMESTAMP(3), @finance_id, @finance_id
FROM md_company company
JOIN md_project project ON project.public_id = '80000000000000000000000003'
JOIN md_contract contract ON contract.public_id = '80000000000000000000000004'
JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002'
JOIN src_source_document document ON document.public_id = '80000000000000000000000038'
JOIN md_cost_category category ON category.code = 'MATERIAL'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO fin_payable
(public_id, business_no, company_id, project_id, contract_id, counterparty_id,
source_document_id, cost_category_id, business_date, amount, invoiced_amount,
paid_amount, status, created_by, updated_by)
SELECT '80000000000000000000000037', 'YF-REPORT-PENDING-001', company.id, project.id, contract.id,
counterparty.id, document.id, category.id, CURRENT_DATE(), 75.00, 0.00, 0.00,
'PENDING', @finance_id, @finance_id
FROM md_company company
JOIN md_project project ON project.public_id = '80000000000000000000000003'
JOIN md_contract contract ON contract.public_id = '80000000000000000000000004'
JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002'
JOIN src_source_document document ON document.public_id = '80000000000000000000000039'
JOIN md_cost_category category ON category.code = 'MATERIAL'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO fin_receipt
(public_id, business_no, company_id, project_id, customer_id, source_document_id, source_version_id,
receipt_date, amount, allocated_amount, currency, payer_name, reference_no,
status, confirmed_by, confirmed_at, created_by, updated_by)
SELECT '80000000000000000000000014', '=SUM(A1:A2)', company.id, project.id,
counterparty.id, document.id, document.current_version_id, CURRENT_DATE(), 300.00, 100.00, 'CNY',
'报表测试付款方', 'BANK-REPORT-001', 'PARTIALLY_ALLOCATED', @finance_id, UTC_TIMESTAMP(3),
@finance_id, @finance_id
FROM md_company company
JOIN md_project project ON project.public_id = '80000000000000000000000003'
JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002'
JOIN src_source_document document ON document.public_id = '80000000000000000000000005'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO fin_invoice
(public_id, business_no, company_id, project_id, contract_id, counterparty_id,
source_document_id, source_version_id, invoice_type, amount, currency, tax_rate, requested_date, invoice_no,
issued_date, status, submitted_by, reviewed_by, result_recorded_by, created_by, updated_by)
SELECT '80000000000000000000000015', 'FP-REPORT-001', company.id, project.id, contract.id,
counterparty.id, document.id, document.current_version_id, 'SPECIAL', 200.00, 'CNY', 0.13, CURRENT_DATE(),
'INV-REPORT-001', CURRENT_DATE(), 'ISSUED', @project_user_id, @finance_id,
@finance_id, @finance_id, @finance_id
FROM md_company company
JOIN md_project project ON project.public_id = '80000000000000000000000003'
JOIN md_contract contract ON contract.public_id = '80000000000000000000000004'
JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002'
JOIN src_source_document document ON document.public_id = '80000000000000000000000005'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO fin_receivable
(public_id, business_no, source_document_id, source_invoice_id, company_id, project_id,
contract_id, counterparty_id, business_date, amount, outstanding_amount, currency,
status, created_by, updated_by)
SELECT '80000000000000000000000016', 'YS-REPORT-001', document.id, invoice.id,
company.id, project.id, contract.id, counterparty.id, CURRENT_DATE(), 1000.00,
700.00, 'CNY', 'PARTIALLY_SETTLED', @finance_id, @finance_id
FROM md_company company
JOIN md_project project ON project.public_id = '80000000000000000000000003'
JOIN md_contract contract ON contract.public_id = '80000000000000000000000004'
JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002'
JOIN src_source_document document ON document.public_id = '80000000000000000000000005'
JOIN fin_invoice invoice ON invoice.public_id = '80000000000000000000000015'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO fin_payment_request
(public_id, business_no, company_id, project_id, contract_id, payable_id, supplier_id,
bank_account_version_id, source_document_id, source_version_id, requested_amount, approved_amount,
requested_date, purpose, status, submitted_by, checked_by, approved_by,
submitted_at, checked_at, approved_at, created_by, updated_by)
SELECT '80000000000000000000000017', 'FK-REPORT-001', company.id, project.id, contract.id,
payable.id, counterparty.id, bank.id, document.id, document.current_version_id,
400.00, 400.00, CURRENT_DATE(),
'报表集成测试付款', 'PART_PAID', @project_user_id, @finance_id, @finance_id,
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 3 HOUR), DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 HOUR),
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 HOUR), @finance_id, @finance_id
FROM md_company company
JOIN md_project project ON project.public_id = '80000000000000000000000003'
JOIN md_contract contract ON contract.public_id = '80000000000000000000000004'
JOIN fin_payable payable ON payable.public_id = '80000000000000000000000013'
JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002'
JOIN md_bank_account_version bank ON bank.public_id = '80000000000000000000000012'
JOIN src_source_document document ON document.public_id = '80000000000000000000000005'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO fin_payment_result
(public_id, result_no, payment_id, result_type, paid_date, amount, transaction_no,
recorded_by, recorded_at, verified_by, verified_at, status)
SELECT '80000000000000000000000018', 'PAY-RESULT-REPORT-001', payment.id,
'SUCCESS', CURRENT_DATE(), 200.00, 'TX-REPORT-001', @project_user_id,
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 30 MINUTE), @finance_id,
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 25 MINUTE), 'VERIFIED'
FROM fin_payment_request payment
WHERE payment.public_id = '80000000000000000000000017';
INSERT INTO fin_payment_result
(public_id, result_no, payment_id, result_type, paid_date, amount, transaction_no,
recorded_by, recorded_at, verified_by, verified_at, status)
SELECT '80000000000000000000000031', 'PAY-RESULT-REPORT-002', payment.id,
'SUCCESS', CURRENT_DATE(), 200.00, 'TX-REPORT-002', @project_user_id,
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 20 MINUTE), @finance_id,
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE), 'VERIFIED'
FROM fin_payment_request payment
WHERE payment.public_id = '80000000000000000000000017';
INSERT INTO fin_payment_result
(public_id, result_no, payment_id, result_type, paid_date, amount, transaction_no,
recorded_by, recorded_at, verified_by, verified_at, status)
SELECT '80000000000000000000000032', 'PAY-RESULT-REPORT-003', payment.id,
'REFUND', CURRENT_DATE(), 50.00, 'TX-REPORT-003', @project_user_id,
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 10 MINUTE), @finance_id,
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 5 MINUTE), 'VERIFIED'
FROM fin_payment_request payment
WHERE payment.public_id = '80000000000000000000000017';
INSERT INTO fin_payment_result
(public_id, result_no, payment_id, result_type, paid_date, amount, transaction_no,
recorded_by, recorded_at, status)
SELECT '80000000000000000000000033', 'PAY-RESULT-REPORT-004', payment.id,
'SUCCESS', CURRENT_DATE(), 999.00, 'TX-REPORT-PENDING', @project_user_id,
UTC_TIMESTAMP(3), 'PENDING_VERIFY'
FROM fin_payment_request payment
WHERE payment.public_id = '80000000000000000000000017';
UPDATE fin_payment_request
SET paid_amount = 350.00
WHERE public_id = '80000000000000000000000017';
INSERT INTO acc_event
(public_id, business_no, event_type, source_type, source_public_id, company_id,
project_id, counterparty_id, business_date, period, amount, currency, status, created_by)
SELECT '80000000000000000000000019', 'SJ-REPORT-001', 'PAYMENT', 'PAYMENT', result.public_id,
company.id, project.id, counterparty.id, CURRENT_DATE(), DATE_FORMAT(CURRENT_DATE(), '%Y-%m'),
350.00, 'CNY', 'COMPLETED', @finance_id
FROM fin_payment_result result
JOIN md_company company ON company.public_id = '80000000000000000000000001'
JOIN md_project project ON project.public_id = '80000000000000000000000003'
JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002'
WHERE result.public_id = '80000000000000000000000018';
INSERT INTO acc_voucher
(public_id, business_no, event_id, company_id, project_id, period, business_date,
summary, debit_total, credit_total, status, external_voucher_no, result_at,
result_recorded_by, result_recorded_at, result_verified_by, result_verified_at,
created_by, updated_by)
SELECT '80000000000000000000000020', 'PZ-REPORT-001', event.id, company.id, project.id,
DATE_FORMAT(CURRENT_DATE(), '%Y-%m'), CURRENT_DATE(), '报表集成测试凭证',
350.00, 350.00, 'COMPLETED', 'EXT-REPORT-001', CURRENT_DATE(),
@project_user_id, DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE), @finance_id,
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 10 MINUTE), @finance_id, @finance_id
FROM acc_event event
JOIN md_company company ON company.public_id = '80000000000000000000000001'
JOIN md_project project ON project.public_id = '80000000000000000000000003'
WHERE event.public_id = '80000000000000000000000019';
UPDATE acc_event event
JOIN acc_voucher voucher ON voucher.public_id = '80000000000000000000000020'
SET event.voucher_id = voucher.id
WHERE event.public_id = '80000000000000000000000019';
INSERT INTO arc_package
(public_id, business_no, company_id, project_id, package_version, completeness,
missing_count, status, created_by, updated_by)
SELECT '80000000000000000000000021', 'ARC-REPORT-001-V01', company.id, project.id,
1, 90.00, 0, 'ARCHIVED', @finance_id, @finance_id
FROM md_company company JOIN md_project project ON project.public_id = '80000000000000000000000003'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO arc_package
(public_id, business_no, company_id, project_id, package_version, completeness,
missing_count, status, created_by, updated_by)
SELECT '80000000000000000000000022', 'ARC-REPORT-001-V02', company.id, project.id,
2, 75.00, 1, 'RETURNED', @finance_id, @finance_id
FROM md_company company JOIN md_project project ON project.public_id = '80000000000000000000000003'
WHERE company.public_id = '80000000000000000000000001';
INSERT INTO file_object
(public_id, original_name, storage_key, media_type, extension, size_bytes,
sha256, scan_status, uploaded_by)
VALUES ('80000000000000000000000023', 'report-proof.pdf', 'reporting/report-proof.pdf',
'application/pdf', 'pdf', 128, REPEAT('c', 64), 'AVAILABLE', @finance_public_id);
INSERT INTO file_object
(public_id, original_name, storage_key, media_type, extension, size_bytes,
sha256, scan_status, uploaded_by)
VALUES ('80000000000000000000000035', 'wrong-receipt-proof.pdf', 'reporting/wrong-receipt-proof.pdf',
'application/pdf', 'pdf', 128, REPEAT('d', 64), 'AVAILABLE', @finance_public_id),
('80000000000000000000000036', 'wrong-workflow-proof.pdf', 'reporting/wrong-workflow-proof.pdf',
'application/pdf', 'pdf', 128, REPEAT('e', 64), 'AVAILABLE', @finance_public_id);
INSERT INTO file_link
(file_id, object_type, object_public_id, series_public_id, company_public_id, project_public_id,
form_type, document_type, original_type, archive_status, version_no, active, linked_by)
SELECT file.id, 'SOURCE_DOCUMENT', document.public_id, file.public_id, company.public_id, project.public_id,
document.form_type, 'PAYMENT_SUPPORT', 'PDF', 'ACTIVE', 1, TRUE, @finance_public_id
FROM file_object file
JOIN src_source_document document ON document.public_id = '80000000000000000000000005'
JOIN md_company company ON company.public_id = '80000000000000000000000001'
JOIN md_project project ON project.public_id = '80000000000000000000000003'
WHERE file.public_id = '80000000000000000000000023';
INSERT INTO file_link
(file_id, object_type, object_public_id, series_public_id, company_public_id, project_public_id,
document_type, original_type, archive_status, version_no, active, linked_by)
SELECT file.id, 'RECEIPT', '80000000000000000000000014', file.public_id,
'80000000000000000000000030', '80000000000000000000000003',
'RECEIPT_PROOF', 'PDF', 'ACTIVE', 1, TRUE, @finance_public_id
FROM file_object file WHERE file.public_id = '80000000000000000000000035';
INSERT INTO file_link
(file_id, object_type, object_public_id, series_public_id, company_public_id, project_public_id,
document_type, original_type, archive_status, version_no, active, linked_by)
SELECT file.id, 'WORKFLOW_TASK', '80000000000000000000000009', file.public_id,
'80000000000000000000000030', '80000000000000000000000003',
'WORKFLOW_PROOF', 'PDF', 'ACTIVE', 1, TRUE, @finance_public_id
FROM file_object file WHERE file.public_id = '80000000000000000000000036';
INSERT INTO fin_payment_attachment (public_id, payment_id, file_id, document_type, uploaded_by)
SELECT '80000000000000000000000024', payment.id, file.id, 'PAYMENT_SUPPORT', @finance_id
FROM fin_payment_request payment JOIN file_object file ON file.public_id = '80000000000000000000000023'
WHERE payment.public_id = '80000000000000000000000017';
UPDATE fin_payment_result result
JOIN file_object file ON file.public_id = '80000000000000000000000023'
SET result.receipt_file_id = file.id
WHERE result.public_id = '80000000000000000000000018';
INSERT INTO fin_payment_result_file (public_id, payment_result_id, file_id, created_by)
SELECT '80000000000000000000000025', result.id, file.id, @finance_id
FROM fin_payment_result result JOIN file_object file ON file.public_id = '80000000000000000000000023'
WHERE result.public_id = '80000000000000000000000018';
INSERT INTO fin_payment_result_file (public_id, payment_result_id, file_id, created_by)
SELECT '80000000000000000000000034', result.id, file.id, @finance_id
FROM fin_payment_result result JOIN file_object file ON file.public_id = '80000000000000000000000023'
WHERE result.public_id = '80000000000000000000000033';
INSERT INTO arc_package_item
(public_id, package_id, item_type, item_name, object_type, object_public_id,
file_id, file_link_id, file_sha256, required_flag, status)
SELECT '80000000000000000000000026', package.id, 'PAYMENT_PROOF', '付款回单',
'PAYMENT', '80000000000000000000000017', file.id, link.id, file.sha256, TRUE, 'PRESENT'
FROM arc_package package
JOIN file_object file ON file.public_id = '80000000000000000000000023'
JOIN file_link link ON link.file_id = file.id
WHERE package.public_id = '80000000000000000000000022';
INSERT INTO arc_package_item
(public_id, package_id, item_type, item_name, required_flag, status, missing_reason)
SELECT '80000000000000000000000027', package.id, 'INVOICE', '发票资料', TRUE, 'MISSING', '待补发票附件'
FROM arc_package package WHERE package.public_id = '80000000000000000000000022';
INSERT INTO arc_package_item
(public_id, package_id, item_type, item_name, required_flag, status, not_applicable_reason)
SELECT '80000000000000000000000028', package.id, 'TAX', '税务资料', FALSE, 'NOT_APPLICABLE', '本项目不涉及'
FROM arc_package package WHERE package.public_id = '80000000000000000000000022';
INSERT INTO arc_package_item
(public_id, package_id, item_type, item_name, required_flag, status)
SELECT '80000000000000000000000029', package.id, 'OLD_VERSION', '旧版资料', FALSE, 'SUPERSEDED'
FROM arc_package package WHERE package.public_id = '80000000000000000000000022';