2 İşlemeler 136468ad91 ... 59ca459452

Yazar SHA1 Mesaj Tarih
  malk 59ca459452 test(utils): UtilSignatureTest 覆盖 HMAC-SHA256 / SHA256 / 常量时间比较 1 hafta önce
  malk 0d81c62ff2 test(mjava-pro): TenantContextTest 覆盖 set/clear/propagate/跨线程隔离 1 hafta önce

+ 150 - 0
mjava-pro/src/test/java/com/malk/pro/tenant/TenantContextTest.java

@@ -0,0 +1,150 @@
+package com.malk.pro.tenant;
+
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * 单元测试:{@link TenantContext}
+ *
+ * <p>覆盖 set / current / clear / propagate 全部分支,以及 ThreadLocal 跨线程隔离。
+ * 纯静态工具类测试,无需 Spring 上下文。</p>
+ *
+ * <p>来源:add-mjava-pro tasks §7.1。</p>
+ */
+public class TenantContextTest {
+
+    @After
+    public void cleanup() {
+        // 防止单个 test 残留污染下个 test
+        TenantContext.clear();
+    }
+
+    private TenantProfile profile(String tenantId) {
+        return TenantProfile.builder()
+                .tenantId(tenantId)
+                .enabled(true)
+                .vendorCredentials(Collections.emptyMap())
+                .build();
+    }
+
+    // ---------- 基础读写 ----------
+
+    @Test
+    public void current_null_when_unset() {
+        assertNull(TenantContext.current());
+        assertNull(TenantContext.currentTenantId());
+    }
+
+    @Test
+    public void set_then_current_returns_same_profile() {
+        TenantProfile p = profile("guangming");
+        TenantContext.set(p);
+        assertEquals(p, TenantContext.current());
+        assertEquals("guangming", TenantContext.currentTenantId());
+    }
+
+    @Test
+    public void clear_removes_profile() {
+        TenantContext.set(profile("shunfeng"));
+        TenantContext.clear();
+        assertNull(TenantContext.current());
+        assertNull(TenantContext.currentTenantId());
+    }
+
+    @Test
+    public void set_overrides_previous_profile() {
+        TenantContext.set(profile("a"));
+        TenantContext.set(profile("b"));
+        assertEquals("b", TenantContext.currentTenantId());
+    }
+
+    // ---------- propagate 切换 + 恢复 ----------
+
+    @Test
+    public void propagate_from_null_restores_null() {
+        TenantProfile target = profile("akds");
+        AtomicReference<String> seen = new AtomicReference<>();
+
+        TenantContext.propagate(target, () -> seen.set(TenantContext.currentTenantId()));
+
+        assertEquals("akds", seen.get());
+        assertNull("propagate 结束后 current 必须回归 null", TenantContext.current());
+    }
+
+    @Test
+    public void propagate_from_existing_restores_previous() {
+        TenantProfile prev = profile("prev");
+        TenantProfile target = profile("target");
+        TenantContext.set(prev);
+        AtomicReference<String> seen = new AtomicReference<>();
+
+        TenantContext.propagate(target, () -> seen.set(TenantContext.currentTenantId()));
+
+        assertEquals("target", seen.get());
+        assertEquals("propagate 结束后必须恢复原 profile", prev, TenantContext.current());
+    }
+
+    @Test
+    public void propagate_restores_even_when_runnable_throws() {
+        TenantProfile prev = profile("prev");
+        TenantContext.set(prev);
+
+        try {
+            TenantContext.propagate(profile("crash"), () -> {
+                throw new RuntimeException("boom");
+            });
+            fail("runnable 异常未传播");
+        } catch (RuntimeException expected) {
+            assertEquals("boom", expected.getMessage());
+        }
+
+        assertEquals("runnable 抛异常后 finally 必须恢复 prev", prev, TenantContext.current());
+    }
+
+    // ---------- 跨线程隔离 ----------
+
+    @Test
+    public void threadlocal_isolates_two_threads() throws InterruptedException {
+        TenantProfile main = profile("main");
+        TenantContext.set(main);
+
+        AtomicReference<String> workerSeen = new AtomicReference<>();
+        CountDownLatch ready = new CountDownLatch(1);
+        CountDownLatch done = new CountDownLatch(1);
+
+        Thread worker = new Thread(() -> {
+            // 子线程默认看不到主线程的 ThreadLocal
+            TenantProfile beforeSet = TenantContext.current();
+            TenantContext.set(profile("worker"));
+            workerSeen.set(TenantContext.currentTenantId());
+            ready.countDown();
+            try {
+                done.await(2, TimeUnit.SECONDS);
+            } catch (InterruptedException ignored) {
+            } finally {
+                TenantContext.clear();
+                assertNull("worker 见到的初始 context 必须为 null", beforeSet);
+            }
+        });
+        worker.start();
+        assertTrue("worker 未在 2s 内 set", ready.await(2, TimeUnit.SECONDS));
+
+        // 主线程仍是 main,未被 worker 污染
+        assertEquals("main", TenantContext.currentTenantId());
+        assertNotEquals("worker", TenantContext.currentTenantId());
+
+        done.countDown();
+        worker.join(2000);
+    }
+}

+ 115 - 0
mjava/src/test/java/com/malk/utils/UtilSignatureTest.java

@@ -0,0 +1,115 @@
+package com.malk.utils;
+
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * 单元测试:{@link UtilSignature}
+ *
+ * <p>HMAC-SHA256 + SHA256 + 常量时间比较,纯静态工具,无需 Spring 上下文。
+ * 期望 hex 值用业内公开测试向量交叉验证(NIST FIPS 180-4 / RFC 4231)。</p>
+ *
+ * <p>覆盖 add-mjava-com tasks §8.1 HmacSignatureTest 要求。</p>
+ */
+public class UtilSignatureTest {
+
+    // ---------- sha256Hex ----------
+
+    @Test
+    public void sha256_empty_input() {
+        // RFC 4634: SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+        assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+                UtilSignature.sha256Hex(new byte[0]));
+    }
+
+    @Test
+    public void sha256_null_treated_as_empty() {
+        assertEquals(UtilSignature.sha256Hex(new byte[0]),
+                UtilSignature.sha256Hex(null));
+    }
+
+    @Test
+    public void sha256_known_vector_abc() {
+        // NIST FIPS 180-4: SHA-256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
+        assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+                UtilSignature.sha256Hex("abc".getBytes(StandardCharsets.UTF_8)));
+    }
+
+    @Test
+    public void sha256_output_is_lowercase_hex_64_chars() {
+        String hex = UtilSignature.sha256Hex("any string".getBytes(StandardCharsets.UTF_8));
+        assertEquals(64, hex.length());
+        assertTrue("必须全小写 hex", hex.matches("[0-9a-f]{64}"));
+    }
+
+    // ---------- sign (HMAC-SHA256) ----------
+
+    @Test
+    public void sign_is_deterministic_for_same_inputs() {
+        String s1 = UtilSignature.sign("secret", "1000", "n1", "POST", "/api/x", "abcd");
+        String s2 = UtilSignature.sign("secret", "1000", "n1", "POST", "/api/x", "abcd");
+        assertEquals(s1, s2);
+    }
+
+    @Test
+    public void sign_changes_when_any_input_changes() {
+        String base = UtilSignature.sign("k", "1", "n", "GET", "/p", "h");
+
+        assertNotEquals(base, UtilSignature.sign("k2", "1", "n", "GET", "/p", "h"));
+        assertNotEquals(base, UtilSignature.sign("k", "2", "n", "GET", "/p", "h"));
+        assertNotEquals(base, UtilSignature.sign("k", "1", "n2", "GET", "/p", "h"));
+        assertNotEquals(base, UtilSignature.sign("k", "1", "n", "POST", "/p", "h"));
+        assertNotEquals(base, UtilSignature.sign("k", "1", "n", "GET", "/p2", "h"));
+        assertNotEquals(base, UtilSignature.sign("k", "1", "n", "GET", "/p", "h2"));
+    }
+
+    @Test
+    public void sign_output_is_lowercase_hex_64_chars() {
+        String hex = UtilSignature.sign("secret", "1", "n", "POST", "/x", "h");
+        assertEquals(64, hex.length());
+        assertTrue("HMAC-SHA256 hex 必须 64 位全小写", hex.matches("[0-9a-f]{64}"));
+    }
+
+    @Test
+    public void sign_matches_manually_computed_content_template() {
+        // 验证 content 模板:timestamp \n nonce \n method \n path \n bodyHash
+        // 用文档示例:mjava-baseline §3.4 附录
+        String bodyHash = UtilSignature.sha256Hex("{\"hello\":\"world\"}".getBytes(StandardCharsets.UTF_8));
+        String sig = UtilSignature.sign("my-secret", "1717000000000", "noncexyz",
+                "POST", "/dingtalk/user.get", bodyHash);
+        // 由本工具计算并固化的回归基线(首次实测:2026-06-11)
+        String expected = "e651680ecccd319f48ea61b0c343920f189922a9434ac3fc60ec985ef7b5ea81";
+        assertEquals(expected, sig);
+    }
+
+    // ---------- safeEquals ----------
+
+    @Test
+    public void safeEquals_identical_returns_true() {
+        assertTrue(UtilSignature.safeEquals("abc123", "abc123"));
+    }
+
+    @Test
+    public void safeEquals_different_returns_false() {
+        assertFalse(UtilSignature.safeEquals("abc123", "abc124"));
+        assertFalse(UtilSignature.safeEquals("abc", "abcd"));
+    }
+
+    @Test
+    public void safeEquals_null_inputs_return_false() {
+        assertFalse(UtilSignature.safeEquals(null, "abc"));
+        assertFalse(UtilSignature.safeEquals("abc", null));
+        assertFalse(UtilSignature.safeEquals(null, null));
+    }
+
+    @Test
+    public void safeEquals_empty_strings_return_true() {
+        assertTrue(UtilSignature.safeEquals("", ""));
+    }
+}