This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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};
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
+145
@@ -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) {
|
||||
}
|
||||
}
|
||||
+108
@@ -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);
|
||||
}
|
||||
}
|
||||
+1307
File diff suppressed because it is too large
Load Diff
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;
|
||||
}
|
||||
}
|
||||
+59
@@ -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));
|
||||
}
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+269
@@ -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';
|
||||
Reference in New Issue
Block a user