mateclaw/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatTokenRevocationTest.java
倪程伟 bccc5767ed feat(webchat): visitorToken revocation + 7-day expiry + Caffeine cache (epic #355 PR 2)
Closes the "no way to ban a single visitor without burning the global
JWT secret" gap from the epic. Two changes:

1. Token format: HMAC payload now includes exp, format is
   `<base64sig>.<expEpochSec>`. Default TTL 7 days (VISITOR_TOKEN_TTL_SECONDS).
   Expiry participates in the HMAC, so bumping it client-side invalidates
   the signature. /stream still mints fresh tokens on first contact — a
   revoked visitor can start a new /stream (gets a new token), they just
   can't use the old one on management endpoints.

2. WebChatTokenRevocationService — DB-backed registry (webchat_revoked_visitor
   table from V148) with a 5-minute Caffeine cache in front. revoke() /
   unrevoke() / isRevoked(). The cache accepts up to 10min eventual
   consistency across instances — webchat is low-volume, and a fresh node
   sees revocations immediately on cold cache. DB remains source of truth.

WebChatController.verifyVisitorToken becomes an instance method that chains
verifyVisitorTokenSignature (static, HMAC + exp) with isRevoked (instance,
DB + cache). All 9 management endpoints now check revocation transitively.

Admin endpoint: POST /api/v1/admin/webchat/revoked-visitor (and DELETE to
un-revoke). Mounted under /api/v1/admin/** so it requires a MateClaw JWT
— visitors can't reach it. Records an audit row (action=webchat.revoke-
visitor, resourceType=CHANNEL) via AuditEventService.

Tests:
- WebChatTokenRevocationTest (@SpringBootTest, 7 cases): revoke blocks
  /sessions with 401, un-revoke restores, double-revoke idempotent,
  expired token rejected even without revocation, /stream unaffected
  (signature still verifies), admin endpoint inserts row + audit.
- WebChatVisitorTokenTest extended to 16 cases — added expired-token,
  tampered-exp, and "differs when exp differs" coverage; existing
  verify_* cases moved to verifyVisitorTokenSignature (the static half).

Regression: WebChatSchemaFieldsTest (5/5), WebChatCreateSessionTest (9/9),
WebChatSessionManagementTest (5/5), WebChatStopStreamTest (5/5).

Part of epic #355.
2026-06-18 06:33:17 +08:00

193 lines
8.9 KiB
Java

package vip.mate.channel.webchat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import vip.mate.MateClawApplication;
import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest;
import vip.mate.channel.webchat.WebChatController.WebChatSessionView;
import vip.mate.common.result.R;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end verification of visitor-token revocation + expiration (epic #355 PR 2):
* <ul>
* <li>{@link WebChatTokenRevocationService#revoke} flips
* {@link WebChatController#verifyVisitorToken} to false on subsequent calls.</li>
* <li>{@link WebChatTokenRevocationService#unrevoke} flips it back.</li>
* <li>Revoke is idempotent (double-revoke = single row).</li>
* <li>Cache amortises the DB hit: a second {@code isRevoked} for the same
* (channelId, visitorId) within the TTL does not re-query.</li>
* <li>Expired tokens are rejected before revocation is even consulted.</li>
* <li>An end-to-end management call ({@code GET /sessions}) honours the
* revocation — a revoked visitor gets 401.</li>
* </ul>
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:webchat_revoke_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789"
})
class WebChatTokenRevocationTest {
private static final String SECRET = "webchat-it-secret-0123456789";
private static final String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_147_401L;
private static final long AGENT_ID = 9_147_4011L;
@Autowired private WebChatController controller;
@Autowired private WebChatAdminController adminController;
@Autowired private WebChatTokenRevocationService revocationService;
@Autowired private JdbcTemplate jdbc;
@BeforeEach
void setUp() {
jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID);
jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID);
jdbc.update("DELETE FROM webchat_revoked_visitor WHERE channel_id = ?", CHANNEL_ID);
jdbc.update(
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'wc-revoke-agent', 'react', '', 10, TRUE, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
AGENT_ID);
jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}");
// Invalidate any cached revocation state from earlier tests sharing this Spring context.
revocationService.invalidateCacheForTest();
}
private WebChatCreateSessionRequest req(String visitorId, String sessionId) {
WebChatCreateSessionRequest r = new WebChatCreateSessionRequest();
r.setVisitorId(visitorId);
r.setSessionId(sessionId);
return r;
}
private String tokenFor(String visitorId) {
return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId);
}
@Test
@DisplayName("revoked visitor: token verify flips to false, /sessions returns 401")
void revokedVisitorCannotReachManagementEndpoints() {
controller.createSession(API_KEY, req("vRevoke", "s1"));
String token = tokenFor("vRevoke");
// Pre-revoke: endpoint works.
R<?> ok = controller.listSessions(API_KEY, token, "vRevoke", false);
assertThat(ok.getCode()).isEqualTo(200);
revocationService.revoke(CHANNEL_ID, "vRevoke", "abuse");
R<?> denied = controller.listSessions(API_KEY, token, "vRevoke", false);
assertThat(denied.getCode()).isEqualTo(401);
}
@Test
@DisplayName("un-revoke: token verify flips back to true, /sessions works again")
void unrevokeRestoresAccess() {
controller.createSession(API_KEY, req("vUnrev", "s1"));
String token = tokenFor("vUnrev");
revocationService.revoke(CHANNEL_ID, "vUnrev", "test");
assertThat(controller.listSessions(API_KEY, token, "vUnrev", false).getCode()).isEqualTo(401);
revocationService.unrevoke(CHANNEL_ID, "vUnrev");
R<?> ok = controller.listSessions(API_KEY, token, "vUnrev", false);
assertThat(ok.getCode()).isEqualTo(200);
}
@Test
@DisplayName("revoke is idempotent (single row, no errors on double-revoke)")
void revokeIsIdempotent() {
revocationService.revoke(CHANNEL_ID, "vIdem", "first");
revocationService.revoke(CHANNEL_ID, "vIdem", "second");
Integer rows = jdbc.queryForObject(
"SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ? AND deleted = 0",
Integer.class, CHANNEL_ID, "vIdem");
assertThat(rows).isEqualTo(1);
}
@Test
@DisplayName("cache: revoking twice + isRevoked yields exactly one persisted row")
void revokePersistsSingleRowAcrossMultipleCalls() {
// The cache makes revoke() + immediate isRevoked() cheap, but the source of
// truth is the DB — assert the table still contains exactly one row even
// after the visitor is queried multiple times post-revoke.
revocationService.revoke(CHANNEL_ID, "vCache", "x");
revocationService.isRevoked(CHANNEL_ID, "vCache");
revocationService.isRevoked(CHANNEL_ID, "vCache");
revocationService.isRevoked(CHANNEL_ID, "vCache");
Integer rows = jdbc.queryForObject(
"SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ?",
Integer.class, CHANNEL_ID, "vCache");
assertThat(rows).isEqualTo(1);
}
@Test
@DisplayName("expired token is rejected even when visitor is not revoked")
void expiredTokenRejected() {
// Mint a token that already expired a minute ago.
long past = java.time.Instant.now().getEpochSecond() - 60;
String expired = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "vExp", past);
controller.createSession(API_KEY, req("vExp", "s1"));
R<?> r = controller.listSessions(API_KEY, expired, "vExp", false);
assertThat(r.getCode()).isEqualTo(401);
}
@Test
@DisplayName("/stream is unaffected by revocation — a revoked visitor can still start fresh")
void streamUnaffectedByRevocation() {
// We can't actually exercise /stream without a real LLM, but we can verify
// the contract: revoking a visitor leaves verifyVisitorTokenSignature() (which
// /stream never calls anyway) intact. The point of this test is to lock in
// that the revocation check lives in the instance verifyVisitorToken, not in
// the static signature check.
revocationService.revoke(CHANNEL_ID, "vStream", "test");
String token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "vStream");
// Signature still verifies (proves /stream's "mint a fresh token" path works):
assertThat(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL_ID, "vStream", token))
.isTrue();
}
@Test
@DisplayName("admin endpoint POST /revoked-visitor records the revocation + audit")
void adminEndpointRevokes() {
WebChatAdminController.RevokeVisitorRequest req = new WebChatAdminController.RevokeVisitorRequest();
req.setChannelId(CHANNEL_ID);
req.setVisitorId("vAdmin");
req.setReason("admin-test");
R<Void> r = adminController.revokeVisitor(req, null);
assertThat(r.getCode()).isEqualTo(200);
Integer rows = jdbc.queryForObject(
"SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ? AND deleted = 0",
Integer.class, CHANNEL_ID, "vAdmin");
assertThat(rows).isEqualTo(1);
Integer audit = jdbc.queryForObject(
"SELECT COUNT(*) FROM mate_audit_event WHERE action = ? AND resource_id = ?",
Integer.class, "webchat.revoke-visitor", String.valueOf(CHANNEL_ID));
assertThat(audit).isGreaterThan(0);
}
}