This commit is contained in:
haomingming
2026-05-20 18:21:39 +08:00
commit 815aa04fe8
564 changed files with 82601 additions and 0 deletions
@@ -0,0 +1,18 @@
package com.writeoff;
import com.writeoff.common.exception.BusinessException;
import com.writeoff.module.scheduler.repository.InMemoryAsyncJobRepository;
import com.writeoff.module.scheduler.service.AsyncJobService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class AsyncJobServiceTest {
@Test
void shouldEnforceIdempotency() {
AsyncJobService asyncJobService = new AsyncJobService(new InMemoryAsyncJobRepository());
asyncJobService.enqueue("AUDIT_REMIND", "meetingId=1", "idem-job-1");
Assertions.assertThrows(BusinessException.class, () ->
asyncJobService.enqueue("AUDIT_REMIND", "meetingId=1", "idem-job-1"));
}
}
@@ -0,0 +1,180 @@
package com.writeoff;
import com.writeoff.module.audit.dto.AuditActionRequest;
import com.writeoff.module.audit.model.AuditTask;
import com.writeoff.module.audit.repository.InMemoryAuditTaskRepository;
import com.writeoff.module.audit.service.AuditService;
import com.writeoff.module.finance.dto.ConfirmPaymentRequest;
import com.writeoff.module.finance.repository.InMemoryPaymentRepository;
import com.writeoff.module.finance.service.FinanceService;
import com.writeoff.module.meeting.dto.CreateMeetingRequest;
import com.writeoff.module.meeting.dto.SubmitMeetingRequest;
import com.writeoff.module.meeting.model.Meeting;
import com.writeoff.module.meeting.repository.InMemoryMeetingRepository;
import com.writeoff.module.meeting.service.MeetingService;
import com.writeoff.module.project.dto.CreateProjectRequest;
import com.writeoff.module.project.model.Project;
import com.writeoff.module.project.repository.InMemoryProjectRepository;
import com.writeoff.module.project.service.ProjectService;
import com.writeoff.module.scheduler.repository.InMemoryAsyncJobRepository;
import com.writeoff.module.scheduler.service.AsyncJobService;
import com.writeoff.security.AuthContext;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.time.LocalDate;
import java.util.List;
class MvpFlowIntegrationTest {
@Test
void shouldFinishCoreFlow() throws Exception {
AuthContext.set(1001L, 1L);
try {
InMemoryProjectRepository projectRepository = new InMemoryProjectRepository();
InMemoryMeetingRepository meetingRepository = new InMemoryMeetingRepository();
InMemoryAuditTaskRepository auditTaskRepository = new InMemoryAuditTaskRepository();
InMemoryPaymentRepository paymentRepository = new InMemoryPaymentRepository();
AsyncJobService asyncJobService = new AsyncJobService(new InMemoryAsyncJobRepository());
ProjectService projectService = new ProjectService(projectRepository);
MeetingService meetingService = new MeetingService(meetingRepository, projectService, auditTaskRepository, asyncJobService);
AuditService auditService = new AuditService(auditTaskRepository, meetingService, asyncJobService);
FinanceService financeService = new FinanceService(paymentRepository, meetingService);
Project project = projectService.create(new CreateProjectRequestWrapper("P1", 1000000L, 3).toRequest());
Meeting meeting = meetingService.create(new CreateMeetingRequestWrapper(project.getId(), "M1", 300000L).toRequest());
meetingService.submit(meeting.getId(), new SubmitMeetingRequestWrapper("submit-key-1", "提交审核").toRequest());
List<AuditTask> tasks = auditService.listTasks().getList();
Assertions.assertFalse(tasks.isEmpty());
AuditTask first = tasks.get(0);
auditService.approve(first.getId(), new AuditActionRequestWrapper("audit-key-1", "初审通过").toRequest());
AuditTask second = auditService.listTasks().getList().stream().filter(t -> t.getId() > first.getId()).findFirst().orElseThrow(IllegalStateException::new);
auditService.approve(second.getId(), new AuditActionRequestWrapper("audit-key-2", "复审通过").toRequest());
AuditTask third = auditService.listTasks().getList().stream().filter(t -> t.getId() > second.getId()).findFirst().orElseThrow(IllegalStateException::new);
auditService.approve(third.getId(), new AuditActionRequestWrapper("audit-key-3", "终审通过").toRequest());
Object paymentResult = financeService.confirmPayment(new ConfirmPaymentRequestWrapper(
"pay-key-1", project.getId(), meeting.getId(), 200000L, "oss/voucher.pdf").toRequest());
Assertions.assertNotNull(paymentResult);
} finally {
AuthContext.clear();
}
}
private static class CreateProjectRequestWrapper {
private final String name;
private final long budgetCent;
private final int meetingTotal;
private CreateProjectRequestWrapper(String name, long budgetCent, int meetingTotal) {
this.name = name;
this.budgetCent = budgetCent;
this.meetingTotal = meetingTotal;
}
private CreateProjectRequest toRequest() throws Exception {
CreateProjectRequest request = new CreateProjectRequest();
setField(request, "name", name);
setField(request, "startDate", LocalDate.of(2026, 1, 1));
setField(request, "endDate", LocalDate.of(2026, 12, 31));
setField(request, "budgetCent", budgetCent);
setField(request, "meetingTotal", meetingTotal);
return request;
}
}
private static class CreateMeetingRequestWrapper {
private final long projectId;
private final String topic;
private final long budgetCent;
private CreateMeetingRequestWrapper(long projectId, String topic, long budgetCent) {
this.projectId = projectId;
this.topic = topic;
this.budgetCent = budgetCent;
}
private CreateMeetingRequest toRequest() throws Exception {
CreateMeetingRequest request = new CreateMeetingRequest();
setField(request, "projectId", projectId);
setField(request, "topic", topic);
setField(request, "budgetCent", budgetCent);
setField(request, "meetingCategory", "学术会");
setField(request, "meetingForm", "线下");
setField(request, "location", "线下");
setField(request, "startTime", "2026-01-10 09:00:00");
setField(request, "endTime", "2026-01-10 18:00:00");
return request;
}
}
private static class SubmitMeetingRequestWrapper {
private final String key;
private final String remark;
private SubmitMeetingRequestWrapper(String key, String remark) {
this.key = key;
this.remark = remark;
}
private SubmitMeetingRequest toRequest() throws Exception {
SubmitMeetingRequest request = new SubmitMeetingRequest();
setField(request, "idempotencyKey", key);
setField(request, "remark", remark);
return request;
}
}
private static class AuditActionRequestWrapper {
private final String key;
private final String opinion;
private AuditActionRequestWrapper(String key, String opinion) {
this.key = key;
this.opinion = opinion;
}
private AuditActionRequest toRequest() throws Exception {
AuditActionRequest request = new AuditActionRequest();
setField(request, "idempotencyKey", key);
setField(request, "opinion", opinion);
return request;
}
}
private static class ConfirmPaymentRequestWrapper {
private final String key;
private final long projectId;
private final long meetingId;
private final long amount;
private final String voucher;
private ConfirmPaymentRequestWrapper(String key, long projectId, long meetingId, long amount, String voucher) {
this.key = key;
this.projectId = projectId;
this.meetingId = meetingId;
this.amount = amount;
this.voucher = voucher;
}
private ConfirmPaymentRequest toRequest() throws Exception {
ConfirmPaymentRequest request = new ConfirmPaymentRequest();
setField(request, "idempotencyKey", key);
setField(request, "projectId", projectId);
setField(request, "meetingId", meetingId);
setField(request, "amountCent", amount);
setField(request, "paymentVoucherOssKey", voucher);
return request;
}
}
private static void setField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}
@@ -0,0 +1,63 @@
package com.writeoff.module.system.service;
import com.writeoff.security.AuthContext;
import com.writeoff.security.PasswordCodecService;
import com.writeoff.security.PasswordPolicyService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatcher;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SystemUserServicePasswordTest {
@AfterEach
void tearDown() {
AuthContext.clear();
}
@Test
void shouldAcceptLegacyPlaintextOldPasswordAndUpgradeToHashWhenChangingPassword() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
PasswordCodecService passwordCodecService = new PasswordCodecService();
SystemUserService systemUserService = new SystemUserService(
jdbcTemplate,
null,
new PasswordPolicyService(),
passwordCodecService
);
AuthContext.set(1001L, 2001L);
when(jdbcTemplate.queryForObject(
eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"),
eq(Integer.class),
eq(2001L),
eq(1001L)
)).thenReturn(1);
when(jdbcTemplate.queryForObject(
eq("SELECT password_hash FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1"),
eq(String.class),
eq(2001L),
eq(1001L)
)).thenReturn("legacy-plain-password");
systemUserService.changeMyPassword(1001L, "legacy-plain-password", "Abcd1234!");
verify(jdbcTemplate).update(
eq("UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?"),
argThat(matchesEncodedPassword("Abcd1234!", passwordCodecService)),
eq(2001L),
eq(1001L)
);
}
private ArgumentMatcher<Object> matchesEncodedPassword(String rawPassword, PasswordCodecService passwordCodecService) {
return value -> value instanceof String
&& passwordCodecService.isEncoded((String) value)
&& passwordCodecService.matches(rawPassword, (String) value);
}
}
@@ -0,0 +1,85 @@
package com.writeoff.module.system.service;
import com.writeoff.common.exception.BusinessException;
import com.writeoff.module.system.dto.CreateUserDelegationRequest;
import com.writeoff.security.AuthContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.jdbc.core.JdbcTemplate;
import java.lang.reflect.Field;
class UserDelegationServiceValidationTest {
@AfterEach
void tearDown() {
AuthContext.clear();
}
@Test
void shouldRejectSelfDelegation() throws Exception {
JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class);
UserDelegationService service = new UserDelegationService(jdbcTemplate);
AuthContext.set(1L, 1L);
Mockito.when(jdbcTemplate.queryForObject(
Mockito.eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"),
Mockito.eq(Integer.class),
Mockito.eq(1L),
Mockito.eq(10L)
)).thenReturn(1);
CreateUserDelegationRequest request = new CreateUserDelegationRequest();
setField(request, "delegateUserId", 10L);
setField(request, "effectiveFrom", "2026-03-10 10:00:00");
setField(request, "effectiveTo", "2026-03-10 18:00:00");
Assertions.assertThrows(BusinessException.class, () -> service.create(10L, request));
}
@Test
void shouldRejectInvalidTimeWindow() throws Exception {
JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class);
UserDelegationService service = new UserDelegationService(jdbcTemplate);
AuthContext.set(1L, 1L);
Mockito.when(jdbcTemplate.queryForObject(
Mockito.eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"),
Mockito.eq(Integer.class),
Mockito.eq(1L),
Mockito.eq(10L)
)).thenReturn(1);
Mockito.when(jdbcTemplate.queryForObject(
Mockito.eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"),
Mockito.eq(Integer.class),
Mockito.eq(1L),
Mockito.eq(20L)
)).thenReturn(1);
CreateUserDelegationRequest request = new CreateUserDelegationRequest();
setField(request, "delegateUserId", 20L);
setField(request, "effectiveFrom", "2026-03-10 18:00:00");
setField(request, "effectiveTo", "2026-03-10 10:00:00");
Assertions.assertThrows(BusinessException.class, () -> service.create(10L, request));
Mockito.verify(jdbcTemplate, Mockito.never()).update(
Mockito.contains("INSERT INTO user_delegation"),
Mockito.any(),
Mockito.any(),
Mockito.any(),
Mockito.any(),
Mockito.any(),
Mockito.any(),
Mockito.any(),
Mockito.any(),
Mockito.any()
);
}
private static void setField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}
@@ -0,0 +1,49 @@
package com.writeoff.security;
import org.junit.jupiter.api.Test;
import javax.crypto.Cipher;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.assertEquals;
class LoginPasswordCryptoServiceTest {
private static final OAEPParameterSpec OAEP_SHA256_MGF1_SHA256 = new OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT
);
@Test
void shouldUnwrapBrowserCompatibleEncryptedPassword() throws Exception {
LoginPasswordCryptoService service = new LoginPasswordCryptoService();
service.init();
String rawPassword = "Abcd1234!";
PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(
new X509EncodedKeySpec(Base64.getDecoder().decode(service.getEncodedPublicKey()))
);
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey, OAEP_SHA256_MGF1_SHA256);
String encryptedPassword = LoginPasswordCryptoService.PASSWORD_PREFIX
+ Base64.getEncoder().encodeToString(cipher.doFinal(rawPassword.getBytes(StandardCharsets.UTF_8)));
assertEquals(rawPassword, service.unwrapPassword(encryptedPassword));
}
@Test
void shouldKeepPlainPasswordUntouched() {
LoginPasswordCryptoService service = new LoginPasswordCryptoService();
assertEquals("123456", service.unwrapPassword("123456"));
}
}
@@ -0,0 +1,32 @@
package com.writeoff.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PasswordCodecServiceTest {
private final PasswordCodecService passwordCodecService = new PasswordCodecService();
@Test
void shouldEncodePasswordWithSaltAndMatch() {
String rawPassword = "Abcd1234!";
String first = passwordCodecService.encode(rawPassword);
String second = passwordCodecService.encode(rawPassword);
assertTrue(passwordCodecService.isEncoded(first));
assertTrue(passwordCodecService.matches(rawPassword, first));
assertTrue(passwordCodecService.matches(rawPassword, second));
assertNotEquals(first, second);
assertFalse(passwordCodecService.matches("wrong-password", first));
}
@Test
void shouldRemainCompatibleWithLegacyPlaintextPassword() {
assertTrue(passwordCodecService.matches("123456", "123456"));
assertFalse(passwordCodecService.isEncoded("123456"));
}
}
@@ -0,0 +1,102 @@
package com.writeoff.security;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
class PermissionServiceDelegationTest {
@AfterEach
void tearDown() {
AuthContext.clear();
}
@Test
void shouldInheritPermissionFromPrincipalWhenDelegationEnabled() {
JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class);
PermissionService permissionService = new PermissionService(jdbcTemplate);
AuthContext.set(2001L, 1L);
Mockito.when(jdbcTemplate.queryForObject(
Mockito.eq("SELECT COUNT(1) FROM user_role ur " +
"JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " +
"JOIN permission p ON rp.permission_id=p.id " +
"WHERE ur.user_id=? AND ur.tenant_id=? AND p.permission_code=?"),
Mockito.eq(Integer.class),
Mockito.eq(2001L),
Mockito.eq(1L),
Mockito.eq("meeting.approve")
)).thenReturn(0);
Mockito.when(jdbcTemplate.queryForList(
Mockito.eq("SELECT user_id FROM user_delegation " +
"WHERE tenant_id=? AND delegate_user_id=? AND is_deleted=0 AND status='ENABLED' " +
"AND effective_from<=CURRENT_TIMESTAMP AND effective_to>=CURRENT_TIMESTAMP"),
Mockito.eq(Long.class),
Mockito.eq(1L),
Mockito.eq(2001L)
)).thenReturn(Collections.singletonList(1001L));
Mockito.when(jdbcTemplate.queryForObject(
Mockito.eq("SELECT COUNT(1) FROM user_role ur " +
"JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " +
"JOIN permission p ON rp.permission_id=p.id " +
"WHERE ur.user_id=? AND ur.tenant_id=? AND p.permission_code=?"),
Mockito.eq(Integer.class),
Mockito.eq(1001L),
Mockito.eq(1L),
Mockito.eq("meeting.approve")
)).thenReturn(1);
boolean ok = permissionService.hasPermission(2001L, "meeting.approve");
Assertions.assertTrue(ok);
}
@Test
void shouldMergePermissionsFromSelfAndDelegationWithoutDuplicates() {
JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class);
PermissionService permissionService = new PermissionService(jdbcTemplate);
AuthContext.set(2001L, 1L);
Mockito.when(jdbcTemplate.queryForList(
Mockito.eq("SELECT DISTINCT p.permission_code FROM user_role ur " +
"JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " +
"JOIN permission p ON rp.permission_id=p.id " +
"WHERE ur.user_id=? AND ur.tenant_id=?"),
Mockito.eq(String.class),
Mockito.eq(2001L),
Mockito.eq(1L)
)).thenReturn(Arrays.asList("user.read", "user.delegation.manage"));
Mockito.when(jdbcTemplate.queryForList(
Mockito.eq("SELECT user_id FROM user_delegation " +
"WHERE tenant_id=? AND delegate_user_id=? AND is_deleted=0 AND status='ENABLED' " +
"AND effective_from<=CURRENT_TIMESTAMP AND effective_to>=CURRENT_TIMESTAMP"),
Mockito.eq(Long.class),
Mockito.eq(1L),
Mockito.eq(2001L)
)).thenReturn(Collections.singletonList(1001L));
Mockito.when(jdbcTemplate.queryForList(
Mockito.eq("SELECT DISTINCT p.permission_code FROM user_role ur " +
"JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " +
"JOIN permission p ON rp.permission_id=p.id " +
"WHERE ur.user_id=? AND ur.tenant_id=?"),
Mockito.eq(String.class),
Mockito.eq(1001L),
Mockito.eq(1L)
)).thenReturn(Arrays.asList("meeting.approve", "user.read"));
List<String> perms = permissionService.getPermissions(2001L);
Assertions.assertTrue(perms.contains("user.read"));
Assertions.assertTrue(perms.contains("user.delegation.manage"));
Assertions.assertTrue(perms.contains("meeting.approve"));
Assertions.assertEquals(3, perms.size());
}
}