14 changed files with 478 additions and 12 deletions
@ -0,0 +1,33 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import jakarta.annotation.PostConstruct; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.Map; |
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import org.springframework.boot.context.properties.ConfigurationProperties; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
/** 发布白名单为空时失败关闭;enabled/dataLevel 不表示政策已核验。 */ |
|||
@Getter |
|||
@Setter |
|||
@Component |
|||
@ConfigurationProperties(prefix = "app.salary-publication") |
|||
public class SalaryPolicyPublicationProperties { |
|||
private Map<String, String> approvedPolicies = new LinkedHashMap<>(); |
|||
|
|||
@PostConstruct |
|||
void validate() { |
|||
if (approvedPolicies == null || approvedPolicies.entrySet().stream().anyMatch(entry -> |
|||
entry.getKey() == null || !entry.getKey().matches("[0-9]{6}") |
|||
|| entry.getValue() == null || entry.getValue().isBlank() |
|||
|| !entry.getValue().equals(entry.getValue().trim()))) { |
|||
throw new IllegalStateException("invalid salary publication allowlist"); |
|||
} |
|||
} |
|||
|
|||
public boolean isPublished(String cityCode, String policyVersion) { |
|||
return cityCode != null && policyVersion != null && approvedPolicies != null |
|||
&& policyVersion.equals(approvedPolicies.get(cityCode)); |
|||
} |
|||
} |
|||
@ -0,0 +1,124 @@ |
|||
package com.mydreamjob.integration; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.auth.AuthSessionService; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import java.util.*; |
|||
import org.junit.jupiter.api.*; |
|||
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.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.*; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
import org.testcontainers.containers.MySQLContainer; |
|||
import org.testcontainers.junit.jupiter.*; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
|||
|
|||
/** 默认发布配置必须拦截所有未核验种子;真实 MySQL,Redis mock、MQ 关闭。 */ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("mysql-it") |
|||
@Testcontainers(disabledWithoutDocker = false) |
|||
class SalaryPublicationDefaultMySqlTest { |
|||
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4") |
|||
.withDatabaseName("salary_publication_test") |
|||
.withUsername("publication_test").withPassword("publication-test-only"); |
|||
@DynamicPropertySource static void database(DynamicPropertyRegistry r) { |
|||
r.add("spring.datasource.url", MYSQL::getJdbcUrl); |
|||
r.add("spring.datasource.username", MYSQL::getUsername); |
|||
r.add("spring.datasource.password", MYSQL::getPassword); |
|||
} |
|||
@MockBean RedisTemplate<String, Object> redis; |
|||
@Autowired JdbcTemplate jdbc; |
|||
@Autowired MockMvc mvc; |
|||
@Autowired AuthSessionService sessions; |
|||
@Autowired UserRepository users; |
|||
@Autowired ObjectMapper mapper; |
|||
@Autowired com.mydreamjob.salarycalculator.SalaryRequestFingerprint fingerprint; |
|||
private String token; |
|||
@BeforeEach void identity() { |
|||
token = "Bearer " + LxqTestSessionIssuer.issue(92001, "STUDENT", sessions, users, jdbc); |
|||
} |
|||
@AfterEach void cleanup() { |
|||
jdbc.update("delete from salary_calculation_history where student_user_id=?", 92001); |
|||
LxqTestSessionIssuer.cleanup(jdbc); |
|||
} |
|||
private String request() { |
|||
return "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000,\"monthIndex\":1," |
|||
+ "\"specialAdditionalDeduction\":0,\"expectedPolicyVersion\":\"2026-09-20-v1\"," |
|||
+ "\"idempotencyKey\":\"publication-test\"}"; |
|||
} |
|||
private Map<String, List<String>> snapshot() { |
|||
Map<String, List<String>> result = new TreeMap<>(); |
|||
for (String table : jdbc.queryForList("show tables", String.class)) { |
|||
List<String> rows = jdbc.query("select * from `" + table + "`", (rs, n) -> { |
|||
List<String> columns = new ArrayList<>(); |
|||
for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) { |
|||
columns.add(rs.getMetaData().getColumnName(i) + "=" + rs.getString(i)); |
|||
} |
|||
return columns.toString(); |
|||
}); |
|||
rows.sort(Comparator.naturalOrder()); |
|||
result.put(table, rows); |
|||
} |
|||
return result; |
|||
} |
|||
@Test void seedsRemainButDefaultCityListIsEmptyAndReadOnly() throws Exception { |
|||
assertThat(jdbc.queryForObject("select count(*) from salary_city_policy", Integer.class)).isEqualTo(359); |
|||
var before = snapshot(); |
|||
mvc.perform(get("/salary-calculator/cities").header("Authorization", token)) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data").isEmpty()); |
|||
assertThat(snapshot()).isEqualTo(before); |
|||
} |
|||
@Test void directCityLookupIsClosedWithoutSideEffects() throws Exception { |
|||
var before = snapshot(); |
|||
mvc.perform(get("/salary-calculator/cities/110000").header("Authorization", token)) |
|||
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)); |
|||
assertThat(snapshot()).isEqualTo(before); |
|||
} |
|||
@Test void calculationIsClosedWithoutSideEffects() throws Exception { |
|||
var before = snapshot(); |
|||
mvc.perform(post("/salary-calculator/calculations").header("Authorization", token) |
|||
.contentType(MediaType.APPLICATION_JSON).content(request())) |
|||
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)); |
|||
assertThat(snapshot()).isEqualTo(before); |
|||
} |
|||
@Test void anonymousIsStillUnauthorized() throws Exception { |
|||
mvc.perform(get("/salary-calculator/cities")).andExpect(status().isUnauthorized()); |
|||
} |
|||
@Test void historyListIsStillAvailable() throws Exception { |
|||
mvc.perform(get("/salary-calculator/calculations").header("Authorization", token)) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
@Test void existingHistoryIsReadableButCannotBypassPublicationThroughReplay() throws Exception { |
|||
var body = mapper.readValue(request(), com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest.class); |
|||
jdbc.update("insert into salary_calculation_history(student_user_id,city_code,city_name,data_level," |
|||
+ "policy_version,tax_rules_version,month_index,gross_monthly_salary,net_salary," |
|||
+ "idempotency_key,request_hash,input_snapshot_json,policy_snapshot_json,result_snapshot_json)" |
|||
+ " values(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", |
|||
92001, "110000", "test-city", "CITY", "2026-09-20-v1", "test-tax-version", |
|||
1, 10000, 8000, "publication-test", fingerprint.fingerprint(body), "{}", "{}", "{}"); |
|||
Long id = jdbc.queryForObject("select id from salary_calculation_history where student_user_id=?", |
|||
Long.class, 92001); |
|||
var before = snapshot(); |
|||
mvc.perform(get("/salary-calculator/calculations/" + id).header("Authorization", token)) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.id").value(id)); |
|||
mvc.perform(post("/salary-calculator/calculations").header("Authorization", token) |
|||
.contentType(MediaType.APPLICATION_JSON).content(request())) |
|||
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)); |
|||
assertThat(snapshot()).isEqualTo(before); |
|||
} |
|||
@Test void n03AndN04RemainAvailable() throws Exception { |
|||
mvc.perform(get("/onboarding/company/checklist-items").header("Authorization", token)) |
|||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); |
|||
// 接口仍然存在,原角色门禁不被薪资发布配置影响。
|
|||
mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.paths['/skills/word-cloud']").exists()); |
|||
} |
|||
} |
|||
@ -0,0 +1,169 @@ |
|||
package com.mydreamjob.integration; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.auth.AuthSessionService; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.salarycalculator.SalaryPolicyPublicationProperties; |
|||
import java.util.ArrayList; |
|||
import java.util.Comparator; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.TreeMap; |
|||
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.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.http.MediaType; |
|||
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.web.servlet.MockMvc; |
|||
import org.testcontainers.containers.MySQLContainer; |
|||
import org.testcontainers.junit.jupiter.Container; |
|||
import org.testcontainers.junit.jupiter.Testcontainers; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
|||
|
|||
/** 独立复验撤销、版本漂移、旧历史与归属边界;不证明现实政策准确性。 */ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("mysql-it") |
|||
@Testcontainers(disabledWithoutDocker = false) |
|||
class SalaryPublicationIndependentMySqlVerificationTest { |
|||
private static final long OWNER = 93001; |
|||
private static final long OTHER = 93002; |
|||
private static final String VERSION = "2026-09-20-v1"; |
|||
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4") |
|||
.withDatabaseName("salary_independent_test") |
|||
.withUsername("salary_test").withPassword("independent-test-only"); |
|||
@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); |
|||
} |
|||
@MockBean RedisTemplate<String, Object> redis; |
|||
@Autowired JdbcTemplate jdbc; |
|||
@Autowired MockMvc mvc; |
|||
@Autowired AuthSessionService sessions; |
|||
@Autowired UserRepository users; |
|||
@Autowired SalaryPolicyPublicationProperties publication; |
|||
@Autowired ObjectMapper mapper; |
|||
private String ownerToken; |
|||
|
|||
@BeforeEach void identity() { |
|||
assertThat(publication.getApprovedPolicies()).isEmpty(); |
|||
ownerToken = token(OWNER, "STUDENT"); |
|||
} |
|||
@AfterEach void restore() { |
|||
publication.setApprovedPolicies(new LinkedHashMap<>()); |
|||
jdbc.update("update salary_city_policy set policy_version=?, enabled=true where city_code=?", VERSION, "110000"); |
|||
jdbc.update("delete from salary_calculation_history where student_user_id in (?,?)", OWNER, OTHER); |
|||
LxqTestSessionIssuer.cleanup(jdbc); |
|||
} |
|||
private String token(long id, String role) { |
|||
return "Bearer " + LxqTestSessionIssuer.issue(id, role, sessions, users, jdbc); |
|||
} |
|||
private void approve() { |
|||
publication.setApprovedPolicies(new LinkedHashMap<>(Map.of("110000", VERSION))); |
|||
} |
|||
private String request() { |
|||
return "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000,\"monthIndex\":1," |
|||
+ "\"specialAdditionalDeduction\":0,\"expectedPolicyVersion\":\"" + VERSION + "\"," |
|||
+ "\"idempotencyKey\":\"independent-replay\"}"; |
|||
} |
|||
private long calculate() throws Exception { |
|||
String response = mvc.perform(post("/salary-calculator/calculations") |
|||
.header("Authorization", ownerToken).contentType(MediaType.APPLICATION_JSON).content(request())) |
|||
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); |
|||
return mapper.readTree(response).path("data").path("historyId").asLong(); |
|||
} |
|||
private Map<String, List<String>> snapshot() { |
|||
Map<String, List<String>> result = new TreeMap<>(); |
|||
for (String table : jdbc.queryForList("show tables", String.class)) { |
|||
List<String> rows = jdbc.query("select * from `" + table + "`", (rs, n) -> { |
|||
List<String> columns = new ArrayList<>(); |
|||
for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) { |
|||
columns.add(rs.getMetaData().getColumnName(i) + "=" + rs.getString(i)); |
|||
} |
|||
return columns.toString(); |
|||
}); |
|||
rows.sort(Comparator.naturalOrder()); |
|||
result.put(table, rows); |
|||
} |
|||
return result; |
|||
} |
|||
private void deniedCalculationIsReadOnly() throws Exception { |
|||
var before = snapshot(); |
|||
mvc.perform(post("/salary-calculator/calculations").header("Authorization", ownerToken) |
|||
.contentType(MediaType.APPLICATION_JSON).content(request())) |
|||
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)) |
|||
.andExpect(jsonPath("$.data.policyVersion").doesNotExist()) |
|||
.andExpect(jsonPath("$.data.result").doesNotExist()); |
|||
assertThat(snapshot()).isEqualTo(before); |
|||
} |
|||
|
|||
@Test void allowlistDoesNotPublishOtherCitiesOrVersions() throws Exception { |
|||
approve(); |
|||
mvc.perform(get("/salary-calculator/cities").header("Authorization", ownerToken)) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.length()").value(1)) |
|||
.andExpect(jsonPath("$.data[0].cityCode").value("110000")); |
|||
mvc.perform(get("/salary-calculator/cities/310000").header("Authorization", ownerToken)) |
|||
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10601)); |
|||
publication.setApprovedPolicies(new LinkedHashMap<>(Map.of("110000", "wrong-test-version"))); |
|||
deniedCalculationIsReadOnly(); |
|||
} |
|||
@Test void policyVersionDriftClosesEvenExistingIdempotencyReplay() throws Exception { |
|||
approve(); |
|||
calculate(); |
|||
jdbc.update("update salary_city_policy set policy_version=? where city_code=?", "unapproved-next-test-version", "110000"); |
|||
deniedCalculationIsReadOnly(); |
|||
} |
|||
@Test void approvedCurrentPolicyCannotReplayUnapprovedStoredVersion() throws Exception { |
|||
approve(); |
|||
long id = calculate(); |
|||
jdbc.update("update salary_calculation_history set policy_version=? where id=?", "unapproved-old-test-version", id); |
|||
deniedCalculationIsReadOnly(); |
|||
} |
|||
@Test void revokedPublicationPreservesOwnHistoryAndDeleteButNotCrossOwnerAccess() throws Exception { |
|||
approve(); |
|||
long id = calculate(); |
|||
String otherToken = token(OTHER, "STUDENT"); |
|||
publication.setApprovedPolicies(new LinkedHashMap<>()); |
|||
deniedCalculationIsReadOnly(); |
|||
var before = snapshot(); |
|||
mvc.perform(get("/salary-calculator/calculations/" + id).header("Authorization", ownerToken)) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.id").value(id)); |
|||
mvc.perform(get("/salary-calculator/calculations/" + id).header("Authorization", otherToken)) |
|||
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10603)); |
|||
mvc.perform(delete("/salary-calculator/calculations/" + id).header("Authorization", otherToken)) |
|||
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10603)); |
|||
assertThat(snapshot()).isEqualTo(before); |
|||
mvc.perform(delete("/salary-calculator/calculations/" + id).header("Authorization", ownerToken)) |
|||
.andExpect(status().isOk()); |
|||
assertThat(jdbc.queryForObject("select count(*) from salary_calculation_history where id=?", Integer.class, id)).isZero(); |
|||
} |
|||
@Test void approvedButDisabledPolicyCannotReplay() throws Exception { |
|||
approve(); |
|||
calculate(); |
|||
jdbc.update("update salary_city_policy set enabled=false where city_code=?", "110000"); |
|||
deniedCalculationIsReadOnly(); |
|||
} |
|||
@Test void approvalDoesNotOverrideCompanyRoleRestriction() throws Exception { |
|||
approve(); |
|||
String companyToken = token(OTHER, "COMPANY"); |
|||
var before = snapshot(); |
|||
mvc.perform(get("/salary-calculator/cities/110000").header("Authorization", companyToken)) |
|||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); |
|||
mvc.perform(post("/salary-calculator/calculations").header("Authorization", companyToken) |
|||
.contentType(MediaType.APPLICATION_JSON).content(request())) |
|||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); |
|||
assertThat(snapshot()).isEqualTo(before); |
|||
} |
|||
} |
|||
@ -0,0 +1,54 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import java.util.Map; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.boot.context.properties.EnableConfigurationProperties; |
|||
import org.springframework.boot.test.context.runner.ApplicationContextRunner; |
|||
import org.springframework.context.annotation.Configuration; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
class SalaryPolicyPublicationPropertiesTest { |
|||
@Configuration(proxyBeanMethods = false) |
|||
@EnableConfigurationProperties(SalaryPolicyPublicationProperties.class) |
|||
static class Config {} |
|||
private final ApplicationContextRunner context = new ApplicationContextRunner() |
|||
.withUserConfiguration(Config.class); |
|||
|
|||
@Test void emptyConfigurationFailsClosed() { |
|||
context.run(ctx -> { |
|||
assertThat(ctx).hasNotFailed(); |
|||
assertThat(ctx.getBean(SalaryPolicyPublicationProperties.class) |
|||
.isPublished("110000", "2026-09-20-v1")).isFalse(); |
|||
}); |
|||
} |
|||
@Test void genericEnabledSwitchCannotPublishSeeds() { |
|||
context.withPropertyValues("app.salary-publication.enabled=true").run(ctx -> |
|||
assertThat(ctx.getBean(SalaryPolicyPublicationProperties.class) |
|||
.isPublished("110000", "2026-09-20-v1")).isFalse()); |
|||
} |
|||
@Test void bindingPublishesOnlyExactCityAndVersion() { |
|||
context.withPropertyValues("app.salary-publication.approved-policies.110000=verified-test-version") |
|||
.run(ctx -> { |
|||
var properties = ctx.getBean(SalaryPolicyPublicationProperties.class); |
|||
assertThat(properties.isPublished("110000", "verified-test-version")).isTrue(); |
|||
assertThat(properties.isPublished("310000", "verified-test-version")).isFalse(); |
|||
assertThat(properties.isPublished("110000", "old-version")).isFalse(); |
|||
assertThat(properties.isPublished("110000", null)).isFalse(); |
|||
}); |
|||
} |
|||
@Test void wildcardCityFailsStartup() { |
|||
context.withPropertyValues("app.salary-publication.approved-policies.any=test-version") |
|||
.run(ctx -> assertThat(ctx).hasFailed()); |
|||
} |
|||
@Test void blankVersionFailsStartup() { |
|||
context.withPropertyValues("app.salary-publication.approved-policies.110000= ") |
|||
.run(ctx -> assertThat(ctx).hasFailed()); |
|||
} |
|||
@Test void whitespaceVersionCannotSilentlyEnable() { |
|||
var properties = new SalaryPolicyPublicationProperties(); |
|||
properties.setApprovedPolicies(Map.of("110000", " version ")); |
|||
org.assertj.core.api.Assertions.assertThatThrownBy(properties::validate) |
|||
.isInstanceOf(IllegalStateException.class) |
|||
.hasMessage("invalid salary publication allowlist"); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue