Commit cd0b937be686d3f87e755aabcd7439d472f3a593

Authored by zichun
1 parent c0592580

test: security invariants (identity fail-closed, conversation scoping, numeric/tenant correctness)

First tests in the repo — they pin the audit fixes that are easiest to silently
regress: no identity without introspection, no conversation across users, no
silent numeric corruption, no tenant-less FK query.
src/test/java/com/xly/service/AuthzServiceTest.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.agent.AgentIdentity;
  5 +import org.junit.jupiter.api.Test;
  6 +import org.springframework.jdbc.core.JdbcTemplate;
  7 +import org.springframework.test.util.ReflectionTestUtils;
  8 +
  9 +import static org.junit.jupiter.api.Assertions.assertEquals;
  10 +import static org.junit.jupiter.api.Assertions.assertNotNull;
  11 +import static org.junit.jupiter.api.Assertions.assertNull;
  12 +import static org.mockito.ArgumentMatchers.any;
  13 +import static org.mockito.ArgumentMatchers.anyString;
  14 +import static org.mockito.Mockito.mock;
  15 +import static org.mockito.Mockito.when;
  16 +
  17 +/**
  18 + * 身份解析的**安全不变量**:身份只能来自 ERP 内省,失败路径一律 fail-closed。
  19 + * 这几条断言对应审计里的 Critical #1(空 token → 超管)与「客户端自称身份」根因。
  20 + */
  21 +class AuthzServiceTest {
  22 +
  23 + private static final ObjectMapper MAPPER = new ObjectMapper();
  24 +
  25 + private AuthzService service(ErpClient erp) {
  26 + JdbcTemplate jdbc = mock(JdbcTemplate.class);
  27 + AuthzService s = new AuthzService(jdbc, erp);
  28 + ReflectionTestUtils.setField(s, "devUserNo", "admin");
  29 + ReflectionTestUtils.setField(s, "devBrand", "B1");
  30 + ReflectionTestUtils.setField(s, "devSub", "S1");
  31 + ReflectionTestUtils.setField(s, "devUserType", "sysadmin");
  32 + ReflectionTestUtils.setField(s, "devUserIdOverride", "dev-user");
  33 + return s;
  34 + }
  35 +
  36 + @Test
  37 + void noTokenInProductionIsRejected() {
  38 + ErpClient erp = mock(ErpClient.class);
  39 + when(erp.devLoginEnabled()).thenReturn(false);
  40 +
  41 + assertNull(service(erp).resolveIdentity(null), "生产环境下无 token 必须拒绝,不能回落 dev 身份");
  42 + assertNull(service(erp).resolveIdentity(" "), "空白 token 同样必须拒绝");
  43 + }
  44 +
  45 + @Test
  46 + void invalidTokenIsRejectedEvenWhenDevLoginEnabled() {
  47 + ErpClient erp = mock(ErpClient.class);
  48 + when(erp.devLoginEnabled()).thenReturn(true);
  49 + when(erp.whoami(anyString())).thenReturn(null); // 过期/伪造
  50 +
  51 + assertNull(service(erp).resolveIdentity("expired-token"),
  52 + "无效 token 必须拒绝,绝不因为开了 dev-login 就降级成管理员");
  53 + }
  54 +
  55 + @Test
  56 + void identityComesFromIntrospectionNotFromClient() throws Exception {
  57 + ErpClient erp = mock(ErpClient.class);
  58 + when(erp.whoami(anyString())).thenReturn(MAPPER.readTree(
  59 + "{\"sId\":\"real-user\",\"sBrandsId\":\"real-brand\",\"sSubsidiaryId\":\"real-sub\",\"sType\":\"sysadmin\"}"));
  60 +
  61 + AgentIdentity id = service(erp).resolveIdentity("good-token");
  62 +
  63 + assertNotNull(id);
  64 + assertEquals("real-user", id.userId());
  65 + assertEquals("real-brand", id.brandsId(), "租户必须取自服务端内省结果");
  66 + assertEquals("good-token", id.token());
  67 + }
  68 +
  69 + @Test
  70 + void noTokenWithDevLoginEnabledYieldsDevIdentity() {
  71 + ErpClient erp = mock(ErpClient.class);
  72 + when(erp.devLoginEnabled()).thenReturn(true);
  73 + JdbcTemplate jdbc = mock(JdbcTemplate.class);
  74 + when(jdbc.queryForList(anyString(), (Object[]) any())).thenReturn(java.util.List.of());
  75 +
  76 + AuthzService s = new AuthzService(jdbc, erp);
  77 + ReflectionTestUtils.setField(s, "devUserIdOverride", "dev-user");
  78 + ReflectionTestUtils.setField(s, "devUserType", "sysadmin");
  79 + ReflectionTestUtils.setField(s, "devBrand", "B1");
  80 + ReflectionTestUtils.setField(s, "devSub", "S1");
  81 + ReflectionTestUtils.setField(s, "devUserNo", "admin");
  82 +
  83 + AgentIdentity id = s.resolveIdentity(null);
  84 + assertNotNull(id, "本地开发允许无 token");
  85 + assertEquals("dev-user", id.userId());
  86 + }
  87 +}
... ...
src/test/java/com/xly/service/ConversationScopeTest.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.agent.AgentIdentity;
  5 +import com.xly.config.RedisChatMemoryStore;
  6 +import org.junit.jupiter.api.Test;
  7 +import org.springframework.data.redis.core.HashOperations;
  8 +import org.springframework.data.redis.core.StringRedisTemplate;
  9 +
  10 +import static org.junit.jupiter.api.Assertions.assertEquals;
  11 +import static org.junit.jupiter.api.Assertions.assertFalse;
  12 +import static org.junit.jupiter.api.Assertions.assertTrue;
  13 +import static org.mockito.ArgumentMatchers.anyString;
  14 +import static org.mockito.Mockito.mock;
  15 +import static org.mockito.Mockito.when;
  16 +
  17 +/**
  18 + * 会话归属的**安全不变量**(审计 Critical #5):会话 id 绑定服务端身份,
  19 + * 伪造他人的 conversationId 只会落到自己命名空间,且 owns() 拒绝越权访问。
  20 + */
  21 +class ConversationScopeTest {
  22 +
  23 + private ConversationService service(StringRedisTemplate redis) {
  24 + return new ConversationService(redis, mock(RedisChatMemoryStore.class), new ObjectMapper(),
  25 + mock(LedgerService.class), mock(StateService.class));
  26 + }
  27 +
  28 + private static AgentIdentity user(String id) {
  29 + return new AgentIdentity("tok", id, "brand", null);
  30 + }
  31 +
  32 + @Test
  33 + void foreignConversationIdIsRemappedIntoOwnNamespace() {
  34 + ConversationService s = service(mock(StringRedisTemplate.class));
  35 +
  36 + String remapped = s.scopedId(user("u1"), "u2:c-123");
  37 + assertTrue(remapped.startsWith("u1:"), "别人的会话 id 必须被重挂到自己名下");
  38 + assertFalse(remapped.equals("u2:c-123"), "绝不能直接命中对方的会话键");
  39 + assertEquals("u1:c-123", s.scopedId(user("u1"), "u1:c-123"), "自己的 id 原样保留");
  40 + assertEquals("u1:default", s.scopedId(user("u1"), null));
  41 + }
  42 +
  43 + @Test
  44 + void ownsRejectsOtherUsersConversation() {
  45 + StringRedisTemplate redis = mock(StringRedisTemplate.class);
  46 + @SuppressWarnings("unchecked")
  47 + HashOperations<String, Object, Object> hash = mock(HashOperations.class);
  48 + when(redis.opsForHash()).thenReturn(hash);
  49 + when(hash.hasKey(anyString(), anyString())).thenReturn(true);
  50 +
  51 + ConversationService s = service(redis);
  52 +
  53 + assertTrue(s.owns("u1", "u1:c-1"));
  54 + assertFalse(s.owns("u1", "u2:c-1"), "前缀不属于本人 → 拒绝");
  55 + assertFalse(s.owns("u1", null));
  56 + assertFalse(s.owns(null, "u1:c-1"));
  57 + }
  58 +}
... ...
src/test/java/com/xly/service/FormResolverCoerceTest.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import org.junit.jupiter.api.Test;
  4 +import org.springframework.jdbc.core.JdbcTemplate;
  5 +
  6 +import java.util.List;
  7 +import java.util.Map;
  8 +
  9 +import static org.junit.jupiter.api.Assertions.assertEquals;
  10 +import static org.junit.jupiter.api.Assertions.assertNull;
  11 +import static org.junit.jupiter.api.Assertions.assertThrows;
  12 +import static org.junit.jupiter.api.Assertions.assertTrue;
  13 +import static org.mockito.ArgumentMatchers.any;
  14 +import static org.mockito.ArgumentMatchers.anyString;
  15 +import static org.mockito.Mockito.mock;
  16 +import static org.mockito.Mockito.when;
  17 +
  18 +/**
  19 + * 数值/租户的**数据正确性不变量**(审计高危项):
  20 + * 脏数值宁可报错也不静默写错值;缺租户时不退化成全库查询。
  21 + */
  22 +class FormResolverCoerceTest {
  23 +
  24 + private final FormResolverService resolver = new FormResolverService(mock(JdbcTemplate.class));
  25 +
  26 + @Test
  27 + void numericCoercionRejectsGarbageInsteadOfWritingZero() {
  28 + assertThrows(IllegalArgumentException.class, () -> resolver.coerce("decimal", "一千"),
  29 + "中文数字曾被写成 0——必须报错");
  30 + assertThrows(IllegalArgumentException.class, () -> resolver.coerce("int", "1k"));
  31 + assertThrows(IllegalArgumentException.class, () -> resolver.coerce("decimal", "abc"));
  32 + }
  33 +
  34 + @Test
  35 + void numericCoercionKeepsFullValueWithThousandSeparators() {
  36 + assertEquals("1000", resolver.coerce("decimal", "1,000"), "「1,000」曾被截成 1");
  37 + assertEquals("1000", resolver.coerce("decimal", "1,000"));
  38 + assertEquals("12.5", resolver.coerce("decimal", " 12.5 "));
  39 + assertEquals("-3", resolver.coerce("int", "-3"));
  40 + }
  41 +
  42 + @Test
  43 + void dateCoercionDropsNonDates() {
  44 + assertNull(resolver.coerce("datetime", "12"), "非日期交给 ERP 默认值,不写垃圾");
  45 + assertEquals("2026-07-28", resolver.coerce("date", "2026-07-28"));
  46 + }
  47 +
  48 + @Test
  49 + void fkOptionsWithoutTenantReturnsEmptyInsteadOfWholeTable() {
  50 + JdbcTemplate jdbc = mock(JdbcTemplate.class);
  51 + when(jdbc.queryForObject(anyString(), any(Class.class), (Object[]) any())).thenReturn(1);
  52 + FormResolverService r = new FormResolverService(jdbc);
  53 +
  54 + Map<String, Object> page = r.fkOptionPage("elecustomer", null, null, 1, 20);
  55 +
  56 + assertEquals(0, page.get("total"));
  57 + assertTrue(((List<?>) page.get("rows")).isEmpty(), "拿不到租户就返回空,绝不跨租户全表翻页");
  58 + }
  59 +}
... ...